BIG pi update with claude chat

This commit is contained in:
Jonas H
2026-04-24 14:22:59 +02:00
parent fbb00a49ba
commit 248667468c
24 changed files with 4225 additions and 1112 deletions

View File

@@ -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=<name> 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=<name>) 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);
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 <name> — 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 <name>' 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 <name>]",
"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",
);
}
},
});
}

View File

@@ -4,11 +4,10 @@
* Replaces the built-in pi footer with a single clean line that assembles * Replaces the built-in pi footer with a single clean line that assembles
* status from all other extensions: * 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: * Status sources:
* "claude-account" — set by claude-account-switch.ts → just the icon * usage:update event — set by usage-bars extension → S/W bars (Claude usage, always shown)
* "usage-bars" — set by usage-bars extension → S/W bars (pass-through)
* ctx.getContextUsage() → C bar (rendered here) * ctx.getContextUsage() → C bar (rendered here)
* ctx.model → model short name * ctx.model → model short name
* "lsp" — set by lsp-pi extension → strip "LSP " prefix * "lsp" — set by lsp-pi extension → strip "LSP " prefix
@@ -77,11 +76,6 @@ function formatDurationMs(ms: number): string {
return "<1m"; 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) { export default function (pi: ExtensionAPI) {
let ctx: any = null; let ctx: any = null;
let tuiRef: any = null; let tuiRef: any = null;
@@ -112,18 +106,7 @@ export default function (pi: ExtensionAPI) {
: cwd; : cwd;
parts.push(theme.fg("dim", dir)); parts.push(theme.fg("dim", dir));
// 2. Account icon // 2. S / W usage bars + C bar — joined as one |-separated block
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 usageRaw = statuses.get("usage-bars");
const contextUsage = ctx?.getContextUsage?.(); 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 session = Math.max(0, Math.min(100, Math.round(usageSession)));
const weekly = Math.max(0, Math.min(100, Math.round(usageWeekly))); 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 sPart = theme.fg("muted", "\uF4F5 S ") + renderBrailleBar(theme, session) + " " + theme.fg("dim", `${session}%`);
let wPart = theme.fg("muted", "W ") + renderBrailleBar(theme, weekly) + " " + theme.fg("dim", `${weekly}%`); let wPart = theme.fg("muted", "\uF4F5 W ") + renderBrailleBar(theme, weekly) + " " + theme.fg("dim", `${weekly}%`);
if (sessionResetsAt !== null) { if (sessionResetsAt !== null) {
const msLeft = sessionResetsAt - Date.now(); const msLeft = sessionResetsAt - Date.now();
@@ -156,34 +139,51 @@ export default function (pi: ExtensionAPI) {
if (contextUsage && contextUsage.percent !== null) { if (contextUsage && contextUsage.percent !== null) {
const pct = Math.round(contextUsage.percent); 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 = const cBar =
theme.fg("muted", "C ") + theme.fg(cColor, cLabel + " ") +
renderBrailleBar(theme, pct) + renderBrailleBar(theme, pct) +
" " + " " +
theme.fg("dim", `${pct}%`); theme.fg(pct >= 70 && isChatActive ? "warning" : "dim", `${pct}%`);
block = block ? block + pipeSep + cBar : cBar; block = block ? block + pipeSep + cBar : cBar;
} }
if (block) parts.push(block); if (block) parts.push(block);
} }
// 4. Model short name // 3. Model short name
const modelId = ctx?.model?.id; const modelId = ctx?.model?.id;
if (modelId) parts.push(theme.fg("dim", getModelShortName(modelId))); 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"); const lspRaw = statuses.get("lsp");
if (lspRaw) { if (lspRaw) {
const clean = stripAnsi(lspRaw).trim().replace(/^LSP\s*[•·]?\s*/i, "").trim(); const clean = stripAnsi(lspRaw).trim().replace(/^LSP\s*[•·]?\s*/i, "").trim();
if (clean) parts.push(theme.fg("dim", clean)); if (clean) parts.push(theme.fg("dim", clean));
} }
// 6. MCP — strip " servers" suffix // 5. MCP — strip " servers" suffix
const mcpRaw = statuses.get("mcp"); const mcpRaw = statuses.get("mcp");
if (mcpRaw) { if (mcpRaw) {
const clean = stripAnsi(mcpRaw).trim().replace(/\s*servers?.*$/, "").trim(); const clean = stripAnsi(mcpRaw).trim().replace(/\s*servers?.*$/, "").trim();
if (clean) parts.push(theme.fg("dim", clean)); 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); return parts.join(sep);
} }
@@ -209,7 +209,7 @@ export default function (pi: ExtensionAPI) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Event handlers // Event handlers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pi.on("session_start", async (_event, _ctx) => { pi.on("session_start", (_event, _ctx) => {
ctx = _ctx; ctx = _ctx;
installFooter(_ctx); installFooter(_ctx);
}); });
@@ -230,12 +230,7 @@ export default function (pi: ExtensionAPI) {
if (tuiRef) tuiRef.requestRender(); 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 // 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. // countdown directly rather than parsing the ANSI status string from usage-bars.
pi.events.on("usage:update", (data: any) => { pi.events.on("usage:update", (data: any) => {

View File

@@ -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<string, string> =
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();
});
}

View File

@@ -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.
}

View File

@@ -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=<sock>`.
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 <generated.json> --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" }
```

View File

@@ -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."
}

