skills/pi-agent/references/tui.md
Source: https://pi.dev/docs/latest/tui
Extensions and custom tools render custom terminal UI through @earendil-works/pi-tui.
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).
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.
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.
Pass { overlay: true } to render on top of existing content without clearing the screen. overlayOptions controls layout:
width, minWidth, maxHeight (numbers or percentage strings)anchor (9 positions, default "center"), offsetX/offsetY, or row/colmargin (number or { top, right, bottom, left })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.
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.
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.
Use the theme passed into the callback or renderer — never import a global theme. theme.fg(color, text) covers general (text, accent, muted, dim), 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, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg. Text styles: theme.bold, theme.italic, theme.strikethrough.
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.
SelectList framed with DynamicBorder, onSelect/onCancel calling done.BorderedLoader(tui, theme, "Fetching…"), pass loader.signal to the async work, loader.onAbort = () => done(null).SettingsList with getSettingsListTheme().ctx.ui.setStatus("my-ext", text); clear with undefined.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.ctx.ui.setWidget(key, lines | factory, { placement: "aboveEditor" | "belowEditor" }); undefined clears.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.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.render(width) on every line.theme from the callback; type DynamicBorder color params explicitly ((s: string) => theme.fg("accent", s)).tui.requestRender() after state changes in handleInput.invalidate() on custom components and their child trees.SelectList, SettingsList, and BorderedLoader before building primitives.ctx.mode === "tui").PI_TUI_WRITE_LOG=/tmp/tui-ansi.log captures the raw ANSI stream written to stdout.