co-mono/packages/coding-agent/docs/extensions.md
Mario Zechner 7210086677 Extensions: add pi.sendUserMessage() for sending user messages
Adds sendUserMessage() to the extension API, allowing extensions to send
actual user messages (role: user) rather than custom messages. Unlike
sendMessage(), this always triggers a turn and behaves as if the user
typed the message.

- Add SendUserMessageHandler type and sendUserMessage() to ExtensionAPI
- Wire handler through loader, runner, and all modes
- Implement via prompt() with expandPromptTemplates: false
- Add send-user-message.ts example with /ask, /steer, /followup commands
- Document in extensions.md

fixes #483
2026-01-06 13:40:24 +01:00

1088 lines
29 KiB
Markdown

> pi can create extensions. Ask it to build one for your use case.
# Extensions
Extensions are TypeScript modules that extend pi's behavior. They can subscribe to lifecycle events, register custom tools callable by the LLM, add commands, and more.
**Key capabilities:**
- **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()` 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
**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)
- 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
- [Quick Start](#quick-start)
- [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)
- [State Management](#state-management)
- [Custom Tools](#custom-tools)
- [Custom UI](#custom-ui)
- [Error Handling](#error-handling)
- [Mode Behavior](#mode-behavior)
## Quick Start
Create `~/.pi/agent/extensions/my-extension.ts`:
```typescript
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
export default function (pi: ExtensionAPI) {
// React to events
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify("Extension loaded!", "info");
});
pi.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
if (!ok) return { block: true, reason: "Blocked by user" };
}
});
// Register a custom tool
pi.registerTool({
name: "greet",
label: "Greet",
description: "Greet someone by name",
parameters: Type.Object({
name: Type.String({ description: "Name to greet" }),
}),
async execute(toolCallId, params, onUpdate, ctx, signal) {
return {
content: [{ type: "text", text: `Hello, ${params.name}!` }],
details: {},
};
},
});
// Register a command
pi.registerCommand("hello", {
description: "Say hello",
handler: async (args, ctx) => {
ctx.ui.notify(`Hello ${args || "world"}!`, "info");
},
});
}
```
Test with `--extension` (or `-e`) flag:
```bash
pi -e ./my-extension.ts
```
## Extension Locations
Extensions are auto-discovered from:
| Location | Scope |
|----------|-------|
| `~/.pi/agent/extensions/*.ts` | Global (all projects) |
| `~/.pi/agent/extensions/*/index.ts` | Global (subdirectory) |
| `.pi/extensions/*.ts` | Project-local |
| `.pi/extensions/*/index.ts` | Project-local (subdirectory) |
Additional paths via `settings.json`:
```json
{
"extensions": ["/path/to/extension.ts", "/path/to/extension/dir"]
}
```
**Discovery rules:**
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
// my-extension-pack/package.json
{
"name": "my-extension-pack",
"dependencies": {
"zod": "^3.0.0"
},
"pi": {
"extensions": ["./src/safety-gates.ts", "./src/custom-tools.ts"]
}
}
```
The `package.json` approach enables:
- Multiple extensions from one package
- Third-party npm dependencies (resolved via jiti)
- Nested source structure (no depth limit within the package)
- Deployment to and installation from npm
## Available Imports
| Package | Purpose |
|---------|---------|
| `@mariozechner/pi-coding-agent` | Extension types (`ExtensionAPI`, `ExtensionContext`, events) |
| `@sinclair/typebox` | Schema definitions for tool parameters |
| `@mariozechner/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) |
| `@mariozechner/pi-tui` | TUI components for custom rendering |
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
An extension exports a default function that receives `ExtensionAPI`:
```typescript
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: ExtensionAPI) {
// 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
});
// Register tools, commands, shortcuts, flags
pi.registerTool({ ... });
pi.registerCommand("name", { ... });
pi.registerShortcut("ctrl+x", { ... });
pi.registerFlag("--my-flag", { ... });
}
```
Extensions are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation.
### 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
user sends prompt ─────────────────────────────────────────┐
│ │
├─► before_agent_start (can inject message, append to 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 ◄────────────────────────────────┘
/new (new session) or /resume (switch session)
├─► session_before_switch (can cancel)
└─► session_switch
/branch
├─► session_before_branch (can cancel)
└─► session_branch
/compact or auto-compaction
├─► session_before_compact (can cancel or customize)
└─► session_compact
/tree navigation
├─► session_before_tree (can cancel or customize)
└─► session_tree
exit (Ctrl+C, Ctrl+D)
└─► session_shutdown
```
### Session Events
#### session_start
Fired on initial session load.
```typescript
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info");
});
```
#### session_before_switch / session_switch
Fired when starting a new session (`/new`) or switching sessions (`/resume`).
```typescript
pi.on("session_before_switch", async (event, ctx) => {
// 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
});
```
#### session_before_branch / session_branch
Fired when branching via `/branch`.
```typescript
pi.on("session_before_branch", async (event, ctx) => {
// event.entryId - ID of the entry being branched from
return { cancel: true }; // Cancel branch
// OR
return { skipConversationRestore: true }; // Branch but don't rewind messages
});
pi.on("session_branch", async (event, ctx) => {
// event.previousSessionFile - previous session file
});
```
#### session_before_compact / session_compact
Fired on compaction. See [compaction.md](compaction.md) for details.
```typescript
pi.on("session_before_compact", async (event, ctx) => {
const { preparation, branchEntries, customInstructions, signal } = event;
// Cancel:
return { cancel: true };
// 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
});
```
#### session_before_tree / session_tree
Fired on `/tree` navigation.
```typescript
pi.on("session_before_tree", async (event, ctx) => {
const { preparation, signal } = event;
return { cancel: true };
// OR provide custom summary:
return { summary: { summary: "...", details: {} } };
});
pi.on("session_tree", async (event, ctx) => {
// event.newLeafId, oldLeafId, summaryEntry, fromExtension
});
```
#### session_shutdown
Fired on exit (Ctrl+C, Ctrl+D, SIGTERM).
```typescript
pi.on("session_shutdown", async (_event, ctx) => {
// Cleanup, save state, etc.
});
```
### Agent Events
#### before_agent_start
Fired after user submits prompt, before agent loop. Can inject a message and/or append to the system prompt.
```typescript
pi.on("before_agent_start", async (event, ctx) => {
// event.prompt - user's prompt text
// event.images - attached images (if any)
return {
// Inject a persistent message (stored in session, sent to LLM)
message: {
customType: "my-extension",
content: "Additional context for the LLM",
display: true,
},
// Append to system prompt for this turn only
systemPromptAppend: "Extra instructions for this turn...",
};
});
```
#### 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
});
```
#### 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
});
```
#### context
Fired before each LLM call. Modify messages non-destructively.
```typescript
pi.on("context", async (event, ctx) => {
// event.messages - deep copy, safe to modify
const filtered = event.messages.filter(m => !shouldPrune(m));
return { messages: filtered };
});
```
### Tool Events
#### tool_call
Fired before tool executes. **Can block.**
```typescript
pi.on("tool_call", async (event, ctx) => {
// event.toolName - "bash", "read", "write", "edit", etc.
// event.toolCallId
// event.input - tool parameters
if (shouldBlock(event)) {
return { block: true, reason: "Not allowed" };
}
});
```
#### 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 is typed as BashToolDetails
}
// Modify result:
return { content: [...], details: {...}, isError: false };
});
```
## ExtensionContext
Every handler receives `ctx: ExtensionContext`:
### ctx.ui
UI methods for user interaction. See [Custom UI](#custom-ui) for full details.
### ctx.hasUI
`false` in print mode (`-p`), JSON mode, and RPC mode. Always check before using `ctx.ui`.
### ctx.cwd
Current working directory.
### ctx.sessionManager
Read-only access to session state:
```typescript
ctx.sessionManager.getEntries() // All entries
ctx.sessionManager.getBranch() // Current branch
ctx.sessionManager.getLeafId() // Current leaf entry ID
```
### ctx.modelRegistry / ctx.model
Access to models and API keys.
### ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages()
Control flow helpers.
## ExtensionCommandContext
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.branch(entryId)
Branch from a specific entry:
```typescript
const result = await ctx.branch("entry-id-123");
if (!result.cancelled) {
// Now in the branched 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,
});
```
## ExtensionAPI Methods
### pi.on(event, handler)
Subscribe to events. See [Events](#events).
### 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";
import { StringEnum } from "@mariozechner/pi-ai";
pi.registerTool({
name: "my_tool",
label: "My Tool",
description: "What this tool does",
parameters: Type.Object({
action: StringEnum(["list", "add"] as const),
text: Type.Optional(Type.String()),
}),
async execute(toolCallId, params, onUpdate, ctx, signal) {
// Stream progress
onUpdate?.({ content: [{ type: "text", text: "Working..." }] });
return {
content: [{ type: "text", text: "Done" }],
details: { result: "..." },
};
},
// Optional: Custom rendering
renderCall(args, theme) { ... },
renderResult(result, options, theme) { ... },
});
```
### 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"`).
### 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
}
}
});
```
### 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");
}
});
```
### 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!");
},
});
```
### 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
}
```
### 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
```
### pi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names)
Manage active tools:
```typescript
const active = pi.getActiveTools(); // ["read", "bash", "edit", "write"]
pi.setActiveTools(["read", "bash"]); // Switch to read-only
```
### pi.events
Shared event bus for communication between extensions:
```typescript
pi.events.on("my:event", (data) => { ... });
pi.events.emit("my:event", { ... });
```
## 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.
### 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) {
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) {
// 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);
}
}
return new Text(text, 0, 0);
}
```
#### 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
Extensions can interact with users via `ctx.ui` methods and customize how messages/tools render.
### Dialogs
```typescript
// 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");
// Multi-line editor
const text = await ctx.ui.editor("Edit:", "prefilled text");
// Notification (non-blocking)
ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
```
### Widgets, Status, and Footer
```typescript
// Status in footer (persistent until cleared)
ctx.ui.setStatus("my-ext", "Processing...");
ctx.ui.setStatus("my-ext", undefined); // Clear
// Widget above editor (string array or factory function)
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
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 Components
For complex UI, use `ctx.ui.custom()`. This temporarily replaces the editor with your component until `done()` is called:
```typescript
import { Text, Component } from "@mariozechner/pi-tui";
const result = await ctx.ui.custom<boolean>((tui, theme, 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
- `done(value)` - Call to close component and return value
See [tui.md](tui.md) for the full component API and [examples/extensions/](../examples/extensions/) for working examples (snake.ts, todo.ts, qna.ts).
### Message Rendering
Register a custom renderer for messages with your `customType`:
```typescript
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 are logged, agent continues
- `tool_call` errors block the tool (fail-safe)
- Tool `execute` errors are reported to the LLM with `isError: true`
## Mode Behavior
| Mode | UI Methods | Notes |
|------|-----------|-------|
| Interactive | Full TUI | Normal operation |
| RPC | JSON protocol | Host handles UI |
| Print (`-p`) | No-op | Extensions run but can't prompt |
In print mode, check `ctx.hasUI` before using UI methods.