/** * @file panePaste.ts — lets another part of the Workspace page paste text into * a workspace's terminal (a diff-line comment from the Changes panel). Every * mounted TerminalView registers its run id; the text goes to the pane of that * workspace focused last — clicked into, or made the Workspace's focused pane * (a tab, Alt+1–4, the sidebar) — else any of its panes. It is a paste — * bracketed, so `claude` takes it as one block in its composer — and never an * Enter: the user reads it and sends it. Only a pane on SCREEN takes it (one * hidden behind a full-width Changes panel or a replay would swallow it * unseen); the caller says where it went, or that it went nowhere. The one * exception is `submitToRun`: a pane's own Chat view (`PaneChat.tsx`) sends * what the user typed there, so it pastes AND presses Enter — and its Stop * button presses Esc through `interruptRun`. Switching a pane between Chat and * 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ĩ */ /** The terminal's screen: one string per row, dim cells (a placeholder) left out. */ export type PaneScreen = { rows: string[]; cols: number }; interface Pane { paste: (text: string, submit?: boolean) => void; visible: () => boolean; key?: (data: string) => void; /** `menuOnly`: null at once unless a row could be a menu's cursor row. */ screen?: (menuOnly?: boolean) => PaneScreen | null; } const panes = new Map(); let lastFocused: string | null = null; /** Registers a mounted terminal (`visible`: is it on screen now; `key`: send * raw input, as a key press; `screen`: its rows now); returns its unregister. */ export function registerPane( runId: string, paste: (text: string, submit?: boolean) => void, visible: () => boolean = () => true, key?: (data: string) => void, screen?: (menuOnly?: boolean) => PaneScreen | null ): () => void { const entry = { paste, visible, key, screen }; panes.set(runId, entry); return () => { if (panes.get(runId) === entry) panes.delete(runId); if (lastFocused === runId) lastFocused = null; }; } export function notePaneFocus(runId: string): void { lastFocused = runId; } /** The tmux names of workspace `laneId`'s panes: `agenthub-lane-[-slot]`. */ function ofLane(runId: string, laneId: number): boolean { const base = `agenthub-lane-${laneId}`; return runId === base || runId.startsWith(`${base}-`); } /** Pastes `text` into the pane of `runId` when it is mounted and on screen; * false otherwise (not mounted yet, or hidden). */ export function pasteToRun(runId: string, text: string): boolean { const p = panes.get(runId); if (!p?.visible()) return false; p.paste(text); return true; } /** Sends `text` as a message to the pane of `runId`: a paste, then Enter. * False when the pane is not mounted or not attached. */ export function submitToRun(runId: string, text: string): boolean { const p = panes.get(runId); if (!p?.visible()) return false; p.paste(text, true); return true; } /** Presses Esc in the pane of `runId` — how `claude` and `codex` stop the turn * they are running. False when the pane is not mounted or not attached. */ export function interruptRun(runId: string): boolean { const p = panes.get(runId); if (!p?.visible() || !p.key) return false; p.key("\x1b"); return true; } /** Pastes `text` into one of the workspace's VISIBLE panes — the one focused * last when it is — and returns its run id; null when none is on screen. */ export function pasteToLane(laneId: number, text: string): string | null { const ok = (id: string) => ofLane(id, laneId) && !!panes.get(id)?.visible(); const id = lastFocused && ok(lastFocused) ? lastFocused : [...panes.keys()].find(ok); if (!id) return null; panes.get(id)!.paste(text); return id; } const BORDER = /^─{3,}$/; /** The text in a `claude` or `codex` input box on `rows`, or null when no box * is shown (a menu, a permission prompt). Claude's box is the `❯` row under a * `─` rule, up to the next rule; Codex's the last `›` row, up to the blank row * above its footer. Both indent a continued row by two and wrap at word * boundaries, so a row whose first word would have fitted on the one above * starts a new line — a soft-wrapped row is joined back with its space. */ export function parsePromptInput(rows: string[], cols: number): string | null { const r = rows.map((l) => l.trimEnd()); let p = -1; let end = -1; for (let i = r.length - 1; i >= 0 && p < 0; i--) { const row = r[i] ?? ""; // Claude draws `❯` + a no-break space (U+00A0) in its box. if (/^❯([ \u00a0]|$)/.test(row) && BORDER.test(r[i - 1]?.trim() ?? "")) { p = i; end = r.findIndex((l, j) => j > i && BORDER.test(l.trim())); } else if (/^›( |$)/.test(row)) { p = i; let f = r.length - 1; while (f > i && !r[f]) f--; while (f > i && r[f]) f--; end = f > i ? f : -1; } } if (p < 0 || end < 0) return null; const lines = r.slice(p, end).map((l) => l.slice(2)); while (lines.length > 1 && !lines[lines.length - 1]) lines.pop(); const width = cols - 2; let out = ""; let prev: string | null = null; for (const line of lines) { const word = line.split(" ")[0] ?? ""; // The first row; a word longer than the row, broken mid-word; one that // would not fit after the row above, wrapped; else a typed newline. if (prev !== null) out += prev.length >= width ? "" : word && prev.length + 1 + word.length > width ? " " : "\n"; out += line; prev = line; } return out; } /** Moves the text out of `runId`'s input box: returns it and deletes it there * (Delete then Backspace, as many as it has characters — never Ctrl+C or * Esc, which stop a working turn). "" when no box can be read, it is empty, * or it holds what the screen cannot show back (`[Pasted text #1]`, * `[Image #1]`) — then it is left as it is. */ export function takeRunInput(runId: string): string { const p = panes.get(runId); const screen = p?.visible() && p.key ? p.screen?.() : null; const text = screen ? parsePromptInput(screen.rows, screen.cols) : null; if (!text?.trim() || /\[(Pasted text|Image) #\d+/.test(text)) return ""; const n = text.length + 1; 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})\. +(.*)$/; /** A row a menu's cursor sits on (a numbered option or Submit): a menu has one. */ export const MENU_CURSOR_ROW = /^ *[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)/; 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); // Polled every 700 ms while a chat shows: a screen with no cursor row is // answered from plain text, without reading it cell by cell. const screen = p?.visible() && p.key ? p.screen?.(true) : 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(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; }