Skip to main content
Atomic can create TUI components. Ask it to build one for your use case.

TUI Components

Extensions and custom tools can render custom TUI components for interactive user interfaces. This page covers the component system and available building blocks. Source: TUI components are provided by Atomic’s installed @earendil-works/pi-tui runtime dependency (node_modules/@earendil-works/pi-tui/dist/).

Component Interface

All components implement:
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.

Focusable Interface (IME Support)

Components that display a text cursor and need IME (Input Method Editor) support should implement the Focusable interface:
When a Focusable component has focus, TUI:
  1. Sets focused = true on the component
  2. Scans rendered output for CURSOR_MARKER (a zero-width APC escape sequence)
  3. Positions the hardware terminal cursor at that location
  4. Shows the hardware cursor only when showHardwareCursor is enabled
The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with 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 an Input 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.
Without this propagation, typing with an IME (Chinese, Japanese, Korean, etc.) will show the candidate window in the wrong position on screen.

Using Components

Use ctx.ui.custom() with a component factory. The factory receives done(result), and ctx.ui.custom() resolves with that result when the component finishes:
Pass { signal } to ctx.ui.custom() when the UI belongs to an abortable operation. If the signal aborts, Atomic dismisses the custom UI and rejects the returned promise with the signal reason. For overlays, use options.onHandle to receive an overlay handle for programmatic visibility control. In Atomic’s default interactive mode, the component instance remains in the isolated engine child. The terminal host caches rendered lines and forwards input asynchronously, so render() and handleInput() must not depend on direct access to host process objects. The remote bridge preserves pi-tui’s key-release contract: release events are filtered unless the child component sets wantsKeyRelease = true, matching a directly mounted component. Return values passed to done() must be JSON-safe.

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 to process.stdout from render()/handleInput() is a no-op and never reaches the real host terminal. For the two host-terminal modes an overlay commonly needs, the factory tui.terminal exposes typed, allowlisted setters that the host applies to the real TTY over the engine protocol:
These are the only terminal controls exposed; arbitrary child bytes are never forwarded to the terminal. The host resets any mode a component enabled when the overlay hides, closes, is disposed, or when the engine child crashes/restarts, so a stranded child can never leave the terminal in mouse-reporting or autowrap-off mode. On non-isolated hosts and test seams the setters are absent, and callers should fall back to writing escape sequences to their own 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, the ctx.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.
The bundled workflows extension’s /workflow resume picker is built exclusively on this channel.

Host-native input form

Use ctx.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.
Field types are 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.

Overlays

Overlays render components on top of existing content without clearing the screen. Pass { overlay: true } to ctx.ui.custom():
For positioning and sizing, use overlayOptions:

Overlay Focus

A focused visible overlay keeps input ownership across temporary non-overlay UI. If an overlay opens another ctx.ui.custom() component without { overlay: true }, that replacement UI receives input while it is active; when it closes, the focused overlay can reclaim input. Use handle.unfocus() when a visible overlay should stop owning input and let TUI fall back to another visible capturing overlay or the previous focus target. Use handle.unfocus({ target }) when a specific component should receive input while the overlay stays visible. Passing { target: null } intentionally leaves no focused component until focus is set again.

Overlay Lifecycle

Overlay components are disposed when closed. Don’t reuse references - create fresh instances:
See overlay-qa-tests.ts for comprehensive examples covering anchors, margins, stacking, responsive visibility, and animation.

Built-in Components

Import from @earendil-works/pi-tui:

Text

Multi-line text with word wrapping.

Box

Container with padding and background color.

Container

Groups child components vertically.

Spacer

Empty vertical space.

Markdown

Renders markdown with syntax highlighting.

Image

Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm, Warp).

Keyboard Input

Use matchesKey() for key detection:
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.
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
Usage in an extension:

Theming

Components accept theme objects for styling. In renderCall/renderResult, use the theme parameter:
Foreground colors (theme.fg(color, text)): Background colors (theme.bg(color, text)): selectedBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg For Markdown, use getMarkdownTheme():
For custom components, define your own theme interface:

