483 lines
16 KiB
JavaScript
483 lines
16 KiB
JavaScript
//
|
|
// 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,
|
|
};
|