diff --git a/.github/workflows/release-theclaudehub.yml b/.github/workflows/release-theclaudehub.yml index 80fb663..8ecee02 100644 --- a/.github/workflows/release-theclaudehub.yml +++ b/.github/workflows/release-theclaudehub.yml @@ -101,12 +101,14 @@ jobs: # --onedir, not --onefile: Velopack swaps an app folder in place, and a onefile exe would # unpack itself on every start. velopack is imported lazily, so it is named explicitly; the - # speech scripts are data files. + # speech scripts are data files. --collect-binaries wx brings wx's WebView2Loader.dll, which + # PyInstaller otherwise leaves out, and without it full messages can't open as a formatted + # page (#190; the smoke test checks for it). - name: Build run: | python tools/make_version_info.py python -m PyInstaller --noconfirm --clean --noconsole --onedir --name TheClaudeHub ` - --version-file build/version_info.txt --hidden-import velopack ` + --version-file build/version_info.txt --hidden-import velopack --collect-binaries wx ` --add-data "theclaudehub/speech;theclaudehub/speech" TheClaudeHub.pyw if ($LASTEXITCODE -ne 0) { exit 1 } diff --git a/TheClaudeHub/README.md b/TheClaudeHub/README.md index b0a156c..6ba2611 100644 --- a/TheClaudeHub/README.md +++ b/TheClaudeHub/README.md @@ -29,8 +29,14 @@ session list is always there. - **Messages:** newest last, with focus on the newest. Each reads "You:" or "Claude:" and its first line, and the list's name says the session's state and whether it's read-only. **Enter** (or the context menu's Read Full Message, with the Applications key or Shift+F10) shows the - whole message in a read-only text box, to read by line, word and character; Escape closes it, - back on the same message. Question cards read as "Claude asked: Which version?" with the + whole message as a formatted page, so your screen reader's browse mode moves by heading (H), + table (T, then its cell commands), list (L) and code block (each is a region, R, named like + "Code block, Python, 14 lines"). Links open in your browser. **Read as Plain Text** (Alt+P) + switches to a read-only text box, to read by line, word and character, and Settings can make + that the default. Escape closes either, back on the same message. The page needs Microsoft's + Edge WebView2 runtime, which comes with Windows 11; without it, the text box opens. Nothing in + the page is fetched from the internet, and HTML in a message is shown as text, never run. + Question cards read as "Claude asked: Which version?" with the options in the full text, then "You answered: ...". Refused tools read "Permission denied: ...". Tool calls and tool results are hidden unless you turn on Show Tool Activity (Ctrl+T). New messages arrive at the end without moving you. @@ -41,7 +47,12 @@ session list is always there. session list, on the same session. Enter there on the session that's already loaded takes you back to its messages where you left them, without reloading. - **Open in Claude** (Ctrl+O) switches the desktop app to the session, for approving a permission - prompt or answering a question card there. + prompt or answering a question card in a desktop app session. +- **Continue Here** (Ctrl+Shift+N, or the button beside Open in Claude) carries a desktop app + session on in TheClaudeHub, as a copy: a new TheClaudeHub session in the same folder, with the + whole conversation so far in its messages, that you reply to here. You type its first message + in the same dialog as New Session; the title starts as " (continued)". The desktop app + session isn't changed, and what you do in the copy doesn't appear in it. - **New Session** (Ctrl+N) starts a session of TheClaudeHub's own: choose a folder, a title, the model (Alt+D), a permission mode (auto by default; accept edits, manual and plan are offered) and the first message. The models are Default (your Claude Code setting), Opus, @@ -50,12 +61,30 @@ session list is always there. Opus)"). Fable isn't offered: on some plans it bills to usage credits, and in the headless mode TheClaudeHub uses, Claude Code does that without asking. If that first message never reaches Claude (Claude Code not signed in, say), it goes back into the reply box and Send starts the session again. +- **Claude asks, you answer.** In TheClaudeHub's own sessions, when Claude needs permission + for something the permission mode doesn't allow, asks you a question, or has a plan for you + to approve, the turn waits for you. It's announced ("Build needs you. Claude wants to run + git push. Ctrl+Shift+A answers."), and the session shows as needing you in the list. + **Ctrl+Shift+A** opens the answer: + - **Permission:** the whole request (the command, or the file and what would change) to + read by line, then **Allow**, **Allow for this session** (shown when Claude Code suggests + a rule, and named in full, such as "don't ask again this session for Bash(git push:*)"), + or **Deny**, which is the default button and can carry a reason Claude reads. + - **Questions:** each question is a group of options with their descriptions, with Other + and a box to type your own answer; Send Answers, or Don't Answer. + - **Plan:** the plan to read (Read Formatted shows it as a page, by heading), then **Approve**, choosing the mode to carry on in (accept + edits, auto or manual), or **Keep Planning** with what to change, which is the default. + + Escape in any of them answers later; nothing is approved or refused by waiting. "For this + session" lasts for the session, not just the turn: TheClaudeHub keeps the rule with the + session and gives it to every later turn. It never writes Claude Code's settings files. - **Announcements.** When the open session gets a new reply, or one of TheClaudeHub's sessions finishes a turn, or any listed session stops working, it's announced through your screen reader (or a system voice) and put on the status bar. Settings (Ctrl+Comma) chooses full (the whole reply), summary (the session's name and the first sentence) or silent (status bar only), whether - every listed session is announced or just the open one, and the speech route. Ctrl+Shift+R - repeats the last announcement. + every listed session is announced or just the open one, and the speech route; its Reading + messages group chooses whether full messages open as a formatted page or plain text. + Ctrl+Shift+R repeats the last announcement. - **Answers to what you do are spoken too**, briefly and without cutting off your screen reader: "Tool activity shown.", "Message copied.", and so on (unless announcements are set to silent). - **Your own message is read back when it's sent**, so you hear what actually went to Claude, @@ -150,6 +179,7 @@ The same list is in the app under Help, Keyboard Shortcuts (F1). | Session list | Enter | Load that session and move to its messages | | Session list | Ctrl+O | Open the selected session in the Claude desktop app | | Session list | Ctrl+N | New TheClaudeHub session | +| Anywhere | Ctrl+Shift+N | Continue the selected (or loaded) desktop app session here, as a copy | | Session list | F5 | Refresh the list now and put it in order | | Session list | Delete | Forget the selected TheClaudeHub session (asks first; its transcript is kept) | | Messages | Enter, or Applications key then Read Full Message | Read the whole message; Escape comes back to it | @@ -159,6 +189,7 @@ The same list is in the app under Help, Keyboard Shortcuts (F1). | Reply box | Ctrl+Enter | Send (TheClaudeHub sessions only); you stay in the reply box | | Reply box | Ctrl+Period | Stop the running turn | | Reply box | Ctrl+Shift+T | Turn status: how long it has been working, and on what | +| Anywhere | Ctrl+Shift+A | Answer Claude: a permission request, a question or a plan | | Anywhere | F1 | Keyboard shortcuts | | Anywhere | Ctrl+Comma | Settings | | Anywhere | Ctrl+Shift+R | Repeat the last announcement | @@ -174,7 +205,7 @@ Everything it reads is on your own PC, so reading costs nothing. | What | Where | |---|---| -| The desktop app's sessions | `%APPDATA%\Claude\claude-code-sessions\<id>\<org>\local_<id>.json`: title, folder, last activity, archived, and sometimes a summary of the last turn that says whether it needs you. Archived sessions are left out. | +| The desktop app's sessions | `%APPDATA%\Claude\claude-code-sessions\<id>\<org>\local_<id>.json`: title, folder, last activity, archived, and sometimes a summary of the last turn that says whether it needs you. Archived sessions are left out. The Microsoft Store version of the desktop app keeps the same files in `%LOCALAPPDATA%\Packages\Claude_<id>\LocalCache\Roaming\Claude\claude-code-sessions`; both places are read, and a session in both is read from the newer copy. | | Whether a session is working | `%USERPROFILE%\.claude\sessions\<pid>.json`, which says busy or idle while Claude Code runs it. Files whose process has gone are ignored. | | The conversation | `%USERPROFILE%\.claude\projects\<folder>\<session>.jsonl`, where `<folder>` is the session's folder with every character that isn't a letter or digit turned into `-`. This was checked against every transcript on Kelly's PC; if it ever misses, the app searches all the project folders for the session id instead. | | TheClaudeHub's own sessions | `%APPDATA%\TheClaudeHub\sessions.json` (and `speech.json` for settings). If `sessions.json` can't be read, it's renamed to `sessions.json.bad-<date>` rather than overwritten, and the app says so. | @@ -191,11 +222,14 @@ brings the first to the front. ### Its own sessions, and why desktop sessions are read-only -TheClaudeHub drives its own sessions with the `claude` command in print mode: -`claude -p --output-format stream-json --verbose --permission-mode <mode> --permission-prompts none`, -with `--session-id` and `--name` for the first message and `--resume <id>` for each reply. The -message goes in on standard input, byte for byte. That runs under the same login as the desktop -app, which is why it costs nothing extra. +TheClaudeHub drives its own sessions with the `claude` command in print (headless) mode: +`claude -p --input-format stream-json --output-format stream-json --verbose --permission-mode <mode> +--permission-prompts host --permission-prompt-tool stdio`, with `--session-id` and `--name` for the +first message and `--resume <id>` for each reply, plus `--allowedTools` with any rules you chose +"for this session". The message goes in on standard input as a stream-json message, and standard +input stays open for the turn so TheClaudeHub can answer what Claude asks; it's closed when the +turn's result arrives. That runs under the same login as the desktop app, which is why it costs +nothing extra. - It never uses `--bare`, which needs an API key. - It needs the native `claude.exe`. The npm install's `claude.cmd` is refused, because Windows runs @@ -216,7 +250,11 @@ the same transcript at once and tangle it. Only the desktop app writes to its se TheClaudeHub writes to its own. The rule is enforced in code: TheClaudeHub refuses to build a `--resume` command for any session it didn't start, any session the desktop app knows about (archived ones included), or any `local_` id, and there are tests for each. It also won't send -into one of its own sessions while that session is running somewhere else. One turn runs at a time +into one of its own sessions while that session is running somewhere else. Continue Here is the +one exception that reads a desktop session through `claude`, and it doesn't write to it: +`--resume <desktop id> --fork-session --session-id <new id>` copies the history into a new +session. Checked with Claude Code 2.1.286: the desktop session's transcript was byte for byte the +same afterwards, and Claude knew the earlier conversation. One turn runs at a time per session. Send during a turn queues the message: TheClaudeHub says "Queued", and once the turn's reply has been announced, it sends the message. More messages sent while one is waiting join it, and they go together as one message. Turn status (Ctrl+Shift+T) says when a message is @@ -241,19 +279,23 @@ slip out first. ### What headless sessions do with questions and permissions -Checked with Claude Code 2.1.289: - -- **Question cards don't happen.** In print mode Claude Code doesn't offer the AskUserQuestion - tool at all, so Claude asks its question in ordinary words and you answer in the reply box. - Question cards in desktop app sessions are shown as text in the chat ("Claude asked: ..., - Options: ...", then "You answered: ..."). -- **Nothing can approve a permission prompt**, so `--permission-prompts none` refuses anything - that would ask, straight away; the turn carries on rather than waiting. (Checked in manual mode: - the refused Write came back at once and Claude finished the turn explaining it.) Claude is - told and usually says what it couldn't do. TheClaudeHub adds the refused tools to the - announcement ("1 tool was refused: Write was refused: C:\...\probe.txt"), marks the session - "needs you", and the chat shows a "Permission denied" line. In auto mode, Claude's safety check - decides most of what would otherwise ask, so refusals are rarer there. +Checked with Claude Code 2.1.286 (issues #187 and #188): + +- **Permission prompts, questions and plans come to TheClaudeHub.** With + `--permission-prompts host --permission-prompt-tool stdio`, anything that would ask arrives on + the turn's output as a `can_use_tool` request, the same control protocol the Agent SDK uses, + and the turn waits for the answer on its input. AskUserQuestion and ExitPlanMode come the same + way: a question is answered by allowing the tool with the answers added to its input, a plan + is approved by allowing it with a switch of permission mode, and Keep Planning denies it with + your note. Each was checked against the real CLI: an allowed Write wrote its file; a denied + one didn't, and Claude quoted the reason it was given; an approved plan carried on in accept + edits without asking again. +- **Refusals still happen without asking** where Claude Code's own rules say so (a write outside + the session's folder, say, or auto mode's safety check). TheClaudeHub adds those to the + announcement ("1 tool was refused: ..."), marks the session "needs you", and the chat shows a + "Permission denied" line. +- Question cards in desktop app sessions are shown as text in the chat ("Claude asked: ..., + Options: ...", then "You answered: ...") and answered in Claude. ## Limitations @@ -281,6 +323,7 @@ Checked with Claude Code 2.1.289: | `theclaudehub/claude_cli.py` | `claude` commands, the `--resume` guard, the environment, stream-json events, running a turn | | `theclaudehub/hub.py` | Gathering the list, noticing finished turns | | `theclaudehub/announce.py` | What gets announced | +| `theclaudehub/rendering.py` | A message's markdown as a safe HTML page, for the formatted view | | `theclaudehub/speech.py`, `theclaudehub/speech/` | Speech, adapted from Image Description Toolkit (ClaudeSpeak's engine scripts) | | `theclaudehub/platform_paths.py` | Every path and OS call | | `theclaudehub/ui/` | The wxPython window and dialogs | diff --git a/TheClaudeHub/requirements.txt b/TheClaudeHub/requirements.txt index 4833196..0b4d4a0 100644 --- a/TheClaudeHub/requirements.txt +++ b/TheClaudeHub/requirements.txt @@ -1,4 +1,6 @@ wxPython>=4.2 +# Full messages as formatted pages (rendering.py, issue #190). +markdown==3.11 # Automatic updates (installed copies only; a source run ignores it). velopack==1.2.161 # Certificate checks through Windows, for the update check (see updater._ssl_context). diff --git a/TheClaudeHub/tests/fake_claude.py b/TheClaudeHub/tests/fake_claude.py index 79e04d6..07c7bf4 100644 --- a/TheClaudeHub/tests/fake_claude.py +++ b/TheClaudeHub/tests/fake_claude.py @@ -1,9 +1,13 @@ -"""A stand-in for `claude -p --output-format stream-json`, for tests. +"""A stand-in for `claude -p --input-format stream-json --output-format stream-json`, +for tests. -Reads the prompt from stdin as raw bytes, records what it got, and writes -stream-json to stdout as raw UTF-8 (with a raw U+2028 inside one string, as -JSON writers leave it). FAKE_CLAUDE_MODE=hang starts a grandchild process and -waits, so a test can check that cancelling kills the whole tree. +Reads stream-json lines from stdin as raw bytes until the user's message, +records what it got, and writes stream-json to stdout as raw UTF-8 (with a raw +U+2028 inside one string, as JSON writers leave it). FAKE_CLAUDE_MODE=hang +starts a grandchild process and waits, so a test can check that cancelling +kills the whole tree. FAKE_CLAUDE_MODE=ask sends a permission request and +waits for the answer, which goes in the result. After the result it waits for +stdin to close, as the real CLI does. """ import json import os @@ -11,14 +15,25 @@ import sys import time -prompt = sys.stdin.buffer.read().decode("utf-8") -log = os.environ.get("FAKE_CLAUDE_LOG") -if log: - with open(log, "w", encoding="utf-8") as handle: - json.dump({"prompt": prompt, "args": sys.argv[1:], - "claude_env": sorted(k for k in os.environ - if k.upper() in ("CLAUDECODE", "ANTHROPIC_API_KEY"))}, - handle) +received = [] +prompt = None +for raw in iter(sys.stdin.buffer.readline, b""): + message = json.loads(raw.decode("utf-8")) + received.append(message) + if message.get("type") == "user": + prompt = message["message"]["content"] + break + + +def log(**extra): + path = os.environ.get("FAKE_CLAUDE_LOG") + if path: + with open(path, "w", encoding="utf-8") as handle: + json.dump({"prompt": prompt, "args": sys.argv[1:], + "received": [m.get("type") for m in received], + "claude_env": sorted(k for k in os.environ + if k.upper() in ("CLAUDECODE", "ANTHROPIC_API_KEY")), + **extra}, handle) def out(**data): @@ -26,12 +41,23 @@ def out(**data): sys.stdout.buffer.flush() +log() out(type="system", subtype="init", session_id="abc-1", apiKeySource="none") -if os.environ.get("FAKE_CLAUDE_MODE") == "hang": +mode = os.environ.get("FAKE_CLAUDE_MODE") +if mode == "hang": child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)"]) with open(os.path.join(os.getcwd(), "grandchild.pid"), "w") as handle: handle.write(str(child.pid)) time.sleep(120) +result = "done" +if mode == "ask": + out(type="control_request", request_id="req-1", + request={"subtype": "can_use_tool", "tool_name": "Bash", + "input": {"command": "git push"}, "tool_use_id": "t1"}) + answer = json.loads(sys.stdin.buffer.readline().decode("utf-8")) + log(answer=answer) + result = answer["response"]["response"]["behavior"] out(type="assistant", parent_tool_use_id=None, message={"content": [{"type": "text", "text": "reply
with a line separator"}]}) -out(type="result", subtype="success", is_error=False, result="done", session_id="abc-1") +out(type="result", subtype="success", is_error=False, result=result, session_id="abc-1") +sys.stdin.buffer.read() # until TheClaudeHub closes stdin diff --git a/TheClaudeHub/tests/test_claude_cli.py b/TheClaudeHub/tests/test_claude_cli.py index 8d4ccaa..7671e67 100644 --- a/TheClaudeHub/tests/test_claude_cli.py +++ b/TheClaudeHub/tests/test_claude_cli.py @@ -15,7 +15,8 @@ EXE = "C:\\bin\\claude.exe" OWN = {"aaaa-1111"} DESKTOP = {"dddd-2222"} -FLAGS = ["-p", "--output-format", "stream-json", "--verbose"] +FLAGS = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"] +PROMPTS = ["--permission-prompts", "host", "--permission-prompt-tool", "stdio"] # -- command construction -------------------------------------------------------- @@ -23,8 +24,7 @@ def test_new_command(): command = build_new_command(EXE, "aaaa-1111", " My title ", "auto") - assert command == [EXE, *FLAGS, "--permission-mode", "auto", - "--permission-prompts", "none", + assert command == [EXE, *FLAGS, "--permission-mode", "auto", *PROMPTS, "--session-id", "aaaa-1111", "--name", "My title"] @@ -40,12 +40,13 @@ def test_old_default_mode_name_becomes_manual(): assert command[command.index("--permission-mode") + 1] == "manual" -def test_never_bare_prompts_refused_and_prompt_not_on_command_line(): +def test_never_bare_prompts_come_to_us_and_prompt_not_on_command_line(): for command in (build_new_command(EXE, "aaaa-1111", "t", "auto"), build_resume_command(EXE, "aaaa-1111", "auto", OWN, DESKTOP)): assert "--bare" not in command assert "-p" in command - assert command[command.index("--permission-prompts") + 1] == "none" + assert command[command.index("--permission-prompts") + 1] == "host" + assert command[command.index("--permission-prompt-tool") + 1] == "stdio" def test_unknown_permission_mode_refused(): @@ -62,7 +63,7 @@ def test_new_command_rejects_bad_id(): def test_resume_command_for_own_session(): assert build_resume_command(EXE, "aaaa-1111", "plan", OWN, DESKTOP) == [ - EXE, *FLAGS, "--permission-mode", "plan", "--permission-prompts", "none", + EXE, *FLAGS, "--permission-mode", "plan", *PROMPTS, "--resume", "aaaa-1111"] @@ -316,7 +317,11 @@ def test_runner_sends_prompt_on_stdin_and_reports_events(tmp_path): ev(type="result", subtype="success", result="pong", session_id="s1"), ]) runner, events, captured = run_turn(process, tmp_path) - assert process.written == b"Hello -p --bare" + sent = [json.loads(line) for line in process.written.decode("utf-8").splitlines()] + assert sent[0]["type"] == "control_request" + assert sent[0]["request"]["subtype"] == "initialize" + assert sent[1]["type"] == "user" + assert sent[1]["message"] == {"role": "user", "content": "Hello -p --bare"} assert captured["cwd"] == str(tmp_path) assert captured["env"] == {"PATH": "x"} assert "encoding" not in captured and "text" not in captured # byte pipes @@ -497,6 +502,7 @@ def test_real_child_process_pipes_bytes_exactly(tmp_path): assert seen["claude_env"] == [] # CLAUDECODE was stripped kinds = [e.kind for e in events] assert kinds == ["started", "text", "finished"] + assert seen["received"] == ["control_request", "user"] # The reply carries a raw U+2028 inside the JSON line; it must survive. assert events[1].text == "reply\u2028with a line separator" assert events[-1].text == "done" @@ -511,3 +517,173 @@ def test_real_child_process_cancel_kills_the_tree(tmp_path): while platform_paths.pid_alive(grandchild) and time.time() < deadline: time.sleep(0.1) assert not platform_paths.pid_alive(grandchild) + + +def test_real_child_process_permission_request_answered(tmp_path): + log = tmp_path / "fake.log" + env = child_environment({**child_environment(), "FAKE_CLAUDE_LOG": str(log), + "FAKE_CLAUDE_MODE": "ask"}) + events = [] + done = threading.Event() + holder = {} + + def on_event(event): + events.append(event) + if event.kind == "permission": + # The UI answers from its own thread. + threading.Thread(target=lambda: holder["runner"].respond( + event.request.request_id, cli.deny_response("not today"))).start() + if event.kind in ("finished", "failed"): + done.set() + runner = TurnRunner([sys.executable, str(FAKE_CLAUDE)], str(tmp_path), "push it", + on_event, env=env) + holder["runner"] = runner + runner.start() + assert done.wait(30) + runner.join(10) + kinds = [e.kind for e in events] + assert kinds == ["started", "permission", "text", "finished"] + request = events[1].request + assert request.tool_name == "Bash" and request.summary() == "Claude wants to run git push" + assert events[-1].text == "deny" + answer = json.loads(log.read_text(encoding="utf-8"))["answer"] + assert answer == {"type": "control_response", "response": { + "subtype": "success", "request_id": "req-1", + "response": {"behavior": "deny", "message": "not today"}}} + assert runner.pending == {} + assert not runner.respond("req-1", cli.deny_response()) # already answered + + +# -- permission requests ----------------------------------------------------------------- + + +def control(request_id="r1", **request): + return ev(type="control_request", request_id=request_id, + request={"subtype": "can_use_tool", **request}) + + +def test_parser_turns_can_use_tool_into_a_permission_event(): + parser = StreamParser() + events = parser.feed(control( + tool_name="PowerShell", input={"command": "npm init -y", "description": "Init"}, + description="Initialize a new npm project", + permission_suggestions=[{"type": "addRules", "behavior": "allow", + "destination": "localSettings", + "rules": [{"toolName": "PowerShell", + "ruleContent": "npm init -y"}]}], + tool_use_id="t1")) + assert [e.kind for e in events] == ["permission"] + request = events[0].request + assert request.request_id == "r1" + assert request.summary() == "Claude wants to run npm init -y" + assert "PowerShell command:\nnpm init -y" in request.detail() + assert "Why: Initialize a new npm project" in request.detail() + assert request.session_rules() == ["PowerShell(npm init -y)"] + assert request.allow_for_session_label() == ( + "Allow, and don't ask again this session for PowerShell(npm init -y)") + + +def test_parser_refuses_other_control_requests_and_reads_commands(): + parser = StreamParser() + assert parser.feed(ev(type="control_request", request_id="h1", + request={"subtype": "hook_callback"})) == [] + assert parser.unsupported_requests == ["h1"] + parser.feed(ev(type="control_response", response={ + "subtype": "success", "request_id": cli.INIT_REQUEST_ID, + "response": {"commands": [{"name": "compact", "description": "Compact"}, "junk"]}})) + assert parser.commands == [{"name": "compact", "description": "Compact"}] + + +def test_allow_responses(): + request = cli.PermissionRequest("r1", "Write", {"file_path": "C:\\x\\a.txt", "content": "hi"}, + suggestions=[{"type": "setMode", "mode": "acceptEdits", + "destination": "session"}]) + assert request.summary() == "Claude wants to write a.txt" + assert cli.allow_response(request) == {"behavior": "allow", "updatedInput": request.input} + assert request.allow_for_session_label().startswith("Allow, and accept all file edits") + assert cli.allow_response(request, for_session=True)["updatedPermissions"] == [ + {"type": "setMode", "mode": "acceptEdits", "destination": "session"}] + bash = cli.PermissionRequest("r2", "Bash", {"command": "git push"}, suggestions=[ + {"type": "addRules", "behavior": "allow", "destination": "localSettings", + "rules": [{"toolName": "Bash", "ruleContent": "git push:*"}]}]) + # Always "session": TheClaudeHub never writes Claude Code's settings files. + assert cli.allow_response(bash, for_session=True)["updatedPermissions"] == [ + {"type": "addRules", "rules": [{"toolName": "Bash", "ruleContent": "git push:*"}], + "behavior": "allow", "destination": "session"}] + plain = cli.PermissionRequest("r3", "WebFetch", {"url": "https://example.com"}) + assert plain.allow_for_session_label() == "" + assert "updatedPermissions" not in cli.allow_response(plain, for_session=True) + + +def test_plan_approval_switches_mode_and_deny_carries_the_reason(): + plan = cli.PermissionRequest("r4", "ExitPlanMode", {"plan": "# Plan\n1. Do it"}) + assert plan.is_plan and plan.plan() == "# Plan\n1. Do it" + assert plan.summary() == "Claude's plan is ready for you to approve" + assert cli.allow_response(plan, mode="acceptEdits")["updatedPermissions"] == [ + {"type": "setMode", "mode": "acceptEdits", "destination": "session"}] + assert cli.deny_response("Keep it smaller") == {"behavior": "deny", + "message": "Keep it smaller"} + assert cli.deny_response(" ")["message"] # never empty + + +def test_questions_answered_through_updated_input(): + questions = [{"question": "Which color?", "header": "Color", "multiSelect": False, + "options": [{"label": "Red", "description": "Warm"}, + {"label": "Blue", "description": "Cool"}]}, + {"question": "Which size?", "options": [{"label": "S"}]}] + request = cli.PermissionRequest("r5", "AskUserQuestion", {"questions": questions}) + assert request.is_question + assert request.summary() == "Claude asks: Which color? (and 1 more)" + response = cli.answer_questions_response(request, {"Which color?": "Blue", + "Which size?": "S"}) + assert response == {"behavior": "allow", "updatedInput": { + "questions": questions, "answers": {"Which color?": "Blue", "Which size?": "S"}}} + + +def test_allowed_tools_go_on_every_turn_and_unsafe_rules_are_dropped(): + rules = ["Bash(git push:*)", "Edit", "--dangerously-skip-permissions", "Bash(a\nb)"] + for command in (build_new_command(EXE, "aaaa-1111", "t", "auto", allowed_tools=rules), + build_resume_command(EXE, "aaaa-1111", "auto", OWN, DESKTOP, + allowed_tools=rules)): + at = command.index("--allowedTools") + assert command[at + 1: at + 3] == ["Bash(git push:*)", "Edit"] + assert command[at + 3].startswith("--") # the list ends at the next flag + assert "--dangerously-skip-permissions" not in command + assert "--allowedTools" not in build_new_command(EXE, "aaaa-1111", "t", "auto") + + +@pytest.mark.parametrize("name,tool_input,words", [ + ("Bash", {"command": "git status"}, "run git status"), + ("Edit", {"file_path": "C:\\r\\main.py"}, "edit main.py"), + ("WebFetch", {"url": "https://x.org"}, "fetch https://x.org"), + ("WebSearch", {"query": "wx accessible"}, "search the web for wx accessible"), + ("Task", {}, "use Task"), + ("mcp__x__y", {"pattern": "*.py"}, "use mcp__x__y: *.py"), +]) +def test_describe_tool_use(name, tool_input, words): + assert cli.describe_tool_use(name, tool_input) == words + + +# -- continuing another session as a copy (#189) ------------------------------------------- + + +def test_fork_command_reads_the_source_and_writes_a_new_session(): + command = cli.build_fork_command(EXE, "dddd-2222", "nnnn-3333", " Carry on ", "auto", + "sonnet", taken_ids=OWN | DESKTOP) + assert command[command.index("--resume") + 1] == "dddd-2222" + assert "--fork-session" in command + assert command[command.index("--session-id") + 1] == "nnnn-3333" + assert command[command.index("--name") + 1] == "Carry on" + assert command[command.index("--model") + 1] == "sonnet" + + +@pytest.mark.parametrize("source,new", [ + ("local_abc", "nnnn-3333"), # a desktop app id, not a Claude Code one + ("-p", "nnnn-3333"), + ("dddd-2222", "dddd-2222"), # the copy needs its own id + ("dddd-2222", "aaaa-1111"), # already taken + ("dddd-2222", "--resume"), +]) +def test_fork_command_refuses_bad_ids(source, new): + with pytest.raises(ValueError): + cli.build_fork_command(EXE, source, new, "t", "auto", taken_ids=OWN) diff --git a/TheClaudeHub/tests/test_paths_and_store.py b/TheClaudeHub/tests/test_paths_and_store.py index f9ca70b..ed07084 100644 --- a/TheClaudeHub/tests/test_paths_and_store.py +++ b/TheClaudeHub/tests/test_paths_and_store.py @@ -4,7 +4,7 @@ from theclaudehub import platform_paths from theclaudehub.own_store import OwnSession, OwnSessionStore -from theclaudehub.sessions import NEEDS_YOU +from theclaudehub.sessions import NEEDS_YOU, load_desktop_sessions @pytest.mark.parametrize("cwd,expected", [ @@ -53,6 +53,59 @@ def test_app_dirs_under_appdata(monkeypatch, tmp_path): assert platform_paths.desktop_sessions_dir() == tmp_path / "Claude" / "claude-code-sessions" +def _store_layout(tmp_path, monkeypatch, classic=False): + """A Microsoft Store install of the desktop app (#183), and optionally + the classic folder too. Returns (classic dir, store dir).""" + monkeypatch.setattr(platform_paths.sys, "platform", "win32") + monkeypatch.setenv("APPDATA", str(tmp_path / "Roaming")) + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "Local")) + store = (tmp_path / "Local" / "Packages" / "Claude_pzs8sxrjxfjjc" / "LocalCache" + / "Roaming" / "Claude" / "claude-code-sessions") + store.mkdir(parents=True) + (tmp_path / "Local" / "Packages" / "OtherApp_123").mkdir() + classic_dir = tmp_path / "Roaming" / "Claude" / "claude-code-sessions" + if classic: + classic_dir.mkdir(parents=True) + return classic_dir, store + + +def _write_desktop(folder, local, title): + path = folder / local / "org" / f"{local}.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"sessionId": local, "cliSessionId": "cli-" + local, + "cwd": "C:\\G\\Repo", "title": title, + "lastActivityAt": 1}), encoding="utf-8") + return path + + +def test_store_app_sessions_found(monkeypatch, tmp_path): + _classic, store = _store_layout(tmp_path, monkeypatch) + assert platform_paths.desktop_sessions_dirs() == [store] + _write_desktop(store, "local_s", "From the Store app") + titles = [s.title for s in load_desktop_sessions().sessions] + assert titles == ["From the Store app"] + + +def test_both_installs_read_once_each_newest_copy_wins(monkeypatch, tmp_path): + import os + classic, store = _store_layout(tmp_path, monkeypatch, classic=True) + assert platform_paths.desktop_sessions_dirs() == [classic, store] + old = _write_desktop(classic, "local_both", "Old title") + _write_desktop(store, "local_both", "New title") + os.utime(old, (1, 1)) + _write_desktop(classic, "local_only_classic", "Classic only") + titles = sorted(s.title for s in load_desktop_sessions().sessions) + assert titles == ["Classic only", "New title"] + + +def test_no_desktop_app_means_no_folders(monkeypatch, tmp_path): + monkeypatch.setattr(platform_paths.sys, "platform", "win32") + monkeypatch.setenv("APPDATA", str(tmp_path / "Roaming")) + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "Local")) + assert platform_paths.desktop_sessions_dirs() == [] + assert load_desktop_sessions().sessions == [] + + # -- own store ------------------------------------------------------------------ diff --git a/TheClaudeHub/tests/test_rendering.py b/TheClaudeHub/tests/test_rendering.py new file mode 100644 index 0000000..1c47835 --- /dev/null +++ b/TheClaudeHub/tests/test_rendering.py @@ -0,0 +1,55 @@ +"""Messages as formatted pages (#190): structure kept, nothing unsafe let in.""" +import pytest + +from theclaudehub.rendering import describe_code_block, markdown_to_html, message_page +from theclaudehub.speech import SpeechSettings + + +def test_headings_lists_and_tables_keep_their_structure(): + out = markdown_to_html("## Result\n\n- one\n- two\n\n| Name | Value |\n|---|---|\n| a | 1 |") + assert "<h2>Result</h2>" in out + assert "<ul>\n<li>one</li>\n<li>two</li>\n</ul>" in out + assert "<th>Name</th>" in out and "<td>1</td>" in out + + +def test_code_blocks_are_named_regions(): + out = markdown_to_html("```python\ndef f():\n return 1\n```\n\n```\nplain\n```") + assert '<section role="region" aria-label="Code block, Python, 2 lines">' in out + assert '<section role="region" aria-label="Code block, 1 line">' in out + assert "def f():" in out + + +@pytest.mark.parametrize("language,code,words", [ + ("py", "a\nb\nc\n", "Code block, Python, 3 lines"), + ("", "x", "Code block, 1 line"), + ("zig", "x\ny", "Code block, zig, 2 lines"), + ("ps1", "", "Code block, PowerShell, 0 lines"), +]) +def test_describe_code_block(language, code, words): + assert describe_code_block(language, code) == words + + +def test_raw_html_and_images_are_text_and_bad_links_are_dropped(): + out = markdown_to_html("<script>alert(1)</script>\n\nhi <b>x</b> ![p](http://x/p.png) " + "[ok](https://example.com) [bad](javascript:alert(1)) " + "[file](file:///C:/x)") + assert "<script" not in out and "<script>" in out + assert "<b>" not in out and "<b>" in out + assert "<img" not in out + assert '<a href="https://example.com">ok</a>' in out + assert "javascript:" not in out and "file:" not in out + assert "<a>bad</a>" in out + + +def test_page_allows_nothing_remote(): + page = message_page('Claude "says"', "Hello") + assert "default-src 'none'" in page + assert "<title>Claude "says"" in page + assert '

