From ded5d1bd1de0c7a2a09dd3963f506efe58f53d19 Mon Sep 17 00:00:00 2001 From: Malcolm Habeeb <65781639+xerhab@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:12:35 -0400 Subject: [PATCH] Surface the TUI's blocking dialogs in chat, and let them be answered there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code blocks a turn on two dialogs the chat could not see: a tool permission request ("Bash command ... Do you want to proceed?") and a plan approval. Neither has a hook to intercept it and neither writes anything to the transcript, so the chat showed nothing at all. Worse, while a dialog is up the pane shows NEITHER the "esc to interrupt" hint NOR the mode footer, so paneBusy reads False and every surface labelled the session idle. A session actually blocked on a human read as finished, and the only way to see it - or answer - was the raw terminal. That was the last routine reason to leave the chat page. - parse_pane_prompt reads the dialog off the same capture as the mode marker: {prompt, options:[{number,label,selected}], detail}. Nothing keys on the wording (it differs per dialog); a line run qualifies only with options numbered 1..N, exactly one ❯ cursor, a "?" line above, and no mode footer below - the footer rides the composer a dialog replaces, so its absence is what separates a live dialog from transcript text shaped like one. detail carries the command + its description, or the plan body. - Reported as session.panePrompt, rendered in the chat's pending-question box with numbered picks, and answered via POST .../sessions//pane-prompt {optionNumber} -> answerPanePrompt, which types the digit. The agent RE-READS the pane first and drops the answer unless that number is on screen: the click was made against a beat-old heartbeat, and a stray digit typed into a live composer prepends itself to the operator's next message. - Both liveStates check it ahead of the busy read, so the card reads "waiting for your answer" instead of idle, and Stop is hidden while a dialog waits (same reasoning as XERK-21 for questions). Verified: agent 803 + turma 634 green, with the parser and answer tests driven by verbatim capture-pane output from two live dialogs rather than hand-written strings. Exercised in Chromium end to end - the dialog renders with its context and picks, clicking option 2 queues answerPanePrompt{optionNumber:2}, the sidebar reports "1 waiting on you", checked light/dark/390px. Android gap recorded in PARITY.md. --- CLAUDE.md | 16 +++ agent/hub-agent.py | 177 ++++++++++++++++++++++++++++++-- agent/tests/test_hub_agent.py | 185 +++++++++++++++++++++++++++++++++- android/PARITY.md | 7 ++ turma/public/chat.js | 75 +++++++++++++- turma/public/index.html | 7 ++ turma/public/sessions.html | 11 ++ turma/server.js | 15 +++ turma/tests/chat.test.js | 46 ++++++++- turma/tests/server.test.js | 28 +++++ 10 files changed, 554 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0cd51aa..c2e13ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -368,6 +368,22 @@ Currently Claude Code; the name is agent-generic so it can host other agents lat bridge (see "AskUserQuestion answer bridge"), which drops a `.req.json` under `~/.turma/questions/` while the call blocks. `session_report` reads it and the answer rides back as `.ans.json` — no pane scraping. A transcript scan is the already-answered fallback. +- **`panePrompt`** — the TUI's OTHER blocking dialog (tool-permission request / plan approval): no hook + intercepts it and it writes nothing to the transcript, and while it is up the pane shows neither the + interrupt hint nor the mode footer — so `paneBusy` read False and a session blocked on a human read + **idle**. `parse_pane_prompt` reads it off the mode marker's capture as + `{prompt, options:[{number,label,selected}], detail}`. + - Nothing keys on the wording (it differs per dialog). A line run is a dialog only with ALL of: options + numbered 1..N (N≥2), exactly one carrying the `❯` cursor, a `?` line directly above, and **no mode + footer below** — the footer rides the composer, which a dialog replaces, so its absence is what + separates a live dialog from transcript text shaped like one. `detail` is the block above the question + (command + description, or the plan body): blanks never close it, a rule does. + - Answered by `answerPanePrompt` → `answer_pane_prompt`, typing the option digit — but it **re-reads the + pane first** and drops the answer unless that number is on screen NOW: the click was made against a + beat-old heartbeat, and a stray digit typed into a live composer prepends itself to the next message. + - Both `liveState`s check it ahead of the busy read, so the card reads "waiting for your answer". Tests: + `TestParsePanePrompt`/`TestAnswerPanePrompt` (verbatim `capture-pane` output from live dialogs), the + `pane-prompt` cases in `server.test.js`, the `panePromptHtml` cases in `chat.test.js`. #### PR status diff --git a/agent/hub-agent.py b/agent/hub-agent.py index 0ca624c..1bb9b37 100644 --- a/agent/hub-agent.py +++ b/agent/hub-agent.py @@ -3227,14 +3227,139 @@ def parse_pane_mode(cap): return None +# Claude Code blocks a turn on a CHOICE DIALOG the transcript never records: a +# tool-permission request ("Bash command … Do you want to proceed?") or a plan +# approval ("Claude has written up a plan … Would you like to proceed?"). It is +# a TUI affordance, like the AskUserQuestion picker the ask.py bridge +# intercepts — but this one has no hook to intercept it, so nothing about it +# reaches the transcript, the tail, or the chat. +# +# Worse, while a dialog is up the pane shows NEITHER the "esc to interrupt" +# hint NOR the mode footer (the composer is replaced by the dialog), so +# `paneBusy` reads False and every status surface calls the session IDLE. It is +# in fact blocked on a human, and the only way to see that — or to answer — was +# to open the raw terminal. So we read the dialog off the pane, the same way +# `parse_pane_mode` reads the mode marker. +# +# Shapes verified against live panes (Claude Code 2.1.220), a permission +# dialog and a plan approval: +# +# Bash command │ Claude has written up a plan and is +# │ ready to execute. Would you like to +# touch /tmp/marker │ proceed? +# Create marker file in /tmp │ +# │ ❯ 1. Yes, and use auto mode +# Do you want to proceed? │ 2. Yes, manually approve edits +# ❯ 1. Yes │ 3. No, refine with Ultraplan … +# 2. Yes, and always allow … │ 4. Tell Claude what to change +# 3. No │ +# +# The wordings differ, so nothing keys on them. What both share, and what an +# ordinary numbered list in conversation text does not, is all four of: +# 1. a contiguous run of >= 2 options numbered 1..N in order; +# 2. exactly one carrying the TUI's "❯" selection cursor; +# 3. a question line (ends in "?") directly above the run; +# 4. NO mode footer below it — the footer marker rides the composer, which a +# dialog replaces, so its absence is what says "this is a live dialog" +# rather than transcript text that happens to look like one. +PANE_PROMPT_OPTION_RE = re.compile(r"^\s*(❯\s+)?(\d+)\.\s+(\S.*?)\s*$") +# A box/rule line the TUI draws between sections. The dialog's context is the +# nearest block ABOVE the question fenced by these, which is why they are +# skipped before the block and end it after: a permission dialog's block sits +# directly above the question ("Bash command" + the command + its description), +# while a plan's body sits one rule further up (the approval sentence has its +# own rule under it), and one walk has to find both. +PANE_PROMPT_RULE_RE = re.compile(r"^[\s─╌━▔▁═_│╭╮╰╯┌┐└┘├┤┬┴┼-]+$") +# How much context above the question to carry (the tool's command + its +# description, or the plan's body): enough to decide on, bounded for the beat. +PANE_PROMPT_DETAIL_LINES = 14 +PANE_PROMPT_DETAIL_CHARS = 800 +PANE_PROMPT_MAX_OPTIONS = 9 # answered by typing the digit; 10+ isn't one key + + +def parse_pane_prompt(cap): + """The blocking choice dialog the session's TUI is showing, or None. + + Returns {prompt, options: [{number, label, selected}], detail} — `detail` + being the context lines above the question (the command being asked about, + or the plan). See the comment above for the four conditions a run of lines + must meet, and why an idle/working pane can't produce a false positive. + + Scanned bottom-up: the dialog owns the bottom of the pane, so an earlier + dialog still scrolled on screen can't shadow the live one.""" + if not cap: + return None + lines = cap.splitlines() + # A mode footer anywhere below means the composer is live -> no dialog. + for i in range(len(lines) - 1, -1, -1): + line = lines[i] + m = PANE_PROMPT_OPTION_RE.match(line) + if not m or m.group(2) == "0": + if PANE_MODE_RE.search(line): + return None # composer footer: nothing is blocking + continue + # Walk up while the numbers keep descending to 1. + end = i + start = i + want = int(m.group(2)) + while start >= 0: + om = PANE_PROMPT_OPTION_RE.match(lines[start]) + if not om or int(om.group(2)) != want: + break + start -= 1 + want -= 1 + if want != 0 or end - start < 2: + continue # not 1..N, or fewer than two options + start += 1 + opts = [] + for line_no in range(start, end + 1): + om = PANE_PROMPT_OPTION_RE.match(lines[line_no]) + opts.append({"number": int(om.group(2)), + "label": om.group(3)[:200], + "selected": bool(om.group(1))}) + if sum(1 for o in opts if o["selected"]) != 1: + continue # no cursor (or several): not a live picker + if len(opts) > PANE_PROMPT_MAX_OPTIONS: + continue + # The question sits directly above, skipping blanks. + q = start - 1 + while q >= 0 and not lines[q].strip(): + q -= 1 + if q < 0 or not lines[q].strip().endswith("?"): + continue + prompt = lines[q].strip() + # Walk up for the fenced block above the question: skip blanks and rules + # until real text starts, then stop at the rule that closes it. + detail = [] + for line in reversed(lines[:q]): + text = line.strip() + if not text: + continue # a blank never closes the block (the TUI puts + # one between a dialog's title and its body) + if PANE_PROMPT_RULE_RE.match(line): + if detail: + break # the rule fencing the block we just collected + continue # still above/between rules — keep looking + detail.append(text) + if len(detail) >= PANE_PROMPT_DETAIL_LINES: + break + detail.reverse() + out = {"prompt": prompt[:300], "options": opts} + if detail: + out["detail"] = "\n".join(detail)[:PANE_PROMPT_DETAIL_CHARS] + return out + return None + + def _pane_status(tmux_name, state): - """(paneBusy, modeActual) for one beat: the busy half goes through - _stable_pane_busy's busy->idle flicker suppression (hence `state`), the - mode half reads the footer marker off its own capture.""" + """(paneBusy, modeActual, panePrompt) for one beat: the busy half goes + through _stable_pane_busy's busy->idle flicker suppression (hence `state`), + the mode and blocking-dialog halves read one shared capture.""" if not tmux_name: - return None, None - return (_stable_pane_busy(tmux_name, state), - parse_pane_mode(_capture_pane(tmux_name))) + return None, None, None + busy = _stable_pane_busy(tmux_name, state) + cap = _capture_pane(tmux_name) + return busy, parse_pane_mode(cap), parse_pane_prompt(cap) def _capture_pane(tmux_name): @@ -3272,7 +3397,7 @@ def session_report(workdir, state, tmux_name=None, session_id=None, proj = os.path.join(PROJECTS_ROOT, slug) primed = state.get("primed", False) offsets = state.setdefault("offsets", {}) - pane_busy, mode_actual = _pane_status(tmux_name, state) + pane_busy, mode_actual, pane_prompt = _pane_status(tmux_name, state) report = { "bridgeAttached": os.path.exists(os.path.join(proj, "bridge-pointer.json")), # Live "is it working right now" read straight off the session's TUI — @@ -3286,6 +3411,11 @@ def session_report(workdir, state, tmux_name=None, session_id=None, # the operator cycled by hand in the terminal doesn't leave the stored # mode — and every switch computed from it — wrong forever. "modeActual": mode_actual, + # The blocking choice dialog the TUI is showing (tool permission / plan + # approval), or None. Nothing about it reaches the transcript, and it + # suppresses the busy hint — so without this read the session looks idle + # while it waits on a human. See parse_pane_prompt. + "panePrompt": pane_prompt, "transcriptAgeSec": None, # seconds since the newest transcript write "lastRole": None, # "assistant"/"user"/... of the newest entry "lastHasToolUse": False, @@ -7357,6 +7487,36 @@ def interrupt(self, sid): run(["tmux", "send-keys", "-t", sess["tmuxName"], "Escape"]) log(f"interrupted session {sid}") + def answer_pane_prompt(self, sid, number): + """Answer the blocking choice dialog a session's TUI is showing (a tool + permission request or a plan approval — see parse_pane_prompt) by typing + its option digit, exactly the key an operator at the live terminal would + press. This is what makes the dialog answerable from the chat page + instead of only from the raw terminal. + + The pane is RE-READ first and the number checked against what is + actually on screen right now. That is the whole safety property: the + heartbeat a click was made against is up to a beat stale, and by the + time it lands the dialog may be gone — typing a bare digit into a live + composer would silently prepend a stray character to the operator's next + message. So a stale answer is dropped rather than sent.""" + sess = self._find(sid) + if not sess or sess.get("status") != "running": + return + try: + number = int(number) + except (TypeError, ValueError): + return + prompt = parse_pane_prompt(_capture_pane(sess.get("tmuxName"))) + if not prompt: + log(f"pane-prompt answer for {sid} dropped: no dialog on screen") + return + if not any(o["number"] == number for o in prompt["options"]): + log(f"pane-prompt answer {number} for {sid} dropped: not an option") + return + run(["tmux", "send-keys", "-t", sess["tmuxName"], str(number)]) + log(f"answered pane prompt for session {sid}: option {number}") + def set_summary(self, sid, summary): """Rename a session: replace the auto-generated few-word name the card leads with by one the operator typed. Works on a stopped session too (the @@ -9016,6 +9176,9 @@ def handle_commands(self, commands): cmd.get("custom"), cmd.get("optionIndices"), ) + elif ctype == "answerPanePrompt": + self.answer_pane_prompt( + cmd.get("sessionId"), cmd.get("optionNumber")) elif ctype == "history": self._stage_history(cmd.get("sessionId")) elif ctype == "subagentHistory": diff --git a/agent/tests/test_hub_agent.py b/agent/tests/test_hub_agent.py index 6466f9a..668042c 100644 --- a/agent/tests/test_hub_agent.py +++ b/agent/tests/test_hub_agent.py @@ -1335,18 +1335,21 @@ def test_false_when_a_spinner_line_is_quoted_in_tool_output(self): class TestSessionReportPaneBusy(ProjectDirMixin, unittest.TestCase): """session_report surfaces the (single-capture) pane probe as - report['paneBusy'] + report['modeActual'] on every return path (even - before any transcript exists).""" + report['paneBusy'] + report['modeActual'] + report['panePrompt'] on every + return path (even before any transcript exists).""" def test_pane_reads_reported_with_transcript(self): path = os.path.join(self.proj, "s.jsonl") write_jsonl(path, [{"type": "assistant", "message": {"content": [{"type": "text", "text": "hi"}]}}]) + prompt = {"prompt": "Do you want to proceed?", + "options": [{"number": 1, "label": "Yes", "selected": True}]} with mock.patch.object(ha, "_pane_status", - return_value=(True, "plan")) as ps: + return_value=(True, "plan", prompt)) as ps: rep = ha.session_report(self.WORKDIR, {}, "agent-abc") self.assertIs(rep["paneBusy"], True) self.assertEqual(rep["modeActual"], "plan") + self.assertEqual(rep["panePrompt"], prompt) # (tmux_name, state): state carries _stable_pane_busy's edge memory. self.assertEqual(ps.call_args[0][0], "agent-abc") self.assertIsInstance(ps.call_args[0][1], dict) @@ -1354,10 +1357,11 @@ def test_pane_reads_reported_with_transcript(self): def test_pane_reads_reported_without_transcript(self): # No transcript yet — the pane reads must still ride the early-return # path. - with mock.patch.object(ha, "_pane_status", return_value=(False, "auto")): + with mock.patch.object(ha, "_pane_status", return_value=(False, "auto", None)): rep = ha.session_report("/absent/worktree", {}, "agent-abc") self.assertIs(rep["paneBusy"], False) self.assertEqual(rep["modeActual"], "auto") + self.assertIsNone(rep["panePrompt"]) def test_pane_reads_default_none_without_tmux(self): path = os.path.join(self.proj, "s.jsonl") @@ -1366,6 +1370,7 @@ def test_pane_reads_default_none_without_tmux(self): rep = ha.session_report(self.WORKDIR, {}) # no tmux_name self.assertIsNone(rep["paneBusy"]) self.assertIsNone(rep["modeActual"]) + self.assertIsNone(rep["panePrompt"]) class TestStablePaneBusy(unittest.TestCase): @@ -4544,6 +4549,119 @@ def test_seen_set_is_capped(self): self.assertIn("knew", sess["prCommentBase"][self.URL]) +# Verbatim `tmux capture-pane -p` output from a live Claude Code 2.1.220 +# session, trimmed to the dialog region — the two blocking dialogs +# parse_pane_prompt exists to read. Kept as real captures rather than +# hand-written strings: the wordings, glyphs and blank-line placement are the +# contract, and inventing them is how a parser passes its tests and fails a pane. +PANE_PERMISSION_DIALOG = """\ +● Running 1 shell command… + ⎿ $ touch /tmp/permtest-marker + +──────────────────────────────────────────────────────────────────── + Bash command + + touch /tmp/permtest-marker + Create marker file in /tmp + + Do you want to proceed? + ❯ 1. Yes + 2. Yes, and always allow access to tmp/ from this project + 3. No + + Esc to cancel · Tab to amend · ctrl+e to explain +""" + +PANE_PLAN_DIALOG = """\ + ──────────────────────────────────────────────────────────────────── + Ready to code? + + Here is Claude's plan: + ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + Plan + + I will add one test. + ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + + ──────────────────────────────────────────────────────────────────── + Claude has written up a plan and is ready to execute. Would you like to proceed? + + ❯ 1. Yes, and use auto mode + 2. Yes, manually approve edits + 3. No, refine with Ultraplan on Claude Code on the web + 4. Tell Claude what to change + shift+tab to approve with this feedback +""" + +# The same session with no dialog up: the composer is live, so the mode footer +# is on screen. This is the shape that must NEVER parse as a dialog. +PANE_IDLE_COMPOSER = """\ +● Done — PR #230 is up. + + 1. first thing + 2. second thing + Which one? +──────────────────────────────────────────────────────────────────── +❯ Try "edit to..." +──────────────────────────────────────────────────────────────────── + ⏸ manual mode on · ? for shortcuts · ← for agents +""" + + +class TestAnswerPanePrompt(ManagerMixin, unittest.TestCase): + """Answering the TUI's blocking dialog from the chat page: type the option + digit, but only after re-reading the pane — the click was made against a + heartbeat that is up to a beat stale.""" + + def make_manager(self): + sm = super().make_manager() + self.run_calls.clear() + return sm + + def _session(self, sm, status="running"): + sm.registry = [{"id": "abcde", "status": status, "tmuxName": "agent-abcde"}] + + def _answer(self, sm, number, cap=PANE_PERMISSION_DIALOG): + with mock.patch.object(ha, "_capture_pane", return_value=cap): + sm.answer_pane_prompt("abcde", number) + + def test_types_the_option_digit(self): + sm = self.make_manager() + self._session(sm) + self._answer(sm, 2) + self.assertEqual( + self.run_calls, [["tmux", "send-keys", "-t", "agent-abcde", "2"]]) + + def test_stale_click_is_dropped_when_the_dialog_is_gone(self): + # The whole safety property: without the re-read this would type a bare + # "1" into the live composer, silently prepending a stray character to + # the operator's next message. + sm = self.make_manager() + self._session(sm) + self._answer(sm, 1, cap=PANE_IDLE_COMPOSER) + self.assertEqual(self.run_calls, []) + + def test_number_not_on_screen_is_dropped(self): + sm = self.make_manager() + self._session(sm) + self._answer(sm, 4) # the permission dialog offers 1-3 + self.assertEqual(self.run_calls, []) + + def test_noop_for_stopped_or_unknown_session(self): + sm = self.make_manager() + self._session(sm, status="stopped") + self._answer(sm, 1) + sm.registry = [] + self._answer(sm, 1) + self.assertEqual(self.run_calls, []) + + def test_non_numeric_answer_is_dropped(self): + sm = self.make_manager() + self._session(sm) + self._answer(sm, "; rm -rf /") + self.assertEqual(self.run_calls, []) + + class TestInterrupt(ManagerMixin, unittest.TestCase): """Stop the turn a running session has in flight: a single Escape into its TUI, which cancels the generation/tool call and leaves the session running @@ -4907,6 +5025,65 @@ def test_set_mode_noop_for_non_running(self): self.assertEqual(self.run_calls, []) +class TestParsePanePrompt(unittest.TestCase): + """Reading the TUI's blocking choice dialog off the pane. It never reaches + the transcript and it suppresses the busy hint, so without this read a + session blocked on a human reports idle.""" + + def test_permission_dialog(self): + p = ha.parse_pane_prompt(PANE_PERMISSION_DIALOG) + self.assertEqual(p["prompt"], "Do you want to proceed?") + self.assertEqual( + [(o["number"], o["label"], o["selected"]) for o in p["options"]], + [(1, "Yes", True), + (2, "Yes, and always allow access to tmp/ from this project", False), + (3, "No", False)]) + # The context is the fenced block above the question — the tool and the + # exact command it wants to run, which is the whole decision. + self.assertEqual( + p["detail"], + "Bash command\ntouch /tmp/permtest-marker\nCreate marker file in /tmp") + + def test_plan_dialog(self): + p = ha.parse_pane_prompt(PANE_PLAN_DIALOG) + self.assertEqual( + p["prompt"], + "Claude has written up a plan and is ready to execute. Would you like to proceed?") + self.assertEqual([o["number"] for o in p["options"]], [1, 2, 3, 4]) + self.assertEqual(p["options"][0]["label"], "Yes, and use auto mode") + self.assertTrue(p["options"][0]["selected"]) + # The plan body sits one rule further up than a permission dialog's + # block; the same walk has to reach it. + self.assertEqual(p["detail"], "Plan\nI will add one test.") + + def test_idle_composer_is_never_a_dialog(self): + # A numbered list in the conversation, a "?" line above it, and the + # composer live: the mode footer is what says nothing is blocking. + self.assertIsNone(ha.parse_pane_prompt(PANE_IDLE_COMPOSER)) + self.assertIsNone(ha.parse_pane_prompt("")) + self.assertIsNone(ha.parse_pane_prompt(None)) + + def test_requires_cursor_numbering_and_a_question(self): + # No ❯ cursor -> not a live picker. + self.assertIsNone(ha.parse_pane_prompt( + "Do you want to proceed?\n 1. Yes\n 2. No\n")) + # Cursor and numbering, but no question line above. + self.assertIsNone(ha.parse_pane_prompt( + "some prose\n ❯ 1. Yes\n 2. No\n")) + # Numbering that doesn't start at 1 (a list continuing from off-screen). + self.assertIsNone(ha.parse_pane_prompt( + "Proceed?\n ❯ 2. Yes\n 3. No\n")) + # A single option is a list, not a choice. + self.assertIsNone(ha.parse_pane_prompt("Proceed?\n ❯ 1. Yes\n")) + + def test_a_dialog_suppresses_the_busy_hint(self): + # This is WHY the read is needed: while the dialog is up the pane shows + # no interrupt hint and no mode footer, so paneBusy reads False — the + # session looks idle while it is actually blocked on a human. + self.assertFalse(ha._busy_from_capture(PANE_PERMISSION_DIALOG)) + self.assertIsNone(ha.parse_pane_mode(PANE_PERMISSION_DIALOG)) + + class TestParsePaneMode(unittest.TestCase): def test_all_five_markers(self): for marker, mode in [ diff --git a/android/PARITY.md b/android/PARITY.md index 5b84b60..c650c39 100644 --- a/android/PARITY.md +++ b/android/PARITY.md @@ -268,6 +268,13 @@ those are marked `[MODEL]`. #N", linked, consecutive duplicates folded — synthesized ids, `entryId`). Android decodes these to `UnknownBlock`/ignored keys today (safe degrade); port the fields onto `model/Models.kt`'s `ToolUseBlock` + new marker blocks, build items in `core/ChatItems.kt`, render in `ChatScreen`. +- P1 **Blocking TUI dialogs (`session.panePrompt`).** The agent now reports the tool-permission / + plan-approval dialog scraped off the pane, and the web renders it in the pending-question box with + numbered picks that `POST .../sessions//pane-prompt {optionNumber}`; both web `liveState`s also + treat it as "waiting for your answer" so the card isn't labelled idle while it blocks. Android + decodes the field away today, so such a session still reads idle with no way to answer. Port: + `panePrompt` onto the session model, the waiting state in `core/Sessions.kt`, and the picker in + `ChatScreen`/`ChatViewModel` beside the existing question sheet. - P3 **Terminal compose Stop.** XERK-33 also split the terminal compose bar; Android's `ui/TerminalScreen.kt` bar still only sends (it's a separate WebView screen with no live busy read). diff --git a/turma/public/chat.js b/turma/public/chat.js index afe626f..bdd3822 100644 --- a/turma/public/chat.js +++ b/turma/public/chat.js @@ -330,6 +330,11 @@ // Text of a question we just answered; suppresses re-showing its box while an // in-flight heartbeat still reports it as pending (cleared once it's gone). let answeredQuestion = null; + // The TUI's blocking dialog (permission / plan approval) is on screen, and + // the prompt text of one just answered — same roles as the two above, for the + // pane-scraped dialog rather than the hook-intercepted question. + let panePromptActive = false; + let answeredPanePrompt = null; // When Stop was clicked, or 0. See composeBusy(). let stopPendingAt = 0; // Until when the compose button is showing a transient failure message. @@ -965,6 +970,9 @@ // interrupt the turn and destroy the question, which is exactly the wrong // thing when the operator only wanted to type a custom response (XERK-21). if (questionActive) { stopPendingAt = 0; return false; } + // Same for the TUI's own blocking dialog: it is answered with its buttons, + // and a Stop there would cancel the decision rather than a running turn. + if (panePromptActive) { stopPendingAt = 0; return false; } if (!liveStatus) { stopPendingAt = 0; return false; } // A clicked Stop only lands on the agent's next beat, so the pane keeps // reporting the turn for a second or two afterwards. Hide Stop immediately @@ -1431,11 +1439,50 @@ head + "" + body + ""; } + // The blocking choice dialog the session's TUI is showing — a tool-permission + // request or a plan approval (agent parse_pane_prompt). It never reaches the + // transcript, and it suppresses the pane's busy hint, so before this the + // session read IDLE while it sat waiting on a human and the only way to + // answer was the raw terminal. Rendered in the same box as a pending + // AskUserQuestion: a session is never blocked on both (one blocks in the + // ask.py hook, the other in the TUI), and one "waiting on you" surface is + // what the operator wants. + function panePromptHtml(p) { + const opts = (p.options || []).map((o) => + '
' + + '' + esc(o.label || "") + "" + + '" + + "
").join(""); + return '
waiting
' + + '
' + esc(p.prompt || "") + "
" + + (p.detail ? '
' + esc(p.detail) + "
" : "") + + '
' + opts + "
"; + } + function updateQuestion(s) { const box = $("chatQuestion"); if (!box) return; const sess2 = s && s.session; const q = sess2 && sess2.question; + // No AskUserQuestion pending: a TUI dialog may still be blocking the turn. + if (!q) { + const p = sess2 && sess2.panePrompt; + const valid = p && p.prompt && p.options && p.options.length; + panePromptActive = !!valid && p.prompt !== answeredPanePrompt; + if (!panePromptActive) { answeredPanePrompt = null; } + updateComposeAction(); + if (panePromptActive) { + questionActive = false; + box.hidden = false; + box.innerHTML = panePromptHtml(p); + box.querySelectorAll(".q-opt-pick").forEach((b) => b.addEventListener("click", () => + answerPanePrompt(parseInt(b.getAttribute("data-num"), 10), p.prompt))); + return; + } + } else { + panePromptActive = false; + } // Prefer the rich options ({label, description?, preview?}); fall back to the // legacy label strings so an older agent still renders a pick list. const rich = (sess2 && sess2.questionOptionsRich) || null; @@ -1487,6 +1534,30 @@ } } + // Answer the TUI's blocking dialog by its option number — the agent re-reads + // the pane and drops the answer if the dialog has moved on (see + // answer_pane_prompt), so a click made against a stale beat can't type a + // stray digit into a live composer. + async function answerPanePrompt(optionNumber, prompt) { + if (!hostKey || !sessionId || !Number.isInteger(optionNumber)) return; + // Hide on click like the question box: the agent's next beat is a moment + // away, and leaving the dialog up reads as if the click didn't register. + answeredPanePrompt = prompt || null; + panePromptActive = false; + const box = $("chatQuestion"); if (box) { box.hidden = true; box.innerHTML = ""; } + updateComposeAction(); + try { + const r = await fetch("/api/agents/" + enc(hostKey) + "/sessions/" + enc(sessionId) + "/pane-prompt", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ optionNumber }), + }); + if (!r.ok) throw new Error(String(r.status)); + } catch { + answeredPanePrompt = null; // let the next beat re-surface it + actionFailed("Couldn't answer"); + } + } + async function answerQuestion(optionIndex, custom, optionIndices) { if (!hostKey || !sessionId) return; const body = { optionIndex }; @@ -1712,6 +1783,7 @@ if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; } hostKey = null; sessionId = null; sess = null; agent = null; buffer = []; queuedPrompts = []; liveTurn = ""; liveStatus = null; questionActive = false; answeredQuestion = null; + panePromptActive = false; answeredPanePrompt = null; stopPendingAt = 0; actionFailUntil = 0; modelSwitchPending = null; modeSwitchPending = null; lastHtml = null; repaintDeferred = false; updateLiveStatus(); // hide the pinned bar when the view closes @@ -1753,7 +1825,7 @@ module.exports = { mergeTail, weight, buildItems, itemsToHtml, esc, linkify, renderInline, renderProse, prFooterChip, ticketFooterChip, modelOpts, prettyModel, MODEL_OPTS, - agentsHtml, optionCardHtml, filterModeOpts, MODE_OPTS, repaint, selectionInScroll, tick, + agentsHtml, optionCardHtml, panePromptHtml, filterModeOpts, MODE_OPTS, repaint, selectionInScroll, tick, isBusy, updateComposeAction, isToolBullet, // Drive the real `turn`-frame classifier (see applyTurn): the ws onmessage // hands it frame.text verbatim, so the flicker tests exercise it directly. @@ -1766,6 +1838,7 @@ __setModelSwitchPending: (p) => { modelSwitchPending = p; }, __setModeSwitchPending: (p) => { modeSwitchPending = p; }, __setQuestionActive: (v) => { questionActive = v; }, + __setPanePromptActive: (v) => { panePromptActive = v; }, __setVerbosity: (v) => { verbosity = v; }, __setNoExpand: (v) => { noExpand = v; }, __setBuffer: (b) => { buffer = b; }, diff --git a/turma/public/index.html b/turma/public/index.html index 08be312..a03f53c 100644 --- a/turma/public/index.html +++ b/turma/public/index.html @@ -696,6 +696,13 @@ const age = s.transcriptAgeSec * 1000 + Math.max(0, now - host.lastSeen); if (s.question) return { label: "waiting for your answer", cls: "sess-wait", question: s.question, busy: true }; + // A blocking TUI dialog (tool permission / plan approval) is the same thing + // for the operator, and MUST be checked before the working read: while one is + // up the pane shows no interrupt hint, so paneBusy reads false and the + // session would otherwise be labelled plain "idle" while it waits on a human. + if (s.panePrompt && s.panePrompt.prompt) + return { label: "waiting for your answer", cls: "sess-wait", + question: s.panePrompt.prompt, busy: true }; const working = s.paneBusy != null ? (host.online && s.paneBusy) : (host.online && age < 90 * 1000); diff --git a/turma/public/sessions.html b/turma/public/sessions.html index ac0a3ee..40a9eeb 100644 --- a/turma/public/sessions.html +++ b/turma/public/sessions.html @@ -600,6 +600,10 @@

