diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 293e127e..abc741f8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,8 +7,13 @@ - ExtensionAPI: `setModel()`, `getThinkingLevel()`, `setThinkingLevel()` methods for extensions to change model and thinking level at runtime ([#509](https://github.com/badlogic/pi-mono/issues/509)) - Exported truncation utilities for custom tools: `truncateHead`, `truncateTail`, `truncateLine`, `formatSize`, `DEFAULT_MAX_BYTES`, `DEFAULT_MAX_LINES`, `TruncationOptions`, `TruncationResult` - New example `truncated-tool.ts` demonstrating proper output truncation with custom rendering for extensions +- New example `preset.ts` demonstrating preset configurations with model/thinking/tools switching ([#347](https://github.com/badlogic/pi-mono/issues/347)) - Documentation for output truncation best practices in `docs/extensions.md` - Exported all UI components for extensions: `ArminComponent`, `AssistantMessageComponent`, `BashExecutionComponent`, `BorderedLoader`, `BranchSummaryMessageComponent`, `CompactionSummaryMessageComponent`, `CustomEditor`, `CustomMessageComponent`, `DynamicBorder`, `ExtensionEditorComponent`, `ExtensionInputComponent`, `ExtensionSelectorComponent`, `FooterComponent`, `LoginDialogComponent`, `ModelSelectorComponent`, `OAuthSelectorComponent`, `SessionSelectorComponent`, `SettingsSelectorComponent`, `ShowImagesSelectorComponent`, `ThemeSelectorComponent`, `ThinkingSelectorComponent`, `ToolExecutionComponent`, `TreeSelectorComponent`, `UserMessageComponent`, `UserMessageSelectorComponent`, plus utilities `renderDiff`, `truncateToVisualLines` +- `docs/tui.md`: Common Patterns section with copy-paste code for SelectList, BorderedLoader, SettingsList, setStatus, setWidget, setFooter +- `docs/tui.md`: Key Rules section documenting critical patterns for extension UI development +- `docs/extensions.md`: Exhaustive example links for all ExtensionAPI methods and events +- System prompt now references `docs/tui.md` for TUI component development ## [0.37.4] - 2026-01-06 diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index a88f73e4..5c0a3362 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -306,6 +306,8 @@ pi.on("session_start", async (_event, ctx) => { }); ``` +**Examples:** [claude-rules.ts](../examples/extensions/claude-rules.ts), [custom-header.ts](../examples/extensions/custom-header.ts), [file-trigger.ts](../examples/extensions/file-trigger.ts), [status-line.ts](../examples/extensions/status-line.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts) + #### session_before_switch / session_switch Fired when starting a new session (`/new`) or switching sessions (`/resume`). @@ -327,6 +329,8 @@ pi.on("session_switch", async (event, ctx) => { }); ``` +**Examples:** [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [status-line.ts](../examples/extensions/status-line.ts), [todo.ts](../examples/extensions/todo.ts) + #### session_before_branch / session_branch Fired when branching via `/branch`. @@ -344,6 +348,8 @@ pi.on("session_branch", async (event, ctx) => { }); ``` +**Examples:** [confirm-destructive.ts](../examples/extensions/confirm-destructive.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts) + #### session_before_compact / session_compact Fired on compaction. See [compaction.md](compaction.md) for details. @@ -371,6 +377,8 @@ pi.on("session_compact", async (event, ctx) => { }); ``` +**Examples:** [custom-compaction.ts](../examples/extensions/custom-compaction.ts) + #### session_before_tree / session_tree Fired on `/tree` navigation. @@ -388,6 +396,8 @@ pi.on("session_tree", async (event, ctx) => { }); ``` +**Examples:** [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts) + #### session_shutdown Fired on exit (Ctrl+C, Ctrl+D, SIGTERM). @@ -398,6 +408,8 @@ pi.on("session_shutdown", async (_event, ctx) => { }); ``` +**Examples:** [auto-commit-on-exit.ts](../examples/extensions/auto-commit-on-exit.ts) + ### Agent Events #### before_agent_start @@ -422,6 +434,8 @@ 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) + #### agent_start / agent_end Fired once per user prompt. @@ -434,6 +448,8 @@ 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) + #### turn_start / turn_end Fired for each turn (one LLM response + tool calls). @@ -448,6 +464,8 @@ 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) + #### context Fired before each LLM call. Modify messages non-destructively. @@ -460,6 +478,8 @@ pi.on("context", async (event, ctx) => { }); ``` +**Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts) + ### Tool Events #### tool_call @@ -478,6 +498,8 @@ 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) + #### tool_result Fired after tool executes. **Can modify result.** @@ -498,6 +520,8 @@ pi.on("tool_result", async (event, ctx) => { }); ``` +**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts) + ## ExtensionContext Every handler receives `ctx: ExtensionContext`: @@ -595,7 +619,7 @@ const result = await ctx.navigateTree("entry-id-456", { ### pi.on(event, handler) -Subscribe to events. See [Events](#events). +Subscribe to events. See [Events](#events) for event types and return values. ### pi.registerTool(definition) @@ -630,9 +654,11 @@ 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) + ### pi.sendMessage(message, options?) -Inject a custom message into the session: +Inject a custom message into the session. ```typescript pi.sendMessage({ @@ -653,6 +679,8 @@ 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) + ### pi.sendUserMessage(content, options?) Send a user message to the agent. Unlike `sendMessage()` which sends custom messages, this sends an actual user message that appears as if typed by the user. Always triggers a turn. @@ -683,7 +711,7 @@ See [send-user-message.ts](../examples/extensions/send-user-message.ts) for a co ### pi.appendEntry(customType, data?) -Persist extension state (does NOT participate in LLM context): +Persist extension state (does NOT participate in LLM context). ```typescript pi.appendEntry("my-state", { count: 42 }); @@ -698,9 +726,11 @@ 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) + ### pi.registerCommand(name, options) -Register a command: +Register a command. ```typescript pi.registerCommand("stats", { @@ -712,13 +742,15 @@ 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) + ### pi.registerMessageRenderer(customType, renderer) Register a custom TUI renderer for messages with your `customType`. See [Custom UI](#custom-ui). ### pi.registerShortcut(shortcut, options) -Register a keyboard shortcut: +Register a keyboard shortcut. ```typescript pi.registerShortcut("ctrl+shift+p", { @@ -729,9 +761,11 @@ pi.registerShortcut("ctrl+shift+p", { }); ``` +**Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts) + ### pi.registerFlag(name, options) -Register a CLI flag: +Register a CLI flag. ```typescript pi.registerFlag("--plan", { @@ -746,24 +780,57 @@ if (pi.getFlag("--plan")) { } ``` +**Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts) + ### pi.exec(command, args, options?) -Execute a shell command: +Execute a shell command. ```typescript const result = await pi.exec("git", ["status"], { signal, timeout: 5000 }); // result.stdout, result.stderr, result.code, result.killed ``` +**Examples:** [auto-commit-on-exit.ts](../examples/extensions/auto-commit-on-exit.ts), [dirty-repo-guard.ts](../examples/extensions/dirty-repo-guard.ts), [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts) + ### pi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names) -Manage active tools: +Manage active tools. ```typescript const active = pi.getActiveTools(); // ["read", "bash", "edit", "write"] 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) + +### pi.setModel(model) + +Set the current model. Returns `false` if no API key is available for the model. + +```typescript +const model = ctx.modelRegistry.find("anthropic", "claude-sonnet-4-5"); +if (model) { + const success = await pi.setModel(model); + if (!success) { + ctx.ui.notify("No API key for this model", "error"); + } +} +``` + +**Examples:** [preset.ts](../examples/extensions/preset.ts) + +### pi.getThinkingLevel() / pi.setThinkingLevel(level) + +Get or set the thinking level. Level is clamped to model capabilities (non-reasoning models always use "off"). + +```typescript +const current = pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh" +pi.setThinkingLevel("high"); +``` + +**Examples:** [preset.ts](../examples/extensions/preset.ts) + ### pi.events Shared event bus for communication between extensions: @@ -994,6 +1061,14 @@ If `renderCall`/`renderResult` is not defined or throws: Extensions can interact with users via `ctx.ui` methods and customize how messages/tools render. +**For custom components, see [tui.md](tui.md)** which has copy-paste patterns for: +- Selection dialogs (SelectList) +- Async operations with cancel (BorderedLoader) +- Settings toggles (SettingsList) +- Status indicators (setStatus) +- Widgets above editor (setWidget) +- Custom footers (setFooter) + ### Dialogs ```typescript @@ -1013,6 +1088,12 @@ const text = await ctx.ui.editor("Edit:", "prefilled text"); 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.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) + ### Widgets, Status, and Footer ```typescript @@ -1040,6 +1121,12 @@ ctx.ui.setEditorText("Prefill text"); const current = ctx.ui.getEditorText(); ``` +**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.setFooter()`: [custom-footer.ts](../examples/extensions/custom-footer.ts) +- `ctx.ui.setHeader()`: [custom-header.ts](../examples/extensions/custom-header.ts) + ### Custom Components For complex UI, use `ctx.ui.custom()`. This temporarily replaces the editor with your component until `done()` is called: @@ -1069,7 +1156,9 @@ The callback receives: - `theme` - Current theme for styling - `done(value)` - Call to close component and return value -See [tui.md](tui.md) for the full component API and [examples/extensions/](../examples/extensions/) for working examples (snake.ts, todo.ts, qna.ts). +See [tui.md](tui.md) for the full component API. + +**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) ### Message Rendering diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index 1f03d748..0db6d97d 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -340,7 +340,225 @@ class CachedComponent { Call `invalidate()` when state changes, then `handle.requestRender()` to trigger re-render. +## Common Patterns + +These patterns cover the most common UI needs in extensions. **Copy these patterns instead of building from scratch.** + +### Pattern 1: Selection Dialog (SelectList) + +For letting users pick from a list of options. Use `SelectList` from `@mariozechner/pi-tui` with `DynamicBorder` for framing. + +```typescript +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { DynamicBorder } from "@mariozechner/pi-coding-agent"; +import { Container, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui"; + +pi.registerCommand("pick", { + handler: async (_args, ctx) => { + const items: SelectItem[] = [ + { value: "opt1", label: "Option 1", description: "First option" }, + { value: "opt2", label: "Option 2", description: "Second option" }, + { value: "opt3", label: "Option 3" }, // description is optional + ]; + + const result = await ctx.ui.custom((tui, theme, done) => { + const container = new Container(); + + // Top border + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + // Title + container.addChild(new Text(theme.fg("accent", theme.bold("Pick an Option")), 1, 0)); + + // SelectList with theme + const selectList = new SelectList(items, Math.min(items.length, 10), { + 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), + }); + selectList.onSelect = (item) => done(item.value); + selectList.onCancel = () => done(null); + container.addChild(selectList); + + // Help text + container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0)); + + // Bottom border + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + return { + render: (w) => container.render(w), + invalidate: () => container.invalidate(), + handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); }, + }; + }); + + if (result) { + ctx.ui.notify(`Selected: ${result}`, "info"); + } + }, +}); +``` + +**Examples:** [preset.ts](../examples/extensions/preset.ts), [tools.ts](../examples/extensions/tools.ts) + +### Pattern 2: Async Operation with Cancel (BorderedLoader) + +For operations that take time and should be cancellable. `BorderedLoader` shows a spinner and handles escape to cancel. + +```typescript +import { BorderedLoader } from "@mariozechner/pi-coding-agent"; + +pi.registerCommand("fetch", { + handler: async (_args, ctx) => { + const result = await ctx.ui.custom((tui, theme, done) => { + const loader = new BorderedLoader(tui, theme, "Fetching data..."); + loader.onAbort = () => done(null); + + // Do async work + fetchData(loader.signal) + .then((data) => done(data)) + .catch(() => done(null)); + + return loader; + }); + + if (result === null) { + ctx.ui.notify("Cancelled", "info"); + } else { + ctx.ui.setEditorText(result); + } + }, +}); +``` + +**Examples:** [qna.ts](../examples/extensions/qna.ts), [handoff.ts](../examples/extensions/handoff.ts) + +### Pattern 3: Settings/Toggles (SettingsList) + +For toggling multiple settings. Use `SettingsList` from `@mariozechner/pi-tui` with `getSettingsListTheme()`. + +```typescript +import { getSettingsListTheme } from "@mariozechner/pi-coding-agent"; +import { Container, type SettingItem, SettingsList, Text } from "@mariozechner/pi-tui"; + +pi.registerCommand("settings", { + handler: async (_args, ctx) => { + const items: SettingItem[] = [ + { id: "verbose", label: "Verbose mode", currentValue: "off", values: ["on", "off"] }, + { id: "color", label: "Color output", currentValue: "on", values: ["on", "off"] }, + ]; + + await ctx.ui.custom((_tui, theme, done) => { + const container = new Container(); + container.addChild(new Text(theme.fg("accent", theme.bold("Settings")), 1, 1)); + + const settingsList = new SettingsList( + items, + Math.min(items.length + 2, 15), + getSettingsListTheme(), + (id, newValue) => { + // Handle value change + ctx.ui.notify(`${id} = ${newValue}`, "info"); + }, + () => done(undefined), // On close + ); + container.addChild(settingsList); + + return { + render: (w) => container.render(w), + invalidate: () => container.invalidate(), + handleInput: (data) => settingsList.handleInput?.(data), + }; + }); + }, +}); +``` + +**Examples:** [tools.ts](../examples/extensions/tools.ts) + +### Pattern 4: Persistent Status Indicator + +Show status in the footer that persists across renders. Good for mode indicators. + +```typescript +// Set status (shown in footer) +ctx.ui.setStatus("my-ext", ctx.ui.theme.fg("accent", "● active")); + +// Clear status +ctx.ui.setStatus("my-ext", undefined); +``` + +**Examples:** [status-line.ts](../examples/extensions/status-line.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts) + +### Pattern 5: Widget Above Editor + +Show persistent content above the input editor. Good for todo lists, progress. + +```typescript +// Simple string array +ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]); + +// Or with theme +ctx.ui.setWidget("my-widget", (_tui, theme) => { + const lines = items.map((item, i) => + item.done + ? theme.fg("success", "✓ ") + theme.fg("muted", item.text) + : theme.fg("dim", "○ ") + item.text + ); + return { + render: () => lines, + invalidate: () => {}, + }; +}); + +// Clear +ctx.ui.setWidget("my-widget", undefined); +``` + +**Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts) + +### Pattern 6: Custom Footer + +Replace the entire footer with custom content. + +```typescript +ctx.ui.setFooter((_tui, theme) => ({ + render(width: number): string[] { + const left = theme.fg("dim", "custom footer"); + const right = theme.fg("accent", "status"); + const padding = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right))); + return [truncateToWidth(left + padding + right, width)]; + }, + invalidate() {}, +})); + +// Restore default +ctx.ui.setFooter(undefined); +``` + +**Examples:** [custom-footer.ts](../examples/extensions/custom-footer.ts) + +## Key Rules + +1. **Always use theme from callback** - Don't import theme directly. Use `theme` from the `ctx.ui.custom((tui, theme, done) => ...)` callback. + +2. **Always type DynamicBorder color param** - Write `(s: string) => theme.fg("accent", s)`, not `(s) => theme.fg("accent", s)`. + +3. **Call tui.requestRender() after state changes** - In `handleInput`, call `tui.requestRender()` after updating state. + +4. **Return the three-method object** - Custom components need `{ render, invalidate, handleInput }`. + +5. **Use existing components** - `SelectList`, `SettingsList`, `BorderedLoader` cover 90% of cases. Don't rebuild them. + ## Examples -- **Snake game**: [examples/hooks/snake.ts](../examples/hooks/snake.ts) - Full game with keyboard input, game loop, state persistence -- **Custom tool rendering**: [examples/extensions/todo.ts](../examples/extensions/todo.ts) - Custom `renderCall` and `renderResult` +- **Selection UI**: [examples/extensions/preset.ts](../examples/extensions/preset.ts) - SelectList with DynamicBorder framing +- **Async with cancel**: [examples/extensions/qna.ts](../examples/extensions/qna.ts) - BorderedLoader for LLM calls +- **Settings toggles**: [examples/extensions/tools.ts](../examples/extensions/tools.ts) - SettingsList for tool enable/disable +- **Status indicators**: [examples/extensions/plan-mode.ts](../examples/extensions/plan-mode.ts) - setStatus and setWidget +- **Custom footer**: [examples/extensions/custom-footer.ts](../examples/extensions/custom-footer.ts) - setFooter with stats +- **Snake game**: [examples/extensions/snake.ts](../examples/extensions/snake.ts) - Full game with keyboard input, game loop +- **Custom tool rendering**: [examples/extensions/todo.ts](../examples/extensions/todo.ts) - renderCall and renderResult diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index 6bb8acd3..80909b41 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -36,6 +36,7 @@ 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 | | `tools.ts` | Interactive `/tools` command to enable/disable tools with session persistence | | `handoff.ts` | Transfer context to a new focused session via `/handoff ` | diff --git a/packages/coding-agent/examples/extensions/preset.ts b/packages/coding-agent/examples/extensions/preset.ts new file mode 100644 index 00000000..12921aa2 --- /dev/null +++ b/packages/coding-agent/examples/extensions/preset.ts @@ -0,0 +1,398 @@ +/** + * Preset Extension + * + * Allows defining named presets that configure model, thinking level, tools, + * and system prompt instructions. Presets are defined in JSON config files + * and can be activated via CLI flag, /preset command, or Ctrl+Shift+U to cycle. + * + * Config files (merged, project takes precedence): + * - ~/.pi/agent/presets.json (global) + * - /.pi/presets.json (project-local) + * + * Example presets.json: + * ```json + * { + * "plan": { + * "provider": "anthropic", + * "model": "claude-sonnet-4-5", + * "thinkingLevel": "high", + * "tools": ["read", "grep", "find", "ls"], + * "instructions": "You are in PLANNING MODE. Your job is to deeply understand the problem and create a detailed implementation plan.\n\nRules:\n- DO NOT make any changes. You cannot edit or write files.\n- Read files IN FULL (no offset/limit) to get complete context. Partial reads miss critical details.\n- Explore thoroughly: grep for related code, find similar patterns, understand the architecture.\n- Ask clarifying questions if requirements are ambiguous. Do not assume.\n- Identify risks, edge cases, and dependencies before proposing solutions.\n\nOutput:\n- Create a structured plan with numbered steps.\n- For each step: what to change, why, and potential risks.\n- List files that will be modified.\n- Note any tests that should be added or updated.\n\nWhen done, ask the user if they want you to:\n1. Write the plan to a markdown file (e.g., PLAN.md)\n2. Create a GitHub issue with the plan\n3. Proceed to implementation (they should switch to 'implement' preset)" + * }, + * "implement": { + * "provider": "anthropic", + * "model": "claude-sonnet-4-5", + * "thinkingLevel": "high", + * "tools": ["read", "bash", "edit", "write"], + * "instructions": "You are in IMPLEMENTATION MODE. Your job is to make focused, correct changes.\n\nRules:\n- Keep scope tight. Do exactly what was asked, no more.\n- Read files before editing to understand current state.\n- Make surgical edits. Prefer edit over write for existing files.\n- Explain your reasoning briefly before each change.\n- Run tests or type checks after changes if the project has them (npm test, npm run check, etc.).\n- If you encounter unexpected complexity, STOP and explain the issue rather than hacking around it.\n\nIf no plan exists:\n- Ask clarifying questions before starting.\n- Propose what you'll do and get confirmation for non-trivial changes.\n\nAfter completing changes:\n- Summarize what was done.\n- Note any follow-up work or tests that should be added." + * } + * } + * ``` + * + * Usage: + * - `pi --preset plan` - start with plan preset + * - `/preset` - show selector to switch presets mid-session + * - `/preset implement` - switch to implement preset directly + * - `Ctrl+Shift+U` - cycle through presets + * + * CLI flags always override preset values. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { DynamicBorder } from "@mariozechner/pi-coding-agent"; +import { Container, Key, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui"; + +// Preset configuration +interface Preset { + /** Provider name (e.g., "anthropic", "openai") */ + provider?: string; + /** Model ID (e.g., "claude-sonnet-4-5") */ + model?: string; + /** Thinking level */ + thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + /** Tools to enable (replaces default set) */ + tools?: string[]; + /** Instructions to append to system prompt */ + instructions?: string; +} + +interface PresetsConfig { + [name: string]: Preset; +} + +/** + * Load presets from config files. + * Project-local presets override global presets with the same name. + */ +function loadPresets(cwd: string): PresetsConfig { + const globalPath = join(homedir(), ".pi", "agent", "presets.json"); + const projectPath = join(cwd, ".pi", "presets.json"); + + let globalPresets: PresetsConfig = {}; + let projectPresets: PresetsConfig = {}; + + // Load global presets + if (existsSync(globalPath)) { + try { + const content = readFileSync(globalPath, "utf-8"); + globalPresets = JSON.parse(content); + } catch (err) { + console.error(`Failed to load global presets from ${globalPath}: ${err}`); + } + } + + // Load project presets + if (existsSync(projectPath)) { + try { + const content = readFileSync(projectPath, "utf-8"); + projectPresets = JSON.parse(content); + } catch (err) { + console.error(`Failed to load project presets from ${projectPath}: ${err}`); + } + } + + // Merge (project overrides global) + return { ...globalPresets, ...projectPresets }; +} + +export default function presetExtension(pi: ExtensionAPI) { + let presets: PresetsConfig = {}; + let activePresetName: string | undefined; + let activePreset: Preset | undefined; + + // Register --preset CLI flag + pi.registerFlag("preset", { + description: "Preset configuration to use", + type: "string", + }); + + /** + * Apply a preset configuration. + */ + async function applyPreset(name: string, preset: Preset, ctx: ExtensionContext): Promise { + // Apply model if specified + if (preset.provider && preset.model) { + const model = ctx.modelRegistry.find(preset.provider, preset.model); + if (model) { + const success = await pi.setModel(model); + if (!success) { + ctx.ui.notify(`Preset "${name}": No API key for ${preset.provider}/${preset.model}`, "warning"); + } + } else { + ctx.ui.notify(`Preset "${name}": Model ${preset.provider}/${preset.model} not found`, "warning"); + } + } + + // Apply thinking level if specified + if (preset.thinkingLevel) { + pi.setThinkingLevel(preset.thinkingLevel); + } + + // Apply tools if specified + if (preset.tools && preset.tools.length > 0) { + const allTools = pi.getAllTools(); + const validTools = preset.tools.filter((t) => allTools.includes(t)); + const invalidTools = preset.tools.filter((t) => !allTools.includes(t)); + + if (invalidTools.length > 0) { + ctx.ui.notify(`Preset "${name}": Unknown tools: ${invalidTools.join(", ")}`, "warning"); + } + + if (validTools.length > 0) { + pi.setActiveTools(validTools); + } + } + + // Store active preset for system prompt injection + activePresetName = name; + activePreset = preset; + + return true; + } + + /** + * Build description string for a preset. + */ + function buildPresetDescription(preset: Preset): string { + const parts: string[] = []; + + if (preset.provider && preset.model) { + parts.push(`${preset.provider}/${preset.model}`); + } + if (preset.thinkingLevel) { + parts.push(`thinking:${preset.thinkingLevel}`); + } + if (preset.tools) { + parts.push(`tools:${preset.tools.join(",")}`); + } + if (preset.instructions) { + const truncated = + preset.instructions.length > 30 ? `${preset.instructions.slice(0, 27)}...` : preset.instructions; + parts.push(`"${truncated}"`); + } + + return parts.join(" | "); + } + + /** + * Show preset selector UI using custom SelectList component. + */ + async function showPresetSelector(ctx: ExtensionContext): Promise { + const presetNames = Object.keys(presets); + + if (presetNames.length === 0) { + ctx.ui.notify("No presets defined. Add presets to ~/.pi/agent/presets.json or .pi/presets.json", "warning"); + return; + } + + // Build select items with descriptions + const items: SelectItem[] = presetNames.map((name) => { + const preset = presets[name]; + const isActive = name === activePresetName; + return { + value: name, + label: isActive ? `${name} (active)` : name, + description: buildPresetDescription(preset), + }; + }); + + // Add "None" option to clear preset + items.push({ + value: "(none)", + label: "(none)", + description: "Clear active preset, restore defaults", + }); + + const result = await ctx.ui.custom((tui, theme, done) => { + const container = new Container(); + container.addChild(new DynamicBorder((str) => theme.fg("accent", str))); + + // Header + container.addChild(new Text(theme.fg("accent", theme.bold("Select Preset")))); + + // SelectList with themed styling + const selectList = new SelectList(items, Math.min(items.length, 10), { + selectedPrefix: (text) => theme.fg("accent", text), + selectedText: (text) => theme.fg("accent", text), + description: (text) => theme.fg("muted", text), + scrollInfo: (text) => theme.fg("dim", text), + noMatch: (text) => theme.fg("warning", text), + }); + + selectList.onSelect = (item) => done(item.value); + selectList.onCancel = () => done(null); + + container.addChild(selectList); + + // Footer hint + container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"))); + + container.addChild(new DynamicBorder((str) => theme.fg("accent", str))); + + return { + render(width: number) { + return container.render(width); + }, + invalidate() { + container.invalidate(); + }, + handleInput(data: string) { + selectList.handleInput(data); + tui.requestRender(); + }, + }; + }); + + if (!result) return; + + if (result === "(none)") { + // Clear preset and restore defaults + activePresetName = undefined; + activePreset = undefined; + pi.setActiveTools(["read", "bash", "edit", "write"]); + ctx.ui.notify("Preset cleared, defaults restored", "info"); + updateStatus(ctx); + return; + } + + const preset = presets[result]; + if (preset) { + await applyPreset(result, preset, ctx); + ctx.ui.notify(`Preset "${result}" activated`, "info"); + updateStatus(ctx); + } + } + + /** + * Update status indicator. + */ + function updateStatus(ctx: ExtensionContext) { + if (activePresetName) { + ctx.ui.setStatus("preset", ctx.ui.theme.fg("accent", `preset:${activePresetName}`)); + } else { + ctx.ui.setStatus("preset", undefined); + } + } + + function getPresetOrder(): string[] { + return Object.keys(presets).sort(); + } + + async function cyclePreset(ctx: ExtensionContext): Promise { + const presetNames = getPresetOrder(); + if (presetNames.length === 0) { + ctx.ui.notify("No presets defined. Add presets to ~/.pi/agent/presets.json or .pi/presets.json", "warning"); + return; + } + + const cycleList = ["(none)", ...presetNames]; + const currentName = activePresetName ?? "(none)"; + const currentIndex = cycleList.indexOf(currentName); + const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % cycleList.length; + const nextName = cycleList[nextIndex]; + + if (nextName === "(none)") { + activePresetName = undefined; + activePreset = undefined; + pi.setActiveTools(["read", "bash", "edit", "write"]); + ctx.ui.notify("Preset cleared, defaults restored", "info"); + updateStatus(ctx); + return; + } + + const preset = presets[nextName]; + if (!preset) return; + + await applyPreset(nextName, preset, ctx); + ctx.ui.notify(`Preset "${nextName}" activated`, "info"); + updateStatus(ctx); + } + + pi.registerShortcut(Key.ctrlShift("u"), { + description: "Cycle presets", + handler: async (ctx) => { + await cyclePreset(ctx); + }, + }); + + // Register /preset command + pi.registerCommand("preset", { + description: "Switch preset configuration", + handler: async (args, ctx) => { + // If preset name provided, apply directly + if (args?.trim()) { + const name = args.trim(); + const preset = presets[name]; + + if (!preset) { + const available = Object.keys(presets).join(", ") || "(none defined)"; + ctx.ui.notify(`Unknown preset "${name}". Available: ${available}`, "error"); + return; + } + + await applyPreset(name, preset, ctx); + ctx.ui.notify(`Preset "${name}" activated`, "info"); + updateStatus(ctx); + return; + } + + // Otherwise show selector + await showPresetSelector(ctx); + }, + }); + + // Inject preset instructions into system prompt + pi.on("before_agent_start", async () => { + if (activePreset?.instructions) { + return { + systemPromptAppend: activePreset.instructions, + }; + } + }); + + // Initialize on session start + pi.on("session_start", async (_event, ctx) => { + // Load presets from config files + presets = loadPresets(ctx.cwd); + + // Check for --preset flag + const presetFlag = pi.getFlag("preset"); + if (typeof presetFlag === "string" && presetFlag) { + const preset = presets[presetFlag]; + if (preset) { + await applyPreset(presetFlag, preset, ctx); + ctx.ui.notify(`Preset "${presetFlag}" activated`, "info"); + } else { + const available = Object.keys(presets).join(", ") || "(none defined)"; + ctx.ui.notify(`Unknown preset "${presetFlag}". Available: ${available}`, "warning"); + } + } + + // Restore preset from session state + const entries = ctx.sessionManager.getEntries(); + const presetEntry = entries + .filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "preset-state") + .pop() as { data?: { name: string } } | undefined; + + if (presetEntry?.data?.name && !presetFlag) { + const preset = presets[presetEntry.data.name]; + if (preset) { + activePresetName = presetEntry.data.name; + activePreset = preset; + // Don't re-apply model/tools on restore, just keep the name for instructions + } + } + + updateStatus(ctx); + }); + + // Persist preset state + pi.on("turn_start", async () => { + if (activePresetName) { + pi.appendEntry("preset-state", { name: activePresetName }); + } + }); +} diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 4b7c901b..5fd979f5 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -39,6 +39,7 @@ export type { FindToolResultEvent, GetActiveToolsHandler, GetAllToolsHandler, + GetThinkingLevelHandler, GrepToolResultEvent, LoadExtensionsResult, // Loaded Extension @@ -70,6 +71,8 @@ export type { SessionSwitchEvent, SessionTreeEvent, SetActiveToolsHandler, + SetModelHandler, + SetThinkingLevelHandler, // Events - Tool ToolCallEvent, ToolCallEventResult, diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index d65b7534..afc23069 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -23,6 +23,7 @@ import type { ExtensionUIContext, GetActiveToolsHandler, GetAllToolsHandler, + GetThinkingLevelHandler, LoadExtensionsResult, LoadedExtension, MessageRenderer, @@ -31,6 +32,8 @@ import type { SendMessageHandler, SendUserMessageHandler, SetActiveToolsHandler, + SetModelHandler, + SetThinkingLevelHandler, ToolDefinition, } from "./types.js"; @@ -124,6 +127,9 @@ function createExtensionAPI( setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void; setGetAllToolsHandler: (handler: GetAllToolsHandler) => void; setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => void; + setSetModelHandler: (handler: SetModelHandler) => void; + setGetThinkingLevelHandler: (handler: GetThinkingLevelHandler) => void; + setSetThinkingLevelHandler: (handler: SetThinkingLevelHandler) => void; setFlagValue: (name: string, value: boolean | string) => void; } { let sendMessageHandler: SendMessageHandler = () => {}; @@ -132,6 +138,9 @@ function createExtensionAPI( let getActiveToolsHandler: GetActiveToolsHandler = () => []; let getAllToolsHandler: GetAllToolsHandler = () => []; let setActiveToolsHandler: SetActiveToolsHandler = () => {}; + let setModelHandler: SetModelHandler = async () => false; + let getThinkingLevelHandler: GetThinkingLevelHandler = () => "off"; + let setThinkingLevelHandler: SetThinkingLevelHandler = () => {}; const messageRenderers = new Map(); const commands = new Map(); @@ -213,6 +222,18 @@ function createExtensionAPI( setActiveToolsHandler(toolNames); }, + setModel(model) { + return setModelHandler(model); + }, + + getThinkingLevel() { + return getThinkingLevelHandler(); + }, + + setThinkingLevel(level) { + setThinkingLevelHandler(level); + }, + events: eventBus, } as ExtensionAPI; @@ -241,6 +262,15 @@ function createExtensionAPI( setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => { setActiveToolsHandler = handler; }, + setSetModelHandler: (handler: SetModelHandler) => { + setModelHandler = handler; + }, + setGetThinkingLevelHandler: (handler: GetThinkingLevelHandler) => { + getThinkingLevelHandler = handler; + }, + setSetThinkingLevelHandler: (handler: SetThinkingLevelHandler) => { + setThinkingLevelHandler = handler; + }, setFlagValue: (name: string, value: boolean | string) => { flagValues.set(name, value); }, @@ -277,6 +307,9 @@ async function loadExtensionWithBun( setGetActiveToolsHandler, setGetAllToolsHandler, setSetActiveToolsHandler, + setSetModelHandler, + setGetThinkingLevelHandler, + setSetThinkingLevelHandler, setFlagValue, } = createExtensionAPI(handlers, tools, cwd, extensionPath, eventBus, sharedUI); @@ -299,6 +332,9 @@ async function loadExtensionWithBun( setGetActiveToolsHandler, setGetAllToolsHandler, setSetActiveToolsHandler, + setSetModelHandler, + setGetThinkingLevelHandler, + setSetThinkingLevelHandler, setFlagValue, }, error: null, @@ -359,6 +395,9 @@ async function loadExtension( setGetActiveToolsHandler, setGetAllToolsHandler, setSetActiveToolsHandler, + setSetModelHandler, + setGetThinkingLevelHandler, + setSetThinkingLevelHandler, setFlagValue, } = createExtensionAPI(handlers, tools, cwd, extensionPath, eventBus, sharedUI); @@ -381,6 +420,9 @@ async function loadExtension( setGetActiveToolsHandler, setGetAllToolsHandler, setSetActiveToolsHandler, + setSetModelHandler, + setGetThinkingLevelHandler, + setSetThinkingLevelHandler, setFlagValue, }, error: null, @@ -416,6 +458,9 @@ export function loadExtensionFromFactory( setGetActiveToolsHandler, setGetAllToolsHandler, setSetActiveToolsHandler, + setSetModelHandler, + setGetThinkingLevelHandler, + setSetThinkingLevelHandler, setFlagValue, } = createExtensionAPI(handlers, tools, cwd, name, eventBus, sharedUI); @@ -437,6 +482,9 @@ export function loadExtensionFromFactory( setGetActiveToolsHandler, setGetAllToolsHandler, setSetActiveToolsHandler, + setSetModelHandler, + setGetThinkingLevelHandler, + setSetThinkingLevelHandler, setFlagValue, }; } diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index dd9ad66a..fffe126d 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -23,6 +23,7 @@ import type { ExtensionUIContext, GetActiveToolsHandler, GetAllToolsHandler, + GetThinkingLevelHandler, LoadedExtension, MessageRenderer, RegisteredCommand, @@ -32,6 +33,8 @@ import type { SessionBeforeCompactResult, SessionBeforeTreeResult, SetActiveToolsHandler, + SetModelHandler, + SetThinkingLevelHandler, ToolCallEvent, ToolCallEventResult, ToolResultEventResult, @@ -115,6 +118,9 @@ export class ExtensionRunner { getActiveToolsHandler: GetActiveToolsHandler; getAllToolsHandler: GetAllToolsHandler; setActiveToolsHandler: SetActiveToolsHandler; + setModelHandler: SetModelHandler; + getThinkingLevelHandler: GetThinkingLevelHandler; + setThinkingLevelHandler: SetThinkingLevelHandler; newSessionHandler?: NewSessionHandler; branchHandler?: BranchHandler; navigateTreeHandler?: NavigateTreeHandler; @@ -148,6 +154,9 @@ export class ExtensionRunner { ext.setGetActiveToolsHandler(options.getActiveToolsHandler); ext.setGetAllToolsHandler(options.getAllToolsHandler); ext.setSetActiveToolsHandler(options.setActiveToolsHandler); + ext.setSetModelHandler(options.setModelHandler); + ext.setGetThinkingLevelHandler(options.getThinkingLevelHandler); + ext.setSetThinkingLevelHandler(options.setThinkingLevelHandler); } this.uiContext = options.uiContext ?? noOpUIContext; diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 92bd7113..ad7b8540 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -8,7 +8,12 @@ * - Interact with the user via UI primitives */ -import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core"; +import type { + AgentMessage, + AgentToolResult, + AgentToolUpdateCallback, + ThinkingLevel, +} from "@mariozechner/pi-agent-core"; import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai"; import type { Component, KeyId, TUI } from "@mariozechner/pi-tui"; import type { Static, TSchema } from "@sinclair/typebox"; @@ -619,6 +624,19 @@ export interface ExtensionAPI { /** Set the active tools by name. */ setActiveTools(toolNames: string[]): void; + // ========================================================================= + // Model and Thinking Level + // ========================================================================= + + /** Set the current model. Returns false if no API key available. */ + setModel(model: Model): Promise; + + /** Get current thinking level. */ + getThinkingLevel(): ThinkingLevel; + + /** Set thinking level (clamped to model capabilities). */ + setThinkingLevel(level: ThinkingLevel): void; + /** Shared event bus for extension communication. */ events: EventBus; } @@ -670,6 +688,12 @@ export type GetAllToolsHandler = () => string[]; export type SetActiveToolsHandler = (toolNames: string[]) => void; +export type SetModelHandler = (model: Model) => Promise; + +export type GetThinkingLevelHandler = () => ThinkingLevel; + +export type SetThinkingLevelHandler = (level: ThinkingLevel) => void; + /** Loaded extension with all registered items. */ export interface LoadedExtension { path: string; @@ -687,6 +711,9 @@ export interface LoadedExtension { setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void; setGetAllToolsHandler: (handler: GetAllToolsHandler) => void; setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => void; + setSetModelHandler: (handler: SetModelHandler) => void; + setGetThinkingLevelHandler: (handler: GetThinkingLevelHandler) => void; + setSetThinkingLevelHandler: (handler: SetThinkingLevelHandler) => void; setFlagValue: (name: string, value: boolean | string) => void; } diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index b4c342d6..73793b89 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -283,7 +283,7 @@ Documentation: - Main documentation: ${readmePath} - Additional docs: ${docsPath} - Examples: ${examplesPath} (extensions, custom tools, SDK) -- When asked to create: custom models/providers (README.md), extensions (docs/extensions.md, examples/extensions/), themes (docs/theme.md), skills (docs/skills.md) +- When asked to create: custom models/providers (README.md), extensions (docs/extensions.md, examples/extensions/), themes (docs/theme.md), skills (docs/skills.md), TUI components (docs/tui.md - has copy-paste patterns) - Always read the doc, examples, AND follow .md cross-references before implementing`; if (appendSection) { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 568812ad..e5a1af3d 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -525,6 +525,14 @@ export class InteractiveMode { return { cancelled: false }; }, + setModelHandler: async (model) => { + const key = await this.session.modelRegistry.getApiKey(model); + if (!key) return false; + await this.session.setModel(model); + return true; + }, + getThinkingLevelHandler: () => this.session.thinkingLevel, + setThinkingLevelHandler: (level) => this.session.setThinkingLevel(level), isIdle: () => !this.session.isStreaming, waitForIdle: () => this.session.agent.waitForIdle(), abort: () => { diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index ecf6c753..afa26853 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -48,6 +48,14 @@ export async function runPrintMode( getActiveToolsHandler: () => session.getActiveToolNames(), getAllToolsHandler: () => session.getAllToolNames(), setActiveToolsHandler: (toolNames: string[]) => session.setActiveToolsByName(toolNames), + setModelHandler: async (model) => { + const key = await session.modelRegistry.getApiKey(model); + if (!key) return false; + await session.setModel(model); + return true; + }, + getThinkingLevelHandler: () => session.thinkingLevel, + setThinkingLevelHandler: (level) => session.setThinkingLevel(level), }); extensionRunner.onError((err) => { console.error(`Extension error (${err.extensionPath}): ${err.error}`); diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 2a8850ed..8a429d5d 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -244,6 +244,14 @@ export async function runRpcMode(session: AgentSession): Promise { getActiveToolsHandler: () => session.getActiveToolNames(), getAllToolsHandler: () => session.getAllToolNames(), setActiveToolsHandler: (toolNames: string[]) => session.setActiveToolsByName(toolNames), + setModelHandler: async (model) => { + const key = await session.modelRegistry.getApiKey(model); + if (!key) return false; + await session.setModel(model); + return true; + }, + getThinkingLevelHandler: () => session.thinkingLevel, + setThinkingLevelHandler: (level) => session.setThinkingLevel(level), uiContext: createExtensionUIContext(), hasUI: false, }); diff --git a/packages/coding-agent/test/compaction-extensions.test.ts b/packages/coding-agent/test/compaction-extensions.test.ts index f98fe5cf..c062d806 100644 --- a/packages/coding-agent/test/compaction-extensions.test.ts +++ b/packages/coding-agent/test/compaction-extensions.test.ts @@ -88,6 +88,9 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => { setGetActiveToolsHandler: () => {}, setGetAllToolsHandler: () => {}, setSetActiveToolsHandler: () => {}, + setSetModelHandler: () => {}, + setGetThinkingLevelHandler: () => {}, + setSetThinkingLevelHandler: () => {}, setFlagValue: () => {}, }; } @@ -117,6 +120,9 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => { getActiveToolsHandler: () => [], getAllToolsHandler: () => [], setActiveToolsHandler: () => {}, + setModelHandler: async () => false, + getThinkingLevelHandler: () => "off", + setThinkingLevelHandler: () => {}, uiContext: { select: async () => undefined, confirm: async () => false, @@ -292,6 +298,9 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => { setGetActiveToolsHandler: () => {}, setGetAllToolsHandler: () => {}, setSetActiveToolsHandler: () => {}, + setSetModelHandler: () => {}, + setGetThinkingLevelHandler: () => {}, + setSetThinkingLevelHandler: () => {}, setFlagValue: () => {}, }; @@ -348,6 +357,9 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => { setGetActiveToolsHandler: () => {}, setGetAllToolsHandler: () => {}, setSetActiveToolsHandler: () => {}, + setSetModelHandler: () => {}, + setGetThinkingLevelHandler: () => {}, + setSetThinkingLevelHandler: () => {}, setFlagValue: () => {}, }; @@ -386,6 +398,9 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => { setGetActiveToolsHandler: () => {}, setGetAllToolsHandler: () => {}, setSetActiveToolsHandler: () => {}, + setSetModelHandler: () => {}, + setGetThinkingLevelHandler: () => {}, + setSetThinkingLevelHandler: () => {}, setFlagValue: () => {}, };