Files
claude-nova/Scripts/session.js
T
2026-08-11 12:38:02 -03:00

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,
};