Merge branch 'main' into feat/tui-overlay-options

This commit is contained in:
Mario Zechner 2026-01-13 22:06:02 +01:00
commit 7d45e434de
90 changed files with 10277 additions and 1700 deletions

1
packages/coding-agent/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
*.bun-build

View file

@ -2,6 +2,60 @@
## [Unreleased]
## [0.45.5] - 2026-01-13
### Fixed
- Skip changelog display on fresh install (only show on upgrades)
## [0.45.4] - 2026-01-13
### Changed
- Light theme colors adjusted for WCAG AA compliance (4.5:1 contrast ratio against white backgrounds)
- Replaced `sharp` with `wasm-vips` for image processing (resize, PNG conversion). Eliminates native build requirements that caused installation failures on some systems. ([#696](https://github.com/badlogic/pi-mono/issues/696))
### Added
- Extension example: `summarize.ts` for summarizing conversations using custom UI and an external model ([#684](https://github.com/badlogic/pi-mono/pull/684) by [@scutifer](https://github.com/scutifer))
- Extension example: `question.ts` enhanced with custom UI for asking user questions ([#693](https://github.com/badlogic/pi-mono/pull/693) by [@ferologics](https://github.com/ferologics))
- Extension example: `plan-mode/` enhanced with explicit step tracking and progress widget ([#694](https://github.com/badlogic/pi-mono/pull/694) by [@ferologics](https://github.com/ferologics))
- Extension example: `questionnaire.ts` for multi-question input with tab bar navigation ([#695](https://github.com/badlogic/pi-mono/pull/695) by [@ferologics](https://github.com/ferologics))
- Experimental Vercel AI Gateway provider support: set `AI_GATEWAY_API_KEY` and use `--provider vercel-ai-gateway`. Token usage is currently reported incorrectly by Anthropic Messages compatible endpoint. ([#689](https://github.com/badlogic/pi-mono/pull/689) by [@timolins](https://github.com/timolins))
### Fixed
- Fix API key resolution after model switches by using provider argument ([#691](https://github.com/badlogic/pi-mono/pull/691) by [@joshp123](https://github.com/joshp123))
- Fixed z.ai thinking/reasoning: thinking toggle now correctly enables/disables thinking for z.ai models ([#688](https://github.com/badlogic/pi-mono/issues/688))
- Fixed extension loading in compiled Bun binary: extensions with local file imports now work correctly. Updated `@mariozechner/jiti` to v2.6.5 which bundles babel for Bun binary compatibility. ([#681](https://github.com/badlogic/pi-mono/issues/681))
- Fixed theme loading when installed via mise: use wrapper directory in release tarballs for compatibility with mise's `strip_components=1` extraction. ([#681](https://github.com/badlogic/pi-mono/issues/681))
## [0.45.3] - 2026-01-13
## [0.45.2] - 2026-01-13
### Fixed
- Extensions now load correctly in compiled Bun binary using `@mariozechner/jiti` fork with `virtualModules` support. Bundled packages (`@sinclair/typebox`, `@mariozechner/pi-tui`, `@mariozechner/pi-ai`, `@mariozechner/pi-coding-agent`) are accessible to extensions without filesystem node_modules.
## [0.45.1] - 2026-01-13
### Changed
- `/share` now outputs `buildwithpi.ai` session preview URLs instead of `shittycodingagent.ai`
## [0.45.0] - 2026-01-13
### Added
- MiniMax provider support: set `MINIMAX_API_KEY` and use `minimax/MiniMax-M2.1` ([#656](https://github.com/badlogic/pi-mono/pull/656) by [@dannote](https://github.com/dannote))
- `/scoped-models`: Alt+Up/Down to reorder enabled models. Order is preserved when saving with Ctrl+S and determines Ctrl+P cycling order. ([#676](https://github.com/badlogic/pi-mono/pull/676) by [@thomasmhr](https://github.com/thomasmhr))
- Amazon Bedrock provider support (experimental, tested with Anthropic Claude models only) ([#494](https://github.com/badlogic/pi-mono/pull/494) by [@unexge](https://github.com/unexge))
- Extension example: `sandbox/` for OS-level bash sandboxing using `@anthropic-ai/sandbox-runtime` with per-project config ([#673](https://github.com/badlogic/pi-mono/pull/673) by [@dannote](https://github.com/dannote))
- Print mode JSON output now emits the session header as the first line.
## [0.44.0] - 2026-01-12
### Breaking Changes
- `pi.getAllTools()` now returns `ToolInfo[]` (with `name` and `description`) instead of `string[]`. Extensions that only need names can use `.map(t => t.name)`. ([#648](https://github.com/badlogic/pi-mono/pull/648) by [@carsonfarmer](https://github.com/carsonfarmer))

View file

@ -166,7 +166,9 @@ Add API keys to `~/.pi/agent/auth.json`:
| Cerebras | `cerebras` | `CEREBRAS_API_KEY` |
| xAI | `xai` | `XAI_API_KEY` |
| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` |
| Vercel AI Gateway | `vercel-ai-gateway` | `AI_GATEWAY_API_KEY` |
| ZAI | `zai` | `ZAI_API_KEY` |
| MiniMax | `minimax` | `MINIMAX_API_KEY` |
Auth file keys take priority over environment variables.
@ -211,6 +213,29 @@ Credentials stored in `~/.pi/agent/auth.json`. Use `/logout` to clear.
- **Token expired / refresh failed:** Run `/login` again for the provider to refresh credentials.
- **Usage limits (429):** Wait for the reset window; pi will surface a friendly message with the approximate retry time.
**Amazon Bedrock:**
Amazon Bedrock supports multiple authentication methods:
```bash
# Option 1: AWS Profile (from ~/.aws/credentials)
export AWS_PROFILE=your-profile-name
# Option 2: IAM Access Keys
export AWS_ACCESS_KEY_ID=AKIA...
export AWS_SECRET_ACCESS_KEY=...
# Option 3: Bedrock API Key (bearer token)
export AWS_BEARER_TOKEN_BEDROCK=...
# Optional: Set region (defaults to us-east-1)
export AWS_REGION=us-east-1
pi --provider amazon-bedrock --model global.anthropic.claude-sonnet-4-5-20250929-v1:0
```
See [Supported foundation models in Amazon Bedrock](https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html).
### Quick Start
```bash
@ -1119,7 +1144,7 @@ pi [options] [@files...] [messages...]
| Option | Description |
|--------|-------------|
| `--provider <name>` | Provider: `anthropic`, `openai`, `openai-codex`, `google`, `mistral`, `xai`, `groq`, `cerebras`, `openrouter`, `zai`, `github-copilot`, `google-gemini-cli`, `google-antigravity`, or custom |
| `--provider <name>` | Provider: `anthropic`, `openai`, `openai-codex`, `google`, `google-vertex`, `amazon-bedrock`, `mistral`, `xai`, `groq`, `cerebras`, `openrouter`, `vercel-ai-gateway`, `zai`, `minimax`, `github-copilot`, `google-gemini-cli`, `google-antigravity`, or custom |
| `--model <id>` | Model ID |
| `--api-key <key>` | API key (overrides environment) |
| `--system-prompt <text\|file>` | Custom system prompt (text or file path) |

View file

@ -18,6 +18,7 @@ Extensions are TypeScript modules that extend pi's behavior. They can subscribe
- 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)
@ -438,7 +439,7 @@ pi.on("before_agent_start", async (event, ctx) => {
});
```
**Examples:** [claude-rules.ts](../examples/extensions/claude-rules.ts), [pirate.ts](../examples/extensions/pirate.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [ssh.ts](../examples/extensions/ssh.ts)
**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
@ -452,7 +453,7 @@ pi.on("agent_end", async (event, ctx) => {
});
```
**Examples:** [chalk-logger.ts](../examples/extensions/chalk-logger.ts), [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts)
**Examples:** [chalk-logger.ts](../examples/extensions/chalk-logger.ts), [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts)
#### turn_start / turn_end
@ -468,7 +469,7 @@ pi.on("turn_end", async (event, ctx) => {
});
```
**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [status-line.ts](../examples/extensions/status-line.ts)
**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
@ -482,7 +483,7 @@ pi.on("context", async (event, ctx) => {
});
```
**Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts)
**Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts)
### Model Events
@ -527,7 +528,7 @@ pi.on("tool_call", async (event, ctx) => {
});
```
**Examples:** [chalk-logger.ts](../examples/extensions/chalk-logger.ts), [permission-gate.ts](../examples/extensions/permission-gate.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [protected-paths.ts](../examples/extensions/protected-paths.ts)
**Examples:** [chalk-logger.ts](../examples/extensions/chalk-logger.ts), [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
@ -549,7 +550,7 @@ pi.on("tool_result", async (event, ctx) => {
});
```
**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts)
**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts)
### User Bash Events
@ -723,7 +724,7 @@ pi.registerTool({
});
```
**Examples:** [hello.ts](../examples/extensions/hello.ts), [question.ts](../examples/extensions/question.ts), [todo.ts](../examples/extensions/todo.ts), [truncated-tool.ts](../examples/extensions/truncated-tool.ts)
**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?)
@ -748,7 +749,7 @@ pi.sendMessage({
- `"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.ts](../examples/extensions/plan-mode.ts)
**Examples:** [file-trigger.ts](../examples/extensions/file-trigger.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts)
### pi.sendUserMessage(content, options?)
@ -795,7 +796,7 @@ pi.on("session_start", async (_event, ctx) => {
});
```
**Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [snake.ts](../examples/extensions/snake.ts), [tools.ts](../examples/extensions/tools.ts)
**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)
@ -830,7 +831,7 @@ pi.registerCommand("stats", {
});
```
**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.ts](../examples/extensions/plan-mode.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), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts)
**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)
@ -849,7 +850,7 @@ pi.registerShortcut("ctrl+shift+p", {
});
```
**Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts)
**Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts)
### pi.registerFlag(name, options)
@ -868,7 +869,7 @@ if (pi.getFlag("--plan")) {
}
```
**Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts)
**Examples:** [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts)
### pi.exec(command, args, options?)
@ -892,7 +893,7 @@ const names = all.map(t => t.name); // Just names if needed
pi.setActiveTools(["read", "bash"]); // Switch to read-only
```
**Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [tools.ts](../examples/extensions/tools.ts)
**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)
@ -1243,7 +1244,7 @@ ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
```
**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.ts](../examples/extensions/plan-mode.ts), [question.ts](../examples/extensions/question.ts)
- `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)
@ -1345,8 +1346,8 @@ ctx.ui.theme.fg("accent", "styled text"); // Access current theme
```
**Examples:**
- `ctx.ui.setStatus()`: [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [status-line.ts](../examples/extensions/status-line.ts)
- `ctx.ui.setWidget()`: [plan-mode.ts](../examples/extensions/plan-mode.ts)
- `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)
@ -1395,9 +1396,22 @@ const result = await ctx.ui.custom<string | null>(
);
```
Overlay components should define a `width` property to control their size. The overlay is centered by default. See [overlay-test.ts](../examples/extensions/overlay-test.ts) for a complete example.
For advanced positioning (anchors, margins, percentages, responsive visibility), pass `overlayOptions`. Use `onHandle` to control visibility programmatically:
**Examples:** [handoff.ts](../examples/extensions/handoff.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts), [qna.ts](../examples/extensions/qna.ts), [snake.ts](../examples/extensions/snake.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts), [overlay-test.ts](../examples/extensions/overlay-test.ts)
```typescript
const result = await ctx.ui.custom<string | null>(
(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

View file

@ -735,12 +735,12 @@ import {
discoverAuthStorage,
discoverModels,
discoverSkills,
discoverHooks,
discoverCustomTools,
discoverExtensions,
discoverContextFiles,
discoverPromptTemplates,
loadSettings,
buildSystemPrompt,
createEventBus,
} from "@mariozechner/pi-coding-agent";
// Auth and Models
@ -754,19 +754,16 @@ const builtIn = getModel("anthropic", "claude-opus-4-5"); // Built-in only
// Skills
const { skills, warnings } = discoverSkills(cwd, agentDir, skillsSettings);
// Hooks (async - loads TypeScript)
// Pass eventBus to share pi.events across hooks/tools
// Extensions (async - loads TypeScript)
// Pass eventBus to share pi.events across extensions
const eventBus = createEventBus();
const hooks = await discoverHooks(eventBus, cwd, agentDir);
// Custom tools (async - loads TypeScript)
const tools = await discoverCustomTools(eventBus, cwd, agentDir);
const { extensions, errors } = await discoverExtensions(eventBus, cwd, agentDir);
// Context files
const contextFiles = discoverContextFiles(cwd, agentDir);
// Prompt templates
const commands = discoverPromptTemplates(cwd, agentDir);
const templates = discoverPromptTemplates(cwd, agentDir);
// Settings (global + project merged)
const settings = loadSettings(cwd, agentDir);
@ -816,8 +813,8 @@ import {
SettingsManager,
readTool,
bashTool,
type HookFactory,
type CustomTool,
type ExtensionFactory,
type ToolDefinition,
} from "@mariozechner/pi-coding-agent";
// Set up auth storage (custom location)
@ -831,16 +828,16 @@ if (process.env.MY_KEY) {
// Model registry (no custom models.json)
const modelRegistry = new ModelRegistry(authStorage);
// Inline hook
const auditHook: HookFactory = (api) => {
api.on("tool_call", async (event) => {
// Inline extension
const auditExtension: ExtensionFactory = (pi) => {
pi.on("tool_call", async (event) => {
console.log(`[Audit] ${event.toolName}`);
return undefined;
});
};
// Inline tool
const statusTool: CustomTool = {
const statusTool: ToolDefinition = {
name: "status",
label: "Status",
description: "Get system status",
@ -872,8 +869,8 @@ const { session } = await createAgentSession({
systemPrompt: "You are a minimal assistant. Be concise.",
tools: [readTool, bashTool],
customTools: [{ tool: statusTool }],
hooks: [{ factory: auditHook }],
customTools: [statusTool],
extensions: [auditExtension],
skills: [],
contextFiles: [],
promptTemplates: [],
@ -961,7 +958,7 @@ The SDK is preferred when:
- You want type safety
- You're in the same Node.js process
- You need direct access to agent state
- You want to customize tools/hooks programmatically
- You want to customize tools/extensions programmatically
RPC mode is preferred when:
- You're integrating from another language
@ -984,12 +981,11 @@ discoverModels
// Discovery
discoverSkills
discoverHooks
discoverCustomTools
discoverExtensions
discoverContextFiles
discoverPromptTemplates
// Event Bus (for shared hook/tool communication)
// Event Bus (for shared extension communication)
createEventBus
// Helpers
@ -1015,8 +1011,9 @@ createGrepTool, createFindTool, createLsTool
// Types
type CreateAgentSessionOptions
type CreateAgentSessionResult
type CustomTool
type HookFactory
type ExtensionFactory
type ExtensionAPI
type ToolDefinition
type Skill
type PromptTemplate
type Settings
@ -1024,28 +1021,4 @@ type SkillsSettings
type Tool
```
For hook types, import from the hooks subpath:
```typescript
import type {
HookAPI,
HookMessage,
HookFactory,
HookEventContext,
HookCommandContext,
ToolCallEvent,
ToolResultEvent,
} from "@mariozechner/pi-coding-agent/hooks";
```
For message utilities:
```typescript
import { isHookMessage, createHookMessage } from "@mariozechner/pi-coding-agent";
```
For config utilities:
```typescript
import { getAgentDir } from "@mariozechner/pi-coding-agent/config";
```
For extension types, see [extensions.md](extensions.md) for the full API.

View file

@ -48,6 +48,56 @@ async execute(toolCallId, params, onUpdate, ctx, signal) {
}
```
## Overlays
Overlays render components on top of existing content without clearing the screen. Pass `{ overlay: true }` to `ctx.ui.custom()`:
```typescript
const result = await ctx.ui.custom<string | null>(
(tui, theme, keybindings, done) => new MyDialog({ onClose: done }),
{ overlay: true }
);
```
For positioning and sizing, use `overlayOptions`:
```typescript
const result = await ctx.ui.custom<string | null>(
(tui, theme, keybindings, done) => new SidePanel({ onClose: done }),
{
overlay: true,
overlayOptions: {
// Size: number or percentage string
width: "50%", // 50% of terminal width
minWidth: 40, // minimum 40 columns
maxHeight: "80%", // max 80% of terminal height
// Position: anchor-based (default: "center")
anchor: "right-center", // 9 positions: center, top-left, top-center, etc.
offsetX: -2, // offset from anchor
offsetY: 0,
// Or percentage/absolute positioning
row: "25%", // 25% from top
col: 10, // column 10
// Margins
margin: 2, // all sides, or { top, right, bottom, left }
// Responsive: hide on narrow terminals
visible: (termWidth, termHeight) => termWidth >= 80,
},
// Get handle for programmatic visibility control
onHandle: (handle) => {
// handle.setHidden(true/false) - toggle visibility
// handle.hide() - permanently remove
},
}
);
```
See [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for comprehensive examples covering anchors, margins, stacking, responsive visibility, and animation.
## Built-in Components
Import from `@mariozechner/pi-tui`:

View file

@ -10,9 +10,12 @@ Programmatic usage via `createAgentSession()`. Shows how to customize models, pr
### [extensions/](extensions/)
Example extensions demonstrating:
- Lifecycle event handlers (tool interception, safety gates, context modifications)
- Custom tools (todo lists, subagents)
- Custom tools (todo lists, questions, subagents, output truncation)
- Commands and keyboard shortcuts
- External integrations (git, file watchers)
- Custom UI (footers, headers, editors, overlays)
- Git integration (checkpoints, auto-commit)
- System prompt modifications and custom compaction
- External integrations (SSH, file watchers, system theme sync)
## Documentation

View file

@ -22,6 +22,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) |
| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, fork) |
| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes |
| `sandbox/` | OS-level sandboxing using `@anthropic-ai/sandbox-runtime` with per-project config |
### Custom Tools
@ -29,8 +30,10 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|-----------|-------------|
| `todo.ts` | Todo list tool + `/todos` command with custom rendering and state persistence |
| `hello.ts` | Minimal custom tool example |
| `question.ts` | Demonstrates `ctx.ui.select()` for asking the user questions |
| `question.ts` | Demonstrates `ctx.ui.select()` for asking the user questions with custom UI |
| `questionnaire.ts` | Multi-question input with tab bar navigation between questions |
| `tool-override.ts` | Override built-in tools (e.g., add logging/access control to `read`) |
| `truncated-tool.ts` | Wraps ripgrep with proper output truncation (50KB/2000 lines) |
| `ssh.ts` | Delegate all tools to a remote machine via SSH using pluggable operations |
| `subagent/` | Delegate tasks to specialized subagents with isolated context windows |
@ -39,19 +42,26 @@ cp permission-gate.ts ~/.pi/agent/extensions/
| Extension | Description |
|-----------|-------------|
| `preset.ts` | Named presets for model, thinking level, tools, and instructions via `--preset` flag and `/preset` command |
| `plan-mode.ts` | Claude Code-style plan mode for read-only exploration with `/plan` command |
| `plan-mode/` | Claude Code-style plan mode for read-only exploration with `/plan` command and step tracking |
| `tools.ts` | Interactive `/tools` command to enable/disable tools with session persistence |
| `handoff.ts` | Transfer context to a new focused session via `/handoff <goal>` |
| `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` |
| `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors |
| `model-status.ts` | Shows model changes in status bar via `model_select` hook |
| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence |
| `send-user-message.ts` | Demonstrates `pi.sendUserMessage()` for sending user messages from extensions |
| `timed-confirm.ts` | Demonstrates AbortSignal for auto-dismissing `ctx.ui.confirm()` and `ctx.ui.select()` dialogs |
| `modal-editor.ts` | Custom vim-like modal editor via `ctx.ui.setEditorComponent()` |
| `rainbow-editor.ts` | Animated rainbow text effect via custom editor |
| `notify.ts` | Desktop notifications via OSC 777 when agent finishes (Ghostty, iTerm2, WezTerm) |
| `summarize.ts` | Summarize conversation with GPT-5.2 and show in transient UI |
| `custom-footer.ts` | Custom footer with git branch and token stats via `ctx.ui.setFooter()` |
| `custom-header.ts` | Custom header via `ctx.ui.setHeader()` |
| `overlay-test.ts` | Test overlay compositing with inline text inputs and edge cases |
| `overlay-qa-tests.ts` | Comprehensive overlay QA tests: anchors, margins, stacking, overflow, animation |
| `doom-overlay/` | DOOM game running as an overlay at 35 FPS (demonstrates real-time game rendering) |
| `shutdown-command.ts` | Adds `/quit` command demonstrating `ctx.shutdown()` |
| `interactive-shell.ts` | Run interactive commands (vim, htop) with full terminal via `user_bash` hook |
### Git Integration
@ -65,8 +75,15 @@ cp permission-gate.ts ~/.pi/agent/extensions/
| Extension | Description |
|-----------|-------------|
| `pirate.ts` | Demonstrates `systemPromptAppend` to dynamically modify system prompt |
| `claude-rules.ts` | Scans `.claude/rules/` folder and lists rules in system prompt |
| `custom-compaction.ts` | Custom compaction that summarizes entire conversation |
### System Integration
| Extension | Description |
|-----------|-------------|
| `mac-system-theme.ts` | Syncs pi theme with macOS dark/light mode |
### External Dependencies
| Extension | Description |

View file

@ -1,548 +0,0 @@
/**
* Plan Mode Extension
*
* Provides a Claude Code-style "plan mode" for safe code exploration.
* When enabled, the agent can only use read-only tools and cannot modify files.
*
* Features:
* - /plan command to toggle plan mode
* - In plan mode: only read, bash (read-only), grep, find, ls are available
* - Injects system context telling the agent about the restrictions
* - After each agent response, prompts to execute the plan or continue planning
* - Shows "plan" indicator in footer when active
* - Extracts todo list from plan and tracks progress during execution
* - Uses ID-based tracking: agent outputs [DONE:id] to mark steps complete
*
* Usage:
* 1. Copy this file to ~/.pi/agent/extensions/ or your project's .pi/extensions/
* 2. Use /plan to toggle plan mode on/off
* 3. Or start in plan mode with --plan flag
*/
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Key } from "@mariozechner/pi-tui";
// Read-only tools for plan mode
const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls"];
// Full set of tools for normal mode
const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
// Patterns for destructive bash commands that should be blocked in plan mode
const DESTRUCTIVE_PATTERNS = [
/\brm\b/i,
/\brmdir\b/i,
/\bmv\b/i,
/\bcp\b/i,
/\bmkdir\b/i,
/\btouch\b/i,
/\bchmod\b/i,
/\bchown\b/i,
/\bchgrp\b/i,
/\bln\b/i,
/\btee\b/i,
/\btruncate\b/i,
/\bdd\b/i,
/\bshred\b/i,
/[^<]>(?!>)/,
/>>/,
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
/\byarn\s+(add|remove|install|publish)/i,
/\bpnpm\s+(add|remove|install|publish)/i,
/\bpip\s+(install|uninstall)/i,
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
/\bbrew\s+(install|uninstall|upgrade)/i,
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout\s+-b|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
/\bsudo\b/i,
/\bsu\b/i,
/\bkill\b/i,
/\bpkill\b/i,
/\bkillall\b/i,
/\breboot\b/i,
/\bshutdown\b/i,
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
/\bservice\s+\S+\s+(start|stop|restart)/i,
/\b(vim?|nano|emacs|code|subl)\b/i,
];
// Read-only commands that are always safe
const SAFE_COMMANDS = [
/^\s*cat\b/,
/^\s*head\b/,
/^\s*tail\b/,
/^\s*less\b/,
/^\s*more\b/,
/^\s*grep\b/,
/^\s*find\b/,
/^\s*ls\b/,
/^\s*pwd\b/,
/^\s*echo\b/,
/^\s*printf\b/,
/^\s*wc\b/,
/^\s*sort\b/,
/^\s*uniq\b/,
/^\s*diff\b/,
/^\s*file\b/,
/^\s*stat\b/,
/^\s*du\b/,
/^\s*df\b/,
/^\s*tree\b/,
/^\s*which\b/,
/^\s*whereis\b/,
/^\s*type\b/,
/^\s*env\b/,
/^\s*printenv\b/,
/^\s*uname\b/,
/^\s*whoami\b/,
/^\s*id\b/,
/^\s*date\b/,
/^\s*cal\b/,
/^\s*uptime\b/,
/^\s*ps\b/,
/^\s*top\b/,
/^\s*htop\b/,
/^\s*free\b/,
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
/^\s*git\s+ls-/i,
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
/^\s*yarn\s+(list|info|why|audit)/i,
/^\s*node\s+--version/i,
/^\s*python\s+--version/i,
/^\s*curl\s/i,
/^\s*wget\s+-O\s*-/i,
/^\s*jq\b/,
/^\s*sed\s+-n/i,
/^\s*awk\b/,
/^\s*rg\b/,
/^\s*fd\b/,
/^\s*bat\b/,
/^\s*exa\b/,
];
function isSafeCommand(command: string): boolean {
if (SAFE_COMMANDS.some((pattern) => pattern.test(command))) {
if (!DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command))) {
return true;
}
}
if (DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command))) {
return false;
}
return true;
}
// Todo item with step number
interface TodoItem {
step: number;
text: string;
completed: boolean;
}
/**
* Clean up extracted step text for display.
*/
function cleanStepText(text: string): string {
let cleaned = text
// Remove markdown bold/italic
.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1")
// Remove markdown code
.replace(/`([^`]+)`/g, "$1")
// Remove leading action words that are redundant
.replace(
/^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i,
"",
)
// Clean up extra whitespace
.replace(/\s+/g, " ")
.trim();
// Capitalize first letter
if (cleaned.length > 0) {
cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
// Truncate if too long
if (cleaned.length > 50) {
cleaned = `${cleaned.slice(0, 47)}...`;
}
return cleaned;
}
/**
* Extract todo items from assistant message.
*/
function extractTodoItems(message: string): TodoItem[] {
const items: TodoItem[] = [];
// Match numbered lists: "1. Task" or "1) Task" - also handle **bold** prefixes
const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
for (const match of message.matchAll(numberedPattern)) {
let text = match[2].trim();
text = text.replace(/\*{1,2}$/, "").trim();
// Skip if too short or looks like code/command
if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
const cleaned = cleanStepText(text);
if (cleaned.length > 3) {
items.push({ step: items.length + 1, text: cleaned, completed: false });
}
}
}
// If no numbered items, try bullet points
if (items.length === 0) {
const stepPattern = /^\s*[-*]\s*(?:Step\s*\d+[:.])?\s*\*{0,2}([^*\n]+)/gim;
for (const match of message.matchAll(stepPattern)) {
let text = match[1].trim();
text = text.replace(/\*{1,2}$/, "").trim();
if (text.length > 10 && !text.startsWith("`")) {
const cleaned = cleanStepText(text);
if (cleaned.length > 3) {
items.push({ step: items.length + 1, text: cleaned, completed: false });
}
}
}
}
return items;
}
export default function planModeExtension(pi: ExtensionAPI) {
let planModeEnabled = false;
let toolsCalledThisTurn = false;
let executionMode = false;
let todoItems: TodoItem[] = [];
// Register --plan CLI flag
pi.registerFlag("plan", {
description: "Start in plan mode (read-only exploration)",
type: "boolean",
default: false,
});
// Helper to update status displays
function updateStatus(ctx: ExtensionContext) {
if (executionMode && todoItems.length > 0) {
const completed = todoItems.filter((t) => t.completed).length;
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`));
} else if (planModeEnabled) {
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan"));
} else {
ctx.ui.setStatus("plan-mode", undefined);
}
// Show widget during execution (no IDs shown to user)
if (executionMode && todoItems.length > 0) {
const lines: string[] = [];
for (const item of todoItems) {
if (item.completed) {
lines.push(
ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text)),
);
} else {
lines.push(ctx.ui.theme.fg("muted", "☐ ") + item.text);
}
}
ctx.ui.setWidget("plan-todos", lines);
} else {
ctx.ui.setWidget("plan-todos", undefined);
}
}
function togglePlanMode(ctx: ExtensionContext) {
planModeEnabled = !planModeEnabled;
executionMode = false;
todoItems = [];
if (planModeEnabled) {
pi.setActiveTools(PLAN_MODE_TOOLS);
ctx.ui.notify(`Plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`);
} else {
pi.setActiveTools(NORMAL_MODE_TOOLS);
ctx.ui.notify("Plan mode disabled. Full access restored.");
}
updateStatus(ctx);
}
// Register /plan command
pi.registerCommand("plan", {
description: "Toggle plan mode (read-only exploration)",
handler: async (_args, ctx) => {
togglePlanMode(ctx);
},
});
// Register /todos command
pi.registerCommand("todos", {
description: "Show current plan todo list",
handler: async (_args, ctx) => {
if (todoItems.length === 0) {
ctx.ui.notify("No todos. Create a plan first with /plan", "info");
return;
}
const todoList = todoItems
.map((item, i) => {
const checkbox = item.completed ? "✓" : "○";
return `${i + 1}. ${checkbox} ${item.text}`;
})
.join("\n");
ctx.ui.notify(`Plan Progress:\n${todoList}`, "info");
},
});
// Register Shift+P shortcut
pi.registerShortcut(Key.shift("p"), {
description: "Toggle plan mode",
handler: async (ctx) => {
togglePlanMode(ctx);
},
});
// Block destructive bash in plan mode
pi.on("tool_call", async (event) => {
if (!planModeEnabled) return;
if (event.toolName !== "bash") return;
const command = event.input.command as string;
if (!isSafeCommand(command)) {
return {
block: true,
reason: `Plan mode: destructive command blocked. Use /plan to disable plan mode first.\nCommand: ${command}`,
};
}
});
// Track step completion based on tool results
pi.on("tool_result", async (_event, ctx) => {
toolsCalledThisTurn = true;
if (!executionMode || todoItems.length === 0) return;
// Mark the first uncompleted step as done when any tool succeeds
const nextStep = todoItems.find((t) => !t.completed);
if (nextStep) {
nextStep.completed = true;
updateStatus(ctx);
}
});
// Filter out stale plan mode context messages from LLM context
// This ensures the agent only sees the CURRENT state (plan mode on/off)
pi.on("context", async (event) => {
// Only filter when NOT in plan mode (i.e., when executing)
if (planModeEnabled) {
return;
}
// Remove any previous plan-mode-context messages
const _beforeCount = event.messages.length;
const filtered = event.messages.filter((m) => {
if (m.role === "user" && Array.isArray(m.content)) {
const hasOldContext = m.content.some((c) => c.type === "text" && c.text.includes("[PLAN MODE ACTIVE]"));
if (hasOldContext) {
return false;
}
}
return true;
});
return { messages: filtered };
});
// Inject plan mode context
pi.on("before_agent_start", async () => {
if (!planModeEnabled && !executionMode) {
return;
}
if (planModeEnabled) {
return {
message: {
customType: "plan-mode-context",
content: `[PLAN MODE ACTIVE]
You are in plan mode - a read-only exploration mode for safe code analysis.
Restrictions:
- You can only use: read, bash, grep, find, ls
- You CANNOT use: edit, write (file modifications are disabled)
- Bash is restricted to READ-ONLY commands
- Focus on analysis, planning, and understanding the codebase
Create a detailed numbered plan:
1. First step description
2. Second step description
...
Do NOT attempt to make changes - just describe what you would do.`,
display: false,
},
};
}
if (executionMode && todoItems.length > 0) {
const remaining = todoItems.filter((t) => !t.completed);
const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n");
return {
message: {
customType: "plan-execution-context",
content: `[EXECUTING PLAN - Full tool access enabled]
Remaining steps:
${todoList}
Execute each step in order.`,
display: false,
},
};
}
});
// After agent finishes
pi.on("agent_end", async (event, ctx) => {
// In execution mode, check if all steps complete
if (executionMode && todoItems.length > 0) {
const allComplete = todoItems.every((t) => t.completed);
if (allComplete) {
// Show final completed list in chat
const completedList = todoItems.map((t) => `~~${t.text}~~`).join("\n");
pi.sendMessage(
{
customType: "plan-complete",
content: `**Plan Complete!** ✓\n\n${completedList}`,
display: true,
},
{ triggerTurn: false },
);
executionMode = false;
todoItems = [];
pi.setActiveTools(NORMAL_MODE_TOOLS);
updateStatus(ctx);
}
return;
}
if (!planModeEnabled) return;
if (!ctx.hasUI) return;
// Extract todos from last message
const messages = event.messages;
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
if (lastAssistant && Array.isArray(lastAssistant.content)) {
const textContent = lastAssistant.content
.filter((block): block is { type: "text"; text: string } => block.type === "text")
.map((block) => block.text)
.join("\n");
if (textContent) {
const extracted = extractTodoItems(textContent);
if (extracted.length > 0) {
todoItems = extracted;
}
}
}
const hasTodos = todoItems.length > 0;
// Show todo list in chat (no IDs shown to user, just numbered)
if (hasTodos) {
const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
pi.sendMessage(
{
customType: "plan-todo-list",
content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
display: true,
},
{ triggerTurn: false },
);
}
const choice = await ctx.ui.select("Plan mode - what next?", [
hasTodos ? "Execute the plan (track progress)" : "Execute the plan",
"Stay in plan mode",
"Refine the plan",
]);
if (choice?.startsWith("Execute")) {
planModeEnabled = false;
executionMode = hasTodos;
pi.setActiveTools(NORMAL_MODE_TOOLS);
updateStatus(ctx);
// Simple execution message - context event filters old plan mode messages
// and before_agent_start injects fresh execution context with IDs
const execMessage = hasTodos
? `Execute the plan. Start with: ${todoItems[0].text}`
: "Execute the plan you just created.";
pi.sendMessage(
{
customType: "plan-mode-execute",
content: execMessage,
display: true,
},
{ triggerTurn: true },
);
} else if (choice === "Refine the plan") {
const refinement = await ctx.ui.input("What should be refined?");
if (refinement) {
ctx.ui.setEditorText(refinement);
}
}
});
// Initialize state on session start
pi.on("session_start", async (_event, ctx) => {
if (pi.getFlag("plan") === true) {
planModeEnabled = true;
}
const entries = ctx.sessionManager.getEntries();
const planModeEntry = entries
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode")
.pop() as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined;
if (planModeEntry?.data) {
if (planModeEntry.data.enabled !== undefined) {
planModeEnabled = planModeEntry.data.enabled;
}
if (planModeEntry.data.todos) {
todoItems = planModeEntry.data.todos;
}
if (planModeEntry.data.executing) {
executionMode = planModeEntry.data.executing;
}
}
if (planModeEnabled) {
pi.setActiveTools(PLAN_MODE_TOOLS);
}
updateStatus(ctx);
});
// Reset tool tracking at start of each turn and persist state
pi.on("turn_start", async () => {
toolsCalledThisTurn = false;
pi.appendEntry("plan-mode", {
enabled: planModeEnabled,
todos: todoItems,
executing: executionMode,
});
});
// Handle non-tool turns (e.g., analysis, explanation steps)
pi.on("turn_end", async (_event, ctx) => {
if (!executionMode || todoItems.length === 0) return;
// If no tools were called this turn, the agent was doing analysis/explanation
// Mark the next uncompleted step as done
if (!toolsCalledThisTurn) {
const nextStep = todoItems.find((t) => !t.completed);
if (nextStep) {
nextStep.completed = true;
updateStatus(ctx);
}
}
});
}

View file

@ -0,0 +1,65 @@
# Plan Mode Extension
Read-only exploration mode for safe code analysis.
## Features
- **Read-only tools**: Restricts available tools to read, bash, grep, find, ls, question
- **Bash allowlist**: Only read-only bash commands are allowed
- **Plan extraction**: Extracts numbered steps from `Plan:` sections
- **Progress tracking**: Widget shows completion status during execution
- **[DONE:n] markers**: Explicit step completion tracking
- **Session persistence**: State survives session resume
## Commands
- `/plan` - Toggle plan mode
- `/todos` - Show current plan progress
- `Shift+P` - Toggle plan mode (shortcut)
## Usage
1. Enable plan mode with `/plan` or `--plan` flag
2. Ask the agent to analyze code and create a plan
3. The agent should output a numbered plan under a `Plan:` header:
```
Plan:
1. First step description
2. Second step description
3. Third step description
```
4. Choose "Execute the plan" when prompted
5. During execution, the agent marks steps complete with `[DONE:n]` tags
6. Progress widget shows completion status
## How It Works
### Plan Mode (Read-Only)
- Only read-only tools available
- Bash commands filtered through allowlist
- Agent creates a plan without making changes
### Execution Mode
- Full tool access restored
- Agent executes steps in order
- `[DONE:n]` markers track completion
- Widget shows progress
### Command Allowlist
Safe commands (allowed):
- File inspection: `cat`, `head`, `tail`, `less`, `more`
- Search: `grep`, `find`, `rg`, `fd`
- Directory: `ls`, `pwd`, `tree`
- Git read: `git status`, `git log`, `git diff`, `git branch`
- Package info: `npm list`, `npm outdated`, `yarn info`
- System info: `uname`, `whoami`, `date`, `uptime`
Blocked commands:
- File modification: `rm`, `mv`, `cp`, `mkdir`, `touch`
- Git write: `git add`, `git commit`, `git push`
- Package install: `npm install`, `yarn add`, `pip install`
- System: `sudo`, `kill`, `reboot`
- Editors: `vim`, `nano`, `code`

View file

@ -0,0 +1,340 @@
/**
* Plan Mode Extension
*
* Read-only exploration mode for safe code analysis.
* When enabled, only read-only tools are available.
*
* Features:
* - /plan command or Shift+P to toggle
* - Bash restricted to allowlisted read-only commands
* - Extracts numbered plan steps from "Plan:" sections
* - [DONE:n] markers to complete steps during execution
* - Progress tracking widget during execution
*/
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai";
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Key } from "@mariozechner/pi-tui";
import { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } from "./utils.js";
// Tools
const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "questionnaire"];
const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
// Type guard for assistant messages
function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
return m.role === "assistant" && Array.isArray(m.content);
}
// Extract text content from an assistant message
function getTextContent(message: AssistantMessage): string {
return message.content
.filter((block): block is TextContent => block.type === "text")
.map((block) => block.text)
.join("\n");
}
export default function planModeExtension(pi: ExtensionAPI): void {
let planModeEnabled = false;
let executionMode = false;
let todoItems: TodoItem[] = [];
pi.registerFlag("plan", {
description: "Start in plan mode (read-only exploration)",
type: "boolean",
default: false,
});
function updateStatus(ctx: ExtensionContext): void {
// Footer status
if (executionMode && todoItems.length > 0) {
const completed = todoItems.filter((t) => t.completed).length;
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`));
} else if (planModeEnabled) {
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan"));
} else {
ctx.ui.setStatus("plan-mode", undefined);
}
// Widget showing todo list
if (executionMode && todoItems.length > 0) {
const lines = todoItems.map((item) => {
if (item.completed) {
return (
ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text))
);
}
return `${ctx.ui.theme.fg("muted", "☐ ")}${item.text}`;
});
ctx.ui.setWidget("plan-todos", lines);
} else {
ctx.ui.setWidget("plan-todos", undefined);
}
}
function togglePlanMode(ctx: ExtensionContext): void {
planModeEnabled = !planModeEnabled;
executionMode = false;
todoItems = [];
if (planModeEnabled) {
pi.setActiveTools(PLAN_MODE_TOOLS);
ctx.ui.notify(`Plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`);
} else {
pi.setActiveTools(NORMAL_MODE_TOOLS);
ctx.ui.notify("Plan mode disabled. Full access restored.");
}
updateStatus(ctx);
}
function persistState(): void {
pi.appendEntry("plan-mode", {
enabled: planModeEnabled,
todos: todoItems,
executing: executionMode,
});
}
pi.registerCommand("plan", {
description: "Toggle plan mode (read-only exploration)",
handler: async (_args, ctx) => togglePlanMode(ctx),
});
pi.registerCommand("todos", {
description: "Show current plan todo list",
handler: async (_args, ctx) => {
if (todoItems.length === 0) {
ctx.ui.notify("No todos. Create a plan first with /plan", "info");
return;
}
const list = todoItems.map((item, i) => `${i + 1}. ${item.completed ? "✓" : "○"} ${item.text}`).join("\n");
ctx.ui.notify(`Plan Progress:\n${list}`, "info");
},
});
pi.registerShortcut(Key.shift("p"), {
description: "Toggle plan mode",
handler: async (ctx) => togglePlanMode(ctx),
});
// Block destructive bash commands in plan mode
pi.on("tool_call", async (event) => {
if (!planModeEnabled || event.toolName !== "bash") return;
const command = event.input.command as string;
if (!isSafeCommand(command)) {
return {
block: true,
reason: `Plan mode: command blocked (not allowlisted). Use /plan to disable plan mode first.\nCommand: ${command}`,
};
}
});
// Filter out stale plan mode context when not in plan mode
pi.on("context", async (event) => {
if (planModeEnabled) return;
return {
messages: event.messages.filter((m) => {
const msg = m as AgentMessage & { customType?: string };
if (msg.customType === "plan-mode-context") return false;
if (msg.role !== "user") return true;
const content = msg.content;
if (typeof content === "string") {
return !content.includes("[PLAN MODE ACTIVE]");
}
if (Array.isArray(content)) {
return !content.some(
(c) => c.type === "text" && (c as TextContent).text?.includes("[PLAN MODE ACTIVE]"),
);
}
return true;
}),
};
});
// Inject plan/execution context before agent starts
pi.on("before_agent_start", async () => {
if (planModeEnabled) {
return {
message: {
customType: "plan-mode-context",
content: `[PLAN MODE ACTIVE]
You are in plan mode - a read-only exploration mode for safe code analysis.
Restrictions:
- You can only use: read, bash, grep, find, ls, questionnaire
- You CANNOT use: edit, write (file modifications are disabled)
- Bash is restricted to an allowlist of read-only commands
Ask clarifying questions using the questionnaire tool.
Use brave-search skill via bash for web research.
Create a detailed numbered plan under a "Plan:" header:
Plan:
1. First step description
2. Second step description
...
Do NOT attempt to make changes - just describe what you would do.`,
display: false,
},
};
}
if (executionMode && todoItems.length > 0) {
const remaining = todoItems.filter((t) => !t.completed);
const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n");
return {
message: {
customType: "plan-execution-context",
content: `[EXECUTING PLAN - Full tool access enabled]
Remaining steps:
${todoList}
Execute each step in order.
After completing a step, include a [DONE:n] tag in your response.`,
display: false,
},
};
}
});
// Track progress after each turn
pi.on("turn_end", async (event, ctx) => {
if (!executionMode || todoItems.length === 0) return;
if (!isAssistantMessage(event.message)) return;
const text = getTextContent(event.message);
if (markCompletedSteps(text, todoItems) > 0) {
updateStatus(ctx);
}
persistState();
});
// Handle plan completion and plan mode UI
pi.on("agent_end", async (event, ctx) => {
// Check if execution is complete
if (executionMode && todoItems.length > 0) {
if (todoItems.every((t) => t.completed)) {
const completedList = todoItems.map((t) => `~~${t.text}~~`).join("\n");
pi.sendMessage(
{ customType: "plan-complete", content: `**Plan Complete!** ✓\n\n${completedList}`, display: true },
{ triggerTurn: false },
);
executionMode = false;
todoItems = [];
pi.setActiveTools(NORMAL_MODE_TOOLS);
updateStatus(ctx);
persistState(); // Save cleared state so resume doesn't restore old execution mode
}
return;
}
if (!planModeEnabled || !ctx.hasUI) return;
// Extract todos from last assistant message
const lastAssistant = [...event.messages].reverse().find(isAssistantMessage);
if (lastAssistant) {
const extracted = extractTodoItems(getTextContent(lastAssistant));
if (extracted.length > 0) {
todoItems = extracted;
}
}
// Show plan steps and prompt for next action
if (todoItems.length > 0) {
const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
pi.sendMessage(
{
customType: "plan-todo-list",
content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
display: true,
},
{ triggerTurn: false },
);
}
const choice = await ctx.ui.select("Plan mode - what next?", [
todoItems.length > 0 ? "Execute the plan (track progress)" : "Execute the plan",
"Stay in plan mode",
"Refine the plan",
]);
if (choice?.startsWith("Execute")) {
planModeEnabled = false;
executionMode = todoItems.length > 0;
pi.setActiveTools(NORMAL_MODE_TOOLS);
updateStatus(ctx);
const execMessage =
todoItems.length > 0
? `Execute the plan. Start with: ${todoItems[0].text}`
: "Execute the plan you just created.";
pi.sendMessage(
{ customType: "plan-mode-execute", content: execMessage, display: true },
{ triggerTurn: true },
);
} else if (choice === "Refine the plan") {
const refinement = await ctx.ui.editor("Refine the plan:", "");
if (refinement?.trim()) {
pi.sendUserMessage(refinement.trim());
}
}
});
// Restore state on session start/resume
pi.on("session_start", async (_event, ctx) => {
if (pi.getFlag("plan") === true) {
planModeEnabled = true;
}
const entries = ctx.sessionManager.getEntries();
// Restore persisted state
const planModeEntry = entries
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode")
.pop() as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined;
if (planModeEntry?.data) {
planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled;
todoItems = planModeEntry.data.todos ?? todoItems;
executionMode = planModeEntry.data.executing ?? executionMode;
}
// On resume: re-scan messages to rebuild completion state
// Only scan messages AFTER the last "plan-mode-execute" to avoid picking up [DONE:n] from previous plans
const isResume = planModeEntry !== undefined;
if (isResume && executionMode && todoItems.length > 0) {
// Find the index of the last plan-mode-execute entry (marks when current execution started)
let executeIndex = -1;
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i] as { type: string; customType?: string };
if (entry.customType === "plan-mode-execute") {
executeIndex = i;
break;
}
}
// Only scan messages after the execute marker
const messages: AssistantMessage[] = [];
for (let i = executeIndex + 1; i < entries.length; i++) {
const entry = entries[i];
if (entry.type === "message" && "message" in entry && isAssistantMessage(entry.message as AgentMessage)) {
messages.push(entry.message as AssistantMessage);
}
}
const allText = messages.map(getTextContent).join("\n");
markCompletedSteps(allText, todoItems);
}
if (planModeEnabled) {
pi.setActiveTools(PLAN_MODE_TOOLS);
}
updateStatus(ctx);
});
}

View file

@ -0,0 +1,168 @@
/**
* Pure utility functions for plan mode.
* Extracted for testability.
*/
// Destructive commands blocked in plan mode
const DESTRUCTIVE_PATTERNS = [
/\brm\b/i,
/\brmdir\b/i,
/\bmv\b/i,
/\bcp\b/i,
/\bmkdir\b/i,
/\btouch\b/i,
/\bchmod\b/i,
/\bchown\b/i,
/\bchgrp\b/i,
/\bln\b/i,
/\btee\b/i,
/\btruncate\b/i,
/\bdd\b/i,
/\bshred\b/i,
/(^|[^<])>(?!>)/,
/>>/,
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
/\byarn\s+(add|remove|install|publish)/i,
/\bpnpm\s+(add|remove|install|publish)/i,
/\bpip\s+(install|uninstall)/i,
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
/\bbrew\s+(install|uninstall|upgrade)/i,
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
/\bsudo\b/i,
/\bsu\b/i,
/\bkill\b/i,
/\bpkill\b/i,
/\bkillall\b/i,
/\breboot\b/i,
/\bshutdown\b/i,
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
/\bservice\s+\S+\s+(start|stop|restart)/i,
/\b(vim?|nano|emacs|code|subl)\b/i,
];
// Safe read-only commands allowed in plan mode
const SAFE_PATTERNS = [
/^\s*cat\b/,
/^\s*head\b/,
/^\s*tail\b/,
/^\s*less\b/,
/^\s*more\b/,
/^\s*grep\b/,
/^\s*find\b/,
/^\s*ls\b/,
/^\s*pwd\b/,
/^\s*echo\b/,
/^\s*printf\b/,
/^\s*wc\b/,
/^\s*sort\b/,
/^\s*uniq\b/,
/^\s*diff\b/,
/^\s*file\b/,
/^\s*stat\b/,
/^\s*du\b/,
/^\s*df\b/,
/^\s*tree\b/,
/^\s*which\b/,
/^\s*whereis\b/,
/^\s*type\b/,
/^\s*env\b/,
/^\s*printenv\b/,
/^\s*uname\b/,
/^\s*whoami\b/,
/^\s*id\b/,
/^\s*date\b/,
/^\s*cal\b/,
/^\s*uptime\b/,
/^\s*ps\b/,
/^\s*top\b/,
/^\s*htop\b/,
/^\s*free\b/,
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
/^\s*git\s+ls-/i,
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
/^\s*yarn\s+(list|info|why|audit)/i,
/^\s*node\s+--version/i,
/^\s*python\s+--version/i,
/^\s*curl\s/i,
/^\s*wget\s+-O\s*-/i,
/^\s*jq\b/,
/^\s*sed\s+-n/i,
/^\s*awk\b/,
/^\s*rg\b/,
/^\s*fd\b/,
/^\s*bat\b/,
/^\s*exa\b/,
];
export function isSafeCommand(command: string): boolean {
const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
return !isDestructive && isSafe;
}
export interface TodoItem {
step: number;
text: string;
completed: boolean;
}
export function cleanStepText(text: string): string {
let cleaned = text
.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1") // Remove bold/italic
.replace(/`([^`]+)`/g, "$1") // Remove code
.replace(
/^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i,
"",
)
.replace(/\s+/g, " ")
.trim();
if (cleaned.length > 0) {
cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
if (cleaned.length > 50) {
cleaned = `${cleaned.slice(0, 47)}...`;
}
return cleaned;
}
export function extractTodoItems(message: string): TodoItem[] {
const items: TodoItem[] = [];
const headerMatch = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i);
if (!headerMatch) return items;
const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length);
const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
for (const match of planSection.matchAll(numberedPattern)) {
const text = match[2]
.trim()
.replace(/\*{1,2}$/, "")
.trim();
if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
const cleaned = cleanStepText(text);
if (cleaned.length > 3) {
items.push({ step: items.length + 1, text: cleaned, completed: false });
}
}
}
return items;
}
export function extractDoneSteps(message: string): number[] {
const steps: number[] = [];
for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
const step = Number(match[1]);
if (Number.isFinite(step)) steps.push(step);
}
return steps;
}
export function markCompletedSteps(text: string, items: TodoItem[]): number {
const doneSteps = extractDoneSteps(text);
for (const step of doneSteps) {
const item = items.find((t) => t.step === step);
if (item) item.completed = true;
}
return doneSteps.length;
}

View file

@ -1,23 +1,50 @@
/**
* Question Tool - Let the LLM ask the user a question with options
* Question Tool - Single question with options
* Full custom UI: options list + inline editor for "Type something..."
* Escape in editor returns to options, Escape in options cancels
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Text } from "@mariozechner/pi-tui";
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
interface OptionWithDesc {
label: string;
description?: string;
}
type DisplayOption = OptionWithDesc & { isOther?: boolean };
interface QuestionDetails {
question: string;
options: string[];
answer: string | null;
wasCustom?: boolean;
}
// Support both simple strings and objects with descriptions
const OptionSchema = Type.Union([
Type.String(),
Type.Object({
label: Type.String({ description: "Display label for the option" }),
description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
}),
]);
const QuestionParams = Type.Object({
question: Type.String({ description: "The question to ask the user" }),
options: Type.Array(Type.String(), { description: "Options for the user to choose from" }),
options: Type.Array(OptionSchema, { description: "Options for the user to choose from" }),
});
export default function (pi: ExtensionAPI) {
// Normalize option to { label, description? }
function normalizeOption(opt: string | { label: string; description?: string }): OptionWithDesc {
if (typeof opt === "string") {
return { label: opt };
}
return opt;
}
export default function question(pi: ExtensionAPI) {
pi.registerTool({
name: "question",
label: "Question",
@ -28,7 +55,11 @@ export default function (pi: ExtensionAPI) {
if (!ctx.hasUI) {
return {
content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }],
details: { question: params.question, options: params.options, answer: null } as QuestionDetails,
details: {
question: params.question,
options: params.options.map((o) => (typeof o === "string" ? o : o.label)),
answer: null,
} as QuestionDetails,
};
}
@ -39,25 +70,183 @@ export default function (pi: ExtensionAPI) {
};
}
const answer = await ctx.ui.select(params.question, params.options);
// Normalize options
const normalizedOptions = params.options.map(normalizeOption);
const allOptions: DisplayOption[] = [...normalizedOptions, { label: "Type something.", isOther: true }];
if (answer === undefined) {
const result = await ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>(
(tui, theme, _kb, done) => {
let optionIndex = 0;
let editMode = false;
let cachedLines: string[] | undefined;
const editorTheme: EditorTheme = {
borderColor: (s) => theme.fg("accent", s),
selectList: {
selectedPrefix: (t) => theme.fg("accent", t),
selectedText: (t) => theme.fg("accent", t),
description: (t) => theme.fg("muted", t),
scrollInfo: (t) => theme.fg("dim", t),
noMatch: (t) => theme.fg("warning", t),
},
};
const editor = new Editor(editorTheme);
editor.onSubmit = (value) => {
const trimmed = value.trim();
if (trimmed) {
done({ answer: trimmed, wasCustom: true });
} else {
editMode = false;
editor.setText("");
refresh();
}
};
function refresh() {
cachedLines = undefined;
tui.requestRender();
}
function handleInput(data: string) {
if (editMode) {
if (matchesKey(data, Key.escape)) {
editMode = false;
editor.setText("");
refresh();
return;
}
editor.handleInput(data);
refresh();
return;
}
if (matchesKey(data, Key.up)) {
optionIndex = Math.max(0, optionIndex - 1);
refresh();
return;
}
if (matchesKey(data, Key.down)) {
optionIndex = Math.min(allOptions.length - 1, optionIndex + 1);
refresh();
return;
}
if (matchesKey(data, Key.enter)) {
const selected = allOptions[optionIndex];
if (selected.isOther) {
editMode = true;
refresh();
} else {
done({ answer: selected.label, wasCustom: false, index: optionIndex + 1 });
}
return;
}
if (matchesKey(data, Key.escape)) {
done(null);
}
}
function render(width: number): string[] {
if (cachedLines) return cachedLines;
const lines: string[] = [];
const add = (s: string) => lines.push(truncateToWidth(s, width));
add(theme.fg("accent", "─".repeat(width)));
add(theme.fg("text", ` ${params.question}`));
lines.push("");
for (let i = 0; i < allOptions.length; i++) {
const opt = allOptions[i];
const selected = i === optionIndex;
const isOther = opt.isOther === true;
const prefix = selected ? theme.fg("accent", "> ") : " ";
if (isOther && editMode) {
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label}`));
} else if (selected) {
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label}`));
} else {
add(` ${theme.fg("text", `${i + 1}. ${opt.label}`)}`);
}
// Show description if present
if (opt.description) {
add(` ${theme.fg("muted", opt.description)}`);
}
}
if (editMode) {
lines.push("");
add(theme.fg("muted", " Your answer:"));
for (const line of editor.render(width - 2)) {
add(` ${line}`);
}
}
lines.push("");
if (editMode) {
add(theme.fg("dim", " Enter to submit • Esc to go back"));
} else {
add(theme.fg("dim", " ↑↓ navigate • Enter to select • Esc to cancel"));
}
add(theme.fg("accent", "─".repeat(width)));
cachedLines = lines;
return lines;
}
return {
render,
invalidate: () => {
cachedLines = undefined;
},
handleInput,
};
},
);
// Build simple options list for details
const simpleOptions = normalizedOptions.map((o) => o.label);
if (!result) {
return {
content: [{ type: "text", text: "User cancelled the selection" }],
details: { question: params.question, options: params.options, answer: null } as QuestionDetails,
details: { question: params.question, options: simpleOptions, answer: null } as QuestionDetails,
};
}
if (result.wasCustom) {
return {
content: [{ type: "text", text: `User wrote: ${result.answer}` }],
details: {
question: params.question,
options: simpleOptions,
answer: result.answer,
wasCustom: true,
} as QuestionDetails,
};
}
return {
content: [{ type: "text", text: `User selected: ${answer}` }],
details: { question: params.question, options: params.options, answer } as QuestionDetails,
content: [{ type: "text", text: `User selected: ${result.index}. ${result.answer}` }],
details: {
question: params.question,
options: simpleOptions,
answer: result.answer,
wasCustom: false,
} as QuestionDetails,
};
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("question ")) + theme.fg("muted", args.question);
if (args.options?.length) {
text += `\n${theme.fg("dim", ` Options: ${args.options.join(", ")}`)}`;
const opts = Array.isArray(args.options) ? args.options : [];
if (opts.length) {
const labels = opts.map((o: string | { label: string }) => (typeof o === "string" ? o : o.label));
const numbered = [...labels, "Type something."].map((o, i) => `${i + 1}. ${o}`);
text += `\n${theme.fg("dim", ` Options: ${numbered.join(", ")}`)}`;
}
return new Text(text, 0, 0);
},
@ -73,7 +262,16 @@ export default function (pi: ExtensionAPI) {
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
}
return new Text(theme.fg("success", "✓ ") + theme.fg("accent", details.answer), 0, 0);
if (details.wasCustom) {
return new Text(
theme.fg("success", "✓ ") + theme.fg("muted", "(wrote) ") + theme.fg("accent", details.answer),
0,
0,
);
}
const idx = details.options.indexOf(details.answer) + 1;
const display = idx > 0 ? `${idx}. ${details.answer}` : details.answer;
return new Text(theme.fg("success", "✓ ") + theme.fg("accent", display), 0, 0);
},
});
}

View file

@ -0,0 +1,427 @@
/**
* Questionnaire Tool - Unified tool for asking single or multiple questions
*
* Single question: simple options list
* Multiple questions: tab bar navigation between questions
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
// Types
interface QuestionOption {
value: string;
label: string;
description?: string;
}
type RenderOption = QuestionOption & { isOther?: boolean };
interface Question {
id: string;
label: string;
prompt: string;
options: QuestionOption[];
allowOther: boolean;
}
interface Answer {
id: string;
value: string;
label: string;
wasCustom: boolean;
index?: number;
}
interface QuestionnaireResult {
questions: Question[];
answers: Answer[];
cancelled: boolean;
}
// Schema
const QuestionOptionSchema = Type.Object({
value: Type.String({ description: "The value returned when selected" }),
label: Type.String({ description: "Display label for the option" }),
description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
});
const QuestionSchema = Type.Object({
id: Type.String({ description: "Unique identifier for this question" }),
label: Type.Optional(
Type.String({
description: "Short contextual label for tab bar, e.g. 'Scope', 'Priority' (defaults to Q1, Q2)",
}),
),
prompt: Type.String({ description: "The full question text to display" }),
options: Type.Array(QuestionOptionSchema, { description: "Available options to choose from" }),
allowOther: Type.Optional(Type.Boolean({ description: "Allow 'Type something' option (default: true)" })),
});
const QuestionnaireParams = Type.Object({
questions: Type.Array(QuestionSchema, { description: "Questions to ask the user" }),
});
function errorResult(
message: string,
questions: Question[] = [],
): { content: { type: "text"; text: string }[]; details: QuestionnaireResult } {
return {
content: [{ type: "text", text: message }],
details: { questions, answers: [], cancelled: true },
};
}
export default function questionnaire(pi: ExtensionAPI) {
pi.registerTool({
name: "questionnaire",
label: "Questionnaire",
description:
"Ask the user one or more questions. Use for clarifying requirements, getting preferences, or confirming decisions. For single questions, shows a simple option list. For multiple questions, shows a tab-based interface.",
parameters: QuestionnaireParams,
async execute(_toolCallId, params, _onUpdate, ctx, _signal) {
if (!ctx.hasUI) {
return errorResult("Error: UI not available (running in non-interactive mode)");
}
if (params.questions.length === 0) {
return errorResult("Error: No questions provided");
}
// Normalize questions with defaults
const questions: Question[] = params.questions.map((q, i) => ({
...q,
label: q.label || `Q${i + 1}`,
allowOther: q.allowOther !== false,
}));
const isMulti = questions.length > 1;
const totalTabs = questions.length + 1; // questions + Submit
const result = await ctx.ui.custom<QuestionnaireResult>((tui, theme, _kb, done) => {
// State
let currentTab = 0;
let optionIndex = 0;
let inputMode = false;
let inputQuestionId: string | null = null;
let cachedLines: string[] | undefined;
const answers = new Map<string, Answer>();
// Editor for "Type something" option
const editorTheme: EditorTheme = {
borderColor: (s) => theme.fg("accent", s),
selectList: {
selectedPrefix: (t) => theme.fg("accent", t),
selectedText: (t) => theme.fg("accent", t),
description: (t) => theme.fg("muted", t),
scrollInfo: (t) => theme.fg("dim", t),
noMatch: (t) => theme.fg("warning", t),
},
};
const editor = new Editor(editorTheme);
// Helpers
function refresh() {
cachedLines = undefined;
tui.requestRender();
}
function submit(cancelled: boolean) {
done({ questions, answers: Array.from(answers.values()), cancelled });
}
function currentQuestion(): Question | undefined {
return questions[currentTab];
}
function currentOptions(): RenderOption[] {
const q = currentQuestion();
if (!q) return [];
const opts: RenderOption[] = [...q.options];
if (q.allowOther) {
opts.push({ value: "__other__", label: "Type something.", isOther: true });
}
return opts;
}
function allAnswered(): boolean {
return questions.every((q) => answers.has(q.id));
}
function advanceAfterAnswer() {
if (!isMulti) {
submit(false);
return;
}
if (currentTab < questions.length - 1) {
currentTab++;
} else {
currentTab = questions.length; // Submit tab
}
optionIndex = 0;
refresh();
}
function saveAnswer(questionId: string, value: string, label: string, wasCustom: boolean, index?: number) {
answers.set(questionId, { id: questionId, value, label, wasCustom, index });
}
// Editor submit callback
editor.onSubmit = (value) => {
if (!inputQuestionId) return;
const trimmed = value.trim() || "(no response)";
saveAnswer(inputQuestionId, trimmed, trimmed, true);
inputMode = false;
inputQuestionId = null;
editor.setText("");
advanceAfterAnswer();
};
function handleInput(data: string) {
// Input mode: route to editor
if (inputMode) {
if (matchesKey(data, Key.escape)) {
inputMode = false;
inputQuestionId = null;
editor.setText("");
refresh();
return;
}
editor.handleInput(data);
refresh();
return;
}
const q = currentQuestion();
const opts = currentOptions();
// Tab navigation (multi-question only)
if (isMulti) {
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
currentTab = (currentTab + 1) % totalTabs;
optionIndex = 0;
refresh();
return;
}
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
currentTab = (currentTab - 1 + totalTabs) % totalTabs;
optionIndex = 0;
refresh();
return;
}
}
// Submit tab
if (currentTab === questions.length) {
if (matchesKey(data, Key.enter) && allAnswered()) {
submit(false);
} else if (matchesKey(data, Key.escape)) {
submit(true);
}
return;
}
// Option navigation
if (matchesKey(data, Key.up)) {
optionIndex = Math.max(0, optionIndex - 1);
refresh();
return;
}
if (matchesKey(data, Key.down)) {
optionIndex = Math.min(opts.length - 1, optionIndex + 1);
refresh();
return;
}
// Select option
if (matchesKey(data, Key.enter) && q) {
const opt = opts[optionIndex];
if (opt.isOther) {
inputMode = true;
inputQuestionId = q.id;
editor.setText("");
refresh();
return;
}
saveAnswer(q.id, opt.value, opt.label, false, optionIndex + 1);
advanceAfterAnswer();
return;
}
// Cancel
if (matchesKey(data, Key.escape)) {
submit(true);
}
}
function render(width: number): string[] {
if (cachedLines) return cachedLines;
const lines: string[] = [];
const q = currentQuestion();
const opts = currentOptions();
// Helper to add truncated line
const add = (s: string) => lines.push(truncateToWidth(s, width));
add(theme.fg("accent", "─".repeat(width)));
// Tab bar (multi-question only)
if (isMulti) {
const tabs: string[] = ["← "];
for (let i = 0; i < questions.length; i++) {
const isActive = i === currentTab;
const isAnswered = answers.has(questions[i].id);
const lbl = questions[i].label;
const box = isAnswered ? "■" : "□";
const color = isAnswered ? "success" : "muted";
const text = ` ${box} ${lbl} `;
const styled = isActive ? theme.bg("selectedBg", theme.fg("text", text)) : theme.fg(color, text);
tabs.push(`${styled} `);
}
const canSubmit = allAnswered();
const isSubmitTab = currentTab === questions.length;
const submitText = " ✓ Submit ";
const submitStyled = isSubmitTab
? theme.bg("selectedBg", theme.fg("text", submitText))
: theme.fg(canSubmit ? "success" : "dim", submitText);
tabs.push(`${submitStyled}`);
add(` ${tabs.join("")}`);
lines.push("");
}
// Helper to render options list
function renderOptions() {
for (let i = 0; i < opts.length; i++) {
const opt = opts[i];
const selected = i === optionIndex;
const isOther = opt.isOther === true;
const prefix = selected ? theme.fg("accent", "> ") : " ";
const color = selected ? "accent" : "text";
// Mark "Type something" differently when in input mode
if (isOther && inputMode) {
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label}`));
} else {
add(prefix + theme.fg(color, `${i + 1}. ${opt.label}`));
}
if (opt.description) {
add(` ${theme.fg("muted", opt.description)}`);
}
}
}
// Content
if (inputMode && q) {
add(theme.fg("text", ` ${q.prompt}`));
lines.push("");
// Show options for reference
renderOptions();
lines.push("");
add(theme.fg("muted", " Your answer:"));
for (const line of editor.render(width - 2)) {
add(` ${line}`);
}
lines.push("");
add(theme.fg("dim", " Enter to submit • Esc to cancel"));
} else if (currentTab === questions.length) {
add(theme.fg("accent", theme.bold(" Ready to submit")));
lines.push("");
for (const question of questions) {
const answer = answers.get(question.id);
if (answer) {
const prefix = answer.wasCustom ? "(wrote) " : "";
add(`${theme.fg("muted", ` ${question.label}: `)}${theme.fg("text", prefix + answer.label)}`);
}
}
lines.push("");
if (allAnswered()) {
add(theme.fg("success", " Press Enter to submit"));
} else {
const missing = questions
.filter((q) => !answers.has(q.id))
.map((q) => q.label)
.join(", ");
add(theme.fg("warning", ` Unanswered: ${missing}`));
}
} else if (q) {
add(theme.fg("text", ` ${q.prompt}`));
lines.push("");
renderOptions();
}
lines.push("");
if (!inputMode) {
const help = isMulti
? " Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel"
: " ↑↓ navigate • Enter select • Esc cancel";
add(theme.fg("dim", help));
}
add(theme.fg("accent", "─".repeat(width)));
cachedLines = lines;
return lines;
}
return {
render,
invalidate: () => {
cachedLines = undefined;
},
handleInput,
};
});
if (result.cancelled) {
return {
content: [{ type: "text", text: "User cancelled the questionnaire" }],
details: result,
};
}
const answerLines = result.answers.map((a) => {
const qLabel = questions.find((q) => q.id === a.id)?.label || a.id;
if (a.wasCustom) {
return `${qLabel}: user wrote: ${a.label}`;
}
return `${qLabel}: user selected: ${a.index}. ${a.label}`;
});
return {
content: [{ type: "text", text: answerLines.join("\n") }],
details: result,
};
},
renderCall(args, theme) {
const qs = (args.questions as Question[]) || [];
const count = qs.length;
const labels = qs.map((q) => q.label || q.id).join(", ");
let text = theme.fg("toolTitle", theme.bold("questionnaire "));
text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`);
if (labels) {
text += theme.fg("dim", ` (${truncateToWidth(labels, 40)})`);
}
return new Text(text, 0, 0);
},
renderResult(result, _options, theme) {
const details = result.details as QuestionnaireResult | undefined;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
if (details.cancelled) {
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
}
const lines = details.answers.map((a) => {
if (a.wasCustom) {
return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${theme.fg("muted", "(wrote) ")}${a.label}`;
}
const display = a.index ? `${a.index}. ${a.label}` : a.label;
return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${display}`;
});
return new Text(lines.join("\n"), 0, 0);
},
});
}

View file

@ -0,0 +1 @@
node_modules

View file

@ -0,0 +1,318 @@
/**
* Sandbox Extension - OS-level sandboxing for bash commands
*
* Uses @anthropic-ai/sandbox-runtime to enforce filesystem and network
* restrictions on bash commands at the OS level (sandbox-exec on macOS,
* bubblewrap on Linux).
*
* Config files (merged, project takes precedence):
* - ~/.pi/agent/sandbox.json (global)
* - <cwd>/.pi/sandbox.json (project-local)
*
* Example .pi/sandbox.json:
* ```json
* {
* "enabled": true,
* "network": {
* "allowedDomains": ["github.com", "*.github.com"],
* "deniedDomains": []
* },
* "filesystem": {
* "denyRead": ["~/.ssh", "~/.aws"],
* "allowWrite": [".", "/tmp"],
* "denyWrite": [".env"]
* }
* }
* ```
*
* Usage:
* - `pi -e ./sandbox` - sandbox enabled with default/config settings
* - `pi -e ./sandbox --no-sandbox` - disable sandboxing
* - `/sandbox` - show current sandbox configuration
*
* Setup:
* 1. Copy sandbox/ directory to ~/.pi/agent/extensions/
* 2. Run `npm install` in ~/.pi/agent/extensions/sandbox/
*
* Linux also requires: bubblewrap, socat, ripgrep
*/
import { spawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { type BashOperations, createBashTool } from "@mariozechner/pi-coding-agent";
interface SandboxConfig extends SandboxRuntimeConfig {
enabled?: boolean;
}
const DEFAULT_CONFIG: SandboxConfig = {
enabled: true,
network: {
allowedDomains: [
"npmjs.org",
"*.npmjs.org",
"registry.npmjs.org",
"registry.yarnpkg.com",
"pypi.org",
"*.pypi.org",
"github.com",
"*.github.com",
"api.github.com",
"raw.githubusercontent.com",
],
deniedDomains: [],
},
filesystem: {
denyRead: ["~/.ssh", "~/.aws", "~/.gnupg"],
allowWrite: [".", "/tmp"],
denyWrite: [".env", ".env.*", "*.pem", "*.key"],
},
};
function loadConfig(cwd: string): SandboxConfig {
const projectConfigPath = join(cwd, ".pi", "sandbox.json");
const globalConfigPath = join(homedir(), ".pi", "agent", "sandbox.json");
let globalConfig: Partial<SandboxConfig> = {};
let projectConfig: Partial<SandboxConfig> = {};
if (existsSync(globalConfigPath)) {
try {
globalConfig = JSON.parse(readFileSync(globalConfigPath, "utf-8"));
} catch (e) {
console.error(`Warning: Could not parse ${globalConfigPath}: ${e}`);
}
}
if (existsSync(projectConfigPath)) {
try {
projectConfig = JSON.parse(readFileSync(projectConfigPath, "utf-8"));
} catch (e) {
console.error(`Warning: Could not parse ${projectConfigPath}: ${e}`);
}
}
return deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig), projectConfig);
}
function deepMerge(base: SandboxConfig, overrides: Partial<SandboxConfig>): SandboxConfig {
const result: SandboxConfig = { ...base };
if (overrides.enabled !== undefined) result.enabled = overrides.enabled;
if (overrides.network) {
result.network = { ...base.network, ...overrides.network };
}
if (overrides.filesystem) {
result.filesystem = { ...base.filesystem, ...overrides.filesystem };
}
const extOverrides = overrides as {
ignoreViolations?: Record<string, string[]>;
enableWeakerNestedSandbox?: boolean;
};
const extResult = result as { ignoreViolations?: Record<string, string[]>; enableWeakerNestedSandbox?: boolean };
if (extOverrides.ignoreViolations) {
extResult.ignoreViolations = extOverrides.ignoreViolations;
}
if (extOverrides.enableWeakerNestedSandbox !== undefined) {
extResult.enableWeakerNestedSandbox = extOverrides.enableWeakerNestedSandbox;
}
return result;
}
function createSandboxedBashOps(): BashOperations {
return {
async exec(command, cwd, { onData, signal, timeout }) {
if (!existsSync(cwd)) {
throw new Error(`Working directory does not exist: ${cwd}`);
}
const wrappedCommand = await SandboxManager.wrapWithSandbox(command);
return new Promise((resolve, reject) => {
const child = spawn("bash", ["-c", wrappedCommand], {
cwd,
detached: true,
stdio: ["ignore", "pipe", "pipe"],
});
let timedOut = false;
let timeoutHandle: NodeJS.Timeout | undefined;
if (timeout !== undefined && timeout > 0) {
timeoutHandle = setTimeout(() => {
timedOut = true;
if (child.pid) {
try {
process.kill(-child.pid, "SIGKILL");
} catch {
child.kill("SIGKILL");
}
}
}, timeout * 1000);
}
child.stdout?.on("data", onData);
child.stderr?.on("data", onData);
child.on("error", (err) => {
if (timeoutHandle) clearTimeout(timeoutHandle);
reject(err);
});
const onAbort = () => {
if (child.pid) {
try {
process.kill(-child.pid, "SIGKILL");
} catch {
child.kill("SIGKILL");
}
}
};
signal?.addEventListener("abort", onAbort, { once: true });
child.on("close", (code) => {
if (timeoutHandle) clearTimeout(timeoutHandle);
signal?.removeEventListener("abort", onAbort);
if (signal?.aborted) {
reject(new Error("aborted"));
} else if (timedOut) {
reject(new Error(`timeout:${timeout}`));
} else {
resolve({ exitCode: code });
}
});
});
},
};
}
export default function (pi: ExtensionAPI) {
pi.registerFlag("no-sandbox", {
description: "Disable OS-level sandboxing for bash commands",
type: "boolean",
default: false,
});
const localCwd = process.cwd();
const localBash = createBashTool(localCwd);
let sandboxEnabled = false;
let sandboxInitialized = false;
pi.registerTool({
...localBash,
label: "bash (sandboxed)",
async execute(id, params, onUpdate, _ctx, signal) {
if (!sandboxEnabled || !sandboxInitialized) {
return localBash.execute(id, params, signal, onUpdate);
}
const sandboxedBash = createBashTool(localCwd, {
operations: createSandboxedBashOps(),
});
return sandboxedBash.execute(id, params, signal, onUpdate);
},
});
pi.on("user_bash", () => {
if (!sandboxEnabled || !sandboxInitialized) return;
return { operations: createSandboxedBashOps() };
});
pi.on("session_start", async (_event, ctx) => {
const noSandbox = pi.getFlag("no-sandbox") as boolean;
if (noSandbox) {
sandboxEnabled = false;
ctx.ui.notify("Sandbox disabled via --no-sandbox", "warning");
return;
}
const config = loadConfig(ctx.cwd);
if (!config.enabled) {
sandboxEnabled = false;
ctx.ui.notify("Sandbox disabled via config", "info");
return;
}
const platform = process.platform;
if (platform !== "darwin" && platform !== "linux") {
sandboxEnabled = false;
ctx.ui.notify(`Sandbox not supported on ${platform}`, "warning");
return;
}
try {
const configExt = config as unknown as {
ignoreViolations?: Record<string, string[]>;
enableWeakerNestedSandbox?: boolean;
};
await SandboxManager.initialize({
network: config.network,
filesystem: config.filesystem,
ignoreViolations: configExt.ignoreViolations,
enableWeakerNestedSandbox: configExt.enableWeakerNestedSandbox,
});
sandboxEnabled = true;
sandboxInitialized = true;
const networkCount = config.network?.allowedDomains?.length ?? 0;
const writeCount = config.filesystem?.allowWrite?.length ?? 0;
ctx.ui.setStatus(
"sandbox",
ctx.ui.theme.fg("accent", `🔒 Sandbox: ${networkCount} domains, ${writeCount} write paths`),
);
ctx.ui.notify("Sandbox initialized", "info");
} catch (err) {
sandboxEnabled = false;
ctx.ui.notify(`Sandbox initialization failed: ${err instanceof Error ? err.message : err}`, "error");
}
});
pi.on("session_shutdown", async () => {
if (sandboxInitialized) {
try {
await SandboxManager.reset();
} catch {
// Ignore cleanup errors
}
}
});
pi.registerCommand("sandbox", {
description: "Show sandbox configuration",
handler: async (_args, ctx) => {
if (!sandboxEnabled) {
ctx.ui.notify("Sandbox is disabled", "info");
return;
}
const config = loadConfig(ctx.cwd);
const lines = [
"Sandbox Configuration:",
"",
"Network:",
` Allowed: ${config.network?.allowedDomains?.join(", ") || "(none)"}`,
` Denied: ${config.network?.deniedDomains?.join(", ") || "(none)"}`,
"",
"Filesystem:",
` Deny Read: ${config.filesystem?.denyRead?.join(", ") || "(none)"}`,
` Allow Write: ${config.filesystem?.allowWrite?.join(", ") || "(none)"}`,
` Deny Write: ${config.filesystem?.denyWrite?.join(", ") || "(none)"}`,
];
ctx.ui.notify(lines.join("\n"), "info");
},
});
}

View file

@ -0,0 +1,92 @@
{
"name": "pi-extension-sandbox",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-sandbox",
"version": "1.0.0",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.26"
}
},
"node_modules/@anthropic-ai/sandbox-runtime": {
"version": "0.0.26",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.26.tgz",
"integrity": "sha512-DYV5LSsVMnzq0lbfaYMSpxZPUMAx4+hy343dRss+pVCLIfF62qOhxpYfZ5TmOk1GTDQm5f9wPprMNSStmnsV4w==",
"license": "Apache-2.0",
"dependencies": {
"@pondwader/socks5-server": "^1.0.10",
"@types/lodash-es": "^4.17.12",
"commander": "^12.1.0",
"lodash-es": "^4.17.21",
"shell-quote": "^1.8.3",
"zod": "^3.24.1"
},
"bin": {
"srt": "dist/cli.js"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@pondwader/socks5-server": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz",
"integrity": "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==",
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
"license": "MIT"
},
"node_modules/@types/lodash-es": {
"version": "4.17.12",
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/lodash-es": {
"version": "4.17.22",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz",
"integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
"license": "MIT"
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View file

@ -0,0 +1,19 @@
{
"name": "pi-extension-sandbox",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
"build": "echo 'nothing to build'",
"check": "echo 'nothing to check'"
},
"pi": {
"extensions": [
"./index.ts"
]
},
"dependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.26"
}
}

View file

@ -0,0 +1,195 @@
import { complete, getModel } from "@mariozechner/pi-ai";
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
import { DynamicBorder, getMarkdownTheme } from "@mariozechner/pi-coding-agent";
import { Container, Markdown, matchesKey, Text } from "@mariozechner/pi-tui";
type ContentBlock = {
type?: string;
text?: string;
name?: string;
arguments?: Record<string, unknown>;
};
type SessionEntry = {
type: string;
message?: {
role?: string;
content?: unknown;
};
};
const extractTextParts = (content: unknown): string[] => {
if (typeof content === "string") {
return [content];
}
if (!Array.isArray(content)) {
return [];
}
const textParts: string[] = [];
for (const part of content) {
if (!part || typeof part !== "object") {
continue;
}
const block = part as ContentBlock;
if (block.type === "text" && typeof block.text === "string") {
textParts.push(block.text);
}
}
return textParts;
};
const extractToolCallLines = (content: unknown): string[] => {
if (!Array.isArray(content)) {
return [];
}
const toolCalls: string[] = [];
for (const part of content) {
if (!part || typeof part !== "object") {
continue;
}
const block = part as ContentBlock;
if (block.type !== "toolCall" || typeof block.name !== "string") {
continue;
}
const args = block.arguments ?? {};
toolCalls.push(`Tool ${block.name} was called with args ${JSON.stringify(args)}`);
}
return toolCalls;
};
const buildConversationText = (entries: SessionEntry[]): string => {
const sections: string[] = [];
for (const entry of entries) {
if (entry.type !== "message" || !entry.message?.role) {
continue;
}
const role = entry.message.role;
const isUser = role === "user";
const isAssistant = role === "assistant";
if (!isUser && !isAssistant) {
continue;
}
const entryLines: string[] = [];
const textParts = extractTextParts(entry.message.content);
if (textParts.length > 0) {
const roleLabel = isUser ? "User" : "Assistant";
const messageText = textParts.join("\n").trim();
if (messageText.length > 0) {
entryLines.push(`${roleLabel}: ${messageText}`);
}
}
if (isAssistant) {
entryLines.push(...extractToolCallLines(entry.message.content));
}
if (entryLines.length > 0) {
sections.push(entryLines.join("\n"));
}
}
return sections.join("\n\n");
};
const buildSummaryPrompt = (conversationText: string): string =>
[
"Summarize this conversation so I can resume it later.",
"Include goals, key decisions, progress, open questions, and next steps.",
"Keep it concise and structured with headings.",
"",
"<conversation>",
conversationText,
"</conversation>",
].join("\n");
const showSummaryUi = async (summary: string, ctx: ExtensionCommandContext) => {
if (!ctx.hasUI) {
return;
}
await ctx.ui.custom((_tui, theme, _kb, done) => {
const container = new Container();
const border = new DynamicBorder((s: string) => theme.fg("accent", s));
const mdTheme = getMarkdownTheme();
container.addChild(border);
container.addChild(new Text(theme.fg("accent", theme.bold("Conversation Summary")), 1, 0));
container.addChild(new Markdown(summary, 1, 1, mdTheme));
container.addChild(new Text(theme.fg("dim", "Press Enter or Esc to close"), 1, 0));
container.addChild(border);
return {
render: (width: number) => container.render(width),
invalidate: () => container.invalidate(),
handleInput: (data: string) => {
if (matchesKey(data, "enter") || matchesKey(data, "escape")) {
done(undefined);
}
},
};
});
};
export default function (pi: ExtensionAPI) {
pi.registerCommand("summarize", {
description: "Summarize the current conversation in a custom UI",
handler: async (_args, ctx) => {
const branch = ctx.sessionManager.getBranch();
const conversationText = buildConversationText(branch);
if (!conversationText.trim()) {
if (ctx.hasUI) {
ctx.ui.notify("No conversation text found", "warning");
}
return;
}
if (ctx.hasUI) {
ctx.ui.notify("Preparing summary...", "info");
}
const model = getModel("openai", "gpt-5.2");
if (!model && ctx.hasUI) {
ctx.ui.notify("Model openai/gpt-5.2 not found", "warning");
}
const apiKey = model ? await ctx.modelRegistry.getApiKey(model) : undefined;
if (!apiKey && ctx.hasUI) {
ctx.ui.notify("No API key for openai/gpt-5.2", "warning");
}
if (!model || !apiKey) {
return;
}
const summaryMessages = [
{
role: "user" as const,
content: [{ type: "text" as const, text: buildSummaryPrompt(conversationText) }],
timestamp: Date.now(),
},
];
const response = await complete(model, { messages: summaryMessages }, { apiKey, reasoningEffort: "high" });
const summary = response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
await showSummaryUi(summary, ctx);
},
});
}

View file

@ -1,12 +1,12 @@
{
"name": "pi-extension-with-deps",
"version": "1.7.0",
"version": "1.9.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-extension-with-deps",
"version": "1.7.0",
"version": "1.9.5",
"dependencies": {
"ms": "^2.1.3"
},

View file

@ -1,7 +1,7 @@
{
"name": "pi-extension-with-deps",
"private": true,
"version": "1.7.0",
"version": "1.9.5",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",

View file

@ -37,9 +37,8 @@ import {
discoverModels,
discoverSkills,
discoverExtensions,
discoverCustomTools,
discoverContextFiles,
discoverSlashCommands,
discoverPromptTemplates,
loadSettings,
buildSystemPrompt,
ModelRegistry,
@ -92,7 +91,7 @@ const { session } = await createAgentSession({
extensions: [{ factory: myExtension }],
skills: [],
contextFiles: [],
slashCommands: [],
promptTemplates: [],
sessionManager: SessionManager.inMemory(),
});
@ -123,7 +122,7 @@ await session.prompt("Hello");
| `additionalExtensionPaths` | `[]` | Merge with discovery |
| `skills` | Discovered | Skills for prompt |
| `contextFiles` | Discovered | AGENTS.md files |
| `slashCommands` | Discovered | File commands |
| `promptTemplates` | Discovered | Prompt templates (slash commands) |
| `sessionManager` | `SessionManager.create(cwd)` | Persistence |
| `settingsManager` | From agentDir | Settings overrides |

View file

@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-coding-agent",
"version": "0.43.0",
"version": "0.45.5",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"type": "module",
"piConfig": {
@ -39,19 +39,19 @@
},
"dependencies": {
"@mariozechner/clipboard": "^0.3.0",
"@mariozechner/pi-agent-core": "^0.43.0",
"@mariozechner/pi-ai": "^0.43.0",
"@mariozechner/pi-tui": "^0.43.0",
"@mariozechner/jiti": "^2.6.2",
"@mariozechner/pi-agent-core": "^0.45.5",
"@mariozechner/pi-ai": "^0.45.5",
"@mariozechner/pi-tui": "^0.45.5",
"chalk": "^5.5.0",
"cli-highlight": "^2.1.11",
"diff": "^8.0.2",
"file-type": "^21.1.1",
"glob": "^11.0.3",
"jiti": "^2.6.1",
"marked": "^15.0.12",
"minimatch": "^10.1.1",
"proper-lockfile": "^4.1.2",
"sharp": "^0.34.2"
"wasm-vips": "^0.0.16"
},
"devDependencies": {
"@types/diff": "^7.0.2",

View file

@ -242,7 +242,15 @@ ${chalk.bold("Environment Variables:")}
CEREBRAS_API_KEY - Cerebras API key
XAI_API_KEY - xAI Grok API key
OPENROUTER_API_KEY - OpenRouter API key
AI_GATEWAY_API_KEY - Vercel AI Gateway API key
ZAI_API_KEY - ZAI API key
MISTRAL_API_KEY - Mistral API key
MINIMAX_API_KEY - MiniMax API key
AWS_PROFILE - AWS profile for Amazon Bedrock
AWS_ACCESS_KEY_ID - AWS access key for Amazon Bedrock
AWS_SECRET_ACCESS_KEY - AWS secret key for Amazon Bedrock
AWS_BEARER_TOKEN_BEDROCK - Bedrock API key (bearer token)
AWS_REGION - AWS region for Amazon Bedrock (e.g., us-east-1)
${ENV_AGENT_DIR.padEnd(23)} - Session storage directory (default: ~/${CONFIG_DIR_NAME}/agent)
${chalk.bold("Available Tools (default: read, bash, edit, write):")}

View file

@ -25,7 +25,7 @@ function formatTokenCount(count: number): string {
* List available models, optionally filtered by search pattern
*/
export async function listModels(modelRegistry: ModelRegistry, searchPattern?: string): Promise<void> {
const models = await modelRegistry.getAvailable();
const models = modelRegistry.getAvailable();
if (models.length === 0) {
console.log("No models available. Set API keys in environment variables.");

View file

@ -1520,8 +1520,8 @@ export class AgentSession {
if (isContextOverflow(message, contextWindow)) return false;
const err = message.errorMessage;
// Match: overloaded_error, rate limit, 429, 500, 502, 503, 504, service unavailable, connection error
return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error/i.test(
// Match: overloaded_error, rate limit, 429, 500, 502, 503, 504, service unavailable, connection error, other side closed
return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|other side closed/i.test(
err,
);
}

View file

@ -1,5 +1,7 @@
/**
* Extension loader - loads TypeScript extension modules using jiti.
*
* Uses @mariozechner/jiti fork with virtualModules support for compiled Bun binaries.
*/
import * as fs from "node:fs";
@ -7,9 +9,19 @@ import { createRequire } from "node:module";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { createJiti } from "@mariozechner/jiti";
import * as _bundledPiAgentCore from "@mariozechner/pi-agent-core";
import * as _bundledPiAi from "@mariozechner/pi-ai";
import type { KeyId } from "@mariozechner/pi-tui";
import { createJiti } from "jiti";
import * as _bundledPiTui from "@mariozechner/pi-tui";
// Static imports of packages that extensions may use.
// These MUST be static so Bun bundles them into the compiled binary.
// The virtualModules option then makes them available to extensions.
import * as _bundledTypebox from "@sinclair/typebox";
import { getAgentDir, isBunBinary } from "../../config.js";
// NOTE: This import works because loader.ts exports are NOT re-exported from index.ts,
// avoiding a circular dependency. Extensions can import from @mariozechner/pi-coding-agent.
import * as _bundledPiCodingAgent from "../../index.js";
import { createEventBus, type EventBus } from "../event-bus.js";
import type { ExecOptions } from "../exec.js";
import { execCommand } from "../exec.js";
@ -24,8 +36,21 @@ import type {
ToolDefinition,
} from "./types.js";
/** Modules available to extensions via virtualModules (for compiled Bun binary) */
const VIRTUAL_MODULES: Record<string, unknown> = {
"@sinclair/typebox": _bundledTypebox,
"@mariozechner/pi-agent-core": _bundledPiAgentCore,
"@mariozechner/pi-tui": _bundledPiTui,
"@mariozechner/pi-ai": _bundledPiAi,
"@mariozechner/pi-coding-agent": _bundledPiCodingAgent,
};
const require = createRequire(import.meta.url);
/**
* Get aliases for jiti (used in Node.js/development mode).
* In Bun binary mode, virtualModules is used instead.
*/
let _aliases: Record<string, string> | null = null;
function getAliases(): Record<string, string> {
if (_aliases) return _aliases;
@ -38,11 +63,12 @@ function getAliases(): Record<string, string> {
_aliases = {
"@mariozechner/pi-coding-agent": packageIndex,
"@mariozechner/pi-coding-agent/extensions": path.resolve(__dirname, "index.js"),
"@mariozechner/pi-agent-core": require.resolve("@mariozechner/pi-agent-core"),
"@mariozechner/pi-tui": require.resolve("@mariozechner/pi-tui"),
"@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"),
"@sinclair/typebox": typeboxRoot,
};
return _aliases;
}
@ -213,18 +239,15 @@ function createExtensionAPI(
return api;
}
async function loadBun(path: string) {
const module = await import(path);
const factory = (module.default ?? module) as ExtensionFactory;
return typeof factory !== "function" ? undefined : factory;
}
async function loadJiti(path: string) {
async function loadExtensionModule(extensionPath: string) {
const jiti = createJiti(import.meta.url, {
alias: getAliases(),
// In Bun binary: use virtualModules for bundled packages (no filesystem resolution)
// Also disable tryNative so jiti handles ALL imports (not just the entry point)
// In Node.js/dev: use aliases to resolve to node_modules paths
...(isBunBinary ? { virtualModules: VIRTUAL_MODULES, tryNative: false } : { alias: getAliases() }),
});
const module = await jiti.import(path, { default: true });
const module = await jiti.import(extensionPath, { default: true });
const factory = module as ExtensionFactory;
return typeof factory !== "function" ? undefined : factory;
}
@ -254,7 +277,7 @@ async function loadExtension(
const resolvedPath = resolvePath(extensionPath, cwd);
try {
const factory = isBunBinary ? await loadBun(resolvedPath) : await loadJiti(resolvedPath);
const factory = await loadExtensionModule(resolvedPath);
if (!factory) {
return { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` };
}

