From 09471ebc7d28bc7d65546f368e38320e7030bb8c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 7 Jan 2026 16:11:49 +0100 Subject: [PATCH] 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 --- .pi/prompts/pr.md | 7 +- packages/coding-agent/CHANGELOG.md | 5 + packages/coding-agent/docs/extensions.md | 51 +++++- packages/coding-agent/docs/tui.md | 85 +++++++++- .../examples/extensions/README.md | 1 + .../examples/extensions/handoff.ts | 2 +- .../examples/extensions/modal-editor.ts | 85 ++++++++++ .../examples/extensions/preset.ts | 2 +- .../coding-agent/examples/extensions/qna.ts | 2 +- .../examples/extensions/rainbow-editor.ts | 95 +++++++++++ .../coding-agent/examples/extensions/snake.ts | 2 +- .../coding-agent/examples/extensions/todo.ts | 2 +- .../coding-agent/examples/extensions/tools.ts | 2 +- .../coding-agent/src/core/extensions/index.ts | 3 + .../src/core/extensions/loader.ts | 1 + .../src/core/extensions/runner.ts | 1 + .../coding-agent/src/core/extensions/types.ts | 42 ++++- packages/coding-agent/src/core/sdk.ts | 1 + packages/coding-agent/src/index.ts | 2 + .../interactive/components/custom-editor.ts | 2 +- .../src/modes/interactive/interactive-mode.ts | 158 +++++++++++++----- .../coding-agent/src/modes/rpc/rpc-mode.ts | 4 + .../test/compaction-extensions.test.ts | 1 + packages/tui/CHANGELOG.md | 4 + packages/tui/src/editor-component.ts | 65 +++++++ packages/tui/src/index.ts | 2 + packages/tui/src/tui.ts | 14 +- 27 files changed, 578 insertions(+), 63 deletions(-) create mode 100644 packages/coding-agent/examples/extensions/modal-editor.ts create mode 100644 packages/coding-agent/examples/extensions/rainbow-editor.ts create mode 100644 packages/tui/src/editor-component.ts diff --git a/.pi/prompts/pr.md b/.pi/prompts/pr.md index e5c41884..f7e2a378 100644 --- a/.pi/prompts/pr.md +++ b/.pi/prompts/pr.md @@ -8,12 +8,13 @@ For each PR URL, do the following in order: 2. Identify any linked issues referenced in the PR body, comments, commit messages, or cross links. Read each issue in full, including all comments. 3. Analyze the PR diff. Read all relevant code files in full with no truncation. Include related code paths that are not in the diff but are required to validate behavior. 4. Check for a changelog entry in the relevant `packages/*/CHANGELOG.md` files. Report whether an entry exists. If missing, state that a changelog entry is required before merge and that you will add it if the user decides to merge. Follow the changelog format rules in AGENTS.md. -5. Provide a structured review with these sections: +5. Check if packages/coding-agent/README.md, packages/coding-agent/docs/*.md, packages/coding-agent/examples/**/*.md require modification. This is usually the case when existing features have been changed, or new features have been added. +6. Provide a structured review with these sections: - Good: solid choices or improvements - Bad: concrete issues, regressions, missing tests, or risks - Ugly: subtle or high impact problems -6. Add Questions or Assumptions if anything is unclear. -7. Add Change summary and Tests. +7. Add Questions or Assumptions if anything is unclear. +8. Add Change summary and Tests. Output format per PR: PR: diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8eecb31e..92561529 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,9 +2,14 @@ ## [Unreleased] +### Breaking Changes + +- `ctx.ui.custom()` factory signature changed from `(tui, theme, done)` to `(tui, theme, keybindings, done)` for consistency with other input-handling factories + ### Added - Extension UI dialogs (`ctx.ui.select()`, `ctx.ui.confirm()`, `ctx.ui.input()`) now support a `timeout` option that auto-dismisses the dialog with a live countdown display. Simpler alternative to `AbortSignal` for timed dialogs. +- Extensions can now provide custom editor components via `ctx.ui.setEditorComponent((tui, theme, keybindings) => ...)`. Extend `CustomEditor` for full app keybinding support (escape, ctrl+d, model switching, etc.). See `examples/extensions/modal-editor.ts`, `examples/extensions/rainbow-editor.ts`, and `docs/tui.md` Pattern 7. ## [0.37.8] - 2026-01-07 diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index a85e8d7f..75718389 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -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((tui, theme, done) => { +const result = await ctx.ui.custom((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`: diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index 0db6d97d..b0c3651f 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -361,7 +361,7 @@ pi.registerCommand("pick", { { value: "opt3", label: "Option 3" }, // description is optional ]; - const result = await ctx.ui.custom((tui, theme, done) => { + const result = await ctx.ui.custom((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((tui, theme, done) => { + const result = await ctx.ui.custom((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 diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index 91ce0918..dd696aef 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -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 diff --git a/packages/coding-agent/examples/extensions/handoff.ts b/packages/coding-agent/examples/extensions/handoff.ts index 09c7227c..f8559a87 100644 --- a/packages/coding-agent/examples/extensions/handoff.ts +++ b/packages/coding-agent/examples/extensions/handoff.ts @@ -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((tui, theme, done) => { + const result = await ctx.ui.custom((tui, theme, _kb, done) => { const loader = new BorderedLoader(tui, theme, `Generating handoff prompt...`); loader.onAbort = () => done(null); diff --git a/packages/coding-agent/examples/extensions/modal-editor.ts b/packages/coding-agent/examples/extensions/modal-editor.ts new file mode 100644 index 00000000..ad060269 --- /dev/null +++ b/packages/coding-agent/examples/extensions/modal-editor.ts @@ -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 = { + 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)); + }); +} diff --git a/packages/coding-agent/examples/extensions/preset.ts b/packages/coding-agent/examples/extensions/preset.ts index befc7560..32e02eb5 100644 --- a/packages/coding-agent/examples/extensions/preset.ts +++ b/packages/coding-agent/examples/extensions/preset.ts @@ -206,7 +206,7 @@ export default function presetExtension(pi: ExtensionAPI) { description: "Clear active preset, restore defaults", }); - const result = await ctx.ui.custom((tui, theme, done) => { + const result = await ctx.ui.custom((tui, theme, _kb, done) => { const container = new Container(); container.addChild(new DynamicBorder((str) => theme.fg("accent", str))); diff --git a/packages/coding-agent/examples/extensions/qna.ts b/packages/coding-agent/examples/extensions/qna.ts index 39ae902d..fc80c41f 100644 --- a/packages/coding-agent/examples/extensions/qna.ts +++ b/packages/coding-agent/examples/extensions/qna.ts @@ -71,7 +71,7 @@ export default function (pi: ExtensionAPI) { } // Run extraction with loader UI - const result = await ctx.ui.custom((tui, theme, done) => { + const result = await ctx.ui.custom((tui, theme, _kb, done) => { const loader = new BorderedLoader(tui, theme, `Extracting questions using ${ctx.model!.id}...`); loader.onAbort = () => done(null); diff --git a/packages/coding-agent/examples/extensions/rainbow-editor.ts b/packages/coding-agent/examples/extensions/rainbow-editor.ts new file mode 100644 index 00000000..060a393c --- /dev/null +++ b/packages/coding-agent/examples/extensions/rainbow-editor.ts @@ -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; + 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)); + }); +} diff --git a/packages/coding-agent/examples/extensions/snake.ts b/packages/coding-agent/examples/extensions/snake.ts index 7f0d3cdc..4378f758 100644 --- a/packages/coding-agent/examples/extensions/snake.ts +++ b/packages/coding-agent/examples/extensions/snake.ts @@ -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), diff --git a/packages/coding-agent/examples/extensions/todo.ts b/packages/coding-agent/examples/extensions/todo.ts index 8b85582e..346ab93e 100644 --- a/packages/coding-agent/examples/extensions/todo.ts +++ b/packages/coding-agent/examples/extensions/todo.ts @@ -291,7 +291,7 @@ export default function (pi: ExtensionAPI) { return; } - await ctx.ui.custom((_tui, theme, done) => { + await ctx.ui.custom((_tui, theme, _kb, done) => { return new TodoListComponent(todos, theme, () => done()); }); }, diff --git a/packages/coding-agent/examples/extensions/tools.ts b/packages/coding-agent/examples/extensions/tools.ts index 7a79bb3f..dbd47377 100644 --- a/packages/coding-agent/examples/extensions/tools.ts +++ b/packages/coding-agent/examples/extensions/tools.ts @@ -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, diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index d62ef0a0..66f8756f 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -11,6 +11,8 @@ export type { // Re-exports AgentToolResult, AgentToolUpdateCallback, + // App keybindings (for custom editors) + AppAction, AppendEntryHandler, BashToolResultEvent, BeforeAgentStartEvent, @@ -42,6 +44,7 @@ export type { GetAllToolsHandler, GetThinkingLevelHandler, GrepToolResultEvent, + KeybindingsManager, LoadExtensionsResult, // Loaded Extension LoadedExtension, diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 03042a87..16460b16 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -99,6 +99,7 @@ function createNoOpUIContext(): ExtensionUIContext { setEditorText: () => {}, getEditorText: () => "", editor: async () => undefined, + setEditorComponent: () => {}, get theme() { return theme; }, diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index fffe126d..79ddcd65 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -74,6 +74,7 @@ const noOpUIContext: ExtensionUIContext = { setEditorText: () => {}, getEditorText: () => "", editor: async () => undefined, + setEditorComponent: () => {}, get theme() { return theme; }, diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 01c2f9ab..dd5de3ab 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -15,12 +15,13 @@ import type { 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 { Component, EditorComponent, EditorTheme, KeyId, TUI } from "@mariozechner/pi-tui"; import type { Static, TSchema } from "@sinclair/typebox"; import type { Theme } from "../../modes/interactive/theme/theme.js"; import type { CompactionPreparation, CompactionResult } from "../compaction/index.js"; import type { EventBus } from "../event-bus.js"; import type { ExecOptions, ExecResult } from "../exec.js"; +import type { AppAction, KeybindingsManager } from "../keybindings.js"; import type { CustomMessage } from "../messages.js"; import type { ModelRegistry } from "../model-registry.js"; import type { @@ -41,6 +42,7 @@ import type { export type { ExecOptions, ExecResult } from "../exec.js"; export type { AgentToolResult, AgentToolUpdateCallback }; +export type { AppAction, KeybindingsManager } from "../keybindings.js"; // ============================================================================ // UI Context @@ -92,6 +94,7 @@ export interface ExtensionUIContext { factory: ( tui: TUI, theme: Theme, + keybindings: KeybindingsManager, done: (result: T) => void, ) => (Component & { dispose?(): void }) | Promise, ): Promise; @@ -105,6 +108,43 @@ export interface ExtensionUIContext { /** Show a multi-line editor for text editing. */ editor(title: string, prefill?: string): Promise; + /** + * Set a custom editor component via factory function. + * Pass undefined to restore the default editor. + * + * The factory receives: + * - `theme`: EditorTheme for styling borders and autocomplete + * - `keybindings`: KeybindingsManager for app-level keybindings + * + * For full app keybinding support (escape, ctrl+d, model switching, etc.), + * extend `CustomEditor` from `@mariozechner/pi-coding-agent` and call + * `super.handleInput(data)` for keys you don't handle. + * + * @example + * ```ts + * import { CustomEditor } from "@mariozechner/pi-coding-agent"; + * + * class VimEditor extends CustomEditor { + * private mode: "normal" | "insert" = "insert"; + * + * handleInput(data: string): void { + * if (this.mode === "normal") { + * // Handle vim normal mode keys... + * if (data === "i") { this.mode = "insert"; return; } + * } + * super.handleInput(data); // App keybindings + text editing + * } + * } + * + * ctx.ui.setEditorComponent((tui, theme, keybindings) => + * new VimEditor(tui, theme, keybindings) + * ); + * ``` + */ + setEditorComponent( + factory: ((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => EditorComponent) | undefined, + ): void; + /** Get the current theme for styling. */ readonly theme: Theme; } diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 34b456ad..97812b58 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -531,6 +531,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} setEditorText: () => {}, getEditorText: () => "", editor: async () => undefined, + setEditorComponent: () => {}, get theme() { return {} as any; }, diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 3fc4ae39..7b81f39c 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -40,6 +40,7 @@ export type { AgentStartEvent, AgentToolResult, AgentToolUpdateCallback, + AppAction, BeforeAgentStartEvent, ContextEvent, ExecOptions, @@ -55,6 +56,7 @@ export type { ExtensionShortcut, ExtensionUIContext, ExtensionUIDialogOptions, + KeybindingsManager, LoadExtensionsResult, LoadedExtension, MessageRenderer, diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index b9dc765b..93f5af78 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -6,7 +6,7 @@ import type { AppAction, KeybindingsManager } from "../../../core/keybindings.js */ export class CustomEditor extends Editor { private keybindings: KeybindingsManager; - private actionHandlers: Map void> = new Map(); + public actionHandlers: Map void> = new Map(); // Special handlers that can be dynamically replaced public onEscape?: () => void; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 823d1c1c..6ae1f24f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -9,7 +9,7 @@ import * as os from "node:os"; import * as path from "node:path"; import type { AgentMessage } from "@mariozechner/pi-agent-core"; import { type AssistantMessage, getOAuthProviders, type Message, type OAuthProvider } from "@mariozechner/pi-ai"; -import type { KeyId, SlashCommand } from "@mariozechner/pi-tui"; +import type { EditorComponent, EditorTheme, KeyId, SlashCommand } from "@mariozechner/pi-tui"; import { CombinedAutocompleteProvider, type Component, @@ -96,7 +96,9 @@ export class InteractiveMode { private chatContainer: Container; private pendingMessagesContainer: Container; private statusContainer: Container; - private editor: CustomEditor; + private defaultEditor: CustomEditor; + private editor: EditorComponent; + private autocompleteProvider: CombinedAutocompleteProvider | undefined; private editorContainer: Container; private footer: FooterComponent; private keybindings: KeybindingsManager; @@ -195,9 +197,10 @@ export class InteractiveMode { this.statusContainer = new Container(); this.widgetContainer = new Container(); this.keybindings = KeybindingsManager.create(); - this.editor = new CustomEditor(getEditorTheme(), this.keybindings); + this.defaultEditor = new CustomEditor(getEditorTheme(), this.keybindings); + this.editor = this.defaultEditor; this.editorContainer = new Container(); - this.editorContainer.addChild(this.editor); + this.editorContainer.addChild(this.editor as Component); this.footer = new FooterComponent(session); this.footer.setAutoCompactEnabled(session.autoCompactionEnabled); @@ -238,12 +241,12 @@ export class InteractiveMode { ); // Setup autocomplete - const autocompleteProvider = new CombinedAutocompleteProvider( + this.autocompleteProvider = new CombinedAutocompleteProvider( [...slashCommands, ...templateCommands, ...extensionCommands], process.cwd(), fdPath, ); - this.editor.setAutocompleteProvider(autocompleteProvider); + this.defaultEditor.setAutocompleteProvider(this.autocompleteProvider); } async init(): Promise { @@ -595,8 +598,8 @@ export class InteractiveMode { hasPendingMessages: () => this.session.pendingMessageCount > 0, }); - // Set up the extension shortcut handler on the editor - this.editor.onExtensionShortcut = (data: string) => { + // Set up the extension shortcut handler on the default editor + this.defaultEditor.onExtensionShortcut = (data: string) => { for (const [shortcutStr, shortcut] of shortcuts) { // Cast to KeyId - extension shortcuts use the same format if (matchesKey(data, shortcutStr as KeyId)) { @@ -753,6 +756,7 @@ export class InteractiveMode { setEditorText: (text) => this.editor.setText(text), getEditorText: () => this.editor.getText(), editor: (title, prefill) => this.showExtensionEditor(title, prefill), + setEditorComponent: (factory) => this.setCustomEditorComponent(factory), get theme() { return theme; }, @@ -918,6 +922,65 @@ export class InteractiveMode { this.ui.requestRender(); } + /** + * Set a custom editor component from an extension. + * Pass undefined to restore the default editor. + */ + private setCustomEditorComponent( + factory: ((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => EditorComponent) | undefined, + ): void { + // Save text from current editor before switching + const currentText = this.editor.getText(); + + this.editorContainer.clear(); + + if (factory) { + // Create the custom editor with tui, theme, and keybindings + const newEditor = factory(this.ui, getEditorTheme(), this.keybindings); + + // Wire up callbacks from the default editor + newEditor.onSubmit = this.defaultEditor.onSubmit; + newEditor.onChange = this.defaultEditor.onChange; + + // Copy text from previous editor + newEditor.setText(currentText); + + // Copy appearance settings if supported + if (newEditor.borderColor !== undefined) { + newEditor.borderColor = this.defaultEditor.borderColor; + } + + // Set autocomplete if supported + if (newEditor.setAutocompleteProvider && this.autocompleteProvider) { + newEditor.setAutocompleteProvider(this.autocompleteProvider); + } + + // If extending CustomEditor, copy app-level handlers + // Use duck typing since instanceof fails across jiti module boundaries + const customEditor = newEditor as unknown as Record; + if ("actionHandlers" in customEditor && customEditor.actionHandlers instanceof Map) { + customEditor.onEscape = this.defaultEditor.onEscape; + customEditor.onCtrlD = this.defaultEditor.onCtrlD; + customEditor.onPasteImage = this.defaultEditor.onPasteImage; + customEditor.onExtensionShortcut = this.defaultEditor.onExtensionShortcut; + // Copy action handlers (clear, suspend, model switching, etc.) + for (const [action, handler] of this.defaultEditor.actionHandlers) { + (customEditor.actionHandlers as Map void>).set(action, handler); + } + } + + this.editor = newEditor; + } else { + // Restore default editor with text from custom editor + this.defaultEditor.setText(currentText); + this.editor = this.defaultEditor; + } + + this.editorContainer.addChild(this.editor as Component); + this.ui.setFocus(this.editor as Component); + this.ui.requestRender(); + } + /** * Show a notification for extensions. */ @@ -938,6 +1001,7 @@ export class InteractiveMode { factory: ( tui: TUI, theme: Theme, + keybindings: KeybindingsManager, done: (result: T) => void, ) => (Component & { dispose?(): void }) | Promise, ): Promise { @@ -956,7 +1020,7 @@ export class InteractiveMode { resolve(result); }; - Promise.resolve(factory(this.ui, theme, close)).then((c) => { + Promise.resolve(factory(this.ui, theme, this.keybindings, close)).then((c) => { component = c; this.editorContainer.clear(); this.editorContainer.addChild(component); @@ -992,7 +1056,9 @@ export class InteractiveMode { // ========================================================================= private setupKeyHandlers(): void { - this.editor.onEscape = () => { + // Set up handlers on defaultEditor - they use this.editor for text access + // so they work correctly regardless of which editor is active + this.defaultEditor.onEscape = () => { if (this.loadingAnimation) { // Abort and restore queued messages to editor const { steering, followUp } = this.session.clearQueue(); @@ -1026,22 +1092,22 @@ export class InteractiveMode { }; // Register app action handlers - this.editor.onAction("clear", () => this.handleCtrlC()); - this.editor.onCtrlD = () => this.handleCtrlD(); - this.editor.onAction("suspend", () => this.handleCtrlZ()); - this.editor.onAction("cycleThinkingLevel", () => this.cycleThinkingLevel()); - this.editor.onAction("cycleModelForward", () => this.cycleModel("forward")); - this.editor.onAction("cycleModelBackward", () => this.cycleModel("backward")); + this.defaultEditor.onAction("clear", () => this.handleCtrlC()); + this.defaultEditor.onCtrlD = () => this.handleCtrlD(); + this.defaultEditor.onAction("suspend", () => this.handleCtrlZ()); + this.defaultEditor.onAction("cycleThinkingLevel", () => this.cycleThinkingLevel()); + this.defaultEditor.onAction("cycleModelForward", () => this.cycleModel("forward")); + this.defaultEditor.onAction("cycleModelBackward", () => this.cycleModel("backward")); // Global debug handler on TUI (works regardless of focus) this.ui.onDebug = () => this.handleDebugCommand(); - this.editor.onAction("selectModel", () => this.showModelSelector()); - this.editor.onAction("expandTools", () => this.toggleToolOutputExpansion()); - this.editor.onAction("toggleThinking", () => this.toggleThinkingBlockVisibility()); - this.editor.onAction("externalEditor", () => this.openExternalEditor()); - this.editor.onAction("followUp", () => this.handleFollowUp()); + this.defaultEditor.onAction("selectModel", () => this.showModelSelector()); + this.defaultEditor.onAction("expandTools", () => this.toggleToolOutputExpansion()); + this.defaultEditor.onAction("toggleThinking", () => this.toggleThinkingBlockVisibility()); + this.defaultEditor.onAction("externalEditor", () => this.openExternalEditor()); + this.defaultEditor.onAction("followUp", () => this.handleFollowUp()); - this.editor.onChange = (text: string) => { + this.defaultEditor.onChange = (text: string) => { const wasBashMode = this.isBashMode; this.isBashMode = text.trimStart().startsWith("!"); if (wasBashMode !== this.isBashMode) { @@ -1050,7 +1116,7 @@ export class InteractiveMode { }; // Handle clipboard image paste (triggered on Ctrl+V) - this.editor.onPasteImage = () => { + this.defaultEditor.onPasteImage = () => { this.handleClipboardImagePaste(); }; } @@ -1070,7 +1136,7 @@ export class InteractiveMode { fs.writeFileSync(filePath, Buffer.from(image.bytes)); // Insert file path directly - this.editor.insertTextAtCursor(filePath); + this.editor.insertTextAtCursor?.(filePath); this.ui.requestRender(); } catch { // Silently ignore clipboard errors (may not have permission, etc.) @@ -1078,7 +1144,7 @@ export class InteractiveMode { } private setupEditorSubmitHandler(): void { - this.editor.onSubmit = async (text: string) => { + this.defaultEditor.onSubmit = async (text: string) => { text = text.trim(); if (!text) return; @@ -1185,7 +1251,7 @@ export class InteractiveMode { this.editor.setText(text); return; } - this.editor.addToHistory(text); + this.editor.addToHistory?.(text); await this.handleBashCommand(command, isExcluded); this.isBashMode = false; this.updateEditorBorderColor(); @@ -1196,7 +1262,7 @@ export class InteractiveMode { // Queue input during compaction (extension commands execute immediately) if (this.session.isCompacting) { if (this.isExtensionCommand(text)) { - this.editor.addToHistory(text); + this.editor.addToHistory?.(text); this.editor.setText(""); await this.session.prompt(text); } else { @@ -1208,7 +1274,7 @@ export class InteractiveMode { // If streaming, use prompt() with steer behavior // This handles extension commands (execute immediately), prompt template expansion, and queueing if (this.session.isStreaming) { - this.editor.addToHistory(text); + this.editor.addToHistory?.(text); this.editor.setText(""); await this.session.prompt(text, { streamingBehavior: "steer" }); this.updatePendingMessagesDisplay(); @@ -1223,7 +1289,7 @@ export class InteractiveMode { if (this.onInputCallback) { this.onInputCallback(text); } - this.editor.addToHistory(text); + this.editor.addToHistory?.(text); }; } @@ -1393,8 +1459,8 @@ export class InteractiveMode { case "auto_compaction_start": { // Keep editor active; submissions are queued during compaction. // Set up escape to abort auto-compaction - this.autoCompactionEscapeHandler = this.editor.onEscape; - this.editor.onEscape = () => { + this.autoCompactionEscapeHandler = this.defaultEditor.onEscape; + this.defaultEditor.onEscape = () => { this.session.abortCompaction(); }; // Show compacting indicator with reason @@ -1414,7 +1480,7 @@ export class InteractiveMode { case "auto_compaction_end": { // Restore escape handler if (this.autoCompactionEscapeHandler) { - this.editor.onEscape = this.autoCompactionEscapeHandler; + this.defaultEditor.onEscape = this.autoCompactionEscapeHandler; this.autoCompactionEscapeHandler = undefined; } // Stop loader @@ -1446,8 +1512,8 @@ export class InteractiveMode { case "auto_retry_start": { // Set up escape to abort retry - this.retryEscapeHandler = this.editor.onEscape; - this.editor.onEscape = () => { + this.retryEscapeHandler = this.defaultEditor.onEscape; + this.defaultEditor.onEscape = () => { this.session.abortRetry(); }; // Show retry indicator @@ -1467,7 +1533,7 @@ export class InteractiveMode { case "auto_retry_end": { // Restore escape handler if (this.retryEscapeHandler) { - this.editor.onEscape = this.retryEscapeHandler; + this.defaultEditor.onEscape = this.retryEscapeHandler; this.retryEscapeHandler = undefined; } // Stop loader @@ -1565,7 +1631,7 @@ export class InteractiveMode { const userComponent = new UserMessageComponent(textContent); this.chatContainer.addChild(userComponent); if (options?.populateHistory) { - this.editor.addToHistory(textContent); + this.editor.addToHistory?.(textContent); } } break; @@ -1734,7 +1800,7 @@ export class InteractiveMode { // Queue input during compaction (extension commands execute immediately) if (this.session.isCompacting) { if (this.isExtensionCommand(text)) { - this.editor.addToHistory(text); + this.editor.addToHistory?.(text); this.editor.setText(""); await this.session.prompt(text); } else { @@ -1746,7 +1812,7 @@ export class InteractiveMode { // Alt+Enter queues a follow-up message (waits until agent finishes) // This handles extension commands (execute immediately), prompt template expansion, and queueing if (this.session.isStreaming) { - this.editor.addToHistory(text); + this.editor.addToHistory?.(text); this.editor.setText(""); await this.session.prompt(text, { streamingBehavior: "followUp" }); this.updatePendingMessagesDisplay(); @@ -1833,7 +1899,7 @@ export class InteractiveMode { return; } - const currentText = this.editor.getExpandedText(); + const currentText = this.editor.getExpandedText?.() ?? this.editor.getText(); const tmpFile = path.join(os.tmpdir(), `pi-editor-${Date.now()}.pi.md`); try { @@ -1934,7 +2000,7 @@ export class InteractiveMode { private queueCompactionMessage(text: string, mode: "steer" | "followUp"): void { this.compactionQueuedMessages.push({ text, mode }); - this.editor.addToHistory(text); + this.editor.addToHistory?.(text); this.editor.setText(""); this.updatePendingMessagesDisplay(); this.showStatus("Queued message for after compaction"); @@ -2253,10 +2319,10 @@ export class InteractiveMode { // Set up escape handler and loader if summarizing let summaryLoader: Loader | undefined; - const originalOnEscape = this.editor.onEscape; + const originalOnEscape = this.defaultEditor.onEscape; if (wantsSummary) { - this.editor.onEscape = () => { + this.defaultEditor.onEscape = () => { this.session.abortBranchSummary(); }; this.chatContainer.addChild(new Spacer(1)); @@ -2298,7 +2364,7 @@ export class InteractiveMode { summaryLoader.stop(); this.statusContainer.clear(); } - this.editor.onEscape = originalOnEscape; + this.defaultEditor.onEscape = originalOnEscape; } }, () => { @@ -2921,8 +2987,8 @@ export class InteractiveMode { this.statusContainer.clear(); // Set up escape handler during compaction - const originalOnEscape = this.editor.onEscape; - this.editor.onEscape = () => { + const originalOnEscape = this.defaultEditor.onEscape; + this.defaultEditor.onEscape = () => { this.session.abortCompaction(); }; @@ -2959,7 +3025,7 @@ export class InteractiveMode { } finally { compactingLoader.stop(); this.statusContainer.clear(); - this.editor.onEscape = originalOnEscape; + this.defaultEditor.onEscape = originalOnEscape; } void this.flushCompactionQueue({ willRetry: false }); } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 928d18f7..4906b695 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -219,6 +219,10 @@ export async function runRpcMode(session: AgentSession): Promise { }); }, + setEditorComponent(): void { + // Custom editor components not supported in RPC mode + }, + get theme() { return theme; }, diff --git a/packages/coding-agent/test/compaction-extensions.test.ts b/packages/coding-agent/test/compaction-extensions.test.ts index c062d806..bf906256 100644 --- a/packages/coding-agent/test/compaction-extensions.test.ts +++ b/packages/coding-agent/test/compaction-extensions.test.ts @@ -137,6 +137,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => { setEditorText: () => {}, getEditorText: () => "", editor: async () => undefined, + setEditorComponent: () => {}, get theme() { return theme; }, diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 2dbedd4d..843ce36a 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- `EditorComponent` interface for custom editor implementations + ## [0.37.8] - 2026-01-07 ### Added diff --git a/packages/tui/src/editor-component.ts b/packages/tui/src/editor-component.ts new file mode 100644 index 00000000..d59d2e29 --- /dev/null +++ b/packages/tui/src/editor-component.ts @@ -0,0 +1,65 @@ +import type { AutocompleteProvider } from "./autocomplete.js"; +import type { Component } from "./tui.js"; + +/** + * Interface for custom editor components. + * + * This allows extensions to provide their own editor implementation + * (e.g., vim mode, emacs mode, custom keybindings) while maintaining + * compatibility with the core application. + */ +export interface EditorComponent extends Component { + // ========================================================================= + // Core text access (required) + // ========================================================================= + + /** Get the current text content */ + getText(): string; + + /** Set the text content */ + setText(text: string): void; + + // ========================================================================= + // Callbacks (required) + // ========================================================================= + + /** Called when user submits (e.g., Enter key) */ + onSubmit?: (text: string) => void; + + /** Called when text changes */ + onChange?: (text: string) => void; + + // ========================================================================= + // History support (optional) + // ========================================================================= + + /** Add text to history for up/down navigation */ + addToHistory?(text: string): void; + + // ========================================================================= + // Advanced text manipulation (optional) + // ========================================================================= + + /** Insert text at current cursor position */ + insertTextAtCursor?(text: string): void; + + /** + * Get text with any markers expanded (e.g., paste markers). + * Falls back to getText() if not implemented. + */ + getExpandedText?(): string; + + // ========================================================================= + // Autocomplete support (optional) + // ========================================================================= + + /** Set the autocomplete provider */ + setAutocompleteProvider?(provider: AutocompleteProvider): void; + + // ========================================================================= + // Appearance (optional) + // ========================================================================= + + /** Border color function */ + borderColor?: (str: string) => string; +} diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 23efc663..3a7944ca 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -20,6 +20,8 @@ export { type SettingItem, SettingsList, type SettingsListTheme } from "./compon export { Spacer } from "./components/spacer.js"; export { Text } from "./components/text.js"; export { TruncatedText } from "./components/truncated-text.js"; +// Editor component interface (for custom editors) +export type { EditorComponent } from "./editor-component.js"; // Keybindings export { DEFAULT_EDITOR_KEYBINDINGS, diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index fdce33d5..a7c086a7 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -332,7 +332,19 @@ export class TUI extends Container { ].join("\n"); fs.mkdirSync(path.dirname(crashLogPath), { recursive: true }); fs.writeFileSync(crashLogPath, crashData); - throw new Error(`Rendered line ${i} exceeds terminal width. Debug log written to ${crashLogPath}`); + + // Clean up terminal state before throwing + this.stop(); + + const errorMsg = [ + `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`, + "", + "This is likely caused by a custom TUI component not truncating its output.", + "Use visibleWidth() to measure and truncateToWidth() to truncate lines.", + "", + `Debug log written to: ${crashLogPath}`, + ].join("\n"); + throw new Error(errorMsg); } buffer += line; }