Debug logging

Set PI_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.
Atomic vendors TUI components through the installed @earendil-works/pi-tui dependency.

Performance

Cache rendered output when possible:
Call 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.

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):

The Solution

Components that build content with theme colors must rebuild that content when invalidate() is called:

Pattern: Rebuild on Invalidate

For components with complex content:

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 @earendil-works/pi-tui with DynamicBorder for framing.
Examples: preset.ts, 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.
Examples: qna.ts, handoff.ts

Pattern 3: Settings/Toggles (SettingsList)

For toggling multiple settings. Use SettingsList from @earendil-works/pi-tui with getSettingsListTheme().
Examples: tools.ts

Pattern 4: Persistent Status Indicator

Show status in the footer that persists across renders. Good for mode indicators.
Examples: status-line.ts, plan-mode/index.ts, preset.ts

Pattern 4b: Working Indicator Customization

Customize the inline Working indicator shown from accepted interactive prompt submission through the active agent turn.
This affects the normal Working indicator from accepted prompt submission through response streaming. Working appears immediately during attachment and other pre-stream startup, then continues without a visible gap when the agent turn begins. A no-turn result, prompt failure, or turn completion removes it. An accepted manual retry clears stale status from the prior prompt before showing new pre-stream activity. Factual automatic retry and fallback status takes precedence while that transition is active; ordinary Working resumes only when a later Working lifecycle actually starts. With no extension override, Atomic renders the exact one-cell immediately before one of its 453 original randomized whimsical working verbs, selected once per turn. Every agent and SDK turn starts at regular weight with a fresh lifecycle-relative 88ms cadence, then follows a ten-frame, theme-aware dark → accent → bright/bold → accent → dark luminance ramp without changing glyph or geometry. Optional theme tone overrides control any desired phases exactly, including terminal palette indices 0–255; Atomic derives omitted tones from selected-surface, accent, and text roles. Dark, light, custom, and dynamically reloaded themes therefore retain their own palette. Under NO_COLOR, the same cadence remains visible through regular/bold weight without foreground-color escapes. Turn completion and terminal cleanup stop the timer cleanly. Restoring Atomic’s default after an extension override also restarts at the dark regular phase; custom extension frames and intervals remain unchanged and render verbatim. ATOMIC_REDUCED_MOTION=1 shows a static regular accent without an animation timer. The icon and longest message fit standard and 64-column widths. Factual status copy takes precedence. Compaction and retry loaders keep their plain built-in styling. During successful post-tool autocompaction, Atomic temporarily replaces the Working indicator with the compaction loader and restores it before the same stream continues; no additional user input is required. Post-tool autocompaction is more precisely delimited by its own event pair. Pi opens the follow-up turn while the compaction is still unmatched, so the compaction status — not a generic Working message — owns the status surface from compaction_start until compaction_end, and the interposed turn does not take it back early. The status paints as soon as the compaction starts rather than on the next animation frame, in the main chat and in an attached workflow-stage chat alike. Ordinary Working then resumes for the continuing stream on any successful mid-turn completion, including a compaction that found nothing to compact and therefore reports no result. A cancelled or failed compaction stops all activity instead. The main chat reports automatic cancellation; an attached workflow-stage chat clears the transient status because the abort event carries no error text. Failures retain their event-provided error text. Examples: working-indicator.ts

Pattern 5: Widgets Above/Below Editor

Show persistent content above or below the input editor. Good for todo lists, progress.
Examples: plan-mode/index.ts Replace the footer. footerData exposes data not otherwise accessible to extensions.
ctx.ui.getFooterDataProvider() exposes the same read-only provider to embedded extension UIs. In isolated interactive mode Atomic maintains the provider inside the engine session, mirrors every setStatus() update into it, and uses the session cwd with the same cached Git-branch watcher, so synchronous renderers can read current status and branch data without an RPC round trip or per-render Git process. Token stats available via ctx.sessionManager.getBranch() and ctx.model. Examples: 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.
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

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