Skill Tracer
Skills › Scientific Agent Skills › pi-agent › tui.md

tui.md

99 lines · skills/pi-agent/references/tui.md at 5f2bc03

Open on GitHub
1# TUI Components
2
3Source: https://pi.dev/docs/latest/tui
4
5Reviewed against Pi 0.99.2 and the package versions listed in `../SKILL.md` on 2026-09-30.
6
7Extensions and custom tools render custom terminal UI through `@earendil-works/pi-tui`.
8

TUI Components

Source: https://pi.dev/docs/latest/tui

Reviewed against Pi 0.99.2 and the package versions listed in ../SKILL.md on 2026-09-30.

Extensions and custom tools render custom terminal UI through @earendil-works/pi-tui.

Raw lines 1 to 8
9## Component Interface
11```ts
12interface Component {
13 render(width: number): string[];
14 handleInput?(data: string): void;
15 wantsKeyRelease?: boolean; // receive key release events (Kitty protocol)
16 invalidate(): void; // clear cached render state; called on theme changes
18```
20Each line from `render(width)` **must not exceed `width`**. The TUI appends a full SGR reset and OSC 8 reset at the end of every rendered line, so styles do not carry across lines — reapply styles per line or use `wrapTextWithAnsi()`.
22Width helpers: `visibleWidth(str)` (ignores ANSI), `truncateToWidth(str, width, ellipsis?)`, `wrapTextWithAnsi(str, width)`.

Component Interface

interface Component {
  render(width: number): string[];
  handleInput?(data: string): void;
  wantsKeyRelease?: boolean;   // receive key release events (Kitty protocol)
  invalidate(): void;          // clear cached render state; called on theme changes
}

Each line from render(width) **must not exceed width**. The TUI appends a full SGR reset and OSC 8 reset at the end of every rendered line, so styles do not carry across lines — reapply styles per line or use wrapTextWithAnsi().

Width helpers: visibleWidth(str) (ignores ANSI), truncateToWidth(str, width, ellipsis?), wrapTextWithAnsi(str, width).

Raw lines 9 to 23
24## Focusable and IME
26Components that draw a text cursor should implement `Focusable { focused: boolean }`. When focused, the TUI sets `focused = true`, scans output for `CURSOR_MARKER` (a zero-width APC sequence emitted right before the fake cursor), and positions the hardware cursor there. The cursor stays hidden by default; some terminals need it visible for IME candidate windows — enable with `showHardwareCursor`, `setShowHardwareCursor(true)`, or `PI_HARDWARE_CURSOR=1`. The built-in `Editor` and `Input` already implement this.
28Container components (dialogs, selectors) that embed an `Input`/`Editor` must implement `Focusable` and propagate `focused` to the child, or IME candidate windows appear in the wrong place.

Focusable and IME

Components that draw a text cursor should implement Focusable { focused: boolean }. When focused, the TUI sets focused = true, scans output for CURSOR_MARKER (a zero-width APC sequence emitted right before the fake cursor), and positions the hardware cursor there. The cursor stays hidden by default; some terminals need it visible for IME candidate windows — enable with showHardwareCursor, setShowHardwareCursor(true), or PI_HARDWARE_CURSOR=1. The built-in Editor and Input already implement this.

Container components (dialogs, selectors) that embed an Input/Editor must implement Focusable and propagate focused to the child, or IME candidate windows appear in the wrong place.

Raw lines 24 to 29
30## Usage
32```ts
33const result = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) =>
34 new MyComponent({ theme, keybindings, onChange: () => tui.requestRender(), onSelect: done, onCancel: () => done(null) })
35);
36```
38The same call works inside a custom tool's `execute(toolCallId, params, signal, onUpdate, ctx)`. Returning a plain `{ render, invalidate, handleInput }` object is also fine.

Usage

const result = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) =>
  new MyComponent({ theme, keybindings, onChange: () => tui.requestRender(), onSelect: done, onCancel: () => done(null) })
);

The same call works inside a custom tool's execute(toolCallId, params, signal, onUpdate, ctx). Returning a plain { render, invalidate, handleInput } object is also fine.

Raw lines 30 to 39
40## Overlays
42Pass `{ overlay: true }` to render on top of existing content without clearing the screen. `overlayOptions` controls layout:
44- Size: `width`, `minWidth`, `maxHeight` (numbers or percentage strings)
45- Position: `anchor` (9 positions, default `"center"`), `offsetX`/`offsetY`, or `row`/`col`
46- Margins: `margin` (number or `{ top, right, bottom, left }`)
47- Responsive: `visible(termWidth, termHeight)`
49`onHandle: (handle) => …` gives `focus()` (focus and bring to front), `unfocus()` (release input to the normal fallback), `unfocus({ target })` (hand input to a specific component, or `null` for none), `setHidden(true|false)`, and `hide()` (permanent removal).
51A focused visible overlay keeps input ownership across temporary non-overlay UI and can reclaim input when that UI closes. Overlay components are disposed when closed — never reuse a reference; call the show function again to re-show.

Overlays

Pass { overlay: true } to render on top of existing content without clearing the screen. overlayOptions controls layout:

  • Size: width, minWidth, maxHeight (numbers or percentage strings)
  • Position: anchor (9 positions, default "center"), offsetX/offsetY, or row/col
  • Margins: margin (number or { top, right, bottom, left })
  • Responsive: visible(termWidth, termHeight)

onHandle: (handle) => … gives focus() (focus and bring to front), unfocus() (release input to the normal fallback), unfocus({ target }) (hand input to a specific component, or null for none), setHidden(true|false), and hide() (permanent removal).

A focused visible overlay keeps input ownership across temporary non-overlay UI and can reclaim input when that UI closes. Overlay components are disposed when closed — never reuse a reference; call the show function again to re-show.

Raw lines 40 to 52
53## Built-in Components
55Import from `@earendil-works/pi-tui`: `Text` (multi-line with word wrapping, `new Text(content, paddingX, paddingY, bgFn?)`, `setText()`), `Box` (padding + background, `addChild`, `setBgFn`), `Container` (vertical grouping, `addChild`/`removeChild`/`clear`), `Spacer(lines)`, `Markdown(text, paddingX, paddingY, theme)`, `Image(base64, mimeType, theme, { maxWidthCells, maxHeightCells })` (Kitty, iTerm2, Ghostty, WezTerm, Warp), `SelectList(items, visibleCount, theme)` with `SelectItem { value, label, description? }`, `SettingsList(items, visibleCount, theme, onChange, onClose, { enableSearch })` with `SettingItem { id, label, currentValue, values }`, plus `AutocompleteItem` types.
57From `@earendil-works/pi-coding-agent`: `DynamicBorder`, `BorderedLoader` (spinner + abort signal + `onAbort`), `CustomEditor`, `getMarkdownTheme()`, `getSettingsListTheme()`, `keyHint`/`keyText`/`rawKeyHint`, `highlightCode`, `getLanguageFromPath`.

Built-in Components

Import from @earendil-works/pi-tui: Text (multi-line with word wrapping, new Text(content, paddingX, paddingY, bgFn?), setText()), Box (padding + background, addChild, setBgFn), Container (vertical grouping, addChild/removeChild/clear), Spacer(lines), Markdown(text, paddingX, paddingY, theme), Image(base64, mimeType, theme, { maxWidthCells, maxHeightCells }) (Kitty, iTerm2, Ghostty, WezTerm, Warp), SelectList(items, visibleCount, theme) with SelectItem { value, label, description? }, SettingsList(items, visibleCount, theme, onChange, onClose, { enableSearch }) with SettingItem { id, label, currentValue, values }, plus AutocompleteItem types.

From @earendil-works/pi-coding-agent: DynamicBorder, BorderedLoader (spinner + abort signal + onAbort), CustomEditor, getMarkdownTheme(), getSettingsListTheme(), keyHint/keyText/rawKeyHint, highlightCode, getLanguageFromPath.

