mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-19 06:01:14 +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
|
|
@ -45,6 +45,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|
|||
| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence |
|
||||
| `send-user-message.ts` | Demonstrates `pi.sendUserMessage()` for sending user messages from extensions |
|
||||
| `timed-confirm.ts` | Demonstrates AbortSignal for auto-dismissing `ctx.ui.confirm()` and `ctx.ui.select()` dialogs |
|
||||
| `modal-editor.ts` | Custom vim-like modal editor via `ctx.ui.setEditorComponent()` |
|
||||
|
||||
### Git Integration
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export default function (pi: ExtensionAPI) {
|
|||
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
||||
|
||||
// Generate the handoff prompt with loader UI
|
||||
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, `Generating handoff prompt...`);
|
||||
loader.onAbort = () => done(null);
|
||||
|
||||
|
|
|
|||
85
packages/coding-agent/examples/extensions/modal-editor.ts
Normal file
85
packages/coding-agent/examples/extensions/modal-editor.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Modal Editor - vim-like modal editing example
|
||||
*
|
||||
* Usage: pi --extension ./examples/extensions/modal-editor.ts
|
||||
*
|
||||
* - Escape: insert → normal mode (in normal mode, aborts agent)
|
||||
* - i: normal → insert mode
|
||||
* - hjkl: navigation in normal mode
|
||||
* - ctrl+c, ctrl+d, etc. work in both modes
|
||||
*/
|
||||
|
||||
import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||
|
||||
// Normal mode key mappings: key -> escape sequence (or null for mode switch)
|
||||
const NORMAL_KEYS: Record<string, string | null> = {
|
||||
h: "\x1b[D", // left
|
||||
j: "\x1b[B", // down
|
||||
k: "\x1b[A", // up
|
||||
l: "\x1b[C", // right
|
||||
"0": "\x01", // line start
|
||||
$: "\x05", // line end
|
||||
x: "\x1b[3~", // delete char
|
||||
i: null, // insert mode
|
||||
a: null, // append (insert + right)
|
||||
};
|
||||
|
||||
class ModalEditor extends CustomEditor {
|
||||
private mode: "normal" | "insert" = "insert";
|
||||
|
||||
handleInput(data: string): void {
|
||||
// Escape toggles to normal mode, or passes through for app handling
|
||||
if (matchesKey(data, "escape")) {
|
||||
if (this.mode === "insert") {
|
||||
this.mode = "normal";
|
||||
} else {
|
||||
super.handleInput(data); // abort agent, etc.
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert mode: pass everything through
|
||||
if (this.mode === "insert") {
|
||||
super.handleInput(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal mode: check mapped keys
|
||||
if (data in NORMAL_KEYS) {
|
||||
const seq = NORMAL_KEYS[data];
|
||||
if (data === "i") {
|
||||
this.mode = "insert";
|
||||
} else if (data === "a") {
|
||||
this.mode = "insert";
|
||||
super.handleInput("\x1b[C"); // move right first
|
||||
} else if (seq) {
|
||||
super.handleInput(seq);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass control sequences (ctrl+c, etc.) to super, ignore printable chars
|
||||
if (data.length === 1 && data.charCodeAt(0) >= 32) return;
|
||||
super.handleInput(data);
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines = super.render(width);
|
||||
if (lines.length === 0) return lines;
|
||||
|
||||
// Add mode indicator to bottom border
|
||||
const label = this.mode === "normal" ? " NORMAL " : " INSERT ";
|
||||
const last = lines.length - 1;
|
||||
if (visibleWidth(lines[last]!) >= label.length) {
|
||||
lines[last] = truncateToWidth(lines[last]!, width - label.length, "") + label;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("session_start", (_event, ctx) => {
|
||||
ctx.ui.setEditorComponent((_tui, theme, kb) => new ModalEditor(theme, kb));
|
||||
});
|
||||
}
|
||||
|
|
@ -206,7 +206,7 @@ export default function presetExtension(pi: ExtensionAPI) {
|
|||
description: "Clear active preset, restore defaults",
|
||||
});
|
||||
|
||||
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();
|
||||
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export default function (pi: ExtensionAPI) {
|
|||
}
|
||||
|
||||
// Run extraction with loader UI
|
||||
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, `Extracting questions using ${ctx.model!.id}...`);
|
||||
loader.onAbort = () => done(null);
|
||||
|
||||
|
|
|
|||
95
packages/coding-agent/examples/extensions/rainbow-editor.ts
Normal file
95
packages/coding-agent/examples/extensions/rainbow-editor.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* Rainbow Editor - highlights "ultrathink" with animated shine effect
|
||||
*
|
||||
* Usage: pi --extension ./examples/extensions/rainbow-editor.ts
|
||||
*/
|
||||
|
||||
import { CustomEditor, type ExtensionAPI, type KeybindingsManager } from "@mariozechner/pi-coding-agent";
|
||||
import type { EditorTheme, TUI } from "@mariozechner/pi-tui";
|
||||
|
||||
// Base colors (coral → yellow → green → teal → blue → purple → pink)
|
||||
const COLORS: [number, number, number][] = [
|
||||
[233, 137, 115], // coral
|
||||
[228, 186, 103], // yellow
|
||||
[141, 192, 122], // green
|
||||
[102, 194, 179], // teal
|
||||
[121, 157, 207], // blue
|
||||
[157, 134, 195], // purple
|
||||
[206, 130, 172], // pink
|
||||
];
|
||||
const RESET = "\x1b[0m";
|
||||
|
||||
function brighten(rgb: [number, number, number], factor: number): string {
|
||||
const [r, g, b] = rgb.map((c) => Math.round(c + (255 - c) * factor));
|
||||
return `\x1b[38;2;${r};${g};${b}m`;
|
||||
}
|
||||
|
||||
function colorize(text: string, shinePos: number): string {
|
||||
return (
|
||||
[...text]
|
||||
.map((c, i) => {
|
||||
const baseColor = COLORS[i % COLORS.length]!;
|
||||
// 3-letter shine: center bright, adjacent dimmer
|
||||
let factor = 0;
|
||||
if (shinePos >= 0) {
|
||||
const dist = Math.abs(i - shinePos);
|
||||
if (dist === 0) factor = 0.7;
|
||||
else if (dist === 1) factor = 0.35;
|
||||
}
|
||||
return `${brighten(baseColor, factor)}${c}`;
|
||||
})
|
||||
.join("") + RESET
|
||||
);
|
||||
}
|
||||
|
||||
class RainbowEditor extends CustomEditor {
|
||||
private animationTimer?: ReturnType<typeof setInterval>;
|
||||
private tui: TUI;
|
||||
private frame = 0;
|
||||
|
||||
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) {
|
||||
super(theme, keybindings);
|
||||
this.tui = tui;
|
||||
}
|
||||
|
||||
private hasUltrathink(): boolean {
|
||||
return /ultrathink/i.test(this.getText());
|
||||
}
|
||||
|
||||
private startAnimation(): void {
|
||||
if (this.animationTimer) return;
|
||||
this.animationTimer = setInterval(() => {
|
||||
this.frame++;
|
||||
this.tui.requestRender();
|
||||
}, 60);
|
||||
}
|
||||
|
||||
private stopAnimation(): void {
|
||||
if (this.animationTimer) {
|
||||
clearInterval(this.animationTimer);
|
||||
this.animationTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
super.handleInput(data);
|
||||
if (this.hasUltrathink()) {
|
||||
this.startAnimation();
|
||||
} else {
|
||||
this.stopAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
// Cycle: 10 shine positions + 10 pause frames
|
||||
const cycle = this.frame % 20;
|
||||
const shinePos = cycle < 10 ? cycle : -1; // -1 means no shine (pause)
|
||||
return super.render(width).map((line) => line.replace(/ultrathink/gi, (m) => colorize(m, shinePos)));
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("session_start", (_event, ctx) => {
|
||||
ctx.ui.setEditorComponent((tui, theme, kb) => new RainbowEditor(tui, theme, kb));
|
||||
});
|
||||
}
|
||||
|
|
@ -327,7 +327,7 @@ export default function (pi: ExtensionAPI) {
|
|||
}
|
||||
}
|
||||
|
||||
await ctx.ui.custom((tui, _theme, done) => {
|
||||
await ctx.ui.custom((tui, _theme, _kb, done) => {
|
||||
return new SnakeComponent(
|
||||
tui,
|
||||
() => done(undefined),
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ export default function (pi: ExtensionAPI) {
|
|||
return;
|
||||
}
|
||||
|
||||
await ctx.ui.custom<void>((_tui, theme, done) => {
|
||||
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
|
||||
return new TodoListComponent(todos, theme, () => done());
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export default function toolsExtension(pi: ExtensionAPI) {
|
|||
// Refresh tool list
|
||||
allTools = pi.getAllTools();
|
||||
|
||||
await ctx.ui.custom((tui, theme, done) => {
|
||||
await ctx.ui.custom((tui, theme, _kb, done) => {
|
||||
// Build settings items for each tool
|
||||
const items: SettingItem[] = allTools.map((tool) => ({
|
||||
id: tool,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue