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:
@@ -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-->B<img>"
|
||||
);
|
||||
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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user