Raw lines 53 to 58
59## Keyboard Input
61```ts
62import { matchesKey, Key } from "@earendil-works/pi-tui";
64if (matchesKey(data, Key.up)) { /* ... */ }
65if (matchesKey(data, Key.ctrl("c"))) { /* ... */ }
66```
68`Key.enter`, `Key.escape`, `Key.tab`, `Key.space`, `Key.backspace`, `Key.delete`, `Key.home`, `Key.end`, arrows, and modifier helpers `Key.ctrl`, `Key.shift`, `Key.alt`, `Key.ctrlShift`. String literals (`"enter"`, `"ctrl+c"`, `"shift+tab"`) also work.

Keyboard Input

import { matchesKey, Key } from "@earendil-works/pi-tui";

if (matchesKey(data, Key.up)) { /* ... */ }
if (matchesKey(data, Key.ctrl("c"))) { /* ... */ }

Key.enter, Key.escape, Key.tab, Key.space, Key.backspace, Key.delete, Key.home, Key.end, arrows, and modifier helpers Key.ctrl, Key.shift, Key.alt, Key.ctrlShift. String literals ("enter", "ctrl+c", "shift+tab") also work.

Raw lines 59 to 69
70## Theming
72Use the `theme` passed into the callback or renderer — never import a global theme. `theme.fg(color, text)` covers general (`text`, `accent`, `muted`, `dim`, `searchMatchText`), status (`success`, `error`, `warning`), borders (`border`, `borderAccent`, `borderMuted`), messages (`userMessageText`, `customMessageText`, `customMessageLabel`), tools (`toolTitle`, `toolOutput`), diffs (`toolDiffAdded`/`Removed`/`Context`), markdown (`md*`), syntax (`syntax*`), thinking levels (`thinkingOff`…`thinkingMax`), and `bashMode`. `theme.bg(color, text)` covers `selectedBg`, `searchMatchBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg`. Text styles: `theme.bold`, `theme.italic`, `theme.strikethrough`; `theme.style()` composes styles, `theme.colors` exposes resolved colors, and `theme.appearance` reports light/dark.
74Components that pre-bake theme colors into cached strings must rebuild that content in `invalidate()` — clearing a render cache is not enough. This applies to `theme.fg`/`theme.bg` strings stored in child components, `highlightCode()` output, and child trees that embed colors. It is unnecessary when you pass theme callbacks that run at render time, for simple containers, or for stateless render.

Theming

Use the theme passed into the callback or renderer — never import a global theme. theme.fg(color, text) covers general (text, accent, muted, dim, searchMatchText), status (success, error, warning), borders (border, borderAccent, borderMuted), messages (userMessageText, customMessageText, customMessageLabel), tools (toolTitle, toolOutput), diffs (toolDiffAdded/Removed/Context), markdown (md*), syntax (syntax*), thinking levels (thinkingOff…thinkingMax), and bashMode. theme.bg(color, text) covers selectedBg, searchMatchBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg. Text styles: theme.bold, theme.italic, theme.strikethrough; theme.style() composes styles, theme.colors exposes resolved colors, and theme.appearance reports light/dark.

Components that pre-bake theme colors into cached strings must rebuild that content in invalidate() — clearing a render cache is not enough. This applies to theme.fg/theme.bg strings stored in child components, highlightCode() output, and child trees that embed colors. It is unnecessary when you pass theme callbacks that run at render time, for simple containers, or for stateless render.