Hello

' in page + + +def test_formatted_setting_round_trips(tmp_path): + path = tmp_path / "speech.json" + assert SpeechSettings.load(path).formatted_messages # on by default + SpeechSettings(formatted_messages=False).save(path) + assert not SpeechSettings.load(path).formatted_messages diff --git a/TheClaudeHub/tests/test_ui_hidden.py b/TheClaudeHub/tests/test_ui_hidden.py index fe123e2..d7d8adf 100644 --- a/TheClaudeHub/tests/test_ui_hidden.py +++ b/TheClaudeHub/tests/test_ui_hidden.py @@ -36,7 +36,7 @@ def env(tmp_path, monkeypatch, app): projects = tmp_path / "projects" for folder in (desktop, live, projects): folder.mkdir() - monkeypatch.setattr(platform_paths, "desktop_sessions_dir", lambda: desktop) + monkeypatch.setattr(platform_paths, "desktop_sessions_dirs", lambda: [desktop]) monkeypatch.setattr(platform_paths, "live_sessions_dir", lambda: live) monkeypatch.setattr(platform_paths, "projects_dir", lambda: projects) monkeypatch.setattr(speech, "DEFAULT_SETTINGS_PATH", tmp_path / "speech.json") @@ -46,8 +46,12 @@ def env(tmp_path, monkeypatch, app): def speak(text, settings, interrupt=True): (spoken if interrupt else feedback).append(text) monkeypatch.setattr(speech.speaker, "speak", speak) - from theclaudehub.ui import main_frame + from theclaudehub.ui import dialogs, main_frame monkeypatch.setattr(main_frame, "list_speech_options", lambda: speech.default_options()) + # No real web page: it would open modal and wait. Tests that want the + # formatted view put a fake in. + monkeypatch.setattr(main_frame, "formatted_view_available", lambda: False) + monkeypatch.setattr(dialogs, "formatted_view_available", lambda: False) opened = [] monkeypatch.setattr(platform_paths, "open_url", lambda url: opened.append(url)) boxes = [] @@ -137,6 +141,10 @@ def elapsed(self): def cancel(self): self.cancelled = True + def respond(self, request_id, response): + self.responses = getattr(self, "responses", []) + [(request_id, response)] + return not getattr(self, "ended", False) + @pytest.fixture def fake_runner(monkeypatch): @@ -262,8 +270,8 @@ def test_tab_order_desktop_session_puts_the_note_where_the_reply_box_is(frame): frame.on_open_session() order = tab_order(frame) assert order == [frame.session_list, frame.chat_list, frame.desktop_note, - frame.reply_claude_btn, frame.activity_check, frame.new_btn, - frame.refresh_btn] + frame.reply_claude_btn, frame.continue_btn, frame.activity_check, + frame.new_btn, frame.refresh_btn] def test_nothing_loaded_at_start(frame): @@ -1288,3 +1296,304 @@ def test_new_session_dialog_offers_the_models(frame): order.index(dialog.mode) finally: dialog.Destroy() + + +# -- answering Claude (#187, #188) ------------------------------------------------------ + + +def _request(request_id="r1", tool="Bash", tool_input=None, suggestions=None): + from theclaudehub.claude_cli import PermissionRequest + return PermissionRequest(request_id, tool, tool_input or {"command": "git push"}, + suggestions=suggestions or []) + + +def _waiting_turn(frame, *requests): + select(frame, "Hub probe") + frame.on_open_session() + runner = FakeRunner([], "", "", None) + frame._runners["own-1"] = runner + for request in requests: + frame._on_turn_event({"id": "own-1"}, "Hub probe", + TurnEvent("permission", text=request.summary(), request=request)) + return runner + + +def test_permission_request_is_announced_and_the_session_needs_you(frame, env): + _waiting_turn(frame, _request()) + assert env["spoken"][-1] == ("Hub probe needs you. Claude wants to run git push. " + "Ctrl+Shift+A answers.") + assert frame.turn_status.GetLabel() == ("Waiting for you: Claude wants to run git push. " + "Ctrl+Shift+A answers.") + settle(frame) + row = [s for s in frame.session_list.GetStrings() if s.startswith("Hub probe")][0] + assert "needs you: Claude wants to run git push" in row + frame.on_turn_status() + assert env["feedback"][-1].startswith("Hub probe is waiting for you: Claude wants to run") + + +def test_answering_sends_the_response_and_announces_the_next(frame, env, monkeypatch): + from theclaudehub.claude_cli import allow_response + first, second = _request("r1"), _request("r2", tool_input={"command": "git tag v1"}) + runner = _waiting_turn(frame, first, second) + monkeypatch.setattr(frame, "_ask", lambda title, request, own: ( + allow_response(request), "Allowed.", {})) + frame.on_answer() + assert runner.responses == [("r1", {"behavior": "allow", "updatedInput": first.input})] + assert env["feedback"][-1] == ("Allowed. Next: Claude wants to run git tag v1. " + "Ctrl+Shift+A answers.") + frame.on_answer() + assert [r[0] for r in runner.responses] == ["r1", "r2"] + assert frame._pending["own-1"] == [] + frame.on_answer() + assert env["feedback"][-1] == "Claude isn't waiting for an answer." + + +def test_answer_later_leaves_it_waiting(frame, env, monkeypatch): + runner = _waiting_turn(frame, _request()) + monkeypatch.setattr(frame, "_ask", lambda *a: None) + frame.on_answer() + assert not hasattr(runner, "responses") + assert len(frame._pending["own-1"]) == 1 + assert "still waiting" in env["feedback"][-1] + + +def test_allow_for_session_keeps_the_rule_for_later_turns(frame, env, fake_runner, monkeypatch): + from theclaudehub.ui import dialogs + request = _request(suggestions=[{"type": "addRules", "behavior": "allow", + "destination": "localSettings", + "rules": [{"toolName": "Bash", + "ruleContent": "git push:*"}]}]) + runner = _waiting_turn(frame, request) + + class Picks(dialogs.PermissionDialog): + def ShowModal(self): + self.choice = dialogs.ALLOW_SESSION + return wx.ID_OK + monkeypatch.setattr("theclaudehub.ui.main_frame.PermissionDialog", Picks) + frame.on_answer() + response = runner.responses[0][1] + assert response["updatedPermissions"][0]["destination"] == "session" + assert frame.store.get("own-1").allowed_tools == ["Bash(git push:*)"] + # The turn ends; the next one is given the rule. + frame._on_turn_event({"id": "own-1"}, "Hub probe", TurnEvent("finished", text="Pushed.")) + frame.reply_text.SetValue("and tag it") + frame.on_send() + command = fake_runner.instances[-1].command + assert command[command.index("--allowedTools") + 1] == "Bash(git push:*)" + + +def test_plan_approval_keeps_the_new_mode(frame, env, monkeypatch): + from theclaudehub.ui import dialogs + frame.store.update("own-1", permission_mode="plan") + runner = _waiting_turn(frame, _request("p1", "ExitPlanMode", {"plan": "# Plan\n1. Go"})) + assert "plan is ready" in env["spoken"][-1] + + class Approves(dialogs.PlanDialog): + def ShowModal(self): + assert self.plan_text.GetValue() == "# Plan\n1. Go" + assert self.mode.GetStringSelection().startswith("Accept edits") + self.approved = True + return wx.ID_OK + monkeypatch.setattr("theclaudehub.ui.main_frame.PlanDialog", Approves) + frame.on_answer() + assert runner.responses[0][1]["updatedPermissions"] == [ + {"type": "setMode", "mode": "acceptEdits", "destination": "session"}] + assert frame.store.get("own-1").permission_mode == "acceptEdits" + assert env["feedback"][-1] == "Plan approved. Claude is carrying on in accept edits mode." + + +def test_question_answers_go_back_to_claude(frame, env, monkeypatch): + from theclaudehub.ui import dialogs + questions = [{"question": "Which color?", "header": "Color", + "options": [{"label": "Red", "description": "Warm"}, {"label": "Blue"}]}, + {"question": "Which toppings?", "multiSelect": True, + "options": [{"label": "Cheese"}, {"label": "Olives"}]}] + request = _request("q1", "AskUserQuestion", {"questions": questions}) + runner = _waiting_turn(frame, request) + + class Answers(dialogs.QuestionDialog): + def ShowModal(self): + color, toppings = self._controls + assert [c.GetLabel() for c in color[1]] == ["Red: Warm", "Blue", + "Other (type below)"] + color[1][1].SetValue(True) + toppings[1][0].SetValue(True) + toppings[2].SetValue("anchovies") # typing ticks Other + assert toppings[1][2].GetValue() + return wx.ID_OK + monkeypatch.setattr("theclaudehub.ui.main_frame.QuestionDialog", Answers) + frame.on_answer() + sent = runner.responses[0][1] + assert sent["updatedInput"]["answers"] == {"Which color?": "Blue", + "Which toppings?": "Cheese, anchovies"} + assert env["feedback"][-1] == "Answer sent." + + +def test_turn_end_clears_waiting_and_a_late_answer_says_so(frame, env, monkeypatch): + from theclaudehub.claude_cli import allow_response + runner = _waiting_turn(frame, _request()) + request = frame._pending["own-1"][0] + frame._on_turn_event({"id": "own-1"}, "Hub probe", TurnEvent("failed", text="Stopped.", + is_error=True)) + assert "own-1" not in frame._pending + runner.ended = True + frame._runners["own-1"] = runner + frame._apply_answer("own-1", request, allow_response(request), "Allowed.", {}) + assert env["feedback"][-1] == "That isn't waiting any more: the turn has ended." + + +def test_permission_dialog_buttons(frame): + from theclaudehub.ui.dialogs import PermissionDialog + plain = PermissionDialog(frame, "Hub probe", _request(tool="WebFetch", + tool_input={"url": "https://x"})) + try: + assert plain.session_button is None + assert plain.request_text.GetValue().startswith("Claude wants to fetch https://x.") + default = plain.GetDefaultItem() + assert default.GetLabel() == "&Deny" + finally: + plain.Destroy() + with_rule = PermissionDialog(frame, "Hub probe", _request(suggestions=[ + {"type": "addRules", "behavior": "allow", + "rules": [{"toolName": "Bash", "ruleContent": "git push:*"}]}])) + try: + assert with_rule.session_button.GetName() == ( + "Allow, and don't ask again this session for Bash(git push:*)") + finally: + with_rule.Destroy() + + +# -- Continue Here (#189) ------------------------------------------------------------------ + + +def _continue(frame, env, monkeypatch, message="Carry on from here"): + from theclaudehub.ui import dialogs + seen = {} + + class Fills(dialogs.NewSessionDialog): + def ShowModal(self): + seen["title"] = self.GetTitle() + seen["folder_editable"] = self.folder.IsEditable() + seen["name"] = self.title_text.GetValue() + self.message.SetValue(message) + return wx.ID_OK + monkeypatch.setattr("theclaudehub.ui.main_frame.NewSessionDialog", Fills) + frame.on_continue_here() + return seen + + +def test_continue_here_forks_a_desktop_session(frame, env, fake_runner, monkeypatch): + folder = env["tmp"] / "repo" + folder.mkdir() + add_desktop(env, "local_c", "cli-c", "Desktop work", cwd=str(folder)) + add_transcript(env, str(folder), "cli-c", [user_text("Earlier question")]) + frame.refresh_sessions(force=True, resort=True) + settle(frame) + select(frame, "Desktop work") + frame.on_open_session() + assert frame.continue_btn.IsShown() + seen = _continue(frame, env, monkeypatch) + assert seen == {"title": "Continue Here: Desktop work", "folder_editable": False, + "name": "Desktop work (continued)"} + runner = fake_runner.instances[-1] + command = runner.command + assert command[command.index("--resume") + 1] == "cli-c" + assert "--fork-session" in command + new_id = command[command.index("--session-id") + 1] + assert new_id != "cli-c" + assert runner.cwd == str(folder) and runner.prompt == "Carry on from here" + own = frame.store.get(new_id) + assert own.fork_source == "cli-c" and own.forked_from == "Desktop work" + assert frame._open.cli_session_id == new_id + assert frame.session_heading.GetLabel().endswith(", continued from Desktop work.") + # If that first turn never got going, Send copies the session again. + frame._on_turn_event({"id": new_id}, "Desktop work (continued)", + TurnEvent("failed", text="not signed in", is_error=True)) + frame.reply_text.SetValue("again") + frame.on_send() + again = fake_runner.instances[-1].command + assert again[again.index("--resume") + 1] == "cli-c" and "--fork-session" in again + assert again[again.index("--session-id") + 1] == new_id + + +def test_continue_here_needs_a_transcript_and_a_desktop_session(frame, env, fake_runner, + monkeypatch): + select(frame, "Quiet one") # no transcript on disk + frame.on_continue_here() + assert "no longer on disk" in env["boxes"][-1] + select(frame, "Hub probe") + frame.on_continue_here() + assert env["feedback"][-1] == ("Hub probe is already a TheClaudeHub session; " + "reply to it here.") + assert fake_runner.instances == [] + + +# -- full messages as a formatted page (#190) ---------------------------------------------- + + +def _load_reply(frame, env, text): + add_transcript(env, "C:\\G\\Repo", "cli-a", [ + user_text("Show me"), assistant_block(text_block(text), "m1")]) + select(frame, "Quiet one") + frame.on_open_session() + assert pump(lambda: frame._chat_loaded and frame.chat_list.GetCount() == 2) + + +def _fake_viewer(monkeypatch, result): + from theclaudehub.ui import main_frame + shown = [] + + class FakeViewer: + def __init__(self, parent, title, page): + shown.append((title, page)) + + def ShowModal(self): + return result + + def Destroy(self): + pass + monkeypatch.setattr(main_frame, "formatted_view_available", lambda: True) + monkeypatch.setattr(main_frame, "FormattedMessageDialog", FakeViewer) + return shown + + +def test_full_message_opens_as_a_formatted_page(frame, env, monkeypatch): + _load_reply(frame, env, "## Result\n\n| a | b |\n|---|---|\n| 1 | 2 |") + shown = _fake_viewer(monkeypatch, wx.ID_CANCEL) + plain = [] + monkeypatch.setattr("theclaudehub.ui.main_frame.MessageDialog", + lambda *a: plain.append(a) or pytest.fail("plain text opened")) + frame.on_read_message() + title, page = shown[0] + assert title == "Message from Claude" + assert "

