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
@@ -156,6 +156,9 @@ interface MessageListProps {
waiting?: boolean;
/** Shows the pane's terminal, where that question is answered. */
onOpenTerminal?: () => void;
/** Bumped by the chat's Stop: the turn on screen is over, even when the
* transcript never says so. */
stopped?: number;
}
/** Build a map from tool_use id → tool_result for matching */
@@ -611,15 +614,24 @@ export function MessageList({
live = false,
waiting = false,
onOpenTerminal,
stopped = 0,
}: MessageListProps) {
const { t } = useTranslation("sessions");
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
const keys = useMemo(() => messageKeys(messages), [messages]);
const lastKey = keys[keys.length - 1] ?? null;
// The newest line when Stop was pressed. Esc before the model wrote anything
// puts the prompt back in Claude Code's input box and writes no
// "[Request interrupted…]" line (issue #74), so the transcript still ends on
// the prompt: no live line until a newer one arrives.
const [stop, setStop] = useState<{ n: number; on: string | null }>({ n: stopped, on: null });
if (stop.n !== stopped) setStop({ n: stopped, on: lastKey });
const halted = stop.on !== null && stop.on === lastKey;
const pending = useMemo(
() => (live ? workingOn(messages, toolResultMap) : null),
[live, messages, toolResultMap]
() => (live && !halted ? workingOn(messages, toolResultMap) : null),
[live, halted, messages, toolResultMap]
);
const stats = useMemo(() => (pending ? turnStats(messages) : null), [pending, messages]);
const keys = useMemo(() => messageKeys(messages), [messages]);
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
if (loading) {