Raw lines 70 to 75
76## Common Patterns
781. **Selection dialog** — `SelectList` framed with `DynamicBorder`, `onSelect`/`onCancel` calling `done`.
792. **Async with cancel** — `BorderedLoader(tui, theme, "Fetching…")`, pass `loader.signal` to the async work, `loader.onAbort = () => done(null)`.
803. **Settings toggles** — `SettingsList` with `getSettingsListTheme()`.
814. **Persistent status** — `ctx.ui.setStatus("my-ext", text)`; clear with `undefined`.
825. **Working indicator** — `ctx.ui.setWorkingIndicator({ frames, intervalMs })`; `{ frames: [] }` hides it, no argument restores the default spinner. Frames render verbatim, so add colors yourself. Compaction and retry loaders keep built-in styling. `setWorkingMessage()` and `setWorkingVisible()` control the loader row.
836. **Widgets** — `ctx.ui.setWidget(key, lines | factory, { placement: "aboveEditor" | "belowEditor" })`; `undefined` clears.
847. **Custom footer** — `ctx.ui.setFooter((tui, theme, footerData) => ({ render, invalidate, dispose }))`. `footerData.getGitBranch()`, `getExtensionStatuses()`, and `onBranchChange(cb)` expose data extensions cannot otherwise reach; token stats come from `ctx.sessionManager.getBranch()` and `ctx.model`. `setFooter(undefined)` restores the default.
858. **Custom editor** — extend `CustomEditor` (not base `Editor`) so app keybindings still work, call `super.handleInput(data)` for keys you do not handle, and install with `ctx.ui.setEditorComponent((tui, theme, keybindings) => new MyEditor(...))`. Capture `ctx.ui.getEditorComponent()` first to wrap another extension's editor; `setEditorComponent(undefined)` restores the default.

Common Patterns

  1. Selection dialog — SelectList framed with DynamicBorder, onSelect/onCancel calling done.
  2. Async with cancel — BorderedLoader(tui, theme, "Fetching…"), pass loader.signal to the async work, loader.onAbort = () => done(null).
  3. Settings toggles — SettingsList with getSettingsListTheme().
  4. Persistent status — ctx.ui.setStatus("my-ext", text); clear with undefined.
  5. Working indicator — ctx.ui.setWorkingIndicator({ frames, intervalMs }); { frames: [] } hides it, no argument restores the default spinner. Frames render verbatim, so add colors yourself. Compaction and retry loaders keep built-in styling. setWorkingMessage() and setWorkingVisible() control the loader row.
  6. Widgets — ctx.ui.setWidget(key, lines | factory, { placement: "aboveEditor" | "belowEditor" }); undefined clears.
  7. Custom footer — ctx.ui.setFooter((tui, theme, footerData) => ({ render, invalidate, dispose })). footerData.getGitBranch(), getExtensionStatuses(), and onBranchChange(cb) expose data extensions cannot otherwise reach; token stats come from ctx.sessionManager.getBranch() and ctx.model. setFooter(undefined) restores the default.
  8. Custom editor — extend CustomEditor (not base Editor) so app keybindings still work, call super.handleInput(data) for keys you do not handle, and install with ctx.ui.setEditorComponent((tui, theme, keybindings) => new MyEditor(...)). Capture ctx.ui.getEditorComponent() first to wrap another extension's editor; setEditorComponent(undefined) restores the default.
Raw lines 76 to 86
87## Key Rules
891. Respect `render(width)` on every line.
902. Always use the `theme` from the callback; type `DynamicBorder` color params explicitly (`(s: string) => theme.fg("accent", s)`).
913. Call `tui.requestRender()` after state changes in `handleInput`.
924. Implement `invalidate()` on custom components and their child trees.
935. Propagate focus to embedded inputs.
946. Reuse `SelectList`, `SettingsList`, and `BorderedLoader` before building primitives.
957. Guard TUI features in non-TUI modes (`ctx.mode === "tui"`).

Key Rules

  1. Respect render(width) on every line.
  2. Always use the theme from the callback; type DynamicBorder color params explicitly ((s: string) => theme.fg("accent", s)).
  3. Call tui.requestRender() after state changes in handleInput.
  4. Implement invalidate() on custom components and their child trees.
  5. Propagate focus to embedded inputs.
  6. Reuse SelectList, SettingsList, and BorderedLoader before building primitives.
  7. Guard TUI features in non-TUI modes (ctx.mode === "tui").
Raw lines 87 to 96
97## Debug Logging
99`PI_TUI_WRITE_LOG=/tmp/tui-ansi.log` captures the raw ANSI stream written to stdout.

Debug Logging

PI_TUI_WRITE_LOG=/tmp/tui-ansi.log captures the raw ANSI stream written to stdout.

Raw lines 97 to 99