Files
AgentHub/server/lib/transcript-message.js
T
nntrivi2001 383b9ae231 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
2026-10-21 15:44:17 +07:00

325 lines
13 KiB
JavaScript

/**
* @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,
};