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/).

On this page and its reference

This page covers writing your first component and the common interaction patterns. The component and focusable interfaces, host terminal modes, the host-native session picker and input form, keyboard input, line width, invalidation, debug logging, and performance contracts live in the TUI API reference.

Component Interface

Moved to TUI API reference.

Focusable Interface (IME Support)

Moved to TUI API reference.

Container Components with Embedded Inputs

Moved to TUI API reference.

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. Aborting a mounted custom UI dismisses it and releases its input ownership. In-process mode rejects the returned promise with the signal reason; isolated mode resolves it with undefined, like host-side cancellation. For overlays, use options.onHandle to receive an overlay handle for programmatic visibility control. For inspection or navigation, pass { purpose: "navigation" }. Atomic then mounts the component without emitting ui_prompt_start / ui_prompt_end, so a persistent viewer does not falsely mark Herdr as blocked. The default is "prompt"; keep it for approvals and required user decisions. Separate prompts opened while a navigation view is mounted still emit their own lifecycle events. Navigation still owns keyboard focus. In getHostCustomUiState() and its change listener, blockingInlineCustomUiActive counts all inline mounts. When navigation is present, blockingInlineCustomUiNeedsInput distinguishes real pending prompts from navigation-only mounts; when omitted, use blockingInlineCustomUiActive. This distinction is preserved across the isolated-engine bridge. Main-chat inline custom UIs share the editor slot. Completing or canceling an older mount leaves the current one visible; closing the current mount restores the most recently mounted UI that is still pending, including task navigation or an approval. The main editor returns only after the last inline owner closes. A foreground workflow graph keeps focus until you hide or close it, then the surviving inline UI is visible and receives input. Reserving bottom prompts (reserveTranscriptRows, including ask_user_question) also wait out of view while inline navigation or an overlay’s deferInlineCustomUiFocus owns input. Leaving navigation restores the same pending prompt, not a new questionnaire. Escape belongs exclusively to the active navigation view until then; once the questionnaire returns, its ordinary Escape cancellation applies. In the default isolated interactive mode, do not access host process objects from render() or handleInput(). Return JSON-safe values to done(). Return true for consumed viewport keys or overlay mouse events and false to let the transcript handle them. Set wantsKeyRelease = true only when your component needs release events. Non-overlay components leave mouse scrolling and selection with the transcript.

Host terminal modes from an isolated component

Moved to TUI API reference.

Host-native session picker

Moved to TUI API reference.

Host-native input form

Moved to TUI API reference.

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

Moved to TUI API reference.

Line Width

Moved to TUI API reference.

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

Moved to TUI API reference.

Performance

Moved to TUI API reference.

Invalidation and Theme Changes

Moved to TUI API reference.

The Problem

Moved to TUI API reference.

The Solution

Moved to TUI API reference.

Pattern: Rebuild on Invalidate

Moved to TUI API reference.

When This Matters

Moved to TUI API reference.

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. It appears in a standalone status row by default; custom editors may place it in their top border. Newlines and terminal control characters in extension-supplied messages or frames remain stored verbatim, and the standalone row preserves their multi-line rendering. Working appears during prompt startup and response streaming, then disappears on completion, failure, or a no-turn result. Manual retry clears stale status; automatic retry and fallback messages take precedence. The default is a one-cell ∀ pulse beside a message selected once per turn. Theme workingIndicator tones customize its colors. NO_COLOR uses weight changes without foreground colors; ATOMIC_REDUCED_MOTION=1 makes it static. Custom frames and intervals render unchanged. Compaction and retry loaders use their own factual text and the built-in indicator, not extension frames. Successful mid-turn compaction restores Working without another user submission. Cancelled or failed compaction stops activity; errors appear when supplied by the compaction event. Examples: working-indicator.ts

Pattern 5: Widgets Above/Below Editor

Show persistent content above or below the input editor. Good for todo lists, progress.
Hosts may clear extension widgets during a UI reset, and ctx.ui.setWidget(key, undefined) is the same kind of host removal. A reactive extension that needs to keep a long-lived widget registration can observe ctx.ui.onWidgetRelease(key, listener) when available. The listener runs after the host removes that key, so the extension can reset local mount state and re-register on its next refresh. Replacing a widget with another factory is not a release. Ordinary content changes should continue to update the existing component with requestRender() rather than repeatedly calling setWidget().

Scrollable widgets

Opt in with scroll: { maxHeight: 6 } to give a widget its own fullscreen viewport. The host may allocate fewer rows when the editor or other widgets need space. Render the full content, not a pre-clipped slice. Wheel input over the widget scrolls only that widget, including at either end. A one-column scrollbar appears only while content overflows. Scrolling does not take editor focus or register keyboard shortcuts. For a cap that follows terminal resizing, use scroll: { maxHeight: 10, maxHeightFraction: 1 / 3 }. The fraction is applied to live terminal rows, rounded down with a one-row minimum, then limited by maxHeight. Actual allocation may still be zero when other dock content uses all available rows. Omitting the fraction retains the fixed cap. The same options work for isolated-engine widgets.
getScrollRequest and onScroll are optional. Keep the request version unchanged during ordinary updates so they do not reset wheel scrolling. New positions are clamped to the actual viewport. Requests with older versions are ignored. Unmounting starts a fresh viewport. Keep item identity and insertion/deletion anchors in your extension; positions here count rendered rows. installReactiveWidget accepts the same scroll, getScrollRequest, and onScroll options. Existing widgets remain unscrolled unless they opt in. Factory widgets work in both in-process and isolated-engine sessions. Native wheel input requires the fullscreen renderer and a terminal that forwards mouse events; the guarded main-screen fallback does not provide a native viewport. No Option/Alt key setup is required for wheel input. Examples: plan-mode/index.ts Replace the footer. footerData exposes data not otherwise accessible to extensions.
ctx.ui.getFooterDataProvider() exposes the same read-only status and Git-branch provider to embedded extension UIs, including isolated interactive sessions. For a workflow-stage session with a different cwd, subscribe with that cwd to retain its branch watcher, and read the live branch during each render using the same raw cwd string:
The returned unsubscribe function belongs to the viewer: call it on disposal or before replacing the viewer’s cwd, then subscribe for the new cwd. Embedded UIs using ctx.ui.getFooterDataProvider() must likewise release their own subscription. Active viewers using the same raw cwd share the cached provider and watcher; the last unsubscribe releases that alternate-cwd resource. Do not call the parent provider’s dispose() from an individual viewer. An unleased getGitBranch(stageCwd) lookup is transient: it does not retain an alternate-cwd cache or watcher, so an unscoped callback alone does not enable stage-branch updates. Scoped subscriptions retain resources, but notifications still reach the same branch-change listeners; they are not filtered by cwd. Omitting the cwd argument retains the provider’s own cwd behavior shown in the default recipe above. 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
  • Working status: editors keep the standalone working row by default. Pass { embedWorkingStatus: true } as the fourth CustomEditor constructor argument to opt into the editor-border spinner.
  • Factory pattern: setEditorComponent receives a factory function that gets tui, theme, and keybindings
  • Autocomplete limit: custom editors installed through setEditorComponent() that expose setAutocompleteMaxVisible() inherit the active autocompleteMaxVisible setting
  • 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