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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue