> pi can create TUI components. Ask it to build one for your use case. # TUI Components Hooks and custom tools can render custom TUI components for interactive user interfaces. This page covers the component system and available building blocks. **Source:** [`@mariozechner/pi-tui`](https://github.com/badlogic/pi-mono/tree/main/packages/tui) ## Component Interface All components implement: ```typescript interface Component { render(width: number): string[]; handleInput?(data: string): void; invalidate?(): void; } ``` | Method | Description | |--------|-------------| | `render(width)` | Return array of strings (one per line). Each line **must not exceed `width`**. | | `handleInput?(data)` | Receive keyboard input when component has focus. | | `invalidate?()` | Clear cached render state. | ## Using Components **In hooks** via `ctx.ui.custom()`: ```typescript pi.on("session_start", async (_event, ctx) => { const handle = ctx.ui.custom(myComponent); // handle.requestRender() - trigger re-render // handle.close() - restore normal UI }); ``` **In custom tools** via `pi.ui.custom()`: ```typescript async execute(toolCallId, params, onUpdate, ctx, signal) { const handle = pi.ui.custom(myComponent); // ... handle.close(); } ``` ## Built-in Components Import from `@mariozechner/pi-tui`: ```typescript import { Text, Box, Container, Spacer, Markdown } from "@mariozechner/pi-tui"; ``` ### Text Multi-line text with word wrapping. ```typescript const text = new Text( "Hello World", // content 1, // paddingX (default: 1) 1, // paddingY (default: 1) (s) => bgGray(s) // optional background function ); text.setText("Updated"); ``` ### Box Container with padding and background color. ```typescript const box = new Box( 1, // paddingX 1, // paddingY (s) => bgGray(s) // background function ); box.addChild(new Text("Content", 0, 0)); box.setBgFn((s) => bgBlue(s)); ``` ### Container Groups child components vertically. ```typescript const container = new Container(); container.addChild(component1); container.addChild(component2); container.removeChild(component1); ``` ### Spacer Empty vertical space. ```typescript const spacer = new Spacer(2); // 2 empty lines ``` ### Markdown Renders markdown with syntax highlighting. ```typescript const md = new Markdown( "# Title\n\nSome **bold** text", 1, // paddingX 1, // paddingY theme // MarkdownTheme (see below) ); md.setText("Updated markdown"); ``` ### Image Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm). ```typescript const image = new Image( base64Data, // base64-encoded image "image/png", // MIME type theme, // ImageTheme { maxWidthCells: 80, maxHeightCells: 24 } ); ``` ## Keyboard Input Use `matchesKey()` for key detection: ```typescript import { matchesKey, Key } from "@mariozechner/pi-tui"; handleInput(data: string) { if (matchesKey(data, Key.up)) { this.selectedIndex--; } else if (matchesKey(data, Key.enter)) { this.onSelect?.(this.selectedIndex); } else if (matchesKey(data, Key.escape)) { this.onCancel?.(); } else if (matchesKey(data, Key.ctrl("c"))) { // Ctrl+C } } ``` **Key identifiers** (use `Key.*` for autocomplete, or string literals): - Basic keys: `Key.enter`, `Key.escape`, `Key.tab`, `Key.space`, `Key.backspace`, `Key.delete`, `Key.home`, `Key.end` - Arrow keys: `Key.up`, `Key.down`, `Key.left`, `Key.right` - With modifiers: `Key.ctrl("c")`, `Key.shift("tab")`, `Key.alt("left")`, `Key.ctrlShift("p")` - String format also works: `"enter"`, `"ctrl+c"`, `"shift+tab"`, `"ctrl+shift+p"` ## Line Width **Critical:** Each line from `render()` must not exceed the `width` parameter. ```typescript import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui"; render(width: number): string[] { // Truncate long lines return [truncateToWidth(this.text, width)]; } ``` Utilities: - `visibleWidth(str)` - Get display width (ignores ANSI codes) - `truncateToWidth(str, width, ellipsis?)` - Truncate with optional ellipsis - `wrapTextWithAnsi(str, width)` - Word wrap preserving ANSI codes ## Creating Custom Components Example: Interactive selector ```typescript import { matchesKey, Key, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; class MySelector { private items: string[]; private selected = 0; private cachedWidth?: number; private cachedLines?: string[]; public onSelect?: (item: string) => void; public onCancel?: () => void; constructor(items: string[]) { this.items = items; } handleInput(data: string): void { if (matchesKey(data, Key.up) && this.selected > 0) { this.selected--; this.invalidate(); } else if (matchesKey(data, Key.down) && this.selected < this.items.length - 1) { this.selected++; this.invalidate(); } else if (matchesKey(data, Key.enter)) { this.onSelect?.(this.items[this.selected]); } else if (matchesKey(data, Key.escape)) { this.onCancel?.(); } } render(width: number): string[] { if (this.cachedLines && this.cachedWidth === width) { return this.cachedLines; } this.cachedLines = this.items.map((item, i) => { const prefix = i === this.selected ? "> " : " "; return truncateToWidth(prefix + item, width); }); this.cachedWidth = width; return this.cachedLines; } invalidate(): void { this.cachedWidth = undefined; this.cachedLines = undefined; } } ``` Usage in a hook: ```typescript pi.registerCommand("pick", { description: "Pick an item", handler: async (args, ctx) => { const items = ["Option A", "Option B", "Option C"]; const selector = new MySelector(items); let handle: { close: () => void; requestRender: () => void }; await new Promise((resolve) => { selector.onSelect = (item) => { ctx.ui.notify(`Selected: ${item}`, "info"); handle.close(); resolve(); }; selector.onCancel = () => { handle.close(); resolve(); }; handle = ctx.ui.custom(selector); }); } }); ``` ## Theming Components accept theme objects for styling. **In `renderCall`/`renderResult`**, use the `theme` parameter: ```typescript renderResult(result, options, theme) { // Use theme.fg() for foreground colors return new Text(theme.fg("success", "Done!"), 0, 0); // Use theme.bg() for background colors const styled = theme.bg("toolPendingBg", theme.fg("accent", "text")); } ``` **Foreground colors** (`theme.fg(color, text)`): | Category | Colors | |----------|--------| | General | `text`, `accent`, `muted`, `dim` | | Status | `success`, `error`, `warning` | | Borders | `border`, `borderAccent`, `borderMuted` | | Messages | `userMessageText`, `customMessageText`, `customMessageLabel` | | Tools | `toolTitle`, `toolOutput` | | Diffs | `toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext` | | Markdown | `mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet` | | Syntax | `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation` | | Thinking | `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh` | | Modes | `bashMode` | **Background colors** (`theme.bg(color, text)`): `selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg` **For Markdown**, use `getMarkdownTheme()`: ```typescript import { getMarkdownTheme } from "@mariozechner/pi-coding-agent"; import { Markdown } from "@mariozechner/pi-tui"; renderResult(result, options, theme) { const mdTheme = getMarkdownTheme(); return new Markdown(result.details.markdown, 0, 0, mdTheme); } ``` **For custom components**, define your own theme interface: ```typescript interface MyTheme { selected: (s: string) => string; normal: (s: string) => string; } ``` ## Performance Cache rendered output when possible: ```typescript class CachedComponent { private cachedWidth?: number; private cachedLines?: string[]; render(width: number): string[] { if (this.cachedLines && this.cachedWidth === width) { return this.cachedLines; } // ... compute lines ... this.cachedWidth = width; this.cachedLines = lines; return lines; } invalidate(): void { this.cachedWidth = undefined; this.cachedLines = undefined; } } ``` Call `invalidate()` when state changes, then `handle.requestRender()` to trigger re-render. ## Invalidation and Theme Changes When the theme changes, the TUI calls `invalidate()` on all components to clear their caches. Components must properly implement `invalidate()` to ensure theme changes take effect. ### The Problem If a component pre-bakes theme colors into strings (via `theme.fg()`, `theme.bg()`, etc.) and caches them, the cached strings contain ANSI escape codes from the old theme. Simply clearing the render cache isn't enough if the component stores the themed content separately. **Wrong approach** (theme colors won't update): ```typescript class BadComponent extends Container { private content: Text; constructor(message: string, theme: Theme) { super(); // Pre-baked theme colors stored in Text component this.content = new Text(theme.fg("accent", message), 1, 0); this.addChild(this.content); } // No invalidate override - parent's invalidate only clears // child render caches, not the pre-baked content } ``` ### The Solution Components that build content with theme colors must rebuild that content when `invalidate()` is called: ```typescript class GoodComponent extends Container { private message: string; private content: Text; constructor(message: string) { super(); this.message = message; this.content = new Text("", 1, 0); this.addChild(this.content); this.updateDisplay(); } private updateDisplay(): void { // Rebuild content with current theme this.content.setText(theme.fg("accent", this.message)); } override invalidate(): void { super.invalidate(); // Clear child caches this.updateDisplay(); // Rebuild with new theme } } ``` ### Pattern: Rebuild on Invalidate For components with complex content: ```typescript class ComplexComponent extends Container { private data: SomeData; constructor(data: SomeData) { super(); this.data = data; this.rebuild(); } private rebuild(): void { this.clear(); // Remove all children // Build UI with current theme this.addChild(new Text(theme.fg("accent", theme.bold("Title")), 1, 0)); this.addChild(new Spacer(1)); for (const item of this.data.items) { const color = item.active ? "success" : "muted"; this.addChild(new Text(theme.fg(color, item.label), 1, 0)); } } override invalidate(): void { super.invalidate(); this.rebuild(); } } ``` ### When This Matters This pattern is needed when: 1. **Pre-baking theme colors** - Using `theme.fg()` or `theme.bg()` to create styled strings stored in child components 2. **Syntax highlighting** - Using `highlightCode()` which applies theme-based syntax colors 3. **Complex layouts** - Building child component trees that embed theme colors This pattern is NOT needed when: 1. **Using theme callbacks** - Passing functions like `(text) => theme.fg("accent", text)` that are called during render 2. **Simple containers** - Just grouping other components without adding themed content 3. **Stateless render** - Computing themed output fresh in every `render()` call (no caching) ## Common Patterns These patterns cover the most common UI needs in extensions. **Copy these patterns instead of building from scratch.** ### Pattern 1: Selection Dialog (SelectList) For letting users pick from a list of options. Use `SelectList` from `@mariozechner/pi-tui` with `DynamicBorder` for framing. ```typescript import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { DynamicBorder } from "@mariozechner/pi-coding-agent"; import { Container, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui"; pi.registerCommand("pick", { handler: async (_args, ctx) => { const items: SelectItem[] = [ { value: "opt1", label: "Option 1", description: "First option" }, { value: "opt2", label: "Option 2", description: "Second option" }, { value: "opt3", label: "Option 3" }, // description is optional ]; const result = await ctx.ui.custom((tui, theme, _kb, done) => { const container = new Container(); // Top border container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); // Title container.addChild(new Text(theme.fg("accent", theme.bold("Pick an Option")), 1, 0)); // SelectList with theme const selectList = new SelectList(items, Math.min(items.length, 10), { selectedPrefix: (t) => theme.fg("accent", t), selectedText: (t) => theme.fg("accent", t), description: (t) => theme.fg("muted", t), scrollInfo: (t) => theme.fg("dim", t), noMatch: (t) => theme.fg("warning", t), }); selectList.onSelect = (item) => done(item.value); selectList.onCancel = () => done(null); container.addChild(selectList); // Help text container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0)); // Bottom border container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); return { render: (w) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); }, }; }); if (result) { ctx.ui.notify(`Selected: ${result}`, "info"); } }, }); ``` **Examples:** [preset.ts](../examples/extensions/preset.ts), [tools.ts](../examples/extensions/tools.ts) ### Pattern 2: Async Operation with Cancel (BorderedLoader) For operations that take time and should be cancellable. `BorderedLoader` shows a spinner and handles escape to cancel. ```typescript import { BorderedLoader } from "@mariozechner/pi-coding-agent"; pi.registerCommand("fetch", { handler: async (_args, ctx) => { const result = await ctx.ui.custom((tui, theme, _kb, done) => { const loader = new BorderedLoader(tui, theme, "Fetching data..."); loader.onAbort = () => done(null); // Do async work fetchData(loader.signal) .then((data) => done(data)) .catch(() => done(null)); return loader; }); if (result === null) { ctx.ui.notify("Cancelled", "info"); } else { ctx.ui.setEditorText(result); } }, }); ``` **Examples:** [qna.ts](../examples/extensions/qna.ts), [handoff.ts](../examples/extensions/handoff.ts) ### Pattern 3: Settings/Toggles (SettingsList) For toggling multiple settings. Use `SettingsList` from `@mariozechner/pi-tui` with `getSettingsListTheme()`. ```typescript import { getSettingsListTheme } from "@mariozechner/pi-coding-agent"; import { Container, type SettingItem, SettingsList, Text } from "@mariozechner/pi-tui"; pi.registerCommand("settings", { handler: async (_args, ctx) => { const items: SettingItem[] = [ { id: "verbose", label: "Verbose mode", currentValue: "off", values: ["on", "off"] }, { id: "color", label: "Color output", currentValue: "on", values: ["on", "off"] }, ]; await ctx.ui.custom((_tui, theme, _kb, done) => { const container = new Container(); container.addChild(new Text(theme.fg("accent", theme.bold("Settings")), 1, 1)); const settingsList = new SettingsList( items, Math.min(items.length + 2, 15), getSettingsListTheme(), (id, newValue) => { // Handle value change ctx.ui.notify(`${id} = ${newValue}`, "info"); }, () => done(undefined), // On close ); container.addChild(settingsList); return { render: (w) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data) => settingsList.handleInput?.(data), }; }); }, }); ``` **Examples:** [tools.ts](../examples/extensions/tools.ts) ### Pattern 4: Persistent Status Indicator Show status in the footer that persists across renders. Good for mode indicators. ```typescript // Set status (shown in footer) ctx.ui.setStatus("my-ext", ctx.ui.theme.fg("accent", "● active")); // Clear status ctx.ui.setStatus("my-ext", undefined); ``` **Examples:** [status-line.ts](../examples/extensions/status-line.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts) ### Pattern 5: Widget Above Editor Show persistent content above the input editor. Good for todo lists, progress. ```typescript // Simple string array ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]); // Or with theme ctx.ui.setWidget("my-widget", (_tui, theme) => { const lines = items.map((item, i) => item.done ? theme.fg("success", "✓ ") + theme.fg("muted", item.text) : theme.fg("dim", "○ ") + item.text ); return { render: () => lines, invalidate: () => {}, }; }); // Clear ctx.ui.setWidget("my-widget", undefined); ``` **Examples:** [plan-mode.ts](../examples/extensions/plan-mode.ts) ### Pattern 6: Custom Footer Replace the entire footer with custom content. ```typescript ctx.ui.setFooter((_tui, theme) => ({ render(width: number): string[] { const left = theme.fg("dim", "custom footer"); const right = theme.fg("accent", "status"); const padding = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right))); return [truncateToWidth(left + padding + right, width)]; }, invalidate() {}, })); // Restore default ctx.ui.setFooter(undefined); ``` **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 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)`. 3. **Call tui.requestRender() after state changes** - In `handleInput`, call `tui.requestRender()` after updating state. 4. **Return the three-method object** - Custom components need `{ render, invalidate, handleInput }`. 5. **Use existing components** - `SelectList`, `SettingsList`, `BorderedLoader` cover 90% of cases. Don't rebuild them. ## Examples - **Selection UI**: [examples/extensions/preset.ts](../examples/extensions/preset.ts) - SelectList with DynamicBorder framing - **Async with cancel**: [examples/extensions/qna.ts](../examples/extensions/qna.ts) - BorderedLoader for LLM calls - **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 - **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 - **Custom tool rendering**: [examples/extensions/todo.ts](../examples/extensions/todo.ts) - renderCall and renderResult