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:
+115
-11
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user