01e66ba122
The chat's live line said "Thinking… 21s" while the box offered Send, so the
turn could not be stopped. Stop shows only when a hook marked main "working"
or the pane's busy line matched, and Claude Code 2.1.3xx writes
"esc to interrupt" only in its footer, which a narrow pane, a tip or a notice
replaces. Claude's busy line now also matches its spinner (verb… (41s · …)),
which the line a finished turn leaves ("Cooked for 1m 2s") does not; and the
chat re-reads "working" on transcript_changed, so a hook that reached another
dashboard on the same database no longer leaves Stop hidden.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
485 lines
17 KiB
TypeScript
485 lines
17 KiB
TypeScript
/**
|
||
* @file panePaste.test.ts
|
||
* @description Pins where a pasted diff comment lands: the workspace's pane
|
||
* focused last, else any of its panes, never another workspace's (lane 1 is
|
||
* not lane 12), nowhere once the pane unmounted, and never into a pane that
|
||
* 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
|
||
* 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, vi } from "vitest";
|
||
import {
|
||
interruptRun,
|
||
notePaneFocus,
|
||
parsePromptInput,
|
||
answerRunMenu,
|
||
chooseRunOption,
|
||
parsePaneMenu,
|
||
pasteToLane,
|
||
readRunBusy,
|
||
readRunMenu,
|
||
registerPane,
|
||
takeRunInput,
|
||
MENU_CURSOR_ROW,
|
||
} from "../panePaste";
|
||
|
||
const RULE = "─".repeat(60);
|
||
// Claude Code at 60 columns: a wrapped first line, then a typed newline. Its
|
||
// box draws `❯` and a NO-BREAK space (U+00A0), never a plain one.
|
||
const CLAUDE = [
|
||
"❯ Use the Bash tool to run: sleep 20",
|
||
"",
|
||
RULE,
|
||
"❯\u00a0alpha beta gamma delta epsilon zeta eta theta iota kappa",
|
||
" lambda mu nu xi omicron pi rho sigma tau",
|
||
" Tiếng Việt có dấu",
|
||
RULE,
|
||
" Model: Haiku 5.5",
|
||
" ⏵⏵ auto mode on (shift+tab to cycle)",
|
||
];
|
||
// Codex at 60 columns (its dim placeholder already left out of an empty box).
|
||
const CODEX = ["› earlier message", "", "› alpha beta", " indented", "", " GPT-6 · /tmp", ""];
|
||
|
||
describe("panePaste", () => {
|
||
it("pastes into the workspace's last-focused pane, and only its own", () => {
|
||
const got: Record<string, string[]> = {};
|
||
const reg = (id: string) => registerPane(id, (t) => (got[id] ??= []).push(t));
|
||
const off12 = reg("agenthub-lane-12");
|
||
const off0 = reg("agenthub-lane-1");
|
||
const off2 = reg("agenthub-lane-1-2");
|
||
expect(pasteToLane(1, "a")).toBe("agenthub-lane-1");
|
||
expect(got["agenthub-lane-1"]).toEqual(["a"]);
|
||
notePaneFocus("agenthub-lane-1-2");
|
||
pasteToLane(1, "b");
|
||
expect(got["agenthub-lane-1-2"]).toEqual(["b"]);
|
||
// Focus on another workspace's pane does not pull lane 1's text there.
|
||
notePaneFocus("agenthub-lane-12");
|
||
pasteToLane(1, "c");
|
||
expect(got["agenthub-lane-12"]).toBeUndefined();
|
||
off0();
|
||
off2();
|
||
expect(pasteToLane(1, "d")).toBeNull();
|
||
off12();
|
||
});
|
||
|
||
it("never pastes into a pane that is not visible", () => {
|
||
const got: string[] = [];
|
||
let shown = false;
|
||
const off = registerPane(
|
||
"agenthub-lane-5",
|
||
(t) => got.push(t),
|
||
() => shown
|
||
);
|
||
notePaneFocus("agenthub-lane-5");
|
||
expect(pasteToLane(5, "x")).toBeNull();
|
||
expect(got).toEqual([]);
|
||
shown = true;
|
||
expect(pasteToLane(5, "y")).toBe("agenthub-lane-5");
|
||
expect(got).toEqual(["y"]);
|
||
off();
|
||
});
|
||
|
||
it("interruptRun presses Esc in a visible pane only", () => {
|
||
const keys: string[] = [];
|
||
let shown = false;
|
||
const off = registerPane(
|
||
"agenthub-lane-6",
|
||
() => {},
|
||
() => shown,
|
||
(d) => keys.push(d)
|
||
);
|
||
expect(interruptRun("agenthub-lane-6")).toBe(false);
|
||
shown = true;
|
||
expect(interruptRun("agenthub-lane-6")).toBe(true);
|
||
expect(keys).toEqual(["\x1b"]);
|
||
off();
|
||
});
|
||
|
||
it("reads claude's and codex's input box, joining soft-wrapped rows back", () => {
|
||
expect(parsePromptInput(CLAUDE, 60)).toBe(
|
||
"alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau\nTiếng Việt có dấu"
|
||
);
|
||
expect(parsePromptInput(CODEX, 60)).toBe("alpha beta\n indented");
|
||
expect(parsePromptInput([RULE, "❯\u00a0", RULE, " ⏵⏵ auto mode on"], 60)).toBe("");
|
||
expect(parsePromptInput(["› ", "", " GPT-6 · /tmp", " ← for agents"], 60)).toBe("");
|
||
// A menu or permission prompt in the box's place: nothing to read.
|
||
expect(parsePromptInput(["Do you want to proceed?", "❯ 1. Yes", " 2. No"], 60)).toBeNull();
|
||
});
|
||
|
||
it("takeRunInput deletes what it read, and leaves a paste placeholder alone", () => {
|
||
const keys: string[] = [];
|
||
let rows = CODEX;
|
||
const off = registerPane(
|
||
"agenthub-lane-7",
|
||
() => {},
|
||
() => true,
|
||
(d) => keys.push(d),
|
||
() => ({ rows, cols: 60 })
|
||
);
|
||
expect(takeRunInput("agenthub-lane-7")).toBe("alpha beta\n indented");
|
||
const n = "alpha beta\n indented".length + 1;
|
||
expect(keys).toEqual(["\x1b[3~".repeat(n) + "\x7f".repeat(n)]);
|
||
rows = [RULE, "❯ [Pasted text #1 +40 lines] and more", RULE];
|
||
expect(takeRunInput("agenthub-lane-7")).toBe("");
|
||
expect(keys).toHaveLength(1);
|
||
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);
|
||
});
|
||
|
||
// Several questions: the unnumbered row is Next until the last one, and the
|
||
// tab bar says which questions have an answer.
|
||
it("reads a multi-question multiSelect with its Next row and question tabs", () => {
|
||
const m = parsePaneMenu(MULTI_Q)!;
|
||
expect(m.options.map((o) => [o.n, o.label, o.checked])).toEqual([
|
||
[1, "JWT HS256 + default secret", true],
|
||
[2, "Product code issue 2 lần", true],
|
||
[3, "Type something", false],
|
||
[null, "Next", null],
|
||
[4, "Chat about this", null],
|
||
]);
|
||
expect(m.options[2]!.hint).toBe("");
|
||
expect(m.cursor).toBe(0);
|
||
expect(m.tabs).toEqual([
|
||
{ label: "Critical", done: true },
|
||
{ label: "High 1/3", done: false },
|
||
{ label: "High 2/3", done: false },
|
||
{ label: "High 3/3", done: false },
|
||
]);
|
||
expect(m.tab).toBe(0);
|
||
expect(MENU_CURSOR_ROW.test("❯ Next")).toBe(true);
|
||
});
|
||
|
||
it("reads no tabs for a single question", () => {
|
||
expect(parsePaneMenu(ASK)!.tabs).toBeUndefined();
|
||
});
|
||
|
||
// 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"]);
|
||
// The tool and its command, drawn over the dashed rule.
|
||
expect(c.detail).toEqual(["Bash command", "touch probe-file.txt"]);
|
||
const x = parsePaneMenu(CODEX_APPROVAL)!;
|
||
expect(x.question).toBe("Would you like to run the following command?\n$ touch probe.txt");
|
||
expect(x.detail).toBeUndefined();
|
||
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("reads Claude's unnumbered trust prompt and picks by arrows", () => {
|
||
vi.useFakeTimers();
|
||
const m = parsePaneMenu(TRUST)!;
|
||
expect(m.options.map((o) => o.label)).toEqual(["No, exit", "Yes, I trust this folder"]);
|
||
expect(m.options.every((o) => o.n === null)).toBe(true);
|
||
expect(m.cursor).toBe(0);
|
||
expect(m.question.split("\n")[0]).toBe("Accessing workspace:");
|
||
expect(m.question).toContain("Security guide");
|
||
const keys: string[] = [];
|
||
const off = registerPane(
|
||
"agenthub-lane-12",
|
||
() => {},
|
||
() => true,
|
||
(k) => keys.push(k)
|
||
);
|
||
expect(chooseRunOption("agenthub-lane-12", m, 1)).toBe(true);
|
||
vi.runAllTimers();
|
||
expect(keys).toEqual(["\x1b[B", "\r"]);
|
||
off();
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
it("finds no unnumbered menu in Claude's input box or a wrapped prompt above it", () => {
|
||
expect(parsePaneMenu(CLAUDE)).toBeNull();
|
||
expect(
|
||
parsePaneMenu(["❯ a long prompt that", " wrapped onto two rows", "", RULE, "❯\u00a0", RULE])
|
||
).toBeNull();
|
||
});
|
||
|
||
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 MULTI_Q = [
|
||
RULE,
|
||
"← ☒ Critical □ High 1/3 □ High 2/3 □ High 3/3 ✔ Submit →",
|
||
"",
|
||
"Critical (đã verify trong code). Chọn issue để log BUG:",
|
||
"",
|
||
"❯ 1. [✔] JWT HS256 + default secret",
|
||
" pkg/middleware/auth.go:156,184 — HMAC only",
|
||
" 2. [✔] Product code issue 2 lần",
|
||
" gift product_code_repository.go:57 — không FOR UPDATE",
|
||
" 3. [ ] Type something",
|
||
" Next",
|
||
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",
|
||
];
|
||
|
||
// Claude Code's folder-trust prompt: no numbers, its footer dim (left out).
|
||
const TRUST = [
|
||
RULE,
|
||
" Accessing workspace:",
|
||
"",
|
||
" /home/me",
|
||
"",
|
||
" Quick safety check: Is this a project you created or one you trust?",
|
||
"",
|
||
" Security guide",
|
||
"",
|
||
" ❯ No, exit",
|
||
" Yes, I trust this folder",
|
||
"",
|
||
"",
|
||
];
|
||
|
||
describe("MENU_CURSOR_ROW", () => {
|
||
// The chat's 700 ms poll reads the screen cell by cell only when a row
|
||
// matches it, so every menu parsePaneMenu finds must have such a row.
|
||
it("matches a row of every menu, and no row of an idle prompt", () => {
|
||
const has = (rows: string[]) => rows.some((r) => MENU_CURSOR_ROW.test(r));
|
||
for (const menu of [
|
||
ASK,
|
||
MULTI,
|
||
PERMISSION,
|
||
CODEX_APPROVAL,
|
||
[...TRUST, " Enter to confirm · Esc to cancel"],
|
||
]) {
|
||
expect(parsePaneMenu(menu)).not.toBeNull();
|
||
expect(has(menu)).toBe(true);
|
||
}
|
||
expect(has(CLAUDE)).toBe(false);
|
||
expect(has(CODEX)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("readRunBusy", () => {
|
||
it("reads each tool's own busy line, and nothing for a tool it does not know", () => {
|
||
let rows = ["✻ Thinking… (4s · ↓ 120 tokens · esc to interrupt)"];
|
||
const off = registerPane("agenthub-lane-8", () => {}, () => true, undefined, undefined, () => rows);
|
||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
|
||
// Claude's spinner with the footer's hint gone (a narrow pane, a tip).
|
||
rows = ["✶ Skedaddling… (41s · ↓ 2.8k tokens · thought for 2s)", " ⏵⏵ auto mode on"];
|
||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
|
||
rows = ["✻ Thinking… (1m 3s)"];
|
||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
|
||
// The line a finished turn leaves is not.
|
||
rows = ["✻ Cooked for 1m 2s", "❯ "];
|
||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
|
||
// Claude's permission footer is not a turn at work.
|
||
rows = ["Esc to cancel · Tab to amend"];
|
||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
|
||
expect(readRunBusy("agenthub-lane-8", "antigravity")).toBe(false);
|
||
rows = ["Thinking… (3s · esc to cancel)"];
|
||
expect(readRunBusy("agenthub-lane-8", "antigravity")).toBe(true);
|
||
expect(readRunBusy("agenthub-lane-8", "omp")).toBeNull();
|
||
off();
|
||
expect(readRunBusy("agenthub-lane-8", "claude")).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("Codex's pickers", () => {
|
||
it("reads /permissions with each row's description column as its hint", () => {
|
||
const menu = parsePaneMenu([
|
||
" >_ OpenAI Codex (v0.163.0)",
|
||
"",
|
||
" Update Model Permissions",
|
||
"",
|
||
"› 1. Ask for approval (current) Read and edit workspace files and run commands, with approval req",
|
||
" access or edits outside the workspace",
|
||
" 2. Approve for me Only ask for actions detected as potentially unsafe",
|
||
" 3. Full Access Use with caution: Codex can edit files",
|
||
" without approval",
|
||
" 4. Read Only Read workspace files",
|
||
"",
|
||
" enter select · esc back",
|
||
]);
|
||
expect(menu?.question).toBe("Update Model Permissions");
|
||
expect(menu?.cursor).toBe(0);
|
||
expect(menu?.options.map((o) => [o.n, o.label, o.hint])).toEqual([
|
||
[
|
||
1,
|
||
"Ask for approval (current)",
|
||
"Read and edit workspace files and run commands, with approval req access or edits outside the workspace",
|
||
],
|
||
[2, "Approve for me", "Only ask for actions detected as potentially unsafe"],
|
||
[3, "Full Access", "Use with caution: Codex can edit files without approval"],
|
||
[4, "Read Only", "Read workspace files"],
|
||
]);
|
||
});
|
||
});
|