feat(chat): answer the CLI's menus from the chat; Stop ends Thinking… (#74)

- A menu the CLI asks with (AskUserQuestion — one question or several,
  multiSelect —, a permission or trust prompt, Codex's approval, a
  first-start login menu) is read off the terminal under the chat
  (`parsePaneMenu`/`readRunMenu`) and shown in the prompt box's place as a
  card of buttons. A pick presses its digit (picks it, or ticks its box),
  Claude's unnumbered Submit row is reached with the arrows and Enter, and
  Cancel presses Esc. "Type something" opens a field in the card: its text
  goes in by the arrows, Ctrl+U over anything already typed there, a
  bracketed paste and Enter. That row presses no key — a second press of
  its digit typed the digit into the CLI's field ("44444…"). Keys verified
  on Claude Code 2.1.296 and Codex 0.161. AskUserQuestion fires no
  Notification, so a menu on screen also counts as waiting (live line, tab
  badge). Client only: no new route.
- #74: Esc before the model wrote anything puts the prompt back in Claude
  Code's input box and writes no "[Request interrupted by user]" line, so
  the transcript ended on the prompt and the chat read "Thinking…" for
  15 min. The chat's Stop now ends the live line until a newer transcript
  line arrives (matched by line, not by clock).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 09:59:40 +07:00
