From c7edbb1be0375d995d28515fec8204c617330a75 Mon Sep 17 00:00:00 2001 From: Jonas H Date: Sun, 10 May 2026 09:34:33 +0200 Subject: [PATCH] pi --- pi/.pi/agent/extensions/chat-claude.ts | 339 ++++++++++++++++-- pi/.pi/agent/shared/claude-stream.ts | 61 +++- ...35138e.json => wezterm-sync-ba8a76f5.json} | 42 +-- 3 files changed, 391 insertions(+), 51 deletions(-) rename pi/.pi/agent/themes/{wezterm-sync-9a35138e.json => wezterm-sync-ba8a76f5.json} (75%) diff --git a/pi/.pi/agent/extensions/chat-claude.ts b/pi/.pi/agent/extensions/chat-claude.ts index 29c258e..14238b3 100644 --- a/pi/.pi/agent/extensions/chat-claude.ts +++ b/pi/.pi/agent/extensions/chat-claude.ts @@ -27,7 +27,7 @@ import { closeSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { CustomEditor, getMarkdownTheme } from "@mariozechner/pi-coding-agent"; +import { copyToClipboard, CustomEditor, getMarkdownTheme } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI, KeybindingsManager } from "@mariozechner/pi-coding-agent"; import { Box, Container, matchesKey, Markdown, Spacer, Text, truncateToWidth, TUI, visibleWidth, type Component, type EditorTheme } from "@mariozechner/pi-tui"; import { @@ -43,7 +43,7 @@ import { askSingleQuestionWithInlineNote } from "./pi-ask-tool/ask-inline-ui.js" // Orange styling // --------------------------------------------------------------------------- const ORANGE = "\x1b[38;5;208m"; // pumpkin / tangerine -const ORANGE_DIM = "\x1b[38;5;130m"; +const ORANGE_DIM = "\x1b[38;5;94m"; const RESET = "\x1b[0m"; const BOLD = "\x1b[1m"; const orange = (s: string) => ORANGE + s + RESET; @@ -184,6 +184,25 @@ const MODELS = ["haiku", "sonnet", "opus"] as const; type Model = (typeof MODELS)[number]; const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); +// UI-facing model slot → actual `claude --model ` argument. +// +// `opus` is pinned to claude-opus-4-6 on purpose: Opus 4.7 (what the plain +// `opus` alias currently resolves to) returns thinking as an encrypted +// signature only — no `thinking_delta` events ever stream, so the italic +// thinking-block rendering stays blank the entire turn. 4.6 streams +// plaintext thinking normally, so pinning here restores the feature for +// the `opus` slot. Haiku/Sonnet use the plain alias (newest). +// +// We also pin haiku/sonnet to their CLI aliases for symmetry — if a +// future CLI alias bump lands on a model with the same redacted-thinking +// behaviour, we can downgrade the pin here without touching the rest of +// the extension. +const CLI_MODEL: Record = { + haiku: "haiku", + sonnet: "sonnet", + opus: "claude-opus-4-6", +}; + // --------------------------------------------------------------------------- // Past-session discovery (used by /claude-resume). // @@ -418,8 +437,15 @@ function loadSessionTurns(sessionId: string, cwd: string, fallbackModel: Model): sawToolResult = true; const { text } = tool_resultText(block.content); const isError = block.is_error === true; - if (current) { - for (const tb of current.blocks) { + // TS 5.x loses narrowing of the `let current` that is + // reassigned by the `flush` closure — even a `const cur + // = current` annotation doesn't survive the for-of + // header re-evaluation. A direct cast on the `.blocks` + // access is the minimal escape hatch confirmed to work + // in isolation tests with TS 5.9. + if (current !== null) { + const curBlocks = (current as AssistantTurn).blocks; + for (const tb of curBlocks) { if (tb.type === "tool" && tb.id === block.tool_use_id) { tb.result = { text, isError }; break; @@ -503,6 +529,120 @@ interface ChatSessionDetails { turns: ChatTurn[]; } +// --------------------------------------------------------------------------- +// Todo extraction — scan the session for the most recent TodoWrite tool call +// and return its todos array. Rendered BETWEEN the orange-bordered +// conversation and the mode banner by the chat-claude widget so the +// current task list is always visible without scrolling through history. +// +// Only the latest TodoWrite wins (earlier ones are superseded); empty or +// malformed inputs are treated as "no todos" and suppress the section. +// --------------------------------------------------------------------------- +type TodoStatus = "completed" | "in_progress" | "pending"; +interface Todo { + content: string; + status: TodoStatus; + activeForm: string; +} +function getLatestTodos(details: ChatSessionDetails | null): Todo[] | null { + if (!details) return null; + for (let i = details.turns.length - 1; i >= 0; i--) { + const turn = details.turns[i]; + if (turn.role !== "assistant") continue; + for (let j = turn.blocks.length - 1; j >= 0; j--) { + const block = turn.blocks[j]; + if (block.type !== "tool") continue; + if (block.name !== "TodoWrite") continue; + try { + const input = JSON.parse(block.inputJson); + if (Array.isArray(input?.todos) && input.todos.length > 0) { + return input.todos as Todo[]; + } + // Hit the latest TodoWrite but it's empty/malformed — stop, + // don't fall through to an older one (the user cleared it). + return null; + } catch { + return null; + } + } + } + return null; +} + +// Cap so a runaway todo list can't push the editor off-screen. In practice +// lists stay well under this; when they don't, we render the first N-1 items +// plus a "… X more" notice. Non-completed items are prioritised over +// completed ones in the visible slice, since the point of surfacing todos +// on-screen is to show what's left to do. +const MAX_TODO_LINES = 12; +function sliceTodosForDisplay(todos: Todo[]): { shown: Todo[]; hidden: number } { + if (todos.length <= MAX_TODO_LINES) return { shown: todos, hidden: 0 }; + const budget = MAX_TODO_LINES - 1; // reserve one line for the "… more" notice + const nonCompleted = todos.filter((t) => t.status !== "completed"); + const completed = todos.filter((t) => t.status === "completed"); + const shown: Todo[] = []; + // Non-completed items come first so in-flight / pending work is always + // visible; any leftover budget is filled with completed items (for + // context) in original order. + for (const t of nonCompleted) { + if (shown.length >= budget) break; + shown.push(t); + } + for (const t of completed) { + if (shown.length >= budget) break; + shown.push(t); + } + return { shown, hidden: todos.length - shown.length }; +} + +// --------------------------------------------------------------------------- +// Code block extraction — raw fenced code from the session's text blocks. +// +// Used by the Ctrl+Shift+C shortcut to copy clean, unrendered code directly +// from the parsed JSON stream, avoiding the ANSI escape sequences, stray +// indentation, and line-continuation artefacts that terminal selection gives. +// +// Blocks are returned newest-first (last assistant turn first; within a turn, +// last code fence first) so the most recent snippet is always at index 0. +// --------------------------------------------------------------------------- +interface ExtractedCodeBlock { + lang: string; // language tag after the opening fence ("" when absent) + code: string; // raw content between the fences (no surrounding ```) + label: string; // compact one-line description for the picker UI +} + +function extractCodeBlocksFromSession(details: ChatSessionDetails): ExtractedCodeBlock[] { + const out: ExtractedCodeBlock[] = []; + for (let ti = details.turns.length - 1; ti >= 0; ti--) { + const turn = details.turns[ti]; + if (turn.role !== "assistant") continue; + const turnBlocks: ExtractedCodeBlock[] = []; + for (const block of turn.blocks) { + if (block.type !== "text") continue; + // Match fenced code: ```lang\n…content…``` (lang optional) + // \r? handles CRLF transcripts; [\s\S]*? is non-greedy so nested + // fences (rare but possible in prose) are handled correctly. + const fence = /```(\w*)\r?\n([\s\S]*?)```/g; + let m: RegExpExecArray | null; + while ((m = fence.exec(block.text)) !== null) { + const lang = m[1] ?? ""; + const code = m[2] ?? ""; + if (!code.trim()) continue; // skip empty fences + // Build a compact one-line label: [lang] first-non-blank-line + const firstLine = code.split("\n").find((l) => l.trim()) ?? ""; + const preview = firstLine.length > 55 + ? firstLine.slice(0, 52).trimEnd() + "…" + : firstLine; + const langTag = lang ? `[${lang}] ` : ""; + turnBlocks.push({ lang, code, label: `${langTag}${preview}` }); + } + } + // Reverse within the turn so the last fence in that turn comes first. + for (let i = turnBlocks.length - 1; i >= 0; i--) out.push(turnBlocks[i]!); + } + return out; +} + // ============================================================================= // Extension entry point // ============================================================================= @@ -535,18 +675,29 @@ interface ChatClaudePersistedState { // so the user's choice survives the extension teardown the same way // resumable session ids do. effort: Effort; + // Prompts typed in chat mode, oldest-first. Capped at MAX_PROMPT_HISTORY. + // Replayed into the editor on every ChatEscEditor creation so up-arrow + // history is available immediately in any new chat session. + promptHistory: string[]; } const CHAT_CLAUDE_STATE_KEY = "__pi_chat_claude_persisted__"; +// Maximum number of prompts to persist. The Editor caps its own in-memory +// list at 100; we persist more so the most recent 100 are always available +// even after many reloads without hitting the per-instance limit. +const MAX_PROMPT_HISTORY = 200; + function getPersistedState(): ChatClaudePersistedState { const g = globalThis as unknown as Record; let state = g[CHAT_CLAUDE_STATE_KEY]; if (!state) { - state = { sessions: new Map(), effort: DEFAULT_EFFORT }; + state = { sessions: new Map(), effort: DEFAULT_EFFORT, promptHistory: [] }; g[CHAT_CLAUDE_STATE_KEY] = state; } // Back-fill for any persisted state written by an older revision of // the extension (pre-/claude-effort) that didn't carry an effort field. if (!state.effort) state.effort = DEFAULT_EFFORT; + // Back-fill for pre-promptHistory revisions. + if (!state.promptHistory) state.promptHistory = []; return state; } @@ -574,6 +725,11 @@ export default function (pi: ExtensionAPI) { // current chat-claude-session message. let tuiRef: { requestRender: () => void } | null = null; + // Reference to the active ChatEscEditor instance so we can call + // addToHistory() on it after each prompt submission, making the new entry + // immediately navigable with the up-arrow inside the same session. + let editorRef: ChatEscEditor | null = null; + // The in-flight chat session's `details` object. Stored by reference so // mutations here are reflected in the CustomMessage already displayed // in pi's conversation. Null between chat-mode sessions. @@ -625,7 +781,7 @@ export default function (pi: ExtensionAPI) { const md = getMarkdownTheme(); if (turn.role === "user") { - container.addChild(new Text(orangeBold("▶ you"), 1, 0)); + container.addChild(new Text(orangeBold(" you"), 1, 0)); container.addChild(new Spacer(1)); container.addChild(new Markdown(turn.text.trim(), 1, 0, md)); return; @@ -635,24 +791,15 @@ export default function (pi: ExtensionAPI) { const icon = turn.cancelled ? orange("◇ ") : turn.error ? theme.fg("error", "✗ ") - : turn.isResume ? orange("↩ ") + : turn.isResume ? orange(" ") : orange("◆ "); const header = icon + orangeBold(`Claude ${capitalize(turn.model)}`) + (turn.sessionId ? theme.fg("dim", ` session:${turn.sessionId.slice(0, 8)}`) : "") - + (!turn.done ? theme.fg("warning", " ⏳") : ""); + + (!turn.done ? theme.fg("warning", " ") : ""); container.addChild(new Text(header, 1, 0)); container.addChild(new Spacer(1)); - if (turn.cancelled) { - container.addChild(new Text(orange("(Cancelled)"), 1, 0)); - return; - } - if (turn.error) { - container.addChild(new Text(theme.fg("error", `Error: ${turn.error}`), 1, 0)); - return; - } - // Defensive dedup — see claude-stream.ts for the root-cause fix, but // keep a safety net here in case a future Claude CLI change re-orders // events differently. @@ -688,7 +835,16 @@ export default function (pi: ExtensionAPI) { } } - if (turn.done) { + // Render the terminal notice AFTER any partial blocks so streamed + // output accumulated before a timeout / abort / error is preserved + // and visible rather than being silently discarded. + if (turn.cancelled) { + if (addedAny) container.addChild(new Spacer(1)); + container.addChild(new Text(orange("(Cancelled)"), 1, 0)); + } else if (turn.error) { + if (addedAny) container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("error", `Error: ${turn.error}`), 1, 0)); + } else if (turn.done) { const usage = formatUsage(turn as any); if (usage) { container.addChild(new Spacer(1)); @@ -816,17 +972,55 @@ export default function (pi: ExtensionAPI) { tuiRef = tui; // ← captured for live streaming re-renders return { invalidate: () => {}, - render: () => { - const rail = orange("▌ "); + render: (width: number) => { + const rail = orange("▌ "); + const out: string[] = []; + + // ── Todos (if any) ──────────────────────────────────── + // Sourced from the most recent TodoWrite tool call in + // this chat session. Rendered BEFORE the mode banner so + // the layout reads, top→bottom: + // orange-bordered conversation + // ▌ ☒ completed todo + // ▌ ▸ current in-progress todo (activeForm) + // ▌ ☐ pending todo + // ▌ ◆ CLAUDE CHAT MODE … + // ▌ Type to chat · … + const todos = getLatestTodos(currentDetails); + if (todos && todos.length > 0) { + const { shown, hidden } = sliceTodosForDisplay(todos); + for (const todo of shown) { + let marker: string; + let text: string; + if (todo.status === "completed") { + marker = theme.fg("success", "☒"); + text = theme.fg("dim", todo.content); + } else if (todo.status === "in_progress") { + marker = orangeBold("▸"); + text = orangeBold(todo.activeForm || todo.content); + } else { + marker = orangeDim("☐"); + text = todo.content; + } + out.push(truncateToWidth(rail + marker + " " + text, width, "…", false)); + } + if (hidden > 0) { + const notice = `… ${hidden} more todo${hidden === 1 ? "" : "s"} hidden`; + out.push(truncateToWidth(rail + theme.fg("dim", notice), width, "…", false)); + } + } + + // ── Mode banner ────────────────────────────────────── const title = orangeBold("◆ CLAUDE CHAT MODE"); const modelLabel = orangeBold(modelUp); const sessionTag = orangeDim("session:" + short); const effortTag = orangeDim("effort:" + persisted.effort); - const running = isGenerating ? " " + orange("⏳ streaming…") : ""; + const running = isGenerating ? " " + orange(" streaming…") : ""; const line1 = rail + title + " " + modelLabel + " " + sessionTag + " " + effortTag + running; const line2 = rail + theme.fg("dim", "Type to chat · /claude haiku|sonnet|opus · /claude-new · /claude-effort · /claude-end · /claude-abort"); - return [line1, line2]; + out.push(line1, line2); + return out; }, }; }, { placement: "aboveEditor" }); @@ -848,6 +1042,22 @@ export default function (pi: ExtensionAPI) { // setCustomEditorComponent copies onto the custom editor at install time // (see interactive-mode.js setCustomEditorComponent, ~line 1258). class ChatEscEditor extends CustomEditor { + constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) { + super(tui, theme, keybindings); + // Store a module-level reference so runChatTurn can feed the new + // prompt into the editor's history after each successful submission. + editorRef = this; + // Replay persisted history oldest-first: addToHistory() unshifts each + // entry, so the last call's text lands at index 0 (most recent) and + // up-arrow shows it first — exactly the expected shell-history UX. + // We cap the replay at 100 (the Editor's own internal limit) so the + // unshift loop doesn't silently discard entries mid-way. + const toReplay = persisted.promptHistory.slice(-100); + for (const text of toReplay) { + this.addToHistory(text); + } + } + handleInput(data: string): void { if (matchesKey(data, "escape") && isGenerating && currentAbort) { try { currentAbort.abort(); } catch { /* ok */ } @@ -936,6 +1146,7 @@ export default function (pi: ExtensionAPI) { askBridge = null; // Restore pi's default editor (undoes ChatEscEditor from enterChatMode). if (ctx?.hasUI) ctx.ui.setEditorComponent(undefined); + editorRef = null; syncUI(ctx); if (ctx?.hasUI) ctx.ui.notify("Exited chat mode — back to normal pi.", "info"); } @@ -964,6 +1175,21 @@ export default function (pi: ExtensionAPI) { const model = chatMode; const details = ensureSessionMessage(); + // Persist the prompt so it survives /reload and is available in future + // chat sessions. We record it here — before the async Claude call — + // so cancellations and errors still land in history. + // Deduplicate: skip if identical to the most recent persisted entry. + const trimmedPrompt = userText.trim(); + if (trimmedPrompt && persisted.promptHistory.at(-1) !== trimmedPrompt) { + persisted.promptHistory.push(trimmedPrompt); + if (persisted.promptHistory.length > MAX_PROMPT_HISTORY) { + persisted.promptHistory = persisted.promptHistory.slice(-MAX_PROMPT_HISTORY); + } + } + // Also push into the live editor so the entry is navigable immediately + // (without requiring a reload to replay from persisted state). + if (trimmedPrompt) editorRef?.addToHistory(trimmedPrompt); + // Append user turn + placeholder assistant turn up front so the // border extends as soon as the user hits enter. details.turns.push({ role: "user", text: userText }); @@ -986,7 +1212,9 @@ export default function (pi: ExtensionAPI) { try { const r = await runClaude(userText, { - model, + // Resolve UI slot ("opus") → CLI model id ("claude-opus-4-6") + // so Opus streams plaintext thinking (4.7 redacts it). + model: CLI_MODEL[model], sessionId: existingSession, cwd: ctx.cwd, signal: currentAbort.signal, @@ -1337,6 +1565,71 @@ export default function (pi: ExtensionAPI) { // pi.registerShortcut("escape", …) is silently ignored. ESC-to-abort is // wired via the ChatEscEditor custom editor installed in enterChatMode. + // ── Raw code copy shortcut ─────────────────────────────────────────────── + // Ctrl+Shift+C copies the raw, unrendered content of a fenced code block + // from the current chat-claude session by reading directly from the parsed + // JSON stream — bypassing ANSI sequences, stray indentation, and + // line-continuation garbage that normal terminal selection produces. + // + // 0 blocks found → notify; nothing copied + // 1 block found → copy immediately + notify + // N blocks found → inline picker (newest first) → copy selected + notify + // + // Note: most terminal emulators handle Ctrl+Shift+C at the VTE layer + // (before the app sees it) so this shortcut is only reachable when + // Kitty keyboard protocol is active and the terminal forwards the combo. + // It does NOT intercept the terminal's own clipboard mechanism when pi + // is not the foreground process receiving extended key events. + pi.registerShortcut("ctrl+shift+c", { + description: "Copy a raw fenced code block from the current Claude chat session (bypasses ANSI rendering).", + handler: async (ctx) => { + if (!currentDetails) { + ctx.ui.notify( + "No active chat-claude session — start one with /claude first.", + "info", + ); + return; + } + const blocks = extractCodeBlocksFromSession(currentDetails); + if (blocks.length === 0) { + ctx.ui.notify( + "No fenced code blocks found in the current chat-claude session.", + "info", + ); + return; + } + + let chosen: ExtractedCodeBlock; + + if (blocks.length === 1 || !ctx.hasUI) { + // Single block or no UI — copy the newest (index 0) directly. + chosen = blocks[0]!; + } else { + // Multiple blocks — present a picker, numbered for uniqueness. + // Number prefix guarantees distinct labels even when two blocks + // share the same first line. + const labels = blocks.map((b, i) => `${i + 1}. ${b.label}`); + const pick = await askSingleQuestionWithInlineNote(ctx.ui, { + question: `${blocks.length} code blocks in this session — pick one to copy:`, + options: labels.map((label) => ({ label })), + recommended: 0, // default: newest block + }); + if (pick.selectedOptions.length === 0) return; // user cancelled + const idx = labels.indexOf(pick.selectedOptions[0] ?? ""); + if (idx < 0) return; + chosen = blocks[idx]!; + } + + copyToClipboard(chosen.code); + const lines = chosen.code.split("\n").length; + const langNote = chosen.lang ? ` (${chosen.lang})` : ""; + ctx.ui.notify( + `Copied${langNote} · ${lines} line${lines === 1 ? "" : "s"}`, + "success", + ); + }, + }); + // ── Message renderer ───────────────────────────────────────────────────── // ONE custom message type holds the WHOLE chat-mode session. Returning a // live component (render reads `details.turns` on every frame) lets diff --git a/pi/.pi/agent/shared/claude-stream.ts b/pi/.pi/agent/shared/claude-stream.ts index 7839594..8010790 100644 --- a/pi/.pi/agent/shared/claude-stream.ts +++ b/pi/.pi/agent/shared/claude-stream.ts @@ -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 { - 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(); + // 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(); 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; @@ -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)); } diff --git a/pi/.pi/agent/themes/wezterm-sync-9a35138e.json b/pi/.pi/agent/themes/wezterm-sync-ba8a76f5.json similarity index 75% rename from pi/.pi/agent/themes/wezterm-sync-9a35138e.json rename to pi/.pi/agent/themes/wezterm-sync-ba8a76f5.json index 6e6e989..5b685f1 100644 --- a/pi/.pi/agent/themes/wezterm-sync-9a35138e.json +++ b/pi/.pi/agent/themes/wezterm-sync-ba8a76f5.json @@ -1,24 +1,24 @@ { "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", - "name": "wezterm-sync-9a35138e", + "name": "wezterm-sync-ba8a76f5", "vars": { - "bg": "#faf4ed", - "fg": "#2a2a2a", - "accent": "#7b4fc4", - "accentAlt": "#c45a1c", - "link": "#1a7db5", - "error": "#d1344f", - "success": "#1e9b52", - "warning": "#b8890f", - "muted": "#73716e", - "dim": "#9c9995", - "borderMuted": "#c6c2bc", - "selectedBg": "#eee8e1", - "userMsgBg": "#f2ece5", - "toolPendingBg": "#f5efe8", - "toolSuccessBg": "#e0e9da", - "toolErrorBg": "#f5ddda", - "customMsgBg": "#f0e7ea" + "bg": "#1c2433", + "fg": "#afbbd2", + "accent": "#b78aff", + "accentAlt": "#ff955c", + "link": "#69c3ff", + "error": "#ff738a", + "success": "#3cec85", + "warning": "#eacd61", + "muted": "#7c869a", + "dim": "#5e687b", + "borderMuted": "#414a5b", + "selectedBg": "#28303f", + "userMsgBg": "#242c3b", + "toolPendingBg": "#212938", + "toolSuccessBg": "#203c3d", + "toolErrorBg": "#372d3d", + "customMsgBg": "#282c43" }, "colors": { "accent": "accent", @@ -74,8 +74,8 @@ "bashMode": "success" }, "export": { - "pageBg": "#fffcf5", - "cardBg": "#faf4ed", - "infoBg": "#f2e7d2" + "pageBg": "#141c2b", + "cardBg": "#1c2433", + "infoBg": "#353839" } } \ No newline at end of file