/** * @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. * @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; screen?: () => 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?: () => 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; }