Files
AgentHub/client/src/lib/panePaste.ts
T
nntrivi2001 1c5521cf2e perf: rest animations, cheaper chat typing and server polls, async per-tool hooks
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>
2026-10-14 11:55:53 +07:00

300 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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;
}