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\\\local_.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\\\local_.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_\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\.json`, which says busy or idle while Claude Code runs it. Files whose process has gone are ignored. |
| The conversation | `%USERPROFILE%\.claude\projects\\.jsonl`, where `` 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-` 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 --permission-prompts none`,
-with `--session-id` and `--name` for the first message and `--resume ` 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
+--permission-prompts host --permission-prompt-tool stdio`, with `--session-id` and `--name` for the
+first message and `--resume ` 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 --fork-session --session-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 "Result
" in out
+ assert "" in out
+ assert "Name | " in out and "1 | " 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 '' in out
+ assert '' 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("\n\nhi x  "
+ "[ok](https://example.com) [bad](javascript:alert(1)) "
+ "[file](file:///C:/x)")
+ assert "