Stack: OpenTUI

OpenTUI terminal UI library reference: @opentui/core, terminal UIs, renderables, Yoga layouts, Zig-native rendering.

SKILL.md

GitHub

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. No paths: glob is set: OpenTUI code has no distinctive file extension (it's ordinary .ts/.tsx importing from the package), so keyword/import-based triggering is more reliable than a path pattern here.

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

Option Type Default Description
exitOnCtrlC boolean true Destroy renderer on Ctrl+C
exitSignals NodeJS.Signals[] (none) 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 (none) Built-in console overlay options
openConsoleOnError boolean true Auto-open console on errors (dev only)
onDestroy () => void (none) 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

Full per-component API (options, events, gotchas) is in component-reference.md; load it when working with a specific component beyond basic construction.

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 is 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

Additional resources

Deep-dive detail lives in supporting files, loaded only when needed:

  • component-reference.md: full API for every renderable (BoxRenderable through FrameBufferRenderable): options, events, gotchas
  • input-and-styling.md: keyboard input, focus management, colours/RGBA, console overlay, environment variables
  • advanced-and-testing.md: tree-sitter integration, Solid.js/React framework bindings, common patterns (screen pattern, lifecycle, dynamic updates), mock-renderer testing, the full gotchas list