1c5521cf2e
UI
- No animation runs forever: Sagi moods, the custom pet sprite, status dots
(pulse-dot, pulse-slow, ping) and the chat shimmer play a few cycles and
rest. An infinite animation kept the browser drawing 60 frames a second.
- useSagiBrain: frames that only move lastActivityAt by <1 s keep the old
state; the 1 s tick re-renders only when the mood changes.
- ConversationView and MessageList are memoized and PaneChat passes stable
callbacks: typing in the chat box no longer re-renders the conversation.
- The chat's 700 ms menu poll reads plain text first and scans the terminal
cell by cell (reusing one cell) only when a row could be a menu cursor row.
Server
- GET /sessions/:id/transcript?after=N skips lines <= N before JSON.parse.
- GET /sessions/:id/outline is cached until the session's events change.
- broadcastLane uses the cached tmux.liveSessions(); tmux.hasSession answers
"yes" from the fresh shared list instead of a synchronous spawn (50-150 ms
per psmux call on Windows, blocking terminal echo).
- pane-mode polls every 150 ms; writeHourly writes only changed hour buckets.
- /api/metrics: agenthub_event_loop_delay_seconds{quantile}.
Hooks
- PreToolUse/PostToolUse run with "async": true (settings installer,
plugin.json, S3 managed settings): Claude Code no longer waits a Node start
before and after every tool call.
Committed with --no-verify at the owner's request: the pre-commit full server
suite fails on 9 tests that fail identically on 0c2f498 (lane-sync merge: no
git identity on this host; read-pool; resolveHost; codex-usage 1 ms flake).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
300 lines
12 KiB
TypeScript
300 lines
12 KiB
TypeScript
/**
|
||
* @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ĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
/** 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<string, Pane>();
|
||
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-<id>[-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<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;
|
||
}
|