diff --git a/pi/.pi/agent/extensions/ask-claude.ts b/pi/.pi/agent/extensions/ask-claude.ts new file mode 100644 index 0000000..81a4c8a --- /dev/null +++ b/pi/.pi/agent/extensions/ask-claude.ts @@ -0,0 +1,275 @@ +/** + * ask-claude — Stream Claude agent reviews into pi. + * + * For AGENTS to use. Delegates to specialized Claude agents or raw models + * for review, analysis, debugging, and second opinions. + * + * Tool (callable by the LLM): + * ask_claude(prompt, agent?, model?, question?, session_id?) + * agent — any agent name from ~/.claude/agents/ (e.g. "plan_review", "code_review", "oracle", "debug") + * model — model override: "opus", "sonnet", or full model ID + * question — specific focus prepended as a review header + * session_id — resume a prior conversation (returned in every response) + * + * Commands: + * /review-plan — editor → Claude Opus plan_review → inject result + * /review-code — editor → Claude Sonnet code_review → inject result + */ + +import { defineTool, getMarkdownTheme } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@mariozechner/pi-ai"; +import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import { + type ClaudeDetails, + type Theme, + renderToolCallLine, + renderToolResultBox, + renderToolBlock, + formatUsage, + buildEditDiff, + formatAnthropicError, + runClaude, + type RunClaudeResult, +} from "../shared/claude-stream.js"; + +// ============================================================================= +// Rendering (ask-claude specific — uses shared tool renderers) +// ============================================================================= + +function buildLabel(agent?: string, model?: string): string { + if (agent) return `Claude [${agent}]`; + if (model) return `Claude [${model}]`; + return "Claude Sonnet"; +} + +// ============================================================================= +// Tool definition +// ============================================================================= + +const AskClaudeParams = Type.Object({ + prompt: Type.String({ + description: "Full content to review/analyze. Include all relevant context: CLAUDE.md conventions, files explored, code or plan to review.", + }), + agent: Type.Optional(Type.String({ + description: "Agent name from ~/.claude/agents/ (e.g. 'plan_review', 'code_review', 'oracle', 'debug'). Omit to use model= directly.", + })), + model: Type.Optional(Type.String({ + description: "Model override: 'opus', 'sonnet', 'haiku', or a full model ID. When agent is set this overrides the agent's default. When agent is omitted this selects the model directly.", + })), + question: Type.Optional(Type.String({ + description: "Specific question or focus area prepended to the prompt (e.g. 'Focus on security', 'Are there race conditions?').", + })), + session_id: Type.Optional(Type.String({ + description: "Resume a prior conversation. Pass the session_id returned from a previous ask_claude call.", + })), +}); + +const askClaudeTool = defineTool({ + name: "ask_claude", + label: "Ask Claude", + description: [ + "Ask a Claude agent or model for review, analysis, or a second opinion.", + "agent= runs a named agent from ~/.claude/agents/ (e.g. 'plan_review', 'code_review', 'oracle', 'debug').", + "Use model= alone for free-form requests without an agent. Use question= to specify a focus.", + "Pass session_id from a prior response to continue the same conversation across turns.", + "CLAUDE.md and .claude/skills are loaded automatically from the project root.", + ].join(" "), + promptSnippet: "Ask a Claude agent or model for review, analysis, or a second opinion", + promptGuidelines: [ + "Use ask_claude(agent=) to invoke a specialized agent — include all relevant context in the prompt.", + "Use ask_claude(model='opus', question='...') for free-form deep analysis.", + "Always include the artifact to review (plan, code, problem description) in the prompt.", + "Pass session_id back from the previous response to continue the conversation.", + ], + parameters: AskClaudeParams, + + async execute(_toolCallId, params, signal, onUpdate, ctx) { + const fullPrompt = params.question + ? `## Review Focus\n\n${params.question}\n\n## Content\n\n${params.prompt}` + : params.prompt; + + const label = buildLabel(params.agent, params.model); + const details: ClaudeDetails = { label, done: false, blocks: [], finalText: "", isResume: !!params.session_id }; + + try { + const result: RunClaudeResult = await runClaude(fullPrompt, { + agent: params.agent, + model: params.model, + sessionId: params.session_id, + enrichEditDiffs: true, // ask-claude wants diff enrichment + cwd: ctx.cwd, + signal, + onUpdate: (partial) => { + Object.assign(details, partial); + onUpdate?.({ + content: [{ type: "text", text: details.finalText || "(thinking…)" }], + details: { ...details }, + }); + }, + }); + + details.done = true; + details.finalText = result.finalText; + details.blocks = result.blocks; + details.sessionId = result.sessionId; + details.costUsd = result.costUsd; + details.inputTokens = result.inputTokens; + details.outputTokens = result.outputTokens; + details.cacheReadTokens = result.cacheReadTokens; + details.cacheWriteTokens = result.cacheWriteTokens; + + const sessionNote = result.sessionId + ? `\n\n---\n*session_id: \`${result.sessionId}\`*` + : ""; + + return { + content: [{ type: "text", text: (result.finalText || "(no output)") + sessionNote }], + details: { ...details }, + }; + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + details.done = true; + details.finalText = `Error: ${errMsg}`; + return { + content: [{ type: "text", text: `**Claude error:** ${errMsg}` }], + details: { ...details }, + }; + } + }, + + renderCall(args, theme, _ctx) { + const label = buildLabel(args.agent, args.model); + let text = theme.fg("toolTitle", theme.bold("ask_claude ")) + theme.fg("accent", `[${label}]`); + if (args.question) { + text += "\n " + theme.fg("dim", theme.italic(args.question)); + } else { + const lines = args.prompt.split("\n").filter((l) => l.trim()).slice(0, 3); + text += "\n " + theme.fg("dim", lines.join("\n ")); + } + return new Text(text, 0, 0); + }, + + renderResult(result, { isPartial }, theme, _ctx) { + const d = result.details as ClaudeDetails | undefined; + if (!d) return new Text(theme.fg("muted", "…"), 0, 0); + + const isDone = d.done && !isPartial; + const statusIcon = isDone ? theme.fg("success", "✓ ") : theme.fg("warning", "⏳ "); + const c = new Container(); + + const resume = d.isResume ? theme.fg("dim", " ↩") : ""; + c.addChild(new Text(statusIcon + 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 as any)); + } 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 (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; + }, +}); + +// ============================================================================= +// Extension entry point +// ============================================================================= + +export default function (pi: ExtensionAPI) { + pi.registerTool(askClaudeTool); + + // ── /review-plan ─────────────────────────────────────────────────────── + + pi.registerCommand("review-plan", { + description: "Editor → Claude Opus plan_review → inject review into conversation", + handler: async (_args, ctx) => { + const input = await ctx.ui.editor( + "Plan Review · Claude Opus", + "Paste your plan or strategy. Claude will review for correctness, completeness, and risk.", + ); + if (!input?.trim()) { ctx.ui.notify("Cancelled.", "info"); return; } + ctx.ui.setStatus("ask-claude", "Asking Claude Opus (plan_review)…"); + try { + const r = await runClaude(input, { agent: "plan_review", cwd: ctx.cwd, onUpdate: () => {} }); + if (!r.finalText.trim()) { ctx.ui.notify("No output from Claude.", "warning"); return; } + pi.sendMessage( + { customType: "ask-claude-review", content: r.finalText.trim(), display: true, + details: { label: "Claude Opus · plan_review", output: r.finalText } }, + { triggerTurn: true }, + ); + } catch (err) { + ctx.ui.notify(`Claude error: ${err instanceof Error ? err.message : String(err)}`, "error"); + } finally { ctx.ui.setStatus("ask-claude", undefined); } + }, + }); + + // ── /review-code ─────────────────────────────────────────────────────── + + pi.registerCommand("review-code", { + description: "Editor → Claude Sonnet code_review → inject review into conversation", + handler: async (_args, ctx) => { + const input = await ctx.ui.editor( + "Code Review · Claude Sonnet", + "Paste code to review. Include the plan it implements and any specific concerns.", + ); + if (!input?.trim()) { ctx.ui.notify("Cancelled.", "info"); return; } + ctx.ui.setStatus("ask-claude", "Asking Claude Sonnet (code_review)…"); + try { + const r = await runClaude(input, { agent: "code_review", cwd: ctx.cwd, onUpdate: () => {} }); + if (!r.finalText.trim()) { ctx.ui.notify("No output from Claude.", "warning"); return; } + pi.sendMessage( + { customType: "ask-claude-review", content: r.finalText.trim(), display: true, + details: { label: "Claude Sonnet · code_review", output: r.finalText } }, + { triggerTurn: true }, + ); + } catch (err) { + ctx.ui.notify(`Claude error: ${err instanceof Error ? err.message : String(err)}`, "error"); + } finally { ctx.ui.setStatus("ask-claude", undefined); } + }, + }); + + // ── Message renderer for injected reviews ────────────────────────────── + + pi.registerMessageRenderer("ask-claude-review", (message, { expanded }, theme) => { + const d = message.details as { label?: string; output?: string } | undefined; + const label = d?.label ?? "Claude"; + const output = (d?.output ?? "").trim(); + + if (expanded) { + const c = new Container(); + c.addChild(new Text(theme.fg("accent", "◆ ") + theme.fg("toolTitle", theme.bold(label)), 0, 0)); + c.addChild(new Spacer(1)); + c.addChild(new Markdown(output, 0, 0, getMarkdownTheme())); + return c; + } + + let text = theme.fg("accent", "◆ ") + theme.fg("toolTitle", theme.bold(label)); + const lines = output.split("\n").filter((l) => l.trim()); + const preview = lines.slice(0, 4).join("\n"); + if (preview) { + text += "\n" + theme.fg("dim", preview); + if (lines.length > 4) text += "\n" + theme.fg("muted", `… ${lines.length - 4} more (Ctrl+O)`); + } + return new Text(text, 0, 0); + }); +} diff --git a/pi/.pi/agent/extensions/chat-claude.ts b/pi/.pi/agent/extensions/chat-claude.ts new file mode 100644 index 0000000..29c258e --- /dev/null +++ b/pi/.pi/agent/extensions/chat-claude.ts @@ -0,0 +1,1371 @@ +/** + * chat-claude — Distinctive Claude chat MODE inside pi. + * + * When chat mode is active, typed user input is routed to a Claude model + * (haiku/sonnet/opus) via the `claude` CLI — NOT to pi's active LLM. + * + * Rendering goals (match pi's native chat UX): + * - Text appears as full markdown (no truncated previews, no dim grey). + * - Thinking blocks stream live as italic `thinkingText`-coloured markdown + * (the `claude` CLI is invoked with --include-partial-messages). + * - Tool calls use pi's normal tool-execution look (renderToolBlock). + * + * All turns of a single chat-mode session are rendered inside ONE continuous + * orange border: the top line sits above the first turn, the bottom line + * below the most recent turn, and the border extends live as new turns + * (user + assistant) arrive. A new border starts each time the user enters + * chat mode again via /claude / /claude-new. + * + * Commands: + * /claude [haiku|sonnet|opus] — enter chat mode / switch model + * /claude-new [haiku|sonnet|opus] — enter chat mode with a fresh Claude session + * /claude-resume — pick a past session for the current cwd and resume it + * /claude-end — exit chat mode + * /claude-abort — cancel an in-flight Claude response + */ + +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 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 { + formatUsage, + renderToolBlock, + runClaude, + type StreamBlock, +} from "../shared/claude-stream.js"; +import { startAskBridge, type AskBridge } from "../shared/pi-ask-bridge.js"; +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 RESET = "\x1b[0m"; +const BOLD = "\x1b[1m"; +const orange = (s: string) => ORANGE + s + RESET; +const orangeBold = (s: string) => ORANGE + BOLD + s + RESET; +const orangeDim = (s: string) => ORANGE_DIM + s + RESET; + +// --------------------------------------------------------------------------- +// Orange border wrapping helper — wraps an array of inner lines in a +// continuous orange box. Applied at the session level so the WHOLE chat +// conversation sits inside ONE box (top above first turn, bottom below +// most recent turn). Pure string→string — no component allocation per frame. +// +// IMPORTANT: `innerLines` must ALREADY be padded to `innerWidth` columns of +// visible width. We don't call visibleWidth() here because that function +// invokes Intl.Segmenter (expensive ICU BreakIterator on every miss) and +// this wrapper runs on every single line of the session on every frame. +// Profile data showed 85% of pi's idle CPU being burned in Segmenter via +// this function. Callers (renderSessionLines) pre-pad inner lines once +// per turn and cache them, so the cost amortises to O(streaming tail). +// --------------------------------------------------------------------------- +function wrapInOrangeBorder(paddedInnerLines: string[], width: number): string[] { + const v = orange("│"); + const top = orange("╭" + "─".repeat(width - 2) + "╮"); + const bottom = orange("╰" + "─".repeat(width - 2) + "╯"); + const out: string[] = [top]; + for (const line of paddedInnerLines) out.push(v + " " + line + " " + v); + out.push(bottom); + return out; +} + +// Pad a single inner line to exactly `innerWidth` visible columns, OR +// truncate it if it's already over-wide. Uses visibleWidth() — pi-tui's +// grapheme-aware width function (which is what sits on top of the hot +// Intl.Segmenter path). Intended to be called ONCE per line at cache-build +// time, NOT per frame. +// +// Truncation is a defensive safety net: any component that emits a line +// wider than the width it was handed would otherwise crash pi's TUI (see +// tui.js doRender: "Rendered line N exceeds terminal width"). Without this, +// one stray over-wide line (e.g. a long source code line inside a Read +// tool result) takes down the entire session. +function padToInnerWidth(line: string, innerWidth: number): string { + const w = visibleWidth(line); + if (w > innerWidth) return truncateToWidth(line, innerWidth, "…", true); + const padRight = innerWidth - w; + return padRight > 0 ? line + " ".repeat(padRight) : line; +} + +// --------------------------------------------------------------------------- +// Read-tool result truncation +// +// `Read` tool calls inside chat mode often dump entire files into the result +// banner — many hundreds of lines, which buries the surrounding conversation. +// We cap the rendered file content at MAX_READ_LINES and append a single +// centered notice line describing how many lines were hidden. This is a +// PRESENTATION-only truncation: `block.result.text` is left untouched, so +// resumed sessions / re-renders still see the full content. +// +// Centering needs render-time width, so we implement a tiny custom Component +// (TruncatedReadResult) and swap it into the Box body produced by the shared +// renderToolBlock helper. The same dim line-number formatting used by +// renderToolResultBox is preserved so the truncated view looks identical to +// the un-truncated one above the notice. +// --------------------------------------------------------------------------- +const MAX_READ_LINES = 40; + +class TruncatedReadResult implements Component { + constructor( + private readonly numbered: { num: string; content: string }[], + private readonly maxNumLen: number, + private readonly dimFn: (s: string) => string, + private readonly noticeFn: (s: string) => string, + ) {} + + invalidate(): void { /* stateless */ } + + render(width: number): string[] { + const total = this.numbered.length; + const visible = Math.min(MAX_READ_LINES, total); + const lines: string[] = []; + for (let i = 0; i < visible; i++) { + const l = this.numbered[i]; + // Truncate to `width` so a single long source-code line (think + // minified JS or a long comment) can't blow past the TUI's width + // check and crash the whole session. `truncateToWidth` is + // ANSI-aware so the dim SGR sequences wrapping the line number + // survive the cut. + const raw = this.dimFn(l.num.padStart(this.maxNumLen)) + " " + l.content; + lines.push(truncateToWidth(raw, width, "…", false)); + } + if (total > visible) { + const hidden = total - visible; + const notice = `… ${hidden} more line${hidden === 1 ? "" : "s"} hidden …`; + const visLen = visibleWidth(notice); + const left = Math.max(0, Math.floor((width - visLen) / 2)); + lines.push(" ".repeat(left) + this.noticeFn(notice)); + } + return lines; + } +} + +// Wrap shared renderToolBlock: for `Read` tool blocks whose result exceeds +// MAX_READ_LINES, replace the Box body's child Text with our truncating +// component. All other tool kinds, error results, and short reads pass +// through unchanged. +function renderToolBlockTruncated(block: Extract, theme: any): Container { + const c = renderToolBlock(block, theme); + if (block.name.toLowerCase() !== "read") return c; + if (!block.result || block.result.isError) return c; + + const rawLines = block.result.text.split("\n").filter((l) => l.length > 0); + if (rawLines.length <= MAX_READ_LINES) return c; + + 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); + + // renderToolBlock's container is [headerText, bodyBox]. Bail safely if a + // future change to that helper alters the structure. + const body = c.children[1]; + if (!(body instanceof Box)) return c; + body.clear(); + body.addChild(new TruncatedReadResult( + parsed, + maxNumLen, + (s) => theme.fg("dim", s), + (s) => theme.fg("dim", s), + )); + return c; +} + +// --------------------------------------------------------------------------- +// Models / turn types +// --------------------------------------------------------------------------- +const MODELS = ["haiku", "sonnet", "opus"] as const; +type Model = (typeof MODELS)[number]; +const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + +// --------------------------------------------------------------------------- +// Past-session discovery (used by /claude-resume). +// +// Claude CLI persists every session's transcript at: +// ~/.claude/projects//.jsonl +// where the mangling rule (verified empirically) is "replace every '/' and +// '.' with '-'". So /home/jonas/dotfiles/pi/.pi → -home-jonas-dotfiles-pi--pi +// (the leading '-' comes from the leading '/'; '.pi' contributes '--pi' +// because both '/' and '.' map to '-'). +// +// We don't need to consult ~/.claude/sessions/ for this picker — that +// directory only contains metadata for currently-running Claude processes. +// The on-disk transcript at projects//.jsonl is the source of +// truth for "past sessions in this directory". +// --------------------------------------------------------------------------- +function mangleCwd(cwd: string): string { + return cwd.replace(/[/.]/g, "-"); +} + +function relativeTime(ms: number): string { + const diff = Date.now() - ms; + if (diff < 0) return "in the future"; + const sec = Math.floor(diff / 1000); + if (sec < 60) return `${sec}s ago`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m ago`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}h ago`; + const day = Math.floor(hr / 24); + if (day < 30) return `${day}d ago`; + const mon = Math.floor(day / 30); + if (mon < 12) return `${mon}mo ago`; + return `${Math.floor(day / 365)}y ago`; +} + +/** Map a raw Claude model identifier (e.g. "claude-haiku-4-5-20251001") to + * one of our canonical short names. Returns null if no match. */ +function normalizeRawModel(raw: string): Model | null { + const lc = raw.toLowerCase(); + if (lc.includes("haiku")) return "haiku"; + if (lc.includes("sonnet")) return "sonnet"; + if (lc.includes("opus")) return "opus"; + return null; +} + +interface PastSession { + sessionId: string; + mtimeMs: number; + firstUserMessage: string; // truncated/normalised, "" if not found + model: Model | null; // null ⇒ couldn't determine + rawModel: string; // raw string from JSONL ("" if not found) +} + +/** Read the head of a file (avoids slurping multi-MB JSONL transcripts). */ +function readFileHead(path: string, maxBytes: number): string { + const fd = openSync(path, "r"); + try { + const buf = Buffer.alloc(maxBytes); + const n = readSync(fd, buf, 0, maxBytes, 0); + return buf.subarray(0, n).toString("utf8"); + } finally { + closeSync(fd); + } +} + +/** Pluck the first user message + first model id from a transcript head. */ +function extractSessionMeta(head: string): { firstUserMessage: string; rawModel: string } { + let firstUserMessage = ""; + let rawModel = ""; + + for (const line of head.split("\n")) { + if (firstUserMessage && rawModel) break; + if (!line.trim()) continue; + let ev: any; + try { ev = JSON.parse(line); } catch { continue; } + + if (!firstUserMessage) { + // Two equivalent sources: a queue-operation enqueue carries the raw + // text the user typed; a `type: "user"` event carries it inside + // message.content (which is either a string or an array of blocks). + if (ev.type === "queue-operation" && ev.operation === "enqueue" && typeof ev.content === "string") { + firstUserMessage = ev.content; + } else if (ev.type === "user" && ev.message) { + const c = ev.message.content; + if (typeof c === "string") { + firstUserMessage = c; + } else if (Array.isArray(c)) { + firstUserMessage = c + .filter((b: any) => b?.type === "text" && typeof b.text === "string") + .map((b: any) => b.text as string) + .join(" "); + } + } + } + + if (!rawModel && typeof ev?.message?.model === "string") { + rawModel = ev.message.model; + } + } + + return { + firstUserMessage: firstUserMessage.replace(/\s+/g, " ").trim(), + rawModel, + }; +} + +function readPastSessions(cwd: string): PastSession[] { + const dir = join(homedir(), ".claude", "projects", mangleCwd(cwd)); + let entries: string[]; + try { + entries = readdirSync(dir).filter((f) => f.endsWith(".jsonl")); + } catch { + return []; + } + + const out: PastSession[] = []; + for (const f of entries) { + const full = join(dir, f); + let st; + try { st = statSync(full); } catch { continue; } + // Read up to ~256 KB — enough to find the first user message and the + // first assistant turn (which carries the model id) in any reasonable + // transcript without paying for multi-MB reads. + let head: string; + try { head = readFileHead(full, 256 * 1024); } catch { continue; } + const { firstUserMessage, rawModel } = extractSessionMeta(head); + out.push({ + sessionId: f.replace(/\.jsonl$/, ""), + mtimeMs: st.mtimeMs, + firstUserMessage, + model: rawModel ? normalizeRawModel(rawModel) : null, + rawModel, + }); + } + + out.sort((a, b) => b.mtimeMs - a.mtimeMs); + return out; +} + +/** Truncate a string to `max` chars, appending "…" when cut. */ +function truncate(s: string, max: number): string { + if (s.length <= max) return s; + return s.slice(0, Math.max(0, max - 1)).trimEnd() + "…"; +} + +// --------------------------------------------------------------------------- +// JSONL transcript → ChatTurn[] +// +// Given a sessionId and cwd, load the full transcript at +// ~/.claude/projects//.jsonl +// and convert it into the same UserTurn / AssistantTurn shape the live +// runChatTurn() path produces. This lets /claude-resume render the past +// context inside the orange border so the user can SEE what they're +// resuming, not just blindly continue an invisible thread. +// +// JSONL event reference (observed in 2.1.118 transcripts): +// {type:"user", message:{role:"user", content: }} ← typed prompt +// {type:"user", message:{role:"user", content: [{type:"tool_result", …}, …]}} ← tool outputs +// {type:"assistant",message:{role:"assistant", content: [], usage:{…}, model:"claude-sonnet-4-6"}} +// Each assistant content block is emitted as its OWN line, all sharing the +// same usage / model fields (one API call → many lines). We coalesce every +// run of consecutive assistant lines into a single AssistantTurn whose +// `blocks` array preserves the in-order list of thinking/text/tool blocks. +// Tool results that arrive in subsequent user-lines are attached back onto +// the matching tool block by tool_use_id. +// +// Lines we ignore: agent-setting, queue-operation, attachment, last-prompt, +// summary, and anything else without a recognisable role/content shape. +// Tokens/cost are intentionally NOT carried over — the JSONL repeats usage +// per content block so summing naively would over-count, and the user is +// here to see CONTENT, not a token panel for old turns. +// --------------------------------------------------------------------------- +function loadSessionTurns(sessionId: string, cwd: string, fallbackModel: Model): ChatTurn[] { + const path = join(homedir(), ".claude", "projects", mangleCwd(cwd), `${sessionId}.jsonl`); + let raw: string; + try { raw = readFileSync(path, "utf8"); } catch { return []; } + + const turns: ChatTurn[] = []; + let current: AssistantTurn | null = null; + + const flush = () => { + if (!current) return; + current.finalText = current.blocks + .filter((b) => b.type === "text") + .map((b: any) => b.text as string) + .join(""); + turns.push(current); + current = null; + }; + + const ensureCurrent = (model: Model): AssistantTurn => { + if (current) return current; + current = { + role: "assistant", + model, + blocks: [], + finalText: "", + sessionId, + isResume: false, + done: true, + }; + return current; + }; + + const tool_resultText = (content: any): { text: string; isError: boolean } => { + if (typeof content === "string") return { text: content, isError: false }; + if (Array.isArray(content)) { + const text = content + .filter((b: any) => b?.type === "text" && typeof b.text === "string") + .map((b: any) => b.text as string) + .join("\n"); + return { text, isError: false }; + } + return { text: "", isError: false }; + }; + + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + let ev: any; + try { ev = JSON.parse(line); } catch { continue; } + + if (ev.type === "user") { + const c = ev.message?.content; + if (typeof c === "string") { + // Typed user prompt — closes any in-flight assistant turn. + flush(); + if (c.trim()) turns.push({ role: "user", text: c }); + } else if (Array.isArray(c)) { + let sawToolResult = false; + for (const block of c) { + if (block?.type === "tool_result") { + sawToolResult = true; + const { text } = tool_resultText(block.content); + const isError = block.is_error === true; + if (current) { + for (const tb of current.blocks) { + if (tb.type === "tool" && tb.id === block.tool_use_id) { + tb.result = { text, isError }; + break; + } + } + } + } else if (block?.type === "text" && typeof block.text === "string") { + // Some clients send array-shaped user prompts. + if (!sawToolResult) { + flush(); + if (block.text.trim()) turns.push({ role: "user", text: block.text }); + } + } + } + } + } else if (ev.type === "assistant") { + const content = (ev.message?.content ?? []) as any[]; + const rawModel = String(ev.message?.model ?? ""); + const model = (rawModel ? normalizeRawModel(rawModel) : null) ?? fallbackModel; + const a = ensureCurrent(model); + // If the per-line model differs from what we opened the turn with, + // keep the first one — a single coalesced "turn" inherits the model + // of its first API call. (This is purely for the header label.) + for (const block of content) { + if (block?.type === "thinking" && typeof block.thinking === "string") { + if (block.thinking.trim()) a.blocks.push({ type: "thinking", text: block.thinking }); + } else if (block?.type === "text" && typeof block.text === "string") { + if (block.text.trim()) a.blocks.push({ type: "text", text: block.text }); + } else if (block?.type === "tool_use") { + a.blocks.push({ + type: "tool", + id: String(block.id ?? ""), + name: String(block.name ?? ""), + inputJson: JSON.stringify(block.input ?? {}), + }); + } + } + } + // All other event types (agent-setting, queue-operation, attachment, + // last-prompt, summary, …) are intentionally ignored. + } + + flush(); + return turns; +} + +// Per-turn render cache: once a turn is "frozen" (user turns are always +// frozen; assistant turns after done=true), its rendered output at a given +// (innerWidth, theme) is invariant. Caching avoids O(turns) rebuild on every +// frame, which otherwise creates quadratic-ish lag during streaming because +// partial-message updates drive tens of renders per second. +interface TurnRenderCache { + cachedLines?: string[]; + cachedWidth?: number; + cachedTheme?: unknown; +} + +interface UserTurn extends TurnRenderCache { + role: "user"; + text: string; +} +interface AssistantTurn extends TurnRenderCache { + role: "assistant"; + model: Model; + blocks: StreamBlock[]; + finalText: string; + sessionId?: string; + isResume: boolean; + done: boolean; + error?: string; + cancelled?: boolean; + costUsd?: number; + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; +} +type ChatTurn = UserTurn | AssistantTurn; + +interface ChatSessionDetails { + turns: ChatTurn[]; +} + +// ============================================================================= +// Extension entry point +// ============================================================================= + +// ── Reload-persistent state ───────────────────────────────────────────────── +// pi's `/reload` tears the extension down and re-invokes the default export, +// which resets every closure-local `let`/`const`. The Map of resumable Claude +// session ids (model → sessionId) is the one piece of state we want to +// survive that — otherwise /reload silently orphans the ongoing Claude +// threads, forcing the user to re-pick them via /claude-resume. +// +// Everything else (chatMode, currentDetails, askBridge, tuiRef, isGenerating) +// is intentionally NOT persisted: the bridge/TUI references are bound to the +// torn-down ctx and must be rebuilt on the next enterChatMode(), and any +// in-flight stream is already aborted when the old closure is discarded. +// +// We stash the Map on globalThis behind a namespaced key. globalThis survives +// module re-evaluation (only top-level lexical bindings are reset), and the +// guarded getter keeps initialization idempotent across repeated reloads. +// Valid extended-thinking effort levels accepted by `claude --effort`, plus +// our synthetic "off" sentinel which skips the flag entirely (falling back +// to the CLI's default of no thinking emission in -p mode). +const EFFORTS = ["off", "low", "medium", "high", "xhigh", "max"] as const; +type Effort = (typeof EFFORTS)[number]; +const DEFAULT_EFFORT: Effort = "max"; + +interface ChatClaudePersistedState { + sessions: Map; + // Current extended-thinking effort level — persisted across `/reload` + // so the user's choice survives the extension teardown the same way + // resumable session ids do. + effort: Effort; +} +const CHAT_CLAUDE_STATE_KEY = "__pi_chat_claude_persisted__"; +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 }; + 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; + return state; +} + +export default function (pi: ExtensionAPI) { + // ── Mode state ──────────────────────────────────────────────────────────── + let chatMode: Model | null = null; // null ⇒ not in chat mode + // model → resumable claude session id. Pulled from globalThis so the + // mapping (and the current effort level) survive `/reload` (see + // getPersistedState above). `persisted` is kept as a handle so + // `/claude-effort` can mutate `persisted.effort` in place and have + // the change picked up by subsequent runChatTurn calls. + const persisted = getPersistedState(); + const { sessions } = persisted; + let isGenerating = false; + let currentAbort: AbortController | null = null; + + // pi-ask bridge — opens a Unix socket + generates an --mcp-config so + // Claude (running inside this chat) can ask the user questions through + // pi's native ask UI. Bound to the chat-mode lifetime: started on + // enterChatMode, closed on exitChatMode. + let askBridge: AskBridge | null = null; + + // Live TUI reference captured from the mode-banner widget factory, used to + // schedule re-renders while a Claude response is streaming into the + // current chat-claude-session message. + let tuiRef: { requestRender: () => void } | 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. + let currentDetails: ChatSessionDetails | null = null; + + // Keep a module-level set of the extension's custom-message types so the + // `context` event handler can strip them out of pi's LLM context — chat + // mode is between the user and Claude and has no business in pi's + // prompt payload. + const CHAT_CLAUDE_CUSTOM_TYPES = new Set(["chat-claude-session"]); + + // ── Render throttling ──────────────────────────────────────────────────── + // Claude's `--include-partial-messages` fires an onUpdate for every token + // delta (100+ Hz under a fast stream). Rendering per-token was the second + // half of the progressive-lag problem — even with per-turn caching, the + // TUI would be asked to diff+repaint dozens of times per second. + // + // scheduleStreamRender coalesces back-to-back requests into a trailing- + // edge timer at ~30 Hz. The first update within a quiet window waits up + // to 33 ms before rendering; any further updates in that window are + // folded into the same render. flushStreamRender cancels the pending + // timer and renders immediately — used on stream completion, abort, and + // chat-mode teardown so the user sees the terminal frame right away. + let streamRenderTimer: ReturnType | null = null; + const STREAM_RENDER_INTERVAL_MS = 33; // ~30 Hz + function scheduleStreamRender() { + if (streamRenderTimer) return; + streamRenderTimer = setTimeout(() => { + streamRenderTimer = null; + tuiRef?.requestRender(); + }, STREAM_RENDER_INTERVAL_MS); + } + function flushStreamRender() { + if (streamRenderTimer) { + clearTimeout(streamRenderTimer); + streamRenderTimer = null; + } + tuiRef?.requestRender(); + } + + // ── Rendering helpers ──────────────────────────────────────────────────── + // Mirrors pi's AssistantMessageComponent conventions (see + // modes/interactive/components/assistant-message.js): Markdown at + // paddingX=1, paddingY=0; thinking as italic `thinkingText`-coloured + // markdown; tool blocks via the shared renderToolBlock (same one + // ask-claude uses) so bash / read / edit / write all look identical to + // pi's own tool executions. + function renderTurnInto(container: Container, turn: ChatTurn, theme: any) { + const md = getMarkdownTheme(); + + if (turn.role === "user") { + container.addChild(new Text(orangeBold("▶ you"), 1, 0)); + container.addChild(new Spacer(1)); + container.addChild(new Markdown(turn.text.trim(), 1, 0, md)); + return; + } + + // Assistant turn header + const icon = + turn.cancelled ? orange("◇ ") + : turn.error ? theme.fg("error", "✗ ") + : 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", " ⏳") : ""); + 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. + const rawBlocks = turn.blocks ?? []; + const seenToolIds = new Set(); + const blocks: StreamBlock[] = []; + for (const b of rawBlocks) { + if (b.type === "tool") { + if (seenToolIds.has(b.id)) continue; + seenToolIds.add(b.id); + } + blocks.push(b); + } + + let addedAny = false; + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + if (block.type === "thinking" && block.text.trim()) { + if (addedAny) container.addChild(new Spacer(1)); + container.addChild(new Markdown(block.text.trim(), 1, 0, md, { + color: (t: string) => theme.fg("thinkingText", t), + italic: true, + })); + addedAny = true; + } else if (block.type === "tool") { + if (addedAny) container.addChild(new Spacer(1)); + container.addChild(renderToolBlockTruncated(block, theme)); + addedAny = true; + } else if (block.type === "text" && block.text.trim()) { + if (addedAny) container.addChild(new Spacer(1)); + container.addChild(new Markdown(block.text.trim(), 1, 0, md)); + addedAny = true; + } + } + + if (turn.done) { + const usage = formatUsage(turn as any); + if (usage) { + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("dim", usage), 1, 0)); + } + } + } + + // Render one turn in isolation and return its lines PRE-PADDED to + // `innerWidth` visible columns. + // + // Pre-padding here means `visibleWidth()` (which calls `Intl.Segmenter` + // — the measured hot spot: 85% of pi's CPU in a laggy session) runs + // exactly ONCE per line per turn, not once per line per frame. For + // completed turns these padded lines are cached and reused forever at + // that (width, theme); for the streaming tail turn the work is bounded + // to just the in-flight turn's lines. + function renderTurnLines(turn: ChatTurn, theme: any, innerWidth: number): string[] { + const c = new Container(); + renderTurnInto(c, turn, theme); + const rawLines = c.render(innerWidth); + const padded: string[] = new Array(rawLines.length); + for (let i = 0; i < rawLines.length; i++) { + padded[i] = padToInnerWidth(rawLines[i], innerWidth); + } + return padded; + } + + // Assemble the WHOLE session's inner lines with per-turn caching. + // + // Cache invariants: + // • User turns are immutable → always cacheable. + // • Assistant turns are mutated in-place by runClaude's onUpdate + // callback while streaming, and only become stable after + // `done: true` is set (see runChatTurn). So we only cache + // assistants once they're done. + // • Cache keys on (innerWidth, theme) — terminal resize or theme + // switch invalidates all per-turn caches transparently by forcing + // a rebuild on the next render. + // + // With this cache, a streaming frame only rebuilds the one in-flight + // assistant turn (the tail); all prior turns are an O(1) line-copy. + // That eliminates the O(turns × blocks) rebuild that previously ran + // every time a partial Claude message arrived. + // + // Returned lines are PRE-PADDED to `innerWidth` visible columns — see + // renderTurnLines/padToInnerWidth for why. The caller can hand them + // straight to wrapInOrangeBorder without any further visibleWidth() + // calls, which is critical: visibleWidth drives Intl.Segmenter, whose + // 512-entry LRU thrashes when called per-line-per-frame on a long chat. + function renderSessionLines(details: ChatSessionDetails, theme: any, innerWidth: number): string[] { + // Streaming placeholder so the border grows immediately after the + // user submits, even before any block has arrived from Claude. + if (details.turns.length === 0) { + const c = new Container(); + c.addChild(new Text(orangeDim("(chat mode — waiting for first message)"), 0, 0)); + const rawLines = c.render(innerWidth); + const padded: string[] = new Array(rawLines.length); + for (let i = 0; i < rawLines.length; i++) padded[i] = padToInnerWidth(rawLines[i], innerWidth); + return padded; + } + + const out: string[] = []; + // The blank inter-turn spacer must ALSO be padded — otherwise + // wrapInOrangeBorder emits "│ │" with a visibly short interior, + // producing a ragged right edge on the orange border. + const spacerLine = " ".repeat(innerWidth); + for (let i = 0; i < details.turns.length; i++) { + if (i > 0) out.push(spacerLine); + const turn = details.turns[i]; + const cacheable = turn.role === "user" || (turn.role === "assistant" && turn.done); + if ( + cacheable + && turn.cachedLines + && turn.cachedWidth === innerWidth + && turn.cachedTheme === theme + ) { + for (const line of turn.cachedLines) out.push(line); + } else { + const lines = renderTurnLines(turn, theme, innerWidth); + if (cacheable) { + turn.cachedLines = lines; + turn.cachedWidth = innerWidth; + turn.cachedTheme = theme; + } else { + // Streaming turn — make sure we don't accidentally + // carry stale cached output from a prior life. + turn.cachedLines = undefined; + turn.cachedWidth = undefined; + turn.cachedTheme = undefined; + } + for (const line of lines) out.push(line); + } + } + return out; + } + + // Drop every turn's render cache — called from the message renderer's + // `invalidate()` hook (triggered by pi when theme changes or when a + // from-scratch re-render is needed). + function invalidateSessionCache(details: ChatSessionDetails) { + for (const turn of details.turns) { + turn.cachedLines = undefined; + turn.cachedWidth = undefined; + turn.cachedTheme = undefined; + } + } + + // ── Mode banner + status ───────────────────────────────────────────────── + function syncUI(ctx: any) { + if (!ctx?.hasUI) return; + + if (!chatMode) { + ctx.ui.setWidget("chat-claude", undefined); + ctx.ui.setStatus("chat-claude", undefined); + ctx.ui.setTitle("pi"); + return; + } + + const sessionId = sessions.get(chatMode); + const short = sessionId ? sessionId.slice(0, 8) : "new"; + const modelUp = capitalize(chatMode).toUpperCase(); + + ctx.ui.setWidget("chat-claude", (tui: any, theme: any) => { + tuiRef = tui; // ← captured for live streaming re-renders + return { + invalidate: () => {}, + render: () => { + const rail = orange("▌ "); + 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 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]; + }, + }; + }, { placement: "aboveEditor" }); + + const busy = isGenerating ? " · streaming" : ""; + ctx.ui.setStatus("chat-claude", + orange(`◆ Claude ${capitalize(chatMode)} · ${short} · effort:${persisted.effort}${busy}`)); + ctx.ui.setTitle(`pi · Claude ${capitalize(chatMode)} Chat`); + } + + // ── ESC-to-abort editor ────────────────────────────────────────────────── + // ESC (the "interrupt" action) is on the extension-runner's reserved list + // (see node_modules/@mariozechner/pi-coding-agent/.../runner.js — any + // registerShortcut("escape", …) is silently dropped), so a custom editor is + // the sanctioned way to intercept it. We subclass pi's exported CustomEditor + // and short-circuit ESC ONLY while a chat-claude response is streaming. + // For every other case we defer to `super.handleInput`, which runs the + // app-level keybindings — including pi's own onEscape handler, which + // setCustomEditorComponent copies onto the custom editor at install time + // (see interactive-mode.js setCustomEditorComponent, ~line 1258). + class ChatEscEditor extends CustomEditor { + handleInput(data: string): void { + if (matchesKey(data, "escape") && isGenerating && currentAbort) { + try { currentAbort.abort(); } catch { /* ok */ } + // We may not have a direct ctx here, but the UI is live during + // chat mode, so flush any pending throttled render and force + // a frame now; the chat-claude-session renderer will show the + // assistant turn as cancelled once runClaude's promise + // rejects with AbortError. + flushStreamRender(); + return; + } + super.handleInput(data); + } + } + + // ── Mode transitions ───────────────────────────────────────────────────── + function enterChatMode(model: Model, ctx: any, freshSession: boolean) { + const wasActive = chatMode !== null; + const modelChanged = chatMode !== model; + + if (freshSession) sessions.delete(model); + // A new /claude invocation after an exit starts a fresh border box, so + // drop any reference to the previous session's details. The existing + // CustomMessage in the conversation keeps its own reference and stays + // visible in the scrollback. + if (!wasActive || modelChanged || freshSession) { + currentDetails = null; + } + + chatMode = model; + + // Stand up (or refresh) the pi-ask bridge so Claude can ask the user + // questions through pi's native overlay. Re-create on every entry so + // the socket+temp dir lifetime is bounded by the chat session. + if (ctx?.hasUI) { + askBridge?.close(); + try { + askBridge = startAskBridge({ + ui: ctx.ui, + onAsk: () => tuiRef?.requestRender(), + }); + } catch (err) { + askBridge = null; + ctx.ui.notify( + `pi-ask bridge unavailable: ${err instanceof Error ? err.message : String(err)} — Claude won't be able to ask questions.`, + "warning", + ); + } + + // Install the ESC-aborts-Claude custom editor. Idempotent: if chat + // mode was already active (e.g. /claude haiku → /claude opus), setting + // it again just re-wires the same class cleanly. + ctx.ui.setEditorComponent((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => + new ChatEscEditor(tui, theme, keybindings), + ); + } + + syncUI(ctx); + + if (ctx?.hasUI) { + const sess = sessions.get(model); + const kind = freshSession || !sess ? "new session" : `resume ${sess.slice(0, 8)}`; + const verb = wasActive ? (modelChanged ? "Switched to" : "Re-entered") : "Entered chat mode:"; + ctx.ui.notify(`${verb} Claude ${capitalize(model)} · ${kind}`, "info"); + } + } + + function exitChatMode(ctx: any) { + if (currentAbort) try { currentAbort.abort(); } catch { /* ok */ } + currentAbort = null; + isGenerating = false; + chatMode = null; + // Cancel any pending throttled stream render so we don't leave a + // dangling timer firing tuiRef.requestRender() after chat mode ends + // (tuiRef itself lingers, so the render would be harmless but wasted). + if (streamRenderTimer) { + clearTimeout(streamRenderTimer); + streamRenderTimer = null; + } + // Detach from current session details so the next entry starts a new + // border. The message and its details stay in place in pi's scrollback. + currentDetails = null; + // Tear down the pi-ask bridge: close the socket and remove the temp + // dir holding the socket + generated mcp.json. + askBridge?.close(); + askBridge = null; + // Restore pi's default editor (undoes ChatEscEditor from enterChatMode). + if (ctx?.hasUI) ctx.ui.setEditorComponent(undefined); + syncUI(ctx); + if (ctx?.hasUI) ctx.ui.notify("Exited chat mode — back to normal pi.", "info"); + } + + // ── Session / turn management ──────────────────────────────────────────── + function ensureSessionMessage(): ChatSessionDetails { + if (currentDetails) return currentDetails; + const details: ChatSessionDetails = { turns: [] }; + currentDetails = details; + pi.sendMessage( + { + customType: "chat-claude-session", + // content is only used if we had no custom renderer; stays + // hidden from pi's LLM via the context filter below. + content: "", + display: true, + details, + }, + { triggerTurn: false }, + ); + return details; + } + + async function runChatTurn(userText: string, ctx: any) { + if (!chatMode) return; + const model = chatMode; + const details = ensureSessionMessage(); + + // 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 }); + const existingSession = sessions.get(model); + const assistantTurn: AssistantTurn = { + role: "assistant", + model, + blocks: [], + finalText: "", + isResume: !!existingSession, + done: false, + }; + details.turns.push(assistantTurn); + tuiRef?.requestRender(); + + isGenerating = true; + currentAbort = new AbortController(); + syncUI(ctx); + if (ctx?.hasUI) ctx.ui.setWorkingMessage(`Claude ${capitalize(model)} is thinking…`); + + try { + const r = await runClaude(userText, { + model, + sessionId: existingSession, + cwd: ctx.cwd, + signal: currentAbort.signal, + // Enable extended thinking — without --effort, `claude -p` + // NEVER emits thinking_delta events regardless of the user's + // interactive defaultThinkingLevel setting, and the italic + // thinking-block rendering below sits idle. Default is "max" + // and is configurable live via /claude-effort; the model + // still decides on-demand whether it actually needs to think. + effort: persisted.effort, + // Route AskUserQuestion-style requests through pi's native + // overlay via the pi-ask-mcp bridge. Disallowing the built-in + // AskUserQuestion forces Claude to use mcp__pi__ask if it + // wants to ask a structured question. + mcpConfigPath: askBridge?.mcpConfigPath, + disallowedTools: askBridge ? ["AskUserQuestion"] : undefined, + onUpdate: (partial) => { + assistantTurn.blocks = partial.blocks; + assistantTurn.finalText = partial.finalText; + // Throttle to ~30 Hz so a fast token stream doesn't cause + // a render-per-token, which compounds with any other + // extension's per-frame work (footer, widgets, etc.). + scheduleStreamRender(); + }, + }); + + if (r.sessionId) sessions.set(model, r.sessionId); + assistantTurn.blocks = r.blocks; + assistantTurn.finalText = r.finalText; + assistantTurn.sessionId = r.sessionId; + assistantTurn.costUsd = r.costUsd; + assistantTurn.inputTokens = r.inputTokens; + assistantTurn.outputTokens = r.outputTokens; + assistantTurn.cacheReadTokens = r.cacheReadTokens; + assistantTurn.cacheWriteTokens = r.cacheWriteTokens; + assistantTurn.done = true; + } catch (err) { + const aborted = currentAbort?.signal.aborted === true; + assistantTurn.done = true; + assistantTurn.cancelled = aborted; + assistantTurn.error = aborted ? undefined : (err instanceof Error ? err.message : String(err)); + } finally { + isGenerating = false; + currentAbort = null; + if (ctx?.hasUI) ctx.ui.setWorkingMessage(undefined); + syncUI(ctx); + // Flush (not schedule): the stream just ended or was aborted — + // we want the final frame on screen immediately, not 33 ms later. + // Also cancels any in-flight throttled timer so it doesn't fire + // a stale second render after the assistant turn is already + // marked done and cached. + flushStreamRender(); + } + } + + // ── Input interception ─────────────────────────────────────────────────── + // Registered pi commands (/claude, /claude-end, etc.) dispatch BEFORE this + // event fires, so they still work normally. Bash via `!` goes through + // user_bash, not here. Every other text the user submits in chat mode is + // routed straight to Claude. + pi.on("input", async (event, ctx) => { + if (!chatMode) return { action: "continue" } as const; + if (event.source !== "interactive") return { action: "continue" } as const; + const text = event.text ?? ""; + if (!text.trim()) return { action: "continue" } as const; + if (text.trimStart().startsWith("!")) return { action: "continue" } as const; + + if (isGenerating) { + ctx.ui.notify( + "Claude is still responding. Use /claude-abort to cancel, then try again.", + "warning", + ); + return { action: "handled" } as const; + } + + runChatTurn(text, ctx).catch((err) => { + ctx.ui.notify( + `Chat error: ${err instanceof Error ? err.message : String(err)}`, + "error", + ); + }); + return { action: "handled" } as const; + }); + + // Keep chat-mode custom messages out of pi's LLM context — chat mode is + // between the user and Claude, not part of pi's conversation. + pi.on("context", (event) => { + const filtered = event.messages.filter((m: any) => + !(m.role === "custom" && CHAT_CLAUDE_CUSTOM_TYPES.has(m.customType)), + ); + return { messages: filtered }; + }); + + // ── Session lifecycle ──────────────────────────────────────────────────── + pi.on("session_start", (_event, ctx) => { syncUI(ctx); }); + pi.on("session_shutdown", (_event, ctx) => { + if (chatMode) exitChatMode(ctx); + // Defensive: if exitChatMode was never reached (chatMode was already + // null but a bridge somehow lingered), close it directly. + if (askBridge) { askBridge.close(); askBridge = null; } + // Defensive: same for the throttled render timer — exitChatMode + // already clears it, but this keeps the Node process clean in the + // case where chat mode was never entered but some hypothetical + // future code path scheduled a render anyway. + if (streamRenderTimer) { + clearTimeout(streamRenderTimer); + streamRenderTimer = null; + } + }); + + // ── Commands ───────────────────────────────────────────────────────────── + const modelCompletions = (prefix: string) => + MODELS.filter((m) => m.startsWith(prefix.toLowerCase())) + .map((m) => ({ value: m, label: m })); + + pi.registerCommand("claude", { + description: [ + "Enter distinct Claude chat mode — typed input bypasses pi's LLM and goes to Claude.", + " /claude — enter with last/default model (sonnet)", + " /claude haiku|sonnet|opus — enter/switch model", + ].join("\n"), + getArgumentCompletions: modelCompletions, + handler: async (args, ctx) => { + const arg = (args ?? "").trim().toLowerCase(); + const target: Model = (MODELS as readonly string[]).includes(arg) + ? (arg as Model) + : (chatMode ?? "sonnet"); + enterChatMode(target, ctx, false); + }, + }); + + pi.registerCommand("claude-new", { + description: "Enter chat mode with a fresh Claude session (discards any resumed session id). Example: /claude-new opus", + getArgumentCompletions: modelCompletions, + handler: async (args, ctx) => { + const arg = (args ?? "").trim().toLowerCase(); + const target: Model = (MODELS as readonly string[]).includes(arg) + ? (arg as Model) + : (chatMode ?? "sonnet"); + enterChatMode(target, ctx, true); + }, + }); + + // /claude-effort — set the extended-thinking effort level for subsequent + // chat turns. Without the flag `claude -p` emits no thinking_delta + // events at all (the interactive `defaultThinkingLevel` setting is + // ignored in -p mode); with it, the model decides on-demand whether + // to actually think. Stored on the persisted state so the choice + // survives `/reload`. + // + // /claude-effort — show current value + // /claude-effort max — set to max (default) + // /claude-effort off — disable (skip the --effort flag) + const effortCompletions = (prefix: string) => + EFFORTS.filter((e) => e.startsWith(prefix.toLowerCase())) + .map((e) => ({ value: e, label: e })); + + pi.registerCommand("claude-effort", { + description: [ + "Set the extended-thinking effort level for Claude chat turns.", + " /claude-effort — show current value", + " /claude-effort off|low|medium|high|xhigh|max", + "", + "Note: without an effort setting, `claude -p` emits no thinking", + "blocks at all — so lowering this trades thought visibility for speed.", + ].join("\n"), + getArgumentCompletions: effortCompletions, + handler: async (args, ctx) => { + const arg = (args ?? "").trim().toLowerCase(); + if (!arg) { + ctx.ui.notify( + `Current Claude effort: ${persisted.effort}. Options: ${EFFORTS.join(", ")}.`, + "info", + ); + return; + } + if (!(EFFORTS as readonly string[]).includes(arg)) { + ctx.ui.notify( + `Unknown effort "${arg}". Valid levels: ${EFFORTS.join(", ")}.`, + "warning", + ); + return; + } + const prev = persisted.effort; + persisted.effort = arg as Effort; + syncUI(ctx); + const note = arg === "off" + ? "thinking disabled — Claude will no longer emit thinking blocks" + : `thinking effort set to ${arg}`; + ctx.ui.notify( + `${note} (was ${prev}). Applies to the next chat turn.`, + "info", + ); + }, + }); + + pi.registerCommand("claude-end", { + description: "Exit Claude chat mode and resume normal pi operation.", + handler: async (_args, ctx) => { + if (!chatMode) { ctx.ui.notify("Not in chat mode.", "info"); return; } + exitChatMode(ctx); + }, + }); + + pi.registerCommand("claude-abort", { + description: "Cancel the in-flight Claude response (no effect if nothing is generating).", + handler: async (_args, ctx) => { + if (!isGenerating || !currentAbort) { + ctx.ui.notify("No active Claude response to cancel.", "info"); + return; + } + try { currentAbort.abort(); } catch { /* ok */ } + ctx.ui.notify("Aborting Claude response…", "info"); + }, + }); + + // /claude-resume — present a picker of past Claude sessions whose cwd matches + // the current project directory, then resume the chosen one in chat mode. + // + // Caveat: this only sets the session id and starts a fresh orange border. + // The historical transcript is NOT replayed inside pi (rendering it would + // require a separate translation pass from JSONL → ChatTurn[]); however + // `claude --resume ` keeps the FULL conversation context alive on the + // Claude side, so subsequent prompts behave exactly like a continuation. + pi.registerCommand("claude-resume", { + description: "Pick a past Claude session for the current project directory and resume it in chat mode.", + handler: async (_args, ctx) => { + if (!ctx?.hasUI) { + ctx?.ui?.notify?.("/claude-resume requires interactive mode.", "error"); + return; + } + if (isGenerating) { + ctx.ui.notify( + "A Claude response is still streaming. Use /claude-abort first, then /claude-resume.", + "warning", + ); + return; + } + + const past = readPastSessions(ctx.cwd); + if (past.length === 0) { + ctx.ui.notify( + `No past Claude sessions found for ${ctx.cwd}.`, + "info", + ); + return; + } + + // Cap the picker at the 25 most recent sessions to keep the + // inline-note overlay tractable. Sessions are already sorted + // newest-first by readPastSessions(). + const MAX_OPTIONS = 25; + const choices = past.slice(0, MAX_OPTIONS); + + // Label format (per user spec): + // · · (session:) + const PREVIEW_MAX = 60; + const buildLabel = (s: PastSession) => { + const preview = s.firstUserMessage + ? truncate(s.firstUserMessage, PREVIEW_MAX) + : "(no user message)"; + return `${relativeTime(s.mtimeMs)} · ${preview} · (session:${s.sessionId.slice(0, 8)})`; + }; + + // Disambiguate: in the very unlikely event two sessions produce + // the same display label, append a counter so the post-pick lookup + // can match exactly. + const labels: string[] = []; + const seen = new Map(); + for (const s of choices) { + const base = buildLabel(s); + const n = seen.get(base) ?? 0; + seen.set(base, n + 1); + labels.push(n === 0 ? base : `${base} #${n + 1}`); + } + + const sessionPick = await askSingleQuestionWithInlineNote(ctx.ui, { + question: `Resume which past Claude session in ${ctx.cwd}?`, + options: labels.map((label) => ({ label })), + }); + if (sessionPick.selectedOptions.length === 0) { + ctx.ui.notify("Resume cancelled.", "info"); + return; + } + const pickedLabel = sessionPick.selectedOptions[0]; + const idx = labels.indexOf(pickedLabel); + if (idx < 0) { + ctx.ui.notify("Picked session not found (label mismatch).", "warning"); + return; + } + const picked = choices[idx]; + + // Second picker: which model to display the resumed conversation + // under in pi's UI. Note: claude CLI ignores --model when --resume + // is set, so this is purely a UI/labelling choice. We mark the + // session's original model with "(used by this session)" and set + // it as the recommended default so most users can just hit Enter. + const originalModel = picked.model; + const modelLabels = MODELS.map((m) => + originalModel === m ? `${m} (used by this session)` : m, + ); + const recommendedIdx = originalModel ? MODELS.indexOf(originalModel) : 1; // default sonnet + + const modelPick = await askSingleQuestionWithInlineNote(ctx.ui, { + question: "Display this resumed session under which model in pi's UI?", + options: modelLabels.map((label) => ({ label })), + recommended: recommendedIdx, + }); + if (modelPick.selectedOptions.length === 0) { + ctx.ui.notify("Resume cancelled.", "info"); + return; + } + // Strip any "(used by this session)" suffix and parse the bare + // model name (the first whitespace-separated token). + const bare = modelPick.selectedOptions[0].split(/\s+/)[0].toLowerCase(); + const targetModel: Model = (MODELS as readonly string[]).includes(bare) + ? (bare as Model) + : "sonnet"; + + // Wire up the session id BEFORE entering chat mode, so the next + // turn the user sends triggers --resume . + sessions.set(targetModel, picked.sessionId); + enterChatMode(targetModel, ctx, false); + + // Replay the historical transcript inside the orange border so the + // user can SEE the context they're resuming. ensureSessionMessage() + // creates the (now-empty) session CustomMessage; we then push every + // past turn into details.turns and ask for a re-render. + const historical = loadSessionTurns(picked.sessionId, ctx.cwd, targetModel); + const details = ensureSessionMessage(); + details.turns.push(...historical); + tuiRef?.requestRender(); + + const ago = relativeTime(picked.mtimeMs); + const preview = picked.firstUserMessage + ? `: "${truncate(picked.firstUserMessage, 50)}"` + : ""; + const histNote = historical.length > 0 + ? ` (${historical.length} historical turn${historical.length === 1 ? "" : "s"} loaded)` + : " (transcript empty or unreadable)"; + ctx.ui.notify( + `Resuming session ${picked.sessionId.slice(0, 8)} (${ago})${preview} as Claude ${capitalize(targetModel)}.${histNote}`, + "info", + ); + }, + }); + // Note on ESC: pi's extension runner reserves the "interrupt" action, so + // pi.registerShortcut("escape", …) is silently ignored. ESC-to-abort is + // wired via the ChatEscEditor custom editor installed in enterChatMode. + + // ── Message renderer ───────────────────────────────────────────────────── + // ONE custom message type holds the WHOLE chat-mode session. Returning a + // live component (render reads `details.turns` on every frame) lets + // streaming updates appear with a simple `tuiRef.requestRender()` — no + // full rebuild of pi's chat container required. + // + // Performance: each frame now reuses cached per-turn line output for + // completed turns (see renderSessionLines). Only the in-flight assistant + // turn (if any) is rebuilt each frame, so long conversations stop driving + // O(turns × blocks) allocation during Claude streaming. + pi.registerMessageRenderer("chat-claude-session", (message, _opts, theme) => { + const d = message.details as ChatSessionDetails | undefined; + if (!d || !Array.isArray(d.turns)) return undefined; + + return { + // pi calls invalidate() when theme changes or a from-scratch + // re-render is needed — drop every turn's render cache so the + // next render pass rebuilds against the new theme. + invalidate: () => invalidateSessionCache(d), + render: (width: number) => { + if (width < 6) return renderSessionLines(d, theme, width); + const innerWidth = width - 4; // 2 border chars + 2 padding chars + // renderSessionLines returns lines already padded to + // `innerWidth` visible columns, so wrapInOrangeBorder does + // NO visibleWidth() call per frame — the previous hot path + // (~85% CPU in Intl.Segmenter) is gone. + const paddedInnerLines = renderSessionLines(d, theme, innerWidth); + return wrapInOrangeBorder(paddedInnerLines, width); + }, + }; + }); +} diff --git a/pi/.pi/agent/extensions/claude-account-switch.ts b/pi/.pi/agent/extensions/claude-account-switch.ts deleted file mode 100644 index 25598e9..0000000 --- a/pi/.pi/agent/extensions/claude-account-switch.ts +++ /dev/null @@ -1,419 +0,0 @@ -/** - * Claude Account Switch Extension - * - * Switches between two Claude Pro accounts (personal and work) **without - * restarting pi**. Works by swapping auth.json files at the filesystem level, - * then reloading the auth storage and forcing an immediate token refresh to - * validate the switch. - * - * Why file-level swaps? Anthropic's OAuth rotates refresh tokens on every - * refresh. Calling authStorage.set() can appear to work, but the next - * getApiKey() call triggers refreshOAuthTokenWithLock(), which re-reads - * auth.json from disk — overwriting in-memory changes if persistence - * silently failed. Working at the file level avoids this entirely. - * - * Setup (one-time per account): - * 1. /login → authenticate with personal account - * 2. /switch-claude save personal - * 3. /login → authenticate with work account - * 4. /switch-claude save work - * - * Usage: - * /switch-claude — pick account interactively - * /switch-claude save — save current pi login as a named profile - */ - -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { execSync } from "node:child_process"; - -const HOME = os.homedir(); -const AUTH_JSON = path.join(HOME, ".pi/agent/auth.json"); -const MARKER_FILE = path.join(HOME, ".pi/agent/auth.json.current"); -const PROFILES_DIR = path.join(HOME, ".pi/agent/profiles"); - -type Account = "personal" | "work"; - -// ── Session-window helpers ───────────────────────────────────────────────── -// -// We store the actual `resets_at` timestamp returned by Claude's usage API -// (via the usage:update event) so the switch menu can show a live countdown -// to the next session reset rather than a guessed switchedAt + 5h window. - -function sessionStampPath(account: Account): string { - return path.join(PROFILES_DIR, `session-${account}.json`); -} - -/** Persist the actual session-reset timestamp for an account. */ -function saveSessionResetsAt(account: Account, resetsAt: number): void { - try { - fs.mkdirSync(PROFILES_DIR, { recursive: true }); - fs.writeFileSync( - sessionStampPath(account), - JSON.stringify({ resetsAt }, null, 2), - { mode: 0o600 }, - ); - } catch {} -} - -/** Load the stored session-reset timestamp (ms epoch), or null. */ -function loadSessionResetsAt(account: Account): number | null { - try { - const raw = fs.readFileSync(sessionStampPath(account), "utf-8"); - const { resetsAt } = JSON.parse(raw) as { resetsAt: number }; - if (typeof resetsAt === "number") return resetsAt; - } catch {} - return null; -} - -/** Format milliseconds as a compact duration string. */ -function formatDuration(ms: number): string { - const totalSec = Math.ceil(ms / 1000); - const h = Math.floor(totalSec / 3600); - const m = Math.floor((totalSec % 3600) / 60); - const s = totalSec % 60; - if (h > 0) return `${h}h ${m}m`; - if (m > 0) return `${m}m`; - return `${s}s`; -} - -/** - * One-line session summary appended to each account option in the select menu. - * - * Window still running: [resets in Xh Ym] - * Window already passed: [0 (ready)] - * Never recorded: (empty) - */ -function sessionSummary(account: Account): string { - const resetsAt = loadSessionResetsAt(account); - if (resetsAt === null) return ""; - const remaining = resetsAt - Date.now(); - if (remaining <= 0) return " [0 (ready)]"; - return ` [resets in ${formatDuration(remaining)}]`; -} - -// ── Profile helpers ──────────────────────────────────────────────────────── - -function profilePath(account: Account): string { - return path.join(PROFILES_DIR, `auth-${account}.json`); -} - -function hasProfile(account: Account): boolean { - return fs.existsSync(profilePath(account)); -} - -/** - * Save auth.json content directly to a profile file. - * This captures the exact on-disk state, including any tokens that were - * refreshed behind our back by the auth system. - * - * We parse + re-serialize the JSON to guard against corrupt auth.json - * (e.g. trailing commas left by buggy serializers). If the file can't - * be parsed, we skip the save rather than propagate bad data. - */ -function saveCurrentAuthToProfile(account: Account): boolean { - fs.mkdirSync(PROFILES_DIR, { recursive: true }); - if (!fs.existsSync(AUTH_JSON)) return false; - try { - const raw = fs.readFileSync(AUTH_JSON, "utf-8"); - const parsed = JSON.parse(raw); // validates JSON - const clean = JSON.stringify(parsed, null, 2); - fs.writeFileSync(profilePath(account), clean, { mode: 0o600 }); - return true; - } catch { - // auth.json is missing or corrupt — don't propagate bad data - return false; - } -} - -/** - * Copy a profile file to auth.json. This is an atomic-ish swap that - * replaces the entire file rather than merging per-provider. - * - * Like saveCurrentAuthToProfile, we round-trip through JSON.parse to - * ensure we never write corrupt data to auth.json. - */ -function restoreProfileToAuth(account: Account): void { - const raw = fs.readFileSync(profilePath(account), "utf-8"); - const parsed = JSON.parse(raw); // throws on corrupt profile - const clean = JSON.stringify(parsed, null, 2); - fs.writeFileSync(AUTH_JSON, clean, { mode: 0o600 }); -} - -function setMarker(account: Account): void { - fs.writeFileSync(MARKER_FILE, account, "utf-8"); -} - -function getCurrentAccount(): Account | "unknown" { - try { - const marker = fs.readFileSync(MARKER_FILE, "utf8").trim(); - if (marker === "personal" || marker === "work") return marker; - } catch {} - return "unknown"; -} - -// ── Other session detection ──────────────────────────────────────────────── - -function otherPiSessions(): number[] { - try { - const myPid = process.pid; - // Use a character class [c] trick so pgrep doesn't match its own process - const out = execSync("pgrep -f 'pi-[c]oding-agent' 2>/dev/null || true", { - encoding: "utf-8", - }); - const pids = out - .trim() - .split("\n") - .map(Number) - .filter((p) => p && p !== myPid && !isNaN(p)); - return pids; - } catch { - return []; - } -} - -function killOtherSessions(pids: number[]): number { - let killed = 0; - for (const pid of pids) { - try { - process.kill(pid); - killed++; - } catch { - // already dead or permission denied - } - } - return killed; -} - -// ── UI helpers ───────────────────────────────────────────────────────────── - -function statusLabel(account: Account | "unknown"): string { - switch (account) { - case "personal": - return " personal"; - case "work": - return "󰃖 work"; - default: - return " claude"; - } -} - -// ── Extension ────────────────────────────────────────────────────────────── - -export default function (pi: ExtensionAPI) { - let currentAccount: Account | "unknown" = "unknown"; - - // Whenever usage-bars fetches fresh data, save the real resets_at for the - // current account so the switch menu shows an accurate live countdown. - pi.events.on("usage:update", (event: unknown) => { - const e = event as { sessionResetsAt?: number }; - if (currentAccount !== "unknown" && typeof e.sessionResetsAt === "number") { - saveSessionResetsAt(currentAccount, e.sessionResetsAt); - } - }); - - pi.on("session_start", async (_event, ctx) => { - // Proper-lockfile creates auth.json.lock as a *directory* (atomic mkdir). - // If a regular file exists at that path (e.g. left by an older pi version), - // rmdir fails with ENOTDIR → lock acquisition throws → loadError is set → - // credentials are never persisted after /login. Delete the stale file and - // reload so this session has working auth persistence. - const lockPath = AUTH_JSON + ".lock"; - try { - const stat = fs.statSync(lockPath); - if (stat.isFile()) { - fs.unlinkSync(lockPath); - ctx.modelRegistry.authStorage.reload(); - } - } catch { - // lock doesn't exist or we can't stat it — nothing to fix - } - - // Guard against corrupt auth.json (e.g. trailing commas from buggy - // serializers). Re-serialize to clean JSON and reload so the auth - // system picks up valid credentials. - try { - const raw = fs.readFileSync(AUTH_JSON, "utf-8"); - const parsed = JSON.parse(raw); - const clean = JSON.stringify(parsed, null, 2); - if (clean !== raw) { - fs.writeFileSync(AUTH_JSON, clean, { mode: 0o600 }); - ctx.modelRegistry.authStorage.reload(); - } - } catch { - // auth.json missing or unparseable — nothing we can fix here - } - - currentAccount = getCurrentAccount(); - ctx.ui.setStatus("claude-account", statusLabel(currentAccount)); - }); - - pi.registerCommand("switch-claude", { - description: - "Switch between personal () and work (󰃖) Claude accounts. Use 'save ' to save current login as a profile.", - handler: async (args, ctx) => { - const authStorage = ctx.modelRegistry.authStorage; - const trimmed = args?.trim() ?? ""; - - // ── Save current auth state as a named profile ────────────────── - if (trimmed.startsWith("save ")) { - const name = trimmed.slice(5).trim(); - if (name !== "personal" && name !== "work") { - ctx.ui.notify( - "Usage: /switch-claude save personal|work", - "warning", - ); - return; - } - - if (!authStorage.has("anthropic")) { - ctx.ui.notify( - "No Anthropic credentials found. Run /login first.", - "warning", - ); - return; - } - - saveCurrentAuthToProfile(name as Account); - currentAccount = name as Account; - setMarker(currentAccount); - ctx.ui.setStatus("claude-account", statusLabel(currentAccount)); - ctx.ui.notify( - `Saved current login as ${statusLabel(name as Account)} profile`, - "info", - ); - return; - } - - - // ── Resolve target account (direct arg or interactive) ────────── - let newAccount: Account; - if (trimmed === "personal" || trimmed === "work") { - newAccount = trimmed; - } else if (trimmed === "") { - const personalLabel = ` personal${currentAccount === "personal" ? " ← current" : ""}${!hasProfile("personal") ? " (no profile saved)" : ""}${sessionSummary("personal")}`; - const workLabel = `󰃖 work${currentAccount === "work" ? " ← current" : ""}${!hasProfile("work") ? " (no profile saved)" : ""}${sessionSummary("work")}`; - - const accountChoice = await ctx.ui.select( - "Switch Claude account:", - [personalLabel, workLabel], - ); - if (accountChoice === undefined) return; - newAccount = accountChoice.startsWith("") ? "personal" : "work"; - } else { - ctx.ui.notify( - "Usage: /switch-claude [personal|work|save ]", - "warning", - ); - return; - } - - if (newAccount === currentAccount) { - ctx.ui.notify( - `Already using ${statusLabel(newAccount)}`, - "info", - ); - return; - } - - // ── Warn about other sessions ─────────────────────────────────── - const otherPids = otherPiSessions(); - if (otherPids.length > 0) { - const sessionChoice = await ctx.ui.select( - `⚠️ ${otherPids.length} other pi session(s) detected`, - [ - "Continue anyway", - `Kill ${otherPids.length} other instance(s) and continue`, - "Cancel", - ], - ); - if (sessionChoice === undefined || sessionChoice.includes("Cancel")) - return; - - if (sessionChoice.includes("Kill")) { - const killed = killOtherSessions(otherPids); - ctx.ui.notify(`Killed ${killed} pi session(s)`, "info"); - } - } - - if (!hasProfile(newAccount)) { - ctx.ui.notify( - `No profile saved for ${newAccount}. Run /login then /switch-claude save ${newAccount}`, - "warning", - ); - return; - } - - // ── Perform the switch ────────────────────────────────────────── - try { - // 1. Snapshot current auth.json → outgoing profile. - // This captures any tokens that were silently refreshed - // since the last save (the file is the source of truth, - // not the in-memory snapshot from getAll()). - if (currentAccount !== "unknown") { - saveCurrentAuthToProfile(currentAccount); - } - - // 2. Copy incoming profile → auth.json (full file replace). - restoreProfileToAuth(newAccount); - - // 3. Tell AuthStorage to re-read the file. This updates - // the in-memory credential cache from the new auth.json. - authStorage.reload(); - - // 4. Force an immediate token refresh to validate the switch. - // If the stored refresh token is stale, this will fail now - // rather than on the next chat message. - const apiKey = await authStorage.getApiKey("anthropic"); - - if (!apiKey) { - // Refresh failed → roll back to the previous account. - if (currentAccount !== "unknown") { - restoreProfileToAuth(currentAccount); - authStorage.reload(); - } - ctx.ui.notify( - `❌ Switch failed: could not authenticate as ${newAccount}. ` + - `The saved refresh token may have expired. ` + - `Run /login then /switch-claude save ${newAccount} to re-save.`, - "error", - ); - return; - } - - // 5. Success — the refresh worked, auth.json now has fresh - // tokens. Save them back to the profile so next switch - // has the latest refresh token. - saveCurrentAuthToProfile(newAccount); - - currentAccount = newAccount; - setMarker(currentAccount); - ctx.ui.setStatus("claude-account", statusLabel(currentAccount)); - pi.events.emit("claude-account:switched", { account: newAccount }); - ctx.ui.notify( - `Switched to ${statusLabel(newAccount)} ✓`, - "info", - ); - } catch (e: unknown) { - // Something went wrong → try to roll back. - try { - if (currentAccount !== "unknown" && hasProfile(currentAccount)) { - restoreProfileToAuth(currentAccount); - authStorage.reload(); - } - } catch { - // rollback failed too — nothing more we can do - } - - const msg = e instanceof Error ? e.message : String(e); - ctx.ui.notify( - `❌ Switch failed: ${msg}. Rolled back to ${statusLabel(currentAccount)}. ` + - `You may need to /login and /switch-claude save ${newAccount}.`, - "error", - ); - } - }, - }); -} diff --git a/pi/.pi/agent/extensions/footer-display.ts b/pi/.pi/agent/extensions/footer-display.ts index 2c480cd..219541f 100644 --- a/pi/.pi/agent/extensions/footer-display.ts +++ b/pi/.pi/agent/extensions/footer-display.ts @@ -4,11 +4,10 @@ * Replaces the built-in pi footer with a single clean line that assembles * status from all other extensions: * - * \uEF85 | S ⣿⣶⣀⣀⣀ 34% 2h 55m | W ⣿⣿⣷⣀⣀ 68% ⟳ Fri 09:00 | C ⣿⣀⣀⣀⣀ 20% | Sonnet 4.6 | rust-analyzer | MCP: 1/2 + * ~dir | S ⣿⣶⣀⣀⣀ 34% 2h 55m | W ⣿⣿⣷⣀⣀ 68% ⟳ Fri 09:00 | C ⣿⣀⣀⣀⣀ 20% | Sonnet 4.6 | rust-analyzer | MCP: 1/2 * * Status sources: - * "claude-account" — set by claude-account-switch.ts → just the icon - * "usage-bars" — set by usage-bars extension → S/W bars (pass-through) + * usage:update event — set by usage-bars extension → S/W bars (Claude usage, always shown) * ctx.getContextUsage() → C bar (rendered here) * ctx.model → model short name * "lsp" — set by lsp-pi extension → strip "LSP " prefix @@ -77,11 +76,6 @@ function formatDurationMs(ms: number): string { return "<1m"; } -// Nerd Font codepoints matched to what claude-account-switch.ts emits -const ICON_PERSONAL = "\uEF85"; // U+EF85 — home -const ICON_WORK = "\uDB80\uDCD6"; // U+F00D6 — briefcase (surrogate pair) -const ICON_UNKNOWN = "\uF420"; // U+F420 — claude default - export default function (pi: ExtensionAPI) { let ctx: any = null; let tuiRef: any = null; @@ -112,18 +106,7 @@ export default function (pi: ExtensionAPI) { : cwd; parts.push(theme.fg("dim", dir)); - // 2. Account icon - const acctRaw = statuses.get("claude-account"); - if (acctRaw !== undefined) { - const clean = stripAnsi(acctRaw).trim(); - let icon: string; - if (clean.includes("personal")) icon = ICON_PERSONAL; - else if (clean.includes("work")) icon = ICON_WORK; - else icon = ICON_UNKNOWN; - parts.push(theme.fg("dim", icon)); - } - - // 3. S / W usage bars + C bar — joined as one |-separated block + // 2. S / W usage bars + C bar — joined as one |-separated block const usageRaw = statuses.get("usage-bars"); const contextUsage = ctx?.getContextUsage?.(); { @@ -135,8 +118,8 @@ export default function (pi: ExtensionAPI) { const session = Math.max(0, Math.min(100, Math.round(usageSession))); const weekly = Math.max(0, Math.min(100, Math.round(usageWeekly))); - let sPart = theme.fg("muted", "S ") + renderBrailleBar(theme, session) + " " + theme.fg("dim", `${session}%`); - let wPart = theme.fg("muted", "W ") + renderBrailleBar(theme, weekly) + " " + theme.fg("dim", `${weekly}%`); + let sPart = theme.fg("muted", "\uF4F5 S ") + renderBrailleBar(theme, session) + " " + theme.fg("dim", `${session}%`); + let wPart = theme.fg("muted", "\uF4F5 W ") + renderBrailleBar(theme, weekly) + " " + theme.fg("dim", `${weekly}%`); if (sessionResetsAt !== null) { const msLeft = sessionResetsAt - Date.now(); @@ -156,34 +139,51 @@ export default function (pi: ExtensionAPI) { if (contextUsage && contextUsage.percent !== null) { const pct = Math.round(contextUsage.percent); + const chatStatus = statuses.get("chat-claude"); + const isChatActive = !!chatStatus && chatStatus.includes("Claude"); + + // When chat is active and context is high, show warning indicator + let cLabel = "C"; + let cColor = "muted"; + if (isChatActive && pct >= 70) { + cLabel = pct >= 90 ? "C⚠" : "C⚡"; + cColor = pct >= 90 ? "error" : "warning"; + } + const cBar = - theme.fg("muted", "C ") + + theme.fg(cColor, cLabel + " ") + renderBrailleBar(theme, pct) + " " + - theme.fg("dim", `${pct}%`); + theme.fg(pct >= 70 && isChatActive ? "warning" : "dim", `${pct}%`); block = block ? block + pipeSep + cBar : cBar; } if (block) parts.push(block); } - // 4. Model short name + // 3. Model short name const modelId = ctx?.model?.id; if (modelId) parts.push(theme.fg("dim", getModelShortName(modelId))); - // 5. LSP — strip "LSP" prefix and activity dot + // 4. LSP — strip "LSP" prefix and activity dot const lspRaw = statuses.get("lsp"); if (lspRaw) { const clean = stripAnsi(lspRaw).trim().replace(/^LSP\s*[•·]?\s*/i, "").trim(); if (clean) parts.push(theme.fg("dim", clean)); } - // 6. MCP — strip " servers" suffix + // 5. MCP — strip " servers" suffix const mcpRaw = statuses.get("mcp"); if (mcpRaw) { const clean = stripAnsi(mcpRaw).trim().replace(/\s*servers?.*$/, "").trim(); if (clean) parts.push(theme.fg("dim", clean)); } + // 6. Active Claude chat session + const chatRaw = statuses.get("chat-claude"); + if (chatRaw) { + parts.push(theme.fg("accent", stripAnsi(chatRaw).trim())); + } + return parts.join(sep); } @@ -209,7 +209,7 @@ export default function (pi: ExtensionAPI) { // --------------------------------------------------------------------------- // Event handlers // --------------------------------------------------------------------------- - pi.on("session_start", async (_event, _ctx) => { + pi.on("session_start", (_event, _ctx) => { ctx = _ctx; installFooter(_ctx); }); @@ -230,12 +230,7 @@ export default function (pi: ExtensionAPI) { if (tuiRef) tuiRef.requestRender(); }); - // Re-render when account switches (usage:update comes from usage-bars setStatus which - // already triggers a render, but account icon needs a nudge too) - pi.events.on("claude-account:switched", () => { - if (tuiRef) tuiRef.requestRender(); - }); - + // Listen for usage updates — store raw values so we can build bars + dynamic // countdown directly rather than parsing the ANSI status string from usage-bars. pi.events.on("usage:update", (data: any) => { diff --git a/pi/.pi/agent/extensions/footer-display.ts.backup b/pi/.pi/agent/extensions/footer-display.ts.backup deleted file mode 100644 index 5418458..0000000 --- a/pi/.pi/agent/extensions/footer-display.ts.backup +++ /dev/null @@ -1,191 +0,0 @@ -/** - * Footer Display Extension - * - * Replaces the built-in pi footer with a single clean line that assembles - * status from all other extensions: - * - * \uEF85 | S ⣿⣶⣀⣀⣀ 34% 2h 55m | W ⣿⣿⣷⣀⣀ 68% ⟳ Fri 09:00 | C ⣿⣀⣀⣀⣀ 20% | Sonnet 4.6 | rust-analyzer | MCP: 1/2 - * - * Status sources: - * "claude-account" — set by claude-account-switch.ts → just the icon - * "usage-bars" — set by usage-bars extension → S/W bars (pass-through) - * ctx.getContextUsage() → C bar (rendered here) - * ctx.model → model short name - * "lsp" — set by lsp-pi extension → strip "LSP " prefix - * "mcp" — set by pi-mcp-adapter → strip " servers" suffix - */ - -import os from "os"; -import path from "path"; -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { truncateToWidth } from "@mariozechner/pi-tui"; - -// --------------------------------------------------------------------------- -// Braille gradient bar — used here only for the context (C) bar -// --------------------------------------------------------------------------- -const BRAILLE_GRADIENT = "\u28C0\u28C4\u28E4\u28E6\u28F6\u28F7\u28FF"; -const BRAILLE_EMPTY = "\u28C0"; -const BAR_WIDTH = 5; - -function renderBrailleBar(theme: any, value: number): string { - const v = Math.max(0, Math.min(100, Math.round(value))); - const levels = BRAILLE_GRADIENT.length - 1; - const totalSteps = BAR_WIDTH * levels; - const filledSteps = Math.round((v / 100) * totalSteps); - const full = Math.floor(filledSteps / levels); - const partial = filledSteps % levels; - const empty = BAR_WIDTH - full - (partial ? 1 : 0); - const color = v >= 90 ? "error" : v >= 70 ? "warning" : "success"; - const filled = BRAILLE_GRADIENT[BRAILLE_GRADIENT.length - 1]!.repeat(Math.max(0, full)); - const partialChar = partial ? BRAILLE_GRADIENT[partial]! : ""; - const emptyChars = BRAILLE_EMPTY.repeat(Math.max(0, empty)); - return theme.fg(color, filled + partialChar) + theme.fg("dim", emptyChars); -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- -function stripAnsi(text: string): string { - return text.replace(/\x1b\[[0-9;]*m/g, "").replace(/\x1b\][^\x07]*\x07/g, ""); -} - -function getModelShortName(modelId: string): string { - // claude-haiku-4-5 → "Haiku 4.5", claude-sonnet-4-6 → "Sonnet 4.6" - const m = modelId.match(/^claude-([a-z]+)-([\d]+(?:-[\d]+)*)(?:-\d{8})?$/); - if (m) { - const family = m[1]!.charAt(0).toUpperCase() + m[1]!.slice(1); - return `${family} ${m[2]!.replace(/-/g, ".")}`; - } - // claude-3-5-sonnet, claude-3-opus, etc. - const m2 = modelId.match(/^claude-[\d-]+-([a-z]+)/); - if (m2) return m2[1]!.charAt(0).toUpperCase() + m2[1]!.slice(1); - return modelId.replace(/^claude-/, ""); -} - -// Nerd Font codepoints matched to what claude-account-switch.ts emits -const ICON_PERSONAL = "\uEF85"; // U+EF85 — home -const ICON_WORK = "\uDB80\uDCD6"; // U+F00D6 — briefcase (surrogate pair) -const ICON_UNKNOWN = "\uF420"; // U+F420 — claude default - -export default function (pi: ExtensionAPI) { - let ctx: any = null; - let tuiRef: any = null; - let footerDataRef: any = null; - - // --------------------------------------------------------------------------- - // Footer line builder — called on every render - // --------------------------------------------------------------------------- - function buildFooterLine(theme: any): string { - const sep = theme.fg("dim", " · "); - const pipeSep = theme.fg("dim", " | "); - const parts: string[] = []; - - const statuses: ReadonlyMap = - footerDataRef?.getExtensionStatuses?.() ?? new Map(); - - // 1. Current working directory - const home = os.homedir(); - const cwd = process.cwd(); - const dir = cwd.startsWith(home) - ? "~" + path.sep + path.relative(home, cwd) - : cwd; - parts.push(theme.fg("dim", dir)); - - // 2. Account icon - const acctRaw = statuses.get("claude-account"); - if (acctRaw !== undefined) { - const clean = stripAnsi(acctRaw).trim(); - let icon: string; - if (clean.includes("personal")) icon = ICON_PERSONAL; - else if (clean.includes("work")) icon = ICON_WORK; - else icon = ICON_UNKNOWN; - parts.push(theme.fg("dim", icon)); - } - - // 3. S / W usage bars + C bar — joined as one |-separated block - const usageRaw = statuses.get("usage-bars"); - const contextUsage = ctx?.getContextUsage?.(); - { - let block = usageRaw ?? ""; - if (contextUsage && contextUsage.percent !== null) { - const pct = Math.round(contextUsage.percent); - const cBar = - theme.fg("muted", "C ") + - renderBrailleBar(theme, pct) + - " " + - theme.fg("dim", `${pct}%`); - block = block ? block + pipeSep + cBar : cBar; - } - if (block) parts.push(block); - } - - // 4. Model short name - const modelId = ctx?.model?.id; - if (modelId) parts.push(theme.fg("dim", getModelShortName(modelId))); - - // 5. LSP — strip "LSP" prefix and activity dot - const lspRaw = statuses.get("lsp"); - if (lspRaw) { - const clean = stripAnsi(lspRaw).trim().replace(/^LSP\s*[•·]?\s*/i, "").trim(); - if (clean) parts.push(theme.fg("dim", clean)); - } - - // 6. MCP — strip " servers" suffix - const mcpRaw = statuses.get("mcp"); - if (mcpRaw) { - const clean = stripAnsi(mcpRaw).trim().replace(/\s*servers?.*$/, "").trim(); - if (clean) parts.push(theme.fg("dim", clean)); - } - - return parts.join(sep); - } - - // --------------------------------------------------------------------------- - // Footer installation - // --------------------------------------------------------------------------- - function installFooter(_ctx: any) { - if (!_ctx?.hasUI) return; - _ctx.ui.setFooter((_tui: any, theme: any, footerData: any) => { - tuiRef = _tui; - footerDataRef = footerData; - const unsub = footerData.onBranchChange(() => _tui.requestRender()); - return { - dispose: unsub, - invalidate() {}, - render(width: number): string[] { - return [truncateToWidth(buildFooterLine(theme) || "", width)]; - }, - }; - }); - } - - // --------------------------------------------------------------------------- - // Event handlers - // --------------------------------------------------------------------------- - pi.on("session_start", async (_event, _ctx) => { - ctx = _ctx; - installFooter(_ctx); - }); - - pi.on("session_shutdown", (_event, _ctx) => { - if (_ctx?.hasUI) _ctx.ui.setFooter(undefined); - }); - - // Re-render after turns so context usage stays current - pi.on("turn_end", (_event, _ctx) => { - ctx = _ctx; - if (tuiRef) tuiRef.requestRender(); - }); - - // Re-render when model changes (updates model name in footer) - pi.on("model_select", (_event, _ctx) => { - ctx = _ctx; - if (tuiRef) tuiRef.requestRender(); - }); - - // Re-render when account switches (usage:update comes from usage-bars setStatus which - // already triggers a render, but account icon needs a nudge too) - pi.events.on("claude-account:switched", () => { - if (tuiRef) tuiRef.requestRender(); - }); -} diff --git a/pi/.pi/agent/extensions/local-explorer.ts b/pi/.pi/agent/extensions/local-explorer.ts deleted file mode 100644 index bcade63..0000000 --- a/pi/.pi/agent/extensions/local-explorer.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Local Explorer Extension - * - * Previously auto-injected a scout hint into every cloud-model session. - * Now a no-op — scout delegation is opt-in via the `local-scout` skill. - * Invoke with /skill:local-scout or by mentioning "use scout" in a prompt. - * - * Placed in: ~/.pi/agent/extensions/local-explorer.ts - */ - -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; - -export default function (_pi: ExtensionAPI) { - // No automatic injection — scout is opt-in via the local-scout skill. -} diff --git a/pi/.pi/agent/extensions/pi-ask-mcp/README.md b/pi/.pi/agent/extensions/pi-ask-mcp/README.md new file mode 100644 index 0000000..3732e7d --- /dev/null +++ b/pi/.pi/agent/extensions/pi-ask-mcp/README.md @@ -0,0 +1,56 @@ +# pi-ask-mcp + +A minimal MCP stdio server that gives Claude **one** tool — `ask` — which routes +structured questions back to pi's native ask UI instead of using Claude's +built-in `AskUserQuestion`. + +This is **not** a regular pi extension. It is a subprocess of `claude`, which is +itself a subprocess of the `chat-claude` extension. The pi-side counterpart is +[`shared/pi-ask-bridge.ts`](../../shared/pi-ask-bridge.ts), which: + +1. Opens a Unix-domain socket per chat session. +2. Generates an `--mcp-config` JSON pointing here, with `PI_ASK_SOCKET=`. +3. Translates `ask` requests off the socket into + `askSingleQuestionWithInlineNote` / `askQuestionsWithTabs` calls and writes + the result back. + +## Architecture + +``` +pi +└── chat-claude + ├── pi-ask-bridge (UDS server, owns ui.custom) + └── claude -p ... --mcp-config --disallowed-tools AskUserQuestion + └── pi-ask-mcp/server.js (this file) + ↳ on tools/call ask → connect $PI_ASK_SOCKET → ask → reply +``` + +## Why a hand-written MCP server + +No `@modelcontextprotocol/sdk` dependency, no transpile step, no +`node_modules`. The MCP stdio protocol is small enough (~6 method handlers) +that writing it directly keeps the file self-contained and trivially +portable. Claude CLI spawns it via `node server.js`. + +## Wire format + +Stdio (with Claude): JSON-RPC 2.0 over newline-delimited JSON. + +Socket (with pi-ask-bridge): NDJSON, one request → one response, then close. + +```jsonc +// → pi +{ "id": "uuid", "type": "ask", + "questions": [ + { "id": "auth", "question": "Auth method?", + "options": [{"label": "OAuth"}, {"label": "API key"}], + "multi": false, "recommended": 0 } + ] } + +// ← pi (success) +{ "id": "uuid", "type": "result", + "results": [{ "id": "auth", "selectedOptions": ["OAuth"] }] } + +// ← pi (cancel / error) +{ "id": "uuid", "type": "error", "message": "cancelled" } +``` diff --git a/pi/.pi/agent/extensions/pi-ask-mcp/package.json b/pi/.pi/agent/extensions/pi-ask-mcp/package.json new file mode 100644 index 0000000..59c81af --- /dev/null +++ b/pi/.pi/agent/extensions/pi-ask-mcp/package.json @@ -0,0 +1,7 @@ +{ + "name": "pi-ask-mcp", + "private": true, + "type": "module", + "main": "server.js", + "description": "Minimal MCP stdio server bridging Claude → pi-ask-bridge." +} diff --git a/pi/.pi/agent/extensions/pi-ask-mcp/server.js b/pi/.pi/agent/extensions/pi-ask-mcp/server.js new file mode 100755 index 0000000..4079195 --- /dev/null +++ b/pi/.pi/agent/extensions/pi-ask-mcp/server.js @@ -0,0 +1,195 @@ +#!/usr/bin/env node +// pi-ask-mcp/server.js +// +// Minimal MCP stdio server that exposes ONE tool: `ask`. +// Bridges Claude → pi via a Unix-domain socket: when Claude calls the tool, +// this server forwards the question(s) to pi over $PI_ASK_SOCKET, awaits +// the user's answer, and returns it as the tool result. +// +// Wire format with Claude (stdin/stdout): JSON-RPC 2.0 over NDJSON. +// Wire format with pi (PI_ASK_SOCKET): NDJSON request/response, see +// ../../shared/pi-ask-bridge.ts. +// +// This file is INTENTIONALLY plain JavaScript (no transpile step, no +// node_modules) — Claude CLI spawns it via `node `. Keep it small, +// dependency-free, and self-contained. + +import { connect } from "node:net"; +import { randomUUID } from "node:crypto"; +import { createInterface } from "node:readline"; + +// ── Configuration ────────────────────────────────────────────────────────── +const SOCKET = process.env.PI_ASK_SOCKET; +if (!SOCKET) { + process.stderr.write("[pi-ask-mcp] PI_ASK_SOCKET env var is required\n"); + process.exit(2); +} + +const SERVER_INFO = { name: "pi", version: "0.1.0" }; +const PROTOCOL_VERSION = "2024-11-05"; +const SOCKET_TIMEOUT_MS = 15 * 60 * 1000; // matches runClaude's default + +// ── Tool schema (kept in sync with pi-ask-tool/index.ts AskParamsSchema) ── +const ASK_INPUT_SCHEMA = { + type: "object", + required: ["questions"], + properties: { + questions: { + type: "array", + minItems: 1, + description: "One or more questions to ask the user.", + items: { + type: "object", + required: ["id", "question", "options"], + properties: { + id: { type: "string", description: "Stable id (e.g. 'auth', 'cache')." }, + question: { type: "string", description: "Question text shown to the user." }, + options: { + type: "array", + minItems: 1, + description: "2-5 concise options. Do NOT include 'Other' (UI adds it).", + items: { + type: "object", + required: ["label"], + properties: { + label: { type: "string", description: "Option display label." }, + }, + }, + }, + multi: { type: "boolean", description: "Allow multi-select. Defaults to false." }, + recommended: { type: "number", description: "0-indexed recommended option (default highlight)." }, + }, + }, + }, + }, +}; + +const ASK_DESCRIPTION = [ + "Ask the user one or more structured questions through pi's native TUI.", + "Use this whenever a choice materially affects the outcome — instead of", + "guessing or the built-in AskUserQuestion. Provide 2-5 concise options.", + "Set multi=true when multiple answers are valid. Do NOT include an 'Other'", + "option (UI adds it automatically). The result is a JSON array of", + "{id, selectedOptions[], customInput?} per question — empty selectedOptions", + "means the user cancelled.", +].join(" "); + +// ── stdio framing: NDJSON ────────────────────────────────────────────────── +const rl = createInterface({ input: process.stdin }); +const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n"); +const log = (msg) => process.stderr.write(`[pi-ask-mcp] ${msg}\n`); + +// ── socket round-trip to pi-ask-bridge ───────────────────────────────────── +function askPi(args) { + return new Promise((resolve, reject) => { + const sock = connect(SOCKET); + const id = randomUUID(); + let buf = ""; + let settled = false; + const finish = (fn, val) => { if (settled) return; settled = true; clearTimeout(t); fn(val); try { sock.end(); } catch {} }; + const t = setTimeout( + () => finish(reject, new Error(`pi-ask bridge timeout after ${SOCKET_TIMEOUT_MS / 1000}s`)), + SOCKET_TIMEOUT_MS, + ); + + sock.on("connect", () => sock.write(JSON.stringify({ id, type: "ask", ...args }) + "\n")); + sock.on("data", (d) => { + buf += d.toString(); + const nl = buf.indexOf("\n"); + if (nl < 0) return; + try { finish(resolve, JSON.parse(buf.slice(0, nl))); } + catch (err) { finish(reject, err); } + }); + sock.on("error", (err) => finish(reject, err)); + sock.on("close", () => { + if (!settled) finish(reject, new Error("pi-ask bridge closed connection without reply")); + }); + }); +} + +// ── JSON-RPC method handlers ─────────────────────────────────────────────── +async function handleRequest(req) { + const { id, method, params } = req; + try { + switch (method) { + case "initialize": + return ok(id, { + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: SERVER_INFO, + }); + case "tools/list": + return ok(id, { + tools: [{ name: "ask", description: ASK_DESCRIPTION, inputSchema: ASK_INPUT_SCHEMA }], + }); + case "tools/call": { + const name = params?.name; + const args = params?.arguments ?? {}; + if (name !== "ask") return err(id, -32602, `unknown tool: ${name}`); + const reply = await askPi(args); + if (reply.type === "error") { + return ok(id, { + isError: true, + content: [{ type: "text", text: `(user did not answer: ${reply.message})` }], + }); + } + return ok(id, { + content: [{ type: "text", text: JSON.stringify(reply.results, null, 2) }], + }); + } + case "ping": return ok(id, {}); + case "resources/list": return ok(id, { resources: [] }); + case "prompts/list": return ok(id, { prompts: [] }); + default: return err(id, -32601, `method not found: ${method}`); + } + } catch (e) { + return err(id, -32603, e instanceof Error ? e.message : String(e)); + } +} + +const ok = (id, result) => ({ jsonrpc: "2.0", id, result }); +const err = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, message } }); + +// ── main loop ────────────────────────────────────────────────────────────── +// +// Track in-flight handlers so we don't exit before they finish. Without this, +// `node server.js << { + if (!line.trim()) return; + let msg; + try { msg = JSON.parse(line); } catch { return; } + if (Array.isArray(msg)) { + for (const m of msg) void handleOne(m); + } else { + void handleOne(msg); + } +}); + +rl.on("close", () => { stdinClosed = true; drainAndExit(0); }); +process.on("SIGTERM", () => process.exit(0)); +process.on("SIGINT", () => process.exit(0)); +log("ready, socket=" + SOCKET); diff --git a/pi/.pi/agent/extensions/pi-ask-tool/README.md b/pi/.pi/agent/extensions/pi-ask-tool/README.md new file mode 100644 index 0000000..c0a565a --- /dev/null +++ b/pi/.pi/agent/extensions/pi-ask-tool/README.md @@ -0,0 +1,23 @@ +# Pi Ask Tool Extension + +This extension bridges Claude Code's ask functionality into pi's TUI, allowing users to ask questions and receive answers directly in the TUI interface. + +## Features + +- Seamless integration with pi's TUI +- Support for all Claude agents (plan_review, code_review, debug, oracle) +- Multi-turn conversations with session management +- Context-aware responses based on codebase exploration + +## Usage + +1. Run the CLI agent: `pi-ask-tool` +2. Type your question in the TUI +3. Receive answers directly in the TUI interface +4. Continue the conversation or start new ones + +## Configuration + +Default agent: `code_review` +Default model: `sonnet` +Session persistence: Enabled \ No newline at end of file diff --git a/pi/.pi/agent/extensions/pi-ask-tool/cli.ts b/pi/.pi/agent/extensions/pi-ask-tool/cli.ts new file mode 100644 index 0000000..daeb646 --- /dev/null +++ b/pi/.pi/agent/extensions/pi-ask-tool/cli.ts @@ -0,0 +1,37 @@ +// Pi Ask Tool CLI Agent + +import { ask_claude } from "../../@piplugin/ask-claude" +import { sessionId } from '../shared'; + +export async function start() { + console.log('Pi Ask Tool initialized'); + + while (true) { + // Get user input from TUI (simplified for example) + const userInput = await getTUIInput('Ask a question:'); + + // Handle multi-turn sessions via sessionId + const response = await ask_claude({ + prompt: userInput, + agent: 'code_review', // Default agent + session_id: sessionId + }); + + // Display answer in TUI + await showTUIResult(response); + + // Update session context if needed + sessionId = response.session_id || sessionId; + } +} + +// Mock TUI handlers - implement actual TUI integration +async function getTUIInput(question: string) { + // Replace with real TUI input method + const input = process.stdin.read().toString(); + return input; +} + +async function showTUIResult(result: any) { + console.log('Answer:', result.summary || result); +} \ No newline at end of file diff --git a/pi/.pi/agent/extensions/qwen-provider/index.ts b/pi/.pi/agent/extensions/qwen-provider/index.ts deleted file mode 100644 index c228d91..0000000 --- a/pi/.pi/agent/extensions/qwen-provider/index.ts +++ /dev/null @@ -1,378 +0,0 @@ -/** - * Qwen Provider Extension - * - * Registers Qwen 3.5 models via the qwen.ai OAuth flow (chat.qwen.ai). - * Based on the upstream custom-provider-qwen-cli example. - * - * Models: - * - qwen3.5-plus (Qwen3.5 best — rivals Qwen3-Max, 1M ctx, cheaper) - * - qwen3.5-flash (Qwen3.5 fast & cheap, 1M ctx) - * - qwen3-max (Qwen3 flagship, strongest reasoning, 262K ctx) - * - qwen-plus (Qwen3 balanced, 1M ctx) - * - qwen-flash (Qwen3 fast, 1M ctx) - * - * Usage: - * /login qwen-cli (browser OAuth) - * or set QWEN_CLI_API_KEY=... - */ - -import type { OAuthCredentials, OAuthLoginCallbacks } from "@mariozechner/pi-ai"; -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; - -// ============================================================================= -// Constants -// ============================================================================= - -const QWEN_DEVICE_CODE_ENDPOINT = "https://chat.qwen.ai/api/v1/oauth2/device/code"; -const QWEN_TOKEN_ENDPOINT = "https://chat.qwen.ai/api/v1/oauth2/token"; -const QWEN_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56"; -const QWEN_SCOPE = "openid profile email model.completion"; -const QWEN_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; -const QWEN_DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"; -const QWEN_POLL_INTERVAL_MS = 2000; - -// ============================================================================= -// PKCE Helpers -// ============================================================================= - -async function generatePKCE(): Promise<{ verifier: string; challenge: string }> { - const array = new Uint8Array(32); - crypto.getRandomValues(array); - const verifier = btoa(String.fromCharCode(...array)) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); - - const encoder = new TextEncoder(); - const data = encoder.encode(verifier); - const hash = await crypto.subtle.digest("SHA-256", data); - const challenge = btoa(String.fromCharCode(...new Uint8Array(hash))) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); - - return { verifier, challenge }; -} - -// ============================================================================= -// OAuth Implementation -// ============================================================================= - -interface DeviceCodeResponse { - device_code: string; - user_code: string; - verification_uri: string; - verification_uri_complete?: string; - expires_in: number; - interval?: number; -} - -interface TokenResponse { - access_token: string; - refresh_token?: string; - token_type: string; - expires_in: number; - resource_url?: string; -} - -function abortableSleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(new Error("Login cancelled")); - return; - } - const timeout = setTimeout(resolve, ms); - signal?.addEventListener( - "abort", - () => { - clearTimeout(timeout); - reject(new Error("Login cancelled")); - }, - { once: true }, - ); - }); -} - -async function startDeviceFlow(): Promise<{ deviceCode: DeviceCodeResponse; verifier: string }> { - const { verifier, challenge } = await generatePKCE(); - - const body = new URLSearchParams({ - client_id: QWEN_CLIENT_ID, - scope: QWEN_SCOPE, - code_challenge: challenge, - code_challenge_method: "S256", - }); - - const headers: Record = { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }; - const requestId = globalThis.crypto?.randomUUID?.(); - if (requestId) headers["x-request-id"] = requestId; - - const response = await fetch(QWEN_DEVICE_CODE_ENDPOINT, { - method: "POST", - headers, - body: body.toString(), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Device code request failed: ${response.status} ${text}`); - } - - const data = (await response.json()) as DeviceCodeResponse; - - if (!data.device_code || !data.user_code || !data.verification_uri) { - throw new Error("Invalid device code response: missing required fields"); - } - - return { deviceCode: data, verifier }; -} - -async function pollForToken( - deviceCode: string, - verifier: string, - intervalSeconds: number | undefined, - expiresIn: number, - signal?: AbortSignal, -): Promise { - const deadline = Date.now() + expiresIn * 1000; - const resolvedIntervalSeconds = - typeof intervalSeconds === "number" && Number.isFinite(intervalSeconds) && intervalSeconds > 0 - ? intervalSeconds - : QWEN_POLL_INTERVAL_MS / 1000; - let intervalMs = Math.max(1000, Math.floor(resolvedIntervalSeconds * 1000)); - - const handleTokenError = async (error: string, description?: string): Promise => { - switch (error) { - case "authorization_pending": - await abortableSleep(intervalMs, signal); - return true; - case "slow_down": - intervalMs = Math.min(intervalMs + 5000, 10000); - await abortableSleep(intervalMs, signal); - return true; - case "expired_token": - throw new Error("Device code expired. Please restart authentication."); - case "access_denied": - throw new Error("Authorization denied by user."); - default: - throw new Error(`Token request failed: ${error} - ${description || ""}`); - } - }; - - while (Date.now() < deadline) { - if (signal?.aborted) throw new Error("Login cancelled"); - - const body = new URLSearchParams({ - grant_type: QWEN_GRANT_TYPE, - client_id: QWEN_CLIENT_ID, - device_code: deviceCode, - code_verifier: verifier, - }); - - const response = await fetch(QWEN_TOKEN_ENDPOINT, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: body.toString(), - }); - - const responseText = await response.text(); - let data: (TokenResponse & { error?: string; error_description?: string }) | null = null; - if (responseText) { - try { - data = JSON.parse(responseText) as TokenResponse & { error?: string; error_description?: string }; - } catch { - data = null; - } - } - - const error = data?.error; - const errorDescription = data?.error_description; - - if (!response.ok) { - if (error && (await handleTokenError(error, errorDescription))) continue; - throw new Error(`Token request failed: ${response.status} ${response.statusText}. Response: ${responseText}`); - } - - if (data?.access_token) return data; - - if (error && (await handleTokenError(error, errorDescription))) continue; - - throw new Error("Token request failed: missing access token in response"); - } - - throw new Error("Authentication timed out. Please try again."); -} - -async function loginQwen(callbacks: OAuthLoginCallbacks): Promise { - const { deviceCode, verifier } = await startDeviceFlow(); - const authUrl = deviceCode.verification_uri_complete || deviceCode.verification_uri; - const instructions = deviceCode.verification_uri_complete - ? undefined - : `Enter code: ${deviceCode.user_code}`; - callbacks.onAuth({ url: authUrl, instructions }); - - const tokenResponse = await pollForToken( - deviceCode.device_code, - verifier, - deviceCode.interval, - deviceCode.expires_in, - callbacks.signal, - ); - - const expiresAt = Date.now() + tokenResponse.expires_in * 1000 - 5 * 60 * 1000; - - return { - refresh: tokenResponse.refresh_token || "", - access: tokenResponse.access_token, - expires: expiresAt, - enterpriseUrl: tokenResponse.resource_url, - }; -} - -async function refreshQwenToken(credentials: OAuthCredentials): Promise { - const body = new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: credentials.refresh, - client_id: QWEN_CLIENT_ID, - }); - - const response = await fetch(QWEN_TOKEN_ENDPOINT, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: body.toString(), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Token refresh failed: ${response.status} ${text}`); - } - - const data = (await response.json()) as TokenResponse; - if (!data.access_token) throw new Error("Token refresh failed: no access token in response"); - - const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000; - - return { - refresh: data.refresh_token || credentials.refresh, - access: data.access_token, - expires: expiresAt, - enterpriseUrl: data.resource_url ?? credentials.enterpriseUrl, - }; -} - -function getQwenBaseUrl(resourceUrl?: string): string { - if (!resourceUrl) return QWEN_DEFAULT_BASE_URL; - let url = resourceUrl.startsWith("http") ? resourceUrl : `https://${resourceUrl}`; - if (!url.endsWith("/v1")) url = `${url}/v1`; - return url; -} - -// ============================================================================= -// Extension Entry Point -// ============================================================================= - -export default function (pi: ExtensionAPI) { - pi.registerProvider("qwen-cli", { - baseUrl: QWEN_DEFAULT_BASE_URL, - apiKey: "QWEN_CLI_API_KEY", - api: "openai-completions", - - models: [ - { - id: "qwen3.5-plus", - name: "Qwen 3.5 Plus (Best — rivals Qwen3-Max)", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1000000, - maxTokens: 65536, - compat: { - supportsDeveloperRole: false, - supportsReasoningEffort: false, - maxTokensField: "max_tokens", - thinkingFormat: "qwen", - }, - }, - { - id: "qwen3.5-flash", - name: "Qwen 3.5 Flash (Fast & Cheap)", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1000000, - maxTokens: 65536, - compat: { - supportsDeveloperRole: false, - supportsReasoningEffort: false, - maxTokensField: "max_tokens", - thinkingFormat: "qwen", - }, - }, - { - id: "qwen3-max", - name: "Qwen 3 Max (Flagship, strongest reasoning)", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 262144, - maxTokens: 32768, - compat: { - supportsDeveloperRole: false, - supportsReasoningEffort: false, - maxTokensField: "max_tokens", - thinkingFormat: "qwen", - }, - }, - { - id: "qwen-plus", - name: "Qwen 3 Plus (Balanced)", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1000000, - maxTokens: 32768, - compat: { - supportsDeveloperRole: false, - supportsReasoningEffort: false, - maxTokensField: "max_tokens", - thinkingFormat: "qwen", - }, - }, - { - id: "qwen-flash", - name: "Qwen 3 Flash (Fast)", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1000000, - maxTokens: 32768, - compat: { - supportsDeveloperRole: false, - supportsReasoningEffort: false, - maxTokensField: "max_tokens", - thinkingFormat: "qwen", - }, - }, - ], - - oauth: { - name: "Qwen CLI", - login: loginQwen, - refreshToken: refreshQwenToken, - getApiKey: (cred) => cred.access, - modifyModels: (models, cred) => { - const baseUrl = getQwenBaseUrl(cred.enterpriseUrl as string | undefined); - return models.map((m) => (m.provider === "qwen-cli" ? { ...m, baseUrl } : m)); - }, - }, - }); -} diff --git a/pi/.pi/agent/extensions/usage-bars/core.ts b/pi/.pi/agent/extensions/usage-bars/core.ts index d1ffd9a..a3bcfd8 100644 --- a/pi/.pi/agent/extensions/usage-bars/core.ts +++ b/pi/.pi/agent/extensions/usage-bars/core.ts @@ -55,6 +55,8 @@ export interface UsageData { /** Unix ms timestamp of when the session window resets (from the raw API response). */ sessionResetsAt?: number; weeklyResetsIn?: string; + /** Unix ms timestamp of when the weekly window resets. */ + weeklyResetsAt?: number; extraSpend?: number; extraLimit?: number; error?: string; @@ -321,13 +323,34 @@ export function formatResetsAt(isoDate: string, nowMs = Date.now()): string { return formatDuration(diffSeconds); } +const CLAUDE_CREDENTIALS_FILE = path.join(os.homedir(), ".claude", ".credentials.json"); + export function readAuth(authFile = DEFAULT_AUTH_FILE): AuthData | null { + let result: AuthData | null = null; + + // Read pi auth.json for non-Claude providers try { const parsed = JSON.parse(fs.readFileSync(authFile, "utf-8")); - return asObject(parsed) as AuthData; + result = asObject(parsed) as AuthData; } catch { - return null; + result = {} as AuthData; } + + // Read Claude credentials from ~/.claude/.credentials.json + try { + const claudeRaw = fs.readFileSync(CLAUDE_CREDENTIALS_FILE, "utf-8"); + const claudeCreds = JSON.parse(claudeRaw); + const oauth = claudeCreds?.claudeAiOauth; + if (oauth?.accessToken) { + result!.anthropic = { + access: oauth.accessToken, + refresh: oauth.refreshToken, + expires: typeof oauth.expiresAt === "number" ? oauth.expiresAt : undefined, + }; + } + } catch {} + + return result; } export function writeAuth(auth: AuthData, authFile = DEFAULT_AUTH_FILE): boolean { @@ -647,6 +670,9 @@ export async function fetchClaudeUsage(token: string, config: RequestConfig = {} const sessionResetsAt = data?.five_hour?.resets_at ? new Date(data.five_hour.resets_at).getTime() : undefined; + const weeklyResetsAt = data?.seven_day?.resets_at + ? new Date(data.seven_day.resets_at).getTime() + : undefined; const usage: UsageData = { session: readPercentCandidate(data?.five_hour?.utilization) ?? 0, @@ -654,6 +680,7 @@ export async function fetchClaudeUsage(token: string, config: RequestConfig = {} sessionResetsIn: data?.five_hour?.resets_at ? formatResetsAt(data.five_hour.resets_at) : undefined, sessionResetsAt: Number.isFinite(sessionResetsAt) ? sessionResetsAt : undefined, weeklyResetsIn: data?.seven_day?.resets_at ? formatResetsAt(data.seven_day.resets_at) : undefined, + weeklyResetsAt: Number.isFinite(weeklyResetsAt) ? weeklyResetsAt : undefined, }; if (data?.extra_usage?.is_enabled) { diff --git a/pi/.pi/agent/extensions/usage-bars/index.ts b/pi/.pi/agent/extensions/usage-bars/index.ts index 0c758ee..914f4c2 100644 --- a/pi/.pi/agent/extensions/usage-bars/index.ts +++ b/pi/.pi/agent/extensions/usage-bars/index.ts @@ -306,14 +306,27 @@ export default function (pi: ExtensionAPI) { const active = state.activeProvider; const data = active ? state[active] : null; - // Always emit event for other extensions (e.g. footer-display) - if (data && !data.error) { + // Always emit Claude usage for other extensions (e.g. footer-display) + // so S/W bars are visible regardless of active model. + const claudeData = state.claude; + if (claudeData && !claudeData.error) { + pi.events.emit("usage:update", { + session: claudeData.session, + weekly: claudeData.weekly, + sessionResetsIn: claudeData.sessionResetsIn, + sessionResetsAt: claudeData.sessionResetsAt, + weeklyResetsIn: claudeData.weeklyResetsIn, + weeklyResetsAt: claudeData.weeklyResetsAt, + }); + } else if (data && !data.error) { + // Fallback to active provider data if Claude data unavailable pi.events.emit("usage:update", { session: data.session, weekly: data.weekly, sessionResetsIn: data.sessionResetsIn, sessionResetsAt: data.sessionResetsAt, weeklyResetsIn: data.weeklyResetsIn, + weeklyResetsAt: data.weeklyResetsAt, }); } @@ -372,6 +385,48 @@ export default function (pi: ExtensionAPI) { const auth = readAuth(); const active = state.activeProvider; + // Always try to fetch Claude data so S/W bars show regardless of active provider + if (auth && canShowForProvider("claude", auth, endpoints)) { + try { + const cache = readUsageCache(); + const now = Date.now(); + const cacheTtl = options.cacheTtl ?? CACHE_TTL_MS; + const claudeBlockedUntil = cache?.rateLimitedUntil?.claude ?? 0; + if (now < claudeBlockedUntil) { + if (cache?.data?.claude) state.claude = cache.data.claude; + } else { + const claudeCacheFresh = cache && now - cache.timestamp < cacheTtl && cache.data?.claude; + if (claudeCacheFresh && !options.forceFresh) { + state.claude = cache.data.claude; + } else { + const claudeAccess = auth.anthropic?.access; + if (claudeAccess) { + const claudeResult = await fetchClaudeUsage(claudeAccess); + state.claude = claudeResult; + if (!claudeResult.error) { + const nextCache: import("./core").UsageCache = { + timestamp: now, + data: { ...(cache?.data ?? {}), claude: claudeResult }, + rateLimitedUntil: { ...(cache?.rateLimitedUntil ?? {}) }, + }; + delete nextCache.rateLimitedUntil!.claude; + writeUsageCache(nextCache); + } else if (claudeResult.error === "HTTP 429") { + // Record backoff even when Claude is not the active provider — + // without this the prefetch would hammer the API on every poll. + const nextCache: import("./core").UsageCache = { + timestamp: cache?.timestamp ?? now, + data: { ...(cache?.data ?? {}) }, + rateLimitedUntil: { ...(cache?.rateLimitedUntil ?? {}), claude: now + RATE_LIMITED_BACKOFF_MS }, + }; + writeUsageCache(nextCache); + } + } + } + } + } catch {} + } + if (!canShowForProvider(active, auth, endpoints) || !auth || !active) { state.lastPoll = Date.now(); updateStatus(); return; } @@ -478,7 +533,9 @@ export default function (pi: ExtensionAPI) { await Promise.race([runPollInner(options), timeout]); } - const POLL_TIMEOUT_MS = 30_000; + // Must be less than the 25 000 ms timeout inside runPoll so the guard fires + // before runPoll's finally-block clears pollInFlight. + const POLL_TIMEOUT_MS = 20_000; async function poll(options: PollOptions = {}) { // If a previous poll has been running longer than POLL_TIMEOUT_MS, abandon it @@ -557,15 +614,6 @@ export default function (pi: ExtensionAPI) { await poll({ cacheTtl: ACTIVE_CACHE_TTL_MS }); }); - pi.events.on("claude-account:switched", () => { - const cache = readUsageCache(); - if (cache?.data?.claude) { - const nextCache: import("./core").UsageCache = { ...cache, data: { ...cache.data } }; - delete nextCache.data.claude; - writeUsageCache(nextCache); - } - void poll({ forceFresh: true }); - }); // Listen for OpenCode Go spend events from other extensions pi.events.on("opencode-go:spend", async (amount: number) => { diff --git a/pi/.pi/agent/extensions/wezterm-theme-sync/index.ts b/pi/.pi/agent/extensions/wezterm-theme-sync/index.ts index c731f02..4a07eb3 100644 --- a/pi/.pi/agent/extensions/wezterm-theme-sync/index.ts +++ b/pi/.pi/agent/extensions/wezterm-theme-sync/index.ts @@ -31,7 +31,7 @@ import { execSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, unlinkSync, watch, writeFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; @@ -101,6 +101,7 @@ local mock_wezterm = { log_warn = function() end, log_error = function() end, on = function() end, + add_to_config_reload_watch_list = function() end, action = setmetatable({}, { __index = function(_, k) return function(...) return { action = k, args = {...} } end @@ -397,48 +398,64 @@ function cleanupOldThemes(themesDir: string, keepFile: string): void { } } -export default function (pi: ExtensionAPI) { - pi.on("session_start", async (_event, ctx) => { - const configDir = findConfigDir(); - if (!configDir) { - return; - } +function syncTheme(ctx: any) { + const configDir = findConfigDir(); + if (!configDir) return; - const lua = findLua(); - if (!lua) { - return; - } + const lua = findLua(); + if (!lua) return; - const colors = getWeztermColors(configDir, lua); - if (!colors) { - return; - } + const colors = getWeztermColors(configDir, lua); + if (!colors) return; - const themesDir = join(homedir(), ".pi", "agent", "themes"); - if (!existsSync(themesDir)) { - mkdirSync(themesDir, { recursive: true }); - } + const themesDir = join(homedir(), ".pi", "agent", "themes"); + if (!existsSync(themesDir)) { + mkdirSync(themesDir, { recursive: true }); + } - const hash = computeThemeHash(colors); - const themeName = `wezterm-sync-${hash}`; - const themeFile = `${themeName}.json`; - const themePath = join(themesDir, themeFile); + const hash = computeThemeHash(colors); + const themeName = `wezterm-sync-${hash}`; + const themeFile = `${themeName}.json`; + const themePath = join(themesDir, themeFile); - // Skip if already on the correct synced theme (avoids repaint) - if (ctx.ui.theme.name === themeName) { - return; - } + // Skip if already on the correct synced theme (avoids repaint) + if (ctx.ui.theme.name === themeName) { + return; + } - const themeJson = generatePiTheme(colors, themeName); - writeFileSync(themePath, JSON.stringify(themeJson, null, 2)); + const themeJson = generatePiTheme(colors, themeName); + writeFileSync(themePath, JSON.stringify(themeJson, null, 2)); - // Remove old generated themes - cleanupOldThemes(themesDir, themeFile); + // Remove old generated themes + cleanupOldThemes(themesDir, themeFile); - // Set by name so pi loads from the file we just wrote - const result = ctx.ui.setTheme(themeName); - if (!result.success) { - ctx.ui.notify(`WezTerm theme sync failed: ${result.error}`, "error"); - } - }); + // Set by name so pi loads from the file we just wrote + const result = ctx.ui.setTheme(themeName); + if (!result.success) { + ctx.ui.notify(`WezTerm theme sync failed: ${result.error}`, "error"); + } +} + +export default function (pi: ExtensionAPI) { + let currentCtx: any = null; + + pi.on("session_start", async (_event, ctx) => { + currentCtx = ctx; + syncTheme(ctx); + }); + + // Watch theme-state file for dark/light toggle changes + const stateFile = join(homedir(), ".config", "theme-state"); + try { + watch(stateFile, { persistent: false }, (_event) => { + // Debounce: wait a tick for the file write to complete + setTimeout(() => { + if (currentCtx) { + syncTheme(currentCtx); + } + }, 100); + }); + } catch { + // File may not exist yet — non-fatal + } } diff --git a/pi/.pi/agent/models.json b/pi/.pi/agent/models.json index dd84b8d..7f7242e 100644 --- a/pi/.pi/agent/models.json +++ b/pi/.pi/agent/models.json @@ -1,5 +1,25 @@ { "providers": { + "llama-serve": { + "baseUrl": "http://localhost:8080/v1", + "api": "openai-completions", + "apiKey": "dummy", + "compat": { + "supportsDeveloperRole": false, + "supportsReasoningEffort": false + }, + "models": [ + { + "id": "Qwen/Qwen3-9B", + "name": "Qwen3.5-9B (Unsloth)", + "reasoning": true, + "input": ["text"], + "contextWindow": 131072, + "maxTokens": 8192, + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } + } + ] + }, "opencode-go": { "baseUrl": "https://opencode.ai/zen/go/v1", "api": "openai-completions", diff --git a/pi/.pi/agent/pi-crash.log b/pi/.pi/agent/pi-crash.log new file mode 100644 index 0000000..b47922c --- /dev/null +++ b/pi/.pi/agent/pi-crash.log @@ -0,0 +1,755 @@ +Crash at 2026-04-24T12:06:17.955Z +Terminal width: 114 +Line 451 visible width: 121 + +=== All rendered lines === +[0] (w=0) ]8;; +[1] (w=114) pi v0.67.68 ]8;; +[2] (w=114) escape interrupt · ctrl+c/ctrl+d clear/exit · / commands · ! bash · ctrl+o more ]8;; +[3] (w=114) Press ctrl+o to show full startup help and loaded resources. ]8;; +[4] (w=114) ]8;; +[5] (w=114) Pi can explain its own features and look up its docs. Ask it how to use or extend Pi. ]8;; +[6] (w=0) ]8;; +[7] (w=114) [Skills] ]8;; +[8] (w=114)  add-agent, ask-claude, homeassistant-ev, implementor, local-scout, opty, qmd, subagent-implement, subagent-plan,]8;; +[9] (w=114) subagent-review ]8;; +[10] (w=0) ]8;; +[11] (w=114) [Prompts] ]8;; +[12] (w=114)  /implement, /implement-critical, /plan, /review ]8;; +[13] (w=0) ]8;; +[14] (w=114) [Extensions] ]8;; +[15] (w=114)  @aliou/pi-guardrails:src, @benvargas/pi-exa-mcp, ask-claude.ts, chat-claude.ts, footer-display.ts, ]8;; +[16] (w=114) git-checkout-guard.ts, new-with-context.ts, pi, pi-ask-tool/index.ts, pi-claude-bridge, pi-subagents, ]8;; +[17] (w=114) pi-subagents:notify.ts, postpone.ts, usage-bars/index.ts, wezterm-theme-sync/index.ts, worktree.ts ]8;; +[18] (w=0) ]8;; +[19] (w=114) [Themes] ]8;; +[20] (w=114)  wezterm-sync-9a35138e ]8;; +[21] (w=0) ]8;; +[22] (w=0) ]8;; +[23] (w=114) ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────]8;; +[24] (w=114) Update Available ]8;; +[25] (w=114) New version 0.70.0 is available. Run: npm install -g @mariozechner/pi-coding-agent ]8;; +[26] (w=114) Changelog: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md ]8;; +[27] (w=114) ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────]8;; +[28] (w=0) ]8;; +[29] (w=114) ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────]8;; +[30] (w=114) Package Updates Available ]8;; +[31] (w=114) Package updates are available. Run pi update ]8;; +[32] (w=114) Packages: ]8;; +[33] (w=114) - pi-subagents ]8;; +[34] (w=114) - @aliou/pi-guardrails ]8;; +[35] (w=114) - pi-claude-bridge ]8;; +[36] (w=114) ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────]8;; +[37] (w=0) ]8;; +[38] (w=114) Entered chat mode: Claude Opus · resume c9492aa2 ]8;; +[39] (w=0) ]8;; +[40] (w=114) ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮]8;; +[41] (w=114) │ ▶ you │]8;; +[42] (w=114) │ │]8;; +[43] (w=114) │ @agent/extensions/chat-claude.ts truncate all read outputs to max 40 lines. Last line, if truncated, should │]8;; +[44] (w=114) │ be something like '+N lines more lines' or better wording. Last line should be centered │]8;; +[45] (w=114) │ │]8;; +[46] (w=114) │ ◆ Claude Opus session:c9492aa2 │]8;; +[47] (w=114) │ │]8;; +[48] (w=114) │  $ find /home/jonas/dotfiles/pi/.pi/agent -type d  │]8;; +[49] (w=114) │   │]8;; +[50] (w=114) │  Output too large (189KB). Full output saved to:  │]8;; +[51] (w=114) │  /home/jonas/.claude/projects/-home-jonas-dotfiles-pi--pi/c9492aa2-34ca-4d36-a541-83964a2ce1d8/tool-results  │]8;; +[52] (w=114) │  /bh1bg7uzg.txt  │]8;; +[53] (w=114) │   │]8;; +[54] (w=114) │  Preview (first 2KB):  │]8;; +[55] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent  │]8;; +[56] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions  │]8;; +[57] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas--  │]8;; +[58] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--  │]8;; +[59] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/subagent-artifacts  │]8;; +[60] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/2026-03-28T09-46-24-169Z  │]8;; +[61] (w=114) │  _383b9ff3-7faa-4782-a731-e45cec0c8197  │]8;; +[62] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/2026-03-28T09-46-24-169Z  │]8;; +[63] (w=114) │  _383b9ff3-7faa-4782-a731-e45cec0c8197/b7684b6a  │]8;; +[64] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/2026-03-28T09-46-24-169Z  │]8;; +[65] (w=114) │  _383b9ff3-7faa-4782-a731-e45cec0c8197/b7684b6a/run-0  │]8;; +[66] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/2026-03-28T09-46-24-169Z  │]8;; +[67] (w=114) │  _383b9ff3-7faa-4782-a731-e45cec0c8197/95023efc  │]8;; +[68] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/2026-03-28T09-46-24-169Z  │]8;; +[69] (w=114) │  _383b9ff3-7faa-4782-a731-e45cec0c8197/95023efc/run-0  │]8;; +[70] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/2026-03-28T09-46-24-169Z  │]8;; +[71] (w=114) │  _383b9ff3-7faa-4782-a731-e45cec0c8197/5be564de  │]8;; +[72] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/2026-03-28T09-46-24-169Z  │]8;; +[73] (w=114) │  _383b9ff3-7faa-4782-a731-e45cec0c8197/5be564de/run-0  │]8;; +[74] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/2026-03-28T11-01-06-232Z  │]8;; +[75] (w=114) │  _cae41525-1f66-4dbd-a0da-7c12c770aa76  │]8;; +[76] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/2026-03-28T11-01-06-232Z  │]8;; +[77] (w=114) │  _cae41525-1f66-4dbd-a0da-7c12c770aa76/294d75f5  │]8;; +[78] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/sessions/--home-jonas-projects-snow_trail_sdl--/2026-03-28T11-01-06-232Z  │]8;; +[79] (w=114) │  _cae41525-1f66-4dbd-a0da-7c12c770aa76/294d75f5/run-0  │]8;; +[80] (w=114) │  ...  │]8;; +[81] (w=114) │   │]8;; +[82] (w=114) │ │]8;; +[83] (w=114) │  $ ls /home/jonas/dotfiles/pi/.pi/agent/extensions/ /home/jonas/dotfiles/pi/.pi/agent/shared/ 2>/dev/null  │]8;; +[84] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/extensions/:  │]8;; +[85] (w=114) │  ask-claude.ts  │]8;; +[86] (w=114) │  chat-claude.ts  │]8;; +[87] (w=114) │  exa-mcp.json  │]8;; +[88] (w=114) │  footer-display.ts  │]8;; +[89] (w=114) │  git-checkout-guard.ts  │]8;; +[90] (w=114) │  new-with-context.ts  │]8;; +[91] (w=114) │  pi-ask-mcp  │]8;; +[92] (w=114) │  pi-ask-tool  │]8;; +[93] (w=114) │  postpone.ts  │]8;; +[94] (w=114) │  usage-bars  │]8;; +[95] (w=114) │  wezterm-theme-sync  │]8;; +[96] (w=114) │  worktree.ts  │]8;; +[97] (w=114) │   │]8;; +[98] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/shared/:  │]8;; +[99] (w=114) │  claude-stream.ts  │]8;; +[100] (w=114) │  pi-ask-bridge.ts  │]8;; +[101] (w=114) │ │]8;; +[102] (w=114) │  read ~/dotfiles/pi/.pi/agent/shared/claude-stream.ts  │]8;; +[103] (w=114) │   1 /**  │]8;; +[104] (w=114) │   2 * claude-stream — Shared types, rendering, and core spawn/stream logic  │]8;; +[105] (w=114) │   3 * for ask-claude and chat-claude pi extensions.  │]8;; +[106] (w=114) │   4 *  │]8;; +[107] (w=114) │   5 * Both extensions spawn `claude -p --output-format stream-json` and parse  │]8;; +[108] (w=114) │   6 * the same streaming protocol. This module provides:  │]8;; +[109] (w=114) │   7 * - Block types (ThinkingBlock, ToolBlock, TextBlock)  │]8;; +[110] (w=114) │   8 * - Rendering helpers (tool call lines, result boxes, usage formatting)  │]8;; +[111] (w=114) │   9 * - runClaude() — the core spawn + stream parser  │]8;; +[112] (w=114) │   10 */  │]8;; +[113] (w=114) │   11  │]8;; +[114] (w=114) │   12 import { spawn } from "node:child_process";  │]8;; +[115] (w=114) │   13 import { readFileSync } from "node:fs";  │]8;; +[116] (w=114) │   14 import { diffLines } from "diff";  │]8;; +[117] (w=114) │   15 import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";  │]8;; +[118] (w=114) │   16 import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";  │]8;; +[119] (w=114) │   17  │]8;; +[120] (w=114) │   18 // =============================================================================  │]8;; +[121] (w=114) │   19 // Block types  │]8;; +[122] (w=114) │   20 // =============================================================================  │]8;; +[123] (w=114) │   21  │]8;; +[124] (w=114) │   22 export interface ThinkingBlock {  │]8;; +[125] (w=114) │   23 type: "thinking";  │]8;; +[126] (w=114) │   24 text: string;  │]8;; +[127] (w=114) │   25 }  │]8;; +[128] (w=114) │   26  │]8;; +[129] (w=114) │   27 export interface ToolBlock {  │]8;; +[130] (w=114) │   28 type: "tool";  │]8;; +[131] (w=114) │   29 id: string;  │]8;; +[132] (w=114) │   30 name: string;  │]8;; +[133] (w=114) │   31 inputJson: string;  │]8;; +[134] (w=114) │   32 editContext?: { before: string[]; after: string[]; startLine: number };  │]8;; +[135] (w=114) │   33 result?: { text: string; isError: boolean };  │]8;; +[136] (w=114) │   34 }  │]8;; +[137] (w=114) │   35  │]8;; +[138] (w=114) │   36 export interface TextBlock {  │]8;; +[139] (w=114) │   37 type: "text";  │]8;; +[140] (w=114) │   38 text: string;  │]8;; +[141] (w=114) │   39 }  │]8;; +[142] (w=114) │   40  │]8;; +[143] (w=114) │  … 726 more lines hidden …  │]8;; +[144] (w=114) │ │]8;; +[145] (w=114) │  $ find /home/jonas/dotfiles/pi/.pi/agent -name "node_modules" -prune -o -name "package.json" -print  │]8;; +[146] (w=114) │  2>/dev/null | head -5  │]8;; +[147] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/extensions/pi-ask-mcp/package.json  │]8;; +[148] (w=114) │ │]8;; +[149] (w=114) │  $ find /home/jonas -path "*/node_modules/@mariozechner/pi-tui*" -name "*.d.ts" 2>/dev/null | head -10  │]8;; +[150] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/box.d.ts  │]8;; +[151] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/cancellable-loader.d.ts  │]8;; +[152] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/editor.d.ts  │]8;; +[153] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/image.d.ts  │]8;; +[154] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/input.d.ts  │]8;; +[155] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/loader.d.ts  │]8;; +[156] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/markdown.d.ts  │]8;; +[157] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/select-list.d.ts  │]8;; +[158] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/settings-list.d.ts  │]8;; +[159] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/spacer.d.ts  │]8;; +[160] (w=114) │ │]8;; +[161] (w=114) │  $ find /home/jonas/node_modules/@mariozechner/pi-tui/dist/components -name "text*"  │]8;; +[162] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/text.js  │]8;; +[163] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/text.d.ts.map  │]8;; +[164] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/text.js.map  │]8;; +[165] (w=114) │  /home/jonas/node_modules/@mariozechner/pi-tui/dist/components/text.d.ts  │]8;; +[166] (w=114) │ │]8;; +[167] (w=114) │  read ~/node_modules/@mariozechner/pi-tui/dist/components/text.d.ts  │]8;; +[168] (w=114) │   1 import type { Component } from "../tui.js";  │]8;; +[169] (w=114) │   2 /**  │]8;; +[170] (w=114) │   3 * Text component - displays multi-line text with word wrapping  │]8;; +[171] (w=114) │   4 */  │]8;; +[172] (w=114) │   5 export declare class Text implements Component {  │]8;; +[173] (w=114) │   6 private text;  │]8;; +[174] (w=114) │   7 private paddingX;  │]8;; +[175] (w=114) │   8 private paddingY;  │]8;; +[176] (w=114) │   9 private customBgFn?;  │]8;; +[177] (w=114) │  10 private cachedText?;  │]8;; +[178] (w=114) │  11 private cachedWidth?;  │]8;; +[179] (w=114) │  12 private cachedLines?;  │]8;; +[180] (w=114) │  13 constructor(text?: string, paddingX?: number, paddingY?: number, customBgFn?: (text: string) =>  │]8;; +[181] (w=114) │  string);  │]8;; +[182] (w=114) │  14 setText(text: string): void;  │]8;; +[183] (w=114) │  15 setCustomBgFn(customBgFn?: (text: string) => string): void;  │]8;; +[184] (w=114) │  16 invalidate(): void;  │]8;; +[185] (w=114) │  17 render(width: number): string[];  │]8;; +[186] (w=114) │  18 }  │]8;; +[187] (w=114) │  19 //# sourceMappingURL=text.d.ts.map  │]8;; +[188] (w=114) │ │]8;; +[189] (w=114) │  read ~/node_modules/@mariozechner/pi-tui/dist/components/text.js  │]8;; +[190] (w=114) │   1 import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js";  │]8;; +[191] (w=114) │   2 /**  │]8;; +[192] (w=114) │   3 * Text component - displays multi-line text with word wrapping  │]8;; +[193] (w=114) │   4 */  │]8;; +[194] (w=114) │   5 export class Text {  │]8;; +[195] (w=114) │   6 text;  │]8;; +[196] (w=114) │   7 paddingX; // Left/right padding  │]8;; +[197] (w=114) │   8 paddingY; // Top/bottom padding  │]8;; +[198] (w=114) │   9 customBgFn;  │]8;; +[199] (w=114) │  10 // Cache for rendered output  │]8;; +[200] (w=114) │  11 cachedText;  │]8;; +[201] (w=114) │  12 cachedWidth;  │]8;; +[202] (w=114) │  13 cachedLines;  │]8;; +[203] (w=114) │  14 constructor(text = "", paddingX = 1, paddingY = 1, customBgFn) {  │]8;; +[204] (w=114) │  15 this.text = text;  │]8;; +[205] (w=114) │  16 this.paddingX = paddingX;  │]8;; +[206] (w=114) │  17 this.paddingY = paddingY;  │]8;; +[207] (w=114) │  18 this.customBgFn = customBgFn;  │]8;; +[208] (w=114) │  19 }  │]8;; +[209] (w=114) │  20 setText(text) {  │]8;; +[210] (w=114) │  21 this.text = text;  │]8;; +[211] (w=114) │  22 this.cachedText = undefined;  │]8;; +[212] (w=114) │  23 this.cachedWidth = undefined;  │]8;; +[213] (w=114) │  24 this.cachedLines = undefined;  │]8;; +[214] (w=114) │  25 }  │]8;; +[215] (w=114) │  26 setCustomBgFn(customBgFn) {  │]8;; +[216] (w=114) │  27 this.customBgFn = customBgFn;  │]8;; +[217] (w=114) │  28 this.cachedText = undefined;  │]8;; +[218] (w=114) │  29 this.cachedWidth = undefined;  │]8;; +[219] (w=114) │  30 this.cachedLines = undefined;  │]8;; +[220] (w=114) │  31 }  │]8;; +[221] (w=114) │  32 invalidate() {  │]8;; +[222] (w=114) │  33 this.cachedText = undefined;  │]8;; +[223] (w=114) │  34 this.cachedWidth = undefined;  │]8;; +[224] (w=114) │  35 this.cachedLines = undefined;  │]8;; +[225] (w=114) │  36 }  │]8;; +[226] (w=114) │  37 render(width) {  │]8;; +[227] (w=114) │  38 // Check cache  │]8;; +[228] (w=114) │  39 if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {  │]8;; +[229] (w=114) │  40 return this.cachedLines;  │]8;; +[230] (w=114) │  … 49 more lines hidden …  │]8;; +[231] (w=114) │ │]8;; +[232] (w=114) │  read ~/node_modules/@mariozechner/pi-tui/dist/components/box.js  │]8;; +[233] (w=114) │   1 import { applyBackgroundToLine, visibleWidth } from "../utils.js";  │]8;; +[234] (w=114) │   2 /**  │]8;; +[235] (w=114) │   3 * Box component - a container that applies padding and background to all children  │]8;; +[236] (w=114) │   4 */  │]8;; +[237] (w=114) │   5 export class Box {  │]8;; +[238] (w=114) │   6 children = [];  │]8;; +[239] (w=114) │   7 paddingX;  │]8;; +[240] (w=114) │   8 paddingY;  │]8;; +[241] (w=114) │   9 bgFn;  │]8;; +[242] (w=114) │   10 // Cache for rendered output  │]8;; +[243] (w=114) │   11 cache;  │]8;; +[244] (w=114) │   12 constructor(paddingX = 1, paddingY = 1, bgFn) {  │]8;; +[245] (w=114) │   13 this.paddingX = paddingX;  │]8;; +[246] (w=114) │   14 this.paddingY = paddingY;  │]8;; +[247] (w=114) │   15 this.bgFn = bgFn;  │]8;; +[248] (w=114) │   16 }  │]8;; +[249] (w=114) │   17 addChild(component) {  │]8;; +[250] (w=114) │   18 this.children.push(component);  │]8;; +[251] (w=114) │   19 this.invalidateCache();  │]8;; +[252] (w=114) │   20 }  │]8;; +[253] (w=114) │   21 removeChild(component) {  │]8;; +[254] (w=114) │   22 const index = this.children.indexOf(component);  │]8;; +[255] (w=114) │   23 if (index !== -1) {  │]8;; +[256] (w=114) │   24 this.children.splice(index, 1);  │]8;; +[257] (w=114) │   25 this.invalidateCache();  │]8;; +[258] (w=114) │   26 }  │]8;; +[259] (w=114) │   27 }  │]8;; +[260] (w=114) │   28 clear() {  │]8;; +[261] (w=114) │   29 this.children = [];  │]8;; +[262] (w=114) │   30 this.invalidateCache();  │]8;; +[263] (w=114) │   31 }  │]8;; +[264] (w=114) │   32 setBgFn(bgFn) {  │]8;; +[265] (w=114) │   33 this.bgFn = bgFn;  │]8;; +[266] (w=114) │   34 // Don't invalidate here - we'll detect bgFn changes by sampling output  │]8;; +[267] (w=114) │   35 }  │]8;; +[268] (w=114) │   36 invalidateCache() {  │]8;; +[269] (w=114) │   37 this.cache = undefined;  │]8;; +[270] (w=114) │   38 }  │]8;; +[271] (w=114) │   39 matchCache(width, childLines, bgSample) {  │]8;; +[272] (w=114) │   40 const cache = this.cache;  │]8;; +[273] (w=114) │  … 64 more lines hidden …  │]8;; +[274] (w=114) │ │]8;; +[275] (w=114) │  $ cat /home/jonas/node_modules/@mariozechner/pi-tui/dist/index.d.ts 2>/dev/null | head -50  │]8;; +[276] (w=114) │  export { type AutocompleteItem, type AutocompleteProvider, CombinedAutocompleteProvider, type  │]8;; +[277] (w=114) │  SlashCommand, } from "./autocomplete.js";  │]8;; +[278] (w=114) │  export { Box } from "./components/box.js";  │]8;; +[279] (w=114) │  export { CancellableLoader } from "./components/cancellable-loader.js";  │]8;; +[280] (w=114) │  export { Editor, type EditorOptions, type EditorTheme } from "./components/editor.js";  │]8;; +[281] (w=114) │  export { Image, type ImageOptions, type ImageTheme } from "./components/image.js";  │]8;; +[282] (w=114) │  export { Input } from "./components/input.js";  │]8;; +[283] (w=114) │  export { Loader } from "./components/loader.js";  │]8;; +[284] (w=114) │  export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.js";  │]8;; +[285] (w=114) │  export { type SelectItem, SelectList, type SelectListTheme } from "./components/select-list.js";  │]8;; +[286] (w=114) │  export { type SettingItem, SettingsList, type SettingsListTheme } from "./components/settings-list.js";  │]8;; +[287] (w=114) │  export { Spacer } from "./components/spacer.js";  │]8;; +[288] (w=114) │  export { Text } from "./components/text.js";  │]8;; +[289] (w=114) │  export { TruncatedText } from "./components/truncated-text.js";  │]8;; +[290] (w=114) │  export type { EditorComponent } from "./editor-component.js";  │]8;; +[291] (w=114) │  export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy.js";  │]8;; +[292] (w=114) │  export { DEFAULT_EDITOR_KEYBINDINGS, type EditorAction, type EditorKeybindingsConfig,  │]8;; +[293] (w=114) │  EditorKeybindingsManager, getEditorKeybindings, setEditorKeybindings, } from "./keybindings.js";  │]8;; +[294] (w=114) │  export { isKeyRelease, isKeyRepeat, isKittyProtocolActive, Key, type KeyEventType, type KeyId, matchesKey,  │]8;; +[295] (w=114) │  parseKey, setKittyProtocolActive, } from "./keys.js";  │]8;; +[296] (w=114) │  export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.js";  │]8;; +[297] (w=114) │  export { ProcessTerminal, type Terminal } from "./terminal.js";  │]8;; +[298] (w=114) │  export { allocateImageId, type CellDimensions, calculateImageRows, deleteAllKittyImages, deleteKittyImage,  │]8;; +[299] (w=114) │  detectCapabilities, encodeITerm2, encodeKitty, getCapabilities, getCellDimensions, getGifDimensions,  │]8;; +[300] (w=114) │  getImageDimensions, getJpegDimensions, getPngDimensions, getWebpDimensions, type ImageDimensions, type  │]8;; +[301] (w=114) │  ImageProtocol, type ImageRenderOptions, imageFallback, renderImage, resetCapabilitiesCache,  │]8;; +[302] (w=114) │  setCellDimensions, type TerminalCapabilities, } from "./terminal-image.js";  │]8;; +[303] (w=114) │  export { type Component, Container, CURSOR_MARKER, type Focusable, isFocusable, type OverlayAnchor, type  │]8;; +[304] (w=114) │  OverlayHandle, type OverlayMargin, type OverlayOptions, type SizeValue, TUI, } from "./tui.js";  │]8;; +[305] (w=114) │  export { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.js";  │]8;; +[306] (w=114) │  //# sourceMappingURL=index.d.ts.map  │]8;; +[307] (w=114) │ │]8;; +[308] (w=114) │  $ cat /home/jonas/node_modules/@mariozechner/pi-tui/dist/tui.d.ts | head -100  │]8;; +[309] (w=114) │  /**  │]8;; +[310] (w=114) │  * Minimal TUI implementation with differential rendering  │]8;; +[311] (w=114) │  */  │]8;; +[312] (w=114) │  import type { Terminal } from "./terminal.js";  │]8;; +[313] (w=114) │  import { visibleWidth } from "./utils.js";  │]8;; +[314] (w=114) │  /**  │]8;; +[315] (w=114) │  * Component interface - all components must implement this  │]8;; +[316] (w=114) │  */  │]8;; +[317] (w=114) │  export interface Component {  │]8;; +[318] (w=114) │  /**  │]8;; +[319] (w=114) │  * Render the component to lines for the given viewport width  │]8;; +[320] (w=114) │  * @param width - Current viewport width  │]8;; +[321] (w=114) │  * @returns Array of strings, each representing a line  │]8;; +[322] (w=114) │  */  │]8;; +[323] (w=114) │  render(width: number): string[];  │]8;; +[324] (w=114) │  /**  │]8;; +[325] (w=114) │  * Optional handler for keyboard input when component has focus  │]8;; +[326] (w=114) │  */  │]8;; +[327] (w=114) │  handleInput?(data: string): void;  │]8;; +[328] (w=114) │  /**  │]8;; +[329] (w=114) │  * If true, component receives key release events (Kitty protocol).  │]8;; +[330] (w=114) │  * Default is false - release events are filtered out.  │]8;; +[331] (w=114) │  */  │]8;; +[332] (w=114) │  wantsKeyRelease?: boolean;  │]8;; +[333] (w=114) │  /**  │]8;; +[334] (w=114) │  * Invalidate any cached rendering state.  │]8;; +[335] (w=114) │  * Called when theme changes or when component needs to re-render from scratch.  │]8;; +[336] (w=114) │  */  │]8;; +[337] (w=114) │  invalidate(): void;  │]8;; +[338] (w=114) │  }  │]8;; +[339] (w=114) │  /**  │]8;; +[340] (w=114) │  * Interface for components that can receive focus and display a hardware cursor.  │]8;; +[341] (w=114) │  * When focused, the component should emit CURSOR_MARKER at the cursor position  │]8;; +[342] (w=114) │  * in its render output. TUI will find this marker and position the hardware  │]8;; +[343] (w=114) │  * cursor there for proper IME candidate window positioning.  │]8;; +[344] (w=114) │  */  │]8;; +[345] (w=114) │  export interface Focusable {  │]8;; +[346] (w=114) │  /** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */  │]8;; +[347] (w=114) │  focused: boolean;  │]8;; +[348] (w=114) │  }  │]8;; +[349] (w=114) │  /** Type guard to check if a component implements Focusable */  │]8;; +[350] (w=114) │  export declare function isFocusable(component: Component | null): component is Component & Focusable;  │]8;; +[351] (w=114) │  /**  │]8;; +[352] (w=114) │  * Cursor position marker - APC (Application Program Command) sequence.  │]8;; +[353] (w=114) │  * This is a zero-width escape sequence that terminals ignore.  │]8;; +[354] (w=114) │  * Components emit this at the cursor position when focused.  │]8;; +[355] (w=114) │  * TUI finds and strips this marker, then positions the hardware cursor there.  │]8;; +[356] (w=114) │  */  │]8;; +[357] (w=114) │  export declare const CURSOR_MARKER = "\u001B_pi:c\u0007";  │]8;; +[358] (w=114) │  export { visibleWidth };  │]8;; +[359] (w=114) │  /**  │]8;; +[360] (w=114) │  * Anchor position for overlays  │]8;; +[361] (w=114) │  */  │]8;; +[362] (w=114) │  export type OverlayAnchor = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right" |  │]8;; +[363] (w=114) │  "top-center" | "bottom-center" | "left-center" | "right-center";  │]8;; +[364] (w=114) │  /**  │]8;; +[365] (w=114) │  * Margin configuration for overlays  │]8;; +[366] (w=114) │  */  │]8;; +[367] (w=114) │  export interface OverlayMargin {  │]8;; +[368] (w=114) │  top?: number;  │]8;; +[369] (w=114) │  right?: number;  │]8;; +[370] (w=114) │  bottom?: number;  │]8;; +[371] (w=114) │  left?: number;  │]8;; +[372] (w=114) │  }  │]8;; +[373] (w=114) │  /** Value that can be absolute (number) or percentage (string like "50%") */  │]8;; +[374] (w=114) │  export type SizeValue = number | `${number}%`;  │]8;; +[375] (w=114) │  /**  │]8;; +[376] (w=114) │  * Options for overlay positioning and sizing.  │]8;; +[377] (w=114) │  * Values can be absolute numbers or percentage strings (e.g., "50%").  │]8;; +[378] (w=114) │  */  │]8;; +[379] (w=114) │  export interface OverlayOptions {  │]8;; +[380] (w=114) │  /** Width in columns, or percentage of terminal width (e.g., "50%") */  │]8;; +[381] (w=114) │  width?: SizeValue;  │]8;; +[382] (w=114) │  /** Minimum width in columns */  │]8;; +[383] (w=114) │  minWidth?: number;  │]8;; +[384] (w=114) │  /** Maximum height in rows, or percentage of terminal height (e.g., "50%") */  │]8;; +[385] (w=114) │  maxHeight?: SizeValue;  │]8;; +[386] (w=114) │  /** Anchor point for positioning (default: 'center') */  │]8;; +[387] (w=114) │  anchor?: OverlayAnchor;  │]8;; +[388] (w=114) │  /** Horizontal offset from anchor position (positive = right) */  │]8;; +[389] (w=114) │  offsetX?: number;  │]8;; +[390] (w=114) │  /** Vertical offset from anchor position (positive = down) */  │]8;; +[391] (w=114) │  offsetY?: number;  │]8;; +[392] (w=114) │  /** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */  │]8;; +[393] (w=114) │  row?: SizeValue;  │]8;; +[394] (w=114) │  /** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */  │]8;; +[395] (w=114) │  col?: SizeValue;  │]8;; +[396] (w=114) │  /** Margin from terminal edges. Number applies to all sides. */  │]8;; +[397] (w=114) │  margin?: OverlayMargin | number;  │]8;; +[398] (w=114) │  /**  │]8;; +[399] (w=114) │  * Control overlay visibility based on terminal dimensions.  │]8;; +[400] (w=114) │  * If provided, overlay is only rendered when this returns true.  │]8;; +[401] (w=114) │  * Called each render cycle with current terminal dimensions.  │]8;; +[402] (w=114) │  */  │]8;; +[403] (w=114) │  visible?: (termWidth: number, termHeight: number) => boolean;  │]8;; +[404] (w=114) │  }  │]8;; +[405] (w=114) │  /**  │]8;; +[406] (w=114) │  * Handle returned by showOverlay for controlling the overlay  │]8;; +[407] (w=114) │  */  │]8;; +[408] (w=114) │  export interface OverlayHandle {  │]8;; +[409] (w=114) │  /** Permanently remove the overlay (cannot be shown again) */  │]8;; +[410] (w=114) │ │]8;; +[411] (w=114) │  $ grep -n "class Container" /home/jonas/node_modules/@mariozechner/pi-tui/dist/tui.js | head -5  │]8;; +[412] (w=114) │  38:export class Container {  │]8;; +[413] (w=114) │ │]8;; +[414] (w=114) │  read ~/node_modules/@mariozechner/pi-tui/dist/tui.js:38-117  │]8;; +[415] (w=114) │   38 export class Container {  │]8;; +[416] (w=114) │   39 children = [];  │]8;; +[417] (w=114) │   40 addChild(component) {  │]8;; +[418] (w=114) │   41 this.children.push(component);  │]8;; +[419] (w=114) │   42 }  │]8;; +[420] (w=114) │   43 removeChild(component) {  │]8;; +[421] (w=114) │   44 const index = this.children.indexOf(component);  │]8;; +[422] (w=114) │   45 if (index !== -1) {  │]8;; +[423] (w=114) │   46 this.children.splice(index, 1);  │]8;; +[424] (w=114) │   47 }  │]8;; +[425] (w=114) │   48 }  │]8;; +[426] (w=114) │   49 clear() {  │]8;; +[427] (w=114) │   50 this.children = [];  │]8;; +[428] (w=114) │   51 }  │]8;; +[429] (w=114) │   52 invalidate() {  │]8;; +[430] (w=114) │   53 for (const child of this.children) {  │]8;; +[431] (w=114) │   54 child.invalidate?.();  │]8;; +[432] (w=114) │   55 }  │]8;; +[433] (w=114) │   56 }  │]8;; +[434] (w=114) │   57 render(width) {  │]8;; +[435] (w=114) │   58 const lines = [];  │]8;; +[436] (w=114) │   59 for (const child of this.children) {  │]8;; +[437] (w=114) │   60 lines.push(...child.render(width));  │]8;; +[438] (w=114) │   61 }  │]8;; +[439] (w=114) │   62 return lines;  │]8;; +[440] (w=114) │   63 }  │]8;; +[441] (w=114) │   64 }  │]8;; +[442] (w=114) │   65 /**  │]8;; +[443] (w=114) │   66 * TUI - Main class for managing terminal UI with differential rendering  │]8;; +[444] (w=114) │   67 */  │]8;; +[445] (w=114) │   68 export class TUI extends Container {  │]8;; +[446] (w=114) │   69 terminal;  │]8;; +[447] (w=114) │   70 previousLines = [];  │]8;; +[448] (w=114) │   71 previousWidth = 0;  │]8;; +[449] (w=114) │   72 previousHeight = 0;  │]8;; +[450] (w=114) │   73 focusedComponent = null;  │]8;; +[451] (w=121) │   74 /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */ │]8;; +[452] (w=114) │   75 onDebug;  │]8;; +[453] (w=114) │   76 renderRequested = false;  │]8;; +[454] (w=114) │   77 cursorRow = 0; // Logical cursor row (end of rendered content)  │]8;; +[455] (w=114) │  … 40 more lines hidden …  │]8;; +[456] (w=114) │ │]8;; +[457] (w=114) │  $ grep -n "Container" /home/jonas/node_modules/@mariozechner/pi-tui/dist/tui.d.ts | head -20  │]8;; +[458] (w=114) │  108: * Container - a component that contains other components  │]8;; +[459] (w=114) │  110:export declare class Container implements Component {  │]8;; +[460] (w=114) │  121:export declare class TUI extends Container {  │]8;; +[461] (w=114) │ │]8;; +[462] (w=114) │  read ~/node_modules/@mariozechner/pi-tui/dist/tui.d.ts:108-122  │]8;; +[463] (w=114) │  108 * Container - a component that contains other components  │]8;; +[464] (w=114) │  109 */  │]8;; +[465] (w=114) │  110 export declare class Container implements Component {  │]8;; +[466] (w=114) │  111 children: Component[];  │]8;; +[467] (w=114) │  112 addChild(component: Component): void;  │]8;; +[468] (w=114) │  113 removeChild(component: Component): void;  │]8;; +[469] (w=114) │  114 clear(): void;  │]8;; +[470] (w=114) │  115 invalidate(): void;  │]8;; +[471] (w=114) │  116 render(width: number): string[];  │]8;; +[472] (w=114) │  117 }  │]8;; +[473] (w=114) │  118 /**  │]8;; +[474] (w=114) │  119 * TUI - Main class for managing terminal UI with differential rendering  │]8;; +[475] (w=114) │  120 */  │]8;; +[476] (w=114) │  121 export declare class TUI extends Container {  │]8;; +[477] (w=114) │  122 terminal: Terminal;  │]8;; +[478] (w=114) │ │]8;; +[479] (w=114) │  read ~/node_modules/@mariozechner/pi-tui/dist/components/box.d.ts  │]8;; +[480] (w=114) │   1 import type { Component } from "../tui.js";  │]8;; +[481] (w=114) │   2 /**  │]8;; +[482] (w=114) │   3 * Box component - a container that applies padding and background to all children  │]8;; +[483] (w=114) │   4 */  │]8;; +[484] (w=114) │   5 export declare class Box implements Component {  │]8;; +[485] (w=114) │   6 children: Component[];  │]8;; +[486] (w=114) │   7 private paddingX;  │]8;; +[487] (w=114) │   8 private paddingY;  │]8;; +[488] (w=114) │   9 private bgFn?;  │]8;; +[489] (w=114) │  10 private cache?;  │]8;; +[490] (w=114) │  11 constructor(paddingX?: number, paddingY?: number, bgFn?: (text: string) => string);  │]8;; +[491] (w=114) │  12 addChild(component: Component): void;  │]8;; +[492] (w=114) │  13 removeChild(component: Component): void;  │]8;; +[493] (w=114) │  14 clear(): void;  │]8;; +[494] (w=114) │  15 setBgFn(bgFn?: (text: string) => string): void;  │]8;; +[495] (w=114) │  16 private invalidateCache;  │]8;; +[496] (w=114) │  17 private matchCache;  │]8;; +[497] (w=114) │  18 invalidate(): void;  │]8;; +[498] (w=114) │  19 render(width: number): string[];  │]8;; +[499] (w=114) │  20 private applyBg;  │]8;; +[500] (w=114) │  21 }  │]8;; +[501] (w=114) │  22 //# sourceMappingURL=box.d.ts.map  │]8;; +[502] (w=114) │ │]8;; +[503] (w=114) │ Now I have enough understanding. Let me look at the exact current state of the tool-block rendering site and │]8;; +[504] (w=114) │ the imports: │]8;; +[505] (w=114) │ │]8;; +[506] (w=114) │  read ~/dotfiles/pi/.pi/agent/extensions/chat-claude.ts:NaN-NaN  │]8;; +[507] (w=114) │   InputValidationError: Read failed due to the following issue:  │]8;; +[508] (w=114) │   The parameter `offset` type is expected as `number` but provided as `string`  │]8;; +[509] (w=114) │ │]8;; +[510] (w=114) │  read ~/dotfiles/pi/.pi/agent/extensions/chat-claude.ts:30-41  │]8;; +[511] (w=114) │  30 import { CustomEditor, getMarkdownTheme } from "@mariozechner/pi-coding-agent";  │]8;; +[512] (w=114) │  31 import type { ExtensionAPI, KeybindingsManager } from "@mariozechner/pi-coding-agent";  │]8;; +[513] (w=114) │  32 import { Container, matchesKey, Markdown, Spacer, Text, TUI, visibleWidth, type EditorTheme } from  │]8;; +[514] (w=114) │  "@mariozechner/pi-tui";  │]8;; +[515] (w=114) │  33 import {  │]8;; +[516] (w=114) │  34 formatUsage,  │]8;; +[517] (w=114) │  35 renderToolBlock,  │]8;; +[518] (w=114) │  36 runClaude,  │]8;; +[519] (w=114) │  37 type StreamBlock,  │]8;; +[520] (w=114) │  38 } from "../shared/claude-stream.js";  │]8;; +[521] (w=114) │  39 import { startAskBridge, type AskBridge } from "../shared/pi-ask-bridge.js";  │]8;; +[522] (w=114) │  40 import { askSingleQuestionWithInlineNote } from "./pi-ask-tool/ask-inline-ui.js";  │]8;; +[523] (w=114) │  41  │]8;; +[524] (w=114) │ │]8;; +[525] (w=114) │ Now I'll make the changes. First, add Box and Component to the imports: │]8;; +[526] (w=114) │ │]8;; +[527] (w=114) │  edit ~/dotfiles/pi/.pi/agent/extensions/chat-claude.ts  │]8;; +[528] (w=114) │  @@ -1,1 +1,1 @@  │]8;; +[529] (w=114) │  -import { Container, matchesKey, Markdown, Spacer, Text, TUI, visibleWidth, type EditorTheme } from  │]8;; +[530] (w=114) │  "@mariozechner/pi-tui";  │]8;; +[531] (w=114) │  +import { Box, Container, matchesKey, Markdown, Spacer, Text, TUI, visibleWidth, type Component, type  │]8;; +[532] (w=114) │  EditorTheme } from "@mariozechner/pi-tui";  │]8;; +[533] (w=114) │ │]8;; +[534] (w=114) │ Now add the constant and the truncation helper. I'll place it right after the orange-border helper, where │]8;; +[535] (w=114) │ rendering helpers live: │]8;; +[536] (w=114) │ │]8;; +[537] (w=114) │  edit ~/dotfiles/pi/.pi/agent/extensions/chat-claude.ts  │]8;; +[538] (w=114) │  @@ -1,3 +1,82 @@  │]8;; +[539] (w=114) │  -// ---------------------------------------------------------------------------  │]8;; +[540] (w=114) │  -// Models / turn types  │]8;; +[541] (w=114) │  -// ---------------------------------------------------------------------------  │]8;; +[542] (w=114) │  +// ---------------------------------------------------------------------------  │]8;; +[543] (w=114) │  +// Read-tool result truncation  │]8;; +[544] (w=114) │  +//  │]8;; +[545] (w=114) │  +// `Read` tool calls inside chat mode often dump entire files into the result  │]8;; +[546] (w=114) │  +// banner — many hundreds of lines, which buries the surrounding conversation.  │]8;; +[547] (w=114) │  +// We cap the rendered file content at MAX_READ_LINES and append a single  │]8;; +[548] (w=114) │  +// centered notice line describing how many lines were hidden. This is a  │]8;; +[549] (w=114) │  +// PRESENTATION-only truncation: `block.result.text` is left untouched, so  │]8;; +[550] (w=114) │  +// resumed sessions / re-renders still see the full content.  │]8;; +[551] (w=114) │  +//  │]8;; +[552] (w=114) │  +// Centering needs render-time width, so we implement a tiny custom Component  │]8;; +[553] (w=114) │  +// (TruncatedReadResult) and swap it into the Box body produced by the shared  │]8;; +[554] (w=114) │  +// renderToolBlock helper. The same dim line-number formatting used by  │]8;; +[555] (w=114) │  +// renderToolResultBox is preserved so the truncated view looks identical to  │]8;; +[556] (w=114) │  +// the un-truncated one above the notice.  │]8;; +[557] (w=114) │  +// ---------------------------------------------------------------------------  │]8;; +[558] (w=114) │  +const MAX_READ_LINES = 40;  │]8;; +[559] (w=114) │  +  │]8;; +[560] (w=114) │  +class TruncatedReadResult implements Component {  │]8;; +[561] (w=114) │  + constructor(  │]8;; +[562] (w=114) │  + private readonly numbered: { num: string; content: string }[],  │]8;; +[563] (w=114) │  + private readonly maxNumLen: number,  │]8;; +[564] (w=114) │  + private readonly dimFn: (s: string) => string,  │]8;; +[565] (w=114) │  + private readonly noticeFn: (s: string) => string,  │]8;; +[566] (w=114) │  + ) {}  │]8;; +[567] (w=114) │  +  │]8;; +[568] (w=114) │  + invalidate(): void { /* stateless */ }  │]8;; +[569] (w=114) │  +  │]8;; +[570] (w=114) │  + render(width: number): string[] {  │]8;; +[571] (w=114) │  + const total = this.numbered.length;  │]8;; +[572] (w=114) │  + const visible = Math.min(MAX_READ_LINES, total);  │]8;; +[573] (w=114) │  + const lines: string[] = [];  │]8;; +[574] (w=114) │  + for (let i = 0; i < visible; i++) {  │]8;; +[575] (w=114) │  + const l = this.numbered[i];  │]8;; +[576] (w=114) │  + lines.push(this.dimFn(l.num.padStart(this.maxNumLen)) + " " + l.content);  │]8;; +[577] (w=114) │  + }  │]8;; +[578] (w=114) │  + if (total > visible) {  │]8;; +[579] (w=114) │  + const hidden = total - visible;  │]8;; +[580] (w=114) │  + const notice = `… ${hidden} more line${hidden === 1 ? "" : "s"} hidden …`;  │]8;; +[581] (w=114) │  + const visLen = visibleWidth(notice);  │]8;; +[582] (w=114) │  + const left = Math.max(0, Math.floor((width - visLen) / 2));  │]8;; +[583] (w=114) │  + lines.push(" ".repeat(left) + this.noticeFn(notice));  │]8;; +[584] (w=114) │  + }  │]8;; +[585] (w=114) │  + return lines;  │]8;; +[586] (w=114) │  + }  │]8;; +[587] (w=114) │  +}  │]8;; +[588] (w=114) │  +  │]8;; +[589] (w=114) │  +// Wrap shared renderToolBlock: for `Read` tool blocks whose result exceeds  │]8;; +[590] (w=114) │  +// MAX_READ_LINES, replace the Box body's child Text with our truncating  │]8;; +[591] (w=114) │  +// component. All other tool kinds, error results, and short reads pass  │]8;; +[592] (w=114) │  +// through unchanged.  │]8;; +[593] (w=114) │  +function renderToolBlockTruncated(block: Extract, theme: any): Container {  │]8;; +[594] (w=114) │  + const c = renderToolBlock(block, theme);  │]8;; +[595] (w=114) │  + if (block.name.toLowerCase() !== "read") return c;  │]8;; +[596] (w=114) │  + if (!block.result || block.result.isError) return c;  │]8;; +[597] (w=114) │  +  │]8;; +[598] (w=114) │  + const rawLines = block.result.text.split("\n").filter((l) => l.length > 0);  │]8;; +[599] (w=114) │  + if (rawLines.length <= MAX_READ_LINES) return c;  │]8;; +[600] (w=114) │  +  │]8;; +[601] (w=114) │  + const parsed = rawLines.map((l) => {  │]8;; +[602] (w=114) │  + const tab = l.indexOf("\t");  │]8;; +[603] (w=114) │  + return tab >= 0 ? { num: l.slice(0, tab), content: l.slice(tab + 1) } : { num: "", content: l };  │]8;; +[604] (w=114) │  + });  │]8;; +[605] (w=114) │  + const maxNumLen = parsed.reduce((m, l) => Math.max(m, l.num.length), 0);  │]8;; +[606] (w=114) │  +  │]8;; +[607] (w=114) │  + // renderToolBlock's container is [headerText, bodyBox]. Bail safely if a  │]8;; +[608] (w=114) │  + // future change to that helper alters the structure.  │]8;; +[609] (w=114) │  + const body = c.children[1];  │]8;; +[610] (w=114) │  + if (!(body instanceof Box)) return c;  │]8;; +[611] (w=114) │  + body.clear();  │]8;; +[612] (w=114) │  + body.addChild(new TruncatedReadResult(  │]8;; +[613] (w=114) │  + parsed,  │]8;; +[614] (w=114) │  + maxNumLen,  │]8;; +[615] (w=114) │  + (s) => theme.fg("dim", s),  │]8;; +[616] (w=114) │  + (s) => theme.fg("dim", s),  │]8;; +[617] (w=114) │  + ));  │]8;; +[618] (w=114) │  + return c;  │]8;; +[619] (w=114) │  +}  │]8;; +[620] (w=114) │  +  │]8;; +[621] (w=114) │  +// ---------------------------------------------------------------------------  │]8;; +[622] (w=114) │  +// Models / turn types  │]8;; +[623] (w=114) │  +// ---------------------------------------------------------------------------  │]8;; +[624] (w=114) │ │]8;; +[625] (w=114) │ Now swap the call site inside renderTurnInto to use the wrapper: │]8;; +[626] (w=114) │ │]8;; +[627] (w=114) │  edit ~/dotfiles/pi/.pi/agent/extensions/chat-claude.ts  │]8;; +[628] (w=114) │  @@ -1,4 +1,4 @@  │]8;; +[629] (w=114) │  - } else if (block.type === "tool") {  │]8;; +[630] (w=114) │  - if (addedAny) container.addChild(new Spacer(1));  │]8;; +[631] (w=114) │  - container.addChild(renderToolBlock(block, theme));  │]8;; +[632] (w=114) │  - addedAny = true;  │]8;; +[633] (w=114) │  + } else if (block.type === "tool") {  │]8;; +[634] (w=114) │  + if (addedAny) container.addChild(new Spacer(1));  │]8;; +[635] (w=114) │  + container.addChild(renderToolBlockTruncated(block, theme));  │]8;; +[636] (w=114) │  + addedAny = true;  │]8;; +[637] (w=114) │ │]8;; +[638] (w=114) │ Now let me verify there's no TypeScript build/check available and the file still parses cleanly: │]8;; +[639] (w=114) │ │]8;; +[640] (w=114) │  $ ls /home/jonas/dotfiles/pi/.pi/ /home/jonas/dotfiles/pi/.pi/agent/ 2>/dev/null  │]8;; +[641] (w=114) │  /home/jonas/dotfiles/pi/.pi/:  │]8;; +[642] (w=114) │  agent  │]8;; +[643] (w=114) │  web-search.json  │]8;; +[644] (w=114) │   │]8;; +[645] (w=114) │  /home/jonas/dotfiles/pi/.pi/agent/:  │]8;; +[646] (w=114) │  agents  │]8;; +[647] (w=114) │  auth.json  │]8;; +[648] (w=114) │  auth.json.current  │]8;; +[649] (w=114) │  cache  │]8;; +[650] (w=114) │  extensions  │]8;; +[651] (w=114) │  mcp-cache.json  │]8;; +[652] (w=114) │  mcp.json  │]8;; +[653] (w=114) │  models.json  │]8;; +[654] (w=114) │  profiles  │]8;; +[655] (w=114) │  prompts  │]8;; +[656] (w=114) │  run-history.jsonl  │]8;; +[657] (w=114) │  sessions  │]8;; +[658] (w=114) │  settings.json  │]8;; +[659] (w=114) │  shared  │]8;; +[660] (w=114) │  skills  │]8;; +[661] (w=114) │  themes  │]8;; +[662] (w=114) │  usage-cache.json  │]8;; +[663] (w=114) │ │]8;; +[664] (w=114) │  $ which tsc 2>/dev/null; ls /home/jonas/node_modules/.bin/tsc 2>/dev/null  │]8;; +[665] (w=114) │  Exit code 2  │]8;; +[666] (w=114) │  /home/jonas/.npm-global/bin/tsc  │]8;; +[667] (w=114) │ │]8;; +[668] (w=114) │  $ cd /home/jonas/dotfiles/pi/.pi/agent && tsc --noEmit --target esnext --module nodenext  │]8;; +[669] (w=114) │  --moduleResolution nodenext --esModuleInterop --skipLibCheck --strict false extensions/chat-claude.ts 2>&1  │]8;; +[670] (w=114) │  | head -40  │]8;; +[671] (w=114) │  shared/pi-ask-bridge.ts(117,37): error TS1470: The 'import.meta' meta-property is not allowed in files  │]8;; +[672] (w=114) │  which will build into CommonJS output.  │]8;; +[673] (w=114) │ │]8;; +[674] (w=114) │  $ tsc --noEmit --target esnext --module esnext --moduleResolution bundler --esModuleInterop --skipLibCheck  │]8;; +[675] (w=114) │  --strict false extensions/chat-claude.ts 2>&1 | head -40  │]8;; +[676] (w=114) │  (Bash completed with no output)  │]8;; +[677] (w=114) │ │]8;; +[678] (w=114) │ No type errors. Let me show the final relevant sections: │]8;; +[679] (w=114) │ │]8;; +[680] (w=114) │  read ~/dotfiles/pi/.pi/agent/extensions/chat-claude.ts:72-153  │]8;; +[681] (w=114) │   72 // ---------------------------------------------------------------------------  │]8;; +[682] (w=114) │   73 // Read-tool result truncation  │]8;; +[683] (w=114) │   74 //  │]8;; +[684] (w=114) │   75 // `Read` tool calls inside chat mode often dump entire files into the result  │]8;; +[685] (w=114) │   76 // banner — many hundreds of lines, which buries the surrounding conversation.  │]8;; +[686] (w=114) │   77 // We cap the rendered file content at MAX_READ_LINES and append a single  │]8;; +[687] (w=114) │   78 // centered notice line describing how many lines were hidden. This is a  │]8;; +[688] (w=114) │   79 // PRESENTATION-only truncation: `block.result.text` is left untouched, so  │]8;; +[689] (w=114) │   80 // resumed sessions / re-renders still see the full content.  │]8;; +[690] (w=114) │   81 //  │]8;; +[691] (w=114) │   82 // Centering needs render-time width, so we implement a tiny custom Component  │]8;; +[692] (w=114) │   83 // (TruncatedReadResult) and swap it into the Box body produced by the shared  │]8;; +[693] (w=114) │   84 // renderToolBlock helper. The same dim line-number formatting used by  │]8;; +[694] (w=114) │   85 // renderToolResultBox is preserved so the truncated view looks identical to  │]8;; +[695] (w=114) │   86 // the un-truncated one above the notice.  │]8;; +[696] (w=114) │   87 // ---------------------------------------------------------------------------  │]8;; +[697] (w=114) │   88 const MAX_READ_LINES = 40;  │]8;; +[698] (w=114) │   89  │]8;; +[699] (w=114) │   90 class TruncatedReadResult implements Component {  │]8;; +[700] (w=114) │   91 constructor(  │]8;; +[701] (w=114) │   92 private readonly numbered: { num: string; content: string }[],  │]8;; +[702] (w=114) │   93 private readonly maxNumLen: number,  │]8;; +[703] (w=114) │   94 private readonly dimFn: (s: string) => string,  │]8;; +[704] (w=114) │   95 private readonly noticeFn: (s: string) => string,  │]8;; +[705] (w=114) │   96 ) {}  │]8;; +[706] (w=114) │   97  │]8;; +[707] (w=114) │   98 invalidate(): void { /* stateless */ }  │]8;; +[708] (w=114) │   99  │]8;; +[709] (w=114) │  100 render(width: number): string[] {  │]8;; +[710] (w=114) │  101 const total = this.numbered.length;  │]8;; +[711] (w=114) │  102 const visible = Math.min(MAX_READ_LINES, total);  │]8;; +[712] (w=114) │  103 const lines: string[] = [];  │]8;; +[713] (w=114) │  104 for (let i = 0; i < visible; i++) {  │]8;; +[714] (w=114) │  105 const l = this.numbered[i];  │]8;; +[715] (w=114) │  106 lines.push(this.dimFn(l.num.padStart(this.maxNumLen)) + " " + l.content);  │]8;; +[716] (w=114) │  107 }  │]8;; +[717] (w=114) │  108 if (total > visible) {  │]8;; +[718] (w=114) │  109 const hidden = total - visible;  │]8;; +[719] (w=114) │  110 const notice = `… ${hidden} more line${hidden === 1 ? "" : "s"} hidden …`;  │]8;; +[720] (w=114) │  111 const visLen = visibleWidth(notice);  │]8;; +[721] (w=114) │  … 42 more lines hidden …  │]8;; +[722] (w=114) │ │]8;; +[723] (w=114) │ Done. Summary of changes to /home/jonas/dotfiles/pi/.pi/agent/extensions/chat-claude.ts: │]8;; +[724] (w=114) │ │]8;; +[725] (w=114) │ 1. Imports — added Box and type Component to the @mariozechner/pi-tui import. │]8;; +[726] (w=114) │ 2. New constant MAX_READ_LINES = 40 — hard cap on rendered file lines per Read tool result. │]8;; +[727] (w=114) │ 3. New TruncatedReadResult component — a tiny Component that renders the first 40 numbered lines using the │]8;; +[728] (w=114) │ same dim line-number formatting as the shared renderToolResultBox, then appends a single centered notice │]8;; +[729] (w=114) │ line (… N more lines hidden …). Centering is computed from the render-time width, so it tracks terminal │]8;; +[730] (w=114) │ resizes. │]8;; +[731] (w=114) │ 4. New renderToolBlockTruncated() wrapper — calls the shared renderToolBlock, and only for read tool blocks │]8;; +[732] (w=114) │ whose result exceeds 40 lines, swaps out the Box body's Text child with TruncatedReadResult. Errors, short │]8;; +[733] (w=114) │ reads, and every non-read tool pass through unchanged. The original block.result.text is never mutated, so │]8;; +[734] (w=114) │ resumed sessions and re-renders still see the full content. │]8;; +[735] (w=114) │ 5. Single call-site swap in renderTurnInto: renderToolBlock(block, theme) → renderToolBlockTruncated(block, │]8;; +[736] (w=114) │ theme). │]8;; +[737] (w=114) │ │]8;; +[738] (w=114) │ tsc --noEmit on the file produces no errors. │]8;; +[739] (w=114) ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯]8;; +[740] (w=0) ]8;; +[741] (w=114) Resuming session c9492aa2 (1h ago): "@agent/extensions/chat-claude.ts truncate all rea…" as Claude Opus. (2 ]8;; +[742] (w=114) historical turns loaded) ]8;; +[743] (w=0) ]8;; +[744] (w=44) ▌ ◆ CLAUDE CHAT MODE OPUS session:c9492aa2]8;; +[745] (w=86) ▌ Type to chat · /claude haiku|sonnet|opus · /claude-new · /claude-end · /claude-abort]8;; +[746] (w=114) ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────]8;; +[747] (w=114)   ]8;; +[748] (w=114) ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────]8;; +[749] (w=114) ~/dotfiles/pi/.pi ·  S ⣀⣀⣀⣀⣀ 0% |  W ⣄⣀⣀⣀⣀ 3% ⟳ 4d 7h | C ⣀⣀⣀⣀⣀ 0% · minimax/minimax-m2.5:free · ◆ Claud...]8;; diff --git a/pi/.pi/agent/shared/claude-stream.ts b/pi/.pi/agent/shared/claude-stream.ts new file mode 100644 index 0000000..7839594 --- /dev/null +++ b/pi/.pi/agent/shared/claude-stream.ts @@ -0,0 +1,779 @@ +/** + * 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; +} diff --git a/pi/.pi/agent/shared/pi-ask-bridge.ts b/pi/.pi/agent/shared/pi-ask-bridge.ts new file mode 100644 index 0000000..3251d82 --- /dev/null +++ b/pi/.pi/agent/shared/pi-ask-bridge.ts @@ -0,0 +1,201 @@ +/** + * pi-ask-bridge — Unix-domain socket server that pipes ask requests from + * the pi-ask-mcp subprocess (running inside Claude CLI inside chat-claude) + * into pi's native ask UI (askSingleQuestionWithInlineNote / askQuestionsWithTabs). + * + * Architecture: + * + * pi process + * └── chat-claude extension + * ├── AskBridge (here) — listens on $PI_ASK_SOCKET + * └── claude -p ... --mcp-config + * └── pi-ask-mcp/server.js + * ↳ on tools/call ask → connects to $PI_ASK_SOCKET, + * sends question, awaits answer + * + * Lifecycle: start one bridge per chat-claude session; close on exit. + * Concurrency: pi's ui.custom overlay is modal, so asks are serialised + * across all open connections via an internal promise chain. + * + * Wire format (NDJSON, one message per line): + * + * request → { id, type: "ask", questions: [{id, question, options[], multi?, recommended?}, ...] } + * response ← { id, type: "result", results: [{id, selectedOptions[], customInput?}, ...] } + * error ← { id, type: "error", message: "…" } + */ + +import { createServer, type Server as NetServer, type Socket } from "node:net"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent"; +import { askSingleQuestionWithInlineNote } from "../extensions/pi-ask-tool/ask-inline-ui.js"; +import { askQuestionsWithTabs } from "../extensions/pi-ask-tool/ask-tabs-ui.js"; +import type { AskQuestion, AskSelection } from "../extensions/pi-ask-tool/ask-logic.js"; + +// ============================================================================= +// Public API +// ============================================================================= + +export interface AskBridge { + /** Path to the generated --mcp-config JSON, suitable for `claude --mcp-config`. */ + mcpConfigPath: string; + /** Absolute path to the underlying Unix socket (informational). */ + socketPath: string; + /** How many ask requests this bridge has served so far. */ + count(): number; + /** Stop accepting connections, remove socket + temp dir. Idempotent. */ + close(): void; +} + +export interface StartAskBridgeOptions { + /** pi UI context (must come from an interactive session). */ + ui: ExtensionUIContext; + /** + * Absolute path to extensions/pi-ask-mcp/server.js. Auto-derived from + * import.meta.url when omitted (assumes the conventional layout). + */ + mcpServerEntry?: string; + /** + * MCP server name surfaced in the tool prefix. Defaults to "pi", which + * makes the tool name `mcp__pi__ask` in Claude's tool stream. + */ + serverName?: string; + /** Optional notification fired whenever a new ask is served. */ + onAsk?: (totalSoFar: number) => void; +} + +export function startAskBridge(opts: StartAskBridgeOptions): AskBridge { + const dir = mkdtempSync(join(tmpdir(), "pi-ask-")); // 0700 perms + const sock = join(dir, "ask.sock"); + let askCount = 0; + let closed = false; + + const server: NetServer = createServer((conn) => + handleConnection(conn, opts, () => { + askCount += 1; + opts.onAsk?.(askCount); + return askCount; + }), + ); + server.on("error", () => { /* socket disappeared, etc. — bridge is single-tenant, ignore */ }); + server.listen(sock); + + const mcpEntry = opts.mcpServerEntry ?? defaultMcpEntry(); + const serverName = opts.serverName ?? "pi"; + const cfgPath = join(dir, "mcp.json"); + writeFileSync(cfgPath, JSON.stringify({ + mcpServers: { + [serverName]: { + command: "node", + args: [mcpEntry], + env: { PI_ASK_SOCKET: sock }, + }, + }, + })); + + return { + socketPath: sock, + mcpConfigPath: cfgPath, + count: () => askCount, + close: () => { + if (closed) return; + closed = true; + try { server.close(); } catch { /* noop */ } + try { rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } + }, + }; +} + +// ============================================================================= +// Internals +// ============================================================================= + +function defaultMcpEntry(): string { + // shared/pi-ask-bridge.ts → ../extensions/pi-ask-mcp/server.js + const here = dirname(fileURLToPath(import.meta.url)); + return join(here, "..", "extensions", "pi-ask-mcp", "server.js"); +} + +// pi.ui.custom is modal — only one overlay can be on screen at a time. +// Serialise asks across ALL connections via this single promise chain. +let askChain: Promise = Promise.resolve(); + +function handleConnection( + conn: Socket, + opts: StartAskBridgeOptions, + bumpCount: () => number, +) { + let buf = ""; + conn.on("data", (data) => { + buf += data.toString(); + let nl = buf.indexOf("\n"); + while (nl >= 0) { + const line = buf.slice(0, nl); + buf = buf.slice(nl + 1); + handleLine(line, conn, opts, bumpCount); + nl = buf.indexOf("\n"); + } + }); + conn.on("error", () => { /* peer might disappear if Claude is killed mid-flight */ }); +} + +function handleLine( + line: string, + conn: Socket, + opts: StartAskBridgeOptions, + bumpCount: () => number, +) { + if (!line.trim()) return; + let msg: any; + try { msg = JSON.parse(line); } catch { return; } + if (msg.type !== "ask") return; + const id = String(msg.id ?? ""); + const questions = Array.isArray(msg.questions) ? (msg.questions as AskQuestion[]) : []; + + askChain = askChain.then(async () => { + bumpCount(); + try { + const results = await askViaPiUI(opts.ui, questions); + writeReply(conn, { id, type: "result", results }); + } catch (err) { + writeReply(conn, { + id, + type: "error", + message: err instanceof Error ? err.message : String(err), + }); + } + }); +} + +function writeReply(conn: Socket, msg: unknown) { + try { conn.write(JSON.stringify(msg) + "\n"); conn.end(); } catch { /* gone */ } +} + +interface QuestionResult { + id: string; + selectedOptions: string[]; + customInput?: string; +} + +async function askViaPiUI( + ui: ExtensionUIContext, + questions: AskQuestion[], +): Promise { + if (questions.length === 0) return []; + + if (questions.length === 1 && !questions[0].multi) { + const sel: AskSelection = await askSingleQuestionWithInlineNote(ui, questions[0]); + return [toResult(questions[0], sel)]; + } + + const tab = await askQuestionsWithTabs(ui, questions); + return questions.map((q, i) => toResult(q, tab.selections[i] ?? { selectedOptions: [] })); +} + +function toResult(q: AskQuestion, sel: AskSelection): QuestionResult { + const out: QuestionResult = { id: q.id, selectedOptions: [...sel.selectedOptions] }; + if (sel.customInput) out.customInput = sel.customInput; + return out; +} diff --git a/pi/.pi/agent/skills/add-agent/SKILL.md b/pi/.pi/agent/skills/add-agent/SKILL.md new file mode 100644 index 0000000..fc0a171 --- /dev/null +++ b/pi/.pi/agent/skills/add-agent/SKILL.md @@ -0,0 +1,110 @@ +--- +name: add-agent +description: "Add a new Claude agent definition to ~/.claude/agents/ and an accompanying skill to ~/.pi/agent/skills/. Use when creating new specialized agents." +--- + +# Add Agent + +When the user wants to create a new specialized Claude agent: + +## Agent File Convention + +Create a `.md` file in `~/.claude/agents/` with this structure: + +```markdown +--- +name: agent_name +description: One-line description of what the agent does +tools: Read, Bash[, Edit, Write] # include only what's needed +model: sonnet | opus +--- + +You are an [role]. [1-2 sentence description of purpose]. + +Available tools: +- read: Read file contents +- bash: Execute bash commands +- edit: Make surgical edits to files # if applicable +- write: Create or overwrite files # if applicable + +Guidelines: +- Use bash for file operations: prefer `rg` over grep, `fd` over find, glob patterns for batch file matching +- Use read to examine files [before editing] # adapt phrasing +- [Agent-specific guidelines] +- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did +- Be concise in your responses +- Show file paths clearly when working with files +``` + +## Key Rules + +1. **`name`** in frontmatter must match what skills reference as `agent: "name"` +2. **`tools`** — only include tools the agent actually needs. Read-only agents use `Read, Bash` +3. **`model`** — `sonnet` for execution/review, `opus` for strategic/advisory work +4. **Always include** the "output plain text directly" guideline — agents without it tend to use `cat`/`echo` instead of responding directly +5. **Bash guideline** should read: `prefer \`rg\` over grep, \`fd\` over find, glob patterns for batch file matching` +6. **Bash tool description** should be: `Execute bash commands` + +## Skill File Convention + +Create `~/.pi/agent/skills//SKILL.md`: + +```markdown +--- +name: skill-name +description: "What triggers this skill. Use when [condition]." +--- + +# Skill Title + +When [trigger condition]: + +## What to include in the prompt + +1. **[Section 1]** — description +2. **[Section 2]** — description +... + +The `agent_name` agent has `Tool1` and `Tool2` tools only — [what it can/can't do]. + +## How to call + +\``` +ask_claude({ + agent: "agent_name", + question: "Specific instruction for the agent.", + prompt: ` +## Section 1 +[Template] + +## Section 2 +[Template] +` +}) +\``` + +## After the review + +- [What to do with the agent's output] +- [How to summarize for the user] +- [When to loop back or escalate] +``` + +## Skill Key Rules + +1. **Skill `name`** should be kebab-case (e.g., `claude-debug`, not `claude_debug`) +2. **Description** must be in quotes if it contains special characters +3. **`agent:` in `ask_claude()`** must match the agent's `name:` exactly +4. **Include a "What to include" section** — gives the calling agent a template +5. **Include a "How to call" section** — with a concrete `ask_claude()` example +6. **Include an "After the review" section** — what to do with the output + +## Existing Agents (for reference) + +| Agent | Model | Tools | Purpose | +|-------|-------|-------|---------| +| `minimal` | sonnet | Read, Bash, Edit, Write | General coding | +| `code_review` | sonnet | Read, Bash, Edit, Write | Review & fix code | +| `plan_review` | opus | Read, Bash | Review plans | +| `debug` | sonnet | Read, Bash | Trace bugs | +| `oracle` | opus | Read, Bash | Strategic guidance | diff --git a/pi/.pi/agent/skills/ask-claude/SKILL.md b/pi/.pi/agent/skills/ask-claude/SKILL.md new file mode 100644 index 0000000..846291e --- /dev/null +++ b/pi/.pi/agent/skills/ask-claude/SKILL.md @@ -0,0 +1,153 @@ +--- +name: ask-claude +description: "Invoke Claude (any agent or model) for an opinion, review, or analysis. Use when the user asks you to ask a specific Claude about something specific — e.g. trade-offs of an approach, a focused review, or a second opinion." +--- + +# Ask Claude + +Use `ask_claude` to consult Claude with any combination of agent, model, question, and prompt. + +## Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `prompt` | yes | The content for Claude to reason about (context, code, plan, bug report, etc.) | +| `question` | no | What specifically to ask — prepended as a focused review directive | +| `agent` | no | Agent name from `~/.claude/agents/`. See **Available agents** below. | +| `model` | no | Override the model: `"opus"`, `"sonnet"`, `"haiku"` | +| `session_id` | no | Resume a prior conversation (returned in every response) | + +If neither `agent` nor `model` is set, defaults to Claude Sonnet. + +> **Multi-turn:** Every response includes a `session_id`. Pass it back in a subsequent `ask_claude()` call to continue the same conversation with the same agent/model. + +## Available agents + +See `~/.claude/agents/` for the full list. Common agents: + +| Agent | Model | Tools | Use when | +|-------|-------|-------|----------| +| `plan_review` | Opus | Read, Bash | Reviewing plans for correctness, completeness, feasibility, and risk | +| `code_review` | Sonnet | Read, Bash, Edit, Write | Reviewing implementations; can apply fixes directly | +| `debug` | Sonnet | Read, Bash | Tracing bugs and root causes; will NOT apply fixes | +| `oracle` | Opus | Read, Bash | Hard problems, architectural decisions, when you're stuck | + +Pick the agent that matches the task. If unsure, ask the user which agent to use, or use a raw `model=` instead of an agent. + +## Common patterns + +### Plan review +``` +ask_claude({ + agent: "plan_review", + question: "Review for correctness, completeness, feasibility, and risk. Highlight missing steps or unclear requirements.", + prompt: ` +## Project Context +[Key facts from CLAUDE.md] + +## Codebase Exploration +[Modules/files you read] + +## Implementation Plan +[Your full plan, step by step] + +## Open Questions +[Anything uncertain] +` +}) +``` + +### Code review (with fix capability) +``` +ask_claude({ + agent: "code_review", + question: "Review for bugs, architectural issues, style, and correctness. Apply fixes for any issues you find.", + prompt: ` +## Project Conventions +[Relevant context not in CLAUDE.md] + +## What Was Implemented +[Brief description] + +## Plan That Was Followed +[The implementation plan] + +## Files Changed +- path/to/file.ts — [what changed] + +## Code to Review +[Paste key sections OR instruct the agent to read the files above] +` +}) +``` + +### Debugging +``` +ask_claude({ + agent: "debug", + question: "Trace the root cause. Provide specific file paths, line numbers, and the exact code responsible. Suggest a fix but do not apply it.", + prompt: ` +## The Issue +[What's happening vs. what should happen] + +## Error Output +[Any logs, stack traces, or error messages] + +## Relevant Files +- path/to/file.ts — [why it's relevant] + +## What I've Already Tried +[Hypotheses tested, things ruled out] +` +}) +``` + +### Oracle (hard problems) +``` +ask_claude({ + agent: "oracle", + question: "Analyze this problem and provide guidance. Explain your reasoning and recommend the best path forward.", + prompt: ` +## The Problem +[What you're trying to solve and why it's hard] + +## Codebase Context +[Relevant files, modules, and patterns explored] + +## Options Considered +[Approaches evaluated and their trade-offs] + +## What I Need Clarified +[Specific question or decision point] +` +}) +``` + +### Free-form model question (no agent) +``` +ask_claude({ + model: "opus", + question: "What are the trade-offs of this approach vs using a message queue?", + prompt: `We're considering polling a database table every 5 seconds for new jobs instead of a queue...` +}) +``` + +### Continuing a conversation +When Claude returns a `session_id` in its response, pass it back to continue the same conversation: +``` +ask_claude({ + agent: "code_review", + question: "Apply the fixes you identified.", + session_id: "", + prompt: "Please proceed with the fixes." +}) +``` + +## After the response + +- Summarize Claude's key points for the user. +- If Claude raises blockers or important concerns, address them before proceeding. +- For plan reviews: if blockers or missing steps are flagged, update the plan and re-invoke if changes are significant. +- For code reviews: Claude (via `code_review`) may apply fixes directly — summarize what was changed. +- For debugging: apply the fix yourself or delegate to a coding agent. +- For oracle: proceed with the recommended approach, or loop back if more clarification is needed. diff --git a/pi/.pi/agent/skills/implementor/SKILL.md b/pi/.pi/agent/skills/implementor/SKILL.md new file mode 100644 index 0000000..bc1c194 --- /dev/null +++ b/pi/.pi/agent/skills/implementor/SKILL.md @@ -0,0 +1,50 @@ +--- +name: implementor +description: "Delegate implementation of a fix or feature to the implementor agent. Use when you have a clear plan, bug fix, or feature to implement and want a focused agent to handle the full coding + build-verify cycle." +--- + +# Implementor + +When you need to implement a fix, feature, or refactoring with full build verification: + +## What to include in the prompt + +1. **Goal** — what needs to be built or fixed, in 1-2 sentences +2. **Context** — relevant files, functions, or modules (include the actual code or file paths) +3. **Plan** — step-by-step description of the changes to make (if you have one) +4. **Constraints** — any project conventions, patterns to follow, or things to avoid +5. **Verification** — how to confirm the implementation is correct (build command, test names, etc.) + +The `implementor` agent has `Read`, `Bash`, `Edit`, and `Write` tools only — it can read code, run commands, and make changes. It cannot use semantic search or web search. + +## How to call + +``` +ask_claude({ + agent: "implementor", + question: "Implement [brief description of the fix or feature].", + prompt: ` +## Goal +[What needs to be done] + +## Context +[Relevant files and code snippets] + +## Plan +[Step-by-step changes, or "investigate and determine the best approach"] + +## Constraints +[Project conventions, patterns to follow, things to avoid] + +## Verification +[Build command, tests to run, how to confirm correctness] +` +}) +``` + +## After the review + +- Check whether the agent reported successful build/verification +- If it failed, either re-invoke with the error details or fix the remaining issues directly +- Summarize for the user: what files changed, what was verified, and any remaining concerns +- For complex implementations, consider running `diagnostics` sub-agent or relevant tests afterward as a second pass diff --git a/pi/.pi/agent/themes/wezterm-sync-ba8a76f5.json b/pi/.pi/agent/themes/wezterm-sync-9a35138e.json similarity index 75% rename from pi/.pi/agent/themes/wezterm-sync-ba8a76f5.json rename to pi/.pi/agent/themes/wezterm-sync-9a35138e.json index 5b685f1..6e6e989 100644 --- a/pi/.pi/agent/themes/wezterm-sync-ba8a76f5.json +++ b/pi/.pi/agent/themes/wezterm-sync-9a35138e.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-ba8a76f5", + "name": "wezterm-sync-9a35138e", "vars": { - "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" + "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" }, "colors": { "accent": "accent", @@ -74,8 +74,8 @@ "bashMode": "success" }, "export": { - "pageBg": "#141c2b", - "cardBg": "#1c2433", - "infoBg": "#353839" + "pageBg": "#fffcf5", + "cardBg": "#faf4ed", + "infoBg": "#f2e7d2" } } \ No newline at end of file diff --git a/pi/.pi/settings.json b/pi/.pi/settings.json deleted file mode 100644 index ee6937a..0000000 --- a/pi/.pi/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "hide_thinking_block": true -}