Result

" in page and "a" in page and "2" in page + + +def test_read_as_plain_text_and_the_setting_open_the_text_box(frame, env, monkeypatch): + from theclaudehub.ui.dialogs import ID_PLAIN_TEXT, MessageDialog + _load_reply(frame, env, "## Result") + shown = _fake_viewer(monkeypatch, ID_PLAIN_TEXT) + opened = [] + + class Plain(MessageDialog): + def ShowModal(self): + opened.append(self.text.GetValue()) + return wx.ID_CANCEL + monkeypatch.setattr("theclaudehub.ui.main_frame.MessageDialog", Plain) + frame.on_read_message() + assert len(shown) == 1 and opened == ["## Result"] + frame.speech.formatted_messages = False + frame.on_read_message() + assert len(shown) == 1 and len(opened) == 2 # straight to the text box + + +def test_settings_dialog_has_the_formatted_page_choice(frame): + from theclaudehub.ui.dialogs import SettingsDialog + dialog = SettingsDialog(frame, speech.SpeechSettings(formatted_messages=False), + speech.default_options()) + try: + assert not dialog.formatted.GetValue() + dialog.formatted.SetValue(True) + assert dialog.get_settings().formatted_messages + finally: + dialog.Destroy() diff --git a/TheClaudeHub/theclaudehub/app.py b/TheClaudeHub/theclaudehub/app.py index ca3a716..47b7c52 100644 --- a/TheClaudeHub/theclaudehub/app.py +++ b/TheClaudeHub/theclaudehub/app.py @@ -73,6 +73,22 @@ def smoke_test(out_path: str) -> int: from .ui import main_frame # noqa: F401 - imported to prove it's bundled except Exception as exc: # noqa: BLE001 problems.append(f"wx/ui: {exc}") + try: + # Formatted full messages (#190): markdown and its extensions, and + # wx's WebView2 loader, which a build can leave behind. + from .rendering import message_page + report["markdown"] = "

