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
Usectx.ui.custom() with a component factory. The factory receives done(result), and ctx.ui.custom() resolves with that result when the component finishes:
{ 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():
overlayOptions:
Overlay Focus
A focused visible overlay keeps input ownership across temporary non-overlay UI. If an overlay opens anotherctx.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: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 selectorTheming
Components accept theme objects for styling. InrenderCall/renderResult, use the theme parameter:
theme.fg(color, text)):
Background colors (
theme.bg(color, text)):
selectedBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg
For Markdown, use getMarkdownTheme():
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. UseSelectList from @earendil-works/pi-tui with DynamicBorder for framing.
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.
Pattern 3: Settings/Toggles (SettingsList)
For toggling multiple settings. UseSettingsList from @earendil-works/pi-tui with getSettingsListTheme().
Pattern 4: Persistent Status Indicator
Show status in the footer that persists across renders. Good for mode indicators.Pattern 4b: Working Indicator Customization
Customize the inline Working indicator shown from accepted interactive prompt submission through the active agent turn.∀ 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.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 withscroll: { 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
Pattern 6: Custom Footer
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:
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.- Extend
CustomEditor(not baseEditor) 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 fourthCustomEditorconstructor argument to opt into the editor-border spinner. - Factory pattern:
setEditorComponentreceives a factory function that getstui,theme, andkeybindings - Autocomplete limit: custom editors installed through
setEditorComponent()that exposesetAutocompleteMaxVisible()inherit the activeautocompleteMaxVisiblesetting - Pass
undefinedto restore the default editor:ctx.ui.setEditorComponent(undefined)
Key Rules
-
Always use theme from callback - Don’t import theme directly. Use
themefrom thectx.ui.custom((tui, theme, keybindings, done) => ...)callback. -
Always type DynamicBorder color param - Write
(s: string) => theme.fg("accent", s), not(s) => theme.fg("accent", s). -
Call tui.requestRender() after state changes - In
handleInput, calltui.requestRender()after updating state. -
Return the three-method object - Custom components need
{ render, invalidate, handleInput }. -
Use existing components -
SelectList,SettingsList,BorderedLoadercover 90% of cases. Don’t rebuild them.
Examples
- Selection UI: examples/extensions/preset.ts - SelectList with DynamicBorder framing
- Async with cancel: examples/extensions/qna.ts - BorderedLoader for LLM calls
- Settings toggles: examples/extensions/tools.ts - SettingsList for tool enable/disable
- Status indicators: examples/extensions/plan-mode/index.ts - setStatus and setWidget
- Working indicator: examples/extensions/working-indicator.ts - setWorkingIndicator
- Custom footer: examples/extensions/custom-footer.ts - setFooter with stats
- Custom editor: examples/extensions/modal-editor.ts - Vim-like modal editing
- Snake game: examples/extensions/snake.ts - Full game with keyboard input, game loop
- Custom tool rendering: examples/extensions/todo.ts - renderCall and renderResult