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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user