View File

@@ -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 <path>`. 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 <<<input` (or any case where stdin closes mid-request) would
// race the async tools/call handler against rl 'close' → process.exit, and
// the reply would silently disappear.
let inflight = 0;
let stdinClosed = false;
function drainAndExit(code = 0) {
if (inflight === 0) process.exit(code);
}
async function handleOne(msg) {
// Notifications carry no id and expect no response.
if (msg.id === undefined || msg.id === null) {
if (msg.method === "exit") drainAndExit(0);
return; // notifications/initialized, notifications/cancelled, etc.
}
inflight += 1;
try {
const reply = await handleRequest(msg);
if (reply) send(reply);
} finally {
inflight -= 1;
if (stdinClosed) drainAndExit(0);
}
}
rl.on("line", (line) => {
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);

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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<void> {
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<string, string> = {
"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<TokenResponse> {
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<boolean> => {
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<OAuthCredentials> {
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<OAuthCredentials> {
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));
},
},
});
}

View File

@@ -55,6 +55,8 @@ export interface UsageData {
/** Unix ms timestamp of when the session window resets (from the raw API response). */ /** Unix ms timestamp of when the session window resets (from the raw API response). */
sessionResetsAt?: number; sessionResetsAt?: number;
weeklyResetsIn?: string; weeklyResetsIn?: string;
/** Unix ms timestamp of when the weekly window resets. */
weeklyResetsAt?: number;
extraSpend?: number; extraSpend?: number;
extraLimit?: number; extraLimit?: number;
error?: string; error?: string;
@@ -321,13 +323,34 @@ export function formatResetsAt(isoDate: string, nowMs = Date.now()): string {
return formatDuration(diffSeconds); return formatDuration(diffSeconds);
} }
const CLAUDE_CREDENTIALS_FILE = path.join(os.homedir(), ".claude", ".credentials.json");
export function readAuth(authFile = DEFAULT_AUTH_FILE): AuthData | null { export function readAuth(authFile = DEFAULT_AUTH_FILE): AuthData | null {
let result: AuthData | null = null;
// Read pi auth.json for non-Claude providers
try { try {
const parsed = JSON.parse(fs.readFileSync(authFile, "utf-8")); const parsed = JSON.parse(fs.readFileSync(authFile, "utf-8"));
return asObject(parsed) as AuthData; result = asObject(parsed) as AuthData;
} catch { } 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 { 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 const sessionResetsAt = data?.five_hour?.resets_at
? new Date(data.five_hour.resets_at).getTime() ? new Date(data.five_hour.resets_at).getTime()
: undefined; : undefined;
const weeklyResetsAt = data?.seven_day?.resets_at
? new Date(data.seven_day.resets_at).getTime()
: undefined;
const usage: UsageData = { const usage: UsageData = {
session: readPercentCandidate(data?.five_hour?.utilization) ?? 0, 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, sessionResetsIn: data?.five_hour?.resets_at ? formatResetsAt(data.five_hour.resets_at) : undefined,
sessionResetsAt: Number.isFinite(sessionResetsAt) ? sessionResetsAt : undefined, sessionResetsAt: Number.isFinite(sessionResetsAt) ? sessionResetsAt : undefined,
weeklyResetsIn: data?.seven_day?.resets_at ? formatResetsAt(data.seven_day.resets_at) : 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) { if (data?.extra_usage?.is_enabled) {

View File

@@ -306,14 +306,27 @@ export default function (pi: ExtensionAPI) {
const active = state.activeProvider; const active = state.activeProvider;
const data = active ? state[active] : null; const data = active ? state[active] : null;
// Always emit event for other extensions (e.g. footer-display) // Always emit Claude usage for other extensions (e.g. footer-display)
if (data && !data.error) { // 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", { pi.events.emit("usage:update", {
session: data.session, session: data.session,
weekly: data.weekly, weekly: data.weekly,
sessionResetsIn: data.sessionResetsIn, sessionResetsIn: data.sessionResetsIn,
sessionResetsAt: data.sessionResetsAt, sessionResetsAt: data.sessionResetsAt,
weeklyResetsIn: data.weeklyResetsIn, weeklyResetsIn: data.weeklyResetsIn,
weeklyResetsAt: data.weeklyResetsAt,
}); });
} }
@@ -372,6 +385,48 @@ export default function (pi: ExtensionAPI) {
const auth = readAuth(); const auth = readAuth();
const active = state.activeProvider; 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) { if (!canShowForProvider(active, auth, endpoints) || !auth || !active) {
state.lastPoll = Date.now(); updateStatus(); return; state.lastPoll = Date.now(); updateStatus(); return;
} }
@@ -478,7 +533,9 @@ export default function (pi: ExtensionAPI) {
await Promise.race([runPollInner(options), timeout]); 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 = {}) { async function poll(options: PollOptions = {}) {
// If a previous poll has been running longer than POLL_TIMEOUT_MS, abandon it // 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 }); 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 // Listen for OpenCode Go spend events from other extensions
pi.events.on("opencode-go:spend", async (amount: number) => { pi.events.on("opencode-go:spend", async (amount: number) => {

View File

@@ -31,7 +31,7 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { createHash } from "node:crypto"; 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 { join } from "node:path";
import { homedir } from "node:os"; import { homedir } from "node:os";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
@@ -101,6 +101,7 @@ local mock_wezterm = {
log_warn = function() end, log_warn = function() end,
log_error = function() end, log_error = function() end,
on = function() end, on = function() end,
add_to_config_reload_watch_list = function() end,
action = setmetatable({}, { action = setmetatable({}, {
__index = function(_, k) __index = function(_, k)
return function(...) return { action = k, args = {...} } end return function(...) return { action = k, args = {...} } end
@@ -397,48 +398,64 @@ function cleanupOldThemes(themesDir: string, keepFile: string): void {
} }
} }
export default function (pi: ExtensionAPI) { function syncTheme(ctx: any) {
pi.on("session_start", async (_event, ctx) => { const configDir = findConfigDir();
const configDir = findConfigDir(); if (!configDir) return;
if (!configDir) {
return;
}
const lua = findLua(); const lua = findLua();
if (!lua) { if (!lua) return;
return;
}
const colors = getWeztermColors(configDir, lua); const colors = getWeztermColors(configDir, lua);
if (!colors) { if (!colors) return;
return;
}
const themesDir = join(homedir(), ".pi", "agent", "themes"); const themesDir = join(homedir(), ".pi", "agent", "themes");
if (!existsSync(themesDir)) { if (!existsSync(themesDir)) {
mkdirSync(themesDir, { recursive: true }); mkdirSync(themesDir, { recursive: true });
} }
const hash = computeThemeHash(colors); const hash = computeThemeHash(colors);
const themeName = `wezterm-sync-${hash}`; const themeName = `wezterm-sync-${hash}`;
const themeFile = `${themeName}.json`; const themeFile = `${themeName}.json`;
const themePath = join(themesDir, themeFile); const themePath = join(themesDir, themeFile);
// Skip if already on the correct synced theme (avoids repaint) // Skip if already on the correct synced theme (avoids repaint)
if (ctx.ui.theme.name === themeName) { if (ctx.ui.theme.name === themeName) {
return; return;
} }
const themeJson = generatePiTheme(colors, themeName); const themeJson = generatePiTheme(colors, themeName);
writeFileSync(themePath, JSON.stringify(themeJson, null, 2)); writeFileSync(themePath, JSON.stringify(themeJson, null, 2));
// Remove old generated themes // Remove old generated themes
cleanupOldThemes(themesDir, themeFile); cleanupOldThemes(themesDir, themeFile);
// Set by name so pi loads from the file we just wrote // Set by name so pi loads from the file we just wrote
const result = ctx.ui.setTheme(themeName); const result = ctx.ui.setTheme(themeName);
if (!result.success) { if (!result.success) {
ctx.ui.notify(`WezTerm theme sync failed: ${result.error}`, "error"); 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
}
} }

View File

@@ -1,5 +1,25 @@
{ {
"providers": { "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": { "opencode-go": {
"baseUrl": "https://opencode.ai/zen/go/v1", "baseUrl": "https://opencode.ai/zen/go/v1",
"api": "openai-completions", "api": "openai-completions",

755
pi/.pi/agent/pi-crash.log Normal file
View File

@@ -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) │  <persisted-output>  │]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) │  </persisted-output>  │]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<StreamBlock, { type: "tool" }>, 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;;

View File

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

View File

@@ -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 <generated>
* └── 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<unknown> = 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<QuestionResult[]> {
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;
}

View File

@@ -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-name>/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 |

View File

@@ -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: "<session_id from prior response>",
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.

View File

@@ -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

View File

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

View File

@@ -1,3 +0,0 @@
{
"hide_thinking_block": true
}