" in message_page("t", "## x") + import wx.html2 + + loader = Path(wx.html2.__file__).parent / "WebView2Loader.dll" + report["webview2_loader"] = loader.is_file() + if not loader.is_file(): + problems.append(f"missing {loader}") + # Informational: the runtime itself is the PC's, not the build's. + report["webview2_available"] = bool( + wx.html2.WebView.IsBackendAvailable(wx.html2.WebViewBackendEdge)) + except Exception as exc: # noqa: BLE001 + problems.append(f"formatted messages: {exc}") try: import velopack # noqa: F401 diff --git a/TheClaudeHub/theclaudehub/claude_cli.py b/TheClaudeHub/theclaudehub/claude_cli.py index 67d1e97..db47500 100644 --- a/TheClaudeHub/theclaudehub/claude_cli.py +++ b/TheClaudeHub/theclaudehub/claude_cli.py @@ -19,9 +19,16 @@ endpoint, Bedrock/Vertex/Foundry). Everything else is kept, including settings the user chose such as ``CLAUDE_CODE_GIT_BASH_PATH``, ``CLAUDE_CONFIG_DIR``, proxies and timeouts. -* **Permission prompts are refused, never waited on.** ``--permission-prompts - none`` tells the CLI nobody is there to answer, so anything that would ask - is denied at once and the turn carries on. +* **Permission prompts come to TheClaudeHub** (issues #187, #188), over the + same pipes, with the control protocol the Agent SDK uses: + ``--input-format stream-json --permission-prompts host + --permission-prompt-tool stdio``. Anything that would ask arrives as a + ``control_request`` (``can_use_tool``) and the turn waits until it is + answered with a ``control_response``: allow (optionally with the input + changed, which is how Claude's questions are answered), or deny with a + reason Claude reads. Nothing is ever answered by timing out; Stop ends the + turn. Claude's questions (AskUserQuestion) and plans (ExitPlanMode) come + the same way. Checked against Claude Code 2.1.286. * **The run is stopped if the CLI says it is using an API key.** The first stream-json event (``system/init``) reports ``apiKeySource``; on a subscription login it is ``"none"``. Anything else ends the turn before a @@ -35,10 +42,12 @@ (``ResumeRefused``). That check lives in ``build_resume_command`` so no caller can skip it. -Prompts go in on stdin, as UTF-8 bytes with the newlines exactly as typed, not -on the command line: a message that begins with ``-`` cannot be mistaken for a -flag, and there is no command-line length limit. Output is read as bytes and -split only on newline bytes. +Prompts go in on stdin, as a stream-json user message (UTF-8, the newlines +exactly as typed, escaped by JSON), not on the command line: a message that +begins with ``-`` cannot be mistaken for a flag, and there is no command-line +length limit. Stdin stays open for the turn, for answers to permission +requests, and is closed when the result arrives so the CLI exits. Output is +read as bytes and split only on newline bytes. """ from __future__ import annotations @@ -153,12 +162,24 @@ def model_label(model: str) -> str: return MODEL_LABELS.get(model, model) if model else "the default model" -def _common_flags(permission_mode: str, model: str = "") -> List[str]: +#: A permission rule as Claude Code writes them: ``Tool`` or +#: ``Tool(content)``, one line. Rules come from Claude Code's own suggestions, +#: and must never read as a flag. +_SAFE_RULE = re.compile(r"[A-Za-z][A-Za-z0-9_.:-]{0,99}(\([^\r\n]{0,500}\))?") + + +def is_safe_rule(rule: str) -> bool: + return isinstance(rule, str) and bool(_SAFE_RULE.fullmatch(rule)) + + +def _common_flags(permission_mode: str, model: str = "", + allowed_tools: Collection[str] = ()) -> List[str]: permission_mode = normalize_permission_mode(permission_mode) if permission_mode not in PERMISSION_MODE_VALUES: raise ValueError(f"Unknown permission mode: {permission_mode!r}") - flags = ["-p", "--output-format", "stream-json", "--verbose", - "--permission-mode", permission_mode, "--permission-prompts", "none"] + flags = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", + "--verbose", "--permission-mode", permission_mode, + "--permission-prompts", "host", "--permission-prompt-tool", "stdio"] if model: if not is_safe_model(model): raise ValueError(f"Not a valid model name: {model!r}") @@ -166,6 +187,14 @@ def _common_flags(permission_mode: str, model: str = "") -> List[str]: # session keeps its model without this, but saying it each time keeps # the session on Kelly's choice whatever Claude Code does later. flags += ["--model", model] + rules = [rule for rule in allowed_tools if is_safe_rule(rule)] + if rules: + # "Allow for this session" (#187). Claude Code keeps a session-scoped + # rule only for the life of the process, and every turn here is a new + # process, so the rules are kept with the session and given again + # each turn. The option takes several values; the flag after it ends + # the list. + flags += ["--allowedTools", *rules] return flags @@ -174,11 +203,12 @@ def new_session_id() -> str: def build_new_command(executable: str, session_id: str, title: str, - permission_mode: str, model: str = "") -> List[str]: + permission_mode: str, model: str = "", + allowed_tools: Collection[str] = ()) -> List[str]: """Command for the first turn of a new session. The prompt goes on stdin.""" if not platform_paths.is_safe_id(session_id): raise ValueError("Not a valid session id.") - command = [executable, *_common_flags(permission_mode, model), + command = [executable, *_common_flags(permission_mode, model, allowed_tools), "--session-id", session_id] title = " ".join((title or "").split()) if title: @@ -203,12 +233,39 @@ def check_resume_allowed(session_id: str, own_ids: Collection[str], raise ResumeRefused("TheClaudeHub only sends messages to sessions it started.") +def build_fork_command(executable: str, source_id: str, new_id: str, title: str, + permission_mode: str, model: str = "", + taken_ids: Collection[str] = ()) -> List[str]: + """Command for the first turn of a copy of another session (#189), such + as a desktop app session, to carry on in TheClaudeHub. + + ``--resume --fork-session --session-id ``: Claude Code reads + the source's history and writes only to the new session. Checked with + Claude Code 2.1.286: the source transcript was byte for byte the same + afterwards, the new id was the one asked for, and Claude knew the earlier + conversation. So this is allowed for desktop sessions, where a plain + ``--resume`` is not; the new id must be one nobody has. + """ + if not platform_paths.is_safe_id(source_id) or source_id.startswith("local_"): + raise ResumeRefused("That is not a session id Claude Code can continue from.") + if not platform_paths.is_safe_id(new_id) or new_id == source_id or new_id in taken_ids: + raise ValueError("The new session needs an id of its own.") + command = [executable, *_common_flags(permission_mode, model), + "--resume", source_id, "--fork-session", "--session-id", new_id] + title = " ".join((title or "").split()) + if title: + command += ["--name", title] + return command + + def build_resume_command(executable: str, session_id: str, permission_mode: str, own_ids: Collection[str], - desktop_ids: Collection[str], model: str = "") -> List[str]: + desktop_ids: Collection[str], model: str = "", + allowed_tools: Collection[str] = ()) -> List[str]: """Command for a later turn. Refuses anything but TheClaudeHub's own sessions.""" check_resume_allowed(session_id, own_ids, desktop_ids) - return [executable, *_common_flags(permission_mode, model), "--resume", session_id] + return [executable, *_common_flags(permission_mode, model, allowed_tools), + "--resume", session_id] # --------------------------------------------------------------------------- @@ -216,16 +273,49 @@ def build_resume_command(executable: str, session_id: str, permission_mode: str, # --------------------------------------------------------------------------- -@dataclass -class TurnEvent: - """One thing that happened during a turn, already made readable.""" +INIT_REQUEST_ID = "theclaudehub-init" - kind: str # started | text | tool | denied | finished | failed | notice - text: str = "" - session_id: str = "" - is_error: bool = False - denials: List[str] = field(default_factory=list) - raw_type: str = "" + +def stdin_lines(prompt: str) -> bytes: + """What starts a turn on stdin: the ``initialize`` control request (its + answer lists the slash commands and skills) and the user's message.""" + initialize = {"type": "control_request", "request_id": INIT_REQUEST_ID, + "request": {"subtype": "initialize", "hooks": None}} + message = {"type": "user", "session_id": "", "parent_tool_use_id": None, + "message": {"role": "user", "content": prompt}} + return (json.dumps(initialize) + "\n" + json.dumps(message, ensure_ascii=False) + + "\n").encode("utf-8") + + +QUESTION_TOOL = "AskUserQuestion" +PLAN_TOOL = "ExitPlanMode" +SHELL_TOOLS = ("Bash", "PowerShell") + + +def _tool_target(tool_input: dict, limit: int = 160) -> str: + """The one value that says what a tool call is about: its command, file, + URL or pattern, on one line.""" + for key in ("command", "file_path", "notebook_path", "path", "url", "pattern", "query"): + value = tool_input.get(key) + if isinstance(value, str) and value.strip(): + target = " ".join(value.split()) + return target if len(target) <= limit else target[: limit - 1] + "…" + return "" + + +def describe_tool_use(name: str, tool_input: dict) -> str: + """A tool call as words: "run npm init -y", "edit README.md".""" + tool_input = tool_input if isinstance(tool_input, dict) else {} + target = _tool_target(tool_input) + if name in SHELL_TOOLS and target: + return f"run {target}" + verbs = {"Write": "write", "Edit": "edit", "MultiEdit": "edit", "NotebookEdit": "edit", + "Read": "read", "WebFetch": "fetch", "WebSearch": "search the web for"} + if name in verbs and target: + if name in ("Write", "Edit", "MultiEdit", "NotebookEdit", "Read"): + target = os.path.basename(target.rstrip("\\/")) or target + return f"{verbs[name]} {target}" + return f"use {name}: {target}" if target else f"use {name}" def describe_denial(denial: dict) -> str: @@ -234,17 +324,172 @@ def describe_denial(denial: dict) -> str: return "A tool was refused." name = str(denial.get("tool_name") or "A tool") tool_input = denial.get("tool_input") if isinstance(denial.get("tool_input"), dict) else {} - target = "" - for key in ("command", "file_path", "path", "url", "pattern"): - value = tool_input.get(key) - if isinstance(value, str) and value.strip(): - target = " ".join(value.split()) - if len(target) > 160: - target = target[:159] + "…" - break + target = _tool_target(tool_input) return f"{name} was refused: {target}" if target else f"{name} was refused" +@dataclass +class PermissionRequest: + """Claude Code asking TheClaudeHub before a tool runs (a ``can_use_tool`` + control request). Claude's questions and plans come this way too.""" + + request_id: str + tool_name: str + input: dict = field(default_factory=dict) + description: str = "" + suggestions: list = field(default_factory=list) + tool_use_id: str = "" + + @property + def is_question(self) -> bool: + return self.tool_name == QUESTION_TOOL + + @property + def is_plan(self) -> bool: + return self.tool_name == PLAN_TOOL + + def questions(self) -> List[dict]: + items = self.input.get("questions") + return [q for q in items if isinstance(q, dict) and isinstance(q.get("question"), str)] \ + if isinstance(items, list) else [] + + def plan(self) -> str: + plan = self.input.get("plan") + return plan if isinstance(plan, str) else "" + + def summary(self) -> str: + """One line, for announcements: what Claude is waiting for.""" + if self.is_question: + questions = self.questions() + first = questions[0]["question"] if questions else "a question" + more = f" (and {len(questions) - 1} more)" if len(questions) > 1 else "" + return f"Claude asks: {first}{more}" + if self.is_plan: + return "Claude's plan is ready for you to approve" + return f"Claude wants to {describe_tool_use(self.tool_name, self.input)}" + + def detail(self) -> str: + """Everything about the request, to read by line.""" + lines = [self.summary() + "."] + if self.description and self.description not in lines[0]: + lines.append(f"Why: {self.description}") + if self.tool_name in SHELL_TOOLS: + lines += ["", f"{self.tool_name} command:", str(self.input.get("command", ""))] + return "\n".join(lines) + if self.tool_name == "Write": + lines += ["", f"File: {self.input.get('file_path', '')}", "", "Content:", + str(self.input.get("content", ""))] + return "\n".join(lines) + if self.tool_name == "Edit": + lines += ["", f"File: {self.input.get('file_path', '')}", "", "Replace:", + str(self.input.get("old_string", "")), "", "With:", + str(self.input.get("new_string", ""))] + return "\n".join(lines) + lines.append("") + for key, value in self.input.items(): + if isinstance(value, str): + lines.append(f"{key}: {value}") + else: + lines.append(f"{key}: {json.dumps(value, ensure_ascii=False, indent=2)}") + return "\n".join(lines) + + def session_rules(self) -> List[str]: + """The rules Claude Code suggests for not asking again, as + ``Tool(content)`` strings.""" + rules = [] + for suggestion in self.suggestions: + if not isinstance(suggestion, dict) or suggestion.get("type") != "addRules": + continue + if suggestion.get("behavior", "allow") != "allow": + continue + for rule in suggestion.get("rules") or []: + if not isinstance(rule, dict) or not isinstance(rule.get("toolName"), str): + continue + content = rule.get("ruleContent") + text = f"{rule['toolName']}({content})" if isinstance(content, str) and content \ + else rule["toolName"] + if is_safe_rule(text): + rules.append(text) + return rules + + def session_mode(self) -> str: + """A permission mode Claude Code suggests switching to instead + ("acceptEdits" for a file edit), or "".""" + for suggestion in self.suggestions: + if isinstance(suggestion, dict) and suggestion.get("type") == "setMode" \ + and suggestion.get("mode") in PERMISSION_MODE_VALUES: + return suggestion["mode"] + return "" + + def allow_for_session_label(self) -> str: + """What "Allow for this session" means for this request, or "" when + Claude Code has nothing to suggest.""" + mode = self.session_mode() + if mode == "acceptEdits": + return "Allow, and accept all file edits for the rest of this session" + if mode: + return f"Allow, and switch this session to {mode} mode" + rules = self.session_rules() + if rules: + return "Allow, and don't ask again this session for " + ", ".join(rules) + return "" + + +def allow_response(request: PermissionRequest, updated_input: Optional[dict] = None, + for_session: bool = False, mode: str = "") -> dict: + """The answer that lets the tool run. ``for_session`` applies Claude + Code's suggestion for the rest of this turn (the caller keeps it for + later turns); ``mode`` switches the permission mode (approving a plan).""" + response = {"behavior": "allow", + "updatedInput": updated_input if updated_input is not None else request.input} + updates = [] + if mode: + updates.append({"type": "setMode", "mode": mode, "destination": "session"}) + elif for_session: + session_mode = request.session_mode() + if session_mode: + updates.append({"type": "setMode", "mode": session_mode, "destination": "session"}) + else: + rules = [] + for rule in request.session_rules(): + name, _, content = rule.partition("(") + entry = {"toolName": name} + if content: + entry["ruleContent"] = content[:-1] + rules.append(entry) + if rules: + updates.append({"type": "addRules", "rules": rules, "behavior": "allow", + "destination": "session"}) + if updates: + response["updatedPermissions"] = updates + return response + + +def deny_response(message: str = "") -> dict: + """The answer that refuses; Claude reads ``message`` as the reason.""" + return {"behavior": "deny", + "message": message.strip() or "The user refused this. Don't try it another way " + "without asking."} + + +def answer_questions_response(request: PermissionRequest, answers: Dict[str, str]) -> dict: + """Claude's questions answered: the tool runs with the answers added.""" + return allow_response(request, updated_input={**request.input, "answers": answers}) + + +@dataclass +class TurnEvent: + """One thing that happened during a turn, already made readable.""" + + kind: str # started | text | tool | denied | permission | finished | failed + text: str = "" + session_id: str = "" + is_error: bool = False + denials: List[str] = field(default_factory=list) + raw_type: str = "" + request: Optional[PermissionRequest] = None + + class StreamParser: """Turns ``claude -p --output-format stream-json --verbose`` lines into ``TurnEvent``s. Unknown event types are ignored; bad lines are counted.""" @@ -254,6 +499,12 @@ def __init__(self) -> None: self.api_key_source: Optional[str] = None self.bad_lines = 0 self.finished = False + #: Slash commands and skills, from the answer to ``initialize``: + #: dicts with ``name``, ``description`` and ``argumentHint``. + self.commands: List[dict] = [] + #: Control requests TheClaudeHub can't answer (not ``can_use_tool``); + #: the runner refuses them so the CLI doesn't wait. + self.unsupported_requests: List[str] = [] def feed(self, line: str) -> List[TurnEvent]: line = line.strip() @@ -280,6 +531,34 @@ def _event(self, event: dict) -> List[TurnEvent]: if isinstance(sid, str) and sid: self.session_id = sid + if etype == "control_request": + request_id = str(event.get("request_id") or "") + request = event.get("request") if isinstance(event.get("request"), dict) else {} + if request.get("subtype") == "can_use_tool" and request_id: + tool_input = request.get("input") if isinstance(request.get("input"), dict) else {} + suggestions = request.get("permission_suggestions") + permission = PermissionRequest( + request_id=request_id, + tool_name=str(request.get("tool_name") or "a tool"), + input=tool_input, + description=str(request.get("description") or ""), + suggestions=suggestions if isinstance(suggestions, list) else [], + tool_use_id=str(request.get("tool_use_id") or "")) + return [TurnEvent("permission", text=permission.summary(), + session_id=self.session_id, request=permission)] + if request_id: + self.unsupported_requests.append(request_id) + return [] + if etype == "control_response": + response = event.get("response") if isinstance(event.get("response"), dict) else {} + if response.get("request_id") == INIT_REQUEST_ID: + body = response.get("response") if isinstance(response.get("response"), dict) \ + else {} + commands = body.get("commands") + if isinstance(commands, list): + self.commands = [c for c in commands + if isinstance(c, dict) and isinstance(c.get("name"), str)] + return [] if etype == "system" and subtype == "init": source = event.get("apiKeySource") self.api_key_source = source if isinstance(source, str) else None @@ -387,10 +666,50 @@ def __init__(self, command: List[str], cwd: str, prompt: str, #: the session exists and later turns must --resume it. self.session_started = False self.parser = StreamParser() + #: Permission requests sent to the UI and not yet answered. + self.pending: Dict[str, PermissionRequest] = {} + self._stdin_open = False def elapsed(self) -> float: return self._clock() - self.started_at + def respond(self, request_id: str, response: dict) -> bool: + """Answer a permission request (from the UI thread). False if the turn + is already over or the request isn't waiting.""" + with self._lock: + request = self.pending.pop(request_id, None) + if request is None: + return False + ok = self._write_line({"type": "control_response", "response": { + "subtype": "success", "request_id": request_id, "response": response}}) + if ok: + self.last_activity = ( + "working" if response.get("behavior") == "allow" + else f"carrying on after you refused {request.tool_name}") + return ok + + def _write_line(self, obj: dict) -> bool: + """Write one JSON line to the CLI's stdin. Call with ``_lock`` held.""" + process = self._process + if process is None or not self._stdin_open: + return False + try: + process.stdin.write((json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8")) + process.stdin.flush() + return True + except (OSError, ValueError): + return False + + def _close_stdin(self) -> None: + with self._lock: + if not self._stdin_open or self._process is None: + return + self._stdin_open = False + try: + self._process.stdin.close() + except (OSError, ValueError): + pass + def start(self) -> None: self._thread = threading.Thread(target=self._run, name="claude-turn", daemon=True) self._thread.start() @@ -464,11 +783,13 @@ def drain_stderr() -> None: err_thread = threading.Thread(target=drain_stderr, daemon=True) err_thread.start() - try: - process.stdin.write(self.prompt.encode("utf-8")) - process.stdin.close() - except (OSError, ValueError): - pass # the process died early; its exit code tells us why + with self._lock: + try: + process.stdin.write(stdin_lines(self.prompt)) + process.stdin.flush() + self._stdin_open = True + except (OSError, ValueError): + pass # the process died early; its exit code tells us why for raw in iter(process.stdout.readline, b""): for event in self.parser.feed(_decode(raw)): @@ -485,12 +806,20 @@ def drain_stderr() -> None: self.last_activity = f"using {event.text}" elif event.kind == "text": self.last_activity = "writing a reply" + elif event.kind == "permission": + with self._lock: + self.pending[event.request.request_id] = event.request + self.last_activity = f"waiting for you: {event.request.summary()}" if event.kind == "finished": final = event + # The turn is over: closing stdin lets the CLI exit. + self._close_stdin() else: self._emit(event) + self._refuse_unsupported() if self._stopped_for_key: break + self._close_stdin() process.wait() err_thread.join(timeout=2) if final is None: @@ -514,10 +843,23 @@ def drain_stderr() -> None: final = TurnEvent("failed", text=f"Couldn't run claude: {exc}", is_error=True) finally: self._tree.close() + with self._lock: + self.pending.clear() if not final.session_id: final.session_id = self.parser.session_id self._emit(final) + def _refuse_unsupported(self) -> None: + """Control requests other than permission checks (hooks, MCP + messages): TheClaudeHub registers none, so say so at once rather than + leave the CLI waiting.""" + while self.parser.unsupported_requests: + request_id = self.parser.unsupported_requests.pop(0) + with self._lock: + self._write_line({"type": "control_response", "response": { + "subtype": "error", "request_id": request_id, + "error": "TheClaudeHub doesn't support this request."}}) + class _Cancelled(Exception): pass diff --git a/TheClaudeHub/theclaudehub/hub.py b/TheClaudeHub/theclaudehub/hub.py index 1ae6204..b9b5a57 100644 --- a/TheClaudeHub/theclaudehub/hub.py +++ b/TheClaudeHub/theclaudehub/hub.py @@ -12,7 +12,7 @@ from . import platform_paths from .own_store import OwnSession -from .sessions import (WORKING, DesktopLoadResult, LiveStatus, SessionInfo, +from .sessions import (NEEDS_YOU, WORKING, DesktopLoadResult, LiveStatus, SessionInfo, load_desktop_sessions, load_live_status, sort_sessions) from .transcript import TranscriptParser, split_jsonl @@ -28,13 +28,19 @@ class Snapshot: def collect(own: Iterable[OwnSession], running_own_ids: Set[str], desktop_dir: Optional[Path] = None, live_dir: Optional[Path] = None, - alive=platform_paths.pid_alive) -> Snapshot: + alive=platform_paths.pid_alive, + waiting: Optional[Dict[str, str]] = None) -> Snapshot: + """``waiting`` maps a running own session to what Claude is waiting for + (a permission request, question or plan): it needs you, not working.""" live = load_live_status(live_dir, alive=alive) desktop: DesktopLoadResult = load_desktop_sessions(desktop_dir, live) sessions = list(desktop.sessions) + waiting = waiting or {} for item in own: info = item.to_info() - if item.cli_session_id in running_own_ids: + if item.cli_session_id in running_own_ids and waiting.get(item.cli_session_id): + info.state, info.detail = NEEDS_YOU, waiting[item.cli_session_id] + elif item.cli_session_id in running_own_ids: info.state, info.detail = WORKING, "" elif (live.get(item.cli_session_id) is not None and live[item.cli_session_id].status == "busy"): diff --git a/TheClaudeHub/theclaudehub/own_store.py b/TheClaudeHub/theclaudehub/own_store.py index 2756d38..38e85c6 100644 --- a/TheClaudeHub/theclaudehub/own_store.py +++ b/TheClaudeHub/theclaudehub/own_store.py @@ -11,12 +11,12 @@ import os import tempfile import time -from dataclasses import asdict, dataclass, fields +from dataclasses import asdict, dataclass, field, fields from pathlib import Path from typing import Dict, List, Optional from . import platform_paths -from .claude_cli import MODEL_LABELS +from .claude_cli import MODEL_LABELS, is_safe_rule from .sessions import IDLE, OWN, SessionInfo @@ -39,6 +39,14 @@ class OwnSession: #: ``--model`` for every turn ("" is Claude Code's own default). Older #: stores don't have it and load as the default. model: str = "" + #: Permission rules chosen with "Allow for this session" (#187), given + #: to every later turn as ``--allowedTools``. + allowed_tools: List[str] = field(default_factory=list) + #: Continued from another session (#189): its Claude Code id, used to + #: start the copy again if the first turn never got going, and its title, + #: for the heading. + fork_source: str = "" + forked_from: str = "" def to_info(self) -> SessionInfo: return SessionInfo( @@ -73,6 +81,12 @@ def _session_from_dict(item: dict) -> Optional[OwnSession]: for name in _FIELDS: if name not in item: continue + if name == "allowed_tools": + # Only rules Claude Code itself could have suggested; anything + # else in a hand-edited file is dropped. + rules = item[name] if isinstance(item[name], list) else [] + values[name] = [r for r in rules if is_safe_rule(r)] + continue value = item[name] kind = _TYPES[name] if kind in ("str", str): @@ -88,6 +102,8 @@ def _session_from_dict(item: dict) -> Optional[OwnSession]: # that bills usage credits) would fail or cost extra on every turn. if values.get("model") not in MODEL_LABELS: values["model"] = "" + if values.get("fork_source") and not platform_paths.is_safe_id(values["fork_source"]): + values["fork_source"] = "" return OwnSession(**values) diff --git a/TheClaudeHub/theclaudehub/platform_paths.py b/TheClaudeHub/theclaudehub/platform_paths.py index 53da4ca..1571537 100644 --- a/TheClaudeHub/theclaudehub/platform_paths.py +++ b/TheClaudeHub/theclaudehub/platform_paths.py @@ -8,7 +8,9 @@ app (none of this is documented, so every caller treats it as best effort): * Desktop app session metadata: - ``%APPDATA%\\Claude\\claude-code-sessions\\\\\\local_.json`` + ``%APPDATA%\\Claude\\claude-code-sessions\\\\\\local_.json``, + or for the Microsoft Store version + ``%LOCALAPPDATA%\\Packages\\Claude_\\LocalCache\\Roaming\\Claude\\claude-code-sessions`` * Transcripts: ``%USERPROFILE%\\.claude\\projects\\\\.jsonl`` * Live sessions: ``%USERPROFILE%\\.claude\\sessions\\.json`` * TheClaudeHub's own files: ``%APPDATA%\\TheClaudeHub\\`` @@ -19,7 +21,7 @@ import re import sys from pathlib import Path -from typing import Optional +from typing import List, Optional APP_DIR_NAME = "TheClaudeHub" @@ -52,10 +54,36 @@ def _roaming_dir() -> Path: def desktop_sessions_dir() -> Path: - """Where the Claude desktop app keeps one JSON file per Code session.""" + """Where the Claude desktop app keeps one JSON file per Code session, when + it was installed with its own installer.""" return _roaming_dir() / "Claude" / "claude-code-sessions" +def _store_app_sessions_dirs() -> List[Path]: + """The same folder for the Microsoft Store (MSIX) version of the desktop + app (issue #183). Windows redirects a packaged app's AppData writes into + its package folder, so ``%APPDATA%\\Claude`` doesn't exist there. Matched + on ``Claude_*`` rather than the package id seen on Kelly's PCs + (``Claude_pzs8sxrjxfjjc``), in case it differs.""" + if sys.platform != "win32": + return [] + local = os.environ.get("LOCALAPPDATA") + packages = Path(local) / "Packages" if local else Path.home() / "AppData" / "Local" / "Packages" + try: + return sorted(p / "LocalCache" / "Roaming" / "Claude" / "claude-code-sessions" + for p in packages.glob("Claude_*")) + except OSError: + return [] + + +def desktop_sessions_dirs() -> List[Path]: + """Every folder that holds desktop app session files, that exists. Both + kinds of install can be present (switching from one to the other leaves + the old folder behind); callers read all of them.""" + candidates = [desktop_sessions_dir(), *_store_app_sessions_dirs()] + return [path for path in candidates if path.is_dir()] + + def app_data_dir() -> Path: """TheClaudeHub's own settings and session store.""" return _roaming_dir() / APP_DIR_NAME diff --git a/TheClaudeHub/theclaudehub/rendering.py b/TheClaudeHub/theclaudehub/rendering.py new file mode 100644 index 0000000..3e54ed7 --- /dev/null +++ b/TheClaudeHub/theclaudehub/rendering.py @@ -0,0 +1,108 @@ +"""A message's markdown as a small HTML page, for a screen reader's browse +mode (issue #190). + +Claude writes markdown: headings, lists, tables, code. In a plain text box a +table is a run of pipes and a heading is "##". As a web page the screen +reader can move by heading (H), table and cell (T, Ctrl+Alt+arrows), list +(L) and region (R, where each code block is one, named "Code block, Python, +14 lines"). + +The page is built to be inert: + +* Raw HTML in a message is shown as text, never rendered (Python-Markdown's + HTML block and inline HTML handling is switched off). +* Images aren't loaded: the image syntax is switched off, so it reads as a + link instead. +* A Content-Security-Policy allows nothing but the page's own styles, so + nothing remote is fetched and no script in the page can run. +* Links keep only http, https and mailto targets; the viewer opens them in + the default browser, never in itself. +""" +from __future__ import annotations + +import html +import re + +import markdown +from markdown.extensions.fenced_code import FencedCodeExtension +from markdown.extensions.sane_lists import SaneListExtension +from markdown.extensions.tables import TableExtension + +_CODE = re.compile(r'
(.*?)
', re.DOTALL) +_HREF = re.compile(r' str: + """'py' -> 'Python'; unknown tags as written.""" + tag = (tag or "").strip() + return LANGUAGE_NAMES.get(tag.lower(), tag) + + +def describe_code_block(language: str, code: str) -> str: + """'Code block, Python, 14 lines' (the name a screen reader hears).""" + lines = len(code.rstrip("\n").split("\n")) if code.strip() else 0 + parts = ["Code block"] + if language: + parts.append(language_name(language)) + parts.append(f"{lines} line" + ("" if lines == 1 else "s")) + return ", ".join(parts) + + +def _converter() -> markdown.Markdown: + md = markdown.Markdown(extensions=[FencedCodeExtension(), TableExtension(), + SaneListExtension()], + output_format="html") + md.preprocessors.deregister("html_block") + for name in ("html", "image_link", "image_reference", "short_image_ref"): + md.inlinePatterns.deregister(name) + return md + + +def markdown_to_html(text: str) -> str: + """The body HTML for ``text``: code blocks as named regions, unsafe links + made plain text.""" + body = _converter().convert(text or "") + + def code_region(match: "re.Match[str]") -> str: + language, code = match.group(1) or "", html.unescape(match.group(2)) + label = html.escape(describe_code_block(language, code), quote=True) + return f'
{match.group(0)}
' + + def link(match: "re.Match[str]") -> str: + href = html.unescape(match.group(1)) + return match.group(0) if _SAFE_LINK.match(href) else " str: + """A whole page: ``title`` is the window's and the page's name.""" + name = html.escape(title, quote=True) + return ("" + "" + f"{name}" + f"
{markdown_to_html(text)}
") diff --git a/TheClaudeHub/theclaudehub/sessions.py b/TheClaudeHub/theclaudehub/sessions.py index 483381e..d6fa21d 100644 --- a/TheClaudeHub/theclaudehub/sessions.py +++ b/TheClaudeHub/theclaudehub/sessions.py @@ -180,14 +180,22 @@ class DesktopLoadResult: def load_desktop_sessions(directory: Optional[Path] = None, live: Optional[Dict[str, LiveStatus]] = None) -> DesktopLoadResult: - directory = directory or platform_paths.desktop_sessions_dir() + directories = [directory] if directory else platform_paths.desktop_sessions_dirs() live = live if live is not None else {} result = DesktopLoadResult() - try: - files = list(directory.glob("**/local_*.json")) - except OSError: - return result - for path in files: + # A session in two folders (both kinds of desktop app install, #183) is + # read once, from the copy written last. + newest: Dict[str, Path] = {} + for folder in directories: + try: + found = list(folder.glob("**/local_*.json")) + except OSError: + continue + for path in found: + known = newest.get(path.name) + if known is None or _mtime(path) > _mtime(known): + newest[path.name] = path + for path in newest.values(): try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): @@ -207,6 +215,13 @@ def load_desktop_sessions(directory: Optional[Path] = None, return result +def _mtime(path: Path) -> float: + try: + return path.stat().st_mtime + except OSError: + return 0.0 + + def desktop_session_from_metadata(data: dict, live: Dict[str, LiveStatus]) -> Optional[SessionInfo]: local_id = data.get("sessionId") diff --git a/TheClaudeHub/theclaudehub/speech.py b/TheClaudeHub/theclaudehub/speech.py index 4043fd8..7e92e09 100644 --- a/TheClaudeHub/theclaudehub/speech.py +++ b/TheClaudeHub/theclaudehub/speech.py @@ -122,6 +122,9 @@ class SpeechSettings: engine: str = "auto" voice: str = "" rate_preset: str = "default" + #: Read Full Message opens a formatted page (headings, lists, tables) when + #: Edge WebView2 is there, rather than the plain text box (issue #190). + formatted_messages: bool = True @property def enabled(self) -> bool: @@ -149,6 +152,7 @@ def load(cls, path: Optional[Path] = None) -> "SpeechSettings": settings.voice = str(raw.get("voice", "")) preset = str(raw.get("rate_preset", "default")) settings.rate_preset = preset if preset in RATE_PRESET_LABELS else "default" + settings.formatted_messages = bool(raw.get("formatted_messages", True)) return settings def save(self, path: Optional[Path] = None) -> None: diff --git a/TheClaudeHub/theclaudehub/ui/dialogs.py b/TheClaudeHub/theclaudehub/ui/dialogs.py index 3f8cb7e..bf17e23 100644 --- a/TheClaudeHub/theclaudehub/ui/dialogs.py +++ b/TheClaudeHub/theclaudehub/ui/dialogs.py @@ -31,32 +31,50 @@ def __init__(self, parent): PERMISSION_NOTE = ( - "Nobody can approve a permission prompt while TheClaudeHub runs a turn, so " - "anything that would ask is refused straight away, and the chat and the " - "announcement say what was refused.") + "The permission mode decides what Claude may do without asking. When Claude " + "asks for permission, asks you a question or has a plan for you to approve, " + "the turn waits: TheClaudeHub announces it, the session shows as needing " + "you, and Ctrl+Shift+A answers.") + + +CONTINUE_NOTE = ( + "This starts a TheClaudeHub session that is a copy of the desktop app session, " + "with its whole conversation so far, so you can carry on and reply here. The " + "desktop app session isn't changed, and replies here don't appear in it.") class NewSessionDialog(wx.Dialog): - """Folder, title, model, permission mode, first message.""" + """Folder, title, model, permission mode, first message. + + With ``continue_from`` (a desktop session's title, #189) it continues that + session as a copy: the folder is the session's own and can't be changed, + and the title starts as " (continued)". + """ - def __init__(self, parent, default_folder: str): - super().__init__(parent, title="New TheClaudeHub Session", size=(640, 520), + def __init__(self, parent, default_folder: str, continue_from: str = ""): + title = f"Continue Here: {continue_from}" if continue_from \ + else "New TheClaudeHub Session" + super().__init__(parent, title=title, size=(640, 560), style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) + self.continuing = bool(continue_from) outer = wx.BoxSizer(wx.VERTICAL) grid = wx.FlexGridSizer(cols=2, vgap=8, hgap=8) grid.AddGrowableCol(1, 1) grid.Add(wx.StaticText(self, label="&Folder:"), 0, wx.ALIGN_CENTER_VERTICAL) folder_row = wx.BoxSizer(wx.HORIZONTAL) - self.folder = wx.TextCtrl(self, value=default_folder) + self.folder = wx.TextCtrl(self, value=default_folder, + style=wx.TE_READONLY if continue_from else 0) set_accessible_name(self.folder, "Folder") folder_row.Add(self.folder, 1, wx.EXPAND | wx.RIGHT, 6) browse = wx.Button(self, label="&Browse...") folder_row.Add(browse, 0) + browse.Show(not continue_from) grid.Add(folder_row, 1, wx.EXPAND) grid.Add(wx.StaticText(self, label="&Title:"), 0, wx.ALIGN_CENTER_VERTICAL) - self.title_text = wx.TextCtrl(self) + self.title_text = wx.TextCtrl(self, value=f"{continue_from} (continued)" + if continue_from else "") set_accessible_name(self.title_text, "Title (optional)") grid.Add(self.title_text, 1, wx.EXPAND) @@ -76,10 +94,13 @@ def __init__(self, parent, default_folder: str): # A read-only text box rather than a static label, so it is in the tab # order and a screen reader reaches it. - outer.Add(wx.StaticText(self, label="About &permissions:"), 0, wx.LEFT, 10) + about = "About continuing" if continue_from else "About permissions" + outer.Add(wx.StaticText(self, label=f"{about.replace('About ', 'About &')}:"), + 0, wx.LEFT, 10) note = wx.TextCtrl(self, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_RICH2, - value=PERMISSION_NOTE) - set_accessible_name(note, "About permissions") + value=(CONTINUE_NOTE + "\n\n" + PERMISSION_NOTE) if continue_from + else PERMISSION_NOTE) + set_accessible_name(note, about) note.SetMinSize((-1, 60)) outer.Add(note, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 10) @@ -100,7 +121,8 @@ def __init__(self, parent, default_folder: str): browse.Bind(wx.EVT_BUTTON, self._on_browse) ok.Bind(wx.EVT_BUTTON, self._on_ok) self.Bind(wx.EVT_CHAR_HOOK, self._on_char_hook) - wx.CallAfter(self.folder.SetFocus) + # Continuing, the folder is fixed: start at the first thing to type. + wx.CallAfter((self.message if continue_from else self.folder).SetFocus) def _on_char_hook(self, event): # Ctrl+Enter starts the session from anywhere, as Send does elsewhere. @@ -151,7 +173,7 @@ class SettingsDialog(wx.Dialog): """Announcements and speech (the Speech tab of IDT's settings, adapted).""" def __init__(self, parent, speech: SpeechSettings, options): - super().__init__(parent, title="Settings", size=(660, 460)) + super().__init__(parent, title="Settings", size=(660, 540)) self._options = list(options) outer = wx.BoxSizer(wx.VERTICAL) @@ -174,6 +196,16 @@ def __init__(self, parent, speech: SpeechSettings, options): self.own_messages.SetValue(speech.announce_own) outer.Add(self.own_messages, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) + # What you read, apart from what's spoken on its own. + messages = wx.StaticBoxSizer(wx.VERTICAL, self, "Reading messages") + box = messages.GetStaticBox() + self.formatted = wx.CheckBox( + box, label="Open full messages as a formatted &page (headings, lists and " + "tables), not plain text") + self.formatted.SetValue(speech.formatted_messages) + messages.Add(self.formatted, 0, wx.ALL, 6) + outer.Add(messages, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) + grid = wx.FlexGridSizer(rows=2, cols=2, vgap=8, hgap=8) grid.AddGrowableCol(1, 1) grid.Add(wx.StaticText(self, label="Speech &engine:"), 0, wx.ALIGN_CENTER_VERTICAL) @@ -241,7 +273,8 @@ def get_settings(self) -> SpeechSettings: announce_all_sessions=self.all_sessions.GetValue(), announce_own=self.own_messages.GetValue(), engine=option.engine, voice=option.voice, - rate_preset=preset) + rate_preset=preset, + formatted_messages=self.formatted.GetValue()) class MessageDialog(wx.Dialog): @@ -271,3 +304,379 @@ def __init__(self, parent, speaker_label: str, text: str): self.SetEscapeId(wx.ID_CANCEL) self.text.SetInsertionPoint(0) wx.CallAfter(self.text.SetFocus) + + +# -- a message as a formatted page (#190) ----------------------------------------------- + +#: EndModal code for "Read as Plain Text": the caller opens MessageDialog. +ID_PLAIN_TEXT = wx.NewIdRef() + +#: Escape is pressed inside the page, where wx never sees it, so a script +#: added to the page posts it back. Added with AddUserScript, which runs +#: whatever the page's Content-Security-Policy says. +_KEY_RELAY = """ +document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { e.preventDefault(); window.hub.postMessage('escape'); } + else if (e.altKey && (e.key === 'p' || e.key === 'P')) { + e.preventDefault(); window.hub.postMessage('plain'); } +}, true); +""" + + +def formatted_view_available() -> bool: + """Whether the Edge WebView2 runtime can show the formatted page.""" + try: + import wx.html2 + return bool(wx.html2.WebView.IsBackendAvailable(wx.html2.WebViewBackendEdge)) + except Exception: # noqa: BLE001 - no html2, no runtime: plain text + return False + + +def _prepare_webview_data_folder() -> None: + """WebView2 keeps a profile folder. Its default is beside the program, + which for an installed copy is the folder Velopack replaces on update, and + for a source run may be Python's own folder. Put it in local app data + instead, before the first WebView is made.""" + if os.environ.get("WEBVIEW2_USER_DATA_FOLDER"): + return + local = os.environ.get("LOCALAPPDATA") + if local: + os.environ["WEBVIEW2_USER_DATA_FOLDER"] = os.path.join(local, "TheClaudeHub WebView2") + + +class FormattedMessageDialog(wx.Dialog): + """One message as a web page: the screen reader's browse mode moves by + heading, table, list and code block. Escape closes it; Read as Plain Text + (Alt+P) switches to the text box, to read by character. + + Raises RuntimeError if the WebView can't be made; the caller falls back to + MessageDialog. + """ + + def __init__(self, parent, title: str, page_html: str): + import wx.html2 + from .. import platform_paths + super().__init__(parent, title=title, size=(820, 620), + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) + _prepare_webview_data_folder() + self._open_url = platform_paths.open_url + self._loaded = False + try: + self.view = wx.html2.WebView.New(self, backend=wx.html2.WebViewBackendEdge) + except Exception as exc: # noqa: BLE001 + self.Destroy() + raise RuntimeError(f"Couldn't show the formatted page: {exc}") from exc + sizer = wx.BoxSizer(wx.VERTICAL) + sizer.Add(self.view, 1, wx.EXPAND | wx.ALL, 4) + row = wx.BoxSizer(wx.HORIZONTAL) + plain = wx.Button(self, ID_PLAIN_TEXT, "Read as &Plain Text") + row.Add(plain, 0, wx.RIGHT, 6) + row.Add(wx.Button(self, wx.ID_CANCEL, "&Close"), 0) + sizer.Add(row, 0, wx.ALIGN_RIGHT | wx.ALL, 8) + self.SetSizer(sizer) + self.SetEscapeId(wx.ID_CANCEL) + plain.Bind(wx.EVT_BUTTON, lambda e: self.EndModal(ID_PLAIN_TEXT)) + try: + self.view.EnableAccessToDevTools(False) + self.view.AddScriptMessageHandler("hub") + self.view.AddUserScript(_KEY_RELAY) + except Exception: # noqa: BLE001 - Escape still works from the buttons + pass + self.view.Bind(wx.html2.EVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, self._on_script_message) + self.view.Bind(wx.html2.EVT_WEBVIEW_NAVIGATING, self._on_navigating) + self.view.Bind(wx.html2.EVT_WEBVIEW_NEWWINDOW, self._on_new_window) + self.view.Bind(wx.html2.EVT_WEBVIEW_LOADED, self._on_loaded) + self.view.SetPage(page_html, "") + + def _on_loaded(self, _event): + if not self._loaded: + self._loaded = True + # Into the page, so the screen reader starts reading it. + self.view.SetFocus() + + def _on_script_message(self, event): + message = event.GetString() + if message == "escape": + wx.CallAfter(self.EndModal, wx.ID_CANCEL) + elif message == "plain": + wx.CallAfter(self.EndModal, ID_PLAIN_TEXT) + + def _on_navigating(self, event): + if not self._loaded: + return # the page itself + # A link: never in here. http, https and mailto go to the browser. + event.Veto() + url = event.GetURL() + if url.lower().startswith(("http:", "https:", "mailto:")): + self._open_url(url) + + def _on_new_window(self, event): + url = event.GetURL() + if url.lower().startswith(("http:", "https:", "mailto:")): + self._open_url(url) + + +# -- Claude is waiting for an answer (#187, #188) --------------------------------------- +# +# Shared rules: Escape (Answer Later) closes without answering, so the turn +# keeps waiting and Ctrl+Shift+A comes back to it. Nothing refuses or approves +# by accident: Enter's default button is always the harmless choice. + +ALLOW, ALLOW_SESSION, DENY = "allow", "session", "deny" +ID_ALLOW = wx.NewIdRef() +ID_ALLOW_SESSION = wx.NewIdRef() +ID_DENY = wx.NewIdRef() + + +def _read_only_text(parent, value: str, name: str, min_height: int = 160) -> wx.TextCtrl: + text = wx.TextCtrl(parent, value=value, + style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_RICH2) + set_accessible_name(text, name) + text.SetMinSize((-1, min_height)) + text.SetInsertionPoint(0) + return text + + +class PermissionDialog(wx.Dialog): + """Claude wants to use a tool: Allow, Allow for this session, Deny. + + The request is in a read-only box to read by line (the whole command, or + the file and what would change). Deny is the default button, so a stray + Enter refuses; a reason typed for Deny goes back to Claude. + """ + + def __init__(self, parent, session_title: str, request): + super().__init__(parent, title=f"Claude needs permission: {session_title}", + size=(700, 540), style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) + self.choice = "" + sizer = wx.BoxSizer(wx.VERTICAL) + sizer.Add(wx.StaticText(self, label="&Request:"), 0, wx.LEFT | wx.TOP, 8) + self.request_text = _read_only_text(self, request.detail(), "Request") + sizer.Add(self.request_text, 1, wx.EXPAND | wx.ALL, 8) + sizer.Add(wx.StaticText(self, label="&Reason to give Claude if you deny (optional):"), + 0, wx.LEFT, 8) + self.reason = wx.TextCtrl(self) + set_accessible_name(self.reason, "Reason to give Claude if you deny (optional)") + sizer.Add(self.reason, 0, wx.EXPAND | wx.ALL, 8) + + row = wx.BoxSizer(wx.HORIZONTAL) + allow = wx.Button(self, ID_ALLOW, "&Allow") + row.Add(allow, 0, wx.RIGHT, 6) + session_label = request.allow_for_session_label() + if session_label: + self.session_button = wx.Button(self, ID_ALLOW_SESSION, "Allow for this &session") + # The button says what it does in full to a screen reader. + self.session_button.SetToolTip(session_label) + set_accessible_name(self.session_button, session_label) + row.Add(self.session_button, 0, wx.RIGHT, 6) + else: + self.session_button = None + deny = wx.Button(self, ID_DENY, "&Deny") + deny.SetDefault() + row.Add(deny, 0, wx.RIGHT, 6) + row.Add(wx.Button(self, wx.ID_CANCEL, "Answer &Later"), 0) + sizer.Add(row, 0, wx.ALIGN_RIGHT | wx.ALL, 8) + self.SetSizer(sizer) + self.SetEscapeId(wx.ID_CANCEL) + for button_id, choice in ((ID_ALLOW, ALLOW), (ID_ALLOW_SESSION, ALLOW_SESSION), + (ID_DENY, DENY)): + self.Bind(wx.EVT_BUTTON, lambda e, c=choice: self._choose(c), id=button_id) + wx.CallAfter(self.request_text.SetFocus) + + def _choose(self, choice: str): + self.choice = choice + self.EndModal(wx.ID_OK) + + def reason_text(self) -> str: + return self.reason.GetValue().strip() + + +OTHER = "Other" + + +class QuestionDialog(wx.Dialog): + """Claude's questions (AskUserQuestion), one group per question: the + options as radio buttons (check boxes when several may be chosen), each + with its description, and Other with a text box. Send Answers is the + default button.""" + + def __init__(self, parent, session_title: str, request): + super().__init__(parent, title=f"Claude asks: {session_title}", size=(700, 560), + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) + self._questions = request.questions() + self._controls = [] # per question: (kind, [controls], other text) + outer = wx.BoxSizer(wx.VERTICAL) + panel = wx.ScrolledWindow(self, style=wx.VSCROLL) + panel.SetScrollRate(0, 20) + sizer = wx.BoxSizer(wx.VERTICAL) + first = None + for number, question in enumerate(self._questions, start=1): + header = str(question.get("header") or f"Question {number}") + # The question is the group's label: a screen reader reads it on + # arriving at the first option, which a text above it is not. + box = wx.StaticBoxSizer(wx.VERTICAL, panel, f"{header}: {question['question']}") + parent_window = box.GetStaticBox() + options = [o for o in question.get("options") or [] if isinstance(o, dict)] + multi = bool(question.get("multiSelect")) + controls = [] + for index, option in enumerate(options): + label = str(option.get("label") or f"Option {index + 1}") + description = str(option.get("description") or "") + text = f"{label}: {description}" if description else label + if multi: + control = wx.CheckBox(parent_window, label=text) + else: + style = wx.RB_GROUP if index == 0 else 0 + control = wx.RadioButton(parent_window, label=text, style=style) + control.SetValue(False) + control._hub_label = label + controls.append(control) + box.Add(control, 0, wx.ALL, 4) + first = first or control + if multi: + other = wx.CheckBox(parent_window, label=f"{OTHER} (type below)") + else: + other = wx.RadioButton(parent_window, label=f"{OTHER} (type below)", + style=0 if controls else wx.RB_GROUP) + other.SetValue(False) + other._hub_label = OTHER + controls.append(other) + box.Add(other, 0, wx.ALL, 4) + other_text = wx.TextCtrl(parent_window) + set_accessible_name(other_text, f"{header}: your own answer") + box.Add(other_text, 0, wx.EXPAND | wx.ALL, 4) + other_text.Bind(wx.EVT_TEXT, lambda e, o=other: o.SetValue(True) + if e.GetString().strip() else None) + self._controls.append(("multi" if multi else "single", controls, other_text)) + sizer.Add(box, 0, wx.EXPAND | wx.ALL, 6) + first = first or other + panel.SetSizer(sizer) + outer.Add(panel, 1, wx.EXPAND | wx.ALL, 4) + + row = wx.BoxSizer(wx.HORIZONTAL) + send = wx.Button(self, wx.ID_OK, "&Send Answers") + send.SetDefault() + row.Add(send, 0, wx.RIGHT, 6) + decline = wx.Button(self, ID_DENY, "&Don't Answer") + row.Add(decline, 0, wx.RIGHT, 6) + row.Add(wx.Button(self, wx.ID_CANCEL, "Answer &Later"), 0) + outer.Add(row, 0, wx.ALIGN_RIGHT | wx.ALL, 8) + self.SetSizer(outer) + self.SetEscapeId(wx.ID_CANCEL) + self.declined = False + send.Bind(wx.EVT_BUTTON, self._on_send) + decline.Bind(wx.EVT_BUTTON, self._on_decline) + if first is not None: + wx.CallAfter(first.SetFocus) + + def _on_decline(self, _event): + self.declined = True + self.EndModal(wx.ID_OK) + + def _on_send(self, _event): + for (_kind, _controls, other_text), question in zip(self._controls, self._questions): + if not self._answer_for(_controls, other_text): + wx.MessageBox(f"Choose an answer for: {question['question']}", + self.GetTitle(), wx.OK | wx.ICON_WARNING, self) + (_controls[0] if _controls else other_text).SetFocus() + return + self.EndModal(wx.ID_OK) + + @staticmethod + def _answer_for(controls, other_text) -> str: + chosen = [] + for control in controls: + if not control.GetValue(): + continue + if control._hub_label == OTHER: + typed = " ".join(other_text.GetValue().split()) + if typed: + chosen.append(typed) + else: + chosen.append(control._hub_label) + return ", ".join(chosen) + + def answers(self): + """{question text: answer} as Claude Code expects it; several + choices are joined with commas.""" + return {question["question"]: self._answer_for(controls, other_text) + for (_kind, controls, other_text), question + in zip(self._controls, self._questions)} + + +class PlanDialog(wx.Dialog): + """Claude's plan, to read, then Approve (choosing the permission mode to + carry on in) or Keep Planning with a note. Keep Planning is the default + button: approving starts real changes, so it needs a deliberate press.""" + + def __init__(self, parent, session_title: str, request, modes, default_mode: str): + super().__init__(parent, title=f"Claude's plan: {session_title}", size=(760, 600), + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) + self._modes = list(modes) # (value, label) + self.approved = False + sizer = wx.BoxSizer(wx.VERTICAL) + sizer.Add(wx.StaticText(self, label="&Plan:"), 0, wx.LEFT | wx.TOP, 8) + self.plan_text = _read_only_text(self, request.plan() or "(Claude sent no plan text.)", + "Plan", min_height=260) + sizer.Add(self.plan_text, 1, wx.EXPAND | wx.ALL, 8) + + grid = wx.FlexGridSizer(cols=2, vgap=8, hgap=8) + grid.AddGrowableCol(1, 1) + grid.Add(wx.StaticText(self, label="If approved, carry on &in:"), 0, + wx.ALIGN_CENTER_VERTICAL) + self.mode = wx.Choice(self, choices=[label for _v, label in self._modes]) + set_accessible_name(self.mode, "If approved, carry on in") + values = [v for v, _l in self._modes] + self.mode.SetSelection(values.index(default_mode) if default_mode in values else 0) + grid.Add(self.mode, 1, wx.EXPAND) + grid.Add(wx.StaticText(self, label="&What to change (for Keep Planning):"), 0, + wx.ALIGN_CENTER_VERTICAL) + self.note = wx.TextCtrl(self) + set_accessible_name(self.note, "What to change (for Keep Planning)") + grid.Add(self.note, 1, wx.EXPAND) + sizer.Add(grid, 0, wx.EXPAND | wx.ALL, 8) + + row = wx.BoxSizer(wx.HORIZONTAL) + self.formatted_button = None + if formatted_view_available(): + # The plan as a page, by heading and list (#190); back here after. + self.formatted_button = wx.Button(self, label="Read &Formatted...") + row.Add(self.formatted_button, 0, wx.RIGHT, 18) + self.formatted_button.Bind(wx.EVT_BUTTON, lambda e: self._read_formatted(request)) + approve = wx.Button(self, ID_ALLOW, "&Approve") + row.Add(approve, 0, wx.RIGHT, 6) + keep = wx.Button(self, ID_DENY, "&Keep Planning") + keep.SetDefault() + row.Add(keep, 0, wx.RIGHT, 6) + row.Add(wx.Button(self, wx.ID_CANCEL, "Answer &Later"), 0) + sizer.Add(row, 0, wx.ALIGN_RIGHT | wx.ALL, 8) + self.SetSizer(sizer) + self.SetEscapeId(wx.ID_CANCEL) + approve.Bind(wx.EVT_BUTTON, lambda e: self._finish(True)) + keep.Bind(wx.EVT_BUTTON, lambda e: self._finish(False)) + wx.CallAfter(self.plan_text.SetFocus) + + def _finish(self, approved: bool): + self.approved = approved + self.EndModal(wx.ID_OK) + + def _read_formatted(self, request): + from ..rendering import message_page + title = "Claude's plan" + try: + dialog = FormattedMessageDialog(self, title, message_page(title, request.plan())) + except RuntimeError: + self.plan_text.SetFocus() + return + try: + dialog.ShowModal() + finally: + dialog.Destroy() + (self.formatted_button or self.plan_text).SetFocus() + + def chosen_mode(self) -> str: + index = self.mode.GetSelection() + return self._modes[index][0] if 0 <= index < len(self._modes) else self._modes[0][0] + + def note_text(self) -> str: + return self.note.GetValue().strip() diff --git a/TheClaudeHub/theclaudehub/ui/main_frame.py b/TheClaudeHub/theclaudehub/ui/main_frame.py index b77bb81..aa0b7e5 100644 --- a/TheClaudeHub/theclaudehub/ui/main_frame.py +++ b/TheClaudeHub/theclaudehub/ui/main_frame.py @@ -29,9 +29,16 @@ * **Announcements are spoken through the screen reader** (or a system voice) by the speech engine, never by moving focus, and also go to the status bar. +* **Claude waiting on you never pops up a dialog** (#187, #188). A + permission request, question or plan is announced and the session shows + as needing you; Ctrl+Shift+A opens it. A dialog appearing by itself would + take keystrokes typed for the reply box, and a letter could press Allow. + In each dialog Enter's default is the harmless choice (Deny, Send + Answers, Keep Planning) and Escape answers later. """ from __future__ import annotations +import os import threading import time from concurrent.futures import ThreadPoolExecutor @@ -40,9 +47,11 @@ import wx from .. import __version__, announce, hub, platform_paths -from ..claude_cli import (ResumeRefused, TurnEvent, TurnRunner, build_new_command, - build_resume_command, describe_elapsed, model_label, - new_session_id) +from ..claude_cli import (PERMISSION_MODES, PermissionRequest, ResumeRefused, TurnEvent, + TurnRunner, allow_response, answer_questions_response, + build_fork_command, build_new_command, build_resume_command, + deny_response, + describe_elapsed, model_label, new_session_id) from ..hub import Snapshot, collect, finished_turns, last_reply_from_tail from ..own_store import OwnSession, OwnSessionStore from ..sessions import IDLE, NEEDS_YOU, WORKING, SessionInfo @@ -50,7 +59,11 @@ from ..transcript import ASSISTANT, ERROR, PLAN, QUESTION, ChatMessage, TranscriptReader from ..updater import AVAILABLE, FAILED, CheckResult, UpdateService from .a11y import set_accessible_name -from .dialogs import MessageDialog, NewSessionDialog, SettingsDialog, ShortcutsDialog +from ..rendering import message_page +from .dialogs import (ALLOW, ALLOW_SESSION, ID_PLAIN_TEXT, FormattedMessageDialog, + MessageDialog, NewSessionDialog, PermissionDialog, PlanDialog, + QuestionDialog, SettingsDialog, ShortcutsDialog, + formatted_view_available) APP_NAME = "TheClaudeHub" LIST_REFRESH_MS = 5000 @@ -98,6 +111,9 @@ def __init__(self, store: Optional[OwnSessionStore] = None, self._show_activity = False self._drafts: Dict[str, str] = {} # unsent reply text, per session self._queued: Dict[str, str] = {} # sent during a turn, goes when it ends + # What Claude is waiting for you to answer, per session, oldest first + # (#187, #188). The turn is paused until each is answered. + self._pending: Dict[str, List[PermissionRequest]] = {} self._chat_keys: List[str] = [] self._announce_load = False # say "Loaded X" once its chat arrives @@ -133,6 +149,8 @@ def _build_menu(self): # would steal it from every button and text box in the window). self._item(session, "&Load Session", self.on_open_session) self._item(session, "Open in &Claude\tCtrl+O", self.on_open_in_claude) + self._item(session, "&Answer Claude...\tCtrl+Shift+A", lambda e: self.on_answer()) + self._item(session, "Con&tinue Here...\tCtrl+Shift+N", self.on_continue_here) self._item(session, "&New Session...\tCtrl+N", self.on_new_session) self._item(session, "&Refresh\tF5", lambda e: self.refresh_sessions(force=True, resort=True)) @@ -233,14 +251,22 @@ def _build_ui(self): value=("This session belongs to the Claude desktop app, so you reply to it " "in Claude. TheClaudeHub only reads it: sending from here while the " "desktop app has it open could run two turns at once and tangle " - "the conversation. Open in Claude switches the desktop app to it.")) + "the conversation. Open in Claude switches the desktop app to it. " + "Continue Here starts a TheClaudeHub copy of it, with the whole " + "conversation so far, that you can reply to here; the desktop app " + "session isn't changed.")) set_accessible_name(self.desktop_note, "About replying") self.desktop_note.SetMinSize((-1, 90)) dsizer.Add(self.desktop_note, 1, wx.EXPAND) + drow = wx.BoxSizer(wx.HORIZONTAL) self.reply_claude_btn = wx.Button(self.desktop_reply, label="Open in &Claude") - dsizer.Add(self.reply_claude_btn, 0, wx.TOP, 6) + drow.Add(self.reply_claude_btn, 0, wx.RIGHT, 6) + self.continue_btn = wx.Button(self.desktop_reply, label="Con&tinue Here...") + drow.Add(self.continue_btn, 0) + dsizer.Add(drow, 0, wx.TOP, 6) self.desktop_reply.SetSizer(dsizer) self.reply_claude_btn.Bind(wx.EVT_BUTTON, self.on_open_in_claude) + self.continue_btn.Bind(wx.EVT_BUTTON, self.on_continue_here) vsizer.Add(self.own_reply, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 8) vsizer.Add(self.desktop_reply, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 8) @@ -353,10 +379,11 @@ def refresh_sessions(self, force: bool = False, resort: bool = False): self._snapshot_busy = True own = [OwnSession(**vars(s)) for s in self.store.all()] running = set(self._runners) + waiting = self._waiting() def work(): try: - snap = collect(own, running) + snap = collect(own, running, waiting=waiting) ended = finished_turns(self._previous_states, snap.sessions) replies = {} if not self._first_snapshot and self.speech.announce_all_sessions: @@ -624,6 +651,8 @@ def _update_heading(self): own = self.store.get(info.cli_session_id) if own is not None: kind += f" on {model_label(own.model)}" + if own.forked_from: + kind += f", continued from {own.forked_from}" state = info.state + (f": {info.detail}" if info.detail else "") self.session_heading.SetLabel(f"{info.title}, {info.repo}, {state}. {kind}.") self._update_messages_label() @@ -809,6 +838,9 @@ def on_read_message(self): if message is None: self._feedback("No message selected.") return + if self.speech.formatted_messages and self._show_formatted(message): + self.chat_list.SetFocus() + return dialog = MessageDialog(self, message.label, message.text) try: dialog.ShowModal() @@ -816,6 +848,24 @@ def on_read_message(self): dialog.Destroy() self.chat_list.SetFocus() + def _show_formatted(self, message: ChatMessage) -> bool: + """The message as a formatted page (#190). False when that can't be + shown, or Kelly chose Read as Plain Text: the caller opens the text + box instead.""" + if not formatted_view_available(): + return False + title = (f"Message from {message.label}" if message.label in ("Claude", "You") + else message.label) + try: + dialog = FormattedMessageDialog(self, title, message_page(title, message.text)) + except RuntimeError as exc: + self._status(str(exc)) + return False + try: + return dialog.ShowModal() != ID_PLAIN_TEXT + finally: + dialog.Destroy() + def _message_menu(self) -> wx.Menu: """The messages list's context menu. Its handlers are bound on the menu itself, so nothing accumulates on the frame.""" @@ -898,7 +948,10 @@ def _update_send_state(self): self.send_btn.Enable(own) self.stop_btn.Enable(own) if info is not None and info.is_own: - if running: + waiting = self._pending.get(info.cli_session_id) + if running and waiting: + label = f"Waiting for you: {waiting[0].summary()}. Ctrl+Shift+A answers." + elif running: elapsed = describe_elapsed(self._runners[info.cli_session_id].elapsed()) label = f"Claude is working ({elapsed})." if info.cli_session_id in self._queued: @@ -912,6 +965,11 @@ def on_turn_status(self, _event=None): """How long the running turn has taken, and what it is doing.""" info = self._open runner = self._runners.get(info.cli_session_id) if info else None + if runner is not None and self._pending.get(info.cli_session_id): + request = self._pending[info.cli_session_id][0] + self._feedback(f"{info.title} is waiting for you: {request.summary()}. " + "Ctrl+Shift+A answers.") + return if runner is not None: waiting = (" A message is queued." if info.cli_session_id in self._queued else "") @@ -929,6 +987,112 @@ def on_turn_status(self, _event=None): parts.append(f"{name}, {describe_elapsed(other.elapsed())}") self._feedback("Working: " + "; ".join(parts) + ".") + # ------------------------------------------- answering Claude (#187, #188) + + def _waiting(self) -> Dict[str, str]: + """Sessions whose turn is paused on you, and what for.""" + return {sid: queue[0].summary() for sid, queue in self._pending.items() if queue} + + def _next_waiting_session(self) -> Optional[str]: + """The loaded session if it is waiting, otherwise the one that has + waited longest.""" + if (self._open is not None and self._open.is_own + and self._pending.get(self._open.cli_session_id)): + return self._open.cli_session_id + return next((sid for sid, queue in self._pending.items() if queue), None) + + def on_answer(self): + session_id = self._next_waiting_session() + if session_id is None: + self._feedback("Claude isn't waiting for an answer.") + return + request = self._pending[session_id][0] + own = self.store.get(session_id) + title = own.title if own else "A session" + decision = self._ask(title, request, own) + if decision is None: + self._feedback("Not answered yet. Claude is still waiting; Ctrl+Shift+A answers.") + return + response, said, changes = decision + self._apply_answer(session_id, request, response, said, changes) + + def _ask(self, title: str, request: PermissionRequest, own: Optional[OwnSession]): + """Show the dialog for ``request``. Returns (response for Claude Code, + what to say, changes to keep with the session), or None to answer + later.""" + if request.is_question: + dialog = QuestionDialog(self, title, request) + try: + if dialog.ShowModal() != wx.ID_OK: + return None + if dialog.declined: + return (deny_response("The user chose not to answer these questions. " + "Carry on without the answers, or ask in your " + "reply."), "Declined to answer.", {}) + return answer_questions_response(request, dialog.answers()), "Answer sent.", {} + finally: + dialog.Destroy() + if request.is_plan: + modes = [(value, label) for value, label in PERMISSION_MODES if value != "plan"] + dialog = PlanDialog(self, title, request, modes, "acceptEdits") + try: + if dialog.ShowModal() != wx.ID_OK: + return None + if dialog.approved: + mode = dialog.chosen_mode() + name = dict(modes)[mode].split(":")[0].lower() + # Kept with the session too: every turn is started with + # its stored mode, and it must not go back to planning. + return (allow_response(request, mode=mode), + f"Plan approved. Claude is carrying on in {name} mode.", + {"permission_mode": mode}) + note = dialog.note_text() + return (deny_response(note or "Keep planning: the plan isn't approved yet."), + "Claude will keep planning.", {}) + finally: + dialog.Destroy() + dialog = PermissionDialog(self, title, request) + try: + if dialog.ShowModal() != wx.ID_OK: + return None + if dialog.choice == ALLOW: + return allow_response(request), "Allowed.", {} + if dialog.choice == ALLOW_SESSION: + changes = {} + mode = request.session_mode() + if mode: + changes["permission_mode"] = mode + else: + kept = list(own.allowed_tools) if own is not None else [] + kept += [r for r in request.session_rules() if r not in kept] + changes["allowed_tools"] = kept + return (allow_response(request, for_session=True), + "Allowed for the rest of this session.", changes) + reason = dialog.reason_text() + return deny_response(reason), "Refused." + (" Claude has your reason." if reason + else ""), {} + finally: + dialog.Destroy() + + def _apply_answer(self, session_id: str, request: PermissionRequest, response: dict, + said: str, changes: dict): + queue = self._pending.get(session_id) or [] + if request in queue: + queue.remove(request) + runner = self._runners.get(session_id) + if runner is None or not runner.respond(request.request_id, response): + self._feedback("That isn't waiting any more: the turn has ended.") + self._update_send_state() + return + if changes: + self._store_write(self.store.update, session_id, **changes) + self._update_heading() + if queue: + said += f" Next: {queue[0].summary()}. Ctrl+Shift+A answers." + self._feedback(said) + self._update_send_state() + self.refresh_sessions() + def on_new_session(self, _event=None): lookup = platform_paths.find_claude() if not lookup.path: @@ -953,6 +1117,55 @@ def on_new_session(self, _event=None): self.open_session(own.to_info()) self.refresh_sessions() + def on_continue_here(self, _event=None): + """Carry on a desktop app session in TheClaudeHub, as a copy (#189).""" + info = self._selected_session() + if info is None: + self._feedback("No session selected.") + return + if info.is_own: + self._feedback(f"{info.title} is already a TheClaudeHub session; reply to it here.") + return + if not info.cli_session_id or platform_paths.transcript_path( + info.cwd, info.cli_session_id) is None: + wx.MessageBox("This session's conversation is no longer on disk, so there is " + "nothing to continue from.", APP_NAME, wx.OK | wx.ICON_INFORMATION, + self) + return + if not info.cwd or not os.path.isdir(info.cwd): + wx.MessageBox(f"This session's folder ({info.cwd or 'unknown'}) doesn't exist " + "any more, so it can't be continued here.", APP_NAME, + wx.OK | wx.ICON_WARNING, self) + return + lookup = platform_paths.find_claude() + if not lookup.path: + wx.MessageBox(lookup.problem, APP_NAME, wx.OK | wx.ICON_ERROR, self) + return + dialog = NewSessionDialog(self, info.cwd, continue_from=info.title) + try: + if dialog.ShowModal() != wx.ID_OK: + return + _folder, title, mode, message, model = dialog.values() + finally: + dialog.Destroy() + new_id = new_session_id() + try: + command = build_fork_command( + lookup.path, info.cli_session_id, new_id, title, mode, model, + taken_ids={s.cli_session_id for s in self.store.all()} + | set(self._snapshot.desktop_cli_ids)) + except (ResumeRefused, ValueError) as exc: + wx.MessageBox(str(exc), APP_NAME, wx.OK | wx.ICON_WARNING, self) + return + own = OwnSession(cli_session_id=new_id, title=title, cwd=info.cwd, + permission_mode=mode, started=False, model=model, + fork_source=info.cli_session_id, forked_from=info.title) + if not self._store_write(self.store.add, own): + return + self._start_turn(new_id, command, info.cwd, message, title) + self.open_session(own.to_info()) + self.refresh_sessions() + def on_send(self, _event=None): info = self._open if info is None or not info.is_own: @@ -1015,14 +1228,23 @@ def _send_now(self, session_id: str, message: str, queued: bool = False) -> Opti command = build_resume_command( exe, own.cli_session_id, own.permission_mode, own_ids={s.cli_session_id for s in self.store.all()}, - desktop_ids=self._snapshot.desktop_cli_ids, model=own.model) + desktop_ids=self._snapshot.desktop_cli_ids, model=own.model, + allowed_tools=own.allowed_tools) else: # The first turn never got as far as creating the session: # start it again rather than resume something that isn't there. if own.cli_session_id in self._snapshot.desktop_cli_ids: raise ResumeRefused("That id belongs to a Claude desktop app session.") - command = build_new_command(exe, own.cli_session_id, own.title, - own.permission_mode, own.model) + if own.fork_source: + # A copy of another session whose first turn didn't get + # going: copy it again. + command = build_fork_command(exe, own.fork_source, own.cli_session_id, + own.title, own.permission_mode, own.model, + taken_ids=self._snapshot.desktop_cli_ids) + else: + command = build_new_command(exe, own.cli_session_id, own.title, + own.permission_mode, own.model, + allowed_tools=own.allowed_tools) except (ResumeRefused, ValueError) as exc: return str(exc) self._start_turn(own.cli_session_id, command, own.cwd, message, own.title, @@ -1062,7 +1284,7 @@ def _on_turn_event(self, holder, title, event: TurnEvent): self._store_write(self.store.rename_id, session_id, reported) self._runners[reported] = self._runners.pop(session_id) self._denials[reported] = self._denials.pop(session_id, []) - for per_session in (self._drafts, self._queued): + for per_session in (self._drafts, self._queued, self._pending): if session_id in per_session: per_session[reported] = per_session.pop(session_id) if self._open is not None and self._open.cli_session_id == session_id: @@ -1089,7 +1311,22 @@ def _on_turn_event(self, holder, title, event: TurnEvent): self._denials.setdefault(session_id, []).append(event.text) self._status(f"{title}: permission denied, {event.text}") return + if event.kind == "permission" and event.request is not None: + if session_id not in self._runners: + return # the turn already ended; nothing is waiting + queue = self._pending.setdefault(session_id, []) + queue.append(event.request) + if len(queue) == 1: + self._say(f"{title} needs you. {event.request.summary()}. " + "Ctrl+Shift+A answers.") + else: + self._status(f"{title}: {len(queue)} things are waiting for you.") + self._update_send_state() + self.refresh_sessions() + return if event.kind in ("finished", "failed"): + # Nothing can be waiting once the turn is over. + self._pending.pop(session_id, None) # UI first, store writes after: a failed write must not leave the # session looking busy for good. runner = self._runners.pop(session_id, None) diff --git a/TheClaudeHub/theclaudehub/ui_text.py b/TheClaudeHub/theclaudehub/ui_text.py index 3a681ee..039306f 100644 --- a/TheClaudeHub/theclaudehub/ui_text.py +++ b/TheClaudeHub/theclaudehub/ui_text.py @@ -7,8 +7,8 @@ LAYOUT = ( "One window, three parts, in Tab order: the session list; the messages of the " "loaded session; and the reply box with Send and Stop (for a Claude desktop app " - "session, a read-only note and Open in Claude in the same place). Shift+Tab goes " - "back the same way." + "session, a read-only note, Open in Claude and Continue Here in the same place). " + "Shift+Tab goes back the same way." ) #: Every shortcut, grouped. The Help dialog and the README both list these. @@ -26,13 +26,16 @@ ("Enter", "Load that session and move to its messages"), ("Ctrl+O", "Open the selected session in the Claude desktop app"), ("Ctrl+N", "New TheClaudeHub session"), + ("Ctrl+Shift+N", "Continue the selected desktop app session here, as a copy you can " + "reply to (the desktop app session isn't changed)"), ("F5", "Refresh the list now and put it back in order (it also refreshes itself " "every few seconds, without moving rows while you're in it)"), ("Delete", "Forget the selected TheClaudeHub session (asks first)"), ]), ("Messages", [ ("Enter, or Applications key / Shift+F10 then Read Full Message", - "Read the whole message in a text box; Escape comes back to it"), + "Read the whole message as a formatted page (move by heading, table, list and " + "code block); Alt+P reads it as plain text instead; Escape comes back to it"), ("Ctrl+C", "Copy the whole message"), ("End", "Newest message"), ("Ctrl+T", "Show or hide tool activity"), @@ -46,6 +49,9 @@ "and whether a message is queued"), ]), ("Anywhere", [ + ("Ctrl+Shift+A", "Answer Claude: approve or deny a tool, answer its questions, or " + "approve its plan (the loaded session first, then the one that has " + "waited longest). Escape in the dialog answers later"), ("F1", "This list of shortcuts"), ("Ctrl+Comma", "Settings (announcements and speech)"), ("Ctrl+Shift+R", "Repeat the last announcement"),