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
@@ -0,0 +1,102 @@
/**
* @file artifactView.test.ts
* @description What counts as an artifact and how it is shown: kinds by
* name, a React file only when written whole, a file's versions rebuilt from
* the conversation's Write/Edit/MultiEdit inputs (a failed call skipped, an
* edit to unseen text unknown), CSV rows, and the sandboxed documents.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import {
artifactDocument,
artifactKind,
artifactVersions,
frameSandbox,
isArtifact,
parseCsv,
} from "../artifactView";
import type { TranscriptMessage } from "../types";
const use = (id: string, name: string, input: Record<string, unknown>): TranscriptMessage => ({
type: "assistant",
timestamp: `2026-10-21T00:00:0${id}.000Z`,
content: [{ type: "tool_use", id, name, input } as TranscriptMessage["content"][number]],
});
const result = (id: string, isError = false): TranscriptMessage => ({
type: "user",
sender: "tool",
timestamp: null,
content: [
{
type: "tool_result",
id,
output: "",
is_error: isError,
} as TranscriptMessage["content"][number],
],
});
describe("artifactView", () => {
it("knows an artifact by its name", () => {
expect(artifactKind("/a/page.HTML")).toBe("html");
expect(artifactKind("d.mmd")).toBe("mermaid");
expect(artifactKind("App.tsx")).toBe("react");
expect(artifactKind("x.go")).toBe("code");
expect(isArtifact("report.md", false)).toBe(true);
expect(isArtifact("App.tsx", false)).toBe(false);
expect(isArtifact("App.tsx", true)).toBe(true);
expect(isArtifact("server.js", true)).toBe(false);
});
it("rebuilds a file's versions from Write, Edit and MultiEdit", () => {
const msgs = [
use("1", "Write", { file_path: "/w/a.md", content: "# A\nold line\n" }),
result("1"),
use("2", "Edit", { file_path: "/w/a.md", old_string: "old", new_string: "new" }),
result("2"),
use("3", "Edit", { file_path: "/w/a.md", old_string: "missing", new_string: "x" }),
result("3", true),
use("4", "MultiEdit", {
file_path: "/w/a.md",
edits: [{ old_string: "# A", new_string: "# B" }],
}),
result("4"),
use("5", "Write", { file_path: "/w/other.md", content: "no" }),
];
const v = artifactVersions(msgs, "/w/a.md");
expect(v.map((x) => x.content)).toEqual([
"# A\nold line\n",
"# A\nnew line\n",
"# B\nnew line\n",
]);
});
it("says an edit to text it never saw is of unknown content", () => {
const v = artifactVersions(
[use("1", "Edit", { file_path: "a.md", old_string: "a", new_string: "b" })],
"a.md"
);
expect(v).toHaveLength(1);
expect(v[0]!.content).toBeNull();
});
it("reads CSV with quotes and newlines inside them", () => {
expect(parseCsv('a,b\n"x, y","say ""hi""\nthere"\n')).toEqual([
["a", "b"],
["x, y", 'say "hi"\nthere'],
]);
});
it("builds sandboxed documents that keep the source inert inside a script", () => {
const doc = artifactDocument("react", "export default () => <p>a</script>b</p>", false)!;
expect(doc).toContain("babel");
expect(doc).not.toContain("a</script>b");
expect(artifactDocument("mermaid", "graph TD; A-->B<img>", true)).toContain(
"A--&gt;B&lt;img&gt;"
);
expect(artifactDocument("code", "x", false)).toBeNull();
expect(frameSandbox("svg")).toBe("");
expect(frameSandbox("html")).not.toContain("allow-same-origin");
});
});
@@ -21,6 +21,7 @@ import {
chooseRunOption,
parsePaneMenu,
pasteToLane,
readRunBusy,
readRunMenu,
registerPane,
takeRunInput,
@@ -160,6 +161,33 @@ describe("panePaste", () => {
expect(m.typeAt).toBe(2);
});
// Several questions: the unnumbered row is Next until the last one, and the
// tab bar says which questions have an answer.
it("reads a multi-question multiSelect with its Next row and question tabs", () => {
const m = parsePaneMenu(MULTI_Q)!;
expect(m.options.map((o) => [o.n, o.label, o.checked])).toEqual([
[1, "JWT HS256 + default secret", true],
[2, "Product code issue 2 lần", true],
[3, "Type something", false],
[null, "Next", null],
[4, "Chat about this", null],
]);
expect(m.options[2]!.hint).toBe("");
expect(m.cursor).toBe(0);
expect(m.tabs).toEqual([
{ label: "Critical", done: true },
{ label: "High 1/3", done: false },
{ label: "High 2/3", done: false },
{ label: "High 3/3", done: false },
]);
expect(m.tab).toBe(0);
expect(MENU_CURSOR_ROW.test("❯ Next")).toBe(true);
});
it("reads no tabs for a single question", () => {
expect(parsePaneMenu(ASK)!.tabs).toBeUndefined();
});
// The "Type something" row once something is typed in it: its label is the
// text. It is still the row right above "Chat about this".
it("finds Type something by its place once text is typed in it, and replaces that text", () => {
@@ -188,8 +216,11 @@ describe("panePaste", () => {
const c = parsePaneMenu(PERMISSION)!;
expect(c.question).toBe("Do you want to proceed?");
expect(c.options.map((o) => o.label)).toEqual(["Yes", "Yes, and always allow", "No"]);
// The tool and its command, drawn over the dashed rule.
expect(c.detail).toEqual(["Bash command", "touch probe-file.txt"]);
const x = parsePaneMenu(CODEX_APPROVAL)!;
expect(x.question).toBe("Would you like to run the following command?\n$ touch probe.txt");
expect(x.detail).toBeUndefined();
expect(x.options).toHaveLength(3);
expect(x.cursor).toBe(0);
});
@@ -317,6 +348,23 @@ const MULTI = [
"",
"Enter to select · Tab/Arrow keys to navigate · Esc to cancel",
];
const MULTI_Q = [
RULE,
"← ☒ Critical □ High 1/3 □ High 2/3 □ High 3/3 ✔ Submit →",
"",
"Critical (đã verify trong code). Chọn issue để log BUG:",
"",
"❯ 1. [✔] JWT HS256 + default secret",
" pkg/middleware/auth.go:156,184 — HMAC only",
" 2. [✔] Product code issue 2 lần",
" gift product_code_repository.go:57 — không FOR UPDATE",
" 3. [ ] Type something",
" Next",
RULE,
" 4. Chat about this",
"",
"Enter to select · Tab/Arrow keys to navigate · Esc to cancel",
];
const PERMISSION = [
" Bash command",
" touch probe-file.txt",
@@ -378,3 +426,51 @@ describe("MENU_CURSOR_ROW", () => {
expect(has(CODEX)).toBe(false);
});
});
describe("readRunBusy", () => {
it("reads each tool's own busy line, and nothing for a tool it does not know", () => {
let rows = ["✻ Thinking… (4s · ↓ 120 tokens · esc to interrupt)"];
const off = registerPane("agenthub-lane-8", () => {}, () => true, undefined, undefined, () => rows);
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
// Claude's permission footer is not a turn at work.
rows = ["Esc to cancel · Tab to amend"];
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
expect(readRunBusy("agenthub-lane-8", "antigravity")).toBe(false);
rows = ["Thinking… (3s · esc to cancel)"];
expect(readRunBusy("agenthub-lane-8", "antigravity")).toBe(true);
expect(readRunBusy("agenthub-lane-8", "omp")).toBeNull();
off();
expect(readRunBusy("agenthub-lane-8", "claude")).toBeNull();
});
});
describe("Codex's pickers", () => {
it("reads /permissions with each row's description column as its hint", () => {
const menu = parsePaneMenu([
" >_ OpenAI Codex (v0.163.0)",
"",
" Update Model Permissions",
"",
"› 1. Ask for approval (current) Read and edit workspace files and run commands, with approval req",
" access or edits outside the workspace",
" 2. Approve for me Only ask for actions detected as potentially unsafe",
" 3. Full Access Use with caution: Codex can edit files",
" without approval",
" 4. Read Only Read workspace files",
"",
" enter select · esc back",
]);
expect(menu?.question).toBe("Update Model Permissions");
expect(menu?.cursor).toBe(0);
expect(menu?.options.map((o) => [o.n, o.label, o.hint])).toEqual([
[
1,
"Ask for approval (current)",
"Read and edit workspace files and run commands, with approval req access or edits outside the workspace",
],
[2, "Approve for me", "Only ask for actions detected as potentially unsafe"],
[3, "Full Access", "Use with caution: Codex can edit files without approval"],
[4, "Read Only", "Read workspace files"],
]);
});
});
+227
View File
@@ -0,0 +1,227 @@
/**
* @file artifactView.ts
* @description What makes a file an artifact, Claude Desktop's way, and how
* one is shown: its kind from its name (a page, an image, a diagram, a React
* component, a document, a table, code), the sandboxed document an HTML,
* SVG, Mermaid or React artifact runs in (an iframe `srcdoc` with no
* same-origin access, its libraries from a pinned CDN build, as Claude's own
* artifacts load theirs), a CSV's rows, and the versions a conversation's
* Write/Edit calls left of one file — rebuilt from their own inputs, so no
* server keeps copies.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { TranscriptMessage } from "./types";
export type ArtifactKind =
"html" | "svg" | "markdown" | "mermaid" | "react" | "csv" | "image" | "code";
const EXT = (p: string) =>
(/\.([A-Za-z0-9]+)$/.exec(p.split(/[\\/]/).pop() || "")?.[1] || "").toLowerCase();
/** The kind a file shows as. */
export function artifactKind(path: string): ArtifactKind {
const e = EXT(path);
if (e === "html" || e === "htm") return "html";
if (e === "svg") return "svg";
if (e === "md" || e === "markdown" || e === "mdx") return "markdown";
if (e === "mmd" || e === "mermaid") return "mermaid";
if (e === "jsx" || e === "tsx") return "react";
if (e === "csv" || e === "tsv") return "csv";
if (["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico"].includes(e)) return "image";
return "code";
}
/** Kinds with a rendered Preview (the rest show their code only). */
export const RENDERED: ReadonlySet<ArtifactKind> = new Set([
"html",
"svg",
"markdown",
"mermaid",
"react",
"csv",
"image",
]);
/** An edit worth a card in the conversation: a page, a document, a diagram, a
* table — and a React component the model wrote whole (a `.tsx` it merely
* edited is project code, not an artifact). */
export function isArtifact(path: string, created: boolean): boolean {
const k = artifactKind(path);
if (k === "react") return created;
return k === "html" || k === "svg" || k === "markdown" || k === "mermaid" || k === "csv";
}
/** A code fence language for the Code tab. */
export function artifactLang(path: string): string {
const e = EXT(path);
const map: Record<string, string> = {
htm: "html",
mmd: "text",
mermaid: "text",
md: "markdown",
mdx: "markdown",
jsx: "jsx",
tsx: "tsx",
js: "javascript",
mjs: "javascript",
cjs: "javascript",
ts: "typescript",
py: "python",
sh: "bash",
yml: "yaml",
};
return map[e] || e;
}
// Pinned builds (all on npm for well over two weeks), as Claude's own
// artifacts load React, Tailwind and Mermaid from a CDN.
const CDN = {
react: "https://cdn.jsdelivr.net/npm/react@18.3.1/umd/react.production.min.js",
reactDom: "https://cdn.jsdelivr.net/npm/react-dom@18.3.1/umd/react-dom.production.min.js",
babel: "https://cdn.jsdelivr.net/npm/@babel/standalone@7.26.4/babel.min.js",
tailwind: "https://cdn.tailwindcss.com/3.4.16",
mermaid: "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs",
};
/** Text safe inside a `<script>` element. */
const scriptSafe = (s: string) => s.replace(/<\/(script)/gi, "<\\/$1");
const htmlEscape = (s: string) =>
s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
/** Shown inside the frame when the artifact itself fails. */
const ERROR_BOX = `<pre id="__err" style="display:none;margin:16px;padding:12px;border-radius:8px;background:#fee2e2;color:#991b1b;font:12px/1.5 ui-monospace,monospace;white-space:pre-wrap"></pre>
<script>window.addEventListener("error",function(e){var b=document.getElementById("__err");if(b){b.style.display="block";b.textContent=String(e.message||e.error||e);}});</script>`;
/** The document a rendered artifact runs in (null: it is not a frame kind). */
export function artifactDocument(kind: ArtifactKind, source: string, dark: boolean): string | null {
switch (kind) {
case "html":
return /<html[\s>]|<!doctype/i.test(source)
? source
: `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body>${source}</body></html>`;
case "svg":
return `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%}body{display:flex;align-items:center;justify-content:center;background:${dark ? "#1f1f1f" : "#fff"}}svg{max-width:100%;max-height:100vh;height:auto}</style></head><body>${source}</body></html>`;
case "mermaid":
return `<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;padding:16px;background:${dark ? "#1f1f1f" : "#fff"};display:flex;justify-content:center}</style></head><body>${ERROR_BOX}<pre class="mermaid">${htmlEscape(source)}</pre>
<script type="module">import mermaid from "${CDN.mermaid}";mermaid.initialize({startOnLoad:false,theme:${dark ? '"dark"' : '"default"'},securityLevel:"strict"});mermaid.run().catch(function(e){var b=document.getElementById("__err");b.style.display="block";b.textContent=String(e&&e.message||e);});</script></body></html>`;
case "react":
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<script src="${CDN.react}"></script><script src="${CDN.reactDom}"></script><script src="${CDN.babel}"></script><script src="${CDN.tailwind}"></script></head>
<body><div id="root"></div>${ERROR_BOX}
<script>
(function(){
var src = ${JSON.stringify(scriptSafe(source))};
var mods = { react: window.React, "react-dom": window.ReactDOM, "react-dom/client": window.ReactDOM };
function req(name){ if (mods[name]) return mods[name]; throw new Error("This preview does not provide \\"" + name + "\\" — only react and react-dom are available."); }
try {
var out = Babel.transform(src, { filename: "Artifact.tsx", presets: [["typescript", { isTSX: true, allExtensions: true }], "react"], plugins: ["transform-modules-commonjs"] }).code;
var module = { exports: {} };
new Function("require", "module", "exports", "React", out)(req, module, module.exports, window.React);
var C = module.exports.default || module.exports.App || Object.values(module.exports).find(function(v){ return typeof v === "function"; });
if (!C) throw new Error("No component exported: export a default React component.");
ReactDOM.createRoot(document.getElementById("root")).render(React.createElement(C));
} catch (e) { var b = document.getElementById("__err"); b.style.display = "block"; b.textContent = String(e && e.message || e); }
})();
</script></body></html>`;
default:
return null;
}
}
/** The sandbox a frame kind gets: scripts but never the dashboard's origin
* (no `allow-same-origin`), and none at all for an SVG. */
export const frameSandbox = (kind: ArtifactKind) =>
kind === "svg" ? "" : "allow-scripts allow-forms allow-modals allow-popups allow-downloads";
/** A CSV/TSV's rows (quoted fields, doubled quotes, newlines inside quotes). */
export function parseCsv(text: string, sep = ","): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let quoted = false;
for (let i = 0; i < text.length; i++) {
const c = text[i]!;
if (quoted) {
if (c === '"' && text[i + 1] === '"') {
field += '"';
i++;
} else if (c === '"') quoted = false;
else field += c;
} else if (c === '"' && field === "") quoted = true;
else if (c === sep) {
row.push(field);
field = "";
} else if (c === "\n" || c === "\r") {
if (c === "\r" && text[i + 1] === "\n") i++;
row.push(field);
rows.push(row);
row = [];
field = "";
} else field += c;
}
if (field !== "" || row.length) {
row.push(field);
rows.push(row);
}
return rows;
}
/** One version of a file, as a Write/Edit call left it. */
export interface ArtifactVersion {
/** The call that made it. */
toolUseId: string | null;
timestamp: string | null;
/** null: the call is known, its result text is not (an edit to a file
* whose earlier text the conversation never showed). */
content: string | null;
}
const samePath = (a: unknown, b: string) =>
typeof a === "string" && a.replace(/\\/g, "/") === b.replace(/\\/g, "/");
/** Applies one Edit (old → new, once or everywhere) to known text. */
function applyEdit(text: string, oldS: unknown, newS: unknown, all: unknown): string | null {
if (typeof oldS !== "string" || typeof newS !== "string") return null;
if (!text.includes(oldS)) return null;
return all ? text.split(oldS).join(newS) : text.replace(oldS, () => newS);
}
/**
* The versions of `path` in a conversation, oldest first: each successful
* Write gives its whole text, and each Edit/MultiEdit after a known text gives
* that text with its edits applied. A call whose input the transcript cut, or
* an edit to text never shown, is a version of unknown content (the current
* file on disk stands in for the newest one).
*/
export function artifactVersions(messages: TranscriptMessage[], path: string): ArtifactVersion[] {
const failed = new Set<string>();
for (const m of messages)
for (const c of m.content) if (c.type === "tool_result" && c.is_error && c.id) failed.add(c.id);
const out: ArtifactVersion[] = [];
let text: string | null = null;
for (const m of messages) {
if (m.type !== "assistant") continue;
for (const c of m.content) {
if (c.type !== "tool_use" || (c.id && failed.has(c.id))) continue;
const input = (c.input || {}) as Record<string, unknown>;
if (!samePath(input.file_path, path)) continue;
if (c.name === "Write") {
text = typeof input.content === "string" ? input.content : null;
} else if (c.name === "Edit") {
text =
text === null
? null
: applyEdit(text, input.old_string, input.new_string, input.replace_all);
} else if (c.name === "MultiEdit") {
const edits = Array.isArray(input.edits) ? (input.edits as Record<string, unknown>[]) : [];
for (const e of edits) {
if (text === null) break;
text = applyEdit(text, e.old_string, e.new_string, e.replace_all);
}
} else continue;
out.push({ toolUseId: c.id ?? null, timestamp: m.timestamp, content: text });
}
}
return out;
}
+16 -2
View File
@@ -1,6 +1,7 @@
/**
* @file chatUi.ts — whether a Claude/Codex session shows as a chat
* (`PaneChat`, like Claude Desktop) or as its terminal. Each SESSION has its
* @file chatUi.ts — which tools' terminal panes have a Chat view
* (`CHAT_TOOLS`), and whether such a session shows as a chat (`PaneChat`,
* like Claude Desktop) or as its terminal. Each SESSION has its
* own view (`useSessionChat`, toggled by `setSessionChat` from the pane's
* chat/terminal button), kept in this browser's localStorage
* (`agenthub.chatUi.sessions`, the newest `MAX_VIEWS`). The account's Chat UI
@@ -18,6 +19,19 @@ import { api } from "./api";
export const CHAT_UI_KEY = "agenthub.chatUi";
/** The tools whose terminal pane can show a chat: each one's pane learns its
* session id (Claude's `--session-id`, the others' pollers through
* `claimToolSessions`) and the server reads that session's conversation
* (`GET /api/sessions/:id/transcript`). omp and Cursor send no hooks, so
* their chat has no Stop button and follows at the pollers' pace. */
export const CHAT_TOOLS: ReadonlySet<string> = new Set([
"claude",
"codex",
"omp",
"cursor",
"antigravity",
]);
export const VIEWS_KEY = "agenthub.chatUi.sessions";
const MAX_VIEWS = 200;
+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
+4 -1
View File
@@ -1930,7 +1930,10 @@ export interface WSMessage {
/** A pane ended; refetch GET /api/run/interrupted (no payload). */
| "runs_interrupted"
/** An artifact was added, changed, shared or deleted; refetch the list. */
| "artifact_changed";
| "artifact_changed"
/** A session's transcript grew on disk (`{session_id}`); a chat showing
* it reads the new lines now. */
| "transcript_changed";
/** The message body, whose concrete shape is selected by `type` above. */
data:
| Session