383b9ae231
- 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
325 lines
13 KiB
JavaScript
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,
|
|
};
|