Files
dotfiles/pi/.pi/agent/shared/claude-stream.ts
2026-04-24 14:22:59 +02:00

780 lines
30 KiB
TypeScript

/**
* claude-stream — Shared types, rendering, and core spawn/stream logic
* for ask-claude and chat-claude pi extensions.
*
* Both extensions spawn `claude -p --output-format stream-json` and parse
* the same streaming protocol. This module provides:
* - Block types (ThinkingBlock, ToolBlock, TextBlock)
* - Rendering helpers (tool call lines, result boxes, usage formatting)
* - runClaude() — the core spawn + stream parser
*/
import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { diffLines } from "diff";
import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
// =============================================================================
// Block types
// =============================================================================
export interface ThinkingBlock {
type: "thinking";
text: string;
}
export interface ToolBlock {
type: "tool";
id: string;
name: string;
inputJson: string;
editContext?: { before: string[]; after: string[]; startLine: number };
result?: { text: string; isError: boolean };
}
export interface TextBlock {
type: "text";
text: string;
}
export type StreamBlock = ThinkingBlock | ToolBlock | TextBlock;
// =============================================================================
// Details interface (stored in tool result, drives rendering)
// =============================================================================
export interface ClaudeDetails {
label: string;
done: boolean;
blocks: StreamBlock[];
finalText: string;
sessionId?: string;
isResume?: boolean;
costUsd?: number;
inputTokens?: number;
outputTokens?: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
}
// =============================================================================
// Rendering helpers
// =============================================================================
export function shortenPath(p: string): string {
const home = process.env.HOME ?? "";
return home && p.startsWith(home) ? "~" + p.slice(home.length) : p;
}
export function formatUsage(d: ClaudeDetails): string {
const parts: string[] = [];
if (d.inputTokens) parts.push(`${d.inputTokens}`);
if (d.outputTokens) parts.push(`${d.outputTokens}`);
if (d.cacheReadTokens) parts.push(`R${d.cacheReadTokens}`);
if (d.cacheWriteTokens) parts.push(`W${d.cacheWriteTokens}`);
if (d.costUsd) parts.push(d.costUsd < 0.001 ? `$${(d.costUsd * 1000).toFixed(2)}m` : `$${d.costUsd.toFixed(4)}`);
return parts.join(" ");
}
export type Theme = {
fg: (c: any, t: string) => string;
bg: (c: any, t: string) => string;
bold: (t: string) => string;
italic: (t: string) => string;
dim?: (t: string) => string;
};
export function renderToolCallLine(block: ToolBlock, theme: Theme): string {
let input: Record<string, unknown> = {};
try { input = JSON.parse(block.inputJson); } catch { /* ok */ }
switch (block.name.toLowerCase()) {
case "read": {
const path = shortenPath(String(input.file_path ?? input.path ?? ""));
const offset = Number(input.offset ?? 1);
const limit = input.limit != null ? Number(input.limit) : undefined;
const range = limit != null ? `:${offset}-${offset + limit - 1}` : "";
return theme.fg("muted", "read ") + theme.fg("accent", path) + theme.fg("warning", range);
}
case "bash": {
const cmd = String(input.command ?? "").replace(/\n/g, " ↵ ");
return theme.fg("muted", "$ ") + theme.fg("toolOutput", cmd);
}
case "edit": {
const path = shortenPath(String(input.file_path ?? input.path ?? ""));
return theme.fg("muted", "edit ") + theme.fg("accent", path);
}
case "write": {
const path = shortenPath(String(input.file_path ?? input.path ?? ""));
const lines = String(input.content ?? "").split("\n").length;
return theme.fg("muted", "write ") + theme.fg("accent", path) + theme.fg("dim", ` (${lines} lines)`);
}
case "glob": {
const pat = String(input.pattern ?? "");
const path = input.path ? shortenPath(String(input.path)) : ".";
return theme.fg("muted", "glob ") + theme.fg("accent", pat) + theme.fg("dim", ` in ${path}`);
}
case "grep": {
const pat = String(input.pattern ?? "");
const path = input.path ? shortenPath(String(input.path)) : ".";
return theme.fg("muted", "grep ") + theme.fg("accent", `"${pat}"`) + theme.fg("dim", ` in ${path}`);
}
case "mcp__pi__ask": {
// Surfaces from the pi-ask-bridge MCP server. input is
// { questions: [{ id, question, options[], multi?, recommended? }, ...] }
// Show the first question text inline; if there are more, append a count.
const qs = Array.isArray(input.questions) ? (input.questions as any[]) : [];
const first = qs[0];
const head = first?.question ? String(first.question) : "(empty)";
const more = qs.length > 1 ? ` (+${qs.length - 1} more)` : "";
const tag = first?.id ? ` [${first.id}]` : "";
return theme.fg("muted", "ask ") + theme.fg("accent", head) + theme.fg("dim", tag + more);
}
default: {
const desc = typeof input.description === "string" ? input.description
: typeof input.prompt === "string" ? input.prompt.split("\n")[0]
: block.inputJson;
return theme.fg("toolTitle", block.name) + theme.fg("dim", " " + desc);
}
}
}
export function renderToolResultBox(block: ToolBlock, theme: Theme): Text {
if (!block.result) return new Text("", 0, 0);
let input: Record<string, unknown> = {};
try { input = JSON.parse(block.inputJson); } catch { /* ok */ }
// Strip / restyle synthetic envelopes like <tool_use_error> and
// <system_reminder> before any tool-specific parsing.
const text = transformSpecialTags(block.result.text);
const { isError } = block.result;
switch (block.name.toLowerCase()) {
case "read": {
const rawLines = text.split("\n").filter((l) => l.length > 0);
const parsed = rawLines.map((l) => {
const tab = l.indexOf("\t");
return tab >= 0 ? { num: l.slice(0, tab), content: l.slice(tab + 1) } : { num: "", content: l };
});
const maxNumLen = parsed.reduce((m, l) => Math.max(m, l.num.length), 0);
return new Text(
parsed.map((l) => theme.fg("dim", l.num.padStart(maxNumLen)) + " " + l.content).join("\n"),
0, 0,
);
}
case "edit": {
if (isError) return new Text(text, 0, 0);
const oldStr = String(input.old_string ?? input.oldText ?? "");
const newStr = String(input.new_string ?? input.newText ?? "");
if (!oldStr && !newStr) return new Text(text.slice(0, 200), 0, 0);
const oldLines = oldStr === "" ? [] : oldStr.split("\n");
const newLines = newStr === "" ? [] : newStr.split("\n");
const ctx = block.editContext;
const startLine = ctx?.startLine ?? 1;
const header = theme.fg("dim", `@@ -${startLine},${oldLines.length} +${startLine},${newLines.length} @@`);
const diff: string[] = [header];
for (const l of ctx?.before ?? []) diff.push(theme.fg("dim", " " + l));
for (const l of oldLines) diff.push(theme.fg("toolDiffRemoved", "-" + l));
for (const l of newLines) diff.push(theme.fg("toolDiffAdded", "+" + l));
for (const l of ctx?.after ?? []) diff.push(theme.fg("dim", " " + l));
return new Text(diff.join("\n"), 0, 0);
}
case "write": {
if (isError) return new Text(text, 0, 0);
const lines = String(input.content ?? "").split("\n");
const numWidth = String(lines.length).length;
return new Text(
lines.map((l, i) => theme.fg("dim", String(i + 1).padStart(numWidth)) + " " + l).join("\n"),
0, 0,
);
}
case "bash":
return new Text(text.trimEnd(), 0, 0);
case "mcp__pi__ask": {
// The pi-ask-mcp server returns a JSON array of QuestionResults.
// Pretty-print as one "id → answer" line per question instead of
// dumping raw JSON into the result banner.
if (isError) return new Text(text.trim(), 0, 0);
let parsed: any;
try { parsed = JSON.parse(text); } catch { return new Text(text.trim(), 0, 0); }
if (!Array.isArray(parsed)) return new Text(text.trim(), 0, 0);
const lines: string[] = [];
for (const r of parsed) {
const id = theme.fg("accent", String(r?.id ?? "?"));
const opts = Array.isArray(r?.selectedOptions) ? r.selectedOptions : [];
const custom = r?.customInput ? String(r.customInput) : "";
const arrow = theme.fg("dim", " → ");
let answer: string;
if (opts.length === 0 && !custom) {
answer = theme.fg("warning", "(cancelled)");
} else if (opts.length > 0 && custom) {
answer = theme.fg("toolOutput", `[${opts.join(", ")}] + Other: "${custom}"`);
} else if (custom) {
answer = theme.fg("toolOutput", `Other: "${custom}"`);
} else {
answer = theme.fg("toolOutput", opts.length === 1 ? opts[0] : `[${opts.join(", ")}]`);
}
lines.push(id + arrow + answer);
}
return new Text(lines.join("\n"), 0, 0);
}
default:
return new Text(text.trim(), 0, 0);
}
}
/** Strip ANSI SGR sequences so we can re-style plain text. */
function stripAnsi(s: string): string {
return s.replace(/\x1b\[[0-9;]*m/g, "");
}
// ---------------------------------------------------------------------------
// Special-tag transform — used for tool-result text that embeds synthetic
// envelopes like <tool_use_error> or <system_reminder>.
//
// IMPORTANT: all wrappers use PARTIAL resets (`\x1b[22;23;24;39m` — reset
// bold/dim/italic/underline and foreground only). If we emitted \x1b[0m in
// the middle of a line, it would also wipe any background that Box.bgFn
// painted around us, producing a staircase-shaped hole where the padding
// loses its colour. Partial resets leave the background intact.
// ---------------------------------------------------------------------------
const SGR_FG_RESET = "\x1b[22;23;24;39m";
const SGR_BOLD_WHITE = "\x1b[1;97m";
const SGR_BOLD_RED = "\x1b[1;91m";
const SGR_DIM_GREY = "\x1b[2;90m";
function transformSpecialTags(text: string): string {
// <tool_use_error>message</tool_use_error>
// → strip tags, display message in bright bold red
text = text.replace(
/<tool_use_error>\s*([\s\S]*?)\s*<\/tool_use_error>/g,
(_, inner: string) => SGR_BOLD_RED + inner + SGR_FG_RESET,
);
// <system_reminder> or <system-reminder>
// → strip tags, collapse whitespace to one line, truncate at ~100 chars
// with a trailing "...", render in dim grey
text = text.replace(
/<system[-_]reminder>\s*([\s\S]*?)\s*<\/system[-_]reminder>/g,
(_, inner: string) => {
const oneLine = inner.replace(/\s+/g, " ").trim();
const MAX = 100;
const snippet = oneLine.length > MAX ? oneLine.slice(0, MAX).trimEnd() : oneLine;
return SGR_DIM_GREY + snippet + "..." + SGR_FG_RESET;
},
);
return text;
}
/**
* Render a tool block as two stacked banners:
* 1) header — bold-white title on an ORANGE background (matches the
* chat-claude outer border colour).
* 2) result body (if any) — on the theme's pending / success / error bg.
*
* The header deliberately uses a different background from the result, so
* the "staircase" where the banners have different widths is an intentional
* stylistic cue, not a rendering glitch.
*/
const ORANGE_BG_FN = (s: string) => "\x1b[48;5;130m" + s + "\x1b[0m";
export function renderToolBlock(block: ToolBlock, theme: Theme): Container {
const c = new Container();
// ---- Header banner (orange) ----
// Strip per-segment colours from renderToolCallLine so the title renders
// uniformly bold-white over the orange background. Partial-reset at the
// end keeps the orange bg alive for the trailing padding.
const headerText = SGR_BOLD_WHITE + stripAnsi(renderToolCallLine(block, theme)) + SGR_FG_RESET;
c.addChild(new Text(headerText, 2, 0, ORANGE_BG_FN));
// ---- Result body ----
if (block.result !== undefined) {
const bgFn = block.result.isError
? (s: string) => theme.bg("toolErrorBg", s)
: (s: string) => theme.bg("toolSuccessBg", s);
const box = new Box(2, 0, bgFn);
box.addChild(renderToolResultBox(block, theme));
c.addChild(box);
}
return c;
}
/** Build a unified diff string for an edit tool call. */
export function buildEditDiff(oldStr: string, newStr: string, contextLines = 3): string {
const changes = diffLines(oldStr, newStr);
if (changes.length === 1 && (changes[0].added || changes[0].removed)) {
const oldCount = (oldStr ? oldStr.split("\n").length : 0);
const newCount = (newStr ? newStr.split("\n").length : 0);
const lines: string[] = [
`@@ -1,${oldCount} +1,${newCount} @@`,
...oldStr.split("\n").map((l) => "-" + l),
...newStr.split("\n").map((l) => "+" + l),
];
return lines.join("\n");
}
const ctxLines: string[] = oldStr.split("\n");
const hunks: { start: number; lines: string[] }[] = [];
let currentHunk: { start: number; lines: string[] } | null = null;
let lineIdx = 0;
for (const change of changes) {
const lines = change.value.endsWith("\n") ? change.value.slice(0, -1).split("\n") : change.value.split("\n");
if (change.added) {
if (!currentHunk) currentHunk = { start: lineIdx, lines: [] };
for (const l of lines) currentHunk.lines.push("+" + l);
} else if (change.removed) {
if (!currentHunk) currentHunk = { start: lineIdx, lines: [] };
for (const l of lines) currentHunk.lines.push("-" + l);
lineIdx += lines.length;
} else {
for (const l of lines) {
if (!currentHunk) { lineIdx++; continue; }
currentHunk.lines.push(" " + l);
if (currentHunk.lines.length >= contextLines * 2 + 1) {
hunks.push(currentHunk);
currentHunk = null;
}
lineIdx++;
}
}
}
if (currentHunk) hunks.push(currentHunk);
if (hunks.length === 0) return "(no changes)";
const merged: { start: number; lines: string[] }[] = [];
for (const h of hunks) {
if (merged.length === 0 || h.start <= merged[merged.length - 1].start + merged[merged.length - 1].lines.length) {
if (merged.length > 0) {
const prev = merged[merged.length - 1];
prev.lines.push(...h.lines.slice(Math.max(0, prev.lines.length - (h.start - prev.start))));
} else {
merged.push({ ...h, lines: [...h.lines] });
}
} else {
merged.push({ ...h, lines: [...h.lines] });
}
}
const resultLines: string[] = [];
for (const h of merged) {
const removed = h.lines.filter((l) => l.startsWith("-")).length;
const added = h.lines.filter((l) => l.startsWith("+")).length;
const total = h.lines.length;
resultLines.push(`@@ -${h.start},${removed + (total - removed - added)} +${h.start},${added + (total - removed - added)} @@`);
resultLines.push(...h.lines);
}
return resultLines.join("\n");
}
// =============================================================================
// Error helpers
// =============================================================================
const ANTHROPIC_ERROR_MAP: [RegExp, string][] = [
[/rate_limit/i, "Rate limited by Anthropic — please try again in a few seconds."],
[/invalid_api_key/i, "Invalid Anthropic API key — check your ANTHROPIC_API_KEY."],
[/permission_error/i, "Permission denied — check your Anthropic account and API key."],
[/not_found.*model/i, "Model not found — the specified model is not available."],
[/context_length/i, "Context window exceeded — the input is too large for this model."],
[/overloaded/i, "Anthropic is overloaded — please retry shortly."],
[/billing/i, "Billing issue — check your Anthropic account status."],
[/invalid_request/i, "Invalid request — the prompt or parameters may be malformed."],
];
export function formatAnthropicError(raw: string, exitCode?: number): string {
for (const [re, msg] of ANTHROPIC_ERROR_MAP) {
if (re.test(raw)) return msg;
}
try {
const parsed = JSON.parse(raw.trim());
if (parsed.type === "error" && parsed.error?.message) {
const em = parsed.error.message;
for (const [re, msg] of ANTHROPIC_ERROR_MAP) {
if (re.test(em)) return msg;
}
return em;
}
} catch { /* not JSON */ }
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
try {
const parsed = JSON.parse(trimmed);
if (parsed.type === "error" && parsed.error?.message) {
const em = parsed.error.message;
for (const [re, msg] of ANTHROPIC_ERROR_MAP) {
if (re.test(em)) return msg;
}
return em;
}
} catch { /* skip */ }
}
if (raw.trim()) return raw.trim().slice(0, 500);
return `claude process exited with code ${exitCode ?? "unknown"}`;
}
// =============================================================================
// Core: spawn claude CLI and stream all output
// =============================================================================
export interface RunClaudeOptions {
model?: string; // default: "sonnet"
agent?: string; // --agent <name> (from ~/.claude/agents/)
sessionId?: string; // --resume <id> (multi-turn)
noSessionPersistence?: boolean; // --no-session-persistence (ask-claude default)
enrichEditDiffs?: boolean; // enrich edit tool results with unified diffs
mcpConfigPath?: string; // --mcp-config <path> (e.g. pi-ask-bridge)
disallowedTools?: string[]; // --disallowed-tools (e.g. ["AskUserQuestion"])
// Extended-thinking effort level ("low"|"medium"|"high"|"xhigh"|"max").
// REQUIRED for thinking blocks to appear: in `-p` / stream-json mode the
// CLI does NOT honour the user's interactive `defaultThinkingLevel`
// setting — thinking_delta events are only emitted when `--effort` is
// passed explicitly. Pass "off" to leave the flag off entirely.
effort?: string;
extraEnv?: NodeJS.ProcessEnv; // merged on top of process.env for the child
cwd: string;
signal?: AbortSignal;
timeoutMs?: number; // default: 15 min
onUpdate: (partial: { blocks: StreamBlock[]; finalText: string }) => void;
}
export interface RunClaudeResult {
finalText: string;
blocks: StreamBlock[];
sessionId: string;
costUsd: number;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
}
export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise<RunClaudeResult> {
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
return new Promise((resolve, reject) => {
const args = ["-p", "--output-format", "stream-json", "--include-partial-messages"];
// Session mode: resume takes priority, then agent, then plain model
if (opts.sessionId) {
args.push("--resume", opts.sessionId);
} else if (opts.agent) {
args.push("--agent", opts.agent);
if (opts.model) args.push("--model", opts.model);
if (opts.noSessionPersistence) args.push("--no-session-persistence");
} else {
args.push("--model", opts.model ?? "sonnet");
if (opts.noSessionPersistence) args.push("--no-session-persistence");
}
// Extended-thinking effort — must be passed explicitly in -p mode;
// the interactive `defaultThinkingLevel` setting does NOT apply here.
// Callers pass "off" to suppress the flag (e.g. ask-claude where
// raw speed matters more than thought traces).
if (opts.effort && opts.effort !== "off") {
args.push("--effort", opts.effort);
}
// MCP config (e.g. pi-ask-bridge for routing AskUserQuestion-style
// requests through pi's native UI). Additive — does NOT pass
// --strict-mcp-config, so the user's other configured MCP servers
// (exa, sentry, …) remain available to Claude.
if (opts.mcpConfigPath) {
args.push("--mcp-config", opts.mcpConfigPath);
}
// Tool denylist — typically ["AskUserQuestion"] when the MCP
// server is providing a replacement.
if (opts.disallowedTools?.length) {
args.push("--disallowed-tools", opts.disallowedTools.join(","));
}
const proc = spawn("claude", args, {
stdio: ["pipe", "pipe", "pipe"],
cwd: opts.cwd,
env: opts.extraEnv ? { ...process.env, ...opts.extraEnv } : process.env,
});
try {
proc.stdin.write(prompt, "utf8");
proc.stdin.end();
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "EPIPE") reject(err);
}
let buffer = "";
const blocks: StreamBlock[] = [];
const pendingTools = new Map<number, { name: string; inputJson: string; id: string }>();
let sessionId = "";
let costUsd = 0, inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0;
const getOrCreateTextBlock = (): TextBlock => {
const last = blocks[blocks.length - 1];
if (last?.type === "text") return last;
const b: TextBlock = { type: "text", text: "" };
blocks.push(b);
return b;
};
const getOrCreateThinkingBlock = (): ThinkingBlock => {
const last = blocks[blocks.length - 1];
if (last?.type === "thinking") return last;
const b: ThinkingBlock = { type: "thinking", text: "" };
blocks.push(b);
return b;
};
const emit = () => {
const finalText = blocks.filter((b): b is TextBlock => b.type === "text").map((b) => b.text).join("");
opts.onUpdate({ blocks: [...blocks], finalText });
};
const processLine = (line: string) => {
if (!line.trim()) return;
let event: any;
try { event = JSON.parse(line); } catch { return; }
if (event.type === "stream_event") {
const e = event.event as any;
if (!e) return;
if (e.type === "content_block_start") {
const cb = e.content_block;
if (cb?.type === "tool_use") {
pendingTools.set(e.index as number, { name: cb.name, inputJson: "", id: cb.id });
}
} else if (e.type === "content_block_delta") {
const d = e.delta as any;
if (d?.type === "text_delta") {
getOrCreateTextBlock().text += d.text as string;
emit();
} else if (d?.type === "thinking_delta") {
getOrCreateThinkingBlock().text += d.thinking as string;
emit();
} else if (d?.type === "input_json_delta") {
const tool = pendingTools.get(e.index as number);
if (tool) tool.inputJson += d.partial_json as string ?? "";
}
} else if (e.type === "content_block_stop") {
const tool = pendingTools.get(e.index as number);
if (tool) {
// Claude CLI's --include-partial-messages can emit an `assistant`
// event with the completed tool_use BEFORE content_block_stop
// arrives. That path already pushed a block with the same id;
// update it in place instead of pushing a duplicate.
const existing = blocks.find(
(b): b is ToolBlock => b.type === "tool" && b.id === tool.id,
);
const target: ToolBlock = existing ?? {
type: "tool", id: tool.id, name: tool.name, inputJson: tool.inputJson,
};
if (existing) {
// Prefer the streamed inputJson (it's been accumulating and
// matches what claude-code actually executed).
existing.inputJson = tool.inputJson;
}
// For edit tools, read file context before the edit executes
if (tool.name.toLowerCase() === "edit" && !target.editContext) {
try {
const inp = JSON.parse(tool.inputJson) as Record<string, unknown>;
const fp = String(inp.file_path ?? inp.path ?? "");
const oldStr = String(inp.old_string ?? inp.oldText ?? "");
if (fp && oldStr) {
const fileLines = readFileSync(fp, "utf8").split("\n");
const oldLines = oldStr.split("\n");
let startIdx = -1;
outer: for (let i = 0; i <= fileLines.length - oldLines.length; i++) {
for (let j = 0; j < oldLines.length; j++) {
if (fileLines[i + j] !== oldLines[j]) continue outer;
}
startIdx = i;
break;
}
if (startIdx >= 0) {
target.editContext = {
before: fileLines.slice(Math.max(0, startIdx - 3), startIdx),
after: fileLines.slice(startIdx + oldLines.length, startIdx + oldLines.length + 3),
startLine: startIdx + 1,
};
}
}
} catch { /* ignore */ }
}
if (!existing) blocks.push(target);
pendingTools.delete(e.index as number);
emit();
}
} else if (e.type === "message_delta") {
const u = e.usage as any;
if (u) {
inputTokens = u.input_tokens ?? inputTokens;
outputTokens = u.output_tokens ?? outputTokens;
cacheReadTokens = u.cache_read_input_tokens ?? cacheReadTokens;
cacheWriteTokens = u.cache_creation_input_tokens ?? cacheWriteTokens;
}
}
} else if (event.type === "user") {
for (const c of (event.message?.content ?? []) as any[]) {
if (c.type !== "tool_result") continue;
// tool_result.content may be a plain string (typical for bash/read
// output) or an array of {type,text}/{type,image} blocks. Handle both.
let text: string;
if (typeof c.content === "string") {
text = c.content;
} else if (Array.isArray(c.content)) {
text = (c.content as any[])
.filter((x) => x.type === "text")
.map((x) => x.text as string)
.join("\n");
} else {
text = "";
}
const toolId = c.tool_use_id as string;
const toolBlock = blocks.findLast((b): b is ToolBlock => b.type === "tool" && b.id === toolId);
if (toolBlock && toolBlock.name.toLowerCase() === "edit" && !c.is_error && opts.enrichEditDiffs) {
try {
const inp = JSON.parse(toolBlock.inputJson) as Record<string, unknown>;
const oldStr = String(inp.old_string ?? inp.oldText ?? "");
const newStr = String(inp.new_string ?? inp.newText ?? "");
if (oldStr || newStr) {
const diff = buildEditDiff(oldStr, newStr);
text = `${text}\n\n--- edit diff ---\n${diff}\n--- end diff ---`;
}
} catch { /* ignore */ }
}
if (toolBlock) toolBlock.result = { text, isError: c.is_error === true };
emit();
}
} else if (event.type === "assistant") {
const content: any[] = (event as any).message?.content ?? [];
for (const cb of content) {
if (cb.type !== "tool_use") continue;
const exists = blocks.some((b): b is ToolBlock => b.type === "tool" && b.id === cb.id);
if (!exists) blocks.push({ type: "tool", id: cb.id, name: cb.name, inputJson: JSON.stringify(cb.input ?? {}) });
}
emit();
} else if (event.type === "result") {
sessionId = event.session_id ?? "";
costUsd = event.total_cost_usd ?? 0;
const u = event.usage as any;
if (u) {
inputTokens = u.input_tokens ?? inputTokens;
outputTokens = u.output_tokens ?? outputTokens;
cacheReadTokens = u.cache_read_input_tokens ?? cacheReadTokens;
cacheWriteTokens = u.cache_creation_input_tokens ?? cacheWriteTokens;
}
} else if (event.type === "error") {
const errMsg = event.error?.message ?? event.message ?? JSON.stringify(event);
reject(new Error(formatAnthropicError(errMsg)));
}
};
proc.stdout.on("data", (data: Buffer) => {
buffer += data.toString();
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) processLine(line);
});
let stderrOutput = "";
proc.stderr.on("data", (data: Buffer) => { stderrOutput += data.toString(); });
proc.on("close", (code) => {
clearTimeout(timeoutId);
if (buffer.trim()) processLine(buffer);
const finalText = blocks.filter((b): b is TextBlock => b.type === "text").map((b) => b.text).join("");
if (code !== 0) {
if (timeoutFired) {
reject(new Error(`Claude timed out after ${timeoutMs / 1000}s — the process was killed. Consider using a simpler prompt or a faster model.`));
} else {
const errMsg = formatAnthropicError(stderrOutput.trim(), code);
const detail = finalText ? ` (partial output: ${finalText.slice(0, 100)})` : "";
reject(new Error(errMsg + detail));
}
return;
}
resolve({ finalText, blocks, sessionId, costUsd, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens });
});
proc.on("error", (err) => {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
reject(new Error("Claude CLI not found — install it with `npm install -g @anthropic-ai/claude-code`"));
} else {
reject(err);
}
});
if (opts.signal) {
const kill = () => {
try { if (!proc.killed) proc.kill("SIGTERM"); } catch { /* ok */ }
setTimeout(() => { try { if (!proc.killed) proc.kill("SIGKILL"); } catch { /* ok */ } }, 3000);
};
if (opts.signal.aborted) kill();
else opts.signal.addEventListener("abort", kill, { once: true });
}
let timeoutFired = false;
const timeoutId = setTimeout(() => {
timeoutFired = true;
try { proc.kill("SIGTERM"); } catch { /* ok */ }
setTimeout(() => { try { if (!proc.killed) proc.kill("SIGKILL"); } catch { /* ok */ } }, 3000);
}, timeoutMs);
proc.prependListener("close", () => { clearTimeout(timeoutId); });
proc.prependListener("error", () => { clearTimeout(timeoutId); });
});
}
// =============================================================================
// RenderResult factory — produces the common result rendering for both extensions
// =============================================================================
export function renderClaudeResult(
result: { details?: ClaudeDetails; isPartial?: boolean },
theme: Theme,
opts?: { showSession?: boolean }
): Container | Text {
const d = result.details as ClaudeDetails | undefined;
if (!d) return new Text(theme.fg("muted", "…"), 0, 0);
const isDone = d.done && !result.isPartial;
const icon = isDone ? theme.fg("success", "✓ ") : theme.fg("warning", "⏳ ");
const resume = d.isResume ? theme.fg("dim", " ↩") : "";
const c = new Container();
c.addChild(new Text(icon + theme.fg("toolTitle", theme.bold(d.label)) + resume, 0, 0));
for (const block of d.blocks ?? []) {
if (block.type === "thinking" && block.text.trim()) {
c.addChild(new Text(theme.fg("dim", theme.italic(block.text.trimEnd())), 0, 0));
} else if (block.type === "tool") {
c.addChild(renderToolBlock(block, theme));
} else if (block.type === "text" && block.text.trim()) {
c.addChild(new Spacer(1));
if (isDone) {
c.addChild(new Markdown(block.text.trim(), 0, 0, getMarkdownTheme()));
} else {
c.addChild(new Text(theme.fg("dim", block.text.trimEnd()), 0, 0));
}
}
}
if (isDone) {
const usageLine = formatUsage(d);
const parts: string[] = [];
if (usageLine) parts.push(usageLine);
if (opts?.showSession && d.sessionId) parts.push(`session:${d.sessionId.slice(0, 8)}`);
if (parts.length > 0) {
c.addChild(new Spacer(1));
c.addChild(new Text(theme.fg("dim", parts.join(" ")), 0, 0));
}
}
return c;
}