/** * @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ĩ */ 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 = {}; 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"], ]); }); });