mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-18 19:03:41 +00:00
feat(coding-agent): add ctx.ui.setEditorComponent() extension API
- Add setEditorComponent() to ctx.ui for custom editor components - Add CustomEditor base class for extensions (handles app keybindings) - Add keybindings parameter to ctx.ui.custom() factory (breaking change) - Add modal-editor.ts example (vim-like modes) - Add rainbow-editor.ts example (animated text highlighting) - Update docs: extensions.md, tui.md Pattern 7 - Clean up terminal on TUI render errors
This commit is contained in:
parent
10e651f99b
commit
09471ebc7d
27 changed files with 578 additions and 63 deletions
|
|
@ -1170,6 +1170,10 @@ ctx.ui.setTitle("pi - my-project");
|
|||
// Editor text
|
||||
ctx.ui.setEditorText("Prefill text");
|
||||
const current = ctx.ui.getEditorText();
|
||||
|
||||
// Custom editor (vim mode, emacs mode, etc.)
|
||||
ctx.ui.setEditorComponent((tui, theme, keybindings) => new VimEditor(tui, theme, keybindings));
|
||||
ctx.ui.setEditorComponent(undefined); // Restore default editor
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
|
|
@ -1177,6 +1181,7 @@ const current = ctx.ui.getEditorText();
|
|||
- `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)
|
||||
- `ctx.ui.setEditorComponent()`: [modal-editor.ts](../examples/extensions/modal-editor.ts)
|
||||
|
||||
### Custom Components
|
||||
|
||||
|
|
@ -1185,7 +1190,7 @@ For complex UI, use `ctx.ui.custom()`. This temporarily replaces the editor with
|
|||
```typescript
|
||||
import { Text, Component } from "@mariozechner/pi-tui";
|
||||
|
||||
const result = await ctx.ui.custom<boolean>((tui, theme, done) => {
|
||||
const result = await ctx.ui.custom<boolean>((tui, theme, keybindings, done) => {
|
||||
const text = new Text("Press Enter to confirm, Escape to cancel", 1, 1);
|
||||
|
||||
text.onKey = (key) => {
|
||||
|
|
@ -1205,12 +1210,56 @@ if (result) {
|
|||
The callback receives:
|
||||
- `tui` - TUI instance (for screen dimensions, focus management)
|
||||
- `theme` - Current theme for styling
|
||||
- `keybindings` - App keybinding manager (for checking shortcuts)
|
||||
- `done(value)` - Call to close component and return value
|
||||
|
||||
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)
|
||||
|
||||
### Custom Editor
|
||||
|
||||
Replace the main input editor with a custom implementation (vim mode, emacs mode, etc.):
|
||||
|
||||
```typescript
|
||||
import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { matchesKey } from "@mariozechner/pi-tui";
|
||||
|
||||
class VimEditor extends CustomEditor {
|
||||
private mode: "normal" | "insert" = "insert";
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, "escape") && this.mode === "insert") {
|
||||
this.mode = "normal";
|
||||
return;
|
||||
}
|
||||
if (this.mode === "normal" && data === "i") {
|
||||
this.mode = "insert";
|
||||
return;
|
||||
}
|
||||
super.handleInput(data); // App keybindings + text editing
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("session_start", (_event, ctx) => {
|
||||
ctx.ui.setEditorComponent((_tui, theme, keybindings) =>
|
||||
new VimEditor(theme, keybindings)
|
||||
);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- Extend `CustomEditor` (not base `Editor`) to get app keybindings (escape to abort, ctrl+d, model switching)
|
||||
- Call `super.handleInput(data)` for keys you don't handle
|
||||
- Factory receives `theme` and `keybindings` from the app
|
||||
- Pass `undefined` to restore default: `ctx.ui.setEditorComponent(undefined)`
|
||||
|
||||
See [tui.md](tui.md) Pattern 7 for a complete example with mode indicator.
|
||||
|
||||
**Examples:** [modal-editor.ts](../examples/extensions/modal-editor.ts)
|
||||
|
||||
### Message Rendering
|
||||
|
||||
Register a custom renderer for messages with your `customType`:
|
||||
|
|
|
|||
|
|
@ -361,7 +361,7 @@ pi.registerCommand("pick", {
|
|||
{ value: "opt3", label: "Option 3" }, // description is optional
|
||||
];
|
||||
|
||||
const result = await ctx.ui.custom<string | null>((tui, theme, done) => {
|
||||
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
||||
const container = new Container();
|
||||
|
||||
// Top border
|
||||
|
|
@ -413,7 +413,7 @@ 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 result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
||||
const loader = new BorderedLoader(tui, theme, "Fetching data...");
|
||||
loader.onAbort = () => done(null);
|
||||
|
||||
|
|
@ -451,7 +451,7 @@ pi.registerCommand("settings", {
|
|||
{ id: "color", label: "Color output", currentValue: "on", values: ["on", "off"] },
|
||||
];
|
||||
|
||||
await ctx.ui.custom((_tui, theme, done) => {
|
||||
await ctx.ui.custom((_tui, theme, _kb, done) => {
|
||||
const container = new Container();
|
||||
container.addChild(new Text(theme.fg("accent", theme.bold("Settings")), 1, 1));
|
||||
|
||||
|
|
@ -541,9 +541,85 @@ ctx.ui.setFooter(undefined);
|
|||
|
||||
**Examples:** [custom-footer.ts](../examples/extensions/custom-footer.ts)
|
||||
|
||||
### Pattern 7: Custom Editor (vim mode, etc.)
|
||||
|
||||
Replace the main input editor with a custom implementation. Useful for modal editing (vim), different keybindings (emacs), or specialized input handling.
|
||||
|
||||
```typescript
|
||||
import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
|
||||
|
||||
type Mode = "normal" | "insert";
|
||||
|
||||
class VimEditor extends CustomEditor {
|
||||
private mode: Mode = "insert";
|
||||
|
||||
handleInput(data: string): void {
|
||||
// Escape: switch to normal mode, or pass through for app handling
|
||||
if (matchesKey(data, "escape")) {
|
||||
if (this.mode === "insert") {
|
||||
this.mode = "normal";
|
||||
return;
|
||||
}
|
||||
// In normal mode, escape aborts agent (handled by CustomEditor)
|
||||
super.handleInput(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert mode: pass everything to CustomEditor
|
||||
if (this.mode === "insert") {
|
||||
super.handleInput(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal mode: vim-style navigation
|
||||
switch (data) {
|
||||
case "i": this.mode = "insert"; return;
|
||||
case "h": super.handleInput("\x1b[D"); return; // Left
|
||||
case "j": super.handleInput("\x1b[B"); return; // Down
|
||||
case "k": super.handleInput("\x1b[A"); return; // Up
|
||||
case "l": super.handleInput("\x1b[C"); return; // Right
|
||||
}
|
||||
// Pass unhandled keys to super (ctrl+c, etc.), but filter printable chars
|
||||
if (data.length === 1 && data.charCodeAt(0) >= 32) return;
|
||||
super.handleInput(data);
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines = super.render(width);
|
||||
// Add mode indicator to bottom border (use truncateToWidth for ANSI-safe truncation)
|
||||
if (lines.length > 0) {
|
||||
const label = this.mode === "normal" ? " NORMAL " : " INSERT ";
|
||||
const lastLine = lines[lines.length - 1]!;
|
||||
// Pass "" as ellipsis to avoid adding "..." when truncating
|
||||
lines[lines.length - 1] = truncateToWidth(lastLine, width - label.length, "") + label;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("session_start", (_event, ctx) => {
|
||||
// Factory receives theme and keybindings from the app
|
||||
ctx.ui.setEditorComponent((tui, theme, keybindings) =>
|
||||
new VimEditor(theme, keybindings)
|
||||
);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- **Extend `CustomEditor`** (not base `Editor`) to get app keybindings (escape to abort, ctrl+d to exit, model switching, etc.)
|
||||
- **Call `super.handleInput(data)`** for keys you don't handle
|
||||
- **Factory pattern**: `setEditorComponent` receives a factory function that gets `tui`, `theme`, and `keybindings`
|
||||
- **Pass `undefined`** to restore the default editor: `ctx.ui.setEditorComponent(undefined)`
|
||||
|
||||
**Examples:** [modal-editor.ts](../examples/extensions/modal-editor.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.
|
||||
1. **Always use theme from callback** - Don't import theme directly. Use `theme` from the `ctx.ui.custom((tui, theme, keybindings, done) => ...)` callback.
|
||||
|
||||
2. **Always type DynamicBorder color param** - Write `(s: string) => theme.fg("accent", s)`, not `(s) => theme.fg("accent", s)`.
|
||||
|
||||
|
|
@ -560,5 +636,6 @@ ctx.ui.setFooter(undefined);
|
|||
- **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
|
||||
- **Custom editor**: [examples/extensions/modal-editor.ts](../examples/extensions/modal-editor.ts) - Vim-like modal editing
|
||||
- **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