parent 69a2c37cd2
commit 7cb4f64781
10 changed files with 626 additions and 22 deletions
+190 -2
View File
@@ -6,16 +6,22 @@
* is not on screen (a hidden console behind a full-width Changes panel); and
* `interruptRun`, the Chat view's Stop, presses Esc only in a visible pane;
* and the input box read off a real `claude`/`codex` screen (`parsePromptInput`)
* and moved out of it (`takeRunInput`) when the pane switches to Chat.
* and moved out of it (`takeRunInput`) when the pane switches to Chat; and
* the menu a CLI asks with, read off real Claude Code 2.1.296 / Codex 0.161
* screens (`parsePaneMenu`) and answered with the keys verified there.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
interruptRun,
notePaneFocus,
parsePromptInput,
answerRunMenu,
chooseRunOption,
parsePaneMenu,
pasteToLane,
readRunMenu,
registerPane,
takeRunInput,
} from "../panePaste";
@@ -122,4 +128,186 @@ describe("panePaste", () => {
off();
expect(takeRunInput("agenthub-lane-7")).toBe("");
});
it("reads an AskUserQuestion menu: question, options, hints, cursor", () => {
const m = parsePaneMenu(ASK);
expect(m).toEqual({
question: "Which color do you like: red, green, or blue?",
options: [
{ n: 1, label: "Red", hint: "Crisp red", checked: null },
{ n: 2, label: "Green", hint: "", checked: null },
{ n: 3, label: "Type something.", hint: "", checked: null },
{ n: 4, label: "Chat about this", hint: "", checked: null },
],
cursor: 0,
multi: false,
typeAt: 2,
});
});
it("reads a multiSelect question with its unnumbered Submit row", () => {
const m = parsePaneMenu(MULTI)!;
expect(m.multi).toBe(true);
expect(m.options.map((o) => [o.n, o.label, o.checked])).toEqual([
[1, "Nuts", true],
[2, "Honey", false],
[3, "Type something", false],
[null, "Submit", null],
[4, "Chat about this", null],
]);
expect(m.cursor).toBe(1);
expect(m.typeAt).toBe(2);
});
// The "Type something" row once something is typed in it: its label is the
// text. It is still the row right above "Chat about this".
it("finds Type something by its place once text is typed in it, and replaces that text", () => {
vi.useFakeTimers();
const keys: string[] = [];
const typed = ASK.map((r) =>
r === "❯ 1. Red" ? " 1. Red" : r === " 3. Type something." ? "❯ 3. 44444" : r
);
const m = parsePaneMenu(typed)!;
expect(m.typeAt).toBe(2);
expect(m.cursor).toBe(2);
const off = registerPane(
"agenthub-lane-9",
() => {},
() => true,
(d) => keys.push(d)
);
expect(answerRunMenu("agenthub-lane-9", m, "teal")).toBe(true);
vi.runAllTimers();
expect(keys).toEqual(["\x15", "\x1b[200~teal\x1b[201~", "\r"]);
off();
vi.useRealTimers();
});
it("reads Claude's and Codex's permission prompts", () => {
const c = parsePaneMenu(PERMISSION)!;
expect(c.question).toBe("Do you want to proceed?");
expect(c.options.map((o) => o.label)).toEqual(["Yes", "Yes, and always allow", "No"]);
const x = parsePaneMenu(CODEX_APPROVAL)!;
expect(x.question).toBe("Would you like to run the following command?\n$ touch probe.txt");
expect(x.options).toHaveLength(3);
expect(x.cursor).toBe(0);
});
it("reads a menu with no footer: the login method at first start", () => {
const m = parsePaneMenu([
" Select login method:",
"",
" ❯ 1. Claude account with subscription · Pro, Max, Team, or Enterprise",
"",
" 2. Anthropic Console account · API usage billing",
"",
])!;
expect(m.question).toBe("Select login method:");
expect(m.options.map((o) => o.n)).toEqual([1, 2]);
});
it("finds no menu in a reply's numbered list or a typed `1.`", () => {
// No cursor on the list.
expect(parsePaneMenu(["● Steps:", " 1. one", " 2. two", "", RULE, "❯ ", RULE])).toBeNull();
// A numbered list typed into the input box (under its rule), mid-turn.
expect(parsePaneMenu([RULE, "❯ 1. fix it", RULE, " esc to interrupt"])).toBeNull();
expect(parsePaneMenu([RULE, "❯\u00a01. a", " 2. b", RULE, " esc to interrupt"])).toBeNull();
expect(parsePaneMenu(CLAUDE)).toBeNull();
});
it("answers by digit, walks to Submit, and types into Type something", () => {
vi.useFakeTimers();
const keys: string[] = [];
let rows = ASK;
const off = registerPane(
"agenthub-lane-8",
() => {},
() => true,
(d) => keys.push(d),
() => ({ rows, cols: 80 })
);
const ask = readRunMenu("agenthub-lane-8")!;
expect(chooseRunOption("agenthub-lane-8", ask, 1)).toBe(true);
expect(keys).toEqual(["2"]);
keys.length = 0;
// Single select: walk to the row, paste, Enter.
expect(answerRunMenu("agenthub-lane-8", ask, "purple")).toBe(true);
vi.runAllTimers();
expect(keys).toEqual(["\x1b[B", "\x1b[B", "\x1b[200~purple\x1b[201~", "\r"]);
keys.length = 0;
rows = MULTI;
const multi = readRunMenu("agenthub-lane-8")!;
// From Honey (cursor 1) to Submit (3): two Downs, then Enter.
expect(chooseRunOption("agenthub-lane-8", multi, 3)).toBe(true);
vi.runAllTimers();
expect(keys).toEqual(["\x1b[B", "\x1b[B", "\r"]);
keys.length = 0;
// multiSelect: the typed text ticks the row; no Enter, Submit still to pick.
expect(answerRunMenu("agenthub-lane-8", multi, "salt")).toBe(true);
vi.runAllTimers();
expect(keys).toEqual(["\x1b[B", "\x1b[200~salt\x1b[201~"]);
off();
vi.useRealTimers();
expect(readRunMenu("agenthub-lane-8")).toBeNull();
});
});
// Real screens (descriptions trimmed), bottom of a 120-column pane.
const ASK = [
"❯ Use the AskUserQuestion tool to ask me which color I like.",
RULE,
" ☐ Color",
"",
"Which color do you like: red, green, or blue?",
"",
"❯ 1. Red",
" Crisp red",
" 2. Green",
" Green",
" 3. Type something.",
RULE,
" 4. Chat about this",
"",
"Enter to select · ↑/↓ to navigate · Esc to cancel",
"",
];
const MULTI = [
RULE,
"← ☒ Fruit ☐ Toppings ✔ Submit →",
"",
"Which toppings do you want?",
"",
" 1. [✔] Nuts",
" Crunchy, adds texture.",
"❯ 2. [ ] Honey",
" 3. [ ] Type something",
" Submit",
RULE,
" 4. Chat about this",
"",
"Enter to select · Tab/Arrow keys to navigate · Esc to cancel",
];
const PERMISSION = [
" Bash command",
" touch probe-file.txt",
"╌".repeat(60),
" Do you want to proceed?",
" ❯ 1. Yes",
" 2. Yes, and always allow",
" 3. No",
"",
" Esc to cancel · Tab to amend",
];
const CODEX_APPROVAL = [
"• Running touch probe.txt",
"",
" Would you like to run the following command?",
" $ touch probe.txt",
"",
"› 1. Yes, proceed (y)",
" 2. Yes, and don't ask again for commands that start with `touch probe.txt` (p)",
" 3. No, and tell Codex what to do differently",
"",
// "Press enter to confirm or esc to cancel", its dim cells left out.
" enteresc",
];
+141
View File
@@ -14,6 +14,9 @@
* terminal moves the unsent prompt across (`takeRunInput` reads the TUI's
* input box off the screen and empties it; the chat's draft is pasted back
* without Enter), so the half-typed text is never left behind on the other side.
* The Chat view also answers a menu the CLI asks with (`parsePaneMenu` reads
* it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the
* arrows and Enter, or type into its "Type something" row).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -151,3 +154,141 @@ export function takeRunInput(runId: string): string {
p!.key!("\x1b[3~".repeat(n) + "\x7f".repeat(n));
return text;
}
/** One choice of a menu the CLI shows: `n` its number (null for Claude's
* unnumbered Submit row), `checked` a multiSelect box's state. */
export type PaneMenuOption = {
n: number | null;
label: string;
hint: string;
checked: boolean | null;
};
/** A menu the CLI asks with (AskUserQuestion, a permission or trust prompt,
* Codex's approval): `cursor` the row its `❯`/`›` is on, `typeAt` the
* "Type something" row (-1: none). */
export type PaneMenu = {
question: string;
options: PaneMenuOption[];
cursor: number;
multi: boolean;
typeAt: number;
};
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/;
const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/;
const RULE_ROW = /^ *[─╌]{3,} *$/;
const CHECK = /^\[([ ✔])\] *(.*)$/;
const PLACEHOLDER = /^Type something\.?$/;
/** The numbered menu at the bottom of `rows`, or null. A menu has two
* numbered rows or more from 1, the cursor on one of them, and no rule right
* above its first row — a reply's numbered list has no cursor, and a list
* typed into Claude's input box sits under the box's rule. */
export function parsePaneMenu(rows: string[]): PaneMenu | null {
const r = rows.map((l) => l.trimEnd());
let end = r.length - 1;
while (end >= 0 && !r[end]) end--;
const options: PaneMenuOption[] = [];
let cursor = -1;
let hint: string[] = [];
let top = -1;
// Bottom up: a row under an option that is no option is its hint.
for (let i = end; i >= 0 && end - i < 40; i--) {
const row = r[i] ?? "";
const m = OPTION.exec(row);
const s = m ? null : SUBMIT.exec(row);
if (m || s) {
const text = m ? (m[3] ?? "") : "Submit";
const c = m ? CHECK.exec(text) : null;
const label = c ? (c[2] ?? "") : text;
const h = hint.join(" ");
options.unshift({
n: m ? Number(m[2]) : null,
label,
hint: h === label ? "" : h,
checked: c ? c[1] === "✔" : null,
});
if (m?.[1] || s?.[1]) cursor = options.length;
hint = [];
if (m?.[2] === "1") {
top = i;
break;
}
} else if (row && !RULE_ROW.test(row) && options.length) {
hint.unshift(row.trim());
} else if (row && !RULE_ROW.test(row)) {
hint = []; // the footer and anything under the last option
}
}
const numbered = options.filter((o) => o.n !== null);
if (top < 0 || cursor < 0 || numbered.length < 2 || RULE_ROW.test(r[top - 1] ?? "")) return null;
if (numbered.some((o, i) => o.n !== i + 1)) return null;
// The question: the block of text right above the first option.
let q = top - 1;
while (q >= 0 && !r[q]) q--;
const lines: string[] = [];
for (; q >= 0 && r[q] && !RULE_ROW.test(r[q]!) && lines.length < 4; q--)
lines.unshift(r[q]!.trim());
return {
question: lines.join("\n"),
options,
cursor: options.length - cursor,
multi: options.some((o) => o.checked !== null),
typeAt: typeRow(options),
};
}
/** The "Type something" row. Text typed into it replaces its label, and it is
* still the last numbered row above "Chat about this". */
function typeRow(options: PaneMenuOption[]): number {
const at = options.findIndex((o) => PLACEHOLDER.test(o.label));
if (at >= 0) return at;
for (let i = options.findIndex((o) => o.label === "Chat about this") - 1; i >= 0; i--)
if (options[i]!.n !== null) return i;
return -1;
}
/** The menu `runId`'s visible pane shows now, or null. */
export function readRunMenu(runId: string): PaneMenu | null {
const p = panes.get(runId);
const screen = p?.visible() && p.key ? p.screen?.() : null;
return screen ? parsePaneMenu(screen.rows) : null;
}
// One key per write, a beat apart: a TUI reading two arrows in one chunk may
// take them as one key.
function pressKeys(key: (data: string) => void, keys: string[]) {
keys.forEach((k, i) => setTimeout(() => key(k), i * 40));
}
/** The arrow presses from the menu's cursor to row `to`. */
const walk = (menu: PaneMenu, to: number) =>
Array<string>(Math.abs(to - menu.cursor)).fill(to > menu.cursor ? "\x1b[B" : "\x1b[A");
/** Picks `menu`'s option `index` in `runId`'s pane: its digit — which picks
* it, or ticks its box in a multiSelect — else (Submit) the arrows to it
* and Enter. False when the pane is not visible or not attached. */
export function chooseRunOption(runId: string, menu: PaneMenu, index: number): boolean {
const p = panes.get(runId);
const opt = menu.options[index];
if (!p?.visible() || !p.key || !opt) return false;
if (opt.n !== null && opt.n < 10) p.key(String(opt.n));
else pressKeys(p.key, [...walk(menu, index), "\r"]);
return true;
}
/** Answers `menu` with typed text: the arrows to its "Type something" row,
* Ctrl+U over what is typed there already (one key clears it, however long
* it is), the text as a bracketed
* paste, and Enter — none in a multiSelect, where the text only ticks its
* row and Submit is still to pick. */
export function answerRunMenu(runId: string, menu: PaneMenu, text: string): boolean {
const p = panes.get(runId);
const row = menu.options[menu.typeAt];
if (!p?.visible() || !p.key || !row) return false;
const keys = walk(menu, menu.typeAt);
if (!PLACEHOLDER.test(row.label)) keys.push("\x15");
keys.push(`\x1b[200~${text}\x1b[201~`);
pressKeys(p.key, menu.multi ? keys : [...keys, "\r"]);
return true;
}