feat(chat): terminal chat parity for every tool; Desktop-like artifacts

- The chat over a terminal pane now covers Claude, Codex, omp, Cursor and
  Antigravity: Cursor's agent transcript and agy's step log
  (brain/<id>/.system_generated/logs/transcript_full.jsonl) are read as
  conversations; Cursor sessions are claimed by their workspace
- Replies appear as soon as they are written: the projects-tree watcher
  broadcasts `transcript_changed` (at most every 250 ms per session) and the
  chat re-reads the transcript while the terminal underneath draws
- Stop shows from the pane's own busy line (Claude/Codex `esc to interrupt`,
  agy `… esc to cancel`), or fresh output for omp/Cursor — no hooks needed
- Permission prompts read off the terminal become a card with the tool, its
  command and "Deny with a note"; Codex gets Permission mode / Model buttons
  that open its own numbered pickers, answered from the menu card
- Artifacts: cards for pages/documents/diagrams/tables/React components, a
  Preview | Code panel with versions, copy, download, save to the Artifacts
  Hub, full screen; sandboxed iframes without allow-same-origin
- Claude panes get CLAUDE_CODE_ENABLE_TODO_TOOLS=1 so the Progress panel has a
  plan; multi-question pane menus read their Next row and question tabs
- The realtime chat bridge (structured stdio, /api/chat-bridge, /ws-chat)
  from the unreleased ad9d475 is not kept
