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:| 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. |
wantsKeyRelease? | If true, component receives key release events (Kitty protocol). Default: false. |
invalidate() | Clear cached render state. Called on theme changes. |
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 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
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.
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. 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.
Overlays
Overlays render components on top of existing content without clearing the screen. Pass{ overlay: true } to ctx.ui.custom():
overlayOptions:
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).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
Creating Custom Components
Example: Interactive selectorTheming
Components accept theme objects for styling. InrenderCall/renderResult, use the theme parameter:
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 |
theme.bg(color, text)):
selectedBg, userMessageBg, customMessageBg, toolPendingBg, toolSuccessBg, toolErrorBg
For Markdown, use getMarkdownTheme():
Debug logging
SetPI_TUI_WRITE_LOG to capture the raw ANSI stream written to stdout.
@earendil-works/pi-tui dependency; this monorepo does not include the upstream TUI test source tree.
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.
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)
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 while Atomic is streaming a response.Pattern 5: Widgets Above/Below Editor
Show persistent content above or below the input editor. Good for todo lists, progress.Pattern 6: Custom Footer
Replace the footer.footerData exposes data not otherwise accessible to extensions.
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 - Factory pattern:
setEditorComponentreceives a factory function that getstui,theme, andkeybindings - 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