Files
oh-my-pi/docs/tui.md
T
can1357 2e45297c43 docs(docs): moved documentation to root docs directory and updated all references
- 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/.
2026-02-16 18:33:03 +01:00

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`).