This commit is contained in:
Jonas H
2026-05-10 09:34:33 +02:00
parent ab93172f38
commit c7edbb1be0
3 changed files with 391 additions and 51 deletions

View File

@@ -277,7 +277,7 @@ function transformSpecialTags(text: string): string {
* 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";
const ORANGE_BG_FN = (s: string) => "\x1b[48;5;94m" + s + "\x1b[0m";
export function renderToolBlock(block: ToolBlock, theme: Theme): Container {
const c = new Container();
@@ -438,7 +438,7 @@ export interface RunClaudeOptions {
extraEnv?: NodeJS.ProcessEnv; // merged on top of process.env for the child
cwd: string;
signal?: AbortSignal;
timeoutMs?: number; // default: 15 min
timeoutMs?: number; // default: 30 min
onUpdate: (partial: { blocks: StreamBlock[]; finalText: string }) => void;
}
@@ -454,7 +454,7 @@ export interface RunClaudeResult {
}
export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise<RunClaudeResult> {
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
return new Promise((resolve, reject) => {
@@ -509,6 +509,18 @@ export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise
let buffer = "";
const blocks: StreamBlock[] = [];
const pendingTools = new Map<number, { name: string; inputJson: string; id: string }>();
// Track active thinking content blocks by content-block index so
// thinking_delta events can be routed to the exact block that
// content_block_start opened, and content_block_stop can decide
// whether to inject a "redacted" placeholder when nothing streamed.
//
// This matters for Claude Opus: the API returns Opus's reasoning as
// an encrypted signature only — it emits `content_block_start` with
// `type: "thinking"`, a `signature_delta`, and `content_block_stop`,
// but NEVER any `thinking_delta`. Without special-casing, the user
// sees nothing where thinking should be; with the placeholder, they
// at least know the model thought (just off-record).
const pendingThinkings = new Map<number, ThinkingBlock>();
let sessionId = "";
let costUsd = 0, inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0;
@@ -521,7 +533,14 @@ export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise
return b;
};
const getOrCreateThinkingBlock = (): ThinkingBlock => {
// Thinking block lookup: prefer the one registered by content_block_start
// for `index`; fall back to the tail-merge behaviour for CLIs / edge cases
// that never emit content_block_start for thinking.
const getOrCreateThinkingBlock = (index?: number): ThinkingBlock => {
if (index !== undefined) {
const b = pendingThinkings.get(index);
if (b) return b;
}
const last = blocks[blocks.length - 1];
if (last?.type === "thinking") return last;
const b: ThinkingBlock = { type: "thinking", text: "" };
@@ -547,6 +566,17 @@ export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise
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 (cb?.type === "thinking") {
// Eagerly push an empty thinking block so renderers can show
// *something* the moment Opus opens a thinking block — even
// if no thinking_delta ever arrives (it won't, for Opus).
// Initial text from content_block is usually "" but honour
// it if the CLI ever populates it.
const initial = typeof cb.thinking === "string" ? cb.thinking : "";
const b: ThinkingBlock = { type: "thinking", text: initial };
blocks.push(b);
pendingThinkings.set(e.index as number, b);
emit();
}
} else if (e.type === "content_block_delta") {
const d = e.delta as any;
@@ -554,13 +584,24 @@ export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise
getOrCreateTextBlock().text += d.text as string;
emit();
} else if (d?.type === "thinking_delta") {
getOrCreateThinkingBlock().text += d.thinking as string;
getOrCreateThinkingBlock(e.index as number).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") {
// Finalise a thinking block: if nothing streamed (Opus case),
// replace the empty text with a visible placeholder so the
// user knows the model DID think, just not on-record.
const think = pendingThinkings.get(e.index as number);
if (think) {
if (!think.text.trim()) {
think.text = "_(thinking privately — this model's reasoning isn't streamed as plaintext)_";
}
pendingThinkings.delete(e.index as number);
emit();
}
const tool = pendingTools.get(e.index as number);
if (tool) {
// Claude CLI's --include-partial-messages can emit an `assistant`
@@ -635,7 +676,13 @@ export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise
text = "";
}
const toolId = c.tool_use_id as string;
const toolBlock = blocks.findLast((b): b is ToolBlock => b.type === "tool" && b.id === toolId);
// findLast is ES2023; use a backwards loop so the ES2022
// target compiles cleanly without a lib override.
let toolBlock: ToolBlock | undefined;
for (let _i = blocks.length - 1; _i >= 0; _i--) {
const _b = blocks[_i];
if (_b.type === "tool" && _b.id === toolId) { toolBlock = _b; break; }
}
if (toolBlock && toolBlock.name.toLowerCase() === "edit" && !c.is_error && opts.enrichEditDiffs) {
try {
const inp = JSON.parse(toolBlock.inputJson) as Record<string, unknown>;
@@ -692,7 +739,7 @@ export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise
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 errMsg = formatAnthropicError(stderrOutput.trim(), code ?? undefined);
const detail = finalText ? ` (partial output: ${finalText.slice(0, 100)})` : "";
reject(new Error(errMsg + detail));
}