diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index a4f47a3b..82ed5d68 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -8,12 +8,23 @@ Extensions are TypeScript modules that extend pi's behavior. They can subscribe - **Custom tools** - Register tools the LLM can call via `pi.registerTool()` - **Event interception** - Block or modify tool calls, inject context, customize compaction - **User interaction** - Prompt users via `ctx.ui` (select, confirm, input, notify) -- **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()` +- **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()` for complex interactions - **Custom commands** - Register commands like `/mycommand` via `pi.registerCommand()` - **Session persistence** - Store state that survives restarts via `pi.appendEntry()` - **Custom rendering** - Control how tool calls/results and messages appear in TUI -See [Examples Reference](#examples-reference) for working implementations. +**Example use cases:** +- Permission gates (confirm before `rm -rf`, `sudo`, etc.) +- Git checkpointing (stash at each turn, restore on branch) +- Path protection (block writes to `.env`, `node_modules/`) +- Custom compaction (summarize conversation your way) +- Conversation summaries (see `summarize.ts` example) +- Interactive tools (questions, wizards, custom dialogs) +- Stateful tools (todo lists, connection pools) +- External integrations (file watchers, webhooks, CI triggers) +- Games while you wait (see `snake.ts` example) + +See [examples/extensions/](../examples/extensions/) for working implementations. ## Table of Contents @@ -21,7 +32,12 @@ See [Examples Reference](#examples-reference) for working implementations. - [Extension Locations](#extension-locations) - [Available Imports](#available-imports) - [Writing an Extension](#writing-an-extension) + - [Extension Styles](#extension-styles) - [Events](#events) + - [Lifecycle Overview](#lifecycle-overview) + - [Session Events](#session-events) + - [Agent Events](#agent-events) + - [Tool Events](#tool-events) - [ExtensionContext](#extensioncontext) - [ExtensionCommandContext](#extensioncommandcontext) - [ExtensionAPI Methods](#extensionapi-methods) @@ -30,7 +46,6 @@ See [Examples Reference](#examples-reference) for working implementations. - [Custom UI](#custom-ui) - [Error Handling](#error-handling) - [Mode Behavior](#mode-behavior) -- [Examples Reference](#examples-reference) ## Quick Start @@ -100,32 +115,38 @@ Additional paths via `settings.json`: ```json { - "packages": ["npm:@foo/bar@1.0.0", "git:github.com/user/repo@v1"], - "extensions": ["/path/to/extension.ts"] + "packages": [ + "npm:@foo/bar@1.0.0", + "git:github.com/user/repo@v1" + ], + "extensions": [ + "/path/to/local/extension.ts", + "/path/to/local/extension/dir" + ] } ``` -Manage packages with CLI: +Use `pi install`, `pi remove`, `pi list`, and `pi update` to manage packages: ```bash pi install npm:@foo/bar@1.0.0 pi install git:github.com/user/repo@v1 -pi install https://github.com/user/repo +pi install https://github.com/user/repo # raw URLs work too pi remove npm:@foo/bar -pi list -pi update +pi list # show installed packages +pi update # update all non-pinned packages ``` -**Package filtering:** Selectively load resources: +**Package filtering:** By default, packages load all resources (extensions, skills, prompts, themes). To selectively load only certain resources: ```json { "packages": [ "npm:simple-pkg", { - "source": "npm:my-extensions", - "extensions": ["extensions/oracle.ts"], - "skills": [], + "source": "npm:shitty-extensions", + "extensions": ["extensions/oracle.ts", "extensions/memory-mode.ts"], + "skills": ["skills/a-nach-b"], "themes": [], "prompts": [] } @@ -133,34 +154,112 @@ pi update } ``` -- Omit key = load all, empty array = load none -- Glob patterns and `!exclusions` supported -- User filters layer on top of manifest filters +- Omit a key to load all of that type from the package +- Use empty array `[]` to load none of that type +- Paths are relative to package root +- Use `!pattern` to exclude (e.g., `"themes": ["!funky.json"]`) +- Glob patterns supported via minimatch (e.g., `"extensions": ["**/*.ts", "!**/deprecated/*"]`) +- **Layered filtering:** User filters apply on top of manifest filters. If a manifest excludes 10 extensions and user adds one more exclusion, all 11 are excluded. -**Package deduplication:** If same package in global and project settings, project wins. +**Package deduplication:** If the same package appears in both global (`~/.pi/agent/settings.json`) and project (`.pi/settings.json`) settings, the project version wins and the global one is ignored. This prevents duplicate resource collisions when you have the same package installed at both scopes. Package identity is determined by: +- **npm packages:** Package name (e.g., `npm:foo` and `npm:foo@1.0.0` are the same identity) +- **git packages:** Repository URL without ref (e.g., `git:github.com/user/repo` and `git:github.com/user/repo@v1` are the same identity) +- **local paths:** Resolved absolute path **Discovery rules:** -1. Direct files: `extensions/*.ts` → loaded directly -2. Subdirectory with index: `extensions/myext/index.ts` → single extension -3. Subdirectory with package.json: `extensions/myext/package.json` with `"pi"` field → loads declared paths +1. **Direct files:** `extensions/*.ts` or `*.js` → loaded directly +2. **Subdirectory with index:** `extensions/myext/index.ts` → loaded as single extension +3. **Subdirectory with package.json:** `extensions/myext/package.json` with `"pi"` field → loads declared paths + +``` +~/.pi/agent/extensions/ +├── simple.ts # Direct file (auto-discovered) +├── my-tool/ +│ └── index.ts # Subdirectory with index (auto-discovered) +└── my-extension-pack/ + ├── package.json # Declares multiple extensions + ├── node_modules/ # Dependencies installed here + └── src/ + ├── safety-gates.ts # First extension + └── custom-tools.ts # Second extension +``` ```json -// package.json with pi manifest +// my-extension-pack/package.json { "name": "my-extension-pack", - "keywords": ["pi-package"], - "dependencies": { "zod": "^3.0.0" }, + "dependencies": { + "zod": "^3.0.0" + }, "pi": { - "extensions": ["./src/index.ts"], + "extensions": ["./src/safety-gates.ts", "./src/custom-tools.ts"], "skills": ["./skills/"], "prompts": ["./prompts/"], - "themes": ["./themes/"] + "themes": ["./themes/dark.json"] } } ``` -Run `npm install` in extensions with dependencies. +The pi manifest supports glob patterns and exclusions: + +```json +{ + "pi": { + "extensions": ["./extensions", "!**/deprecated/*"], + "skills": ["./skills", "!**/experimental/*"], + "themes": ["./themes/*.json"] + } +} +``` + +**Bundling other pi packages:** + +To include resources from another pi package, add it as a dependency with `bundledDependencies` to ensure it's embedded in your published tarball: + +```json +{ + "name": "my-extension-pack", + "dependencies": { + "other-pi-package": "^1.0.0" + }, + "bundledDependencies": [ + "other-pi-package" + ], + "pi": { + "extensions": [ + "./extensions", + "./node_modules/other-pi-package/extensions", + "!**/unwanted-extension.ts" + ], + "skills": [ + "./skills", + "./node_modules/other-pi-package/skills" + ] + } +} +``` + +`bundledDependencies` embeds the package inside your tarball, preserving the `node_modules/` structure. Without it, npm's hoisting could move the dependency elsewhere, breaking the paths. + +**Discoverability:** Add the `pi-package` keyword to your `package.json` so users can find your package on npm: + +```json +{ + "name": "my-extension-pack", + "keywords": ["pi-package", "pi-extension", "..."], + ... +} +``` + +Search for pi packages: `curl -s "https://registry.npmjs.org/-/v1/search?text=keywords:pi-package"` + +The `package.json` approach enables: +- Multiple extensions from one package +- Skills, prompts, and themes declared alongside extensions +- Third-party npm dependencies (resolved via jiti) +- Nested source structure (no depth limit within the package) +- Deployment to and installation from npm ## Available Imports @@ -171,17 +270,28 @@ Run `npm install` in extensions with dependencies. | `@mariozechner/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) | | `@mariozechner/pi-tui` | TUI components for custom rendering | -npm dependencies work if you add `package.json` next to extension. Node.js built-ins available. +npm dependencies work too. Add a `package.json` next to your extension (or in a parent directory), run `npm install`, and imports from `node_modules/` are resolved automatically. + +Node.js built-ins (`node:fs`, `node:path`, etc.) are also available. ## Writing an Extension -Export a default function receiving `ExtensionAPI`: +An extension exports a default function that receives `ExtensionAPI`: ```typescript import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: ExtensionAPI) { - pi.on("event_name", async (event, ctx) => { ... }); + // Subscribe to events + pi.on("event_name", async (event, ctx) => { + // ctx.ui for user interaction + const ok = await ctx.ui.confirm("Title", "Are you sure?"); + ctx.ui.notify("Done!", "success"); + ctx.ui.setStatus("my-ext", "Processing..."); // Footer status + ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]); // Widget above editor (default) + }); + + // Register tools, commands, shortcuts, flags pi.registerTool({ ... }); pi.registerCommand("name", { ... }); pi.registerShortcut("ctrl+x", { ... }); @@ -189,43 +299,116 @@ export default function (pi: ExtensionAPI) { } ``` -Extensions loaded via [jiti](https://github.com/unjs/jiti), TypeScript works without compilation. +Extensions are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation. -**Styles:** Single file, directory with `index.ts`, or package with `package.json` for npm dependencies. +### Extension Styles + +**Single file** - simplest, for small extensions: + +``` +~/.pi/agent/extensions/ +└── my-extension.ts +``` + +**Directory with index.ts** - for multi-file extensions: + +``` +~/.pi/agent/extensions/ +└── my-extension/ + ├── index.ts # Entry point (exports default function) + ├── tools.ts # Helper module + └── utils.ts # Helper module +``` + +**Package with dependencies** - for extensions that need npm packages: + +``` +~/.pi/agent/extensions/ +└── my-extension/ + ├── package.json # Declares dependencies and entry points + ├── package-lock.json + ├── node_modules/ # After npm install + └── src/ + └── index.ts +``` + +```json +// package.json +{ + "name": "my-extension", + "dependencies": { + "zod": "^3.0.0", + "chalk": "^5.0.0" + }, + "pi": { + "extensions": ["./src/index.ts"] + } +} +``` + +Run `npm install` in the extension directory, then imports from `node_modules/` work automatically. ## Events ### Lifecycle Overview ``` -pi starts → session_start +pi starts + │ + └─► session_start + │ + ▼ +user sends prompt ─────────────────────────────────────────┐ + │ │ + ├─► (extension commands checked first, bypass if found) │ + ├─► input (can intercept, transform, or handle) │ + ├─► (skill/template expansion if not handled) │ + ├─► before_agent_start (can inject message, modify system prompt) + ├─► agent_start │ + │ │ + │ ┌─── turn (repeats while LLM calls tools) ───┐ │ + │ │ │ │ + │ ├─► turn_start │ │ + │ ├─► context (can modify messages) │ │ + │ │ │ │ + │ │ LLM responds, may call tools: │ │ + │ │ ├─► tool_call (can block) │ │ + │ │ │ tool executes │ │ + │ │ └─► tool_result (can modify) │ │ + │ │ │ │ + │ └─► turn_end │ │ + │ │ + └─► agent_end │ + │ +user sends another prompt ◄────────────────────────────────┘ -user sends prompt - ├─► (extension commands bypass if found) - ├─► input (can intercept/transform) - ├─► (skill/template expansion) - ├─► before_agent_start (inject message, modify system prompt) - ├─► agent_start - │ ┌─── turn (repeats while LLM calls tools) ───┐ - │ ├─► turn_start - │ ├─► context (modify messages) - │ │ LLM responds: - │ │ ├─► tool_call (can block) - │ │ └─► tool_result (can modify) - │ └─► turn_end - └─► agent_end +/new (new session) or /resume (switch session) + ├─► session_before_switch (can cancel) + └─► session_switch -/new, /resume → session_before_switch → session_switch -/fork → session_before_fork → session_fork -/compact → session_before_compact → session_compact -/tree → session_before_tree → session_tree -/model, Ctrl+P → model_select -exit → session_shutdown +/fork + ├─► session_before_fork (can cancel) + └─► session_fork + +/compact or auto-compaction + ├─► session_before_compact (can cancel or customize) + └─► session_compact + +/tree navigation + ├─► session_before_tree (can cancel or customize) + └─► session_tree + +/model or Ctrl+P (model selection/cycling) + └─► model_select + +exit (Ctrl+C, Ctrl+D) + └─► session_shutdown ``` ### Session Events #### session_start + Fired on initial session load. ```typescript @@ -234,362 +417,474 @@ pi.on("session_start", async (_event, ctx) => { }); ``` +**Examples:** [claude-rules.ts](../examples/extensions/claude-rules.ts), [custom-header.ts](../examples/extensions/custom-header.ts), [file-trigger.ts](../examples/extensions/file-trigger.ts), [status-line.ts](../examples/extensions/status-line.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts) + #### session_before_switch / session_switch -Fired on `/new` or `/resume`. Can cancel. + +Fired when starting a new session (`/new`) or switching sessions (`/resume`). ```typescript pi.on("session_before_switch", async (event, ctx) => { - // event.reason: "new" | "resume" - // event.targetSessionFile (only for "resume") + // event.reason - "new" or "resume" + // event.targetSessionFile - session we're switching to (only for "resume") + if (event.reason === "new") { const ok = await ctx.ui.confirm("Clear?", "Delete all messages?"); if (!ok) return { cancel: true }; } }); + +pi.on("session_switch", async (event, ctx) => { + // event.reason - "new" or "resume" + // event.previousSessionFile - session we came from +}); ``` +**Examples:** [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [status-line.ts](../examples/extensions/status-line.ts), [todo.ts](../examples/extensions/todo.ts) + #### session_before_fork / session_fork -Fired on `/fork`. Can cancel or skip conversation restore. + +Fired when forking via `/fork`. ```typescript pi.on("session_before_fork", async (event, ctx) => { - // event.entryId - return { cancel: true }; - // OR: return { skipConversationRestore: true }; + // event.entryId - ID of the entry being forked from + return { cancel: true }; // Cancel fork + // OR + return { skipConversationRestore: true }; // Fork but don't rewind messages +}); + +pi.on("session_fork", async (event, ctx) => { + // event.previousSessionFile - previous session file }); ``` +**Examples:** [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts) + #### session_before_compact / session_compact -Fired on compaction. Can cancel or provide custom summary. See [compaction.md](compaction.md). + +Fired on compaction. See [compaction.md](compaction.md) for details. ```typescript pi.on("session_before_compact", async (event, ctx) => { - // event.preparation, event.branchEntries, event.customInstructions, event.signal + const { preparation, branchEntries, customInstructions, signal } = event; + + // Cancel: return { cancel: true }; - // OR: return { compaction: { summary: "...", firstKeptEntryId: "...", tokensBefore: 0 } }; + + // Custom summary: + return { + compaction: { + summary: "...", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + } + }; +}); + +pi.on("session_compact", async (event, ctx) => { + // event.compactionEntry - the saved compaction + // event.fromExtension - whether extension provided it }); ``` +**Examples:** [custom-compaction.ts](../examples/extensions/custom-compaction.ts), [trigger-compact.ts](../examples/extensions/trigger-compact.ts) + #### session_before_tree / session_tree -Fired on `/tree` navigation. Can cancel or provide custom summary. + +Fired on `/tree` navigation. ```typescript pi.on("session_before_tree", async (event, ctx) => { - // event.preparation, event.signal + const { preparation, signal } = event; return { cancel: true }; - // OR: return { summary: { summary: "...", details: {} } }; + // OR provide custom summary: + return { summary: { summary: "...", details: {} } }; +}); + +pi.on("session_tree", async (event, ctx) => { + // event.newLeafId, oldLeafId, summaryEntry, fromExtension }); ``` +**Examples:** [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts) + #### session_shutdown + Fired on exit (Ctrl+C, Ctrl+D, SIGTERM). ```typescript pi.on("session_shutdown", async (_event, ctx) => { - // Cleanup, save state + // Cleanup, save state, etc. }); ``` +**Examples:** [auto-commit-on-exit.ts](../examples/extensions/auto-commit-on-exit.ts) + ### Agent Events #### before_agent_start -Fired after user submits prompt, before agent loop. Can inject message and/or modify system prompt. + +Fired after user submits prompt, before agent loop. Can inject a message and/or modify the system prompt. ```typescript pi.on("before_agent_start", async (event, ctx) => { - // event.prompt, event.images, event.systemPrompt + // event.prompt - user's prompt text + // event.images - attached images (if any) + // event.systemPrompt - current system prompt + return { - message: { customType: "my-ext", content: "Context", display: true }, - systemPrompt: event.systemPrompt + "\n\nExtra instructions...", + // Inject a persistent message (stored in session, sent to LLM) + message: { + customType: "my-extension", + content: "Additional context for the LLM", + display: true, + }, + // Replace the system prompt for this turn (chained across extensions) + systemPrompt: event.systemPrompt + "\n\nExtra instructions for this turn...", }; }); ``` +**Examples:** [claude-rules.ts](../examples/extensions/claude-rules.ts), [pirate.ts](../examples/extensions/pirate.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts), [ssh.ts](../examples/extensions/ssh.ts) + #### agent_start / agent_end + Fired once per user prompt. ```typescript +pi.on("agent_start", async (_event, ctx) => {}); + pi.on("agent_end", async (event, ctx) => { // event.messages - messages from this prompt }); ``` +**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts) + #### turn_start / turn_end + Fired for each turn (one LLM response + tool calls). ```typescript +pi.on("turn_start", async (event, ctx) => { + // event.turnIndex, event.timestamp +}); + pi.on("turn_end", async (event, ctx) => { // event.turnIndex, event.message, event.toolResults }); ``` +**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [status-line.ts](../examples/extensions/status-line.ts) + #### context + Fired before each LLM call. Modify messages non-destructively. ```typescript pi.on("context", async (event, ctx) => { // event.messages - deep copy, safe to modify - return { messages: event.messages.filter(m => !shouldPrune(m)) }; + const filtered = event.messages.filter(m => !shouldPrune(m)); + return { messages: filtered }; }); ``` +**Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts) + ### Model Events #### model_select -Fired when model changes via `/model`, Ctrl+P, or session restore. + +Fired when the model changes via `/model` command, model cycling (`Ctrl+P`), or session restore. ```typescript pi.on("model_select", async (event, ctx) => { - // event.model, event.previousModel, event.source ("set" | "cycle" | "restore") + // event.model - newly selected model + // event.previousModel - previous model (undefined if first selection) + // event.source - "set" | "cycle" | "restore" + + const prev = event.previousModel + ? `${event.previousModel.provider}/${event.previousModel.id}` + : "none"; + const next = `${event.model.provider}/${event.model.id}`; + + ctx.ui.notify(`Model changed (${event.source}): ${prev} -> ${next}`, "info"); }); ``` +Use this to update UI elements (status bars, footers) or perform model-specific initialization when the active model changes. + +**Examples:** [model-status.ts](../examples/extensions/model-status.ts) + ### Tool Events #### tool_call + Fired before tool executes. **Can block.** ```typescript pi.on("tool_call", async (event, ctx) => { - // event.toolName, event.toolCallId, event.input - if (shouldBlock(event)) return { block: true, reason: "Not allowed" }; + // event.toolName - "bash", "read", "write", "edit", etc. + // event.toolCallId + // event.input - tool parameters + + if (shouldBlock(event)) { + return { block: true, reason: "Not allowed" }; + } }); ``` +**Examples:** [permission-gate.ts](../examples/extensions/permission-gate.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [protected-paths.ts](../examples/extensions/protected-paths.ts) + #### tool_result + Fired after tool executes. **Can modify result.** ```typescript import { isBashToolResult } from "@mariozechner/pi-coding-agent"; pi.on("tool_result", async (event, ctx) => { - // event.toolName, event.toolCallId, event.input, event.content, event.details, event.isError - if (isBashToolResult(event)) { /* event.details typed as BashToolDetails */ } + // event.toolName, event.toolCallId, event.input + // event.content, event.details, event.isError + + if (isBashToolResult(event)) { + // event.details is typed as BashToolDetails + } + + // Modify result: return { content: [...], details: {...}, isError: false }; }); ``` -Type guards: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult` +**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts) ### User Bash Events #### user_bash -Fired on `!` or `!!` commands. Can intercept. + +Fired when user executes `!` or `!!` commands. **Can intercept.** ```typescript pi.on("user_bash", (event, ctx) => { - // event.command, event.excludeFromContext, event.cwd + // event.command - the bash command + // event.excludeFromContext - true if !! prefix + // event.cwd - working directory + + // Option 1: Provide custom operations (e.g., SSH) return { operations: remoteBashOps }; - // OR: return { result: { output: "...", exitCode: 0, cancelled: false, truncated: false } }; + + // Option 2: Full replacement - return result directly + return { result: { output: "...", exitCode: 0, cancelled: false, truncated: false } }; }); ``` +**Examples:** [ssh.ts](../examples/extensions/ssh.ts), [interactive-shell.ts](../examples/extensions/interactive-shell.ts) + ### Input Events #### input -Fired after extension commands checked, before skill/template expansion. + +Fired when user input is received, after extension commands are checked but before skill and template expansion. The event sees the raw input text, so `/skill:foo` and `/template` are not yet expanded. + +**Processing order:** +1. Extension commands (`/cmd`) checked first - if found, handler runs and input event is skipped +2. `input` event fires - can intercept, transform, or handle +3. If not handled: skill commands (`/skill:name`) expanded to skill content +4. If not handled: prompt templates (`/template`) expanded to template content +5. Agent processing begins (`before_agent_start`, etc.) ```typescript pi.on("input", async (event, ctx) => { - // event.text, event.images, event.source ("interactive" | "rpc" | "extension") + // event.text - raw input (before skill/template expansion) + // event.images - attached images, if any + // event.source - "interactive" (typed), "rpc" (API), or "extension" (via sendUserMessage) + + // Transform: rewrite input before expansion if (event.text.startsWith("?quick ")) return { action: "transform", text: `Respond briefly: ${event.text.slice(7)}` }; + + // Handle: respond without LLM (extension shows its own feedback) if (event.text === "ping") { ctx.ui.notify("pong", "info"); return { action: "handled" }; } - return { action: "continue" }; + + // Route by source: skip processing for extension-injected messages + if (event.source === "extension") return { action: "continue" }; + + // Intercept skill commands before expansion + if (event.text.startsWith("/skill:")) { + // Could transform, block, or let pass through + } + + return { action: "continue" }; // Default: pass through to expansion }); ``` -Results: `continue` (default), `transform`, `handled` +**Results:** +- `continue` - pass through unchanged (default if handler returns nothing) +- `transform` - modify text/images, then continue to expansion +- `handled` - skip agent entirely (first handler to return this wins) + +Transforms chain across handlers. See [input-transform.ts](../examples/extensions/input-transform.ts). ## ExtensionContext Every handler receives `ctx: ExtensionContext`: -| Property/Method | Description | -|-----------------|-------------| -| `ui` | UI methods. See [Custom UI](#custom-ui) | -| `hasUI` | `false` in print/RPC mode | -| `cwd` | Current working directory | -| `sessionManager` | Read-only: `getEntries()`, `getBranch()`, `getLeafId()` | -| `modelRegistry` | Model and API key access | -| `model` | Current model (may be undefined) | -| `isIdle()` | Whether agent is idle | -| `abort()` | Abort current operation | -| `hasPendingMessages()` | Whether messages are queued | -| `shutdown()` | Request graceful exit | -| `getContextUsage()` | Returns `{ tokens, contextWindow, percent, ... }` | -| `compact(options?)` | Trigger compaction with `onComplete`/`onError` callbacks | +### ctx.ui -## ExtensionCommandContext +UI methods for user interaction. See [Custom UI](#custom-ui) for full details. -Command handlers get `ExtensionCommandContext` (extends `ExtensionContext`): +### ctx.hasUI -| Method | Description | -|--------|-------------| -| `waitForIdle()` | Wait for agent to finish streaming | -| `newSession(options?)` | Create new session with optional `parentSession` and `setup` callback | -| `fork(entryId)` | Fork from entry, creating new session file | -| `navigateTree(targetId, options?)` | Navigate tree with `summarize`, `customInstructions`, `replaceInstructions`, `label` | +`false` in print mode (`-p`), JSON mode, and RPC mode. Always check before using `ctx.ui`. -## ExtensionAPI Methods +### ctx.cwd -### Event Subscription +Current working directory. + +### ctx.sessionManager + +Read-only access to session state: ```typescript -pi.on(event, handler) // See Events section +ctx.sessionManager.getEntries() // All entries +ctx.sessionManager.getBranch() // Current branch +ctx.sessionManager.getLeafId() // Current leaf entry ID ``` -### Tool Registration +### ctx.modelRegistry / ctx.model + +Access to models and API keys. + +### ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages() + +Control flow helpers. + +### ctx.shutdown() + +Request a graceful shutdown of pi. + +- **Interactive mode:** Deferred until the agent becomes idle (after processing all queued steering and follow-up messages). +- **RPC mode:** Deferred until the next idle state (after completing the current command response, when waiting for the next command). +- **Print mode:** No-op. The process exits automatically when all prompts are processed. + +Emits `session_shutdown` event to all extensions before exiting. Available in all contexts (event handlers, tools, commands, shortcuts). ```typescript -pi.registerTool({ - name: "my_tool", - label: "My Tool", - description: "What this tool does", - parameters: Type.Object({ ... }), - async execute(toolCallId, params, onUpdate, ctx, signal) { ... }, - renderCall?(args, theme) { ... }, - renderResult?(result, options, theme) { ... }, -}) -``` - -### Message Injection - -```typescript -// Custom message -pi.sendMessage({ customType: "my-ext", content: "...", display: true, details: {} }, { - triggerTurn: true, - deliverAs: "steer" | "followUp" | "nextTurn" -}); - -// User message (always triggers turn) -pi.sendUserMessage("text" | [{ type: "text", text: "..." }], { - deliverAs: "steer" | "followUp" // required when streaming -}); -``` - -### State Persistence - -```typescript -pi.appendEntry("my-state", { count: 42 }); // Does NOT go to LLM -``` - -### Session Metadata - -```typescript -pi.setSessionName(name) -pi.getSessionName() -pi.setLabel(entryId, label) -``` - -### Command Registration - -```typescript -pi.registerCommand("name", { - description: "...", - getArgumentCompletions?: (prefix) => AutocompleteItem[] | null, - handler: async (args, ctx) => { ... } -}); -``` - -### Message Rendering - -```typescript -pi.registerMessageRenderer("customType", (message, { expanded }, theme) => Component | undefined); -``` - -### Shortcuts and Flags - -```typescript -pi.registerShortcut("ctrl+shift+p", { - description: "...", - handler: async (ctx) => { ... } -}); - -pi.registerFlag("plan", { description: "...", type: "boolean", default: false }); -pi.getFlag("--plan") -``` - -### Shell Execution - -```typescript -const result = await pi.exec("git", ["status"], { signal, timeout: 5000 }); -// result.stdout, result.stderr, result.code, result.killed -``` - -### Tool Management - -```typescript -pi.getActiveTools() // ["read", "bash", "edit", "write"] -pi.getAllTools() // [{ name, description }, ...] -pi.setActiveTools(names) -``` - -### Model and Thinking - -```typescript -await pi.setModel(model) // Returns false if no API key -pi.getThinkingLevel() // "off" | "minimal" | "low" | "medium" | "high" | "xhigh" -pi.setThinkingLevel(level) -``` - -### Provider Registration - -```typescript -pi.registerProvider("my-proxy", { - baseUrl: "https://proxy.example.com", - apiKey: "PROXY_API_KEY", - api: "anthropic-messages", - headers?: { ... }, - authHeader?: true, - models?: [{ id, name, reasoning, input, cost, contextWindow, maxTokens, compat? }], - oauth?: { name, login, refreshToken, getApiKey, modifyModels? }, - streamSimple?: (model, context, options) => AssistantMessageEventStream -}); -``` - -See [custom-provider.md](custom-provider.md) for details. - -### Event Bus - -```typescript -pi.events.on("my:event", (data) => { ... }); -pi.events.emit("my:event", { ... }); -``` - -## State Management - -Store state in tool result `details` for proper branching: - -```typescript -let items: string[] = []; - -pi.on("session_start", async (_event, ctx) => { - items = []; - for (const entry of ctx.sessionManager.getBranch()) { - if (entry.type === "message" && entry.message.role === "toolResult") { - if (entry.message.toolName === "my_tool") { - items = entry.message.details?.items ?? []; - } - } +pi.on("tool_call", (event, ctx) => { + if (isFatal(event.input)) { + ctx.shutdown(); } }); +``` -pi.registerTool({ - name: "my_tool", - async execute(...) { - items.push("new"); - return { content: [...], details: { items: [...items] } }; +### ctx.getContextUsage() + +Returns current context usage for the active model. Uses last assistant usage when available, then estimates tokens for trailing messages. + +```typescript +const usage = ctx.getContextUsage(); +if (usage && usage.tokens > 100_000) { + // ... +} +``` + +### ctx.compact() + +Trigger compaction without awaiting completion. Use `onComplete` and `onError` for follow-up actions. + +```typescript +ctx.compact({ + customInstructions: "Focus on recent changes", + onComplete: (result) => { + ctx.ui.notify("Compaction completed", "info"); + }, + onError: (error) => { + ctx.ui.notify(`Compaction failed: ${error.message}`, "error"); }, }); ``` -## Custom Tools +## ExtensionCommandContext -### Tool Definition +Command handlers receive `ExtensionCommandContext`, which extends `ExtensionContext` with session control methods. These are only available in commands because they can deadlock if called from event handlers. + +### ctx.waitForIdle() + +Wait for the agent to finish streaming: + +```typescript +pi.registerCommand("my-cmd", { + handler: async (args, ctx) => { + await ctx.waitForIdle(); + // Agent is now idle, safe to modify session + }, +}); +``` + +### ctx.newSession(options?) + +Create a new session: + +```typescript +const result = await ctx.newSession({ + parentSession: ctx.sessionManager.getSessionFile(), + setup: async (sm) => { + sm.appendMessage({ + role: "user", + content: [{ type: "text", text: "Context from previous session..." }], + timestamp: Date.now(), + }); + }, +}); + +if (result.cancelled) { + // An extension cancelled the new session +} +``` + +### ctx.fork(entryId) + +Fork from a specific entry, creating a new session file: + +```typescript +const result = await ctx.fork("entry-id-123"); +if (!result.cancelled) { + // Now in the forked session +} +``` + +### ctx.navigateTree(targetId, options?) + +Navigate to a different point in the session tree: + +```typescript +const result = await ctx.navigateTree("entry-id-456", { + summarize: true, + customInstructions: "Focus on error handling changes", + replaceInstructions: false, // true = replace default prompt entirely + label: "review-checkpoint", +}); +``` + +Options: +- `summarize`: Whether to generate a summary of the abandoned branch +- `customInstructions`: Custom instructions for the summarizer +- `replaceInstructions`: If true, `customInstructions` replaces the default prompt instead of being appended +- `label`: Label to attach to the branch summary entry (or target entry if not summarizing) + +## ExtensionAPI Methods + +### pi.on(event, handler) + +Subscribe to events. See [Events](#events) for event types and return values. + +### pi.registerTool(definition) + +Register a custom tool callable by the LLM. See [Custom Tools](#custom-tools) for full details. ```typescript import { Type } from "@sinclair/typebox"; @@ -600,146 +895,938 @@ pi.registerTool({ label: "My Tool", description: "What this tool does", parameters: Type.Object({ - action: StringEnum(["list", "add"] as const), // Use StringEnum for Google compatibility + action: StringEnum(["list", "add"] as const), text: Type.Optional(Type.String()), }), + async execute(toolCallId, params, onUpdate, ctx, signal) { - if (signal?.aborted) return { content: [{ type: "text", text: "Cancelled" }] }; + // Stream progress onUpdate?.({ content: [{ type: "text", text: "Working..." }] }); - return { content: [{ type: "text", text: "Done" }], details: {} }; + + return { + content: [{ type: "text", text: "Done" }], + details: { result: "..." }, + }; }, + + // Optional: Custom rendering renderCall(args, theme) { ... }, - renderResult(result, { expanded, isPartial }, theme) { ... }, + renderResult(result, options, theme) { ... }, }); ``` +**Examples:** [hello.ts](../examples/extensions/hello.ts), [question.ts](../examples/extensions/question.ts), [questionnaire.ts](../examples/extensions/questionnaire.ts), [todo.ts](../examples/extensions/todo.ts), [truncated-tool.ts](../examples/extensions/truncated-tool.ts) + +### pi.sendMessage(message, options?) + +Inject a custom message into the session. + +```typescript +pi.sendMessage({ + customType: "my-extension", + content: "Message text", + display: true, + details: { ... }, +}, { + triggerTurn: true, + deliverAs: "steer", +}); +``` + +**Options:** +- `deliverAs` - Delivery mode: + - `"steer"` (default) - Interrupts streaming. Delivered after current tool finishes, remaining tools skipped. + - `"followUp"` - Waits for agent to finish. Delivered only when agent has no more tool calls. + - `"nextTurn"` - Queued for next user prompt. Does not interrupt or trigger anything. +- `triggerTurn: true` - If agent is idle, trigger an LLM response immediately. Only applies to `"steer"` and `"followUp"` modes (ignored for `"nextTurn"`). + +**Examples:** [file-trigger.ts](../examples/extensions/file-trigger.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts) + +### pi.sendUserMessage(content, options?) + +Send a user message to the agent. Unlike `sendMessage()` which sends custom messages, this sends an actual user message that appears as if typed by the user. Always triggers a turn. + +```typescript +// Simple text message +pi.sendUserMessage("What is 2+2?"); + +// With content array (text + images) +pi.sendUserMessage([ + { type: "text", text: "Describe this image:" }, + { type: "image", source: { type: "base64", mediaType: "image/png", data: "..." } }, +]); + +// During streaming - must specify delivery mode +pi.sendUserMessage("Focus on error handling", { deliverAs: "steer" }); +pi.sendUserMessage("And then summarize", { deliverAs: "followUp" }); +``` + +**Options:** +- `deliverAs` - Required when agent is streaming: + - `"steer"` - Interrupts after current tool, remaining tools skipped + - `"followUp"` - Waits for agent to finish all tools + +When not streaming, the message is sent immediately and triggers a new turn. When streaming without `deliverAs`, throws an error. + +See [send-user-message.ts](../examples/extensions/send-user-message.ts) for a complete example. + +### pi.appendEntry(customType, data?) + +Persist extension state (does NOT participate in LLM context). + +```typescript +pi.appendEntry("my-state", { count: 42 }); + +// Restore on reload +pi.on("session_start", async (_event, ctx) => { + for (const entry of ctx.sessionManager.getEntries()) { + if (entry.type === "custom" && entry.customType === "my-state") { + // Reconstruct from entry.data + } + } +}); +``` + +**Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts), [snake.ts](../examples/extensions/snake.ts), [tools.ts](../examples/extensions/tools.ts) + +### pi.setSessionName(name) + +Set the session display name (shown in session selector instead of first message). + +```typescript +pi.setSessionName("Refactor auth module"); +``` + +### pi.getSessionName() + +Get the current session name, if set. + +```typescript +const name = pi.getSessionName(); +if (name) { + console.log(`Session: ${name}`); +} +``` + +### pi.setLabel(entryId, label) + +Set or clear a label on an entry. Labels are user-defined markers for bookmarking and navigation (shown in `/tree` selector). + +```typescript +// Set a label +pi.setLabel(entryId, "checkpoint-before-refactor"); + +// Clear a label +pi.setLabel(entryId, undefined); + +// Read labels via sessionManager +const label = ctx.sessionManager.getLabel(entryId); +``` + +Labels persist in the session and survive restarts. Use them to mark important points (turns, checkpoints) in the conversation tree. + +### pi.registerCommand(name, options) + +Register a command. + +```typescript +pi.registerCommand("stats", { + description: "Show session statistics", + handler: async (args, ctx) => { + const count = ctx.sessionManager.getEntries().length; + ctx.ui.notify(`${count} entries`, "info"); + } +}); +``` + +Optional: add argument auto-completion for `/command ...`: + +```typescript +import type { AutocompleteItem } from "@mariozechner/pi-tui"; + +pi.registerCommand("deploy", { + description: "Deploy to an environment", + getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => { + const envs = ["dev", "staging", "prod"]; + const items = envs.map((e) => ({ value: e, label: e })); + const filtered = items.filter((i) => i.value.startsWith(prefix)); + return filtered.length > 0 ? filtered : null; + }, + handler: async (args, ctx) => { + ctx.ui.notify(`Deploying: ${args}`, "info"); + }, +}); +``` + +**Examples:** [custom-footer.ts](../examples/extensions/custom-footer.ts), [custom-header.ts](../examples/extensions/custom-header.ts), [handoff.ts](../examples/extensions/handoff.ts), [pirate.ts](../examples/extensions/pirate.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts), [qna.ts](../examples/extensions/qna.ts), [send-user-message.ts](../examples/extensions/send-user-message.ts), [snake.ts](../examples/extensions/snake.ts), [summarize.ts](../examples/extensions/summarize.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts) + +### pi.registerMessageRenderer(customType, renderer) + +Register a custom TUI renderer for messages with your `customType`. See [Custom UI](#custom-ui). + +### pi.registerShortcut(shortcut, options) + +Register a keyboard shortcut. + +```typescript +pi.registerShortcut("ctrl+shift+p", { + description: "Toggle plan mode", + handler: async (ctx) => { + ctx.ui.notify("Toggled!"); + }, +}); +``` + +**Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts) + +### pi.registerFlag(name, options) + +Register a CLI flag. + +```typescript +pi.registerFlag("plan", { + description: "Start in plan mode", + type: "boolean", + default: false, +}); + +// Check value +if (pi.getFlag("--plan")) { + // Plan mode enabled +} +``` + +**Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts) + +### pi.exec(command, args, options?) + +Execute a shell command. + +```typescript +const result = await pi.exec("git", ["status"], { signal, timeout: 5000 }); +// result.stdout, result.stderr, result.code, result.killed +``` + +**Examples:** [auto-commit-on-exit.ts](../examples/extensions/auto-commit-on-exit.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts) + +### pi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names) + +Manage active tools. + +```typescript +const active = pi.getActiveTools(); // ["read", "bash", "edit", "write"] +const all = pi.getAllTools(); // [{ name: "read", description: "Read file contents..." }, ...] +const names = all.map(t => t.name); // Just names if needed +pi.setActiveTools(["read", "bash"]); // Switch to read-only +``` + +**Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts), [tools.ts](../examples/extensions/tools.ts) + +### pi.setModel(model) + +Set the current model. Returns `false` if no API key is available for the model. + +```typescript +const model = ctx.modelRegistry.find("anthropic", "claude-sonnet-4-5"); +if (model) { + const success = await pi.setModel(model); + if (!success) { + ctx.ui.notify("No API key for this model", "error"); + } +} +``` + +**Examples:** [preset.ts](../examples/extensions/preset.ts) + +### pi.getThinkingLevel() / pi.setThinkingLevel(level) + +Get or set the thinking level. Level is clamped to model capabilities (non-reasoning models always use "off"). + +```typescript +const current = pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh" +pi.setThinkingLevel("high"); +``` + +**Examples:** [preset.ts](../examples/extensions/preset.ts) + +### pi.events + +Shared event bus for communication between extensions: + +```typescript +pi.events.on("my:event", (data) => { ... }); +pi.events.emit("my:event", { ... }); +``` + +### pi.registerProvider(name, config) + +Register or override a model provider dynamically. Useful for proxies, custom endpoints, or team-wide model configurations. + +```typescript +// Register a new provider with custom models +pi.registerProvider("my-proxy", { + baseUrl: "https://proxy.example.com", + apiKey: "PROXY_API_KEY", // env var name or literal + api: "anthropic-messages", + models: [ + { + id: "claude-sonnet-4-20250514", + name: "Claude 4 Sonnet (proxy)", + reasoning: false, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 16384 + } + ] +}); + +// Override baseUrl for an existing provider (keeps all models) +pi.registerProvider("anthropic", { + baseUrl: "https://proxy.example.com" +}); + +// Register provider with OAuth support for /login +pi.registerProvider("corporate-ai", { + baseUrl: "https://ai.corp.com", + api: "openai-responses", + models: [...], + oauth: { + name: "Corporate AI (SSO)", + async login(callbacks) { + // Custom OAuth flow + callbacks.onAuth({ url: "https://sso.corp.com/..." }); + const code = await callbacks.onPrompt({ message: "Enter code:" }); + return { refresh: code, access: code, expires: Date.now() + 3600000 }; + }, + async refreshToken(credentials) { + // Refresh logic + return credentials; + }, + getApiKey(credentials) { + return credentials.access; + } + } +}); +``` + +**Config options:** +- `baseUrl` - API endpoint URL. Required when defining models. +- `apiKey` - API key or environment variable name. Required when defining models (unless `oauth` provided). +- `api` - API type: `"anthropic-messages"`, `"openai-completions"`, `"openai-responses"`, etc. +- `headers` - Custom headers to include in requests. +- `authHeader` - If true, adds `Authorization: Bearer` header automatically. +- `models` - Array of model definitions. If provided, replaces all existing models for this provider. +- `oauth` - OAuth provider config for `/login` support. When provided, the provider appears in the login menu. +- `streamSimple` - Custom streaming implementation for non-standard APIs. + +See [custom-provider.md](custom-provider.md) for advanced topics: custom streaming APIs, OAuth details, model definition reference. + +## State Management + +Extensions with state should store it in tool result `details` for proper branching support: + +```typescript +export default function (pi: ExtensionAPI) { + let items: string[] = []; + + // Reconstruct state from session + pi.on("session_start", async (_event, ctx) => { + items = []; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "message" && entry.message.role === "toolResult") { + if (entry.message.toolName === "my_tool") { + items = entry.message.details?.items ?? []; + } + } + } + }); + + pi.registerTool({ + name: "my_tool", + // ... + async execute(toolCallId, params, onUpdate, ctx, signal) { + items.push("new item"); + return { + content: [{ type: "text", text: "Added" }], + details: { items: [...items] }, // Store for reconstruction + }; + }, + }); +} +``` + +## Custom Tools + +Register tools the LLM can call via `pi.registerTool()`. Tools appear in the system prompt and can have custom rendering. + +### Tool Definition + +```typescript +import { Type } from "@sinclair/typebox"; +import { StringEnum } from "@mariozechner/pi-ai"; +import { Text } from "@mariozechner/pi-tui"; + +pi.registerTool({ + name: "my_tool", + label: "My Tool", + description: "What this tool does (shown to LLM)", + parameters: Type.Object({ + action: StringEnum(["list", "add"] as const), // Use StringEnum for Google compatibility + text: Type.Optional(Type.String()), + }), + + async execute(toolCallId, params, onUpdate, ctx, signal) { + // Check for cancellation + if (signal?.aborted) { + return { content: [{ type: "text", text: "Cancelled" }] }; + } + + // Stream progress updates + onUpdate?.({ + content: [{ type: "text", text: "Working..." }], + details: { progress: 50 }, + }); + + // Run commands via pi.exec (captured from extension closure) + const result = await pi.exec("some-command", [], { signal }); + + // Return result + return { + content: [{ type: "text", text: "Done" }], // Sent to LLM + details: { data: result }, // For rendering & state + }; + }, + + // Optional: Custom rendering + renderCall(args, theme) { ... }, + renderResult(result, options, theme) { ... }, +}); +``` + +**Important:** Use `StringEnum` from `@mariozechner/pi-ai` for string enums. `Type.Union`/`Type.Literal` doesn't work with Google's API. + ### Overriding Built-in Tools -Register tool with same name (`read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`). Built-in renderer used if no custom render functions. Must match exact result shape including `details` type. +Extensions can override built-in tools (`read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`) by registering a tool with the same name. Interactive mode displays a warning when this happens. -Use `--no-tools -e ./my-extension.ts` to start with only extension tools. +```bash +# Extension's read tool replaces built-in read +pi -e ./tool-override.ts +``` + +Alternatively, use `--no-tools` to start without any built-in tools: +```bash +# No built-in tools, only extension tools +pi --no-tools -e ./my-extension.ts +``` + +See [examples/extensions/tool-override.ts](../examples/extensions/tool-override.ts) for a complete example that overrides `read` with logging and access control. + +**Rendering:** If your override doesn't provide custom `renderCall`/`renderResult` functions, the built-in renderer is used automatically (syntax highlighting, diffs, etc.). This lets you wrap built-in tools for logging or access control without reimplementing the UI. + +**Your implementation must match the exact result shape**, including the `details` type. The UI and session logic depend on these shapes for rendering and state tracking. + +Built-in tool implementations: +- [read.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/read.ts) - `ReadToolDetails` +- [bash.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/bash.ts) - `BashToolDetails` +- [edit.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/edit.ts) +- [write.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/write.ts) +- [grep.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/grep.ts) - `GrepToolDetails` +- [find.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/find.ts) - `FindToolDetails` +- [ls.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/tools/ls.ts) - `LsToolDetails` ### Remote Execution -Built-in tools support pluggable operations: +Built-in tools support pluggable operations for delegating to remote systems (SSH, containers, etc.): ```typescript -import { createReadTool, type ReadOperations } from "@mariozechner/pi-coding-agent"; +import { createReadTool, createBashTool, type ReadOperations } from "@mariozechner/pi-coding-agent"; +// Create tool with custom operations const remoteRead = createReadTool(cwd, { operations: { readFile: (path) => sshExec(remote, `cat ${path}`), access: (path) => sshExec(remote, `test -r ${path}`).then(() => {}), } }); + +// Register, checking flag at execution time +pi.registerTool({ + ...remoteRead, + async execute(id, params, onUpdate, _ctx, signal) { + const ssh = getSshConfig(); + if (ssh) { + const tool = createReadTool(cwd, { operations: createRemoteOps(ssh) }); + return tool.execute(id, params, signal, onUpdate); + } + return localRead.execute(id, params, signal, onUpdate); + }, +}); ``` -Interfaces: `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations` +**Operations interfaces:** `ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations` + +See [examples/extensions/ssh.ts](../examples/extensions/ssh.ts) for a complete SSH example with `--ssh` flag. ### Output Truncation -Tools MUST truncate output. Built-in limit: 50KB / 2000 lines. +**Tools MUST truncate their output** to avoid overwhelming the LLM context. Large outputs can cause: +- Context overflow errors (prompt too long) +- Compaction failures +- Degraded model performance + +The built-in limit is **50KB** (~10k tokens) and **2000 lines**, whichever is hit first. Use the exported truncation utilities: ```typescript -import { truncateHead, truncateTail, formatSize, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@mariozechner/pi-coding-agent"; +import { + truncateHead, // Keep first N lines/bytes (good for file reads, search results) + truncateTail, // Keep last N lines/bytes (good for logs, command output) + formatSize, // Human-readable size (e.g., "50KB", "1.5MB") + DEFAULT_MAX_BYTES, // 50KB + DEFAULT_MAX_LINES, // 2000 +} from "@mariozechner/pi-coding-agent"; -const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES }); -if (truncation.truncated) { - // Write full to temp file, inform LLM +async execute(toolCallId, params, onUpdate, ctx, signal) { + const output = await runCommand(); + + // Apply truncation + const truncation = truncateHead(output, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + + let result = truncation.content; + + if (truncation.truncated) { + // Write full output to temp file + const tempFile = writeTempFile(output); + + // Inform the LLM where to find complete output + result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`; + result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`; + result += ` Full output saved to: ${tempFile}]`; + } + + return { content: [{ type: "text", text: result }] }; +} +``` + +**Key points:** +- Use `truncateHead` for content where the beginning matters (search results, file reads) +- Use `truncateTail` for content where the end matters (logs, command output) +- Always inform the LLM when output is truncated and where to find the full version +- Document the truncation limits in your tool's description + +See [examples/extensions/truncated-tool.ts](../examples/extensions/truncated-tool.ts) for a complete example wrapping `rg` (ripgrep) with proper truncation. + +### Multiple Tools + +One extension can register multiple tools with shared state: + +```typescript +export default function (pi: ExtensionAPI) { + let connection = null; + + pi.registerTool({ name: "db_connect", ... }); + pi.registerTool({ name: "db_query", ... }); + pi.registerTool({ name: "db_close", ... }); + + pi.on("session_shutdown", async () => { + connection?.close(); + }); } ``` ### Custom Rendering +Tools can provide `renderCall` and `renderResult` for custom TUI display. See [tui.md](tui.md) for the full component API. + +Tool output is wrapped in a `Box` that handles padding and background. Your render methods return `Component` instances (typically `Text`). + +#### renderCall + +Renders the tool call (before/during execution): + ```typescript import { Text } from "@mariozechner/pi-tui"; renderCall(args, theme) { - return new Text(theme.fg("toolTitle", "my_tool ") + args.action, 0, 0); + let text = theme.fg("toolTitle", theme.bold("my_tool ")); + text += theme.fg("muted", args.action); + if (args.text) { + text += " " + theme.fg("dim", `"${args.text}"`); + } + return new Text(text, 0, 0); // 0,0 padding - Box handles it } +``` +#### renderResult + +Renders the tool result: + +```typescript renderResult(result, { expanded, isPartial }, theme) { - if (isPartial) return new Text(theme.fg("warning", "Processing..."), 0, 0); + // Handle streaming + if (isPartial) { + return new Text(theme.fg("warning", "Processing..."), 0, 0); + } + + // Handle errors + if (result.details?.error) { + return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0); + } + + // Normal result - support expanded view (Ctrl+O) let text = theme.fg("success", "✓ Done"); if (expanded && result.details?.items) { - for (const item of result.details.items) text += "\n " + theme.fg("dim", item); + for (const item of result.details.items) { + text += "\n " + theme.fg("dim", item); + } } return new Text(text, 0, 0); } ``` -Use `keyHint(action, description)` for keybinding hints. +#### Keybinding Hints + +Use `keyHint()` to display keybinding hints that respect user's keybinding configuration: + +```typescript +import { keyHint } from "@mariozechner/pi-coding-agent"; + +renderResult(result, { expanded }, theme) { + let text = theme.fg("success", "✓ Done"); + if (!expanded) { + text += ` (${keyHint("expandTools", "to expand")})`; + } + return new Text(text, 0, 0); +} +``` + +Available functions: +- `keyHint(action, description)` - Editor actions (e.g., `"expandTools"`, `"selectConfirm"`) +- `appKeyHint(keybindings, action, description)` - App actions (requires `KeybindingsManager`) +- `editorKey(action)` - Get raw key string for editor action +- `rawKeyHint(key, description)` - Format a raw key string + +#### Best Practices + +- Use `Text` with padding `(0, 0)` - the Box handles padding +- Use `\n` for multi-line content +- Handle `isPartial` for streaming progress +- Support `expanded` for detail on demand +- Keep default view compact + +#### Fallback + +If `renderCall`/`renderResult` is not defined or throws: +- `renderCall`: Shows tool name +- `renderResult`: Shows raw text from `content` ## Custom UI -See [tui.md](tui.md) for full component API. +Extensions can interact with users via `ctx.ui` methods and customize how messages/tools render. + +**For custom components, see [tui.md](tui.md)** which has copy-paste patterns for: +- Selection dialogs (SelectList) +- Async operations with cancel (BorderedLoader) +- Settings toggles (SettingsList) +- Status indicators (setStatus) +- Working message during streaming (setWorkingMessage) +- Widgets above/below editor (setWidget) +- Custom footers (setFooter) ### Dialogs ```typescript -const choice = await ctx.ui.select("Pick:", ["A", "B", "C"]); -const ok = await ctx.ui.confirm("Delete?", "Cannot be undone"); +// Select from options +const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]); + +// Confirm dialog +const ok = await ctx.ui.confirm("Delete?", "This cannot be undone"); + +// Text input const name = await ctx.ui.input("Name:", "placeholder"); -const text = await ctx.ui.editor("Edit:", "prefill"); -ctx.ui.notify("Done!", "info" | "warning" | "error"); + +// Multi-line editor +const text = await ctx.ui.editor("Edit:", "prefilled text"); + +// Notification (non-blocking) +ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error" ``` -Dialogs support `timeout` (auto-dismiss with countdown) and `signal` (manual abort): +**Examples:** +- `ctx.ui.select()`: [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [permission-gate.ts](../examples/extensions/permission-gate.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [question.ts](../examples/extensions/question.ts), [questionnaire.ts](../examples/extensions/questionnaire.ts) +- `ctx.ui.confirm()`: [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts) +- `ctx.ui.editor()`: [handoff.ts](../examples/extensions/handoff.ts) +- `ctx.ui.setEditorText()`: [handoff.ts](../examples/extensions/handoff.ts), [qna.ts](../examples/extensions/qna.ts) + +#### Timed Dialogs with Countdown + +Dialogs support a `timeout` option that auto-dismisses with a live countdown display: ```typescript -const ok = await ctx.ui.confirm("Title", "Message", { timeout: 5000 }); +// Dialog shows "Title (5s)" → "Title (4s)" → ... → auto-dismisses at 0 +const confirmed = await ctx.ui.confirm( + "Timed Confirmation", + "This dialog will auto-cancel in 5 seconds. Confirm?", + { timeout: 5000 } +); + +if (confirmed) { + // User confirmed +} else { + // User cancelled or timed out +} ``` -### Widgets, Status, Footer, Header +**Return values on timeout:** +- `select()` returns `undefined` +- `confirm()` returns `false` +- `input()` returns `undefined` + +#### Manual Dismissal with AbortSignal + +For more control (e.g., to distinguish timeout from user cancel), use `AbortSignal`: ```typescript -ctx.ui.setStatus("key", "text" | undefined); -ctx.ui.setWorkingMessage("Custom loading..." | undefined); -ctx.ui.setWidget("key", ["Line 1", "Line 2"], { placement: "aboveEditor" | "belowEditor" }); -ctx.ui.setWidget("key", (tui, theme) => Component); -ctx.ui.setFooter((tui, theme, footerData) => Component | undefined); -ctx.ui.setHeader((tui, theme) => Component | undefined); -ctx.ui.setTitle("Window title"); +const controller = new AbortController(); +const timeoutId = setTimeout(() => controller.abort(), 5000); + +const confirmed = await ctx.ui.confirm( + "Timed Confirmation", + "This dialog will auto-cancel in 5 seconds. Confirm?", + { signal: controller.signal } +); + +clearTimeout(timeoutId); + +if (confirmed) { + // User confirmed +} else if (controller.signal.aborted) { + // Dialog timed out +} else { + // User cancelled (pressed Escape or selected "No") +} ``` -### Editor +See [examples/extensions/timed-confirm.ts](../examples/extensions/timed-confirm.ts) for complete examples. + +### Widgets, Status, and Footer ```typescript -ctx.ui.setEditorText("prefill"); -ctx.ui.getEditorText(); -ctx.ui.setEditorComponent((tui, theme, keybindings) => EditorComponent | undefined); +// Status in footer (persistent until cleared) +ctx.ui.setStatus("my-ext", "Processing..."); +ctx.ui.setStatus("my-ext", undefined); // Clear + +// Working message (shown during streaming) +ctx.ui.setWorkingMessage("Thinking deeply..."); +ctx.ui.setWorkingMessage(); // Restore default + +// Widget above editor (default) +ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]); +// Widget below editor +ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" }); +ctx.ui.setWidget("my-widget", (tui, theme) => new Text(theme.fg("accent", "Custom"), 0, 0)); +ctx.ui.setWidget("my-widget", undefined); // Clear + +// Custom footer (replaces built-in footer entirely) +ctx.ui.setFooter((tui, theme) => ({ + render(width) { return [theme.fg("dim", "Custom footer")]; }, + invalidate() {}, +})); +ctx.ui.setFooter(undefined); // Restore built-in footer + +// Terminal title +ctx.ui.setTitle("pi - my-project"); + +// Editor text +ctx.ui.setEditorText("Prefill text"); +const current = ctx.ui.getEditorText(); + +// Custom editor (vim mode, emacs mode, etc.) +ctx.ui.setEditorComponent((tui, theme, keybindings) => new VimEditor(tui, theme, keybindings)); +ctx.ui.setEditorComponent(undefined); // Restore default editor + +// Theme management +const themes = ctx.ui.getAllThemes(); // [{ name: "dark", path: "/..." | undefined }, ...] +const lightTheme = ctx.ui.getTheme("light"); // Load without switching +const result = ctx.ui.setTheme("light"); // Switch by name +if (!result.success) { + ctx.ui.notify(`Failed: ${result.error}`, "error"); +} +ctx.ui.setTheme(lightTheme!); // Or switch by Theme object +ctx.ui.theme.fg("accent", "styled text"); // Access current theme ``` -### Theme - -```typescript -ctx.ui.theme.fg("accent", "text") -ctx.ui.getAllThemes() -ctx.ui.getTheme("light") -ctx.ui.setTheme("light" | themeObject) -``` +**Examples:** +- `ctx.ui.setStatus()`: [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts), [status-line.ts](../examples/extensions/status-line.ts) +- `ctx.ui.setWidget()`: [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts) +- `ctx.ui.setFooter()`: [custom-footer.ts](../examples/extensions/custom-footer.ts) +- `ctx.ui.setHeader()`: [custom-header.ts](../examples/extensions/custom-header.ts) +- `ctx.ui.setEditorComponent()`: [modal-editor.ts](../examples/extensions/modal-editor.ts) +- `ctx.ui.setTheme()`: [mac-system-theme.ts](../examples/extensions/mac-system-theme.ts) ### Custom Components +For complex UI, use `ctx.ui.custom()`. This temporarily replaces the editor with your component until `done()` is called: + ```typescript -const result = await ctx.ui.custom((tui, theme, keybindings, done) => { - return { render(width) { ... }, handleInput(data) { ... }, invalidate() { ... } }; -}, { overlay?: true, overlayOptions?: { anchor, width, margin, ... }, onHandle?: (handle) => {} }); +import { Text, Component } from "@mariozechner/pi-tui"; + +const result = await ctx.ui.custom((tui, theme, keybindings, done) => { + const text = new Text("Press Enter to confirm, Escape to cancel", 1, 1); + + text.onKey = (key) => { + if (key === "return") done(true); + if (key === "escape") done(false); + return true; + }; + + return text; +}); + +if (result) { + // User pressed Enter +} ``` +The callback receives: +- `tui` - TUI instance (for screen dimensions, focus management) +- `theme` - Current theme for styling +- `keybindings` - App keybinding manager (for checking shortcuts) +- `done(value)` - Call to close component and return value + +See [tui.md](tui.md) for the full component API. + +#### Overlay Mode (Experimental) + +Pass `{ overlay: true }` to render the component as a floating modal on top of existing content, without clearing the screen: + +```typescript +const result = await ctx.ui.custom( + (tui, theme, keybindings, done) => new MyOverlayComponent({ onClose: done }), + { overlay: true } +); +``` + +For advanced positioning (anchors, margins, percentages, responsive visibility), pass `overlayOptions`. Use `onHandle` to control visibility programmatically: + +```typescript +const result = await ctx.ui.custom( + (tui, theme, keybindings, done) => new MyOverlayComponent({ onClose: done }), + { + overlay: true, + overlayOptions: { anchor: "top-right", width: "50%", margin: 2 }, + onHandle: (handle) => { /* handle.setHidden(true/false) */ } + } +); +``` + +See [tui.md](tui.md) for the full `OverlayOptions` API and [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for examples. + +**Examples:** [handoff.ts](../examples/extensions/handoff.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts), [qna.ts](../examples/extensions/qna.ts), [snake.ts](../examples/extensions/snake.ts), [summarize.ts](../examples/extensions/summarize.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts), [overlay-test.ts](../examples/extensions/overlay-test.ts) + +### Custom Editor + +Replace the main input editor with a custom implementation (vim mode, emacs mode, etc.): + +```typescript +import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { matchesKey } from "@mariozechner/pi-tui"; + +class VimEditor extends CustomEditor { + private mode: "normal" | "insert" = "insert"; + + handleInput(data: string): void { + if (matchesKey(data, "escape") && this.mode === "insert") { + this.mode = "normal"; + return; + } + if (this.mode === "normal" && data === "i") { + this.mode = "insert"; + return; + } + super.handleInput(data); // App keybindings + text editing + } +} + +export default function (pi: ExtensionAPI) { + pi.on("session_start", (_event, ctx) => { + ctx.ui.setEditorComponent((_tui, theme, keybindings) => + new VimEditor(theme, keybindings) + ); + }); +} +``` + +**Key points:** +- Extend `CustomEditor` (not base `Editor`) to get app keybindings (escape to abort, ctrl+d, model switching) +- Call `super.handleInput(data)` for keys you don't handle +- Factory receives `theme` and `keybindings` from the app +- Pass `undefined` to restore default: `ctx.ui.setEditorComponent(undefined)` + +See [tui.md](tui.md) Pattern 7 for a complete example with mode indicator. + +**Examples:** [modal-editor.ts](../examples/extensions/modal-editor.ts) + ### Message Rendering +Register a custom renderer for messages with your `customType`: + ```typescript -pi.registerMessageRenderer("my-ext", (message, { expanded }, theme) => { - return new Text(theme.fg("accent", message.content), 0, 0); +import { Text } from "@mariozechner/pi-tui"; + +pi.registerMessageRenderer("my-extension", (message, options, theme) => { + const { expanded } = options; + let text = theme.fg("accent", `[${message.customType}] `); + text += message.content; + + if (expanded && message.details) { + text += "\n" + theme.fg("dim", JSON.stringify(message.details, null, 2)); + } + + return new Text(text, 0, 0); }); ``` +Messages are sent via `pi.sendMessage()`: + +```typescript +pi.sendMessage({ + customType: "my-extension", // Matches registerMessageRenderer + content: "Status update", + display: true, // Show in TUI + details: { ... }, // Available in renderer +}); +``` + +### Theme Colors + +All render functions receive a `theme` object: + +```typescript +// Foreground colors +theme.fg("toolTitle", text) // Tool names +theme.fg("accent", text) // Highlights +theme.fg("success", text) // Success (green) +theme.fg("error", text) // Errors (red) +theme.fg("warning", text) // Warnings (yellow) +theme.fg("muted", text) // Secondary text +theme.fg("dim", text) // Tertiary text + +// Text styles +theme.bold(text) +theme.italic(text) +theme.strikethrough(text) +``` + ## Error Handling -- Extension errors logged, agent continues +- Extension errors are logged, agent continues - `tool_call` errors block the tool (fail-safe) -- Tool `execute` errors reported to LLM with `isError: true` +- Tool `execute` errors are reported to the LLM with `isError: true` ## Mode Behavior @@ -747,71 +1834,6 @@ pi.registerMessageRenderer("my-ext", (message, { expanded }, theme) => { |------|-----------|-------| | Interactive | Full TUI | Normal operation | | RPC | JSON protocol | Host handles UI | -| Print (`-p`) | No-op | Check `ctx.hasUI` | +| Print (`-p`) | No-op | Extensions run but can't prompt | -## Examples Reference - -All examples in [examples/extensions/](../examples/extensions/). - -| Example | Description | Key APIs | -|---------|-------------|----------| -| **Tools** | -| `hello.ts` | Minimal tool registration | `registerTool` | -| `question.ts` | Tool with user interaction | `registerTool`, `ui.select` | -| `questionnaire.ts` | Multi-step wizard tool | `registerTool`, `ui.custom` | -| `todo.ts` | Stateful tool with persistence | `registerTool`, `appendEntry`, `renderResult` | -| `truncated-tool.ts` | Output truncation example | `registerTool`, `truncateHead` | -| `tool-override.ts` | Override built-in read tool | `registerTool` | -| **Commands** | -| `pirate.ts` | Modify system prompt | `registerCommand`, `before_agent_start` | -| `summarize.ts` | Conversation summary | `registerCommand`, `ui.custom` | -| `handoff.ts` | Cross-provider handoff | `registerCommand`, `ui.editor` | -| `qna.ts` | Q&A with custom UI | `registerCommand`, `ui.custom` | -| `send-user-message.ts` | Inject user messages | `registerCommand`, `sendUserMessage` | -| `shutdown-command.ts` | Graceful shutdown | `registerCommand`, `shutdown()` | -| **Events & Gates** | -| `permission-gate.ts` | Block dangerous commands | `on("tool_call")`, `ui.confirm` | -| `protected-paths.ts` | Block writes to paths | `on("tool_call")` | -| `confirm-destructive.ts` | Confirm session changes | `on("session_before_*")` | -| `dirty-repo-guard.ts` | Warn on dirty git repo | `on("session_before_*")`, `exec` | -| `input-transform.ts` | Transform user input | `on("input")` | -| `model-status.ts` | React to model changes | `on("model_select")` | -| **Compaction & Sessions** | -| `custom-compaction.ts` | Custom compaction summary | `on("session_before_compact")` | -| `trigger-compact.ts` | Trigger compaction manually | `compact()` | -| `git-checkpoint.ts` | Git stash on turns | `on("turn_end")`, `exec` | -| `auto-commit-on-exit.ts` | Commit on shutdown | `on("session_shutdown")`, `exec` | -| **UI Components** | -| `status-line.ts` | Footer status indicator | `setStatus` | -| `custom-footer.ts` | Replace footer | `setFooter` | -| `custom-header.ts` | Replace startup header | `setHeader` | -| `modal-editor.ts` | Vim-style editor | `setEditorComponent` | -| `rainbow-editor.ts` | Custom editor styling | `setEditorComponent` | -| `widget-placement.ts` | Widget positioning | `setWidget` | -| `overlay-test.ts` | Overlay components | `ui.custom`, overlay options | -| `overlay-qa-tests.ts` | Comprehensive overlay tests | `ui.custom`, all overlay options | -| `notify.ts` | Simple notifications | `ui.notify` | -| `timed-confirm.ts` | Dialogs with timeout | `ui.confirm`, timeout/signal | -| **Complex Extensions** | -| `plan-mode/` | Full plan mode implementation | All APIs | -| `preset.ts` | Saveable presets (model, tools) | `registerCommand`, `registerShortcut`, `registerFlag`, `setModel`, `setActiveTools` | -| `tools.ts` | Toggle tools on/off | `registerCommand`, `setActiveTools`, `SettingsList` | -| `claude-rules.ts` | Load rules from files | `on("before_agent_start")` | -| `file-trigger.ts` | File watcher triggers | `sendMessage` | -| **Remote & Sandbox** | -| `ssh.ts` | SSH remote execution | `registerFlag`, `on("user_bash")`, tool operations | -| `interactive-shell.ts` | Persistent shell | `on("user_bash")` | -| `sandbox/` | Sandboxed execution | Tool operations | -| `subagent/` | Spawn sub-agents | `exec`, tool registration | -| **Games & Fun** | -| `snake.ts` | Snake game | `registerCommand`, `ui.custom` | -| `space-invaders.ts` | Space Invaders game | `registerCommand`, `ui.custom` | -| `doom-overlay/` | Doom in overlay | `ui.custom`, overlay | -| **Providers** | -| `custom-provider-anthropic/` | Custom Anthropic proxy | `registerProvider` | -| `custom-provider-gitlab-duo/` | GitLab Duo integration | `registerProvider`, OAuth | -| **Misc** | -| `mac-system-theme.ts` | Auto-switch theme | `setTheme` | -| `antigravity-image-gen.ts` | Image generation | `registerTool`, Google Antigravity | -| `inline-bash.ts` | Inline bash execution | `on("tool_call")` | -| `with-deps/` | Extension with npm deps | Package structure | +In print mode, check `ctx.hasUI` before using UI methods.