Goblin-mode opentui-operative
OpenTUI terminal UI library reference. Use when working with @opentui/core, terminal UIs, renderables, Yoga layouts, or Zig-native rendering.
git clone https://github.com/JasonWarrenUK/goblin-mode
T=$(mktemp -d) && git clone --depth=1 https://github.com/JasonWarrenUK/goblin-mode "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/opentui-operative" ~/.claude/skills/jasonwarrenuk-goblin-mode-opentui-operative && rm -rf "$T"
skills/opentui-operative/SKILL.mdOpenTUI Operative
Comprehensive reference for building terminal UIs with OpenTUI (@opentui/core). Source: https://opentui.com/docs/ — all 29 documentation pages.
Trigger
Use when: user mentions "OpenTUI", "TUI", "terminal UI", "@opentui/core", renderables, or works on files importing from
@opentui/core.
Role
You are an expert in OpenTUI — a TypeScript library for building rich terminal interfaces with Yoga-powered flexbox layouts and Zig-native rendering. You know every API surface, every gotcha, and every pattern. You write correct OpenTUI code on the first attempt.
1. Quick Start
Requires Bun.
bun add @opentui/core
import { createCliRenderer, Text } from "@opentui/core" const renderer = await createCliRenderer({ exitOnCtrlC: true }) renderer.root.add(Text({ content: "Hello, OpenTUI!", fg: "#00FF00" }))
Run with
bun index.ts. Press Ctrl+C to exit.
2. Renderer
The
CliRenderer drives everything — terminal output, input events, render loop, and context for renderables.
Creation
import { createCliRenderer } from "@opentui/core" const renderer = await createCliRenderer({ exitOnCtrlC: true, // default: true targetFps: 30, // default: 30 })
The factory:
- Loads the native Zig rendering library
- Configures terminal (mouse, keyboard protocol, alternate screen)
- Returns an initialised
CliRenderer
Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
| | | Destroy renderer on Ctrl+C |
| | — | Signals that trigger cleanup |
| | | Target FPS for render loop |
| | | Max FPS for immediate re-renders |
| | | Enable mouse input/tracking |
| | | Focus nearest focusable on left click |
| | | Track mouse movement (not just clicks) |
| | | Use terminal alternate screen buffer |
| | — | Built-in console overlay options |
| | | Auto-open console on errors (dev only) |
| | — | Callback on renderer destruction |
Key Properties
| Property | Type | Description |
|---|---|---|
| | Root of the component tree (fills terminal) |
| | Current width in columns |
| | Current height in rows |
| | Built-in console overlay |
| | Keyboard input handler |
| | Whether render loop is active |
| | Whether renderer is destroyed |
| | Currently focused component |
Render Loop Control
Automatic mode (default) — re-renders only when the component tree changes:
const renderer = await createCliRenderer() renderer.root.add(Text({ content: "Static content" })) // No start() needed — renders automatically on tree changes
Continuous mode — runs at targetFps:
renderer.start() // Begin continuous rendering renderer.stop() // Stop continuous rendering
Live rendering — for animations:
renderer.requestLive() // Request continuous rendering renderer.dropLive() // Drop live rendering request
Pause/Suspend:
renderer.pause() renderer.suspend() renderer.resume()
Events
renderer.on("resize", (width, height) => { /* terminal resized */ }) renderer.on("destroy", () => { /* renderer destroyed */ }) renderer.on("selection", (selection) => { /* text selected */ })
Cursor Control
renderer.setCursorPosition(10, 5, true) renderer.setCursorStyle("block", true) // block | underline | line renderer.setCursorColor(RGBA.fromHex("#FF0000"))
Cleanup
renderer.destroy()
CRITICAL: Always call
destroy() when finished. This restores terminal state (mouse tracking, raw mode, alternate screen). OpenTUI does NOT automatically clean up on process.exit or unhandled errors.
Debug Overlay
renderer.toggleDebugOverlay() import { DebugOverlayCorner } from "@opentui/core" renderer.configureDebugOverlay({ enabled: true, corner: DebugOverlayCorner.topRight })
3. Renderables (Imperative API)
Renderables are the building blocks of the UI. Each represents a visual element using Yoga layout engine for positioning.
Creating Renderables
import { TextRenderable, BoxRenderable } from "@opentui/core" // Constructor: new XxxRenderable(ctx: RenderContext, options) // ctx IS the renderer itself (or any object implementing RenderContext) const greeting = new TextRenderable(renderer, { id: "greeting", content: "Hello!", fg: "#00FF00", }) renderer.root.add(greeting)
Available Renderables
| Class | Description |
|---|---|
| Container with border, background, and layout |
| Read-only styled text display |
| Single-line text input |
| Multi-line editable text |
| Dropdown/list selection |
| Horizontal tab selection |
| Scrollable container |
| Standalone scroll bar control |
| Syntax-highlighted code display |
| Line number gutter |
| Unified or split diff viewer |
| ASCII art font display |
| Raw framebuffer for custom graphics |
| Markdown renderer |
| Numeric slider control |
The Renderable Tree
const container = new BoxRenderable(renderer, { id: "container", flexDirection: "column", padding: 1, }) const title = new TextRenderable(renderer, { id: "title", content: "My App" }) const body = new TextRenderable(renderer, { id: "body", content: "Content" }) container.add(title) container.add(body) renderer.root.add(container) // Remove a child — MUST use string ID, not the renderable instance container.remove("body")
CRITICAL: remove() API
— the ONLY signature. Always pass a string ID.remove(id: string): void
// CORRECT container.remove("body") container.remove(child.id) // .id returns the auto-generated or explicit ID // WRONG — will fail at runtime container.remove(child) // passes object, not string
Every renderable gets an auto-generated
.id from a static counter. If you set id in options, that becomes the ID. Otherwise it's auto-generated. Access via renderable.id.
Finding Renderables
const title = container.getRenderable("title") // Direct child by ID const deep = container.findDescendantById("nested-input") // Recursive search const children = container.getChildren() // All children
Visibility
panel.visible = false // Hides AND removes from layout (like CSS display: none) panel.visible = true
Opacity
panel.opacity = 0.5 // Affects renderable and all children
Z-Index
const overlay = new BoxRenderable(renderer, { position: "absolute", zIndex: 100, // Higher values render on top })
Translation (Visual Offset)
renderable.translateX = 10 renderable.translateY = -5 // Moves visually without affecting layout
Destroying Renderables
renderable.destroy() // Remove from parent, free resources container.destroyRecursively() // Destroy self and all children
Lifecycle Methods (Custom Renderables)
class CustomRenderable extends Renderable { onUpdate(deltaTime: number) { /* called each frame before render */ } onResize(width: number, height: number) { /* dimensions changed */ } onRemove() { /* removed from parent — cleanup here */ } renderSelf(buffer: OptimizedBuffer, deltaTime: number) { /* custom drawing */ } }
Live Rendering
const box = new AnimatedBox(renderer, { live: true, // Enable continuous rendering for this renderable })
Buffered Rendering
const complex = new BoxRenderable(renderer, { buffered: true, // Render to offscreen buffer first renderAfter: (buffer) => { buffer.fillRect(0, 0, 10, 5, RGBA.fromHex("#FF0000")) }, })
4. Constructs (Declarative API)
Factory functions that create VNodes — lightweight descriptions of components. VNodes become actual Renderables when added to the tree.
import { Box, Text, Input } from "@opentui/core" Box( { width: 40, height: 10, borderStyle: "rounded", padding: 1 }, Text({ content: "Welcome!" }), Input({ placeholder: "Enter your name..." }), )
Available Constructs
ASCIIFont, Box, Code, FrameBuffer, Input, ScrollBox, Select, TabSelect, Text, SyntaxStyle
NOT yet available as constructs (use Renderable API):
Textarea, ScrollBar, Slider, Markdown, LineNumber, Diff
Method Chaining on VNodes
VNodes queue method calls — applied after the component is created:
const input = Input({ placeholder: "Name..." }) input.focus() // Queued, applied when added to tree
Delegation
Routes method/property calls to descendant IDs:
import { delegate } from "@opentui/core" function LabeledInput(props) { return delegate( { focus: `${props.id}-input` }, // focus() routes to child input Box( { flexDirection: "row" }, Text({ content: props.label }), Input({ id: `${props.id}-input`, placeholder: props.placeholder }), ), ) } const field = LabeledInput({ id: "name", label: "Name:", placeholder: "..." }) field.focus() // Delegates to the inner input
Mixing Renderables and Constructs
const container = new BoxRenderable(renderer, { id: "root", flexDirection: "column" }) container.add(Text({ content: "Title" }), Input({ placeholder: "Type here..." })) renderer.root.add(container)
5. Layout (Yoga Flexbox)
Flex Direction
{ flexDirection: "column" } // vertical (default) { flexDirection: "row" } // horizontal { flexDirection: "row-reverse" } { flexDirection: "column-reverse" }
Justify Content (Main Axis)
flex-start | flex-end | center | space-between | space-around | space-evenly
Align Items (Cross Axis)
flex-start | flex-end | center | stretch (default) | baseline
Sizing
{ width: 30, height: 10 } // Fixed (characters/rows) { width: "100%", height: "50%" } // Percentage { flexGrow: 1, flexShrink: 0 } // Flex behaviour { minWidth: 20, maxHeight: 30 } // Constraints
Positioning
{ position: "relative" } // default — flows in layout { position: "absolute", left: 10, top: 5 } // removed from flow
Spacing
{ padding: 2 } // All sides { paddingTop: 1, paddingX: 4 } // Specific sides/axes { margin: 1 } // Same pattern
Gap
{ gap: 1 } // Space between children
6. Component Reference
BoxRenderable
Container with borders, backgrounds, and layout.
new BoxRenderable(renderer, { id: "panel", width: 30, height: 10, backgroundColor: "#333366", borderStyle: "rounded", // single | double | rounded | heavy borderColor: "#FFFFFF", border: true, // must be true for border to show title: "Panel Title", titleAlignment: "center", // left | center | right padding: 1, gap: 1, flexDirection: "column", justifyContent: "center", alignItems: "flex-start", flexGrow: 1, })
Mouse events:
onMouseDown, onMouseOver, onMouseOut, onMouseUp, onMouseMove, onMouseDrag, onMouseDragEnd, onMouseDrop, onMouseScroll, onMouse (catch-all).
Mouse events bubble up. Stop with
event.stopPropagation().
TextRenderable
Read-only styled text.
new TextRenderable(renderer, { content: "Hello!", // string or StyledText fg: "#00FF00", // string | RGBA bg: "#000000", attributes: TextAttributes.BOLD | TextAttributes.UNDERLINE, selectable: true, })
Text attributes (combine with bitwise OR):
BOLD, DIM, ITALIC, UNDERLINE, BLINK, INVERSE, HIDDEN, STRIKETHROUGH
Template literals:
import { t, bold, fg } from "@opentui/core" text.content = t`${bold("Hello")} ${fg("#FF0000", "world")}!`
Helpers:
bold, dim, italic, underline, blink, reverse, strikethrough, fg, bg
GOTCHA:
TextRenderable.content returns a StyledText object, not a plain string. To read the raw text: text.content.chunks[0].text.
SelectRenderable
Vertical list for choosing options.
import { SelectRenderable, SelectRenderableEvents } from "@opentui/core" const select = new SelectRenderable(renderer, { options: [ { name: "Option 1", description: "First option", value: "one" }, { name: "Option 2", description: "Second option", value: "two" }, ], backgroundColor: theme.background, // default is transparent (appears black!) selectedBackgroundColor: theme.highlight, selectedTextColor: theme.text, textColor: theme.text, descriptionColor: theme.textMuted, showDescription: true, showScrollIndicator: true, wrapSelection: false, fastScrollStep: 5, flexGrow: 1, }) renderer.root.add(select) select.focus() // REQUIRED for keyboard input
Keyboard controls:
| Key | Action |
|---|---|
| Up / k | Move selection up |
| Down / j | Move selection down |
| Shift+Up / Shift+Down | Fast scroll (5 items) |
| Enter | Select current item |
Events:
// ITEM_SELECTED: fires on Enter select.on(SelectRenderableEvents.ITEM_SELECTED, (index: number, option: SelectOption) => { console.log(option.value) }) // SELECTION_CHANGED: fires when highlighted item changes select.on(SelectRenderableEvents.SELECTION_CHANGED, (index: number, option: SelectOption) => { console.log("Now highlighting:", option.name) })
SelectOption interface:
interface SelectOption { name: string description: string value?: any }
Programmatic methods:
/getSelectedIndex()getSelectedOption()
/setSelectedIndex(n)
/moveUp()
/moveDown()selectCurrent()- Dynamic updates: set
,options
,showDescription
,showScrollIndicator
as propertieswrapSelection
GOTCHA:
backgroundColor defaults to transparent — set it explicitly or items appear with black backgrounds.
InputRenderable
Single-line text input.
import { InputRenderable, InputRenderableEvents } from "@opentui/core" const input = new InputRenderable(renderer, { width: 25, placeholder: "Enter your name...", value: "", maxLength: 1000, backgroundColor: "#1a1a1a", focusedBackgroundColor: "#222222", textColor: "#FFFFFF", cursorColor: "#00FF88", }) input.focus() input.on(InputRenderableEvents.INPUT, (value) => { /* every keystroke */ }) input.on(InputRenderableEvents.CHANGE, (value) => { /* on blur or Enter, if changed */ }) input.on(InputRenderableEvents.ENTER, () => { /* Enter key pressed */ })
TextareaRenderable
Multi-line editable text. No construct API yet.
import { TextareaRenderable } from "@opentui/core" const textarea = new TextareaRenderable(renderer, { width: 50, height: 6, placeholder: "Type notes here...", wrapMode: "word", // none | char | word backgroundColor: "#1a1a1a", focusedBackgroundColor: "#222222", textColor: "#FFFFFF", cursorColor: "#00FF88", onSubmit: () => { console.log(textarea.plainText) }, onContentChange: () => { /* content changed */ }, onCursorChange: () => { /* cursor moved */ }, keyBindings: [{ name: "return", ctrl: true, action: "submit" }], }) textarea.focus()
Properties:
plainText (string), cursorOffset (number)
TabSelectRenderable
Horizontal tab selection.
import { TabSelectRenderable, TabSelectRenderableEvents } from "@opentui/core" const tabs = new TabSelectRenderable(renderer, { width: 60, options: [ { name: "Tab 1", description: "First tab" }, { name: "Tab 2", description: "Second tab" }, ], tabWidth: 20, showScrollArrows: true, showDescription: true, showUnderline: true, wrapSelection: false, }) tabs.focus() tabs.on(TabSelectRenderableEvents.ITEM_SELECTED, (index, option) => { }) tabs.on(TabSelectRenderableEvents.SELECTION_CHANGED, (index, option) => { })
Keys: Left/
[ = prev, Right/] = next, Enter = select
Methods:
getSelectedIndex(), setSelectedIndex(n), setOptions(array)
ScrollBoxRenderable
Scrollable container.
const scrollbox = new ScrollBoxRenderable(renderer, { width: 60, height: 20, scrollX: false, scrollY: true, // default stickyScroll: false, // "bottom" | "top" | "left" | "right" when truthy viewportCulling: true, // Render only visible children (default) })
Keyboard (when focused): Arrow keys, Page Up/Down, Home, End.
Methods:
— relative scrolling by lines, pixels, or viewportscrollBy()
— absolute positioningscrollTo()
Internal structure:
wrapper, viewport, content, horizontalScrollBar, verticalScrollBar
Sub-component options:
rootOptions, wrapperOptions, viewportOptions, contentOptions, scrollbarOptions
ScrollBarRenderable
Standalone scrollbar. No construct API yet.
const scrollbar = new ScrollBarRenderable(renderer, { orientation: "vertical", // vertical | horizontal height: 10, showArrows: true, trackOptions: { backgroundColor: "#222222", foregroundColor: "#888888" }, onChange: (position) => { console.log(position) }, }) scrollbar.scrollSize = 200 scrollbar.viewportSize = 20 scrollbar.scrollPosition = 0 scrollbar.focus()
Keys: Up/Down or k/j (vertical), Left/Right or h/l (horizontal), PageUp/Down, Home/End
SliderRenderable
Draggable slider. No construct API yet.
const slider = new SliderRenderable(renderer, { orientation: "horizontal", // horizontal | vertical width: 30, height: 1, min: 0, max: 100, value: 25, backgroundColor: "#333", foregroundColor: "#0f0", onChange: (value) => { console.log(value) }, })
ASCIIFontRenderable
ASCII art font display.
new ASCIIFontRenderable(renderer, { text: "Iris", font: "block", // tiny | block | shade | slick | huge | grid | pallet color: "#FFFFFF", // or array for gradient: ["#FF0000", "#0000FF"] backgroundColor: "transparent", selectable: false, })
Both Renderable (
ASCIIFontRenderable) and Construct (ASCIIFont) APIs available.
CodeRenderable
Syntax-highlighted code with Tree-sitter.
import { CodeRenderable, SyntaxStyle, RGBA } from "@opentui/core" const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: RGBA.fromHex("#E6EDF3") }, keyword: { fg: RGBA.fromHex("#FF7B72") }, string: { fg: RGBA.fromHex("#A5D6FF") }, comment: { fg: RGBA.fromHex("#8B949E"), italic: true }, function: { fg: RGBA.fromHex("#D2A8FF") }, }) const code = new CodeRenderable(renderer, { content: "const x = 1;", filetype: "typescript", syntaxStyle, streaming: false, conceal: true, selectable: true, wrapMode: "none", })
Token names:
keyword, string, comment, function, operator, variable, type, number, constant, plus markup.* for markdown.
MarkdownRenderable
Markdown renderer. No construct API yet.
new MarkdownRenderable(renderer, { content: "# Hello\n\nSome **bold** text.", syntaxStyle, conceal: true, // Hide markdown markers streaming: false, // Incremental update optimisation renderNode: (node) => { /* custom rendering per block */ }, })
LineNumberRenderable
Line number gutter. No construct API yet.
const lineNumbers = new LineNumberRenderable(renderer, { target: codeRenderable, // Must implement LineInfoProvider minWidth: 3, paddingRight: 1, fg: "#6b7280", bg: "#161b22", }) lineNumbers.setLineColor(3, "#2b6cb0") lineNumbers.setLineSign(3, { before: ">", beforeColor: "#2b6cb0" })
DiffRenderable
Unified or split diffs. No construct API yet.
new DiffRenderable(renderer, { diff: unifiedDiffString, view: "unified", // unified | split filetype: "typescript", syntaxStyle, showLineNumbers: true, addedBg: "#1a4d1a", removedBg: "#4d1a1a", addedSignColor: "#22c55e", removedSignColor: "#ef4444", })
FrameBufferRenderable
Low-level rendering surface.
new FrameBufferRenderable(renderer, { width: 40, height: 20, respectAlpha: false, })
Drawing methods:
setCell, setCellWithAlphaBlending, drawText, fillRect, drawFrameBuffer
7. Keyboard Input
Global Key Handler
renderer.keyInput.on("keypress", (key: KeyEvent) => { console.log(key.name, key.ctrl, key.shift, key.meta) }) renderer.keyInput.on("paste", (event: PasteEvent) => { console.log(event.text) })
KeyEvent Properties
| Property | Type | Description |
|---|---|---|
| | Key identifier (e.g. "a", "escape", "f1", "return") |
| | Raw escape sequence |
| | Ctrl modifier |
| | Shift modifier |
| | Alt/Meta modifier |
| | macOS Option key |
Event methods:
preventDefault(), stopPropagation()
Per-Renderable Key Handling
new InputRenderable(renderer, { onKeyDown: (key) => { if (key.name === "escape") input.blur() }, onPaste: (event) => { console.log(event.text) }, })
Raw Input Handler
renderer.addInputHandler((sequence) => { if (sequence === "\x1b[A") return true // consumed return false // pass through })
8. Focus Management
input.focus() // Give focus input.blur() // Remove focus console.log(input.focused) // Check state
Auto-focus: Left-clicking a renderable auto-focuses nearest focusable ancestor. Disable globally with
{ autoFocus: false } or per-interaction with event.preventDefault() in onMouseDown.
Events:
import { RenderableEvents } from "@opentui/core" input.on(RenderableEvents.FOCUSED, () => { }) input.on(RenderableEvents.BLURRED, () => { })
Internal key routing:
focus() uses _internalKeyInput.onInternal() — the renderer's internal key handler that ensures global handlers can preventDefault before renderable handlers process events.
9. Colours
RGBA Class
import { RGBA } from "@opentui/core" RGBA.fromInts(255, 0, 0, 255) // From integers (0-255) RGBA.fromValues(0.0, 1.0, 0.0, 1.0) // From normalised floats (0.0-1.0) RGBA.fromHex("#800080") // From hex string RGBA.fromHex("#FF000080") // With alpha
String Colour Support
Components accept: hex strings (
"#FF0000"), CSS colour names ("red"), RGBA objects, "transparent".
parseColor() Utility
import { parseColor } from "@opentui/core" const rgba = parseColor("#FF0000") // Converts various formats to RGBA
Common Constants
RGBA.white, RGBA.black, RGBA.red, RGBA.green, RGBA.blue, RGBA.transparent
Palette advisory: When choosing hex values for OpenTUI components, prefer colours from Reasonable Colors (
). The LCH-based palette is designed for consistent rendering across display types, which matters more in terminal contexts than web. Uselibrary/docs/reasonable-colors-reference.mdwith RC hex values directly.RGBA.fromHex()
10. Console Overlay
OpenTUI captures all
console.log/info/warn/error/debug calls to prevent interference with the UI.
const renderer = await createCliRenderer({ consoleOptions: { position: ConsolePosition.BOTTOM, // TOP | BOTTOM | LEFT | RIGHT sizePercent: 30, }, }) renderer.console.toggle()
Keyboard (when focused): Arrow keys to scroll,
+/- to resize.
Env vars:
— disable captureOTUI_USE_CONSOLE=false
— start visibleSHOW_CONSOLE=true
— output on exitOTUI_DUMP_CAPTURES=true
11. Environment Variables
| Variable | Default | Description |
|---|---|---|
| | Alternate screen buffer |
| | Debug overlay at startup |
| | Debug input capture |
| | Disable native rendering |
| | Dump captured output on exit |
| | Override stdout (debug) |
| | Enable console capture |
| | Show console at startup |
| | Warn on missing syntax styles |
| | Tree-sitter worker path |
| | Debug logging for FFI |
| | Tracing for FFI |
| | Use wcwidth for char widths |
| | Force Mode 2026 Unicode |
| | Disable Kitty graphics detection |
| | No ZWJ width method |
| — | Force explicit width detection |
12. Tree-sitter Integration
Global Registration
import { addDefaultParsers } from "@opentui/core" addDefaultParsers([{ filetype: "python", wasm: "https://github.com/tree-sitter/tree-sitter-python/releases/download/v0.23.6/tree-sitter-python.wasm", queries: { highlights: ["https://raw.githubusercontent.com/.../highlights.scm"], }, }])
Per-Client
const client = new TreeSitterClient({ dataPath: "./parsers" }) client.addFiletypeParser({ filetype, wasm, queries })
Utilities
pathToFiletype("/foo/bar.ts") // "typescript" extToFiletype(".py") // "python"
13. Framework Bindings
Solid.js (@opentui/solid
)
@opentui/solidbun install solid-js @opentui/solid
JSX components (snake_case):
text, box, scrollbox, input, textarea, select, tab_select, code, diff, markdown, ascii_font, line_number
Hooks:
useRenderer(), useKeyboard(), useTerminalDimensions(), onResize(), usePaste(), useSelectionHandler(), useTimeline()
Entry:
render(<App />) or testRender(<App />) for testing.
React (@opentui/react
)
@opentui/reactbun add @opentui/react @opentui/core react
JSX components (kebab-case):
<text>, <box>, <scrollbox>, <input>, <textarea>, <select>, <tab-select>, <code>, <diff>, <markdown>, <ascii-font>, <line-number>
Hooks:
useRenderer(), useKeyboard(), useOnResize(), useTerminalDimensions(), useTimeline()
Entry:
createRoot(renderer).render(<App />)
14. Common Patterns
Screen Pattern (Full-screen Views)
const CONTAINER_ID = "my-screen-root" class MyScreen { private renderer: Renderer private keyHandler?: (key: KeyEvent) => void constructor(renderer: Renderer) { this.renderer = renderer } async render(): Promise<Result> { return new Promise((resolve) => { const container = new BoxRenderable(this.renderer, { id: CONTAINER_ID, flexDirection: "column", width: "100%", height: "100%", backgroundColor: "#1a1a2e", }) // ... build UI tree ... this.renderer.root.add(container) select.focus() this.keyHandler = (key) => { if (key.name === "escape") resolve({ action: "back" }) } this.renderer.keyInput.on("keypress", this.keyHandler) }) } cleanup(): void { if (this.keyHandler) { this.renderer.keyInput.off("keypress", this.keyHandler) } this.renderer.root.remove(CONTAINER_ID) } }
Application Lifecycle
const renderer = await createCliRenderer({ exitOnCtrlC: true }) // Build UI... renderer.root.add(container) // When done: renderer.destroy() // ALWAYS call this
Dynamic Content Updates
// Update text content text.content = "New content" // Triggers re-render automatically // Update select options select.options = newOptions select.setSelectedIndex(0) // Update colours text.fg = "#FF0000" box.backgroundColor = "#333"
Swapping Child Renderables
// Remove old child by ID, add new one if (oldChild) { parent.remove(oldChild.id) } const newChild = new TextRenderable(renderer, { content: "New" }) parent.add(newChild)
15. Testing with Mock Renderer
When unit testing screens/components that use OpenTUI, mock the renderer:
import { vi } from "vitest" function createMockRenderer() { const mockRoot = { add: vi.fn(), remove: vi.fn() } const mockKeyInput = { on: vi.fn(), off: vi.fn(), once: vi.fn(), emit: vi.fn(), removeAllListeners: vi.fn(), } const mockInternalKeyInput = { on: vi.fn(), off: vi.fn(), once: vi.fn(), emit: vi.fn(), onInternal: vi.fn(), offInternal: vi.fn(), removeAllListeners: vi.fn(), } return { root: mockRoot, keyInput: mockKeyInput, _internalKeyInput: mockInternalKeyInput, start: vi.fn(), stop: vi.fn(), requestRender: vi.fn(), width: 80, height: 24, addToHitGrid: vi.fn(), pushHitGridScissorRect: vi.fn(), popHitGridScissorRect: vi.fn(), clearHitGridScissorRects: vi.fn(), setCursorPosition: vi.fn(), setCursorStyle: vi.fn(), setCursorColor: vi.fn(), widthMethod: "wcwidth" as const, capabilities: null, requestLive: vi.fn(), dropLive: vi.fn(), hasSelection: false, getSelection: vi.fn().mockReturnValue(null), requestSelectionUpdate: vi.fn(), currentFocusedRenderable: null, focusRenderable: vi.fn(), registerLifecyclePass: vi.fn(), unregisterLifecyclePass: vi.fn(), getLifecyclePasses: vi.fn().mockReturnValue(new Set()), clearSelection: vi.fn(), startSelection: vi.fn(), updateSelection: vi.fn(), on: vi.fn(), off: vi.fn(), once: vi.fn(), emit: vi.fn(), removeAllListeners: vi.fn(), } }
Key testing patterns:
requiresSelectRenderable.focus()
with_internalKeyInput
/onInternaloffInternal- Screen
returns a Promise that waits for user input — don'trender()
in testsawait
returnsTextRenderable.content
, not string — access viaStyledText.content.chunks[0].text- Call
before testing event handlers that depend on the renderable treebuildUI()
16. Gotchas & Pitfalls
takes a string ID, not a renderable instance —remove()
notparent.remove(child.id)parent.remove(child)
notrenderer.destroy()
—stop()
restores terminal state.destroy()
only stops the render loop.stop()
is default — no manual Ctrl+C handler neededexitOnCtrlC: true- Automatic rendering — no
call needed; re-renders on tree changesrenderer.start()
is required — keyboard input won't work without itSelectRenderable.focus()
defaults to transparent — set explicitly on SelectRenderable or items appear blackbackgroundColor
returnsTextRenderable.content
— not a plain string. Read viaStyledText.chunks[0].text
needed for_internalKeyInput
— mock renderers must include this withfocus()
/onInternaloffInternal- OpenTUI does NOT auto-cleanup —
or unhandled errors won't restore terminal. Always callprocess.exit
.destroy() - Mouse events bubble — stop with
event.stopPropagation()
removes from layout — equivalent to CSSvisible = false
, notdisplay: nonevisibility: hidden