2e45297c43
- Moved documentation files from packages/coding-agent/docs/ to root docs/ directory to flatten the documentation structure. - Updated all internal documentation links to account for the new file locations, adjusting relative paths to maintain correct references across the monorepo. - Updated README.md and issue template configuration to reference documentation at the new root docs/ location instead of packages/coding-agent/docs/.
250 lines
8.4 KiB
Markdown
250 lines
8.4 KiB
Markdown
# TUI integration for extensions and custom tools
|
|
|
|
This document covers the **current** TUI contract used by `packages/coding-agent` and `packages/tui` for extension UI, custom tool UI, and custom renderers.
|
|
|
|
## What this subsystem is
|
|
|
|
The runtime has two layers:
|
|
|
|
- **Rendering engine (`packages/tui`)**: differential terminal renderer, input dispatch, focus, overlays, cursor placement.
|
|
- **Integration layer (`packages/coding-agent`)**: mounts extension/custom-tool components, wires keybindings/theme, and restores editor state.
|
|
|
|
## Runtime behavior by mode
|
|
|
|
| Mode | `ctx.ui.custom(...)` availability | Notes |
|
|
| --- | --- | --- |
|
|
| Interactive TUI | Supported | Component is mounted in the editor area, focused, and must call `done(result)` to resolve. |
|
|
| Background/headless | Not interactive | UI context is no-op (`hasUI === false`). |
|
|
| RPC mode | Not supported | `custom()` returns `Promise<never>` and does not mount TUI components. |
|
|
|
|
If your extension/tool can run in non-interactive mode, guard with `ctx.hasUI` / `pi.hasUI`.
|
|
|
|
## Core component contract (`@oh-my-pi/pi-tui`)
|
|
|
|
`packages/tui/src/tui.ts` defines:
|
|
|
|
```ts
|
|
export interface Component {
|
|
render(width: number): string[];
|
|
handleInput?(data: string): void;
|
|
wantsKeyRelease?: boolean;
|
|
invalidate(): void;
|
|
}
|
|
```
|
|
|
|
`Focusable` is separate:
|
|
|
|
```ts
|
|
export interface Focusable {
|
|
focused: boolean;
|
|
}
|
|
```
|
|
|
|
Cursor behavior uses `CURSOR_MARKER` (not `getCursorPosition`). Focused components emit the marker in rendered text; `TUI` extracts it and positions the hardware cursor.
|
|
|
|
## Rendering constraints (terminal safety)
|
|
|
|
Your `render(width)` output must be terminal-safe:
|
|
|
|
1. **Never exceed `width` on any line**. The renderer throws if a non-image line overflows.
|
|
2. **Measure visual width**, not string length: use `visibleWidth()`.
|
|
3. **Truncate/wrap ANSI-aware text** with `truncateToWidth()` / `wrapTextWithAnsi()`.
|
|
4. **Sanitize tabs/content** from external sources using `replaceTabs()` (and higher-level sanitizers in coding-agent render paths).
|
|
|
|
Minimal pattern:
|
|
|
|
```ts
|
|
import { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";
|
|
|
|
render(width: number): string[] {
|
|
return this.lines.map(line => truncateToWidth(replaceTabs(line), width));
|
|
}
|
|
```
|
|
|
|
## Input handling and keybindings
|
|
|
|
### Raw key matching
|
|
|
|
Use `matchesKey(data, "...")` for navigation keys and combos.
|
|
|
|
### Respect user-configured app keybindings
|
|
|
|
Extension UI factories receive a `KeybindingsManager` (interactive mode) so you can honor mapped actions instead of hardcoding keys:
|
|
|
|
```ts
|
|
if (keybindings.matches(data, "interrupt")) {
|
|
done(undefined);
|
|
return;
|
|
}
|
|
```
|
|
|
|
### Key release/repeat events
|
|
|
|
Key release events are filtered unless your component sets:
|
|
|
|
```ts
|
|
wantsKeyRelease = true;
|
|
```
|
|
|
|
Then use `isKeyRelease()` / `isKeyRepeat()` if needed.
|
|
|
|
## Focus, overlays, and cursor
|
|
|
|
- `TUI.setFocus(component)` routes input to that component.
|
|
- Overlay APIs exist in `TUI` (`showOverlay`, `OverlayHandle`), but extension `ctx.ui.custom` mounting in interactive mode currently replaces the editor component area directly.
|
|
- The `custom(..., options?: { overlay?: boolean })` option exists in extension types; interactive extension mounting currently ignores this option.
|
|
|
|
## Mount points and return contracts
|
|
|
|
## 1) Extension UI (`ExtensionUIContext`)
|
|
|
|
Current signature (`extensibility/extensions/types.ts`):
|
|
|
|
```ts
|
|
custom<T>(
|
|
factory: (
|
|
tui: TUI,
|
|
theme: Theme,
|
|
keybindings: KeybindingsManager,
|
|
done: (result: T) => void,
|
|
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
|
|
options?: { overlay?: boolean },
|
|
): Promise<T>
|
|
```
|
|
|
|
Behavior in interactive mode (`extension-ui-controller.ts`):
|
|
|
|
- Saves editor text.
|
|
- Replaces editor component with your component.
|
|
- Focuses your component.
|
|
- On `done(result)`: calls `component.dispose?.()`, restores editor + text, focuses editor, resolves promise.
|
|
|
|
So `done(...)` is mandatory for completion.
|
|
|
|
## 2) Hook/custom-tool UI context (legacy typing)
|
|
|
|
`HookUIContext.custom` is typed as `(tui, theme, done)` in hook/custom-tool types.
|
|
Underlying interactive implementation calls factories with `(tui, theme, keybindings, done)`. JS consumers can use the extra arg; type-level compatibility still reflects the 3-arg legacy signature.
|
|
|
|
Custom tools typically use the same UI entrypoint via the factory-scoped `pi.ui` object, then return the selected value in normal tool content:
|
|
|
|
```ts
|
|
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
|
if (!pi.hasUI) {
|
|
return { content: [{ type: "text", text: "UI unavailable" }] };
|
|
}
|
|
|
|
const picked = await pi.ui.custom<string | undefined>((tui, theme, done) => {
|
|
const component = new MyPickerComponent(done, signal);
|
|
return component;
|
|
});
|
|
|
|
return { content: [{ type: "text", text: picked ? `Picked: ${picked}` : "Cancelled" }] };
|
|
}
|
|
```
|
|
|
|
|
|
## 3) Custom tool call/result renderers
|
|
|
|
Custom tools and extension tools can return components from:
|
|
|
|
- `renderCall(args, theme)`
|
|
- `renderResult(result, options, theme, args?)`
|
|
|
|
`options` currently includes:
|
|
|
|
- `expanded: boolean`
|
|
- `isPartial: boolean`
|
|
- `spinnerFrame?: number`
|
|
|
|
These renderers are mounted by `ToolExecutionComponent`.
|
|
|
|
## Lifecycle and cancellation
|
|
|
|
- `dispose()` is optional at type level but should be implemented when you own timers, subprocesses, watchers, sockets, or overlays.
|
|
- `done(...)` should be called exactly once from your component flow.
|
|
- For cancellable long-running UI, pair `CancellableLoader` with `AbortSignal` and call `done(...)` from `onAbort`.
|
|
|
|
Example cancellation pattern:
|
|
|
|
```ts
|
|
const loader = new CancellableLoader(tui, theme.fg("accent"), theme.fg("muted"), "Working...");
|
|
loader.onAbort = () => done(undefined);
|
|
void doWork(loader.signal).then(result => done(result));
|
|
return loader;
|
|
```
|
|
|
|
## Realistic custom component example (extension command)
|
|
|
|
```ts
|
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
import { SelectList, matchesKey, replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";
|
|
import { getSelectListTheme, type ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
|
|
class Picker implements Component {
|
|
list: SelectList;
|
|
keybindings: any;
|
|
done: (value: string | undefined) => void;
|
|
|
|
constructor(
|
|
items: Array<{ value: string; label: string }>,
|
|
keybindings: any,
|
|
done: (value: string | undefined) => void,
|
|
) {
|
|
this.list = new SelectList(items, 8, getSelectListTheme());
|
|
this.keybindings = keybindings;
|
|
this.done = done;
|
|
this.list.onSelect = item => this.done(item.value);
|
|
this.list.onCancel = () => this.done(undefined);
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
if (this.keybindings.matches(data, "interrupt")) {
|
|
this.done(undefined);
|
|
return;
|
|
}
|
|
this.list.handleInput(data);
|
|
}
|
|
|
|
render(width: number): string[] {
|
|
return this.list.render(width).map(line => truncateToWidth(replaceTabs(line), width));
|
|
}
|
|
|
|
invalidate(): void {
|
|
this.list.invalidate();
|
|
}
|
|
}
|
|
|
|
export default function extension(pi: ExtensionAPI): void {
|
|
pi.registerCommand("pick-model", {
|
|
description: "Pick a model profile",
|
|
handler: async (_args, ctx) => {
|
|
if (!ctx.hasUI) return;
|
|
|
|
const selected = await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
|
|
const items = [
|
|
{ value: "fast", label: theme.fg("accent", "Fast") },
|
|
{ value: "balanced", label: "Balanced" },
|
|
{ value: "quality", label: "Quality" },
|
|
];
|
|
return new Picker(items, keybindings, done);
|
|
});
|
|
|
|
if (selected) ctx.ui.notify(`Selected profile: ${selected}`, "info");
|
|
},
|
|
});
|
|
}
|
|
```
|
|
|
|
## Key implementation files
|
|
|
|
- `packages/tui/src/tui.ts` — `Component`, `Focusable`, cursor marker, focus, overlay, input dispatch.
|
|
- `packages/tui/src/utils.ts` — width/truncation/sanitization primitives.
|
|
- `packages/tui/src/keys.ts` / `keybindings.ts` — key parsing and configurable action mapping.
|
|
- `packages/coding-agent/src/modes/controllers/extension-ui-controller.ts` — interactive mounting/unmounting for extension/hook/custom-tool UI.
|
|
- `packages/coding-agent/src/extensibility/extensions/types.ts` — extension UI and renderer contracts.
|
|
- `packages/coding-agent/src/extensibility/hooks/types.ts` — hook UI contract (legacy custom signature).
|
|
- `packages/coding-agent/src/extensibility/custom-tools/types.ts` — custom tool execute/render contracts.
|
|
- `packages/coding-agent/src/modes/components/tool-execution.ts` — mounting `renderCall`/`renderResult` components and partial-state options.
|
|
- `packages/coding-agent/src/tools/context.ts` — tool UI context propagation (`hasUI`, `ui`).
|