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
+18 -1
View File
@@ -17,7 +17,8 @@
* 5 cache read.
* The session's name and start come from its transcript
* (`brain/<id>/.system_generated/logs/transcript_full.jsonl`, first
* `USER_INPUT`). A file is re-read whole when its mtime (or its non-empty
* `USER_INPUT`), and the same file is the conversation view's
* (`transcriptPath`, read by `GET /api/sessions/:id/transcript`). A file is re-read whole when its mtime (or its non-empty
* `-wal`'s) changes — they are small — and the mtime per file lives in
* `antigravity_conversations`, so a session the user deleted stays deleted
* until agy writes to it again. Opening read-only still lets SQLite leave an
@@ -157,6 +158,21 @@ const transcriptFiles = (id) =>
return ["transcript_full.jsonl", "transcript.jsonl"].map((name) => path.join(dir, name));
});
/** The transcript of conversation `id` (this account's or a member's) the
* conversation view reads, or null. Only a UUID names one. */
function transcriptPath(id) {
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(id || "")))
return null;
for (const file of transcriptFiles(id)) {
try {
if (fs.statSync(file).isFile()) return file;
} catch {
/* try the next name */
}
}
return null;
}
/** The transcript's last write, 0 when it has none. */
function transcriptMtime(id) {
for (const file of transcriptFiles(id)) {
@@ -531,4 +547,5 @@ module.exports = {
readConversation,
ingestAntigravityUsage,
startAntigravityUsagePoll,
transcriptPath,
};
+16 -1
View File
@@ -18,7 +18,11 @@
* is resolved only when exactly such a path still exists, else left empty.
* A file is re-read when its mtime changes, and the mtime per file lives in
* `cursor_transcripts`, so a session the user deleted stays deleted until
* Cursor writes to it again. No transcript is ever stored as `transcript_path`.
* Cursor writes to it again. A session a dashboard pane started is claimed
* for that pane's workspace and run (`claimToolSessions`), which gives the
* pane its session id and so its Chat view. No transcript is ever stored as
* `transcript_path`; the conversation view finds the file through
* `transcriptPath` instead.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -26,6 +30,7 @@ const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { db, stmts } = require("../db");
const { claimToolSessions } = require("./dashboard-runs");
const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active
const FUTURE_SLACK_MS = 1000;
@@ -148,6 +153,14 @@ function transcriptFilesIn(cursorDir) {
}
const getSeen = db.prepare("SELECT * FROM cursor_transcripts WHERE path = ?");
const getPathOf = db.prepare(
"SELECT path FROM cursor_transcripts WHERE session_id = ? ORDER BY mtime_ms DESC LIMIT 1"
);
/** The transcript a Cursor session was read from (recorded by the poller), or null. */
function transcriptPath(sessionId) {
return getPathOf.get(sessionId)?.path || null;
}
const upsertSeen = db.prepare(
`INSERT INTO cursor_transcripts (path, session_id, mtime_ms, updated_at)
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
@@ -235,6 +248,7 @@ function ingestCursorUsage({ broadcast = () => {}, now = Date.now() } = {}) {
upsertSeen.run(f.path, f.id, f.mtime);
if (type) {
written += 1;
claimToolSessions(f.id); // its workspace and live pane, before the broadcast
broadcast(type, stmts.getSession.get(f.id));
}
} catch (err) {
@@ -273,4 +287,5 @@ module.exports = {
firstPrompt,
ingestCursorUsage,
startCursorUsagePoll,
transcriptPath,
};
+4 -2
View File
@@ -90,7 +90,7 @@ function bindable(v) {
*/
/** A run row as it may leave this machine: never a pane credential's hash. */
function exportableRun(row) {
const { pane_token_hash: _secret, ...rest } = row;
const { pane_token_hash: _secret, chat_token_hash: _chat, ...rest } = row;
return rest;
}
@@ -185,7 +185,9 @@ function importExportBundle(db, bundle) {
events: makeInserter(db, "events", { omit: ["id"], wrap: { data: "trim_event_data" } }),
token_usage: makeInserter(db, "token_usage"),
workflows: makeInserter(db, "workflows"),
dashboard_runs: makeInserter(db, "dashboard_runs", { omit: ["pane_token_hash"] }),
dashboard_runs: makeInserter(db, "dashboard_runs", {
omit: ["pane_token_hash", "chat_token_hash"],
}),
alert_rules: makeInserter(db, "alert_rules"),
model_pricing: makeInserter(db, "model_pricing"),
};
+5
View File
@@ -640,6 +640,11 @@ function spawnRun(args) {
...(entry.launcher === "claude" && systemPromptFile
? { CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: "1" }
: {}),
// Claude Code hides TaskCreate/TaskUpdate from its newer models unless
// this is set, and those calls are all the chat Progress panel draws
// (routes/sessions.js sessionOutline). A user's settings.json `env` still
// wins, so `"0"` there turns them back off.
...(entry.launcher === "claude" ? { CLAUDE_CODE_ENABLE_TODO_TOOLS: "1" } : {}),
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
// pushes lines into history its redraw cannot clear). The user's own
+1
View File
@@ -288,6 +288,7 @@ function broadcastAudience(type, data) {
case "agent_updated":
case "new_event":
case "workflow_upserted":
case "transcript_changed":
return { owners: sessionOwners(d.session_id) };
case "alert_triggered":
case "alert_updated":
+324
View File
@@ -0,0 +1,324 @@
/**
* @file Turns one Claude Code transcript line (or a stream-json event shaped
* like one) into the conversation message the chat renders: sender, text,
* thinking, tool calls and results (with an edit's +/- counts, an
* AskUserQuestion's answers, a subagent's facts), model, usage. Shared by the
* transcript route and the realtime chat runtime so both draw the same thing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/**
* Classify the TRUE sender of a transcript entry. A JSONL `type:"user"` line is
* not always the human: it also carries tool results, harness-injected
* task-notifications, /loop re-injections (`isMeta`), and — in a subagent
* transcript — the task prompt handed down by the orchestrator. Attributing all
* of those to "User" is wrong; the UI styles each sender distinctly.
*
* Returns: "user" | "assistant" | "orchestrator" | "system" | "tool".
* user — a real message typed by the human
* assistant — the agent's own turn
* orchestrator — a subagent's task, assigned by its parent/main agent
* system — harness/tooling injection (task-notification, /loop meta, …)
* tool — a tool_result echoed back on a `user` line
*/
function classifyTranscriptSender(entry, isSubagentFile) {
if (entry.type === "assistant") return "assistant";
// `system`/local_command lines are surfaced as the human's slash-command I/O.
if (entry.type !== "user") return "user";
const content = entry.message ? entry.message.content : undefined;
const onlyToolResults =
Array.isArray(content) &&
content.length > 0 &&
content.every((b) => b && b.type === "tool_result");
if (entry.toolUseResult !== undefined || onlyToolResults) return "tool";
const text =
typeof content === "string"
? content
: Array.isArray(content)
? (content.find((b) => b && b.type === "text") || {}).text || ""
: "";
const lead = text.replace(/^\s+/, "");
// Harness injections that masquerade as a user line.
if (entry.isMeta === true) return "system";
if (lead.startsWith("<task-notification>") || lead.startsWith("<task-notification ")) {
return "system";
}
// Background-task event banner ("[SYSTEM NOTIFICATION - NOT USER INPUT] …")
// that newer harness builds prefix ahead of the <task-notification> payload.
if (lead.startsWith("[SYSTEM NOTIFICATION")) return "system";
// In a subagent transcript, a user line with no human prompt provenance is the
// task injected by the Task/Agent tool. A real human message to the subagent
// (rare, but allowed) carries promptSource/origin and stays "user".
if (isSubagentFile && entry.promptSource === undefined && entry.origin === undefined) {
return "orchestrator";
}
return "user";
}
/** The +/- line counts of a Write/Edit/MultiEdit result (`toolUseResult`):
* its `structuredPatch` hunks' `+`/`-` lines, or every line of a file it
* created. Null for any other result. */
function patchCounts(r) {
if (!r || typeof r !== "object") return null;
if (Array.isArray(r.structuredPatch) && r.structuredPatch.length) {
let added = 0;
let removed = 0;
for (const h of r.structuredPatch) {
for (const l of Array.isArray(h?.lines) ? h.lines : []) {
if (typeof l !== "string") continue;
if (l.startsWith("+")) added++;
else if (l.startsWith("-")) removed++;
}
}
return { added, removed };
}
if (r.type === "create" && typeof r.content === "string") {
return { added: r.content.split("\n").length - (r.content.endsWith("\n") ? 1 : 0), removed: 0 };
}
return null;
}
/** An AskUserQuestion result's answers (`toolUseResult.answers`): question
* text → the option picked, or what the user typed. Null for any other result. */
function askAnswers(r) {
if (!r || typeof r !== "object" || !r.answers || typeof r.answers !== "object") return null;
if (Array.isArray(r.answers)) return null;
const out = {};
for (const [q, a] of Object.entries(r.answers)) if (typeof a === "string") out[q] = a;
return Object.keys(out).length ? out : null;
}
/** An Agent/Task result's facts (`toolUseResult`): the subagent's id (its
* transcript is `agent-<id>.jsonl`), the model it ran on and, once it
* finished in the foreground, its time, tokens and tool calls. Null for any
* other result. */
function agentInfo(r) {
if (!r || typeof r !== "object" || typeof r.agentId !== "string") return null;
if (!/^[\w-]{1,200}$/.test(r.agentId)) return null;
const num = (v) => (Number.isFinite(v) ? v : null);
return {
id: r.agentId,
model: typeof r.resolvedModel === "string" ? r.resolvedModel : null,
status: typeof r.status === "string" ? r.status : null,
duration_ms: num(r.totalDurationMs),
tokens: num(r.totalTokens),
tool_uses: num(r.totalToolUseCount),
};
}
// A conversation turn — a prompt, an answer — is shown whole: it is bounded by
// the model's output and what the user typed. Only tool payloads and thinking
// (the parts that can run to megabytes) keep the 10 KB cap. This one guards
// against a pathological line, not a long answer.
const TEXT_MAX = 1024 * 1024;
// The tools whose input carries a file's text (artifactVersions on the client).
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit"]);
const EDIT_INPUT_MAX = 256 * 1024;
function truncate(str, maxLen) {
if (!str || str.length <= maxLen) return str;
return str.slice(0, maxLen) + "[truncated]";
}
function truncateObj(obj, maxLen) {
if (!obj) return obj;
const json = JSON.stringify(obj);
if (json.length <= maxLen) return obj;
return { _truncated: truncate(json, maxLen) };
}
/** A parser for one transcript: it remembers the last rename it surfaced, so
* a repeated `custom-title` line shows once. `isSubagentFile` tells a
* subagent's task (orchestrator) from a human prompt. */
function createClaudeLineParser(isSubagentFile = false) {
// Dedupe state for synthetic rename markers: custom-title lines can repeat
// with the same value across a transcript, so only emit when the title
// actually changes from the last one we surfaced.
let lastRenameTitle = null;
// Helper: parse a JSONL line into a message object, or null if not a displayable message
function parseMessage(entry, num) {
// /rename, `claude -n`, picker Ctrl+R → custom-title metadata line. These
// commands produce no user/assistant turn, so without this they'd be
// invisible. Surface a compact "renamed" marker instead.
if (entry.type === "custom-title") {
const title = typeof entry.customTitle === "string" ? entry.customTitle.trim() : "";
if (!title || title === lastRenameTitle) return null;
lastRenameTitle = title;
return {
type: "session_event",
event_kind: "rename",
title,
timestamp: entry.timestamp || null,
content: [],
line: num,
};
}
// Local slash-command I/O. Newer Claude Code builds write the command
// invocation and its captured output as `system`/`local_command` lines
// with the TUI markup in a top-level `content` string (older builds used
// `user` messages, handled below). Surface those as user-side text so the
// client's tuiSegments parser renders the command pill + stdout/stderr
// (e.g. /color → "/color" pill + "Session color set to: cyan"). Skip
// every other system subtype (turn_duration, stop_hook_summary, …) and
// empty local_command lines (e.g. /clear writes a content-less one).
if (entry.type === "system") {
if (entry.subtype !== "local_command") return null;
const sysText = typeof entry.content === "string" ? entry.content : "";
if (!sysText.trim()) return null;
return {
type: "user",
sender: "user", // local slash-command I/O is the human's own action
timestamp: entry.timestamp || null,
content: [{ type: "text", text: truncate(sysText, TEXT_MAX) }],
line: num,
};
}
// Mid-turn queued message: journaled as a `queued_command` attachment
// (never as a `user` line). Surface it at the position the model actually
// received it. NOT everything in the queue is the human, though — the
// harness delivers its own injections (task-notifications from background
// agents, "[SYSTEM NOTIFICATION …]" banners) through the same queue, and
// those attachments carry NO `origin` field, while a genuinely typed
// message carries `origin.kind = "human"`. So: harness-marker text or a
// non-human origin → "system"; everything else → "user". Other attachment
// subtypes are harness noise → dropped.
if (entry.type === "attachment") {
const att = entry.attachment;
if (!att || att.type !== "queued_command") return null;
const prompt = typeof att.prompt === "string" ? att.prompt : "";
if (!prompt.trim()) return null;
const lead = prompt.replace(/^\s+/, "");
// Same harness markers classifyTranscriptSender strips off user lines.
const isHarnessText =
lead.startsWith("<task-notification") || lead.startsWith("[SYSTEM NOTIFICATION");
const kind = att.origin && typeof att.origin.kind === "string" ? att.origin.kind : null;
const isSystem = isHarnessText || (kind !== null && kind !== "human");
return {
type: "user",
sender: isSystem ? "system" : "user",
timestamp: entry.timestamp || att.timestamp || null,
content: [{ type: "text", text: truncate(prompt, TEXT_MAX) }],
line: num,
};
}
const msg = entry.type === "assistant" ? entry.message || {} : {};
const content = [];
if (entry.type === "user") {
const msgContent = entry.message?.content;
if (typeof msgContent === "string") {
content.push({ type: "text", text: truncate(msgContent, TEXT_MAX) });
} else if (Array.isArray(msgContent)) {
for (const block of msgContent) {
if (block.type === "text" && block.text) {
content.push({ type: "text", text: truncate(block.text, TEXT_MAX) });
} else if (block.type === "tool_result") {
// A line carries one tool result; an edit's +/- counts ride on it.
const diff = patchCounts(entry.toolUseResult);
const answers = askAnswers(entry.toolUseResult);
const agent = agentInfo(entry.toolUseResult);
// A subagent's report is a reply, read as prose: its text blocks
// whole, not a 10 KB slice of their JSON.
const report =
agent && Array.isArray(block.content)
? block.content
.filter((c) => c?.type === "text" && typeof c.text === "string")
.map((c) => c.text)
.join("\n\n")
: null;
content.push({
type: "tool_result",
id: block.tool_use_id || null,
output:
report !== null
? truncate(report, TEXT_MAX)
: truncate(
typeof block.content === "string"
? block.content
: JSON.stringify(block.content || ""),
10240
),
is_error: !!block.is_error,
...(diff ? { diff } : {}),
...(answers ? { answers } : {}),
...(agent ? { agent } : {}),
});
}
}
} else if (msgContent === undefined || msgContent === null) {
return null;
}
} else {
const msgContent = msg.content || [];
if (Array.isArray(msgContent)) {
for (const block of msgContent) {
if (block.type === "text" && block.text) {
content.push({ type: "text", text: truncate(block.text, TEXT_MAX) });
} else if (block.type === "thinking" && block.thinking) {
content.push({ type: "thinking", text: truncate(block.thinking, 10240) });
} else if (block.type === "tool_use") {
content.push({
type: "tool_use",
name: block.name || "unknown",
id: block.id || null,
// A file a Write/Edit made is an artifact the chat can show in
// versions, rebuilt from these inputs: they keep up to 256 KB.
input: truncateObj(block.input, EDIT_TOOLS.has(block.name) ? EDIT_INPUT_MAX : 10240),
});
}
}
}
}
if (content.length === 0) return null;
const message = {
type: entry.type,
sender: classifyTranscriptSender(entry, isSubagentFile),
timestamp: entry.timestamp
? typeof entry.timestamp === "number"
? new Date(entry.timestamp).toISOString()
: entry.timestamp
: null,
content,
line: num,
};
if (entry.type === "assistant") {
if (msg.model) message.model = msg.model;
// "end_turn" closes the turn; "tool_use" means more work follows.
if (typeof msg.stop_reason === "string") message.stop_reason = msg.stop_reason;
if (msg.usage) {
message.usage = {
input_tokens: msg.usage.input_tokens || 0,
output_tokens: msg.usage.output_tokens || 0,
cache_read_input_tokens: msg.usage.cache_read_input_tokens || 0,
cache_creation_input_tokens: msg.usage.cache_creation_input_tokens || 0,
};
}
}
return message;
}
return parseMessage;
}
module.exports = {
classifyTranscriptSender,
patchCounts,
askAnswers,
agentInfo,
truncate,
truncateObj,
TEXT_MAX,
createClaudeLineParser,
};