/** * 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 = {}; 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 = {}; try { input = JSON.parse(block.inputJson); } catch { /* ok */ } // Strip / restyle synthetic envelopes like and // 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 or . // // 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 { // message // → strip tags, display message in bright bold red text = text.replace( /\s*([\s\S]*?)\s*<\/tool_use_error>/g, (_, inner: string) => SGR_BOLD_RED + inner + SGR_FG_RESET, ); // or // → strip tags, collapse whitespace to one line, truncate at ~100 chars // with a trailing "...", render in dim grey text = text.replace( /\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 (from ~/.claude/agents/) sessionId?: string; // --resume (multi-turn) noSessionPersistence?: boolean; // --no-session-persistence (ask-claude default) enrichEditDiffs?: boolean; // enrich edit tool results with unified diffs mcpConfigPath?: string; // --mcp-config (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 { 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(); 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; 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; 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; }