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
@@ -97,6 +97,8 @@ interface ConversationViewProps {
waiting?: boolean;
/** Shows the pane's terminal, where that question is answered. */
onOpenTerminal?: () => void;
/** Bumped by the chat's Stop: the live line ends with the turn on screen. */
stopped?: number;
/** The newest reply's prompt size (input + cache) and model: how full the
* context is. */
onContext?: (tokens: number, model: string | null) => void;
@@ -113,6 +115,7 @@ export function ConversationView({
follow = 0,
waiting = false,
onOpenTerminal,
stopped = 0,
onContext,
onNotStarted,
}: ConversationViewProps) {
@@ -558,7 +561,11 @@ export function ConversationView({
{t("conversation.notStartedBody")}
</p>
{onOpenTerminal && (
<button type="button" onClick={onOpenTerminal} className="btn-secondary mt-3 text-xs">
<button
type="button"
onClick={onOpenTerminal}
className="btn-secondary mt-3 text-xs"
>
{t("conversation.openTerminal")}
</button>
)}
@@ -578,6 +585,7 @@ export function ConversationView({
live={live}
waiting={waiting}
onOpenTerminal={onOpenTerminal}
stopped={stopped}
/>
)}
</div>
@@ -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) {
@@ -182,6 +182,29 @@ describe("MessageList — sender attribution", () => {
expect(screen.queryByTestId("chat-running-step")).toBeNull();
});
// Issue #74: Esc before the model wrote anything takes the prompt back into
// Claude Code's input box and writes NO "[Request interrupted…]" line — the
// transcript still ends on the prompt, which read as "Thinking…" for 15 min.
it("drops Thinking… once Stop was pressed, until the transcript moves on", () => {
const prompt = msg({
sender: "user",
timestamp: new Date(Date.now() - 14_000).toISOString(),
content: [{ type: "text", text: "Phần đề xuất trong báo cáo" }],
});
const { rerender } = render(<MessageList loading={false} messages={[prompt]} live />);
expect(screen.getByTestId("chat-running-step")).toHaveTextContent("Thinking");
rerender(<MessageList loading={false} messages={[prompt]} live stopped={1} />);
expect(screen.queryByTestId("chat-running-step")).toBeNull();
// The next prompt is a new turn: its Thinking… shows.
const next = msg({
sender: "user",
timestamp: new Date().toISOString(),
content: [{ type: "text", text: "again" }],
});
rerender(<MessageList loading={false} messages={[prompt, next]} live stopped={1} />);
expect(screen.getByTestId("chat-running-step")).toHaveTextContent("Thinking");
});
it("counts a turn's output once per API call, since its prompt", () => {
const at = "2026-06-26T08:19:00.000Z";
const line = (cache: number, out: number) =>
+177 -13
View File
@@ -12,7 +12,12 @@
* `/model <id>` / `/effort <level>` — and the permission mode, which only
* Shift+Tab changes in a running session, so the server steps it and reads
* every step back off the pane (`POST /api/run/:id/permission-mode`); it sits
* in the box beside `+`. A permission prompt or a menu still needs the terminal.
* in the box beside `+`. A menu the CLI asks with — AskUserQuestion, a
* permission or trust prompt, Codex's approval — is read off the terminal
* underneath (`readRunMenu`) and shown in the prompt box's place as buttons
* that press its keys there (`PaneMenuCard`); its "Type something" row opens
* a field in the card, whose text is typed there as the answer
* (`answerRunMenu`).
* It also has Claude Desktop's activity panel (`ChatOutline`: progress,
* outputs, what the session used), open at first in a pane spanning the
* grid's full width or height (`panelOpen`) and collapsed into a file button
@@ -49,7 +54,8 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUp, FileText, Square } from "lucide-react";
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
import { isSubmitEnter } from "../../lib/keys";
import { ConversationView } from "../conversation/ConversationView";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
import { ChatAddMenu } from "./ChatAddMenu";
@@ -59,7 +65,14 @@ import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } f
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
import { Select } from "../Select";
import { Divider, showTerminal } from "./PaneGrid";
import { interruptRun, submitToRun } from "../../lib/panePaste";
import {
answerRunMenu,
chooseRunOption,
interruptRun,
readRunMenu,
submitToRun,
type PaneMenu,
} from "../../lib/panePaste";
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
import { openInChanges } from "../../lib/openInChanges";
import { toast } from "../../lib/toast";
@@ -184,6 +197,108 @@ function PermissionChip({ runId, initial }: { runId: string; initial: Permission
);
}
/** The menu the CLI asks with, as buttons: a pick presses its key in the
* pane; its "Type something" row opens a field right there instead —
* pressing that row's digit again would type the digit into the CLI's own. */
function PaneMenuCard({
menu,
onChoose,
onAnswer,
onCancel,
}: {
menu: PaneMenu;
onChoose: (index: number) => void;
onAnswer: (text: string) => void;
onCancel: () => void;
}) {
const { t } = useTranslation("run");
const [typing, setTyping] = useState(false);
const [draft, setDraft] = useState("");
const submit = () => draft.trim() && onAnswer(draft);
return (
<div
data-testid="pane-chat-menu"
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
>
{menu.question && (
<p className="mb-2 whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
{menu.question}
</p>
)}
<div className="flex flex-col gap-1">
{menu.options.map((o, i) =>
typing && i === menu.typeAt ? (
<div
key={`${i}-type`}
className="flex items-center gap-2 rounded-lg border border-accent/50 bg-surface-2 px-3 py-1"
>
<span className="w-4 flex-shrink-0 font-mono text-xs text-fg-muted">{o.n}</span>
<input
autoFocus
data-testid="pane-chat-menu-input"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
submit();
} else if (e.key === "Escape") setTyping(false);
}}
placeholder={t("chat.menu.placeholder")}
aria-label={t("chat.menu.placeholder")}
className="min-w-0 flex-1 bg-transparent py-0.5 text-sm text-fg-primary outline-none placeholder:text-fg-muted"
/>
<button
type="button"
data-testid="pane-chat-menu-send"
onClick={submit}
disabled={!draft.trim()}
aria-label={t("chat.send")}
title={t("chat.send")}
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
>
<ArrowUp className="h-3.5 w-3.5" />
</button>
</div>
) : (
<button
key={`${i}-${o.label}`}
type="button"
onClick={() => (i === menu.typeAt ? setTyping(true) : onChoose(i))}
className={`flex items-start gap-2 rounded-lg border px-3 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 ${
i === menu.cursor ? "border-accent/50 bg-surface-2" : "border-border"
}`}
>
<span className="mt-0.5 w-4 flex-shrink-0 font-mono text-xs text-fg-muted">
{o.checked === null ? (
(o.n ?? "↵")
) : o.checked ? (
<SquareCheck className="h-3.5 w-3.5 text-accent" />
) : (
<Square className="h-3.5 w-3.5" />
)}
</span>
<span className="min-w-0 break-words">
{o.label}
{o.hint && <span className="block text-xs text-fg-muted">{o.hint}</span>}
</span>
</button>
)
)}
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-fg-muted">
<button
type="button"
onClick={onCancel}
className="ml-auto rounded-md border border-border px-2 py-0.5 hover:bg-surface-3"
>
{t("chat.menu.cancel")}
</button>
</div>
</div>
);
}
export function PaneChat({
run,
sessionId,
@@ -247,6 +362,8 @@ export function PaneChat({
// waits on the user from a Notification (a permission prompt) to the next hook.
const [working, setWorking] = useState(false);
const [waiting, setWaiting] = useState(false);
// Stop presses, for the conversation's live line (issue #74).
const [stops, setStops] = useState(0);
const readWorking = useCallback(
() =>
api.agents
@@ -275,14 +392,41 @@ export function PaneChat({
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
});
// A hidden tab says when this session waits on the user or its turn ended.
const was = useRef({ working: false, waiting: false });
// The menu the terminal underneath shows now. AskUserQuestion fires no
// Notification, so this — not `waiting` alone — says the CLI asks.
// ponytail: polls the xterm buffer; subscribe to its onRender if it ever shows in a profile.
const [menu, setMenu] = useState<PaneMenu | null>(null);
useEffect(() => {
if (waiting) setTabBadge(runId, "waiting");
const read = () =>
setMenu((prev) => {
const next = readRunMenu(runId);
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
});
read();
const timer = window.setInterval(read, 700);
return () => window.clearInterval(timer);
}, [runId]);
const asking = waiting || menu !== null;
// Gone until the pane redraws: no second press on a stale menu.
const choose = (index: number) => {
if (menu && chooseRunOption(runId, menu, index)) setMenu(null);
else toast.error(t("chat.notSent"));
};
const answer = (reply: string) => {
if (menu && answerRunMenu(runId, menu, reply)) {
setMenu(null);
setFollow((n) => n + 1);
} else toast.error(t("chat.notSent"));
};
// A hidden tab says when this session waits on the user or its turn ended.
const was = useRef({ working: false, asking: false });
useEffect(() => {
if (asking) setTabBadge(runId, "waiting");
else if (working) setTabBadge(runId, null);
else if (was.current.working || was.current.waiting) setTabBadge(runId, "done");
was.current = { working, waiting };
}, [runId, working, waiting]);
else if (was.current.working || was.current.asking) setTabBadge(runId, "done");
was.current = { working, asking };
}, [runId, working, asking]);
useEffect(() => () => setTabBadge(runId, null), [runId]);
const [context, setContext] = useState<{ tokens: number; model: string | null }>({
tokens: 0,
@@ -339,7 +483,8 @@ export function PaneChat({
fill
live
follow={follow}
waiting={waiting}
waiting={asking}
stopped={stops}
onOpenTerminal={() => showTerminal(runId, sessionId)}
onContext={onContext}
onNotStarted={setNotStarted}
@@ -366,7 +511,24 @@ export function PaneChat({
</div>
<div className="mx-4">
<div className="mx-auto w-full max-w-[max(48rem,70%)]">
<div className="rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
{menu && (
<PaneMenuCard
// A new question starts with its field closed.
key={menu.question}
menu={menu}
onChoose={choose}
onAnswer={answer}
onCancel={() => {
if (interruptRun(runId)) setMenu(null);
else toast.error(t("chat.notSent"));
}}
/>
)}
{/* In the card's place while the CLI asks: hidden, never unmounted,
so a draft or an upload in flight survives the question. */}
<div
className={`rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50 ${menu ? "hidden" : ""}`}
>
<PromptEditor
bare
images={images}
@@ -413,8 +575,10 @@ export function PaneChat({
data-testid="pane-chat-stop"
onClick={() => {
// An interrupted turn fires no Stop hook: clear it here.
if (interruptRun(runId)) setWorking(false);
else toast.error(t("chat.notStopped"));
if (interruptRun(runId)) {
setWorking(false);
setStops((n) => n + 1);
} else toast.error(t("chat.notStopped"));
}}
aria-label={t("chat.stop")}
title={t("chat.stop")}
@@ -7,7 +7,9 @@
* activity panel, collapsed into a file button with the output count, whose
* files open a preview in its place; a file in a turn's "Edited N files" card
* opens in the Workspace's Changes panel when it is in the pane's folder; and
* Stop in Send's place while the main agent works, pressing Esc in the pane.
* Stop in Send's place while the main agent works, pressing Esc in the pane;
* and the menu the CLI asks with, shown as buttons that press its keys, a
* message sent meanwhile typed as its "Type something" answer.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,7 +18,14 @@ import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PaneChat, contextWindow } from "../PaneChat";
import { api } from "../../../lib/api";
import { interruptRun, submitToRun } from "../../../lib/panePaste";
import {
answerRunMenu,
chooseRunOption,
interruptRun,
readRunMenu,
submitToRun,
type PaneMenu,
} from "../../../lib/panePaste";
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
vi.mock("../../conversation/ConversationView", () => ({
@@ -41,6 +50,9 @@ vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
vi.mock("../../../lib/panePaste", () => ({
submitToRun: vi.fn(() => true),
interruptRun: vi.fn(() => true),
readRunMenu: vi.fn(() => null),
chooseRunOption: vi.fn(() => true),
answerRunMenu: vi.fn(() => true),
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -412,3 +424,51 @@ describe("PaneChat — the unsent prompt", () => {
expect(confirmDialog).not.toHaveBeenCalled();
});
});
describe("PaneChat — a menu the CLI asks with", () => {
const MENU: PaneMenu = {
question: "Which color?",
options: [
{ n: 1, label: "Red", hint: "warm", checked: null },
{ n: 2, label: "Type something.", hint: "", checked: null },
],
cursor: 0,
multi: false,
typeAt: 1,
};
it("shows the question as buttons in the box's place, and a pick presses its key", async () => {
vi.mocked(readRunMenu).mockReturnValue(MENU);
try {
renderChat();
const card = await screen.findByTestId("pane-chat-menu");
expect(card).toHaveTextContent("Which color?");
expect(card).toHaveTextContent("warm");
// The prompt box is hidden (kept mounted) while the CLI asks.
expect(screen.getByPlaceholderText("Write a message…").closest(".hidden")).not.toBeNull();
fireEvent.click(screen.getByText("Red"));
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", MENU, 0);
} finally {
vi.mocked(readRunMenu).mockReturnValue(null);
}
});
it("types the answer in the card's own field, never pressing that row's key", async () => {
vi.mocked(readRunMenu).mockReturnValue(MENU);
vi.mocked(chooseRunOption).mockClear();
vi.mocked(submitToRun).mockClear();
try {
renderChat();
fireEvent.click(await screen.findByText("Type something."));
expect(chooseRunOption).not.toHaveBeenCalled();
const field = screen.getByTestId("pane-chat-menu-input");
expect(field).toHaveFocus();
fireEvent.change(field, { target: { value: "purple" } });
fireEvent.keyDown(field, { key: "Enter" });
expect(answerRunMenu).toHaveBeenCalledWith("agenthub-lane-1", MENU, "purple");
expect(submitToRun).not.toHaveBeenCalled();
} finally {
vi.mocked(readRunMenu).mockReturnValue(null);
}
});
});