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

@@ -27,7 +27,7 @@
import { closeSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs"; import { closeSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; 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 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 { Box, Container, matchesKey, Markdown, Spacer, Text, truncateToWidth, TUI, visibleWidth, type Component, type EditorTheme } from "@mariozechner/pi-tui";
import { import {
@@ -43,7 +43,7 @@ import { askSingleQuestionWithInlineNote } from "./pi-ask-tool/ask-inline-ui.js"
// Orange styling // Orange styling
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const ORANGE = "\x1b[38;5;208m"; // pumpkin / tangerine 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 RESET = "\x1b[0m";
const BOLD = "\x1b[1m"; const BOLD = "\x1b[1m";
const orange = (s: string) => ORANGE + s + RESET; const orange = (s: string) => ORANGE + s + RESET;
@@ -184,6 +184,25 @@ const MODELS = ["haiku", "sonnet", "opus"] as const;
type Model = (typeof MODELS)[number]; type Model = (typeof MODELS)[number];
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
// UI-facing model slot → actual `claude --model <id>` 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<Model, string> = {
haiku: "haiku",
sonnet: "sonnet",
opus: "claude-opus-4-6",
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Past-session discovery (used by /claude-resume). // Past-session discovery (used by /claude-resume).
// //
@@ -418,8 +437,15 @@ function loadSessionTurns(sessionId: string, cwd: string, fallbackModel: Model):
sawToolResult = true; sawToolResult = true;
const { text } = tool_resultText(block.content); const { text } = tool_resultText(block.content);
const isError = block.is_error === true; const isError = block.is_error === true;
if (current) { // TS 5.x loses narrowing of the `let current` that is
for (const tb of current.blocks) { // 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) { if (tb.type === "tool" && tb.id === block.tool_use_id) {
tb.result = { text, isError }; tb.result = { text, isError };
break; break;
@@ -503,6 +529,120 @@ interface ChatSessionDetails {
turns: ChatTurn[]; 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 // Extension entry point
// ============================================================================= // =============================================================================
@@ -535,18 +675,29 @@ interface ChatClaudePersistedState {
// so the user's choice survives the extension teardown the same way // so the user's choice survives the extension teardown the same way
// resumable session ids do. // resumable session ids do.
effort: Effort; 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__"; 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 { function getPersistedState(): ChatClaudePersistedState {
const g = globalThis as unknown as Record<string, ChatClaudePersistedState>; const g = globalThis as unknown as Record<string, ChatClaudePersistedState>;
let state = g[CHAT_CLAUDE_STATE_KEY]; let state = g[CHAT_CLAUDE_STATE_KEY];
if (!state) { if (!state) {
state = { sessions: new Map<Model, string>(), effort: DEFAULT_EFFORT }; state = { sessions: new Map<Model, string>(), effort: DEFAULT_EFFORT, promptHistory: [] };
g[CHAT_CLAUDE_STATE_KEY] = state; g[CHAT_CLAUDE_STATE_KEY] = state;
} }
// Back-fill for any persisted state written by an older revision of // Back-fill for any persisted state written by an older revision of
// the extension (pre-/claude-effort) that didn't carry an effort field. // the extension (pre-/claude-effort) that didn't carry an effort field.
if (!state.effort) state.effort = DEFAULT_EFFORT; if (!state.effort) state.effort = DEFAULT_EFFORT;
// Back-fill for pre-promptHistory revisions.
if (!state.promptHistory) state.promptHistory = [];
return state; return state;
} }
@@ -574,6 +725,11 @@ export default function (pi: ExtensionAPI) {
// current chat-claude-session message. // current chat-claude-session message.
let tuiRef: { requestRender: () => void } | null = null; 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 // The in-flight chat session's `details` object. Stored by reference so
// mutations here are reflected in the CustomMessage already displayed // mutations here are reflected in the CustomMessage already displayed
// in pi's conversation. Null between chat-mode sessions. // in pi's conversation. Null between chat-mode sessions.
@@ -625,7 +781,7 @@ export default function (pi: ExtensionAPI) {
const md = getMarkdownTheme(); const md = getMarkdownTheme();
if (turn.role === "user") { 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 Spacer(1));
container.addChild(new Markdown(turn.text.trim(), 1, 0, md)); container.addChild(new Markdown(turn.text.trim(), 1, 0, md));
return; return;
@@ -635,24 +791,15 @@ export default function (pi: ExtensionAPI) {
const icon = const icon =
turn.cancelled ? orange("◇ ") turn.cancelled ? orange("◇ ")
: turn.error ? theme.fg("error", "✗ ") : turn.error ? theme.fg("error", "✗ ")
: turn.isResume ? orange(" ") : turn.isResume ? orange(" ")
: orange("◆ "); : orange("◆ ");
const header = const header =
icon + orangeBold(`Claude ${capitalize(turn.model)}`) icon + orangeBold(`Claude ${capitalize(turn.model)}`)
+ (turn.sessionId ? theme.fg("dim", ` session:${turn.sessionId.slice(0, 8)}`) : "") + (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 Text(header, 1, 0));
container.addChild(new Spacer(1)); 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 // 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 // keep a safety net here in case a future Claude CLI change re-orders
// events differently. // 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); const usage = formatUsage(turn as any);
if (usage) { if (usage) {
container.addChild(new Spacer(1)); container.addChild(new Spacer(1));
@@ -816,17 +972,55 @@ export default function (pi: ExtensionAPI) {
tuiRef = tui; // ← captured for live streaming re-renders tuiRef = tui; // ← captured for live streaming re-renders
return { return {
invalidate: () => {}, invalidate: () => {},
render: () => { render: (width: number) => {
const rail = orange("▌ "); 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 title = orangeBold("◆ CLAUDE CHAT MODE");
const modelLabel = orangeBold(modelUp); const modelLabel = orangeBold(modelUp);
const sessionTag = orangeDim("session:" + short); const sessionTag = orangeDim("session:" + short);
const effortTag = orangeDim("effort:" + persisted.effort); 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 line1 = rail + title + " " + modelLabel + " " + sessionTag + " " + effortTag + running;
const line2 = rail + theme.fg("dim", const line2 = rail + theme.fg("dim",
"Type to chat · /claude haiku|sonnet|opus · /claude-new · /claude-effort · /claude-end · /claude-abort"); "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" }); }, { placement: "aboveEditor" });
@@ -848,6 +1042,22 @@ export default function (pi: ExtensionAPI) {
// setCustomEditorComponent copies onto the custom editor at install time // setCustomEditorComponent copies onto the custom editor at install time
// (see interactive-mode.js setCustomEditorComponent, ~line 1258). // (see interactive-mode.js setCustomEditorComponent, ~line 1258).
class ChatEscEditor extends CustomEditor { 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 { handleInput(data: string): void {
if (matchesKey(data, "escape") && isGenerating && currentAbort) { if (matchesKey(data, "escape") && isGenerating && currentAbort) {
try { currentAbort.abort(); } catch { /* ok */ } try { currentAbort.abort(); } catch { /* ok */ }
@@ -936,6 +1146,7 @@ export default function (pi: ExtensionAPI) {
askBridge = null; askBridge = null;
// Restore pi's default editor (undoes ChatEscEditor from enterChatMode). // Restore pi's default editor (undoes ChatEscEditor from enterChatMode).
if (ctx?.hasUI) ctx.ui.setEditorComponent(undefined); if (ctx?.hasUI) ctx.ui.setEditorComponent(undefined);
editorRef = null;
syncUI(ctx); syncUI(ctx);
if (ctx?.hasUI) ctx.ui.notify("Exited chat mode — back to normal pi.", "info"); 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 model = chatMode;
const details = ensureSessionMessage(); 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 // Append user turn + placeholder assistant turn up front so the
// border extends as soon as the user hits enter. // border extends as soon as the user hits enter.
details.turns.push({ role: "user", text: userText }); details.turns.push({ role: "user", text: userText });
@@ -986,7 +1212,9 @@ export default function (pi: ExtensionAPI) {
try { try {
const r = await runClaude(userText, { 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, sessionId: existingSession,
cwd: ctx.cwd, cwd: ctx.cwd,
signal: currentAbort.signal, signal: currentAbort.signal,
@@ -1337,6 +1565,71 @@ export default function (pi: ExtensionAPI) {
// pi.registerShortcut("escape", …) is silently ignored. ESC-to-abort is // pi.registerShortcut("escape", …) is silently ignored. ESC-to-abort is
// wired via the ChatEscEditor custom editor installed in enterChatMode. // 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 ───────────────────────────────────────────────────── // ── Message renderer ─────────────────────────────────────────────────────
// ONE custom message type holds the WHOLE chat-mode session. Returning a // ONE custom message type holds the WHOLE chat-mode session. Returning a
// live component (render reads `details.turns` on every frame) lets // live component (render reads `details.turns` on every frame) lets

View File

@@ -277,7 +277,7 @@ function transformSpecialTags(text: string): string {
* the "staircase" where the banners have different widths is an intentional * the "staircase" where the banners have different widths is an intentional
* stylistic cue, not a rendering glitch. * 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 { export function renderToolBlock(block: ToolBlock, theme: Theme): Container {
const c = new Container(); const c = new Container();
@@ -438,7 +438,7 @@ export interface RunClaudeOptions {
extraEnv?: NodeJS.ProcessEnv; // merged on top of process.env for the child extraEnv?: NodeJS.ProcessEnv; // merged on top of process.env for the child
cwd: string; cwd: string;
signal?: AbortSignal; signal?: AbortSignal;
timeoutMs?: number; // default: 15 min timeoutMs?: number; // default: 30 min
onUpdate: (partial: { blocks: StreamBlock[]; finalText: string }) => void; onUpdate: (partial: { blocks: StreamBlock[]; finalText: string }) => void;
} }
@@ -454,7 +454,7 @@ export interface RunClaudeResult {
} }
export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise<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; const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -509,6 +509,18 @@ export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise
let buffer = ""; let buffer = "";
const blocks: StreamBlock[] = []; const blocks: StreamBlock[] = [];
const pendingTools = new Map<number, { name: string; inputJson: string; id: string }>(); 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 sessionId = "";
let costUsd = 0, inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0; 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; 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]; const last = blocks[blocks.length - 1];
if (last?.type === "thinking") return last; if (last?.type === "thinking") return last;
const b: ThinkingBlock = { type: "thinking", text: "" }; const b: ThinkingBlock = { type: "thinking", text: "" };
@@ -547,6 +566,17 @@ export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise
const cb = e.content_block; const cb = e.content_block;
if (cb?.type === "tool_use") { if (cb?.type === "tool_use") {
pendingTools.set(e.index as number, { name: cb.name, inputJson: "", id: cb.id }); 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") { } else if (e.type === "content_block_delta") {
const d = e.delta as any; 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; getOrCreateTextBlock().text += d.text as string;
emit(); emit();
} else if (d?.type === "thinking_delta") { } else if (d?.type === "thinking_delta") {
getOrCreateThinkingBlock().text += d.thinking as string; getOrCreateThinkingBlock(e.index as number).text += d.thinking as string;
emit(); emit();
} else if (d?.type === "input_json_delta") { } else if (d?.type === "input_json_delta") {
const tool = pendingTools.get(e.index as number); const tool = pendingTools.get(e.index as number);
if (tool) tool.inputJson += d.partial_json as string ?? ""; if (tool) tool.inputJson += d.partial_json as string ?? "";
} }
} else if (e.type === "content_block_stop") { } 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); const tool = pendingTools.get(e.index as number);
if (tool) { if (tool) {
// Claude CLI's --include-partial-messages can emit an `assistant` // Claude CLI's --include-partial-messages can emit an `assistant`
@@ -635,7 +676,13 @@ export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise
text = ""; text = "";
} }
const toolId = c.tool_use_id as string; 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) { if (toolBlock && toolBlock.name.toLowerCase() === "edit" && !c.is_error && opts.enrichEditDiffs) {
try { try {
const inp = JSON.parse(toolBlock.inputJson) as Record<string, unknown>; 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) { 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.`)); reject(new Error(`Claude timed out after ${timeoutMs / 1000}s — the process was killed. Consider using a simpler prompt or a faster model.`));
} else { } else {
const errMsg = formatAnthropicError(stderrOutput.trim(), code); const errMsg = formatAnthropicError(stderrOutput.trim(), code ?? undefined);
const detail = finalText ? ` (partial output: ${finalText.slice(0, 100)})` : ""; const detail = finalText ? ` (partial output: ${finalText.slice(0, 100)})` : "";
reject(new Error(errMsg + detail)); reject(new Error(errMsg + detail));
} }

View File

@@ -1,24 +1,24 @@
{ {
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", "$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": { "vars": {
"bg": "#faf4ed", "bg": "#1c2433",
"fg": "#2a2a2a", "fg": "#afbbd2",
"accent": "#7b4fc4", "accent": "#b78aff",
"accentAlt": "#c45a1c", "accentAlt": "#ff955c",
"link": "#1a7db5", "link": "#69c3ff",
"error": "#d1344f", "error": "#ff738a",
"success": "#1e9b52", "success": "#3cec85",
"warning": "#b8890f", "warning": "#eacd61",
"muted": "#73716e", "muted": "#7c869a",
"dim": "#9c9995", "dim": "#5e687b",
"borderMuted": "#c6c2bc", "borderMuted": "#414a5b",
"selectedBg": "#eee8e1", "selectedBg": "#28303f",
"userMsgBg": "#f2ece5", "userMsgBg": "#242c3b",
"toolPendingBg": "#f5efe8", "toolPendingBg": "#212938",
"toolSuccessBg": "#e0e9da", "toolSuccessBg": "#203c3d",
"toolErrorBg": "#f5ddda", "toolErrorBg": "#372d3d",
"customMsgBg": "#f0e7ea" "customMsgBg": "#282c43"
}, },
"colors": { "colors": {
"accent": "accent", "accent": "accent",
@@ -74,8 +74,8 @@
"bashMode": "success" "bashMode": "success"
}, },
"export": { "export": {
"pageBg": "#fffcf5", "pageBg": "#141c2b",
"cardBg": "#faf4ed", "cardBg": "#1c2433",
"infoBg": "#f2e7d2" "infoBg": "#353839"
} }
} }