Get the FREE Ultimate OpenClaw Setup Guide →

Opentui Operative

npx machina-cli add skill JasonWarrenUK/claude-code-config/opentui-operative --openclaw
Files (1)
SKILL.md
31.5 KB

OpenTUI 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:

  1. Loads the native Zig rendering library
  2. Configures terminal (mouse, keyboard protocol, alternate screen)
  3. Returns an initialised CliRenderer

Configuration Options

OptionTypeDefaultDescription
exitOnCtrlCbooleantrueDestroy renderer on Ctrl+C
exitSignalsNodeJS.Signals[]Signals that trigger cleanup
targetFpsnumber30Target FPS for render loop
maxFpsnumber60Max FPS for immediate re-renders
useMousebooleantrueEnable mouse input/tracking
autoFocusbooleantrueFocus nearest focusable on left click
enableMouseMovementbooleantrueTrack mouse movement (not just clicks)
useAlternateScreenbooleantrueUse terminal alternate screen buffer
consoleOptionsConsoleOptionsBuilt-in console overlay options
openConsoleOnErrorbooleantrueAuto-open console on errors (dev only)
onDestroy() => voidCallback on renderer destruction

Key Properties

PropertyTypeDescription
rootRootRenderableRoot of the component tree (fills terminal)
widthnumberCurrent width in columns
heightnumberCurrent height in rows
consoleTerminalConsoleBuilt-in console overlay
keyInputKeyHandlerKeyboard input handler
isRunningbooleanWhether render loop is active
isDestroyedbooleanWhether renderer is destroyed
currentFocusedRenderableRenderable | nullCurrently 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

ClassDescription
BoxRenderableContainer with border, background, and layout
TextRenderableRead-only styled text display
InputRenderableSingle-line text input
TextareaRenderableMulti-line editable text
SelectRenderableDropdown/list selection
TabSelectRenderableHorizontal tab selection
ScrollBoxRenderableScrollable container
ScrollBarRenderableStandalone scroll bar control
CodeRenderableSyntax-highlighted code display
LineNumberRenderableLine number gutter
DiffRenderableUnified or split diff viewer
ASCIIFontRenderableASCII art font display
FrameBufferRenderableRaw framebuffer for custom graphics
MarkdownRenderableMarkdown renderer
SliderRenderableNumeric 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:

KeyAction
Up / kMove selection up
Down / jMove selection down
Shift+Up / Shift+DownFast scroll (5 items)
EnterSelect 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, wrapSelection as 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 viewport
  • scrollTo() — 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

PropertyTypeDescription
namestringKey identifier (e.g. "a", "escape", "f1", "return")
sequencestringRaw escape sequence
ctrlbooleanCtrl modifier
shiftbooleanShift modifier
metabooleanAlt/Meta modifier
optionbooleanmacOS 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 capture
  • SHOW_CONSOLE=true — start visible
  • OTUI_DUMP_CAPTURES=true — output on exit

11. Environment Variables

VariableDefaultDescription
OTUI_USE_ALTERNATE_SCREENtrueAlternate screen buffer
OTUI_SHOW_STATSfalseDebug overlay at startup
OTUI_DEBUGfalseDebug input capture
OTUI_NO_NATIVE_RENDERfalseDisable native rendering
OTUI_DUMP_CAPTURESfalseDump captured output on exit
OTUI_OVERRIDE_STDOUTtrueOverride stdout (debug)
OTUI_USE_CONSOLEtrueEnable console capture
SHOW_CONSOLEfalseShow console at startup
OTUI_TS_STYLE_WARNfalseWarn on missing syntax styles
OTUI_TREE_SITTER_WORKER_PATH""Tree-sitter worker path
OTUI_DEBUG_FFIfalseDebug logging for FFI
OTUI_TRACE_FFIfalseTracing for FFI
OPENTUI_FORCE_WCWIDTHfalseUse wcwidth for char widths
OPENTUI_FORCE_UNICODEfalseForce Mode 2026 Unicode
OPENTUI_NO_GRAPHICSfalseDisable Kitty graphics detection
OPENTUI_FORCE_NOZWJfalseNo ZWJ width method
OPENTUI_FORCE_EXPLICIT_WIDTHForce 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 _internalKeyInput with onInternal/offInternal
  • Screen render() returns a Promise that waits for user input — don't await in tests
  • TextRenderable.content returns StyledText, not string — access via .content.chunks[0].text
  • Call buildUI() before testing event handlers that depend on the renderable tree

16. Gotchas & Pitfalls

  1. remove() takes a string ID, not a renderable instanceparent.remove(child.id) not parent.remove(child)
  2. renderer.destroy() not stop()destroy() restores terminal state. stop() only stops the render loop.
  3. exitOnCtrlC: true is default — no manual Ctrl+C handler needed
  4. Automatic rendering — no renderer.start() call needed; re-renders on tree changes
  5. SelectRenderable.focus() is required — keyboard input won't work without it
  6. backgroundColor defaults to transparent — set explicitly on SelectRenderable or items appear black
  7. TextRenderable.content returns StyledText — not a plain string. Read via .chunks[0].text
  8. _internalKeyInput needed for focus() — mock renderers must include this with onInternal/offInternal
  9. OpenTUI does NOT auto-cleanupprocess.exit or unhandled errors won't restore terminal. Always call destroy().
  10. Mouse events bubble — stop with event.stopPropagation()
  11. visible = false removes from layout — equivalent to CSS display: none, not visibility: 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

  1. Step 1: Install the library with Bun (Requires Bun): bun add @opentui/core
  2. Step 2: Create a renderer and add a Text renderable, e.g. import { createCliRenderer, Text } from '@opentui/core' and attach to renderer.root
  3. 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.

Frequently Asked Questions

Add this skill to your agents
Sponsor this space

Reach thousands of developers