TUI API reference
Component Interface
All components implement:
The installed pi-tui type still permits handlers that return
void; Atomic treats a missing or undefined result as unhandled only for a matching fullscreen viewport key or a mouse event deferred to a focused overlay. Components that mutate state for such an input must return true so the viewport does not apply it a second time.
Omitting handleInput altogether is the same answer as declining: a focused overlay with no handler still lets fullscreen viewport keys and mouse wheel reports reach the transcript, so a notice or progress panel does not freeze scrolling behind it. An asynchronous handler is judged when it settles — only a promise that resolves true consumes the input, while false, undefined, and a rejection all fall through to the viewport. Input that moved focus while such a promise was pending is left to whatever holds focus when it settles.
The TUI appends a full SGR reset and OSC 8 reset at the end of each rendered line. Styles do not carry across lines. If you emit multi-line text with styling, reapply styles per line or use wrapTextWithAnsi() so styles are preserved for each wrapped line.
Bundled MCP tools render their server name in the call header before results arrive. Direct calls use the registered server; gateway calls use the explicit target or an unambiguous match in available metadata or configured prefixes. Unresolved calls still show the tool or operation without guessing a server. This display does not open connections or expose tool arguments.
Focusable Interface (IME Support)
Components that display a text cursor and need IME (Input Method Editor) support should implement theFocusable interface:
Focusable component has focus, TUI:
- Sets
focused = trueon the component - Scans rendered output for
CURSOR_MARKER(a zero-width APC escape sequence) - Positions the hardware terminal cursor at that location
- Shows the hardware cursor only when
showHardwareCursoris enabled
showHardwareCursor, setShowHardwareCursor(true), or ATOMIC_HARDWARE_CURSOR=1. The Editor and Input built-in components already implement this interface.
Container Components with Embedded Inputs
When a container component (dialog, selector, etc.) contains anInput or Editor child, the container must implement Focusable and propagate the focus state to the child. Otherwise, the hardware cursor won’t be positioned correctly for IME input.
Keyboard Input
UsematchesKey() for key detection:
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 fromrender() must not exceed the width parameter.
visibleWidth(str)- Get display width (ignores ANSI codes)truncateToWidth(str, width, ellipsis?)- Truncate with optional ellipsiswrapTextWithAnsi(str, width)- Word wrap preserving ANSI codes
Invalidation and Theme Changes
When the theme changes, the TUI callsinvalidate() 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 (viatheme.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):
The Solution
Components that build content with theme colors must rebuild that content wheninvalidate() is called:
Pattern: Rebuild on Invalidate
For components with complex content:When This Matters
This pattern is needed when:- Pre-baking theme colors - Using
theme.fg()ortheme.bg()to create styled strings stored in child components - Syntax highlighting - Using
highlightCode()which applies theme-based syntax colors - Complex layouts - Building child component trees that embed theme colors
- Using theme callbacks - Passing functions like
(text) => theme.fg("accent", text)that are called during render - Simple containers - Just grouping other components without adding themed content
- Stateless render - Computing themed output fresh in every
render()call (no caching)
Debug logging
SetPI_TUI_WRITE_LOG to capture the raw ANSI stream written to stdout. The
variable is read by the vendored @earendil-works/pi-tui terminal, so it keeps
its upstream name; a directory path writes one tui-<timestamp>-<pid>.log file
per process.
@earendil-works/pi-tui dependency.
Performance
Cache rendered output when possible:invalidate() when state changes, then ctx.ui.requestRender() from the extension context or tui.requestRender() from a ctx.ui.custom() factory to trigger re-render.
Host integration
These runtime contracts moved here from the TUI components guide, which keeps the runnablectx.ui.custom() example and the common interaction patterns.
Host terminal modes from an isolated component
Because the component runs in the engine child — whose stdout is the JSONL transport, not a TTY — writing raw terminal escape sequences toprocess.stdout from render()/handleInput() is a no-op and never reaches the real host terminal. For the host autowrap mode an overlay may need, the factory tui.terminal exposes a typed, allowlisted setter that the host applies to the real TTY over the engine protocol:
process.stdout.
Host-native session picker
Remote-rendered components pay one host⇄child round trip per keypress under engine isolation. For session-style list pickers, thectx.ui.hostSessionPicker(request) capability avoids that entirely: the terminal host mounts the real built-in SessionSelectorComponent and feeds it JSON-safe rows, so arrow-key navigation and search stay host-local and survive extension event-loop stalls. Only semantic events cross the host⇄extension boundary: the extension pushes row updates and errors (and may close() the picker); the host reports selection, cancel, and confirmed Ctrl+D deletes.
Every interactive host implements the same API — non-isolated mode mounts the selector directly in-process (no IPC at all), isolated mode routes it over the engine session-picker protocol channel — so callers never branch on the mode. The member is absent only on non-interactive surfaces (headless RPC, print); fail with an actionable error there instead of degrading to a hand-rolled picker.
/workflow resume picker is built exclusively on this channel.
Host-native input form
Usectx.ui.hostInputForm(request) for structured inline forms whose keyboard handling must remain responsive under interactive-engine isolation. The terminal host mounts and focuses the real form in the bottom editor slot (overlay: false); Tab/Shift+Tab, arrows, text editing, configured keybindings, Enter, Escape, and Ctrl+C are handled entirely in the host process. In isolated mode only the JSON-safe open request and the final submit/cancel event cross the engine boundary. Non-isolated mode mounts the same component directly.
string, text, number, integer, boolean, and select. Initial and returned values are raw strings; the caller owns domain coercion. Every current interactive Atomic host exposes the optional capability, while headless RPC and print surfaces omit it. Keep a legacy fallback only when compatibility with older hosts is required.
The bundled /workflow <name> input picker uses this channel and retains its older custom-editor/ctx.ui.custom() paths only as compatibility fallbacks.