mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-19 17:04: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
|
|
@ -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.
|
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.
|
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.
|
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
|
- Good: solid choices or improvements
|
||||||
- Bad: concrete issues, regressions, missing tests, or risks
|
- Bad: concrete issues, regressions, missing tests, or risks
|
||||||
- Ugly: subtle or high impact problems
|
- Ugly: subtle or high impact problems
|
||||||
6. Add Questions or Assumptions if anything is unclear.
|
7. Add Questions or Assumptions if anything is unclear.
|
||||||
7. Add Change summary and Tests.
|
8. Add Change summary and Tests.
|
||||||
|
|
||||||
Output format per PR:
|
Output format per PR:
|
||||||
PR: <url>
|
PR: <url>
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,14 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### 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.
|
- 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
|
## [0.37.8] - 2026-01-07
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1170,6 +1170,10 @@ ctx.ui.setTitle("pi - my-project");
|
||||||
// Editor text
|
// Editor text
|
||||||
ctx.ui.setEditorText("Prefill text");
|
ctx.ui.setEditorText("Prefill text");
|
||||||
const current = ctx.ui.getEditorText();
|
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:**
|
**Examples:**
|
||||||
|
|
@ -1177,6 +1181,7 @@ const current = ctx.ui.getEditorText();
|
||||||
- `ctx.ui.setWidget()`: [plan-mode.ts](../examples/extensions/plan-mode.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.setFooter()`: [custom-footer.ts](../examples/extensions/custom-footer.ts)
|
||||||
- `ctx.ui.setHeader()`: [custom-header.ts](../examples/extensions/custom-header.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
|
### Custom Components
|
||||||
|
|
||||||
|
|
@ -1185,7 +1190,7 @@ For complex UI, use `ctx.ui.custom()`. This temporarily replaces the editor with
|
||||||
```typescript
|
```typescript
|
||||||
import { Text, Component } from "@mariozechner/pi-tui";
|
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);
|
const text = new Text("Press Enter to confirm, Escape to cancel", 1, 1);
|
||||||
|
|
||||||
text.onKey = (key) => {
|
text.onKey = (key) => {
|
||||||
|
|
@ -1205,12 +1210,56 @@ if (result) {
|
||||||
The callback receives:
|
The callback receives:
|
||||||
- `tui` - TUI instance (for screen dimensions, focus management)
|
- `tui` - TUI instance (for screen dimensions, focus management)
|
||||||
- `theme` - Current theme for styling
|
- `theme` - Current theme for styling
|
||||||
|
- `keybindings` - App keybinding manager (for checking shortcuts)
|
||||||
- `done(value)` - Call to close component and return value
|
- `done(value)` - Call to close component and return value
|
||||||
|
|
||||||
See [tui.md](tui.md) for the full component API.
|
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)
|
**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
|
### Message Rendering
|
||||||
|
|
||||||
Register a custom renderer for messages with your `customType`:
|
Register a custom renderer for messages with your `customType`:
|
||||||
|
|
|
||||||
|
|
@ -361,7 +361,7 @@ pi.registerCommand("pick", {
|
||||||
{ value: "opt3", label: "Option 3" }, // description is optional
|
{ 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();
|
const container = new Container();
|
||||||
|
|
||||||
// Top border
|
// Top border
|
||||||
|
|
@ -413,7 +413,7 @@ import { BorderedLoader } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
pi.registerCommand("fetch", {
|
pi.registerCommand("fetch", {
|
||||||
handler: async (_args, ctx) => {
|
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...");
|
const loader = new BorderedLoader(tui, theme, "Fetching data...");
|
||||||
loader.onAbort = () => done(null);
|
loader.onAbort = () => done(null);
|
||||||
|
|
||||||
|
|
@ -451,7 +451,7 @@ pi.registerCommand("settings", {
|
||||||
{ id: "color", label: "Color output", currentValue: "on", values: ["on", "off"] },
|
{ 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();
|
const container = new Container();
|
||||||
container.addChild(new Text(theme.fg("accent", theme.bold("Settings")), 1, 1));
|
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)
|
**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
|
## 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)`.
|
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
|
- **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
|
- **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 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
|
- **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
|
- **Custom tool rendering**: [examples/extensions/todo.ts](../examples/extensions/todo.ts) - renderCall and renderResult
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|
||||||
| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence |
|
| `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 |
|
| `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 |
|
| `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
|
### Git Integration
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ export default function (pi: ExtensionAPI) {
|
||||||
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
||||||
|
|
||||||
// Generate the handoff prompt with loader UI
|
// 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...`);
|
const loader = new BorderedLoader(tui, theme, `Generating handoff prompt...`);
|
||||||
loader.onAbort = () => done(null);
|
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",
|
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();
|
const container = new Container();
|
||||||
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ export default function (pi: ExtensionAPI) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run extraction with loader UI
|
// 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}...`);
|
const loader = new BorderedLoader(tui, theme, `Extracting questions using ${ctx.model!.id}...`);
|
||||||
loader.onAbort = () => done(null);
|
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(
|
return new SnakeComponent(
|
||||||
tui,
|
tui,
|
||||||
() => done(undefined),
|
() => done(undefined),
|
||||||
|
|
|
||||||
|
|
@ -291,7 +291,7 @@ export default function (pi: ExtensionAPI) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.ui.custom<void>((_tui, theme, done) => {
|
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
|
||||||
return new TodoListComponent(todos, theme, () => done());
|
return new TodoListComponent(todos, theme, () => done());
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ export default function toolsExtension(pi: ExtensionAPI) {
|
||||||
// Refresh tool list
|
// Refresh tool list
|
||||||
allTools = pi.getAllTools();
|
allTools = pi.getAllTools();
|
||||||
|
|
||||||
await ctx.ui.custom((tui, theme, done) => {
|
await ctx.ui.custom((tui, theme, _kb, done) => {
|
||||||
// Build settings items for each tool
|
// Build settings items for each tool
|
||||||
const items: SettingItem[] = allTools.map((tool) => ({
|
const items: SettingItem[] = allTools.map((tool) => ({
|
||||||
id: tool,
|
id: tool,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ export type {
|
||||||
// Re-exports
|
// Re-exports
|
||||||
AgentToolResult,
|
AgentToolResult,
|
||||||
AgentToolUpdateCallback,
|
AgentToolUpdateCallback,
|
||||||
|
// App keybindings (for custom editors)
|
||||||
|
AppAction,
|
||||||
AppendEntryHandler,
|
AppendEntryHandler,
|
||||||
BashToolResultEvent,
|
BashToolResultEvent,
|
||||||
BeforeAgentStartEvent,
|
BeforeAgentStartEvent,
|
||||||
|
|
@ -42,6 +44,7 @@ export type {
|
||||||
GetAllToolsHandler,
|
GetAllToolsHandler,
|
||||||
GetThinkingLevelHandler,
|
GetThinkingLevelHandler,
|
||||||
GrepToolResultEvent,
|
GrepToolResultEvent,
|
||||||
|
KeybindingsManager,
|
||||||
LoadExtensionsResult,
|
LoadExtensionsResult,
|
||||||
// Loaded Extension
|
// Loaded Extension
|
||||||
LoadedExtension,
|
LoadedExtension,
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ function createNoOpUIContext(): ExtensionUIContext {
|
||||||
setEditorText: () => {},
|
setEditorText: () => {},
|
||||||
getEditorText: () => "",
|
getEditorText: () => "",
|
||||||
editor: async () => undefined,
|
editor: async () => undefined,
|
||||||
|
setEditorComponent: () => {},
|
||||||
get theme() {
|
get theme() {
|
||||||
return theme;
|
return theme;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ const noOpUIContext: ExtensionUIContext = {
|
||||||
setEditorText: () => {},
|
setEditorText: () => {},
|
||||||
getEditorText: () => "",
|
getEditorText: () => "",
|
||||||
editor: async () => undefined,
|
editor: async () => undefined,
|
||||||
|
setEditorComponent: () => {},
|
||||||
get theme() {
|
get theme() {
|
||||||
return theme;
|
return theme;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,13 @@ import type {
|
||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
} from "@mariozechner/pi-agent-core";
|
} from "@mariozechner/pi-agent-core";
|
||||||
import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
|
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 { Static, TSchema } from "@sinclair/typebox";
|
||||||
import type { Theme } from "../../modes/interactive/theme/theme.js";
|
import type { Theme } from "../../modes/interactive/theme/theme.js";
|
||||||
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
|
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
|
||||||
import type { EventBus } from "../event-bus.js";
|
import type { EventBus } from "../event-bus.js";
|
||||||
import type { ExecOptions, ExecResult } from "../exec.js";
|
import type { ExecOptions, ExecResult } from "../exec.js";
|
||||||
|
import type { AppAction, KeybindingsManager } from "../keybindings.js";
|
||||||
import type { CustomMessage } from "../messages.js";
|
import type { CustomMessage } from "../messages.js";
|
||||||
import type { ModelRegistry } from "../model-registry.js";
|
import type { ModelRegistry } from "../model-registry.js";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -41,6 +42,7 @@ import type {
|
||||||
|
|
||||||
export type { ExecOptions, ExecResult } from "../exec.js";
|
export type { ExecOptions, ExecResult } from "../exec.js";
|
||||||
export type { AgentToolResult, AgentToolUpdateCallback };
|
export type { AgentToolResult, AgentToolUpdateCallback };
|
||||||
|
export type { AppAction, KeybindingsManager } from "../keybindings.js";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// UI Context
|
// UI Context
|
||||||
|
|
@ -92,6 +94,7 @@ export interface ExtensionUIContext {
|
||||||
factory: (
|
factory: (
|
||||||
tui: TUI,
|
tui: TUI,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
|
keybindings: KeybindingsManager,
|
||||||
done: (result: T) => void,
|
done: (result: T) => void,
|
||||||
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
|
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
|
||||||
): Promise<T>;
|
): Promise<T>;
|
||||||
|
|
@ -105,6 +108,43 @@ export interface ExtensionUIContext {
|
||||||
/** Show a multi-line editor for text editing. */
|
/** Show a multi-line editor for text editing. */
|
||||||
editor(title: string, prefill?: string): Promise<string | undefined>;
|
editor(title: string, prefill?: string): Promise<string | undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. */
|
/** Get the current theme for styling. */
|
||||||
readonly theme: Theme;
|
readonly theme: Theme;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -531,6 +531,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||||
setEditorText: () => {},
|
setEditorText: () => {},
|
||||||
getEditorText: () => "",
|
getEditorText: () => "",
|
||||||
editor: async () => undefined,
|
editor: async () => undefined,
|
||||||
|
setEditorComponent: () => {},
|
||||||
get theme() {
|
get theme() {
|
||||||
return {} as any;
|
return {} as any;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ export type {
|
||||||
AgentStartEvent,
|
AgentStartEvent,
|
||||||
AgentToolResult,
|
AgentToolResult,
|
||||||
AgentToolUpdateCallback,
|
AgentToolUpdateCallback,
|
||||||
|
AppAction,
|
||||||
BeforeAgentStartEvent,
|
BeforeAgentStartEvent,
|
||||||
ContextEvent,
|
ContextEvent,
|
||||||
ExecOptions,
|
ExecOptions,
|
||||||
|
|
@ -55,6 +56,7 @@ export type {
|
||||||
ExtensionShortcut,
|
ExtensionShortcut,
|
||||||
ExtensionUIContext,
|
ExtensionUIContext,
|
||||||
ExtensionUIDialogOptions,
|
ExtensionUIDialogOptions,
|
||||||
|
KeybindingsManager,
|
||||||
LoadExtensionsResult,
|
LoadExtensionsResult,
|
||||||
LoadedExtension,
|
LoadedExtension,
|
||||||
MessageRenderer,
|
MessageRenderer,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import type { AppAction, KeybindingsManager } from "../../../core/keybindings.js
|
||||||
*/
|
*/
|
||||||
export class CustomEditor extends Editor {
|
export class CustomEditor extends Editor {
|
||||||
private keybindings: KeybindingsManager;
|
private keybindings: KeybindingsManager;
|
||||||
private actionHandlers: Map<AppAction, () => void> = new Map();
|
public actionHandlers: Map<AppAction, () => void> = new Map();
|
||||||
|
|
||||||
// Special handlers that can be dynamically replaced
|
// Special handlers that can be dynamically replaced
|
||||||
public onEscape?: () => void;
|
public onEscape?: () => void;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
import { type AssistantMessage, getOAuthProviders, type Message, type OAuthProvider } from "@mariozechner/pi-ai";
|
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 {
|
import {
|
||||||
CombinedAutocompleteProvider,
|
CombinedAutocompleteProvider,
|
||||||
type Component,
|
type Component,
|
||||||
|
|
@ -96,7 +96,9 @@ export class InteractiveMode {
|
||||||
private chatContainer: Container;
|
private chatContainer: Container;
|
||||||
private pendingMessagesContainer: Container;
|
private pendingMessagesContainer: Container;
|
||||||
private statusContainer: Container;
|
private statusContainer: Container;
|
||||||
private editor: CustomEditor;
|
private defaultEditor: CustomEditor;
|
||||||
|
private editor: EditorComponent;
|
||||||
|
private autocompleteProvider: CombinedAutocompleteProvider | undefined;
|
||||||
private editorContainer: Container;
|
private editorContainer: Container;
|
||||||
private footer: FooterComponent;
|
private footer: FooterComponent;
|
||||||
private keybindings: KeybindingsManager;
|
private keybindings: KeybindingsManager;
|
||||||
|
|
@ -195,9 +197,10 @@ export class InteractiveMode {
|
||||||
this.statusContainer = new Container();
|
this.statusContainer = new Container();
|
||||||
this.widgetContainer = new Container();
|
this.widgetContainer = new Container();
|
||||||
this.keybindings = KeybindingsManager.create();
|
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 = new Container();
|
||||||
this.editorContainer.addChild(this.editor);
|
this.editorContainer.addChild(this.editor as Component);
|
||||||
this.footer = new FooterComponent(session);
|
this.footer = new FooterComponent(session);
|
||||||
this.footer.setAutoCompactEnabled(session.autoCompactionEnabled);
|
this.footer.setAutoCompactEnabled(session.autoCompactionEnabled);
|
||||||
|
|
||||||
|
|
@ -238,12 +241,12 @@ export class InteractiveMode {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Setup autocomplete
|
// Setup autocomplete
|
||||||
const autocompleteProvider = new CombinedAutocompleteProvider(
|
this.autocompleteProvider = new CombinedAutocompleteProvider(
|
||||||
[...slashCommands, ...templateCommands, ...extensionCommands],
|
[...slashCommands, ...templateCommands, ...extensionCommands],
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
fdPath,
|
fdPath,
|
||||||
);
|
);
|
||||||
this.editor.setAutocompleteProvider(autocompleteProvider);
|
this.defaultEditor.setAutocompleteProvider(this.autocompleteProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
|
|
@ -595,8 +598,8 @@ export class InteractiveMode {
|
||||||
hasPendingMessages: () => this.session.pendingMessageCount > 0,
|
hasPendingMessages: () => this.session.pendingMessageCount > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up the extension shortcut handler on the editor
|
// Set up the extension shortcut handler on the default editor
|
||||||
this.editor.onExtensionShortcut = (data: string) => {
|
this.defaultEditor.onExtensionShortcut = (data: string) => {
|
||||||
for (const [shortcutStr, shortcut] of shortcuts) {
|
for (const [shortcutStr, shortcut] of shortcuts) {
|
||||||
// Cast to KeyId - extension shortcuts use the same format
|
// Cast to KeyId - extension shortcuts use the same format
|
||||||
if (matchesKey(data, shortcutStr as KeyId)) {
|
if (matchesKey(data, shortcutStr as KeyId)) {
|
||||||
|
|
@ -753,6 +756,7 @@ export class InteractiveMode {
|
||||||
setEditorText: (text) => this.editor.setText(text),
|
setEditorText: (text) => this.editor.setText(text),
|
||||||
getEditorText: () => this.editor.getText(),
|
getEditorText: () => this.editor.getText(),
|
||||||
editor: (title, prefill) => this.showExtensionEditor(title, prefill),
|
editor: (title, prefill) => this.showExtensionEditor(title, prefill),
|
||||||
|
setEditorComponent: (factory) => this.setCustomEditorComponent(factory),
|
||||||
get theme() {
|
get theme() {
|
||||||
return theme;
|
return theme;
|
||||||
},
|
},
|
||||||
|
|
@ -918,6 +922,65 @@ export class InteractiveMode {
|
||||||
this.ui.requestRender();
|
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<string, unknown>;
|
||||||
|
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<string, () => 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.
|
* Show a notification for extensions.
|
||||||
*/
|
*/
|
||||||
|
|
@ -938,6 +1001,7 @@ export class InteractiveMode {
|
||||||
factory: (
|
factory: (
|
||||||
tui: TUI,
|
tui: TUI,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
|
keybindings: KeybindingsManager,
|
||||||
done: (result: T) => void,
|
done: (result: T) => void,
|
||||||
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
|
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
|
@ -956,7 +1020,7 @@ export class InteractiveMode {
|
||||||
resolve(result);
|
resolve(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
Promise.resolve(factory(this.ui, theme, close)).then((c) => {
|
Promise.resolve(factory(this.ui, theme, this.keybindings, close)).then((c) => {
|
||||||
component = c;
|
component = c;
|
||||||
this.editorContainer.clear();
|
this.editorContainer.clear();
|
||||||
this.editorContainer.addChild(component);
|
this.editorContainer.addChild(component);
|
||||||
|
|
@ -992,7 +1056,9 @@ export class InteractiveMode {
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
private setupKeyHandlers(): void {
|
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) {
|
if (this.loadingAnimation) {
|
||||||
// Abort and restore queued messages to editor
|
// Abort and restore queued messages to editor
|
||||||
const { steering, followUp } = this.session.clearQueue();
|
const { steering, followUp } = this.session.clearQueue();
|
||||||
|
|
@ -1026,22 +1092,22 @@ export class InteractiveMode {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register app action handlers
|
// Register app action handlers
|
||||||
this.editor.onAction("clear", () => this.handleCtrlC());
|
this.defaultEditor.onAction("clear", () => this.handleCtrlC());
|
||||||
this.editor.onCtrlD = () => this.handleCtrlD();
|
this.defaultEditor.onCtrlD = () => this.handleCtrlD();
|
||||||
this.editor.onAction("suspend", () => this.handleCtrlZ());
|
this.defaultEditor.onAction("suspend", () => this.handleCtrlZ());
|
||||||
this.editor.onAction("cycleThinkingLevel", () => this.cycleThinkingLevel());
|
this.defaultEditor.onAction("cycleThinkingLevel", () => this.cycleThinkingLevel());
|
||||||
this.editor.onAction("cycleModelForward", () => this.cycleModel("forward"));
|
this.defaultEditor.onAction("cycleModelForward", () => this.cycleModel("forward"));
|
||||||
this.editor.onAction("cycleModelBackward", () => this.cycleModel("backward"));
|
this.defaultEditor.onAction("cycleModelBackward", () => this.cycleModel("backward"));
|
||||||
|
|
||||||
// Global debug handler on TUI (works regardless of focus)
|
// Global debug handler on TUI (works regardless of focus)
|
||||||
this.ui.onDebug = () => this.handleDebugCommand();
|
this.ui.onDebug = () => this.handleDebugCommand();
|
||||||
this.editor.onAction("selectModel", () => this.showModelSelector());
|
this.defaultEditor.onAction("selectModel", () => this.showModelSelector());
|
||||||
this.editor.onAction("expandTools", () => this.toggleToolOutputExpansion());
|
this.defaultEditor.onAction("expandTools", () => this.toggleToolOutputExpansion());
|
||||||
this.editor.onAction("toggleThinking", () => this.toggleThinkingBlockVisibility());
|
this.defaultEditor.onAction("toggleThinking", () => this.toggleThinkingBlockVisibility());
|
||||||
this.editor.onAction("externalEditor", () => this.openExternalEditor());
|
this.defaultEditor.onAction("externalEditor", () => this.openExternalEditor());
|
||||||
this.editor.onAction("followUp", () => this.handleFollowUp());
|
this.defaultEditor.onAction("followUp", () => this.handleFollowUp());
|
||||||
|
|
||||||
this.editor.onChange = (text: string) => {
|
this.defaultEditor.onChange = (text: string) => {
|
||||||
const wasBashMode = this.isBashMode;
|
const wasBashMode = this.isBashMode;
|
||||||
this.isBashMode = text.trimStart().startsWith("!");
|
this.isBashMode = text.trimStart().startsWith("!");
|
||||||
if (wasBashMode !== this.isBashMode) {
|
if (wasBashMode !== this.isBashMode) {
|
||||||
|
|
@ -1050,7 +1116,7 @@ export class InteractiveMode {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle clipboard image paste (triggered on Ctrl+V)
|
// Handle clipboard image paste (triggered on Ctrl+V)
|
||||||
this.editor.onPasteImage = () => {
|
this.defaultEditor.onPasteImage = () => {
|
||||||
this.handleClipboardImagePaste();
|
this.handleClipboardImagePaste();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1070,7 +1136,7 @@ export class InteractiveMode {
|
||||||
fs.writeFileSync(filePath, Buffer.from(image.bytes));
|
fs.writeFileSync(filePath, Buffer.from(image.bytes));
|
||||||
|
|
||||||
// Insert file path directly
|
// Insert file path directly
|
||||||
this.editor.insertTextAtCursor(filePath);
|
this.editor.insertTextAtCursor?.(filePath);
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore clipboard errors (may not have permission, etc.)
|
// Silently ignore clipboard errors (may not have permission, etc.)
|
||||||
|
|
@ -1078,7 +1144,7 @@ export class InteractiveMode {
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupEditorSubmitHandler(): void {
|
private setupEditorSubmitHandler(): void {
|
||||||
this.editor.onSubmit = async (text: string) => {
|
this.defaultEditor.onSubmit = async (text: string) => {
|
||||||
text = text.trim();
|
text = text.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
|
||||||
|
|
@ -1185,7 +1251,7 @@ export class InteractiveMode {
|
||||||
this.editor.setText(text);
|
this.editor.setText(text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.editor.addToHistory(text);
|
this.editor.addToHistory?.(text);
|
||||||
await this.handleBashCommand(command, isExcluded);
|
await this.handleBashCommand(command, isExcluded);
|
||||||
this.isBashMode = false;
|
this.isBashMode = false;
|
||||||
this.updateEditorBorderColor();
|
this.updateEditorBorderColor();
|
||||||
|
|
@ -1196,7 +1262,7 @@ export class InteractiveMode {
|
||||||
// Queue input during compaction (extension commands execute immediately)
|
// Queue input during compaction (extension commands execute immediately)
|
||||||
if (this.session.isCompacting) {
|
if (this.session.isCompacting) {
|
||||||
if (this.isExtensionCommand(text)) {
|
if (this.isExtensionCommand(text)) {
|
||||||
this.editor.addToHistory(text);
|
this.editor.addToHistory?.(text);
|
||||||
this.editor.setText("");
|
this.editor.setText("");
|
||||||
await this.session.prompt(text);
|
await this.session.prompt(text);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1208,7 +1274,7 @@ export class InteractiveMode {
|
||||||
// If streaming, use prompt() with steer behavior
|
// If streaming, use prompt() with steer behavior
|
||||||
// This handles extension commands (execute immediately), prompt template expansion, and queueing
|
// This handles extension commands (execute immediately), prompt template expansion, and queueing
|
||||||
if (this.session.isStreaming) {
|
if (this.session.isStreaming) {
|
||||||
this.editor.addToHistory(text);
|
this.editor.addToHistory?.(text);
|
||||||
this.editor.setText("");
|
this.editor.setText("");
|
||||||
await this.session.prompt(text, { streamingBehavior: "steer" });
|
await this.session.prompt(text, { streamingBehavior: "steer" });
|
||||||
this.updatePendingMessagesDisplay();
|
this.updatePendingMessagesDisplay();
|
||||||
|
|
@ -1223,7 +1289,7 @@ export class InteractiveMode {
|
||||||
if (this.onInputCallback) {
|
if (this.onInputCallback) {
|
||||||
this.onInputCallback(text);
|
this.onInputCallback(text);
|
||||||
}
|
}
|
||||||
this.editor.addToHistory(text);
|
this.editor.addToHistory?.(text);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1393,8 +1459,8 @@ export class InteractiveMode {
|
||||||
case "auto_compaction_start": {
|
case "auto_compaction_start": {
|
||||||
// Keep editor active; submissions are queued during compaction.
|
// Keep editor active; submissions are queued during compaction.
|
||||||
// Set up escape to abort auto-compaction
|
// Set up escape to abort auto-compaction
|
||||||
this.autoCompactionEscapeHandler = this.editor.onEscape;
|
this.autoCompactionEscapeHandler = this.defaultEditor.onEscape;
|
||||||
this.editor.onEscape = () => {
|
this.defaultEditor.onEscape = () => {
|
||||||
this.session.abortCompaction();
|
this.session.abortCompaction();
|
||||||
};
|
};
|
||||||
// Show compacting indicator with reason
|
// Show compacting indicator with reason
|
||||||
|
|
@ -1414,7 +1480,7 @@ export class InteractiveMode {
|
||||||
case "auto_compaction_end": {
|
case "auto_compaction_end": {
|
||||||
// Restore escape handler
|
// Restore escape handler
|
||||||
if (this.autoCompactionEscapeHandler) {
|
if (this.autoCompactionEscapeHandler) {
|
||||||
this.editor.onEscape = this.autoCompactionEscapeHandler;
|
this.defaultEditor.onEscape = this.autoCompactionEscapeHandler;
|
||||||
this.autoCompactionEscapeHandler = undefined;
|
this.autoCompactionEscapeHandler = undefined;
|
||||||
}
|
}
|
||||||
// Stop loader
|
// Stop loader
|
||||||
|
|
@ -1446,8 +1512,8 @@ export class InteractiveMode {
|
||||||
|
|
||||||
case "auto_retry_start": {
|
case "auto_retry_start": {
|
||||||
// Set up escape to abort retry
|
// Set up escape to abort retry
|
||||||
this.retryEscapeHandler = this.editor.onEscape;
|
this.retryEscapeHandler = this.defaultEditor.onEscape;
|
||||||
this.editor.onEscape = () => {
|
this.defaultEditor.onEscape = () => {
|
||||||
this.session.abortRetry();
|
this.session.abortRetry();
|
||||||
};
|
};
|
||||||
// Show retry indicator
|
// Show retry indicator
|
||||||
|
|
@ -1467,7 +1533,7 @@ export class InteractiveMode {
|
||||||
case "auto_retry_end": {
|
case "auto_retry_end": {
|
||||||
// Restore escape handler
|
// Restore escape handler
|
||||||
if (this.retryEscapeHandler) {
|
if (this.retryEscapeHandler) {
|
||||||
this.editor.onEscape = this.retryEscapeHandler;
|
this.defaultEditor.onEscape = this.retryEscapeHandler;
|
||||||
this.retryEscapeHandler = undefined;
|
this.retryEscapeHandler = undefined;
|
||||||
}
|
}
|
||||||
// Stop loader
|
// Stop loader
|
||||||
|
|
@ -1565,7 +1631,7 @@ export class InteractiveMode {
|
||||||
const userComponent = new UserMessageComponent(textContent);
|
const userComponent = new UserMessageComponent(textContent);
|
||||||
this.chatContainer.addChild(userComponent);
|
this.chatContainer.addChild(userComponent);
|
||||||
if (options?.populateHistory) {
|
if (options?.populateHistory) {
|
||||||
this.editor.addToHistory(textContent);
|
this.editor.addToHistory?.(textContent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -1734,7 +1800,7 @@ export class InteractiveMode {
|
||||||
// Queue input during compaction (extension commands execute immediately)
|
// Queue input during compaction (extension commands execute immediately)
|
||||||
if (this.session.isCompacting) {
|
if (this.session.isCompacting) {
|
||||||
if (this.isExtensionCommand(text)) {
|
if (this.isExtensionCommand(text)) {
|
||||||
this.editor.addToHistory(text);
|
this.editor.addToHistory?.(text);
|
||||||
this.editor.setText("");
|
this.editor.setText("");
|
||||||
await this.session.prompt(text);
|
await this.session.prompt(text);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1746,7 +1812,7 @@ export class InteractiveMode {
|
||||||
// Alt+Enter queues a follow-up message (waits until agent finishes)
|
// Alt+Enter queues a follow-up message (waits until agent finishes)
|
||||||
// This handles extension commands (execute immediately), prompt template expansion, and queueing
|
// This handles extension commands (execute immediately), prompt template expansion, and queueing
|
||||||
if (this.session.isStreaming) {
|
if (this.session.isStreaming) {
|
||||||
this.editor.addToHistory(text);
|
this.editor.addToHistory?.(text);
|
||||||
this.editor.setText("");
|
this.editor.setText("");
|
||||||
await this.session.prompt(text, { streamingBehavior: "followUp" });
|
await this.session.prompt(text, { streamingBehavior: "followUp" });
|
||||||
this.updatePendingMessagesDisplay();
|
this.updatePendingMessagesDisplay();
|
||||||
|
|
@ -1833,7 +1899,7 @@ export class InteractiveMode {
|
||||||
return;
|
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`);
|
const tmpFile = path.join(os.tmpdir(), `pi-editor-${Date.now()}.pi.md`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -1934,7 +2000,7 @@ export class InteractiveMode {
|
||||||
|
|
||||||
private queueCompactionMessage(text: string, mode: "steer" | "followUp"): void {
|
private queueCompactionMessage(text: string, mode: "steer" | "followUp"): void {
|
||||||
this.compactionQueuedMessages.push({ text, mode });
|
this.compactionQueuedMessages.push({ text, mode });
|
||||||
this.editor.addToHistory(text);
|
this.editor.addToHistory?.(text);
|
||||||
this.editor.setText("");
|
this.editor.setText("");
|
||||||
this.updatePendingMessagesDisplay();
|
this.updatePendingMessagesDisplay();
|
||||||
this.showStatus("Queued message for after compaction");
|
this.showStatus("Queued message for after compaction");
|
||||||
|
|
@ -2253,10 +2319,10 @@ export class InteractiveMode {
|
||||||
|
|
||||||
// Set up escape handler and loader if summarizing
|
// Set up escape handler and loader if summarizing
|
||||||
let summaryLoader: Loader | undefined;
|
let summaryLoader: Loader | undefined;
|
||||||
const originalOnEscape = this.editor.onEscape;
|
const originalOnEscape = this.defaultEditor.onEscape;
|
||||||
|
|
||||||
if (wantsSummary) {
|
if (wantsSummary) {
|
||||||
this.editor.onEscape = () => {
|
this.defaultEditor.onEscape = () => {
|
||||||
this.session.abortBranchSummary();
|
this.session.abortBranchSummary();
|
||||||
};
|
};
|
||||||
this.chatContainer.addChild(new Spacer(1));
|
this.chatContainer.addChild(new Spacer(1));
|
||||||
|
|
@ -2298,7 +2364,7 @@ export class InteractiveMode {
|
||||||
summaryLoader.stop();
|
summaryLoader.stop();
|
||||||
this.statusContainer.clear();
|
this.statusContainer.clear();
|
||||||
}
|
}
|
||||||
this.editor.onEscape = originalOnEscape;
|
this.defaultEditor.onEscape = originalOnEscape;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -2921,8 +2987,8 @@ export class InteractiveMode {
|
||||||
this.statusContainer.clear();
|
this.statusContainer.clear();
|
||||||
|
|
||||||
// Set up escape handler during compaction
|
// Set up escape handler during compaction
|
||||||
const originalOnEscape = this.editor.onEscape;
|
const originalOnEscape = this.defaultEditor.onEscape;
|
||||||
this.editor.onEscape = () => {
|
this.defaultEditor.onEscape = () => {
|
||||||
this.session.abortCompaction();
|
this.session.abortCompaction();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2959,7 +3025,7 @@ export class InteractiveMode {
|
||||||
} finally {
|
} finally {
|
||||||
compactingLoader.stop();
|
compactingLoader.stop();
|
||||||
this.statusContainer.clear();
|
this.statusContainer.clear();
|
||||||
this.editor.onEscape = originalOnEscape;
|
this.defaultEditor.onEscape = originalOnEscape;
|
||||||
}
|
}
|
||||||
void this.flushCompactionQueue({ willRetry: false });
|
void this.flushCompactionQueue({ willRetry: false });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,10 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setEditorComponent(): void {
|
||||||
|
// Custom editor components not supported in RPC mode
|
||||||
|
},
|
||||||
|
|
||||||
get theme() {
|
get theme() {
|
||||||
return theme;
|
return theme;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
|
||||||
setEditorText: () => {},
|
setEditorText: () => {},
|
||||||
getEditorText: () => "",
|
getEditorText: () => "",
|
||||||
editor: async () => undefined,
|
editor: async () => undefined,
|
||||||
|
setEditorComponent: () => {},
|
||||||
get theme() {
|
get theme() {
|
||||||
return theme;
|
return theme;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `EditorComponent` interface for custom editor implementations
|
||||||
|
|
||||||
## [0.37.8] - 2026-01-07
|
## [0.37.8] - 2026-01-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
65
packages/tui/src/editor-component.ts
Normal file
65
packages/tui/src/editor-component.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,8 @@ export { type SettingItem, SettingsList, type SettingsListTheme } from "./compon
|
||||||
export { Spacer } from "./components/spacer.js";
|
export { Spacer } from "./components/spacer.js";
|
||||||
export { Text } from "./components/text.js";
|
export { Text } from "./components/text.js";
|
||||||
export { TruncatedText } from "./components/truncated-text.js";
|
export { TruncatedText } from "./components/truncated-text.js";
|
||||||
|
// Editor component interface (for custom editors)
|
||||||
|
export type { EditorComponent } from "./editor-component.js";
|
||||||
// Keybindings
|
// Keybindings
|
||||||
export {
|
export {
|
||||||
DEFAULT_EDITOR_KEYBINDINGS,
|
DEFAULT_EDITOR_KEYBINDINGS,
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,19 @@ export class TUI extends Container {
|
||||||
].join("\n");
|
].join("\n");
|
||||||
fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
|
fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
|
||||||
fs.writeFileSync(crashLogPath, crashData);
|
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;
|
buffer += line;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue