mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 13:03:42 +00:00
Add ExtensionAPI methods, preset example, and TUI documentation improvements
- ExtensionAPI: setModel(), getThinkingLevel(), setThinkingLevel() methods - New preset.ts example with plan/implement presets for model/thinking/tools switching - Export all UI components from pi-coding-agent for extension use - docs/tui.md: Common Patterns section with copy-paste code for SelectList, BorderedLoader, SettingsList, setStatus, setWidget, setFooter - docs/tui.md: Key Rules section 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 development Fixes #509, relates to #347
This commit is contained in:
parent
c35a18b2b3
commit
59d8b7948c
14 changed files with 850 additions and 13 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string | null>((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<string | null>((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
|
||||
|
|
|
|||
|
|
@ -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 <goal>` |
|
||||
|
|
|
|||
398
packages/coding-agent/examples/extensions/preset.ts
Normal file
398
packages/coding-agent/examples/extensions/preset.ts
Normal file
|
|
@ -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)
|
||||
* - <cwd>/.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<boolean> {
|
||||
// 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<void> {
|
||||
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<string | null>((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<void> {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, MessageRenderer>();
|
||||
const commands = new Map<string, RegisteredCommand>();
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<any>): Promise<boolean>;
|
||||
|
||||
/** 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<any>) => Promise<boolean>;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: () => {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -244,6 +244,14 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: () => {},
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue