From fbfc3c33caba41088ff38de6c3877be035d67ea0 Mon Sep 17 00:00:00 2001 From: "Thomas G. Lopes" Date: Sat, 21 Feb 2026 12:56:20 +0000 Subject: [PATCH] extensions! --- .../agent/extensions/confirm-destructive.ts | 59 ++ pi/files/agent/extensions/custom-footer.ts | 64 ++ .../custom-provider-anthropic/.gitignore | 1 + .../custom-provider-anthropic/index.ts | 604 ++++++++++++++++++ .../package-lock.json | 24 + .../custom-provider-anthropic/package.json | 19 + pi/files/agent/extensions/dirty-repo-guard.ts | 56 ++ pi/files/agent/extensions/git-checkpoint.ts | 53 ++ pi/files/agent/extensions/handoff.ts | 150 +++++ pi/files/agent/extensions/modal-editor.ts | 85 +++ pi/files/agent/extensions/permission-gate.ts | 34 + pi/files/agent/extensions/protected-paths.ts | 30 + pi/files/agent/extensions/question.ts | 264 ++++++++ pi/files/agent/extensions/questionnaire.ts | 427 +++++++++++++ pi/files/agent/extensions/session-name.ts | 27 + pi/files/agent/extensions/status-line.ts | 40 ++ pi/files/agent/extensions/titlebar-spinner.ts | 58 ++ pi/files/agent/extensions/tools-list.ts | 18 - pi/files/agent/extensions/tools.ts | 146 +++++ pi/files/agent/extensions/truncated-tool.ts | 192 ++++++ pi/files/agent/settings.json | 2 +- 21 files changed, 2334 insertions(+), 19 deletions(-) create mode 100644 pi/files/agent/extensions/confirm-destructive.ts create mode 100644 pi/files/agent/extensions/custom-footer.ts create mode 100644 pi/files/agent/extensions/custom-provider-anthropic/.gitignore create mode 100644 pi/files/agent/extensions/custom-provider-anthropic/index.ts create mode 100644 pi/files/agent/extensions/custom-provider-anthropic/package-lock.json create mode 100644 pi/files/agent/extensions/custom-provider-anthropic/package.json create mode 100644 pi/files/agent/extensions/dirty-repo-guard.ts create mode 100644 pi/files/agent/extensions/git-checkpoint.ts create mode 100644 pi/files/agent/extensions/handoff.ts create mode 100644 pi/files/agent/extensions/modal-editor.ts create mode 100644 pi/files/agent/extensions/permission-gate.ts create mode 100644 pi/files/agent/extensions/protected-paths.ts create mode 100644 pi/files/agent/extensions/question.ts create mode 100644 pi/files/agent/extensions/questionnaire.ts create mode 100644 pi/files/agent/extensions/session-name.ts create mode 100644 pi/files/agent/extensions/status-line.ts create mode 100644 pi/files/agent/extensions/titlebar-spinner.ts delete mode 100644 pi/files/agent/extensions/tools-list.ts create mode 100644 pi/files/agent/extensions/tools.ts create mode 100644 pi/files/agent/extensions/truncated-tool.ts diff --git a/pi/files/agent/extensions/confirm-destructive.ts b/pi/files/agent/extensions/confirm-destructive.ts new file mode 100644 index 0000000..7a32df8 --- /dev/null +++ b/pi/files/agent/extensions/confirm-destructive.ts @@ -0,0 +1,59 @@ +/** + * Confirm Destructive Actions Extension + * + * Prompts for confirmation before destructive session actions (clear, switch, branch). + * Demonstrates how to cancel session events using the before_* events. + */ + +import type { ExtensionAPI, SessionBeforeSwitchEvent, SessionMessageEntry } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + pi.on("session_before_switch", async (event: SessionBeforeSwitchEvent, ctx) => { + if (!ctx.hasUI) return; + + if (event.reason === "new") { + const confirmed = await ctx.ui.confirm( + "Clear session?", + "This will delete all messages in the current session.", + ); + + if (!confirmed) { + ctx.ui.notify("Clear cancelled", "info"); + return { cancel: true }; + } + return; + } + + // reason === "resume" - check if there are unsaved changes (messages since last assistant response) + const entries = ctx.sessionManager.getEntries(); + const hasUnsavedWork = entries.some( + (e): e is SessionMessageEntry => e.type === "message" && e.message.role === "user", + ); + + if (hasUnsavedWork) { + const confirmed = await ctx.ui.confirm( + "Switch session?", + "You have messages in the current session. Switch anyway?", + ); + + if (!confirmed) { + ctx.ui.notify("Switch cancelled", "info"); + return { cancel: true }; + } + } + }); + + pi.on("session_before_fork", async (event, ctx) => { + if (!ctx.hasUI) return; + + const choice = await ctx.ui.select(`Fork from entry ${event.entryId.slice(0, 8)}?`, [ + "Yes, create fork", + "No, stay in current session", + ]); + + if (choice !== "Yes, create fork") { + ctx.ui.notify("Fork cancelled", "info"); + return { cancel: true }; + } + }); +} diff --git a/pi/files/agent/extensions/custom-footer.ts b/pi/files/agent/extensions/custom-footer.ts new file mode 100644 index 0000000..f35853d --- /dev/null +++ b/pi/files/agent/extensions/custom-footer.ts @@ -0,0 +1,64 @@ +/** + * Custom Footer Extension - demonstrates ctx.ui.setFooter() + * + * footerData exposes data not otherwise accessible: + * - getGitBranch(): current git branch + * - getExtensionStatuses(): texts from ctx.ui.setStatus() + * + * Token stats come from ctx.sessionManager/ctx.model (already accessible). + */ + +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; + +export default function (pi: ExtensionAPI) { + let enabled = false; + + pi.registerCommand("footer", { + description: "Toggle custom footer", + handler: async (_args, ctx) => { + enabled = !enabled; + + if (enabled) { + ctx.ui.setFooter((tui, theme, footerData) => { + const unsub = footerData.onBranchChange(() => tui.requestRender()); + + return { + dispose: unsub, + invalidate() {}, + render(width: number): string[] { + // Compute tokens from ctx (already accessible to extensions) + let input = 0, + output = 0, + cost = 0; + for (const e of ctx.sessionManager.getBranch()) { + if (e.type === "message" && e.message.role === "assistant") { + const m = e.message as AssistantMessage; + input += m.usage.input; + output += m.usage.output; + cost += m.usage.cost.total; + } + } + + // Get git branch (not otherwise accessible) + const branch = footerData.getGitBranch(); + const fmt = (n: number) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`); + + const left = theme.fg("dim", `↑${fmt(input)} ↓${fmt(output)} $${cost.toFixed(3)}`); + const branchStr = branch ? ` (${branch})` : ""; + const right = theme.fg("dim", `${ctx.model?.id || "no-model"}${branchStr}`); + + const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right))); + return [truncateToWidth(left + pad + right, width)]; + }, + }; + }); + ctx.ui.notify("Custom footer enabled", "info"); + } else { + ctx.ui.setFooter(undefined); + ctx.ui.notify("Default footer restored", "info"); + } + }, + }); +} diff --git a/pi/files/agent/extensions/custom-provider-anthropic/.gitignore b/pi/files/agent/extensions/custom-provider-anthropic/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/pi/files/agent/extensions/custom-provider-anthropic/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/pi/files/agent/extensions/custom-provider-anthropic/index.ts b/pi/files/agent/extensions/custom-provider-anthropic/index.ts new file mode 100644 index 0000000..c122732 --- /dev/null +++ b/pi/files/agent/extensions/custom-provider-anthropic/index.ts @@ -0,0 +1,604 @@ +/** + * Custom Provider Example + * + * Demonstrates registering a custom provider with: + * - Custom API identifier ("custom-anthropic-api") + * - Custom streamSimple implementation + * - OAuth support for /login + * - API key support via environment variable + * - Two model definitions + * + * Usage: + * # First install dependencies + * cd packages/coding-agent/examples/extensions/custom-provider && npm install + * + * # With OAuth (run /login custom-anthropic first) + * pi -e ./packages/coding-agent/examples/extensions/custom-provider + * + * # With API key + * CUSTOM_ANTHROPIC_API_KEY=sk-ant-... pi -e ./packages/coding-agent/examples/extensions/custom-provider + * + * Then use /model to select custom-anthropic/claude-sonnet-4-5 + */ + +import Anthropic from "@anthropic-ai/sdk"; +import type { ContentBlockParam, MessageCreateParamsStreaming } from "@anthropic-ai/sdk/resources/messages.js"; +import { + type Api, + type AssistantMessage, + type AssistantMessageEventStream, + type Context, + calculateCost, + createAssistantMessageEventStream, + type ImageContent, + type Message, + type Model, + type OAuthCredentials, + type OAuthLoginCallbacks, + type SimpleStreamOptions, + type StopReason, + type TextContent, + type ThinkingContent, + type Tool, + type ToolCall, + type ToolResultMessage, +} from "@mariozechner/pi-ai"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +// ============================================================================= +// OAuth Implementation (copied from packages/ai/src/utils/oauth/anthropic.ts) +// ============================================================================= + +const decode = (s: string) => atob(s); +const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl"); +const AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; +const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"; +const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"; +const SCOPES = "org:create_api_key user:profile user:inference"; + +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 }; +} + +async function loginAnthropic(callbacks: OAuthLoginCallbacks): Promise { + const { verifier, challenge } = await generatePKCE(); + + const authParams = new URLSearchParams({ + code: "true", + client_id: CLIENT_ID, + response_type: "code", + redirect_uri: REDIRECT_URI, + scope: SCOPES, + code_challenge: challenge, + code_challenge_method: "S256", + state: verifier, + }); + + callbacks.onAuth({ url: `${AUTHORIZE_URL}?${authParams.toString()}` }); + + const authCode = await callbacks.onPrompt({ message: "Paste the authorization code:" }); + const [code, state] = authCode.split("#"); + + const tokenResponse = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + code, + state, + redirect_uri: REDIRECT_URI, + code_verifier: verifier, + }), + }); + + if (!tokenResponse.ok) { + throw new Error(`Token exchange failed: ${await tokenResponse.text()}`); + } + + const data = (await tokenResponse.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + }; + + return { + refresh: data.refresh_token, + access: data.access_token, + expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, + }; +} + +async function refreshAnthropicToken(credentials: OAuthCredentials): Promise { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "refresh_token", + client_id: CLIENT_ID, + refresh_token: credentials.refresh, + }), + }); + + if (!response.ok) { + throw new Error(`Token refresh failed: ${await response.text()}`); + } + + const data = (await response.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + }; + + return { + refresh: data.refresh_token, + access: data.access_token, + expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, + }; +} + +// ============================================================================= +// Streaming Implementation (simplified from packages/ai/src/providers/anthropic.ts) +// ============================================================================= + +// Claude Code tool names for OAuth stealth mode +const claudeCodeTools = [ + "Read", + "Write", + "Edit", + "Bash", + "Grep", + "Glob", + "AskUserQuestion", + "TodoWrite", + "WebFetch", + "WebSearch", +]; +const ccToolLookup = new Map(claudeCodeTools.map((t) => [t.toLowerCase(), t])); +const toClaudeCodeName = (name: string) => ccToolLookup.get(name.toLowerCase()) ?? name; +const fromClaudeCodeName = (name: string, tools?: Tool[]) => { + const lowerName = name.toLowerCase(); + const matched = tools?.find((t) => t.name.toLowerCase() === lowerName); + return matched?.name ?? name; +}; + +function isOAuthToken(apiKey: string): boolean { + return apiKey.includes("sk-ant-oat"); +} + +function sanitizeSurrogates(text: string): string { + return text.replace(/[\uD800-\uDFFF]/g, "\uFFFD"); +} + +function convertContentBlocks( + content: (TextContent | ImageContent)[], +): string | Array<{ type: "text"; text: string } | { type: "image"; source: any }> { + const hasImages = content.some((c) => c.type === "image"); + if (!hasImages) { + return sanitizeSurrogates(content.map((c) => (c as TextContent).text).join("\n")); + } + + const blocks = content.map((block) => { + if (block.type === "text") { + return { type: "text" as const, text: sanitizeSurrogates(block.text) }; + } + return { + type: "image" as const, + source: { + type: "base64" as const, + media_type: block.mimeType, + data: block.data, + }, + }; + }); + + if (!blocks.some((b) => b.type === "text")) { + blocks.unshift({ type: "text" as const, text: "(see attached image)" }); + } + + return blocks; +} + +function convertMessages(messages: Message[], isOAuth: boolean, _tools?: Tool[]): any[] { + const params: any[] = []; + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + + if (msg.role === "user") { + if (typeof msg.content === "string") { + if (msg.content.trim()) { + params.push({ role: "user", content: sanitizeSurrogates(msg.content) }); + } + } else { + const blocks: ContentBlockParam[] = msg.content.map((item) => + item.type === "text" + ? { type: "text" as const, text: sanitizeSurrogates(item.text) } + : { + type: "image" as const, + source: { type: "base64" as const, media_type: item.mimeType as any, data: item.data }, + }, + ); + if (blocks.length > 0) { + params.push({ role: "user", content: blocks }); + } + } + } else if (msg.role === "assistant") { + const blocks: ContentBlockParam[] = []; + for (const block of msg.content) { + if (block.type === "text" && block.text.trim()) { + blocks.push({ type: "text", text: sanitizeSurrogates(block.text) }); + } else if (block.type === "thinking" && block.thinking.trim()) { + if ((block as ThinkingContent).thinkingSignature) { + blocks.push({ + type: "thinking" as any, + thinking: sanitizeSurrogates(block.thinking), + signature: (block as ThinkingContent).thinkingSignature!, + }); + } else { + blocks.push({ type: "text", text: sanitizeSurrogates(block.thinking) }); + } + } else if (block.type === "toolCall") { + blocks.push({ + type: "tool_use", + id: block.id, + name: isOAuth ? toClaudeCodeName(block.name) : block.name, + input: block.arguments, + }); + } + } + if (blocks.length > 0) { + params.push({ role: "assistant", content: blocks }); + } + } else if (msg.role === "toolResult") { + const toolResults: any[] = []; + toolResults.push({ + type: "tool_result", + tool_use_id: msg.toolCallId, + content: convertContentBlocks(msg.content), + is_error: msg.isError, + }); + + let j = i + 1; + while (j < messages.length && messages[j].role === "toolResult") { + const nextMsg = messages[j] as ToolResultMessage; + toolResults.push({ + type: "tool_result", + tool_use_id: nextMsg.toolCallId, + content: convertContentBlocks(nextMsg.content), + is_error: nextMsg.isError, + }); + j++; + } + i = j - 1; + params.push({ role: "user", content: toolResults }); + } + } + + // Add cache control to last user message + if (params.length > 0) { + const last = params[params.length - 1]; + if (last.role === "user" && Array.isArray(last.content)) { + const lastBlock = last.content[last.content.length - 1]; + if (lastBlock) { + lastBlock.cache_control = { type: "ephemeral" }; + } + } + } + + return params; +} + +function convertTools(tools: Tool[], isOAuth: boolean): any[] { + return tools.map((tool) => ({ + name: isOAuth ? toClaudeCodeName(tool.name) : tool.name, + description: tool.description, + input_schema: { + type: "object", + properties: (tool.parameters as any).properties || {}, + required: (tool.parameters as any).required || [], + }, + })); +} + +function mapStopReason(reason: string): StopReason { + switch (reason) { + case "end_turn": + case "pause_turn": + case "stop_sequence": + return "stop"; + case "max_tokens": + return "length"; + case "tool_use": + return "toolUse"; + default: + return "error"; + } +} + +function streamCustomAnthropic( + model: Model, + context: Context, + options?: SimpleStreamOptions, +): AssistantMessageEventStream { + const stream = createAssistantMessageEventStream(); + + (async () => { + const output: AssistantMessage = { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + + try { + const apiKey = options?.apiKey ?? ""; + const isOAuth = isOAuthToken(apiKey); + + // Configure client based on auth type + const betaFeatures = ["fine-grained-tool-streaming-2025-05-14", "interleaved-thinking-2025-05-14"]; + const clientOptions: any = { + baseURL: model.baseUrl, + dangerouslyAllowBrowser: true, + }; + + if (isOAuth) { + clientOptions.apiKey = null; + clientOptions.authToken = apiKey; + clientOptions.defaultHeaders = { + accept: "application/json", + "anthropic-dangerous-direct-browser-access": "true", + "anthropic-beta": `claude-code-20250219,oauth-2025-04-20,${betaFeatures.join(",")}`, + "user-agent": "claude-cli/2.1.2 (external, cli)", + "x-app": "cli", + }; + } else { + clientOptions.apiKey = apiKey; + clientOptions.defaultHeaders = { + accept: "application/json", + "anthropic-dangerous-direct-browser-access": "true", + "anthropic-beta": betaFeatures.join(","), + }; + } + + const client = new Anthropic(clientOptions); + + // Build request params + const params: MessageCreateParamsStreaming = { + model: model.id, + messages: convertMessages(context.messages, isOAuth, context.tools), + max_tokens: options?.maxTokens || Math.floor(model.maxTokens / 3), + stream: true, + }; + + // System prompt with Claude Code identity for OAuth + if (isOAuth) { + params.system = [ + { + type: "text", + text: "You are Claude Code, Anthropic's official CLI for Claude.", + cache_control: { type: "ephemeral" }, + }, + ]; + if (context.systemPrompt) { + params.system.push({ + type: "text", + text: sanitizeSurrogates(context.systemPrompt), + cache_control: { type: "ephemeral" }, + }); + } + } else if (context.systemPrompt) { + params.system = [ + { + type: "text", + text: sanitizeSurrogates(context.systemPrompt), + cache_control: { type: "ephemeral" }, + }, + ]; + } + + if (context.tools) { + params.tools = convertTools(context.tools, isOAuth); + } + + // Handle thinking/reasoning + if (options?.reasoning && model.reasoning) { + const defaultBudgets: Record = { + minimal: 1024, + low: 4096, + medium: 10240, + high: 20480, + }; + const customBudget = options.thinkingBudgets?.[options.reasoning as keyof typeof options.thinkingBudgets]; + params.thinking = { + type: "enabled", + budget_tokens: customBudget ?? defaultBudgets[options.reasoning] ?? 10240, + }; + } + + const anthropicStream = client.messages.stream({ ...params }, { signal: options?.signal }); + stream.push({ type: "start", partial: output }); + + type Block = (ThinkingContent | TextContent | (ToolCall & { partialJson: string })) & { index: number }; + const blocks = output.content as Block[]; + + for await (const event of anthropicStream) { + if (event.type === "message_start") { + output.usage.input = event.message.usage.input_tokens || 0; + output.usage.output = event.message.usage.output_tokens || 0; + output.usage.cacheRead = (event.message.usage as any).cache_read_input_tokens || 0; + output.usage.cacheWrite = (event.message.usage as any).cache_creation_input_tokens || 0; + output.usage.totalTokens = + output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; + calculateCost(model, output.usage); + } else if (event.type === "content_block_start") { + if (event.content_block.type === "text") { + output.content.push({ type: "text", text: "", index: event.index } as any); + stream.push({ type: "text_start", contentIndex: output.content.length - 1, partial: output }); + } else if (event.content_block.type === "thinking") { + output.content.push({ + type: "thinking", + thinking: "", + thinkingSignature: "", + index: event.index, + } as any); + stream.push({ type: "thinking_start", contentIndex: output.content.length - 1, partial: output }); + } else if (event.content_block.type === "tool_use") { + output.content.push({ + type: "toolCall", + id: event.content_block.id, + name: isOAuth + ? fromClaudeCodeName(event.content_block.name, context.tools) + : event.content_block.name, + arguments: {}, + partialJson: "", + index: event.index, + } as any); + stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output }); + } + } else if (event.type === "content_block_delta") { + const index = blocks.findIndex((b) => b.index === event.index); + const block = blocks[index]; + if (!block) continue; + + if (event.delta.type === "text_delta" && block.type === "text") { + block.text += event.delta.text; + stream.push({ type: "text_delta", contentIndex: index, delta: event.delta.text, partial: output }); + } else if (event.delta.type === "thinking_delta" && block.type === "thinking") { + block.thinking += event.delta.thinking; + stream.push({ + type: "thinking_delta", + contentIndex: index, + delta: event.delta.thinking, + partial: output, + }); + } else if (event.delta.type === "input_json_delta" && block.type === "toolCall") { + (block as any).partialJson += event.delta.partial_json; + try { + block.arguments = JSON.parse((block as any).partialJson); + } catch {} + stream.push({ + type: "toolcall_delta", + contentIndex: index, + delta: event.delta.partial_json, + partial: output, + }); + } else if (event.delta.type === "signature_delta" && block.type === "thinking") { + block.thinkingSignature = (block.thinkingSignature || "") + (event.delta as any).signature; + } + } else if (event.type === "content_block_stop") { + const index = blocks.findIndex((b) => b.index === event.index); + const block = blocks[index]; + if (!block) continue; + + delete (block as any).index; + if (block.type === "text") { + stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: output }); + } else if (block.type === "thinking") { + stream.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: output }); + } else if (block.type === "toolCall") { + try { + block.arguments = JSON.parse((block as any).partialJson); + } catch {} + delete (block as any).partialJson; + stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: output }); + } + } else if (event.type === "message_delta") { + if ((event.delta as any).stop_reason) { + output.stopReason = mapStopReason((event.delta as any).stop_reason); + } + output.usage.input = (event.usage as any).input_tokens || 0; + output.usage.output = (event.usage as any).output_tokens || 0; + output.usage.cacheRead = (event.usage as any).cache_read_input_tokens || 0; + output.usage.cacheWrite = (event.usage as any).cache_creation_input_tokens || 0; + output.usage.totalTokens = + output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; + calculateCost(model, output.usage); + } + } + + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse", message: output }); + stream.end(); + } catch (error) { + for (const block of output.content) delete (block as any).index; + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +} + +// ============================================================================= +// Extension Entry Point +// ============================================================================= + +export default function (pi: ExtensionAPI) { + pi.registerProvider("custom-anthropic", { + baseUrl: "https://api.anthropic.com", + apiKey: "CUSTOM_ANTHROPIC_API_KEY", + api: "custom-anthropic-api", + + models: [ + { + id: "claude-opus-4-5", + name: "Claude Opus 4.5 (Custom)", + reasoning: true, + input: ["text", "image"], + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + contextWindow: 200000, + maxTokens: 64000, + }, + { + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5 (Custom)", + reasoning: true, + input: ["text", "image"], + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + contextWindow: 200000, + maxTokens: 64000, + }, + ], + + oauth: { + name: "Custom Anthropic (Claude Pro/Max)", + login: loginAnthropic, + refreshToken: refreshAnthropicToken, + getApiKey: (cred) => cred.access, + }, + + streamSimple: streamCustomAnthropic, + }); +} diff --git a/pi/files/agent/extensions/custom-provider-anthropic/package-lock.json b/pi/files/agent/extensions/custom-provider-anthropic/package-lock.json new file mode 100644 index 0000000..c6ed32b --- /dev/null +++ b/pi/files/agent/extensions/custom-provider-anthropic/package-lock.json @@ -0,0 +1,24 @@ +{ + "name": "pi-extension-custom-provider", + "version": "1.5.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-extension-custom-provider", + "version": "1.5.0", + "dependencies": { + "@anthropic-ai/sdk": "^0.52.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.52.0.tgz", + "integrity": "sha512-d4c+fg+xy9e46c8+YnrrgIQR45CZlAi7PwdzIfDXDM6ACxEZli1/fxhURsq30ZpMZy6LvSkr41jGq5aF5TD7rQ==", + "license": "MIT", + "bin": { + "anthropic-ai-sdk": "bin/cli" + } + } + } +} diff --git a/pi/files/agent/extensions/custom-provider-anthropic/package.json b/pi/files/agent/extensions/custom-provider-anthropic/package.json new file mode 100644 index 0000000..cb1d203 --- /dev/null +++ b/pi/files/agent/extensions/custom-provider-anthropic/package.json @@ -0,0 +1,19 @@ +{ + "name": "pi-extension-custom-provider-anthropic", + "private": true, + "version": "1.5.0", + "type": "module", + "scripts": { + "clean": "echo 'nothing to clean'", + "build": "echo 'nothing to build'", + "check": "echo 'nothing to check'" + }, + "pi": { + "extensions": [ + "./index.ts" + ] + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.52.0" + } +} diff --git a/pi/files/agent/extensions/dirty-repo-guard.ts b/pi/files/agent/extensions/dirty-repo-guard.ts new file mode 100644 index 0000000..e6e2b5c --- /dev/null +++ b/pi/files/agent/extensions/dirty-repo-guard.ts @@ -0,0 +1,56 @@ +/** + * Dirty Repo Guard Extension + * + * Prevents session changes when there are uncommitted git changes. + * Useful to ensure work is committed before switching context. + */ + +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; + +async function checkDirtyRepo( + pi: ExtensionAPI, + ctx: ExtensionContext, + action: string, +): Promise<{ cancel: boolean } | undefined> { + // Check for uncommitted changes + const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]); + + if (code !== 0) { + // Not a git repo, allow the action + return; + } + + const hasChanges = stdout.trim().length > 0; + if (!hasChanges) { + return; + } + + if (!ctx.hasUI) { + // In non-interactive mode, block by default + return { cancel: true }; + } + + // Count changed files + const changedFiles = stdout.trim().split("\n").filter(Boolean).length; + + const choice = await ctx.ui.select(`You have ${changedFiles} uncommitted file(s). ${action} anyway?`, [ + "Yes, proceed anyway", + "No, let me commit first", + ]); + + if (choice !== "Yes, proceed anyway") { + ctx.ui.notify("Commit your changes first", "warning"); + return { cancel: true }; + } +} + +export default function (pi: ExtensionAPI) { + pi.on("session_before_switch", async (event, ctx) => { + const action = event.reason === "new" ? "new session" : "switch session"; + return checkDirtyRepo(pi, ctx, action); + }); + + pi.on("session_before_fork", async (_event, ctx) => { + return checkDirtyRepo(pi, ctx, "fork"); + }); +} diff --git a/pi/files/agent/extensions/git-checkpoint.ts b/pi/files/agent/extensions/git-checkpoint.ts new file mode 100644 index 0000000..54ec654 --- /dev/null +++ b/pi/files/agent/extensions/git-checkpoint.ts @@ -0,0 +1,53 @@ +/** + * Git Checkpoint Extension + * + * Creates git stash checkpoints at each turn so /fork can restore code state. + * When forking, offers to restore code to that point in history. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + const checkpoints = new Map(); + let currentEntryId: string | undefined; + + // Track the current entry ID when user messages are saved + pi.on("tool_result", async (_event, ctx) => { + const leaf = ctx.sessionManager.getLeafEntry(); + if (leaf) currentEntryId = leaf.id; + }); + + pi.on("turn_start", async () => { + // Create a git stash entry before LLM makes changes + const { stdout } = await pi.exec("git", ["stash", "create"]); + const ref = stdout.trim(); + if (ref && currentEntryId) { + checkpoints.set(currentEntryId, ref); + } + }); + + pi.on("session_before_fork", async (event, ctx) => { + const ref = checkpoints.get(event.entryId); + if (!ref) return; + + if (!ctx.hasUI) { + // In non-interactive mode, don't restore automatically + return; + } + + const choice = await ctx.ui.select("Restore code state?", [ + "Yes, restore code to that point", + "No, keep current code", + ]); + + if (choice?.startsWith("Yes")) { + await pi.exec("git", ["stash", "apply", ref]); + ctx.ui.notify("Code restored to checkpoint", "info"); + } + }); + + pi.on("agent_end", async () => { + // Clear checkpoints after agent completes + checkpoints.clear(); + }); +} diff --git a/pi/files/agent/extensions/handoff.ts b/pi/files/agent/extensions/handoff.ts new file mode 100644 index 0000000..feecf8e --- /dev/null +++ b/pi/files/agent/extensions/handoff.ts @@ -0,0 +1,150 @@ +/** + * Handoff extension - transfer context to a new focused session + * + * Instead of compacting (which is lossy), handoff extracts what matters + * for your next task and creates a new session with a generated prompt. + * + * Usage: + * /handoff now implement this for teams as well + * /handoff execute phase one of the plan + * /handoff check other places that need this fix + * + * The generated prompt appears as a draft in the editor for review/editing. + */ + +import { complete, type Message } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent"; +import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent"; + +const SYSTEM_PROMPT = `You are a context transfer assistant. Given a conversation history and the user's goal for a new thread, generate a focused prompt that: + +1. Summarizes relevant context from the conversation (decisions made, approaches taken, key findings) +2. Lists any relevant files that were discussed or modified +3. Clearly states the next task based on the user's goal +4. Is self-contained - the new thread should be able to proceed without the old conversation + +Format your response as a prompt the user can send to start the new thread. Be concise but include all necessary context. Do not include any preamble like "Here's the prompt" - just output the prompt itself. + +Example output format: +## Context +We've been working on X. Key decisions: +- Decision 1 +- Decision 2 + +Files involved: +- path/to/file1.ts +- path/to/file2.ts + +## Task +[Clear description of what to do next based on user's goal]`; + +export default function (pi: ExtensionAPI) { + pi.registerCommand("handoff", { + description: "Transfer context to a new focused session", + handler: async (args, ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify("handoff requires interactive mode", "error"); + return; + } + + if (!ctx.model) { + ctx.ui.notify("No model selected", "error"); + return; + } + + const goal = args.trim(); + if (!goal) { + ctx.ui.notify("Usage: /handoff ", "error"); + return; + } + + // Gather conversation context from current branch + const branch = ctx.sessionManager.getBranch(); + const messages = branch + .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message") + .map((entry) => entry.message); + + if (messages.length === 0) { + ctx.ui.notify("No conversation to hand off", "error"); + return; + } + + // Convert to LLM format and serialize + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); + const currentSessionFile = ctx.sessionManager.getSessionFile(); + + // Generate the handoff prompt with loader UI + const result = await ctx.ui.custom((tui, theme, _kb, done) => { + const loader = new BorderedLoader(tui, theme, `Generating handoff prompt...`); + loader.onAbort = () => done(null); + + const doGenerate = async () => { + const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!); + + const userMessage: Message = { + role: "user", + content: [ + { + type: "text", + text: `## Conversation History\n\n${conversationText}\n\n## User's Goal for New Thread\n\n${goal}`, + }, + ], + timestamp: Date.now(), + }; + + const response = await complete( + ctx.model!, + { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, + { apiKey, signal: loader.signal }, + ); + + if (response.stopReason === "aborted") { + return null; + } + + return response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + }; + + doGenerate() + .then(done) + .catch((err) => { + console.error("Handoff generation failed:", err); + done(null); + }); + + return loader; + }); + + if (result === null) { + ctx.ui.notify("Cancelled", "info"); + return; + } + + // Let user edit the generated prompt + const editedPrompt = await ctx.ui.editor("Edit handoff prompt", result); + + if (editedPrompt === undefined) { + ctx.ui.notify("Cancelled", "info"); + return; + } + + // Create new session with parent tracking + const newSessionResult = await ctx.newSession({ + parentSession: currentSessionFile, + }); + + if (newSessionResult.cancelled) { + ctx.ui.notify("New session cancelled", "info"); + return; + } + + // Set the edited prompt in the main editor for submission + ctx.ui.setEditorText(editedPrompt); + ctx.ui.notify("Handoff ready. Submit when ready.", "info"); + }, + }); +} diff --git a/pi/files/agent/extensions/modal-editor.ts b/pi/files/agent/extensions/modal-editor.ts new file mode 100644 index 0000000..c1b9d73 --- /dev/null +++ b/pi/files/agent/extensions/modal-editor.ts @@ -0,0 +1,85 @@ +/** + * Modal Editor - vim-like modal editing example + * + * Usage: pi --extension ./examples/extensions/modal-editor.ts + * + * - Escape: insert → normal mode (in normal mode, aborts agent) + * - i: normal → insert mode + * - hjkl: navigation in normal mode + * - ctrl+c, ctrl+d, etc. work in both modes + */ + +import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; + +// Normal mode key mappings: key -> escape sequence (or null for mode switch) +const NORMAL_KEYS: Record = { + h: "\x1b[D", // left + j: "\x1b[B", // down + k: "\x1b[A", // up + l: "\x1b[C", // right + "0": "\x01", // line start + $: "\x05", // line end + x: "\x1b[3~", // delete char + i: null, // insert mode + a: null, // append (insert + right) +}; + +class ModalEditor extends CustomEditor { + private mode: "normal" | "insert" = "insert"; + + handleInput(data: string): void { + // Escape toggles to normal mode, or passes through for app handling + if (matchesKey(data, "escape")) { + if (this.mode === "insert") { + this.mode = "normal"; + } else { + super.handleInput(data); // abort agent, etc. + } + return; + } + + // Insert mode: pass everything through + if (this.mode === "insert") { + super.handleInput(data); + return; + } + + // Normal mode: check mapped keys + if (data in NORMAL_KEYS) { + const seq = NORMAL_KEYS[data]; + if (data === "i") { + this.mode = "insert"; + } else if (data === "a") { + this.mode = "insert"; + super.handleInput("\x1b[C"); // move right first + } else if (seq) { + super.handleInput(seq); + } + return; + } + + // Pass control sequences (ctrl+c, etc.) to super, ignore printable chars + if (data.length === 1 && data.charCodeAt(0) >= 32) return; + super.handleInput(data); + } + + render(width: number): string[] { + const lines = super.render(width); + if (lines.length === 0) return lines; + + // Add mode indicator to bottom border + const label = this.mode === "normal" ? " NORMAL " : " INSERT "; + const last = lines.length - 1; + if (visibleWidth(lines[last]!) >= label.length) { + lines[last] = truncateToWidth(lines[last]!, width - label.length, "") + label; + } + return lines; + } +} + +export default function (pi: ExtensionAPI) { + pi.on("session_start", (_event, ctx) => { + ctx.ui.setEditorComponent((tui, theme, kb) => new ModalEditor(tui, theme, kb)); + }); +} diff --git a/pi/files/agent/extensions/permission-gate.ts b/pi/files/agent/extensions/permission-gate.ts new file mode 100644 index 0000000..0fc97c4 --- /dev/null +++ b/pi/files/agent/extensions/permission-gate.ts @@ -0,0 +1,34 @@ +/** + * Permission Gate Extension + * + * Prompts for confirmation before running potentially dangerous bash commands. + * Patterns checked: rm -rf, sudo, chmod/chown 777 + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i]; + + pi.on("tool_call", async (event, ctx) => { + if (event.toolName !== "bash") return undefined; + + const command = event.input.command as string; + const isDangerous = dangerousPatterns.some((p) => p.test(command)); + + if (isDangerous) { + if (!ctx.hasUI) { + // In non-interactive mode, block by default + return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" }; + } + + const choice = await ctx.ui.select(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ["Yes", "No"]); + + if (choice !== "Yes") { + return { block: true, reason: "Blocked by user" }; + } + } + + return undefined; + }); +} diff --git a/pi/files/agent/extensions/protected-paths.ts b/pi/files/agent/extensions/protected-paths.ts new file mode 100644 index 0000000..fbc1169 --- /dev/null +++ b/pi/files/agent/extensions/protected-paths.ts @@ -0,0 +1,30 @@ +/** + * Protected Paths Extension + * + * Blocks write and edit operations to protected paths. + * Useful for preventing accidental modifications to sensitive files. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + const protectedPaths = [".env", ".git/", "node_modules/"]; + + pi.on("tool_call", async (event, ctx) => { + if (event.toolName !== "write" && event.toolName !== "edit") { + return undefined; + } + + const path = event.input.path as string; + const isProtected = protectedPaths.some((p) => path.includes(p)); + + if (isProtected) { + if (ctx.hasUI) { + ctx.ui.notify(`Blocked write to protected path: ${path}`, "warning"); + } + return { block: true, reason: `Path "${path}" is protected` }; + } + + return undefined; + }); +} diff --git a/pi/files/agent/extensions/question.ts b/pi/files/agent/extensions/question.ts new file mode 100644 index 0000000..73a52b9 --- /dev/null +++ b/pi/files/agent/extensions/question.ts @@ -0,0 +1,264 @@ +/** + * Question Tool - Single question with options + * Full custom UI: options list + inline editor for "Type something..." + * Escape in editor returns to options, Escape in options cancels + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; + +interface OptionWithDesc { + label: string; + description?: string; +} + +type DisplayOption = OptionWithDesc & { isOther?: boolean }; + +interface QuestionDetails { + question: string; + options: string[]; + answer: string | null; + wasCustom?: boolean; +} + +// Options with labels and optional descriptions +const OptionSchema = Type.Object({ + label: Type.String({ description: "Display label for the option" }), + description: Type.Optional(Type.String({ description: "Optional description shown below label" })), +}); + +const QuestionParams = Type.Object({ + question: Type.String({ description: "The question to ask the user" }), + options: Type.Array(OptionSchema, { description: "Options for the user to choose from" }), +}); + +export default function question(pi: ExtensionAPI) { + pi.registerTool({ + name: "question", + label: "Question", + description: "Ask the user a question and let them pick from options. Use when you need user input to proceed.", + parameters: QuestionParams, + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + if (!ctx.hasUI) { + return { + content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }], + details: { + question: params.question, + options: params.options.map((o) => o.label), + answer: null, + } as QuestionDetails, + }; + } + + if (params.options.length === 0) { + return { + content: [{ type: "text", text: "Error: No options provided" }], + details: { question: params.question, options: [], answer: null } as QuestionDetails, + }; + } + + const allOptions: DisplayOption[] = [...params.options, { label: "Type something.", isOther: true }]; + + const result = await ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>( + (tui, theme, _kb, done) => { + let optionIndex = 0; + let editMode = false; + let cachedLines: string[] | undefined; + + const editorTheme: EditorTheme = { + borderColor: (s) => theme.fg("accent", s), + selectList: { + selectedPrefix: (t) => theme.fg("accent", t), + selectedText: (t) => theme.fg("accent", t), + description: (t) => theme.fg("muted", t), + scrollInfo: (t) => theme.fg("dim", t), + noMatch: (t) => theme.fg("warning", t), + }, + }; + const editor = new Editor(tui, editorTheme); + + editor.onSubmit = (value) => { + const trimmed = value.trim(); + if (trimmed) { + done({ answer: trimmed, wasCustom: true }); + } else { + editMode = false; + editor.setText(""); + refresh(); + } + }; + + function refresh() { + cachedLines = undefined; + tui.requestRender(); + } + + function handleInput(data: string) { + if (editMode) { + if (matchesKey(data, Key.escape)) { + editMode = false; + editor.setText(""); + refresh(); + return; + } + editor.handleInput(data); + refresh(); + return; + } + + if (matchesKey(data, Key.up)) { + optionIndex = Math.max(0, optionIndex - 1); + refresh(); + return; + } + if (matchesKey(data, Key.down)) { + optionIndex = Math.min(allOptions.length - 1, optionIndex + 1); + refresh(); + return; + } + + if (matchesKey(data, Key.enter)) { + const selected = allOptions[optionIndex]; + if (selected.isOther) { + editMode = true; + refresh(); + } else { + done({ answer: selected.label, wasCustom: false, index: optionIndex + 1 }); + } + return; + } + + if (matchesKey(data, Key.escape)) { + done(null); + } + } + + function render(width: number): string[] { + if (cachedLines) return cachedLines; + + const lines: string[] = []; + const add = (s: string) => lines.push(truncateToWidth(s, width)); + + add(theme.fg("accent", "─".repeat(width))); + add(theme.fg("text", ` ${params.question}`)); + lines.push(""); + + for (let i = 0; i < allOptions.length; i++) { + const opt = allOptions[i]; + const selected = i === optionIndex; + const isOther = opt.isOther === true; + const prefix = selected ? theme.fg("accent", "> ") : " "; + + if (isOther && editMode) { + add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`)); + } else if (selected) { + add(prefix + theme.fg("accent", `${i + 1}. ${opt.label}`)); + } else { + add(` ${theme.fg("text", `${i + 1}. ${opt.label}`)}`); + } + + // Show description if present + if (opt.description) { + add(` ${theme.fg("muted", opt.description)}`); + } + } + + if (editMode) { + lines.push(""); + add(theme.fg("muted", " Your answer:")); + for (const line of editor.render(width - 2)) { + add(` ${line}`); + } + } + + lines.push(""); + if (editMode) { + add(theme.fg("dim", " Enter to submit • Esc to go back")); + } else { + add(theme.fg("dim", " ↑↓ navigate • Enter to select • Esc to cancel")); + } + add(theme.fg("accent", "─".repeat(width))); + + cachedLines = lines; + return lines; + } + + return { + render, + invalidate: () => { + cachedLines = undefined; + }, + handleInput, + }; + }, + ); + + // Build simple options list for details + const simpleOptions = params.options.map((o) => o.label); + + if (!result) { + return { + content: [{ type: "text", text: "User cancelled the selection" }], + details: { question: params.question, options: simpleOptions, answer: null } as QuestionDetails, + }; + } + + if (result.wasCustom) { + return { + content: [{ type: "text", text: `User wrote: ${result.answer}` }], + details: { + question: params.question, + options: simpleOptions, + answer: result.answer, + wasCustom: true, + } as QuestionDetails, + }; + } + return { + content: [{ type: "text", text: `User selected: ${result.index}. ${result.answer}` }], + details: { + question: params.question, + options: simpleOptions, + answer: result.answer, + wasCustom: false, + } as QuestionDetails, + }; + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("question ")) + theme.fg("muted", args.question); + const opts = Array.isArray(args.options) ? args.options : []; + if (opts.length) { + const labels = opts.map((o: OptionWithDesc) => o.label); + const numbered = [...labels, "Type something."].map((o, i) => `${i + 1}. ${o}`); + text += `\n${theme.fg("dim", ` Options: ${numbered.join(", ")}`)}`; + } + return new Text(text, 0, 0); + }, + + renderResult(result, _options, theme) { + const details = result.details as QuestionDetails | undefined; + if (!details) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "", 0, 0); + } + + if (details.answer === null) { + return new Text(theme.fg("warning", "Cancelled"), 0, 0); + } + + if (details.wasCustom) { + return new Text( + theme.fg("success", "✓ ") + theme.fg("muted", "(wrote) ") + theme.fg("accent", details.answer), + 0, + 0, + ); + } + const idx = details.options.indexOf(details.answer) + 1; + const display = idx > 0 ? `${idx}. ${details.answer}` : details.answer; + return new Text(theme.fg("success", "✓ ") + theme.fg("accent", display), 0, 0); + }, + }); +} diff --git a/pi/files/agent/extensions/questionnaire.ts b/pi/files/agent/extensions/questionnaire.ts new file mode 100644 index 0000000..c73fa76 --- /dev/null +++ b/pi/files/agent/extensions/questionnaire.ts @@ -0,0 +1,427 @@ +/** + * Questionnaire Tool - Unified tool for asking single or multiple questions + * + * Single question: simple options list + * Multiple questions: tab bar navigation between questions + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; + +// Types +interface QuestionOption { + value: string; + label: string; + description?: string; +} + +type RenderOption = QuestionOption & { isOther?: boolean }; + +interface Question { + id: string; + label: string; + prompt: string; + options: QuestionOption[]; + allowOther: boolean; +} + +interface Answer { + id: string; + value: string; + label: string; + wasCustom: boolean; + index?: number; +} + +interface QuestionnaireResult { + questions: Question[]; + answers: Answer[]; + cancelled: boolean; +} + +// Schema +const QuestionOptionSchema = Type.Object({ + value: Type.String({ description: "The value returned when selected" }), + label: Type.String({ description: "Display label for the option" }), + description: Type.Optional(Type.String({ description: "Optional description shown below label" })), +}); + +const QuestionSchema = Type.Object({ + id: Type.String({ description: "Unique identifier for this question" }), + label: Type.Optional( + Type.String({ + description: "Short contextual label for tab bar, e.g. 'Scope', 'Priority' (defaults to Q1, Q2)", + }), + ), + prompt: Type.String({ description: "The full question text to display" }), + options: Type.Array(QuestionOptionSchema, { description: "Available options to choose from" }), + allowOther: Type.Optional(Type.Boolean({ description: "Allow 'Type something' option (default: true)" })), +}); + +const QuestionnaireParams = Type.Object({ + questions: Type.Array(QuestionSchema, { description: "Questions to ask the user" }), +}); + +function errorResult( + message: string, + questions: Question[] = [], +): { content: { type: "text"; text: string }[]; details: QuestionnaireResult } { + return { + content: [{ type: "text", text: message }], + details: { questions, answers: [], cancelled: true }, + }; +} + +export default function questionnaire(pi: ExtensionAPI) { + pi.registerTool({ + name: "questionnaire", + label: "Questionnaire", + description: + "Ask the user one or more questions. Use for clarifying requirements, getting preferences, or confirming decisions. For single questions, shows a simple option list. For multiple questions, shows a tab-based interface.", + parameters: QuestionnaireParams, + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + if (!ctx.hasUI) { + return errorResult("Error: UI not available (running in non-interactive mode)"); + } + if (params.questions.length === 0) { + return errorResult("Error: No questions provided"); + } + + // Normalize questions with defaults + const questions: Question[] = params.questions.map((q, i) => ({ + ...q, + label: q.label || `Q${i + 1}`, + allowOther: q.allowOther !== false, + })); + + const isMulti = questions.length > 1; + const totalTabs = questions.length + 1; // questions + Submit + + const result = await ctx.ui.custom((tui, theme, _kb, done) => { + // State + let currentTab = 0; + let optionIndex = 0; + let inputMode = false; + let inputQuestionId: string | null = null; + let cachedLines: string[] | undefined; + const answers = new Map(); + + // Editor for "Type something" option + const editorTheme: EditorTheme = { + borderColor: (s) => theme.fg("accent", s), + selectList: { + selectedPrefix: (t) => theme.fg("accent", t), + selectedText: (t) => theme.fg("accent", t), + description: (t) => theme.fg("muted", t), + scrollInfo: (t) => theme.fg("dim", t), + noMatch: (t) => theme.fg("warning", t), + }, + }; + const editor = new Editor(tui, editorTheme); + + // Helpers + function refresh() { + cachedLines = undefined; + tui.requestRender(); + } + + function submit(cancelled: boolean) { + done({ questions, answers: Array.from(answers.values()), cancelled }); + } + + function currentQuestion(): Question | undefined { + return questions[currentTab]; + } + + function currentOptions(): RenderOption[] { + const q = currentQuestion(); + if (!q) return []; + const opts: RenderOption[] = [...q.options]; + if (q.allowOther) { + opts.push({ value: "__other__", label: "Type something.", isOther: true }); + } + return opts; + } + + function allAnswered(): boolean { + return questions.every((q) => answers.has(q.id)); + } + + function advanceAfterAnswer() { + if (!isMulti) { + submit(false); + return; + } + if (currentTab < questions.length - 1) { + currentTab++; + } else { + currentTab = questions.length; // Submit tab + } + optionIndex = 0; + refresh(); + } + + function saveAnswer(questionId: string, value: string, label: string, wasCustom: boolean, index?: number) { + answers.set(questionId, { id: questionId, value, label, wasCustom, index }); + } + + // Editor submit callback + editor.onSubmit = (value) => { + if (!inputQuestionId) return; + const trimmed = value.trim() || "(no response)"; + saveAnswer(inputQuestionId, trimmed, trimmed, true); + inputMode = false; + inputQuestionId = null; + editor.setText(""); + advanceAfterAnswer(); + }; + + function handleInput(data: string) { + // Input mode: route to editor + if (inputMode) { + if (matchesKey(data, Key.escape)) { + inputMode = false; + inputQuestionId = null; + editor.setText(""); + refresh(); + return; + } + editor.handleInput(data); + refresh(); + return; + } + + const q = currentQuestion(); + const opts = currentOptions(); + + // Tab navigation (multi-question only) + if (isMulti) { + if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) { + currentTab = (currentTab + 1) % totalTabs; + optionIndex = 0; + refresh(); + return; + } + if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) { + currentTab = (currentTab - 1 + totalTabs) % totalTabs; + optionIndex = 0; + refresh(); + return; + } + } + + // Submit tab + if (currentTab === questions.length) { + if (matchesKey(data, Key.enter) && allAnswered()) { + submit(false); + } else if (matchesKey(data, Key.escape)) { + submit(true); + } + return; + } + + // Option navigation + if (matchesKey(data, Key.up)) { + optionIndex = Math.max(0, optionIndex - 1); + refresh(); + return; + } + if (matchesKey(data, Key.down)) { + optionIndex = Math.min(opts.length - 1, optionIndex + 1); + refresh(); + return; + } + + // Select option + if (matchesKey(data, Key.enter) && q) { + const opt = opts[optionIndex]; + if (opt.isOther) { + inputMode = true; + inputQuestionId = q.id; + editor.setText(""); + refresh(); + return; + } + saveAnswer(q.id, opt.value, opt.label, false, optionIndex + 1); + advanceAfterAnswer(); + return; + } + + // Cancel + if (matchesKey(data, Key.escape)) { + submit(true); + } + } + + function render(width: number): string[] { + if (cachedLines) return cachedLines; + + const lines: string[] = []; + const q = currentQuestion(); + const opts = currentOptions(); + + // Helper to add truncated line + const add = (s: string) => lines.push(truncateToWidth(s, width)); + + add(theme.fg("accent", "─".repeat(width))); + + // Tab bar (multi-question only) + if (isMulti) { + const tabs: string[] = ["← "]; + for (let i = 0; i < questions.length; i++) { + const isActive = i === currentTab; + const isAnswered = answers.has(questions[i].id); + const lbl = questions[i].label; + const box = isAnswered ? "■" : "□"; + const color = isAnswered ? "success" : "muted"; + const text = ` ${box} ${lbl} `; + const styled = isActive ? theme.bg("selectedBg", theme.fg("text", text)) : theme.fg(color, text); + tabs.push(`${styled} `); + } + const canSubmit = allAnswered(); + const isSubmitTab = currentTab === questions.length; + const submitText = " ✓ Submit "; + const submitStyled = isSubmitTab + ? theme.bg("selectedBg", theme.fg("text", submitText)) + : theme.fg(canSubmit ? "success" : "dim", submitText); + tabs.push(`${submitStyled} →`); + add(` ${tabs.join("")}`); + lines.push(""); + } + + // Helper to render options list + function renderOptions() { + for (let i = 0; i < opts.length; i++) { + const opt = opts[i]; + const selected = i === optionIndex; + const isOther = opt.isOther === true; + const prefix = selected ? theme.fg("accent", "> ") : " "; + const color = selected ? "accent" : "text"; + // Mark "Type something" differently when in input mode + if (isOther && inputMode) { + add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`)); + } else { + add(prefix + theme.fg(color, `${i + 1}. ${opt.label}`)); + } + if (opt.description) { + add(` ${theme.fg("muted", opt.description)}`); + } + } + } + + // Content + if (inputMode && q) { + add(theme.fg("text", ` ${q.prompt}`)); + lines.push(""); + // Show options for reference + renderOptions(); + lines.push(""); + add(theme.fg("muted", " Your answer:")); + for (const line of editor.render(width - 2)) { + add(` ${line}`); + } + lines.push(""); + add(theme.fg("dim", " Enter to submit • Esc to cancel")); + } else if (currentTab === questions.length) { + add(theme.fg("accent", theme.bold(" Ready to submit"))); + lines.push(""); + for (const question of questions) { + const answer = answers.get(question.id); + if (answer) { + const prefix = answer.wasCustom ? "(wrote) " : ""; + add(`${theme.fg("muted", ` ${question.label}: `)}${theme.fg("text", prefix + answer.label)}`); + } + } + lines.push(""); + if (allAnswered()) { + add(theme.fg("success", " Press Enter to submit")); + } else { + const missing = questions + .filter((q) => !answers.has(q.id)) + .map((q) => q.label) + .join(", "); + add(theme.fg("warning", ` Unanswered: ${missing}`)); + } + } else if (q) { + add(theme.fg("text", ` ${q.prompt}`)); + lines.push(""); + renderOptions(); + } + + lines.push(""); + if (!inputMode) { + const help = isMulti + ? " Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel" + : " ↑↓ navigate • Enter select • Esc cancel"; + add(theme.fg("dim", help)); + } + add(theme.fg("accent", "─".repeat(width))); + + cachedLines = lines; + return lines; + } + + return { + render, + invalidate: () => { + cachedLines = undefined; + }, + handleInput, + }; + }); + + if (result.cancelled) { + return { + content: [{ type: "text", text: "User cancelled the questionnaire" }], + details: result, + }; + } + + const answerLines = result.answers.map((a) => { + const qLabel = questions.find((q) => q.id === a.id)?.label || a.id; + if (a.wasCustom) { + return `${qLabel}: user wrote: ${a.label}`; + } + return `${qLabel}: user selected: ${a.index}. ${a.label}`; + }); + + return { + content: [{ type: "text", text: answerLines.join("\n") }], + details: result, + }; + }, + + renderCall(args, theme) { + const qs = (args.questions as Question[]) || []; + const count = qs.length; + const labels = qs.map((q) => q.label || q.id).join(", "); + let text = theme.fg("toolTitle", theme.bold("questionnaire ")); + text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`); + if (labels) { + text += theme.fg("dim", ` (${truncateToWidth(labels, 40)})`); + } + return new Text(text, 0, 0); + }, + + renderResult(result, _options, theme) { + const details = result.details as QuestionnaireResult | undefined; + if (!details) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "", 0, 0); + } + if (details.cancelled) { + return new Text(theme.fg("warning", "Cancelled"), 0, 0); + } + const lines = details.answers.map((a) => { + if (a.wasCustom) { + return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${theme.fg("muted", "(wrote) ")}${a.label}`; + } + const display = a.index ? `${a.index}. ${a.label}` : a.label; + return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${display}`; + }); + return new Text(lines.join("\n"), 0, 0); + }, + }); +} diff --git a/pi/files/agent/extensions/session-name.ts b/pi/files/agent/extensions/session-name.ts new file mode 100644 index 0000000..8ff1c37 --- /dev/null +++ b/pi/files/agent/extensions/session-name.ts @@ -0,0 +1,27 @@ +/** + * Session naming example. + * + * Shows setSessionName/getSessionName to give sessions friendly names + * that appear in the session selector instead of the first message. + * + * Usage: /session-name [name] - set or show session name + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + pi.registerCommand("session-name", { + description: "Set or show session name (usage: /session-name [new name])", + handler: async (args, ctx) => { + const name = args.trim(); + + if (name) { + pi.setSessionName(name); + ctx.ui.notify(`Session named: ${name}`, "info"); + } else { + const current = pi.getSessionName(); + ctx.ui.notify(current ? `Session: ${current}` : "No session name set", "info"); + } + }, + }); +} diff --git a/pi/files/agent/extensions/status-line.ts b/pi/files/agent/extensions/status-line.ts new file mode 100644 index 0000000..3c5f778 --- /dev/null +++ b/pi/files/agent/extensions/status-line.ts @@ -0,0 +1,40 @@ +/** + * Status Line Extension + * + * Demonstrates ctx.ui.setStatus() for displaying persistent status text in the footer. + * Shows turn progress with themed colors. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + let turnCount = 0; + + pi.on("session_start", async (_event, ctx) => { + const theme = ctx.ui.theme; + ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready")); + }); + + pi.on("turn_start", async (_event, ctx) => { + turnCount++; + const theme = ctx.ui.theme; + const spinner = theme.fg("accent", "●"); + const text = theme.fg("dim", ` Turn ${turnCount}...`); + ctx.ui.setStatus("status-demo", spinner + text); + }); + + pi.on("turn_end", async (_event, ctx) => { + const theme = ctx.ui.theme; + const check = theme.fg("success", "✓"); + const text = theme.fg("dim", ` Turn ${turnCount} complete`); + ctx.ui.setStatus("status-demo", check + text); + }); + + pi.on("session_switch", async (event, ctx) => { + if (event.reason === "new") { + turnCount = 0; + const theme = ctx.ui.theme; + ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready")); + } + }); +} diff --git a/pi/files/agent/extensions/titlebar-spinner.ts b/pi/files/agent/extensions/titlebar-spinner.ts new file mode 100644 index 0000000..33f92fa --- /dev/null +++ b/pi/files/agent/extensions/titlebar-spinner.ts @@ -0,0 +1,58 @@ +/** + * Titlebar Spinner Extension + * + * Shows a braille spinner animation in the terminal title while the agent is working. + * Uses `ctx.ui.setTitle()` to update the terminal title via the extension API. + * + * Usage: + * pi --extension examples/extensions/titlebar-spinner.ts + */ + +import path from "node:path"; +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; + +const BRAILLE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +function getBaseTitle(pi: ExtensionAPI): string { + const cwd = path.basename(process.cwd()); + const session = pi.getSessionName(); + return session ? `π - ${session} - ${cwd}` : `π - ${cwd}`; +} + +export default function (pi: ExtensionAPI) { + let timer: ReturnType | null = null; + let frameIndex = 0; + + function stopAnimation(ctx: ExtensionContext) { + if (timer) { + clearInterval(timer); + timer = null; + } + frameIndex = 0; + ctx.ui.setTitle(getBaseTitle(pi)); + } + + function startAnimation(ctx: ExtensionContext) { + stopAnimation(ctx); + timer = setInterval(() => { + const frame = BRAILLE_FRAMES[frameIndex % BRAILLE_FRAMES.length]; + const cwd = path.basename(process.cwd()); + const session = pi.getSessionName(); + const title = session ? `${frame} π - ${session} - ${cwd}` : `${frame} π - ${cwd}`; + ctx.ui.setTitle(title); + frameIndex++; + }, 80); + } + + pi.on("agent_start", async (_event, ctx) => { + startAnimation(ctx); + }); + + pi.on("agent_end", async (_event, ctx) => { + stopAnimation(ctx); + }); + + pi.on("session_shutdown", async (_event, ctx) => { + stopAnimation(ctx); + }); +} diff --git a/pi/files/agent/extensions/tools-list.ts b/pi/files/agent/extensions/tools-list.ts deleted file mode 100644 index 4024a5b..0000000 --- a/pi/files/agent/extensions/tools-list.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; - -export default function (pi: ExtensionAPI) { - pi.registerCommand("tools", { - description: "List available and active tools", - handler: async (_args, ctx) => { - const allTools = pi.getAllTools(); - const activeTools = new Set(pi.getActiveTools()); - const lines = allTools - .map((tool) => { - const status = activeTools.has(tool.name) ? "active" : "inactive"; - return `- ${tool.name} (${status})${tool.description ? `: ${tool.description}` : ""}`; - }) - .join("\n"); - ctx.ui.notify(lines || "No tools registered.", "info"); - }, - }); -} diff --git a/pi/files/agent/extensions/tools.ts b/pi/files/agent/extensions/tools.ts new file mode 100644 index 0000000..e10fb96 --- /dev/null +++ b/pi/files/agent/extensions/tools.ts @@ -0,0 +1,146 @@ +/** + * Tools Extension + * + * Provides a /tools command to enable/disable tools interactively. + * Tool selection persists across session reloads and respects branch navigation. + * + * Usage: + * 1. Copy this file to ~/.pi/agent/extensions/ or your project's .pi/extensions/ + * 2. Use /tools to open the tool selector + */ + +import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@mariozechner/pi-coding-agent"; +import { getSettingsListTheme } from "@mariozechner/pi-coding-agent"; +import { Container, type SettingItem, SettingsList } from "@mariozechner/pi-tui"; + +// State persisted to session +interface ToolsState { + enabledTools: string[]; +} + +export default function toolsExtension(pi: ExtensionAPI) { + // Track enabled tools + let enabledTools: Set = new Set(); + let allTools: ToolInfo[] = []; + + // Persist current state + function persistState() { + pi.appendEntry("tools-config", { + enabledTools: Array.from(enabledTools), + }); + } + + // Apply current tool selection + function applyTools() { + pi.setActiveTools(Array.from(enabledTools)); + } + + // Find the last tools-config entry in the current branch + function restoreFromBranch(ctx: ExtensionContext) { + allTools = pi.getAllTools(); + + // Get entries in current branch only + const branchEntries = ctx.sessionManager.getBranch(); + let savedTools: string[] | undefined; + + for (const entry of branchEntries) { + if (entry.type === "custom" && entry.customType === "tools-config") { + const data = entry.data as ToolsState | undefined; + if (data?.enabledTools) { + savedTools = data.enabledTools; + } + } + } + + if (savedTools) { + // Restore saved tool selection (filter to only tools that still exist) + const allToolNames = allTools.map((t) => t.name); + enabledTools = new Set(savedTools.filter((t: string) => allToolNames.includes(t))); + applyTools(); + } else { + // No saved state - sync with currently active tools + enabledTools = new Set(pi.getActiveTools()); + } + } + + // Register /tools command + pi.registerCommand("tools", { + description: "Enable/disable tools", + handler: async (_args, ctx) => { + // Refresh tool list + allTools = pi.getAllTools(); + + await ctx.ui.custom((tui, theme, _kb, done) => { + // Build settings items for each tool + const items: SettingItem[] = allTools.map((tool) => ({ + id: tool.name, + label: tool.name, + currentValue: enabledTools.has(tool.name) ? "enabled" : "disabled", + values: ["enabled", "disabled"], + })); + + const container = new Container(); + container.addChild( + new (class { + render(_width: number) { + return [theme.fg("accent", theme.bold("Tool Configuration")), ""]; + } + invalidate() {} + })(), + ); + + const settingsList = new SettingsList( + items, + Math.min(items.length + 2, 15), + getSettingsListTheme(), + (id, newValue) => { + // Update enabled state and apply immediately + if (newValue === "enabled") { + enabledTools.add(id); + } else { + enabledTools.delete(id); + } + applyTools(); + persistState(); + }, + () => { + // Close dialog + done(undefined); + }, + ); + + container.addChild(settingsList); + + const component = { + render(width: number) { + return container.render(width); + }, + invalidate() { + container.invalidate(); + }, + handleInput(data: string) { + settingsList.handleInput?.(data); + tui.requestRender(); + }, + }; + + return component; + }); + }, + }); + + // Restore state on session start + pi.on("session_start", async (_event, ctx) => { + restoreFromBranch(ctx); + }); + + // Restore state when navigating the session tree + pi.on("session_tree", async (_event, ctx) => { + restoreFromBranch(ctx); + }); + + // Restore state after forking + pi.on("session_fork", async (_event, ctx) => { + restoreFromBranch(ctx); + }); +} diff --git a/pi/files/agent/extensions/truncated-tool.ts b/pi/files/agent/extensions/truncated-tool.ts new file mode 100644 index 0000000..0a0b389 --- /dev/null +++ b/pi/files/agent/extensions/truncated-tool.ts @@ -0,0 +1,192 @@ +/** + * Truncated Tool Example - Demonstrates proper output truncation for custom tools + * + * Custom tools MUST truncate their output to avoid overwhelming the LLM context. + * The built-in limit is 50KB (~10k tokens) and 2000 lines, whichever is hit first. + * + * This example shows how to: + * 1. Use the built-in truncation utilities + * 2. Write full output to a temp file when truncated + * 3. Inform the LLM where to find the complete output + * 4. Custom rendering of tool calls and results + * + * The `rg` tool here wraps ripgrep with proper truncation. Compare this to the + * built-in `grep` tool in src/core/tools/grep.ts for a more complete implementation. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + formatSize, + type TruncationResult, + truncateHead, +} from "@mariozechner/pi-coding-agent"; +import { Text } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; +import { execSync } from "child_process"; +import { mkdtempSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const RgParams = Type.Object({ + pattern: Type.String({ description: "Search pattern (regex)" }), + path: Type.Optional(Type.String({ description: "Directory to search (default: current directory)" })), + glob: Type.Optional(Type.String({ description: "File glob pattern, e.g. '*.ts'" })), +}); + +interface RgDetails { + pattern: string; + path?: string; + glob?: string; + matchCount: number; + truncation?: TruncationResult; + fullOutputPath?: string; +} + +export default function (pi: ExtensionAPI) { + pi.registerTool({ + name: "rg", + label: "ripgrep", + // Document the truncation limits in the tool description so the LLM knows + description: `Search file contents using ripgrep. Output is truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)} (whichever is hit first). If truncated, full output is saved to a temp file.`, + parameters: RgParams, + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const { pattern, path: searchPath, glob } = params; + + // Build the ripgrep command + const args = ["rg", "--line-number", "--color=never"]; + if (glob) args.push("--glob", glob); + args.push(pattern); + args.push(searchPath || "."); + + let output: string; + try { + output = execSync(args.join(" "), { + cwd: ctx.cwd, + encoding: "utf-8", + maxBuffer: 100 * 1024 * 1024, // 100MB buffer to capture full output + }); + } catch (err: any) { + // ripgrep exits with 1 when no matches found + if (err.status === 1) { + return { + content: [{ type: "text", text: "No matches found" }], + details: { pattern, path: searchPath, glob, matchCount: 0 } as RgDetails, + }; + } + throw new Error(`ripgrep failed: ${err.message}`); + } + + if (!output.trim()) { + return { + content: [{ type: "text", text: "No matches found" }], + details: { pattern, path: searchPath, glob, matchCount: 0 } as RgDetails, + }; + } + + // Apply truncation using built-in utilities + // truncateHead keeps the first N lines/bytes (good for search results) + // truncateTail keeps the last N lines/bytes (good for logs/command output) + const truncation = truncateHead(output, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + + // Count matches (each non-empty line with a match) + const matchCount = output.split("\n").filter((line) => line.trim()).length; + + const details: RgDetails = { + pattern, + path: searchPath, + glob, + matchCount, + }; + + let resultText = truncation.content; + + if (truncation.truncated) { + // Save full output to a temp file so LLM can access it if needed + const tempDir = mkdtempSync(join(tmpdir(), "pi-rg-")); + const tempFile = join(tempDir, "output.txt"); + writeFileSync(tempFile, output); + + details.truncation = truncation; + details.fullOutputPath = tempFile; + + // Add truncation notice - this helps the LLM understand the output is incomplete + const truncatedLines = truncation.totalLines - truncation.outputLines; + const truncatedBytes = truncation.totalBytes - truncation.outputBytes; + + resultText += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`; + resultText += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`; + resultText += ` ${truncatedLines} lines (${formatSize(truncatedBytes)}) omitted.`; + resultText += ` Full output saved to: ${tempFile}]`; + } + + return { + content: [{ type: "text", text: resultText }], + details, + }; + }, + + // Custom rendering of the tool call (shown before/during execution) + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("rg ")); + text += theme.fg("accent", `"${args.pattern}"`); + if (args.path) { + text += theme.fg("muted", ` in ${args.path}`); + } + if (args.glob) { + text += theme.fg("dim", ` --glob ${args.glob}`); + } + return new Text(text, 0, 0); + }, + + // Custom rendering of the tool result + renderResult(result, { expanded, isPartial }, theme) { + const details = result.details as RgDetails | undefined; + + // Handle streaming/partial results + if (isPartial) { + return new Text(theme.fg("warning", "Searching..."), 0, 0); + } + + // No matches + if (!details || details.matchCount === 0) { + return new Text(theme.fg("dim", "No matches found"), 0, 0); + } + + // Build result display + let text = theme.fg("success", `${details.matchCount} matches`); + + // Show truncation warning if applicable + if (details.truncation?.truncated) { + text += theme.fg("warning", " (truncated)"); + } + + // In expanded view, show the actual matches + if (expanded) { + const content = result.content[0]; + if (content?.type === "text") { + // Show first 20 lines in expanded view, or all if fewer + const lines = content.text.split("\n").slice(0, 20); + for (const line of lines) { + text += `\n${theme.fg("dim", line)}`; + } + if (content.text.split("\n").length > 20) { + text += `\n${theme.fg("muted", "... (use read tool to see full output)")}`; + } + } + + // Show temp file path if truncated + if (details.fullOutputPath) { + text += `\n${theme.fg("dim", `Full output: ${details.fullOutputPath}`)}`; + } + } + + return new Text(text, 0, 0); + }, + }); +} diff --git a/pi/files/agent/settings.json b/pi/files/agent/settings.json index 255697a..1e12638 100644 --- a/pi/files/agent/settings.json +++ b/pi/files/agent/settings.json @@ -2,6 +2,6 @@ "lastChangelogVersion": "0.54.0", "defaultProvider": "openrouter", "defaultModel": "z-ai/glm-5", - "defaultThinkingLevel": "minimal", + "defaultThinkingLevel": "high", "theme": "matugen" } \ No newline at end of file