mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 19:04:37 +00:00
Merge branch 'main' into feat/tui-overlay-options
This commit is contained in:
commit
7d45e434de
90 changed files with 10277 additions and 1700 deletions
1
packages/coding-agent/.gitignore
vendored
Normal file
1
packages/coding-agent/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
*.bun-build
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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`
|
||||
340
packages/coding-agent/examples/extensions/plan-mode/index.ts
Normal file
340
packages/coding-agent/examples/extensions/plan-mode/index.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
168
packages/coding-agent/examples/extensions/plan-mode/utils.ts
Normal file
168
packages/coding-agent/examples/extensions/plan-mode/utils.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
427
packages/coding-agent/examples/extensions/questionnaire.ts
Normal file
427
packages/coding-agent/examples/extensions/questionnaire.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
1
packages/coding-agent/examples/extensions/sandbox/.gitignore
vendored
Normal file
1
packages/coding-agent/examples/extensions/sandbox/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
node_modules
|
||||
318
packages/coding-agent/examples/extensions/sandbox/index.ts
Normal file
318
packages/coding-agent/examples/extensions/sandbox/index.ts
Normal 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");
|
||||
},
|
||||
});
|
||||
}
|
||||
92
packages/coding-agent/examples/extensions/sandbox/package-lock.json
generated
Normal file
92
packages/coding-agent/examples/extensions/sandbox/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
195
packages/coding-agent/examples/extensions/summarize.ts
Normal file
195
packages/coding-agent/examples/extensions/summarize.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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'",
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):")}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}` };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
40
packages/coding-agent/src/utils/vips.ts
Normal file
40
packages/coding-agent/src/utils/vips.ts
Normal 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;
|
||||
}
|
||||
144
packages/coding-agent/test/image-processing.test.ts
Normal file
144
packages/coding-agent/test/image-processing.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
261
packages/coding-agent/test/plan-mode-utils.test.ts
Normal file
261
packages/coding-agent/test/plan-mode-utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue