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:
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"tab": {
|
||||
"waiting": "Waiting",
|
||||
"done": "Done"
|
||||
},
|
||||
"menu": {
|
||||
"placeholder": "Type your answer…",
|
||||
"cancel": "Cancel (Esc)"
|
||||
}
|
||||
},
|
||||
"interrupted": {
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"tab": {
|
||||
"waiting": "Đang chờ",
|
||||
"done": "Xong"
|
||||
},
|
||||
"menu": {
|
||||
"placeholder": "Nhập câu trả lời của bạn…",
|
||||
"cancel": "Huỷ (Esc)"
|
||||
}
|
||||
},
|
||||
"interrupted": {
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user