This commit is contained in:
2026-10-21 15:44:17 +07:00
parent aee4fc190e
commit 383b9ae231
41 changed files with 2582 additions and 426 deletions
+115 -11
View File
@@ -34,20 +34,24 @@ interface Pane {
key?: (data: string) => void;
/** `menuOnly`: null at once unless a row could be a menu's cursor row. */
screen?: (menuOnly?: boolean) => PaneScreen | null;
/** The rows on screen as plain text, dim cells kept. */
lines?: () => string[];
}
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. */
* raw input, as a key press; `screen`: its rows now; `lines`: the same as
* plain text); 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
screen?: (menuOnly?: boolean) => PaneScreen | null,
lines?: () => string[]
): () => void {
const entry = { paste, visible, key, screen };
const entry = { paste, visible, key, screen, lines };
panes.set(runId, entry);
return () => {
if (panes.get(runId) === entry) panes.delete(runId);
@@ -59,6 +63,26 @@ export function notePaneFocus(runId: string): void {
lastFocused = runId;
}
/** Who wants to hear that a pane drew something (its chat: the CLI is doing
* something, so its transcript may have grown). */
const outputListeners = new Map<string, Set<() => void>>();
/** A terminal received output for `runId` (TerminalView, every PTY frame). */
export function notePaneOutput(runId: string): void {
outputListeners.get(runId)?.forEach((fn) => fn());
}
/** Calls `fn` on every output `runId`'s terminal receives; returns its unregister. */
export function onPaneOutput(runId: string, fn: () => void): () => void {
let set = outputListeners.get(runId);
if (!set) outputListeners.set(runId, (set = new Set()));
set.add(fn);
return () => {
set!.delete(fn);
if (!set!.size) outputListeners.delete(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}`;
@@ -162,32 +186,47 @@ export function takeRunInput(runId: string): string {
}
/** One choice of a menu the CLI shows: `n` its number (null for Claude's
* unnumbered Submit row), `checked` a multiSelect box's state. */
* unnumbered Submit / Next row), `checked` a multiSelect box's state. */
export type PaneMenuOption = {
n: number | null;
label: string;
hint: string;
checked: boolean | null;
};
/** One question tab of an AskUserQuestion that asks several: `done` once it
* has an answer (`☒`). */
export type PaneMenuTab = { label: string; done: boolean };
/** 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). */
* "Type something" row (-1: none). `tabs` only when it asks several
* questions (`← ☒ A □ B ✔ Submit →`), `tab` the one shown (-1: unknown).
* `detail` what a permission prompt asks about (its tool, then the command),
* when the screen shows it apart from the question. */
export type PaneMenu = {
question: string;
options: PaneMenuOption[];
cursor: number;
multi: boolean;
typeAt: number;
tabs?: PaneMenuTab[];
tab?: number;
detail?: string[];
};
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/;
/** A row only a menu shows: its cursor on a numbered option or Submit, or the
* "Enter to confirm" footer of Claude's unnumbered menus (the trust prompt). */
export const MENU_CURSOR_ROW = /^ *(?:[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)|Enter to confirm)/;
const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/;
export const MENU_CURSOR_ROW =
/^ *(?:[❯›][ \u00a0]* *(?:\d{1,2}\. |(?:Submit|Next)\s*$)|Enter to confirm)/;
// The unnumbered button under a multiSelect's rows: Next while more questions
// follow, Submit on the last one.
const SUBMIT = /^ *([❯›])?[ \u00a0]*(Submit|Next)$/;
const TAB_BAR = /^ *← +(.+?) +→ *$/;
const RULE_ROW = /^ *[─╌]{3,} *$/;
const DASHED_ROW = /^ *╌{3,} *$/;
const CHECK = /^\[([ ✔])\] *(.*)$/;
const PLACEHOLDER = /^Type something\.?$/;
const COLUMN = /^(\S.*?\S) {2,}(\S.*)$/;
/** 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
@@ -207,10 +246,13 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
const m = OPTION.exec(row);
const s = m ? null : SUBMIT.exec(row);
if (m || s) {
const text = m ? (m[3] ?? "") : "Submit";
const text = m ? (m[3] ?? "") : (s?.[2] ?? "Submit");
const c = m ? CHECK.exec(text) : null;
const label = c ? (c[2] ?? "") : text;
const h = hint.join(" ");
// Codex's pickers (`/model`, `/permissions`) draw each row's
// description in a column after the name: split it off as the hint.
const col = COLUMN.exec(c ? (c[2] ?? "") : text);
const label = col ? col[1]! : c ? (c[2] ?? "") : text;
const h = [col?.[2], ...hint].filter(Boolean).join(" ");
options.unshift({
n: m ? Number(m[2]) : null,
label,
@@ -239,15 +281,47 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
const lines: string[] = [];
for (; q >= 0 && r[q] && !RULE_ROW.test(r[q]!) && lines.length < 4; q--)
lines.unshift(r[q]!.trim());
const question = lines.join("\n");
const tabs = tabBar(r.slice(Math.max(0, q - 4), Math.max(0, q + 1)));
const low = question.toLowerCase();
// What a permission prompt asks about: Claude draws the tool and its
// command over a dashed rule above the question, up to the dialog's top rule.
const detail: string[] = [];
let d = q;
while (d >= 0 && !r[d]) d--;
if (d >= 0 && DASHED_ROW.test(r[d]!))
for (d--; d >= 0 && !RULE_ROW.test(r[d]!) && detail.length < 12; d--)
if (r[d]) detail.unshift(r[d]!.trim());
return {
question: lines.join("\n"),
question,
options,
cursor: options.length - cursor,
multi: options.some((o) => o.checked !== null),
typeAt: typeRow(options),
...(tabs.length > 1
? { tabs, tab: tabs.findIndex((t) => low.startsWith(t.label.toLowerCase())) }
: {}),
...(detail.length ? { detail } : {}),
};
}
/** The question tabs of a multi-question AskUserQuestion, read off its bar
* (`← ☒ Critical □ High 1/3 ✔ Submit →`) among `rows`; the Submit tab is
* no question. [] when there is no bar. */
function tabBar(rows: string[]): PaneMenuTab[] {
for (let i = rows.length - 1; i >= 0; i--) {
const m = TAB_BAR.exec(rows[i] ?? "");
if (!m) continue;
return (m[1] ?? "")
.split(/(?=[☒☐□✔☑■] )/)
.map((x) => x.trim())
.filter(Boolean)
.map((x) => ({ label: x.slice(1).trim(), done: /^[☒✔☑■]/.test(x) }))
.filter((t) => t.label && t.label !== "Submit");
}
return [];
}
const BARE_CURSOR = /^( {0,4})❯[ \u00a0]+(\S.*)$/;
/** Claude's UNNUMBERED menu at the bottom of `r` (the folder-trust prompt:
@@ -291,6 +365,26 @@ function typeRow(options: PaneMenuOption[]): number {
return -1;
}
/** The status line a tool's TUI shows only while a turn runs, read off its
* binary's own strings: Claude's and Codex's `esc to interrupt`, agy's
* `Thinking… (3s · esc to cancel)` — in parentheses after an ellipsis, so
* Claude's permission footer (`Esc to cancel · Tab to amend`) is not it. A
* tool missing here (omp, Cursor) has no line we know. */
export const BUSY_LINE: Readonly<Record<string, RegExp>> = {
claude: /\besc to interrupt\b/i,
codex: /\besc to interrupt\b/i,
antigravity: /…\s*\([^)]*\besc to cancel\)/i,
};
/** Whether `runId`'s pane shows `tool` at work now (its BUSY_LINE), or null
* when the tool's line is unknown or the pane is not mounted. */
export function readRunBusy(runId: string, tool: string): boolean | null {
const re = BUSY_LINE[tool];
const p = panes.get(runId);
if (!re || !p?.lines) return null;
return p.lines().some((l) => re.test(l));
}
/** The menu `runId`'s visible pane shows now, or null. */
export function readRunMenu(runId: string): PaneMenu | null {
const p = panes.get(runId);
@@ -322,6 +416,16 @@ export function chooseRunOption(runId: string, menu: PaneMenu, index: number): b
return true;
}
/** Moves a multi-question AskUserQuestion to its previous (-1) or next (1)
* question tab, the arrows Claude's footer names. False when the pane is not
* visible or not attached. */
export function stepRunMenu(runId: string, dir: -1 | 1): boolean {
const p = panes.get(runId);
if (!p?.visible() || !p.key) return false;
p.key(dir < 0 ? "\x1b[D" : "\x1b[C");
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