Opentui Operative
npx machina-cli add skill JasonWarrenUK/claude-code-config/opentui-operative --openclawOpenTUI 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 |
|---|---|---|---|
exitOnCtrlC | boolean | true | Destroy renderer on Ctrl+C |
exitSignals | NodeJS.Signals[] | — | Signals that trigger cleanup |
targetFps | number | 30 | Target FPS for render loop |
maxFps | number | 60 | Max FPS for immediate re-renders |
useMouse | boolean | true | Enable mouse input/tracking |
autoFocus | boolean | true | Focus nearest focusable on left click |
enableMouseMovement | boolean | true | Track mouse movement (not just clicks) |
useAlternateScreen | boolean | true | Use terminal alternate screen buffer |
consoleOptions | ConsoleOptions | — | Built-in console overlay options |
openConsoleOnError | boolean | true | Auto-open console on errors (dev only) |
onDestroy | () => void | — | Callback on renderer destruction |
Key Properties
| Property | Type | Description |
|---|---|---|
root | RootRenderable | Root of the component tree (fills terminal) |
width | number | Current width in columns |
height | number | Current height in rows |
console | TerminalConsole | Built-in console overlay |
keyInput | KeyHandler | Keyboard input handler |
isRunning | boolean | Whether render loop is active |
isDestroyed | boolean | Whether renderer is destroyed |
currentFocusedRenderable | Renderable | null | 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 |
|---|---|
BoxRenderable | Container with border, background, and layout |
TextRenderable | Read-only styled text display |
InputRenderable | Single-line text input |
TextareaRenderable | Multi-line editable text |
SelectRenderable | Dropdown/list selection |
TabSelectRenderable | Horizontal tab selection |
ScrollBoxRenderable | Scrollable container |
ScrollBarRenderable | Standalone scroll bar control |
CodeRenderable | Syntax-highlighted code display |
LineNumberRenderable | Line number gutter |
DiffRenderable | Unified or split diff viewer |
ASCIIFontRenderable | ASCII art font display |
FrameBufferRenderable | Raw framebuffer for custom graphics |
MarkdownRenderable | Markdown renderer |
SliderRenderable | 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
remove(id: string): void — the ONLY signature. Always pass a string ID.
// 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,wrapSelectionas properties
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:
scrollBy()— relative scrolling by lines, pixels, or viewportscrollTo()— absolute positioning
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 |
|---|---|---|
name | string | Key identifier (e.g. "a", "escape", "f1", "return") |
sequence | string | Raw escape sequence |
ctrl | boolean | Ctrl modifier |
shift | boolean | Shift modifier |
meta | boolean | Alt/Meta modifier |
option | boolean | 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
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:
OTUI_USE_CONSOLE=false— disable captureSHOW_CONSOLE=true— start visibleOTUI_DUMP_CAPTURES=true— output on exit
11. Environment Variables
| Variable | Default | Description |
|---|---|---|
OTUI_USE_ALTERNATE_SCREEN | true | Alternate screen buffer |
OTUI_SHOW_STATS | false | Debug overlay at startup |
OTUI_DEBUG | false | Debug input capture |
OTUI_NO_NATIVE_RENDER | false | Disable native rendering |
OTUI_DUMP_CAPTURES | false | Dump captured output on exit |
OTUI_OVERRIDE_STDOUT | true | Override stdout (debug) |
OTUI_USE_CONSOLE | true | Enable console capture |
SHOW_CONSOLE | false | Show console at startup |
OTUI_TS_STYLE_WARN | false | Warn on missing syntax styles |
OTUI_TREE_SITTER_WORKER_PATH | "" | Tree-sitter worker path |
OTUI_DEBUG_FFI | false | Debug logging for FFI |
OTUI_TRACE_FFI | false | Tracing for FFI |
OPENTUI_FORCE_WCWIDTH | false | Use wcwidth for char widths |
OPENTUI_FORCE_UNICODE | false | Force Mode 2026 Unicode |
OPENTUI_NO_GRAPHICS | false | Disable Kitty graphics detection |
OPENTUI_FORCE_NOZWJ | false | No ZWJ width method |
OPENTUI_FORCE_EXPLICIT_WIDTH | — | 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)
bun 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)
bun 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:
SelectRenderable.focus()requires_internalKeyInputwithonInternal/offInternal- Screen
render()returns a Promise that waits for user input — don'tawaitin tests TextRenderable.contentreturnsStyledText, not string — access via.content.chunks[0].text- Call
buildUI()before testing event handlers that depend on the renderable tree
16. Gotchas & Pitfalls
remove()takes a string ID, not a renderable instance —parent.remove(child.id)notparent.remove(child)renderer.destroy()notstop()—destroy()restores terminal state.stop()only stops the render loop.exitOnCtrlC: trueis default — no manual Ctrl+C handler needed- Automatic rendering — no
renderer.start()call needed; re-renders on tree changes SelectRenderable.focus()is required — keyboard input won't work without itbackgroundColordefaults to transparent — set explicitly on SelectRenderable or items appear blackTextRenderable.contentreturnsStyledText— not a plain string. Read via.chunks[0].text_internalKeyInputneeded forfocus()— mock renderers must include this withonInternal/offInternal- OpenTUI does NOT auto-cleanup —
process.exitor unhandled errors won't restore terminal. Always calldestroy(). - Mouse events bubble — stop with
event.stopPropagation() visible = falseremoves from layout — equivalent to CSSdisplay: none, notvisibility: hidden
Source
git clone https://github.com/JasonWarrenUK/claude-code-config/blob/main/skills/opentui-operative/SKILL.mdView on GitHub Overview
OpenTUI Operative is the expert reference for building terminal UIs with OpenTUI (@opentui/core). It consolidates guidance from all documentation pages to help developers craft responsive, Zig-rendered, Yoga-powered interfaces. The content covers setup, renderer configuration, render loops, events, and practical patterns for production CLIs.
How This Skill Works
OpenTUI uses a CliRenderer as the central driver for terminal output, input, and the render loop. You create renderables (like Text) and attach them to renderer.root to compose your UI, while the library handles a Zig-native rendering backend and a Yoga-like flexbox for layout. Configure options such as exitOnCtrlC, targetFps, useMouse, and openConsoleOnError to fit your app lifecycle and UX, and leverage event hooks for resize, destroy, and selection handling.
When to Use It
- When building a full-screen terminal dashboard or CLI UI that requires a responsive layout.
- When you need flexible, Yoga-powered layouts for terminal components across sizes.
- When starting a Bun-based project and you want a simple Crtl+C-friendly renderer setup.
- When handling terminal input, resize events, and a controlled render loop.
- When you want to utilize the built-in console overlay and dev-time error handling.
Quick Start
- Step 1: Install the library with Bun (Requires Bun): bun add @opentui/core
- Step 2: Create a renderer and add a Text renderable, e.g. import { createCliRenderer, Text } from '@opentui/core' and attach to renderer.root
- Step 3: Run the script with Bun (e.g., bun index.ts) and press Ctrl+C to exit
Best Practices
- Install and import the library via Bun (bun add @opentui/core) before coding.
- Prefer automatic render mode by updating the render tree rather than starting a continuous loop.
- Tune configuration options (exitOnCtrlC, targetFps, useMouse, openConsoleOnError) to your app.
- Build UI from renderer.root using renderables; avoid DOM-like querying patterns.
- Attach lifecycle and input events (resize, destroy, selection) to manage UX and cleanup.
Example Use Cases
- Terminal system monitor with resizable panes and live metrics.
- Interactive form wizard with keyboard navigation and validation.
- Live log viewer with auto-scroll and colorized output.
- Command palette or menu-driven CLI with focus management.
- Animated dashboard showcasing charts via text renderables.