First version
This commit is contained in:
@@ -0,0 +1,482 @@
|
||||
//
|
||||
// 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,
|
||||
};
|
||||
Reference in New Issue
Block a user