View file

@ -37,6 +37,7 @@ const ModelDefinitionSchema = Type.Object({
Type.Literal("openai-codex-responses"),
Type.Literal("anthropic-messages"),
Type.Literal("google-generative-ai"),
Type.Literal("bedrock-converse-stream"),
]),
),
reasoning: Type.Boolean(),
@ -63,6 +64,7 @@ const ProviderConfigSchema = Type.Object({
Type.Literal("openai-codex-responses"),
Type.Literal("anthropic-messages"),
Type.Literal("google-generative-ai"),
Type.Literal("bedrock-converse-stream"),
]),
),
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
@ -373,6 +375,13 @@ export class ModelRegistry {
return this.authStorage.getApiKey(model.provider);
}
/**
* Get API key for a provider.
*/
async getApiKeyForProvider(provider: string): Promise<string | undefined> {
return this.authStorage.getApiKey(provider);
}
/**
* Check if a model is using OAuth credentials (subscription).
*/

View file

@ -11,6 +11,7 @@ import type { ModelRegistry } from "./model-registry.js";
/** Default model IDs for each known provider */
export const defaultModelPerProvider: Record<KnownProvider, string> = {
"amazon-bedrock": "global.anthropic.claude-sonnet-4-5-20250929-v1:0",
anthropic: "claude-sonnet-4-5",
openai: "gpt-5.1-codex",
"openai-codex": "gpt-5.2-codex",
@ -20,11 +21,13 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
"google-vertex": "gemini-3-pro-preview",
"github-copilot": "gpt-4o",
openrouter: "openai/gpt-5.1-codex",
"vercel-ai-gateway": "anthropic/claude-opus-4.5",
xai: "grok-4-fast-non-reasoning",
groq: "openai/gpt-oss-120b",
cerebras: "zai-glm-4.6",
zai: "glm-4.6",
mistral: "devstral-medium-latest",
minimax: "MiniMax-M2.1",
opencode: "claude-opus-4-5",
};

