// // session.js — the conversation model. // // Consumes messages from the CLI and maintains everything the UI renders: // the ordered transcript, tool activity, changed files, pending approvals, // and running cost. Views subscribe to `onChange`. // const { Signal, oneLine, relativize, log } = require("./util.js"); const EDITING_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit", "Update"]; // Tools whose name is an implementation detail rather than something to show. const TOOL_LABELS = { AskUserQuestion: "Question", ExitPlanMode: "Plan", EnterPlanMode: "Plan mode", TodoWrite: "Todos", }; function friendlyToolName(name) { return TOOL_LABELS[name] || name; } /** The first heading or line of a plan, for use as its title. */ function planTitle(plan) { if (!plan) return ""; for (const line of String(plan).split("\n")) { const trimmed = line.replace(/^#+\s*/, "").trim(); if (trimmed) return trimmed; } return ""; } /** A one-line human summary of a tool call, used in the sidebar and prompts. */ function summarizeTool(name, input) { const args = input || {}; switch (name) { case "Read": case "Edit": case "Write": case "MultiEdit": case "NotebookEdit": return relativize(args.file_path || args.notebook_path || ""); case "Bash": case "BashOutput": return oneLine(args.command || args.description || "", 100); case "Glob": return args.pattern || ""; case "Grep": return oneLine(args.pattern || "", 60) + (args.path ? ` in ${relativize(args.path)}` : ""); case "WebFetch": return args.url || ""; case "WebSearch": return oneLine(args.query || "", 80); case "Task": return oneLine(args.description || args.subagent_type || "", 80); case "TodoWrite": return Array.isArray(args.todos) ? `${args.todos.length} items` : ""; case "AskUserQuestion": { const questions = Array.isArray(args.questions) ? args.questions : []; if (questions.length === 0) return ""; return questions.length === 1 ? oneLine(questions[0].question, 70) : `${questions.length} questions`; } case "ExitPlanMode": case "EnterPlanMode": return oneLine(planTitle(args.plan) || "ready for review", 70); default: break; } if (args.file_path) return relativize(args.file_path); if (args.path) return relativize(args.path); if (args.command) return oneLine(args.command, 100); if (args.query) return oneLine(args.query, 80); const keys = Object.keys(args); if (keys.length === 0) return ""; return oneLine(JSON.stringify(args), 80); } /** The file a tool call touches, when it touches exactly one. */ function toolFilePath(name, input) { if (!input) return null; if (EDITING_TOOLS.indexOf(name) >= 0 || name === "Read") { return input.file_path || input.notebook_path || null; } return null; } function isEditingTool(name) { return EDITING_TOOLS.indexOf(name) >= 0; } class Session { constructor() { this.onChange = new Signal(); this.reset(); } reset() { this.status = "idle"; this.statusDetail = null; this.sessionId = null; this.model = null; this.permissionMode = null; this.cwd = null; this.tools = []; this.slashCommands = []; this.entries = []; this.toolsById = new Map(); this.assistantByMessageId = new Map(); this.approvals = []; this.changedFiles = new Map(); this.usage = { costUSD: null, inputTokens: null, outputTokens: null, durationMs: null, numTurns: 0, }; this.liveAssistant = null; this.lastError = null; this.startedAt = null; } // -- mutation ---------------------------------------------------------- setStatus(status, detail) { this.status = status; this.statusDetail = detail || null; this.changed(); } changed() { this.onChange.emit(this); } addEntry(entry) { entry.at = entry.at || Date.now(); this.entries.push(entry); return entry; } addUserTurn(text, context) { this.addEntry({ kind: "user", text: text, context: context || null }); this.usage.numTurns += 1; this.setStatus("thinking"); return this.entries[this.entries.length - 1]; } addNotice(text, tone) { this.addEntry({ kind: "notice", text: text, tone: tone || "info" }); this.changed(); } // -- incoming CLI messages -------------------------------------------- handleMessage(message) { switch (message.type) { case "system": this._handleSystem(message); break; case "rate_limit_event": return; // advisory only case "assistant": this._handleAssistant(message); break; case "user": this._handleUser(message); break; case "stream_event": this._handleStreamEvent(message); break; case "result": this._handleResult(message); break; default: log("unhandled message type:", message.type); return; } this.changed(); } _handleSystem(message) { if (message.subtype === "init") { this.sessionId = message.session_id || this.sessionId; this.model = message.model || this.model; this.permissionMode = message.permissionMode || message.permission_mode || this.permissionMode; this.cwd = message.cwd || this.cwd; this.tools = message.tools || []; this.slashCommands = message.slash_commands || []; this.startedAt = this.startedAt || Date.now(); if (this.status === "starting") this.setStatus("idle"); return; } if (message.subtype === "compact_boundary") { this.addEntry({ kind: "notice", text: "Conversation history was compacted.", tone: "info" }); return; } if (message.subtype === "permission_denied") { const tool = message.tool_name || "A tool"; this.addEntry({ kind: "notice", text: `${tool} was denied${message.reason ? ": " + message.reason : "."}`, tone: "error", }); return; } // Advisory chatter with nothing to render. if ( message.subtype === "status" || message.subtype === "thinking_tokens" || message.subtype === "commands_changed" || message.subtype === "mcp_status" ) { return; } if (message.subtype === "error" || message.is_error) { const text = message.message || message.error || "Claude Code reported an error."; this.lastError = text; this.addEntry({ kind: "notice", text: text, tone: "error" }); } } /** * The entry that collects one assistant message. * * A single message id arrives more than once: the CLI emits an `assistant` * message per completed content block (thinking, then text), and streaming * adds deltas for the same id. They all belong to one bubble. */ _assistantEntry(messageId) { if (messageId && this.assistantByMessageId.has(messageId)) { return this.assistantByMessageId.get(messageId); } const entry = this.addEntry({ kind: "assistant", messageId: messageId || null, blocks: [], streamed: false, }); if (messageId) this.assistantByMessageId.set(messageId, entry); return entry; } _handleAssistant(message) { const payload = message.message || {}; const blocks = Array.isArray(payload.content) ? payload.content : []; const entry = this._assistantEntry(payload.id); // Keep pointing at this entry: the CLI sends one `assistant` message // per completed block, and further blocks of the same message may // still be streaming in behind it. this.liveAssistant = entry; for (const block of blocks) { if (block.type === "tool_use") { this._registerToolUse(block, message.parent_tool_use_id); continue; } // When the text already arrived as deltas, the authoritative copy // would duplicate it; the streamed blocks are the same content. if (entry.streamed) continue; if (block.type === "text") { entry.blocks.push({ type: "text", text: block.text || "" }); } else if (block.type === "thinking") { entry.blocks.push({ type: "thinking", text: block.thinking || "" }); } } if (this.status === "thinking") this.setStatus("working"); } _registerToolUse(block, parentToolUseId) { const name = block.name || "Tool"; const input = block.input || {}; const entry = this.addEntry({ kind: "tool", id: block.id, name: name, input: input, summary: summarizeTool(name, input), filePath: toolFilePath(name, input), parentToolUseId: parentToolUseId || null, state: "running", result: null, }); this.toolsById.set(block.id, entry); this.setStatus("working", `${name}${entry.summary ? " · " + entry.summary : ""}`); } _handleUser(message) { const payload = message.message || {}; const content = payload.content; if (typeof content === "string") { if (message.isSynthetic) { this.addEntry({ kind: "notice", text: content, tone: "info" }); } return; } if (!Array.isArray(content)) return; for (const block of content) { if (block.type !== "tool_result") continue; const tool = this.toolsById.get(block.tool_use_id); if (!tool) continue; tool.state = block.is_error ? "error" : "done"; tool.result = this._resultText(block.content); // Only successful edits changed the file on disk. A denied or // failed write left it untouched, so it does not belong in the // list of things to review. if (!block.is_error && isEditingTool(tool.name) && tool.filePath) { this._noteChangedFile(tool.filePath, tool.name, "changed", message.tool_use_result); } } } _resultText(content) { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .filter((b) => b && b.type === "text") .map((b) => b.text) .join("\n"); } _handleStreamEvent(message) { const event = message.event; if (!event) return; if (event.type === "message_start") { const id = event.message && event.message.id; const entry = this._assistantEntry(id); entry.streamed = true; entry.streaming = true; this.liveAssistant = entry; return; } if (!this.liveAssistant) return; if (event.type === "content_block_start") { const block = event.content_block || {}; if (block.type === "text") { this.liveAssistant.blocks[event.index] = { type: "text", text: block.text || "" }; } else if (block.type === "thinking") { this.liveAssistant.blocks[event.index] = { type: "thinking", text: block.thinking || "" }; } return; } if (event.type === "content_block_delta") { const delta = event.delta || {}; const target = this.liveAssistant.blocks[event.index]; if (delta.type === "text_delta") { if (target && target.type === "text") target.text += delta.text || ""; else this.liveAssistant.blocks[event.index] = { type: "text", text: delta.text || "" }; } else if (delta.type === "thinking_delta") { if (target && target.type === "thinking") target.text += delta.thinking || ""; else this.liveAssistant.blocks[event.index] = { type: "thinking", text: delta.thinking || "" }; } return; } if (event.type === "message_stop") { if (this.liveAssistant) this.liveAssistant.streaming = false; } } _handleResult(message) { this.usage.costUSD = typeof message.total_cost_usd === "number" ? message.total_cost_usd : this.usage.costUSD; this.usage.durationMs = message.duration_ms || this.usage.durationMs; this.usage.numTurns = message.num_turns || this.usage.numTurns; const usage = message.usage || {}; if (typeof usage.input_tokens === "number") { this.usage.inputTokens = usage.input_tokens + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0); } if (typeof usage.output_tokens === "number") { this.usage.outputTokens = usage.output_tokens; } if (message.is_error) { this.lastError = message.result || "The turn ended with an error."; this.addEntry({ kind: "notice", text: this.lastError, tone: "error" }); this.setStatus("error", this.lastError); } else { this.setStatus("idle"); } if (this.liveAssistant) this.liveAssistant.streaming = false; this.liveAssistant = null; this.assistantByMessageId.clear(); } // -- approvals --------------------------------------------------------- addApproval(approval) { this.approvals.push(approval); this.setStatus("waiting", `${approval.request.tool_name} needs approval`); return approval; } removeApproval(requestId) { const index = this.approvals.findIndex((a) => a.requestId === requestId); if (index >= 0) this.approvals.splice(index, 1); if (this.approvals.length === 0 && this.status === "waiting") { this.setStatus("working"); } else { this.changed(); } } findApproval(requestId) { return this.approvals.find((a) => a.requestId === requestId) || null; } // -- changed files ----------------------------------------------------- _noteChangedFile(path, tool, state, toolResult) { const existing = this.changedFiles.get(path) || { path: path, edits: 0, tool: tool, additions: null, deletions: null, }; existing.tool = tool; existing.edits += 1; if (toolResult && typeof toolResult === "object") { if (typeof toolResult.additions === "number") existing.additions = toolResult.additions; if (typeof toolResult.deletions === "number") existing.deletions = toolResult.deletions; } this.changedFiles.set(path, existing); } clearChangedFiles() { this.changedFiles.clear(); this.changed(); } get isBusy() { return this.status === "thinking" || this.status === "working" || this.status === "waiting"; } } module.exports = { Session, summarizeTool, toolFilePath, isEditingTool, friendlyToolName, planTitle, };