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
+178 -295
View File
@@ -22,6 +22,16 @@ const {
findTranscriptPath,
findSubagentTranscriptPath,
} = require("../lib/claude-home");
const {
classifyTranscriptSender,
patchCounts,
askAnswers,
agentInfo,
truncate,
truncateObj,
TEXT_MAX,
createClaudeLineParser,
} = require("../lib/transcript-message");
const router = Router();
@@ -52,59 +62,6 @@ const TRANSCRIPT_RENDER_TYPES = new Set([
"attachment",
]);
/**
* 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";
}
/**
* Read only the first non-empty line from a JSONL file using streaming.
* Avoids loading the entire file into memory.
@@ -866,6 +823,26 @@ router.get("/:id/transcripts", async (req, res) => {
}
return res.json({ transcripts: list });
}
// Cursor and Antigravity: one transcript per session, no subagent files.
if (session.tool === "cursor" || session.tool === "antigravity") {
const file =
session.tool === "cursor"
? require("../lib/cursor-usage").transcriptPath(req.params.id)
: require("../lib/antigravity-usage").transcriptPath(req.params.id);
const list =
file && fs.existsSync(file)
? [
{
id: "main",
name: "Main Agent",
type: "main",
has_transcript: true,
db_agent_id: null,
},
]
: [];
return res.json({ transcripts: list });
}
// OpenCode: a subagent is a child session of the store, tied to the agent
// row its poller made.
if (session.tool === "opencode") {
@@ -1120,6 +1097,10 @@ router.get("/:id/transcript", async (req, res) => {
const isCodex = session.tool === "codex";
// An omp session's (or subagent's) journal, recorded by omp-usage.js.
const isOmp = session.tool === "omp";
// A Cursor session's agent transcript, recorded by cursor-usage.js.
const isCursor = session.tool === "cursor";
// An agy conversation's step log (`transcript_full.jsonl`), found by antigravity-usage.js.
const isAgy = session.tool === "antigravity";
// An OpenCode conversation lives in its SQLite store, not a JSONL file: read
// whole (one session's rows), then paged like the files below.
if (session.tool === "opencode") {
@@ -1136,6 +1117,14 @@ router.get("/:id/transcript", async (req, res) => {
}
if (isOmp) {
jsonlPath = require("../lib/omp-usage").journalPath(req.params.id, agentId || "");
} else if (isCursor) {
jsonlPath = isSubagentFile
? null
: require("../lib/cursor-usage").transcriptPath(req.params.id);
} else if (isAgy) {
jsonlPath = isSubagentFile
? null
: require("../lib/antigravity-usage").transcriptPath(req.params.id);
} else if (isCodex) {
jsonlPath = db
.prepare("SELECT path FROM codex_rollouts WHERE session_id = ? AND thread_id = ?")
@@ -1172,184 +1161,22 @@ router.get("/:id/transcript", async (req, res) => {
? (e) => e.type === "response_item"
: isOmp
? (e) => e.type === "message"
: (e) => TRANSCRIPT_RENDER_TYPES.has(e.type);
: isCursor
? (e) => e.role === "user" || e.role === "assistant"
: isAgy
? (e) => AGY_RENDER_TYPES.has(e.type)
: (e) => TRANSCRIPT_RENDER_TYPES.has(e.type);
const toMessage = isCodex
? codexMessage
: isOmp
? (e, n) => ompMessage(e, n, isSubagentFile)
: (e, n) => parseMessage(e, n);
: isCursor
? cursorMessage
: isAgy
? agyMessage
: (e, n) => parseMessage(e, n);
// 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,
input: truncateObj(block.input, 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;
}
const parseMessage = createClaudeLineParser(isSubagentFile);
if (afterLine !== null) {
// Incremental mode: skip lines until after afterLine, collect up to limit, then stop.
@@ -1626,6 +1453,131 @@ function ompMessage(entry, num, isSubagent = false) {
}
}
/**
* One line of a Cursor agent transcript (`{role, message:{content}}`, see
* cursor-usage.js) as a transcript message. Cursor writes no timestamps and no
* model, so neither is set. A user line wraps the typed prompt in
* `<user_query>` after a `<timestamp>` block; only the prompt is shown.
*/
function cursorMessage(entry, num) {
const m = entry.message || {};
const parts = Array.isArray(m.content)
? m.content
: typeof m.content === "string"
? [{ type: "text", text: m.content }]
: [];
const msg = (type, sender, content) => ({ type, sender, timestamp: null, content, line: num });
const content = [];
if (entry.role === "user") {
for (const c of parts) {
if (c && c.type === "text" && typeof c.text === "string") {
const q = /<user_query>\s*([\s\S]*?)\s*<\/user_query>/.exec(c.text);
const text = (q ? q[1] : c.text.replace(/<timestamp>[\s\S]*?<\/timestamp>/, "")).trim();
if (text) content.push({ type: "text", text: truncate(text, TEXT_MAX) });
} else if (c && c.type === "tool_result") {
const out = Array.isArray(c.content)
? c.content.map((x) => (x && typeof x.text === "string" ? x.text : "")).join("")
: typeof c.content === "string"
? c.content
: "";
content.push({
type: "tool_result",
id: c.tool_use_id || null,
output: truncate(out, 10240),
is_error: c.is_error === true,
});
}
}
if (!content.length) return null;
const onlyResults = content.every((c) => c.type === "tool_result");
return msg("user", onlyResults ? "tool" : "user", content);
}
for (const c of parts) {
if (!c) continue;
if (c.type === "text" && typeof c.text === "string" && c.text.trim()) {
content.push({ type: "text", text: truncate(c.text, TEXT_MAX) });
} else if (c.type === "thinking" && typeof c.thinking === "string" && c.thinking.trim()) {
content.push({ type: "thinking", text: truncate(c.thinking, 10240) });
} else if (c.type === "tool_use") {
content.push({
type: "tool_use",
name: c.name || "unknown",
id: c.id || null,
input: truncateObj(c.input && typeof c.input === "object" ? c.input : {}, 10240),
});
}
}
return content.length ? msg("assistant", "assistant", content) : null;
}
/** The agy steps a conversation view shows (see `agyMessage`). */
const AGY_RENDER_TYPES = new Set(["USER_INPUT", "PLANNER_RESPONSE", "GENERIC"]);
/**
* One step of an agy conversation (`brain/<id>/.system_generated/logs/
* transcript_full.jsonl`, see antigravity-usage.js) as a transcript message:
* `USER_INPUT` the prompt (inside `<USER_REQUEST>`), `PLANNER_RESPONSE` the
* model's text, thinking, tool calls and tokens, `GENERIC` a tool's result
* (`status: "INVALID"` + `error` an error). agy gives a call no id: its result
* is the step right after it (call `i` of step `s` lands at step `s + 1 + i`),
* so both are named by that step — the same on every page of the file.
*/
function agyMessage(entry, num) {
const at = typeof entry.created_at === "string" ? entry.created_at : null;
const step = Number.isInteger(entry.step_index) ? entry.step_index : num;
const msg = (type, sender, content) => ({ type, sender, timestamp: at, content, line: num });
const text = typeof entry.content === "string" ? entry.content : "";
switch (entry.type) {
case "USER_INPUT": {
const req = /<USER_REQUEST>\s*([\s\S]*?)\s*<\/USER_REQUEST>/.exec(text);
const prompt = (req ? req[1] : text).trim();
if (!prompt) return null;
return msg("user", entry.source === "USER_EXPLICIT" ? "user" : "system", [
{ type: "text", text: truncate(prompt, TEXT_MAX) },
]);
}
case "PLANNER_RESPONSE": {
const content = [];
if (typeof entry.thinking === "string" && entry.thinking.trim())
content.push({ type: "thinking", text: truncate(entry.thinking, 10240) });
if (text.trim()) content.push({ type: "text", text: truncate(text, TEXT_MAX) });
(Array.isArray(entry.tool_calls) ? entry.tool_calls : []).forEach((c, i) => {
if (!c) return;
content.push({
type: "tool_use",
name: c.name || "unknown",
id: `agy-${step + 1 + i}`,
input: truncateObj(c.args && typeof c.args === "object" ? c.args : {}, 10240),
});
});
if (!content.length) return null;
const out = msg("assistant", "assistant", content);
if (Number.isFinite(entry.input_tokens) || Number.isFinite(entry.output_tokens))
out.usage = {
input_tokens: entry.input_tokens || 0,
output_tokens: entry.output_tokens || 0,
cache_read_input_tokens: entry.cache_read_tokens || 0,
cache_creation_input_tokens: 0,
};
return out;
}
case "GENERIC": {
// Its first lines say when the call ran: not part of what it returned.
const output = text.replace(/^Created At: [^\n]*\nCompleted At: [^\n]*\n\n?/, "");
return msg("user", "tool", [
{
type: "tool_result",
id: `agy-${step}`,
output: truncate(output, 10240),
is_error: entry.status === "INVALID" || entry.status === "ERROR" || !!entry.error,
},
]);
}
default:
return null;
}
}
/**
* The transcript route's paging over a conversation already in memory (an
* OpenCode store): message N is "line" N, so `after`/`before`/`offset` and the
@@ -1662,75 +1614,6 @@ function pageMessages(all, { limit, afterLine, beforeLine, offset }) {
};
}
/** 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;
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) };
}
module.exports = router;
module.exports.sessionStats = sessionStats;
// Exported for unit tests — sender attribution is correctness-critical.