View file

@ -628,14 +628,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
steeringMode: settingsManager.getSteeringMode(),
followUpMode: settingsManager.getFollowUpMode(),
thinkingBudgets: settingsManager.getThinkingBudgets(),
getApiKey: async () => {
const currentModel = agent.state.model;
if (!currentModel) {
getApiKey: async (provider) => {
// Use the provider argument from the in-flight request;
// agent.state.model may already be switched mid-turn.
const resolvedProvider = provider || agent.state.model?.provider;
if (!resolvedProvider) {
throw new Error("No model selected");
}
const key = await modelRegistry.getApiKey(currentModel);
const key = await modelRegistry.getApiKeyForProvider(resolvedProvider);
if (!key) {
throw new Error(`No API key found for provider "${currentModel.provider}"`);
throw new Error(`No API key found for provider "${resolvedProvider}"`);
}
return key;
},

View file

@ -88,8 +88,6 @@ export type {
UserBashEventResult,
} from "./core/extensions/index.js";
export {
createExtensionRuntime,
discoverAndLoadExtensions,
ExtensionRunner,
isBashToolResult,
isEditToolResult,
@ -98,7 +96,6 @@ export {
isLsToolResult,
isReadToolResult,
isWriteToolResult,
loadExtensions,
wrapRegisteredTool,
wrapRegisteredTools,
wrapToolsWithExtensions,

View file

@ -12,6 +12,55 @@ import {
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
// EnabledIds: null = all enabled (no filter), string[] = explicit ordered list
type EnabledIds = string[] | null;
function isEnabled(enabledIds: EnabledIds, id: string): boolean {
return enabledIds === null || enabledIds.includes(id);
}
function toggle(enabledIds: EnabledIds, id: string): EnabledIds {
if (enabledIds === null) return [id]; // First toggle: start with only this one
const index = enabledIds.indexOf(id);
if (index >= 0) return [...enabledIds.slice(0, index), ...enabledIds.slice(index + 1)];
return [...enabledIds, id];
}
function enableAll(enabledIds: EnabledIds, allIds: string[], targetIds?: string[]): EnabledIds {
if (enabledIds === null) return null; // Already all enabled
const targets = targetIds ?? allIds;
const result = [...enabledIds];
for (const id of targets) {
if (!result.includes(id)) result.push(id);
}
return result.length === allIds.length ? null : result;
}
function clearAll(enabledIds: EnabledIds, allIds: string[], targetIds?: string[]): EnabledIds {
if (enabledIds === null) {
return targetIds ? allIds.filter((id) => !targetIds.includes(id)) : [];
}
const targets = new Set(targetIds ?? enabledIds);
return enabledIds.filter((id) => !targets.has(id));
}
function move(enabledIds: EnabledIds, allIds: string[], id: string, delta: number): EnabledIds {
const list = enabledIds ?? [...allIds];
const index = list.indexOf(id);
if (index < 0) return list;
const newIndex = index + delta;
if (newIndex < 0 || newIndex >= list.length) return list;
const result = [...list];
[result[index], result[newIndex]] = [result[newIndex], result[index]];
return result;
}
function getSortedIds(enabledIds: EnabledIds, allIds: string[]): string[] {
if (enabledIds === null) return allIds;
const enabledSet = new Set(enabledIds);
return [...enabledIds, ...allIds.filter((id) => !enabledSet.has(id))];
}
interface ModelItem {
fullId: string;
model: Model<any>;
@ -44,7 +93,9 @@ export interface ModelsCallbacks {
* Changes are session-only until explicitly persisted with Ctrl+S.
*/
export class ScopedModelsSelectorComponent extends Container {
private items: ModelItem[] = [];
private modelsById: Map<string, Model<any>> = new Map();
private allIds: string[] = [];
private enabledIds: EnabledIds = null;
private filteredItems: ModelItem[] = [];
private selectedIndex = 0;
private searchInput: Input;
@ -58,28 +109,14 @@ export class ScopedModelsSelectorComponent extends Container {
super();
this.callbacks = callbacks;
// Group models by provider for organized display
const modelsByProvider = new Map<string, Model<any>[]>();
for (const model of config.allModels) {
const list = modelsByProvider.get(model.provider) ?? [];
list.push(model);
modelsByProvider.set(model.provider, list);
const fullId = `${model.provider}/${model.id}`;
this.modelsById.set(fullId, model);
this.allIds.push(fullId);
}
// Build items - group by provider
for (const [provider, models] of modelsByProvider) {
for (const model of models) {
const fullId = `${provider}/${model.id}`;
// If no filter defined, all models are enabled by default
const isEnabled = !config.hasEnabledModelsFilter || config.enabledModelIds.has(fullId);
this.items.push({
fullId,
model,
enabled: isEnabled,
});
}
}
this.filteredItems = this.getSortedItems();
this.enabledIds = config.hasEnabledModelsFilter ? [...config.enabledModelIds] : null;
this.filteredItems = this.buildItems();
// Header
this.addChild(new DynamicBorder());
@ -103,41 +140,34 @@ export class ScopedModelsSelectorComponent extends Container {
this.addChild(this.footerText);
this.addChild(new DynamicBorder());
this.updateList();
}
/** Get items sorted with enabled items first */
private getSortedItems(): ModelItem[] {
const enabled = this.items.filter((i) => i.enabled);
const disabled = this.items.filter((i) => !i.enabled);
return [...enabled, ...disabled];
private buildItems(): ModelItem[] {
return getSortedIds(this.enabledIds, this.allIds).map((id) => ({
fullId: id,
model: this.modelsById.get(id)!,
enabled: isEnabled(this.enabledIds, id),
}));
}
private getFooterText(): string {
const enabledCount = this.items.filter((i) => i.enabled).length;
const allEnabled = enabledCount === this.items.length;
const countText = allEnabled ? "all enabled" : `${enabledCount}/${this.items.length} enabled`;
const parts = ["Enter toggle", "^A all", "^X clear", "^P provider", "^S save", countText];
if (this.isDirty) {
return theme.fg("dim", ` ${parts.join(" · ")} `) + theme.fg("warning", "(unsaved)");
}
return theme.fg("dim", ` ${parts.join(" · ")}`);
const enabledCount = this.enabledIds?.length ?? this.allIds.length;
const allEnabled = this.enabledIds === null;
const countText = allEnabled ? "all enabled" : `${enabledCount}/${this.allIds.length} enabled`;
const parts = ["Enter toggle", "^A all", "^X clear", "^P provider", "Alt+↑↓ reorder", "^S save", countText];
return this.isDirty
? theme.fg("dim", ` ${parts.join(" · ")} `) + theme.fg("warning", "(unsaved)")
: theme.fg("dim", ` ${parts.join(" · ")}`);
}
private updateFooter(): void {
this.footerText.setText(this.getFooterText());
}
private filterItems(query: string): void {
const sorted = this.getSortedItems();
if (!query) {
this.filteredItems = sorted;
} else {
this.filteredItems = fuzzyFilter(sorted, query, (item) => `${item.model.id} ${item.model.provider}`);
}
private refresh(): void {
const query = this.searchInput.getValue();
const items = this.buildItems();
this.filteredItems = query ? fuzzyFilter(items, query, (i) => `${i.model.id} ${i.model.provider}`) : items;
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
this.updateList();
this.footerText.setText(this.getFooterText());
}
private updateList(): void {
@ -153,53 +183,26 @@ export class ScopedModelsSelectorComponent extends Container {
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
);
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
// Only show status if there's a filter (not all models enabled)
const allEnabled = this.items.every((i) => i.enabled);
const allEnabled = this.enabledIds === null;
for (let i = startIndex; i < endIndex; i++) {
const item = this.filteredItems[i];
if (!item) continue;
const item = this.filteredItems[i]!;
const isSelected = i === this.selectedIndex;
const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
const modelText = isSelected ? theme.fg("accent", item.model.id) : item.model.id;
const providerBadge = theme.fg("muted", ` [${item.model.provider}]`);
// Only show checkmarks when there's actually a filter
const status = allEnabled ? "" : item.enabled ? theme.fg("success", " ✓") : theme.fg("dim", " ✗");
this.listContainer.addChild(new Text(`${prefix}${modelText}${providerBadge}${status}`, 0, 0));
}
// Add scroll indicator if needed
if (startIndex > 0 || endIndex < this.filteredItems.length) {
const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredItems.length})`);
this.listContainer.addChild(new Text(scrollInfo, 0, 0));
this.listContainer.addChild(
new Text(theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredItems.length})`), 0, 0),
);
}
}
private toggleItem(item: ModelItem): void {
// If all models are currently enabled (no scope yet), first toggle starts fresh:
// clear all and enable only the selected model
const allEnabled = this.items.every((i) => i.enabled);
if (allEnabled) {
for (const i of this.items) {
i.enabled = false;
}
item.enabled = true;
this.isDirty = true;
this.callbacks.onClearAll();
this.callbacks.onModelToggle(item.fullId, true);
} else {
item.enabled = !item.enabled;
this.isDirty = true;
this.callbacks.onModelToggle(item.fullId, item.enabled);
}
// Re-sort and re-filter to move item to correct section
this.filterItems(this.searchInput.getValue());
this.updateFooter();
}
handleInput(data: string): void {
const kb = getEditorKeybindings();
@ -217,70 +220,81 @@ export class ScopedModelsSelectorComponent extends Container {
return;
}
// Alt+Up/Down - Reorder enabled models
if (matchesKey(data, Key.alt("up")) || matchesKey(data, Key.alt("down"))) {
const item = this.filteredItems[this.selectedIndex];
if (item && isEnabled(this.enabledIds, item.fullId)) {
const delta = matchesKey(data, Key.alt("up")) ? -1 : 1;
const enabledList = this.enabledIds ?? this.allIds;
const currentIndex = enabledList.indexOf(item.fullId);
const newIndex = currentIndex + delta;
// Only move if within bounds
if (newIndex >= 0 && newIndex < enabledList.length) {
this.enabledIds = move(this.enabledIds, this.allIds, item.fullId, delta);
this.isDirty = true;
this.selectedIndex += delta;
this.refresh();
}
}
return;
}
// Toggle on Enter
if (matchesKey(data, Key.enter)) {
const item = this.filteredItems[this.selectedIndex];
if (item) {
this.toggleItem(item);
const wasAllEnabled = this.enabledIds === null;
this.enabledIds = toggle(this.enabledIds, item.fullId);
this.isDirty = true;
if (wasAllEnabled) this.callbacks.onClearAll();
this.callbacks.onModelToggle(item.fullId, isEnabled(this.enabledIds, item.fullId));
this.refresh();
}
return;
}
// Ctrl+A - Enable all (filtered if search active, otherwise all)
if (matchesKey(data, Key.ctrl("a"))) {
const targets = this.searchInput.getValue() ? this.filteredItems : this.items;
for (const item of targets) {
item.enabled = true;
}
const targetIds = this.searchInput.getValue() ? this.filteredItems.map((i) => i.fullId) : undefined;
this.enabledIds = enableAll(this.enabledIds, this.allIds, targetIds);
this.isDirty = true;
this.callbacks.onEnableAll(targets.map((i) => i.fullId));
this.filterItems(this.searchInput.getValue());
this.updateFooter();
this.callbacks.onEnableAll(targetIds ?? this.allIds);
this.refresh();
return;
}
// Ctrl+X - Clear all (filtered if search active, otherwise all)
if (matchesKey(data, Key.ctrl("x"))) {
const targets = this.searchInput.getValue() ? this.filteredItems : this.items;
for (const item of targets) {
item.enabled = false;
}
const targetIds = this.searchInput.getValue() ? this.filteredItems.map((i) => i.fullId) : undefined;
this.enabledIds = clearAll(this.enabledIds, this.allIds, targetIds);
this.isDirty = true;
this.callbacks.onClearAll();
this.filterItems(this.searchInput.getValue());
this.updateFooter();
this.refresh();
return;
}
// Ctrl+P - Toggle provider of current item
if (matchesKey(data, Key.ctrl("p"))) {
const currentItem = this.filteredItems[this.selectedIndex];
if (currentItem) {
const provider = currentItem.model.provider;
const providerItems = this.items.filter((i) => i.model.provider === provider);
const allEnabled = providerItems.every((i) => i.enabled);
const newState = !allEnabled;
for (const item of providerItems) {
item.enabled = newState;
}
const item = this.filteredItems[this.selectedIndex];
if (item) {
const provider = item.model.provider;
const providerIds = this.allIds.filter((id) => this.modelsById.get(id)!.provider === provider);
const allEnabled = providerIds.every((id) => isEnabled(this.enabledIds, id));
this.enabledIds = allEnabled
? clearAll(this.enabledIds, this.allIds, providerIds)
: enableAll(this.enabledIds, this.allIds, providerIds);
this.isDirty = true;
this.callbacks.onToggleProvider(
provider,
providerItems.map((i) => i.fullId),
newState,
);
this.filterItems(this.searchInput.getValue());
this.updateFooter();
this.callbacks.onToggleProvider(provider, providerIds, !allEnabled);
this.refresh();
}
return;
}
// Ctrl+S - Save/persist to settings
if (matchesKey(data, Key.ctrl("s"))) {
const enabledIds = this.items.filter((i) => i.enabled).map((i) => i.fullId);
this.callbacks.onPersist(enabledIds);
this.callbacks.onPersist(this.enabledIds ?? [...this.allIds]);
this.isDirty = false;
this.updateFooter();
this.footerText.setText(this.getFooterText());
return;
}
@ -288,7 +302,7 @@ export class ScopedModelsSelectorComponent extends Container {
if (matchesKey(data, Key.ctrl("c"))) {
if (this.searchInput.getValue()) {
this.searchInput.setValue("");
this.filterItems("");
this.refresh();
} else {
this.callbacks.onCancel();
}
@ -303,7 +317,7 @@ export class ScopedModelsSelectorComponent extends Container {
// Pass everything else to search input
this.searchInput.handleInput(data);
this.filterItems(this.searchInput.getValue());
this.refresh();
}
getSearchInput(): Input {

View file

@ -603,10 +603,9 @@ export class InteractiveMode {
const entries = parseChangelog(changelogPath);
if (!lastVersion) {
if (entries.length > 0) {
this.settingsManager.setLastChangelogVersion(VERSION);
return entries.map((e) => e.content).join("\n\n");
}
// Fresh install - just record the version, don't show changelog
this.settingsManager.setLastChangelogVersion(VERSION);
return undefined;
} else {
const newEntries = getNewEntries(entries, lastVersion);
if (newEntries.length > 0) {
@ -3271,7 +3270,7 @@ export class InteractiveMode {
}
// Create the preview URL
const previewUrl = `https://shittycodingagent.ai/session?${gistId}`;
const previewUrl = `https://buildwithpi.ai/session?${gistId}`;
this.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`);
} catch (error: unknown) {
if (!loader.signal.aborted) {

View file

@ -2,13 +2,13 @@
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
"name": "light",
"vars": {
"teal": "#5f8787",
"blue": "#5f87af",
"green": "#87af87",
"red": "#af5f5f",
"yellow": "#d7af5f",
"teal": "#5a8080",
"blue": "#547da7",
"green": "#588458",
"red": "#aa5555",
"yellow": "#9a7326",
"mediumGray": "#6c6c6c",
"dimGray": "#8a8a8a",
"dimGray": "#767676",
"lightGray": "#b0b0b0",
"selectedBg": "#d0d0e0",
"userMsgBg": "#e8e8e8",
@ -68,9 +68,9 @@
"syntaxPunctuation": "#000000",
"thinkingOff": "lightGray",
"thinkingMinimal": "#9e9e9e",
"thinkingLow": "#5f87af",
"thinkingMedium": "#5f8787",
"thinkingMinimal": "#767676",
"thinkingLow": "blue",
"thinkingMedium": "teal",
"thinkingHigh": "#875f87",
"thinkingXhigh": "#8b008b",

View file

@ -29,6 +29,12 @@ export interface PrintModeOptions {
*/
export async function runPrintMode(session: AgentSession, options: PrintModeOptions): Promise<void> {
const { mode, messages = [], initialMessage, initialImages } = options;
if (mode === "json") {
const header = session.sessionManager.getHeader();
if (header) {
console.log(JSON.stringify(header));
}
}
// Set up extensions for print mode (no UI, no command context)
const extensionRunner = session.extensionRunner;
if (extensionRunner) {

View file

@ -1,3 +1,5 @@
import { getVips } from "./vips.js";
/**
* Convert image to PNG format for terminal display.
* Kitty graphics protocol requires PNG format (f=100).
@ -11,16 +13,23 @@ export async function convertToPng(
return { data: base64Data, mimeType };
}
const vips = await getVips();
if (!vips) {
// wasm-vips not available
return null;
}
try {
const sharp = (await import("sharp")).default;
const buffer = Buffer.from(base64Data, "base64");
const pngBuffer = await sharp(buffer).png().toBuffer();
const img = vips.Image.newFromBuffer(buffer);
const pngBuffer = img.writeToBuffer(".png");
img.delete();
return {
data: pngBuffer.toString("base64"),
data: Buffer.from(pngBuffer).toString("base64"),
mimeType: "image/png",
};
} catch {
// Sharp not available or conversion failed
// Conversion failed
return null;
}
}

View file

@ -1,4 +1,5 @@
import type { ImageContent } from "@mariozechner/pi-ai";
import { getVips } from "./vips.js";
export interface ImageResizeOptions {
maxWidth?: number; // Default: 2000
@ -29,9 +30,9 @@ const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
/** Helper to pick the smaller of two buffers */
function pickSmaller(
a: { buffer: Buffer; mimeType: string },
b: { buffer: Buffer; mimeType: string },
): { buffer: Buffer; mimeType: string } {
a: { buffer: Uint8Array; mimeType: string },
b: { buffer: Uint8Array; mimeType: string },
): { buffer: Uint8Array; mimeType: string } {
return a.buffer.length <= b.buffer.length ? a : b;
}
@ -39,7 +40,7 @@ function pickSmaller(
* Resize an image to fit within the specified max dimensions and file size.
* Returns the original image if it already fits within the limits.
*
* Uses sharp for image processing. If sharp is not available (e.g., in some
* Uses wasm-vips for image processing. If wasm-vips is not available (e.g., in some
* environments), returns the original image unchanged.
*
* Strategy for staying under maxBytes:
@ -52,12 +53,29 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
const opts = { ...DEFAULT_OPTIONS, ...options };
const buffer = Buffer.from(img.data, "base64");
let sharp: typeof import("sharp") | undefined;
const vipsOrNull = await getVips();
if (!vipsOrNull) {
// wasm-vips not available - return original image
// We can't get dimensions without vips, so return 0s
return {
data: img.data,
mimeType: img.mimeType,
originalWidth: 0,
originalHeight: 0,
width: 0,
height: 0,
wasResized: false,
};
}
// Capture non-null reference for use in nested functions
const vips = vipsOrNull;
// Load image to get metadata
let sourceImg: InstanceType<typeof vips.Image>;
try {
sharp = (await import("sharp")).default;
sourceImg = vips.Image.newFromBuffer(buffer);
} catch {
// Sharp not available - return original image
// We can't get dimensions without sharp, so return 0s
// Failed to load image
return {
data: img.data,
mimeType: img.mimeType,
@ -69,16 +87,14 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
};
}
const sharpImg = sharp(buffer);
const metadata = await sharpImg.metadata();
const originalWidth = metadata.width ?? 0;
const originalHeight = metadata.height ?? 0;
const format = metadata.format ?? img.mimeType?.split("/")[1] ?? "png";
const originalWidth = sourceImg.width;
const originalHeight = sourceImg.height;
// Check if already within all limits (dimensions AND size)
const originalSize = buffer.length;
if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= opts.maxBytes) {
sourceImg.delete();
const format = img.mimeType?.split("/")[1] ?? "png";
return {
data: img.data,
mimeType: img.mimeType ?? `image/${format}`,
@ -104,37 +120,45 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
}
// Helper to resize and encode in both formats, returning the smaller one
async function tryBothFormats(
function tryBothFormats(
width: number,
height: number,
jpegQuality: number,
): Promise<{ buffer: Buffer; mimeType: string }> {
const resized = await sharp!(buffer)
.resize(width, height, { fit: "inside", withoutEnlargement: true })
.toBuffer();
): { buffer: Uint8Array; mimeType: string } {
// Load image fresh and resize using scale factor
// (Using newFromBuffer + resize instead of thumbnailBuffer to avoid lazy re-read issues)
const img = vips.Image.newFromBuffer(buffer);
const scale = Math.min(width / img.width, height / img.height);
const resized = scale < 1 ? img.resize(scale) : img;
const [pngBuffer, jpegBuffer] = await Promise.all([
sharp!(resized).png({ compressionLevel: 9 }).toBuffer(),
sharp!(resized).jpeg({ quality: jpegQuality }).toBuffer(),
]);
const pngBuffer = resized.writeToBuffer(".png");
const jpegBuffer = resized.writeToBuffer(".jpg", { Q: jpegQuality });
if (resized !== img) {
resized.delete();
}
img.delete();
return pickSmaller({ buffer: pngBuffer, mimeType: "image/png" }, { buffer: jpegBuffer, mimeType: "image/jpeg" });
}
// Clean up the source image
sourceImg.delete();
// Try to produce an image under maxBytes
const qualitySteps = [85, 70, 55, 40];
const scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];
let best: { buffer: Buffer; mimeType: string };
let best: { buffer: Uint8Array; mimeType: string };
let finalWidth = targetWidth;
let finalHeight = targetHeight;
// First attempt: resize to target dimensions, try both formats
best = await tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
best = tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
if (best.buffer.length <= opts.maxBytes) {
return {
data: best.buffer.toString("base64"),
data: Buffer.from(best.buffer).toString("base64"),
mimeType: best.mimeType,
originalWidth,
originalHeight,
@ -146,11 +170,11 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
// Still too large - try JPEG with decreasing quality (and compare to PNG each time)
for (const quality of qualitySteps) {
best = await tryBothFormats(targetWidth, targetHeight, quality);
best = tryBothFormats(targetWidth, targetHeight, quality);
if (best.buffer.length <= opts.maxBytes) {
return {
data: best.buffer.toString("base64"),
data: Buffer.from(best.buffer).toString("base64"),
mimeType: best.mimeType,
originalWidth,
originalHeight,
@ -172,11 +196,11 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
}
for (const quality of qualitySteps) {
best = await tryBothFormats(finalWidth, finalHeight, quality);
best = tryBothFormats(finalWidth, finalHeight, quality);
if (best.buffer.length <= opts.maxBytes) {
return {
data: best.buffer.toString("base64"),
data: Buffer.from(best.buffer).toString("base64"),
mimeType: best.mimeType,
originalWidth,
originalHeight,
@ -191,7 +215,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
// Last resort: return smallest version we produced even if over limit
// (the API will reject it, but at least we tried everything)
return {
data: best.buffer.toString("base64"),
data: Buffer.from(best.buffer).toString("base64"),
mimeType: best.mimeType,
originalWidth,
originalHeight,

View file

@ -0,0 +1,40 @@
/**
* Singleton wrapper for wasm-vips initialization.
* wasm-vips requires async initialization, so we cache the instance.
*/
import type Vips from "wasm-vips";
let vipsInstance: Awaited<ReturnType<typeof Vips>> | null = null;
let vipsInitPromise: Promise<Awaited<ReturnType<typeof Vips>> | null> | null = null;
/**
* Get the initialized wasm-vips instance.
* Returns null if wasm-vips is not available or fails to initialize.
*/
export async function getVips(): Promise<Awaited<ReturnType<typeof Vips>> | null> {
if (vipsInstance) {
return vipsInstance;
}
if (vipsInitPromise) {
return vipsInitPromise;
}
vipsInitPromise = (async () => {
try {
const VipsInit = (await import("wasm-vips")).default;
vipsInstance = await VipsInit();
return vipsInstance;
} catch {
// wasm-vips not available
return null;
}
})();
const result = await vipsInitPromise;
if (!result) {
vipsInitPromise = null; // Allow retry on failure
}
return result;
}

View file

@ -0,0 +1,144 @@
/**
* Tests for image processing utilities using wasm-vips.
*/
import { describe, expect, it } from "vitest";
import { convertToPng } from "../src/utils/image-convert.js";
import { formatDimensionNote, resizeImage } from "../src/utils/image-resize.js";
import { getVips } from "../src/utils/vips.js";
// Small 2x2 red PNG image (base64)
const TINY_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAADklEQVQI12P4z8DAwMAAAA0BA/m5sb9AAAAAAElFTkSuQmCC";
// Small 2x2 blue JPEG image (base64)
const TINY_JPEG =
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAACAAIDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAn/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCwAB//2Q==";
// 100x100 gray PNG (generated with wasm-vips)
const MEDIUM_PNG_100x100 =
"iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAtGVYSWZJSSoACAAAAAYAEgEDAAEAAAABAAAAGgEFAAEAAABWAAAAGwEFAAEAAABeAAAAKAEDAAEAAAACAAAAEwIDAAEAAAABAAAAaYcEAAEAAABmAAAAAAAAADhjAADoAwAAOGMAAOgDAAAGAACQBwAEAAAAMDIxMAGRBwAEAAAAAQIDAACgBwAEAAAAMDEwMAGgAwABAAAA//8AAAKgBAABAAAAZAAAAAOgBAABAAAAZAAAAAAAAAC1xMTxAAAA4klEQVR4nO3QoQEAAAiAME/3dF+QvmUSs7zNP8WswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKzArMCswKz9zzpHfptnWvrkoQAAAABJRU5ErkJggg==";
// 200x200 colored PNG (generated with wasm-vips)
const LARGE_PNG_200x200 =
"iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAIAAAAiOjnJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAtGVYSWZJSSoACAAAAAYAEgEDAAEAAAABAAAAGgEFAAEAAABWAAAAGwEFAAEAAABeAAAAKAEDAAEAAAACAAAAEwIDAAEAAAABAAAAaYcEAAEAAABmAAAAAAAAADhjAADoAwAAOGMAAOgDAAAGAACQBwAEAAAAMDIxMAGRBwAEAAAAAQIDAACgBwAEAAAAMDEwMAGgAwABAAAA//8AAAKgBAABAAAAyAAAAAOgBAABAAAAyAAAAAAAAADqHRv+AAAD8UlEQVR4nO2UAQnAQACEFtZMy/SxVmJDdggmOOUu7hMtwNsZXG3aAnxwLoVVWKewiuD85V97LN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN8BixSW74BFCst3wCKF5TtgkcLyHbBIYfkOWKSwfAcsUli+AxYpLN/BJIXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5YpLB8ByxSWL4DFiks3wGLFJbvgEUKy3fAIoXlO2CRwvIdsEhh+Q5Y5AHNA7iPx5BmcQAAAABJRU5ErkJggg==";
describe("wasm-vips initialization", () => {
it("should initialize wasm-vips successfully", async () => {
const vips = await getVips();
expect(vips).not.toBeNull();
});
it("should return cached instance on subsequent calls", async () => {
const vips1 = await getVips();
const vips2 = await getVips();
expect(vips1).toBe(vips2);
});
});
describe("convertToPng", () => {
it("should return original data for PNG input", async () => {
const result = await convertToPng(TINY_PNG, "image/png");
expect(result).not.toBeNull();
expect(result!.data).toBe(TINY_PNG);
expect(result!.mimeType).toBe("image/png");
});
it("should convert JPEG to PNG", async () => {
const result = await convertToPng(TINY_JPEG, "image/jpeg");
expect(result).not.toBeNull();
expect(result!.mimeType).toBe("image/png");
// Result should be valid base64
expect(() => Buffer.from(result!.data, "base64")).not.toThrow();
// PNG magic bytes
const buffer = Buffer.from(result!.data, "base64");
expect(buffer[0]).toBe(0x89);
expect(buffer[1]).toBe(0x50); // 'P'
expect(buffer[2]).toBe(0x4e); // 'N'
expect(buffer[3]).toBe(0x47); // 'G'
});
});
describe("resizeImage", () => {
it("should return original image if within limits", async () => {
const result = await resizeImage(
{ type: "image", data: TINY_PNG, mimeType: "image/png" },
{ maxWidth: 100, maxHeight: 100, maxBytes: 1024 * 1024 },
);
expect(result.wasResized).toBe(false);
expect(result.data).toBe(TINY_PNG);
expect(result.originalWidth).toBe(2);
expect(result.originalHeight).toBe(2);
expect(result.width).toBe(2);
expect(result.height).toBe(2);
});
it("should resize image exceeding dimension limits", async () => {
const result = await resizeImage(
{ type: "image", data: MEDIUM_PNG_100x100, mimeType: "image/png" },
{ maxWidth: 50, maxHeight: 50, maxBytes: 1024 * 1024 },
);
expect(result.wasResized).toBe(true);
expect(result.originalWidth).toBe(100);
expect(result.originalHeight).toBe(100);
expect(result.width).toBeLessThanOrEqual(50);
expect(result.height).toBeLessThanOrEqual(50);
});
it("should resize image exceeding byte limit", async () => {
const originalBuffer = Buffer.from(LARGE_PNG_200x200, "base64");
const originalSize = originalBuffer.length;
// Set maxBytes to less than the original image size
const result = await resizeImage(
{ type: "image", data: LARGE_PNG_200x200, mimeType: "image/png" },
{ maxWidth: 2000, maxHeight: 2000, maxBytes: Math.floor(originalSize / 2) },
);
// Should have tried to reduce size
const resultBuffer = Buffer.from(result.data, "base64");
expect(resultBuffer.length).toBeLessThan(originalSize);
});
it("should handle JPEG input", async () => {
const result = await resizeImage(
{ type: "image", data: TINY_JPEG, mimeType: "image/jpeg" },
{ maxWidth: 100, maxHeight: 100, maxBytes: 1024 * 1024 },
);
expect(result.wasResized).toBe(false);
expect(result.originalWidth).toBe(2);
expect(result.originalHeight).toBe(2);
});
});
describe("formatDimensionNote", () => {
it("should return undefined for non-resized images", () => {
const note = formatDimensionNote({
data: "",
mimeType: "image/png",
originalWidth: 100,
originalHeight: 100,
width: 100,
height: 100,
wasResized: false,
});
expect(note).toBeUndefined();
});
it("should return formatted note for resized images", () => {
const note = formatDimensionNote({
data: "",
mimeType: "image/png",
originalWidth: 2000,
originalHeight: 1000,
width: 1000,
height: 500,
wasResized: true,
});
expect(note).toContain("original 2000x1000");
expect(note).toContain("displayed at 1000x500");
expect(note).toContain("2.00"); // scale factor
});
});

View file

@ -1,6 +1,6 @@
import type { Model } from "@mariozechner/pi-ai";
import { describe, expect, test } from "vitest";
import { parseModelPattern } from "../src/core/model-resolver.js";
import { defaultModelPerProvider, findInitialModel, parseModelPattern } from "../src/core/model-resolver.js";
// Mock models for testing
const mockModels: Model<"anthropic-messages">[] = [
@ -200,3 +200,37 @@ describe("parseModelPattern", () => {
});
});
});
describe("default model selection", () => {
test("ai-gateway default is opus 4.5", () => {
expect(defaultModelPerProvider["vercel-ai-gateway"]).toBe("anthropic/claude-opus-4.5");
});
test("findInitialModel selects ai-gateway default when available", async () => {
const aiGatewayModel: Model<"anthropic-messages"> = {
id: "anthropic/claude-opus-4.5",
name: "Claude Opus 4.5",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh",
reasoning: true,
input: ["text", "image"],
cost: { input: 5, output: 15, cacheRead: 0.5, cacheWrite: 5 },
contextWindow: 200000,
maxTokens: 8192,
};
const registry = {
getAvailable: async () => [aiGatewayModel],
} as unknown as Parameters<typeof findInitialModel>[0]["modelRegistry"];
const result = await findInitialModel({
scopedModels: [],
isContinuing: false,
modelRegistry: registry,
});
expect(result.model?.provider).toBe("vercel-ai-gateway");
expect(result.model?.id).toBe("anthropic/claude-opus-4.5");
});
});

View file

@ -0,0 +1,261 @@
import { describe, expect, it } from "vitest";
import {
cleanStepText,
extractDoneSteps,
extractTodoItems,
isSafeCommand,
markCompletedSteps,
type TodoItem,
} from "../examples/extensions/plan-mode/utils.js";
describe("isSafeCommand", () => {
describe("safe commands", () => {
it("allows basic read commands", () => {
expect(isSafeCommand("ls -la")).toBe(true);
expect(isSafeCommand("cat file.txt")).toBe(true);
expect(isSafeCommand("head -n 10 file.txt")).toBe(true);
expect(isSafeCommand("tail -f log.txt")).toBe(true);
expect(isSafeCommand("grep pattern file")).toBe(true);
expect(isSafeCommand("find . -name '*.ts'")).toBe(true);
});
it("allows git read commands", () => {
expect(isSafeCommand("git status")).toBe(true);
expect(isSafeCommand("git log --oneline")).toBe(true);
expect(isSafeCommand("git diff")).toBe(true);
expect(isSafeCommand("git branch")).toBe(true);
});
it("allows npm/yarn read commands", () => {
expect(isSafeCommand("npm list")).toBe(true);
expect(isSafeCommand("npm outdated")).toBe(true);
expect(isSafeCommand("yarn info react")).toBe(true);
});
it("allows other safe commands", () => {
expect(isSafeCommand("pwd")).toBe(true);
expect(isSafeCommand("echo hello")).toBe(true);
expect(isSafeCommand("wc -l file.txt")).toBe(true);
expect(isSafeCommand("du -sh .")).toBe(true);
expect(isSafeCommand("df -h")).toBe(true);
});
});
describe("destructive commands", () => {
it("blocks file modification commands", () => {
expect(isSafeCommand("rm file.txt")).toBe(false);
expect(isSafeCommand("rm -rf dir")).toBe(false);
expect(isSafeCommand("mv old new")).toBe(false);
expect(isSafeCommand("cp src dst")).toBe(false);
expect(isSafeCommand("mkdir newdir")).toBe(false);
expect(isSafeCommand("touch newfile")).toBe(false);
});
it("blocks git write commands", () => {
expect(isSafeCommand("git add .")).toBe(false);
expect(isSafeCommand("git commit -m 'msg'")).toBe(false);
expect(isSafeCommand("git push")).toBe(false);
expect(isSafeCommand("git checkout main")).toBe(false);
expect(isSafeCommand("git reset --hard")).toBe(false);
});
it("blocks package manager installs", () => {
expect(isSafeCommand("npm install lodash")).toBe(false);
expect(isSafeCommand("yarn add react")).toBe(false);
expect(isSafeCommand("pip install requests")).toBe(false);
expect(isSafeCommand("brew install node")).toBe(false);
});
it("blocks redirects", () => {
expect(isSafeCommand("echo hello > file.txt")).toBe(false);
expect(isSafeCommand("cat foo >> bar")).toBe(false);
expect(isSafeCommand(">file.txt")).toBe(false);
});
it("blocks dangerous commands", () => {
expect(isSafeCommand("sudo rm -rf /")).toBe(false);
expect(isSafeCommand("kill -9 1234")).toBe(false);
expect(isSafeCommand("reboot")).toBe(false);
});
it("blocks editors", () => {
expect(isSafeCommand("vim file.txt")).toBe(false);
expect(isSafeCommand("nano file.txt")).toBe(false);
expect(isSafeCommand("code .")).toBe(false);
});
});
describe("edge cases", () => {
it("requires command to be in safe list (not just non-destructive)", () => {
expect(isSafeCommand("unknown-command")).toBe(false);
expect(isSafeCommand("my-script.sh")).toBe(false);
});
it("handles commands with leading whitespace", () => {
expect(isSafeCommand(" ls -la")).toBe(true);
expect(isSafeCommand(" rm file")).toBe(false);
});
});
});
describe("cleanStepText", () => {
it("removes markdown bold/italic", () => {
expect(cleanStepText("**bold text**")).toBe("Bold text");
expect(cleanStepText("*italic text*")).toBe("Italic text");
});
it("removes markdown code", () => {
expect(cleanStepText("run `npm install`")).toBe("Npm install"); // "run" is stripped as action word
expect(cleanStepText("check the `config.json` file")).toBe("Config.json file");
});
it("removes leading action words", () => {
expect(cleanStepText("Create the new file")).toBe("New file");
expect(cleanStepText("Run the tests")).toBe("Tests");
expect(cleanStepText("Check the status")).toBe("Status");
});
it("capitalizes first letter", () => {
expect(cleanStepText("update config")).toBe("Config");
});
it("truncates long text", () => {
const longText = "This is a very long step description that exceeds the maximum allowed length for display";
const result = cleanStepText(longText);
expect(result.length).toBe(50);
expect(result.endsWith("...")).toBe(true);
});
it("normalizes whitespace", () => {
expect(cleanStepText("multiple spaces here")).toBe("Multiple spaces here");
});
});
describe("extractTodoItems", () => {
it("extracts numbered items after Plan: header", () => {
const message = `Here's what we'll do:
Plan:
1. First step here
2. Second step here
3. Third step here`;
const items = extractTodoItems(message);
expect(items).toHaveLength(3);
expect(items[0].step).toBe(1);
expect(items[0].text).toBe("First step here");
expect(items[0].completed).toBe(false);
});
it("handles bold Plan header", () => {
const message = `**Plan:**
1. Do something`;
const items = extractTodoItems(message);
expect(items).toHaveLength(1);
});
it("handles parenthesis-style numbering", () => {
const message = `Plan:
1) First item
2) Second item`;
const items = extractTodoItems(message);
expect(items).toHaveLength(2);
});
it("returns empty array without Plan header", () => {
const message = `Here are some steps:
1. First step
2. Second step`;
const items = extractTodoItems(message);
expect(items).toHaveLength(0);
});
it("filters out short items", () => {
const message = `Plan:
1. OK
2. This is a proper step`;
const items = extractTodoItems(message);
expect(items).toHaveLength(1);
expect(items[0].text).toContain("proper");
});
it("filters out code-like items", () => {
const message = `Plan:
1. \`npm install\`
2. Run the build process`;
const items = extractTodoItems(message);
expect(items).toHaveLength(1);
});
});
describe("extractDoneSteps", () => {
it("extracts single DONE marker", () => {
const message = "I've completed the first step [DONE:1]";
expect(extractDoneSteps(message)).toEqual([1]);
});
it("extracts multiple DONE markers", () => {
const message = "Did steps [DONE:1] and [DONE:2] and [DONE:3]";
expect(extractDoneSteps(message)).toEqual([1, 2, 3]);
});
it("handles case insensitivity", () => {
const message = "[done:1] [DONE:2] [Done:3]";
expect(extractDoneSteps(message)).toEqual([1, 2, 3]);
});
it("returns empty array with no markers", () => {
const message = "No markers here";
expect(extractDoneSteps(message)).toEqual([]);
});
it("ignores malformed markers", () => {
const message = "[DONE:abc] [DONE:] [DONE:1]";
expect(extractDoneSteps(message)).toEqual([1]);
});
});
describe("markCompletedSteps", () => {
it("marks matching items as completed", () => {
const items: TodoItem[] = [
{ step: 1, text: "First", completed: false },
{ step: 2, text: "Second", completed: false },
{ step: 3, text: "Third", completed: false },
];
const count = markCompletedSteps("[DONE:1] [DONE:3]", items);
expect(count).toBe(2);
expect(items[0].completed).toBe(true);
expect(items[1].completed).toBe(false);
expect(items[2].completed).toBe(true);
});
it("returns count of completed items", () => {
const items: TodoItem[] = [{ step: 1, text: "First", completed: false }];
expect(markCompletedSteps("[DONE:1]", items)).toBe(1);
expect(markCompletedSteps("no markers", items)).toBe(0);
});
it("ignores markers for non-existent steps", () => {
const items: TodoItem[] = [{ step: 1, text: "First", completed: false }];
const count = markCompletedSteps("[DONE:99]", items);
expect(count).toBe(1); // Still counts the marker found
expect(items[0].completed).toBe(false); // But doesn't mark anything
});
it("doesn't double-complete already completed items", () => {
const items: TodoItem[] = [{ step: 1, text: "First", completed: true }];
markCompletedSteps("[DONE:1]", items);
expect(items[0].completed).toBe(true);
});
});

View file

@ -1,75 +1,246 @@
import fs from "fs";
import { initTheme, theme } from "../src/modes/interactive/theme/theme.js";
// Initialize with dark theme explicitly
process.env.COLORTERM = "truecolor";
initTheme("dark");
// --- Color utilities ---
console.log("\n=== Foreground Colors ===\n");
function hexToRgb(hex: string): [number, number, number] {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] : [0, 0, 0];
}
// Core UI colors
console.log("accent:", theme.fg("accent", "Sample text"));
console.log("border:", theme.fg("border", "Sample text"));
console.log("borderAccent:", theme.fg("borderAccent", "Sample text"));
console.log("borderMuted:", theme.fg("borderMuted", "Sample text"));
console.log("success:", theme.fg("success", "Sample text"));
console.log("error:", theme.fg("error", "Sample text"));
console.log("warning:", theme.fg("warning", "Sample text"));
console.log("muted:", theme.fg("muted", "Sample text"));
console.log("dim:", theme.fg("dim", "Sample text"));
console.log("text:", theme.fg("text", "Sample text"));
function rgbToHex(r: number, g: number, b: number): string {
return (
"#" +
[r, g, b]
.map((x) =>
Math.round(Math.max(0, Math.min(255, x)))
.toString(16)
.padStart(2, "0"),
)
.join("")
);
}
console.log("\n=== Message Text Colors ===\n");
console.log("userMessageText:", theme.fg("userMessageText", "Sample text"));
console.log("toolTitle:", theme.fg("toolTitle", "Sample text"));
console.log("toolOutput:", theme.fg("toolOutput", "Sample text"));
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b),
min = Math.min(r, g, b);
let h = 0,
s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
case b:
h = ((r - g) / d + 4) / 6;
break;
}
}
return [h, s, l];
}
console.log("\n=== Markdown Colors ===\n");
console.log("mdHeading:", theme.fg("mdHeading", "Sample text"));
console.log("mdLink:", theme.fg("mdLink", "Sample text"));
console.log("mdCode:", theme.fg("mdCode", "Sample text"));
console.log("mdCodeBlock:", theme.fg("mdCodeBlock", "Sample text"));
console.log("mdCodeBlockBorder:", theme.fg("mdCodeBlockBorder", "Sample text"));
console.log("mdQuote:", theme.fg("mdQuote", "Sample text"));
console.log("mdQuoteBorder:", theme.fg("mdQuoteBorder", "Sample text"));
console.log("mdHr:", theme.fg("mdHr", "Sample text"));
console.log("mdListBullet:", theme.fg("mdListBullet", "Sample text"));
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
let r: number, g: number, b: number;
if (s === 0) {
r = g = b = l;
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
console.log("\n=== Tool Diff Colors ===\n");
console.log("toolDiffAdded:", theme.fg("toolDiffAdded", "Sample text"));
console.log("toolDiffRemoved:", theme.fg("toolDiffRemoved", "Sample text"));
console.log("toolDiffContext:", theme.fg("toolDiffContext", "Sample text"));
function getLuminance(r: number, g: number, b: number): number {
const lin = (c: number) => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
console.log("\n=== Thinking Border Colors ===\n");
console.log("thinkingOff:", theme.fg("thinkingOff", "Sample text"));
console.log("thinkingMinimal:", theme.fg("thinkingMinimal", "Sample text"));
console.log("thinkingLow:", theme.fg("thinkingLow", "Sample text"));
console.log("thinkingMedium:", theme.fg("thinkingMedium", "Sample text"));
console.log("thinkingHigh:", theme.fg("thinkingHigh", "Sample text"));
function getContrast(rgb: [number, number, number], bgLum: number): number {
const fgLum = getLuminance(...rgb);
const lighter = Math.max(fgLum, bgLum);
const darker = Math.min(fgLum, bgLum);
return (lighter + 0.05) / (darker + 0.05);
}
console.log("\n=== Background Colors ===\n");
console.log("userMessageBg:", theme.bg("userMessageBg", " Sample background text "));
console.log("toolPendingBg:", theme.bg("toolPendingBg", " Sample background text "));
console.log("toolSuccessBg:", theme.bg("toolSuccessBg", " Sample background text "));
console.log("toolErrorBg:", theme.bg("toolErrorBg", " Sample background text "));
function adjustColorToContrast(hex: string, targetContrast: number, againstWhite: boolean): string {
const rgb = hexToRgb(hex);
const [h, s] = rgbToHsl(...rgb);
const bgLum = againstWhite ? 1.0 : 0.0;
console.log("\n=== Raw ANSI Codes ===\n");
console.log("thinkingMedium ANSI:", JSON.stringify(theme.getFgAnsi("thinkingMedium")));
console.log("accent ANSI:", JSON.stringify(theme.getFgAnsi("accent")));
console.log("muted ANSI:", JSON.stringify(theme.getFgAnsi("muted")));
console.log("dim ANSI:", JSON.stringify(theme.getFgAnsi("dim")));
let lo = againstWhite ? 0 : 0.5;
let hi = againstWhite ? 0.5 : 1.0;
console.log("\n=== Direct RGB Test ===\n");
console.log("Gray #6c6c6c: \x1b[38;2;108;108;108mSample text\x1b[0m");
console.log("Gray #444444: \x1b[38;2;68;68;68mSample text\x1b[0m");
console.log("Gray #303030: \x1b[38;2;48;48;48mSample text\x1b[0m");
for (let i = 0; i < 50; i++) {
const mid = (lo + hi) / 2;
const testRgb = hslToRgb(h, s, mid);
const contrast = getContrast(testRgb, bgLum);
console.log("\n=== Hex Color Test ===\n");
console.log("Direct #00d7ff test: \x1b[38;2;0;215;255mBRIGHT CYAN\x1b[0m");
console.log("Theme cyan (should match above):", theme.fg("accent", "BRIGHT CYAN"));
if (againstWhite) {
if (contrast < targetContrast) hi = mid;
else lo = mid;
} else {
if (contrast < targetContrast) lo = mid;
else hi = mid;
}
}
console.log("\n=== Environment ===\n");
console.log("TERM:", process.env.TERM);
console.log("COLORTERM:", process.env.COLORTERM);
console.log("Color mode:", theme.getColorMode());
const finalL = againstWhite ? lo : hi;
return rgbToHex(...hslToRgb(h, s, finalL));
}
console.log("\n");
function fgAnsi(hex: string): string {
const rgb = hexToRgb(hex);
return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`;
}
const reset = "\x1b[0m";
// --- Commands ---
function cmdContrast(targetContrast: number): void {
const baseColors = {
teal: "#5f8787",
blue: "#5f87af",
green: "#87af87",
yellow: "#d7af5f",
red: "#af5f5f",
};
console.log(`\n=== Colors adjusted to ${targetContrast}:1 contrast ===\n`);
console.log("For LIGHT theme (vs white):");
for (const [name, hex] of Object.entries(baseColors)) {
const adjusted = adjustColorToContrast(hex, targetContrast, true);
const rgb = hexToRgb(adjusted);
const contrast = getContrast(rgb, 1.0);
console.log(` ${name.padEnd(8)} ${fgAnsi(adjusted)}Sample${reset} ${adjusted} (${contrast.toFixed(2)}:1)`);
}
console.log("\nFor DARK theme (vs black):");
for (const [name, hex] of Object.entries(baseColors)) {
const adjusted = adjustColorToContrast(hex, targetContrast, false);
const rgb = hexToRgb(adjusted);
const contrast = getContrast(rgb, 0.0);
console.log(` ${name.padEnd(8)} ${fgAnsi(adjusted)}Sample${reset} ${adjusted} (${contrast.toFixed(2)}:1)`);
}
}
function cmdTest(filePath: string): void {
if (!fs.existsSync(filePath)) {
console.error(`File not found: ${filePath}`);
process.exit(1);
}
const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
const vars = data.vars || data;
console.log(`\n=== Testing ${filePath} ===\n`);
for (const [name, hex] of Object.entries(vars as Record<string, string>)) {
if (!hex.startsWith("#")) continue;
const rgb = hexToRgb(hex);
const vsWhite = getContrast(rgb, 1.0);
const vsBlack = getContrast(rgb, 0.0);
const passW = vsWhite >= 4.5 ? "AA" : vsWhite >= 3.0 ? "AA-lg" : "FAIL";
const passB = vsBlack >= 4.5 ? "AA" : vsBlack >= 3.0 ? "AA-lg" : "FAIL";
console.log(
`${name.padEnd(14)} ${fgAnsi(hex)}Sample text${reset} ${hex} white: ${vsWhite.toFixed(2)}:1 ${passW.padEnd(5)} black: ${vsBlack.toFixed(2)}:1 ${passB}`,
);
}
}
function cmdTheme(themeName: string): void {
process.env.COLORTERM = "truecolor";
initTheme(themeName);
const parseAnsiRgb = (ansi: string): [number, number, number] | null => {
const match = ansi.match(/38;2;(\d+);(\d+);(\d+)/);
return match ? [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)] : null;
};
const getContrastVsWhite = (colorName: string): string => {
const ansi = theme.getFgAnsi(colorName as Parameters<typeof theme.getFgAnsi>[0]);
const rgb = parseAnsiRgb(ansi);
if (!rgb) return "(default)";
const ratio = getContrast(rgb, 1.0);
const pass = ratio >= 4.5 ? "AA" : ratio >= 3.0 ? "AA-lg" : "FAIL";
return `${ratio.toFixed(2)}:1 ${pass}`;
};
const getContrastVsBlack = (colorName: string): string => {
const ansi = theme.getFgAnsi(colorName as Parameters<typeof theme.getFgAnsi>[0]);
const rgb = parseAnsiRgb(ansi);
if (!rgb) return "(default)";
const ratio = getContrast(rgb, 0.0);
const pass = ratio >= 4.5 ? "AA" : ratio >= 3.0 ? "AA-lg" : "FAIL";
return `${ratio.toFixed(2)}:1 ${pass}`;
};
const logColor = (name: string): void => {
const sample = theme.fg(name as Parameters<typeof theme.fg>[0], "Sample text");
const cw = getContrastVsWhite(name);
const cb = getContrastVsBlack(name);
console.log(`${name.padEnd(20)} ${sample} white: ${cw.padEnd(12)} black: ${cb}`);
};
console.log(`\n=== ${themeName} theme (WCAG AA = 4.5:1) ===`);
console.log("\n--- Core UI ---");
["accent", "border", "borderAccent", "borderMuted", "success", "error", "warning", "muted", "dim"].forEach(logColor);
console.log("\n--- Markdown ---");
["mdHeading", "mdLink", "mdCode", "mdCodeBlock", "mdCodeBlockBorder", "mdQuote", "mdListBullet"].forEach(logColor);
console.log("\n--- Diff ---");
["toolDiffAdded", "toolDiffRemoved", "toolDiffContext"].forEach(logColor);
console.log("\n--- Thinking ---");
["thinkingOff", "thinkingMinimal", "thinkingLow", "thinkingMedium", "thinkingHigh"].forEach(logColor);
console.log("\n--- Backgrounds ---");
console.log("userMessageBg:", theme.bg("userMessageBg", " Sample "));
console.log("toolPendingBg:", theme.bg("toolPendingBg", " Sample "));
console.log("toolSuccessBg:", theme.bg("toolSuccessBg", " Sample "));
console.log("toolErrorBg:", theme.bg("toolErrorBg", " Sample "));
console.log();
}
// --- Main ---
const [cmd, arg] = process.argv.slice(2);
if (cmd === "contrast") {
cmdContrast(parseFloat(arg) || 4.5);
} else if (cmd === "test") {
cmdTest(arg);
} else if (cmd === "light" || cmd === "dark") {
cmdTheme(cmd);
} else {
console.log("Usage:");
console.log(" npx tsx test-theme-colors.ts light|dark Test built-in theme");
console.log(" npx tsx test-theme-colors.ts contrast 4.5 Compute colors at ratio");
console.log(" npx tsx test-theme-colors.ts test file.json Test any JSON file");
}