resolves to in the sibling sections, where each one is `:first-of-type` .chat-question .q-opt-pick { font: inherit; font-size: 12px; padding: 4px 12px; border-radius: 999px; border: 1px solid var(--accent); background: var(--surface); color: var(--accent); cursor: pointer; } .chat-question .q-opt-pick:hover { background: var(--accent); color: #fff; } + /* The option the TUI's own cursor is on, for a pane-scraped dialog. */ + .chat-question .q-opt-pick.sel { background: var(--accent); color: #fff; } + /* A pane dialog's context (the command being asked about, or the plan). */ + .chat-question .q-pane-detail { margin: 0 0 8px; max-height: 180px; } .chat-question .q-opt-desc { font-size: 11.5px; color: var(--muted); margin-top: 4px; } .chat-question .q-prev-wrap { margin-top: 6px; } .chat-question .q-prev-wrap summary { font-size: 11px; color: var(--accent); cursor: pointer; } @@ -1226,6 +1230,13 @@

resolves to in the sibling sections, where each one is `:first-of-type` function liveState(sess, host, now) { const s = sess.session; if (s && s.question) return { kind: "waiting", label: "waiting for your answer", question: s.question }; + // A blocking TUI dialog (tool permission / plan approval) is the same thing + // for the operator. Checked ahead of the transcript/paneBusy reads because + // neither can see it: nothing about the dialog reaches the transcript, and + // while it is up the pane shows no interrupt hint — so the card would read + // "idle" for a session that is actually waiting on a human. + if (s && s.panePrompt && s.panePrompt.prompt) + return { kind: "waiting", label: "waiting for your answer", question: s.panePrompt.prompt }; if (!s || s.transcriptAgeSec == null) return { kind: "idle", label: "no transcript yet" }; const age = s.transcriptAgeSec * 1000 + Math.max(0, now - host.lastSeen); const working = s.paneBusy != null diff --git a/turma/server.js b/turma/server.js index 460a804..e4b336d 100644 --- a/turma/server.js +++ b/turma/server.js @@ -2717,6 +2717,21 @@ const server = http.createServer(async (req, res) => { const cmdId = queueCommand(key, cmd); return json(res, 200, { ok: true, cmdId }); } + // POST /api/agents//sessions//pane-prompt -> answer the blocking + // choice dialog the session's TUI is showing (a tool-permission request or + // a plan approval, reported as session.panePrompt). Body: {optionNumber}, + // the 1-based number the dialog itself displays — the agent types that + // digit, and re-reads the pane first so a click made against a stale beat + // is dropped rather than typed into a live composer. + if (req.method === "POST" && parts.length === 6 && parts[5] === "pane-prompt") { + const body = JSON.parse((await readBody(req)) || "{}"); + const optionNumber = Number.isInteger(body.optionNumber) ? body.optionNumber : 0; + if (optionNumber < 1 || optionNumber > 9) { + return json(res, 400, { error: "optionNumber 1-9 required" }); + } + const cmdId = queueCommand(key, { type: "answerPanePrompt", sessionId, optionNumber }); + return json(res, 200, { ok: true, cmdId }); + } // GET /api/agents//sessions//history -> that session's recent // transcript. Serves a fresh cached result (see ingestHistory) or, on a // cache miss/stale entry, queues a fetch and reports it pending; a diff --git a/turma/tests/chat.test.js b/turma/tests/chat.test.js index 82b58b1..26ba81a 100644 --- a/turma/tests/chat.test.js +++ b/turma/tests/chat.test.js @@ -9,7 +9,7 @@ const test = require("node:test"); const assert = require("node:assert/strict"); -const { mergeTail, weight, buildItems, itemsToHtml, linkify, renderInline, renderProse, prFooterChip, ticketFooterChip, modelOpts, prettyModel, MODEL_OPTS, modelChipLabel, modeChipValue, __setSess, __setAgent, __setModelSwitchPending, __setModeSwitchPending, agentsHtml, optionCardHtml, filterModeOpts, MODE_OPTS, isBusy, updateComposeAction, __setVerbosity, __setNoExpand, __setLiveStatus, __stopPending, __setQuestionActive } = require("../public/chat.js"); +const { mergeTail, weight, buildItems, itemsToHtml, linkify, renderInline, renderProse, prFooterChip, ticketFooterChip, modelOpts, prettyModel, MODEL_OPTS, modelChipLabel, modeChipValue, __setSess, __setAgent, __setModelSwitchPending, __setModeSwitchPending, agentsHtml, optionCardHtml, panePromptHtml, __setPanePromptActive, filterModeOpts, MODE_OPTS, isBusy, updateComposeAction, __setVerbosity, __setNoExpand, __setLiveStatus, __stopPending, __setQuestionActive } = require("../public/chat.js"); const PRESETS = { concise: { thinking: false, tools: false, outputs: false }, @@ -270,6 +270,50 @@ test("buildItems/render: pr_link blocks -> one linked marker, consecutive duplic assert.match(html, /Opened PR #230 — o\/r/); }); +test("panePromptHtml: renders the TUI dialog with its context and numbered picks", () => { + const html = panePromptHtml({ + prompt: "Do you want to proceed?", + detail: "Bash command\ntouch /tmp/marker", + options: [ + { number: 1, label: "Yes", selected: true }, + { number: 2, label: "Yes, and always allow access to tmp/", selected: false }, + { number: 3, label: "No", selected: false }, + ], + }); + assert.match(html, /Do you want to proceed\?/); + assert.match(html, /q-pane-detail">Bash command\ntouch \/tmp\/marker]*>1\. Choose]*>3\. Choose { + const html = panePromptHtml({ + prompt: "Run ?", + detail: "rm -rf && echo 'y'", + options: [{ number: 1, label: "Yes", selected: true }, + { number: 2, label: "No", selected: false }], + }); + assert.doesNotMatch(html, /