/** * @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ĩ */ /** * 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("") || lead.startsWith(" 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-.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(" 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, };