| name | opentui-builder |
| description | This skill should be used when the user asks to "create an OpenTUI component", "build a TUI screen", "implement terminal UI", "use @opentui/core", "create renderables", "add OpenTUI animation", "build imperative UI", or mentions OpenTUI framework patterns. Provides comprehensive guidance for building terminal UIs with @opentui/core following established best practices from the Maximus Loop TUI POC. |
OpenTUI Builder
Framework: @opentui/core v0.1.79 (tested)
Status: 🧪 Experimental (Pre-1.0)
Last Updated: 2026-02-14
Runtime: Bun only
⚠️ Pre-1.0 Framework Notice
OpenTUI is actively evolving toward 1.0. This skill reflects best practices
as of v0.1.79. Breaking changes are expected as the framework matures.
Tested with: @opentui/core v0.1.79
Compatible: v0.1.70 - v0.1.x
Reference: Maximus Loop TUI POC
Build production-quality terminal user interfaces with @opentui/core, a pre-1.0 TUI framework for Bun with Zig-native rendering.
Core Concepts
Dual API Patterns
OpenTUI provides both declarative and imperative APIs:
Declarative (VNodes): Use Box(), Text() factories for static layouts
const layout = Box(
{ width: "100%", flexDirection: "column" },
Text({ content: "Hello", fg: "#00ff00" })
)
Imperative (Renderables): Use BoxRenderable, TextRenderable for dynamic mutations
const text = new TextRenderable(ctx, { id: "status", content: "Loading..." })
text.content = "Done!"
text.fg = "#00ff00"
When to use each:
- Declarative: Static layouts, one-time renders, composing layouts
- Imperative: Live updates, animations, real-time data, progress indicators
Key insight: Imperative renderables can be children of declarative VNodes — mix both patterns.
Component Factory Pattern
Follow this pattern for all reusable components:
export interface ComponentHandle {
container: BoxRenderable | TextRenderable
destroy(): void
}
export function createComponent(
ctx: RenderContext,
opts: { id: string; /* options */ }
): ComponentHandle {
let destroyed = false
const container = new BoxRenderable(ctx, { id: opts.id, ... })
function destroy() {
destroyed = true
}
return { container, destroy }
}
Benefits: Consistent API, proper cleanup, type-safe, composable
Screen Pattern
All screens implement this interface:
export interface Screen {
render(): VNode
unmount(): void
footerHints: string
}
export function createMyScreen(renderer: CliRenderer, ctx: RenderContext): Screen {
let cleanupFns: Array<() => void> = []
let unmounted = false
function render(): VNode {
cleanupFns.forEach(fn => fn())
cleanupFns = []
unmounted = false
const keyHandler = (key: KeyEvent) => { }
renderer.keyInput.on("keypress", keyHandler)
cleanupFns.push(() => renderer.keyInput.off("keypress", keyHandler))
return Box({ })
}
function unmount() {
unmounted = true
cleanupFns.forEach(fn => fn())
cleanupFns = []
}
return { render, unmount, footerHints: "[q] quit" }
}
Cleanup Discipline
Critical: Track ALL resources for cleanup to prevent memory leaks:
let cleanupFns: Array<() => void> = []
cleanupFns.push(
() => clearInterval(intervalId),
() => clearTimeout(timeoutId),
() => renderer.keyInput.off("keypress", handler),
() => stream.stop(),
() => component.destroy(),
() => { try { renderer.removeFrameCallback(cb) } catch {} }
)
Guard against destroyed state:
let destroyed = false
function update() {
if (destroyed) return
try {
renderable.content = newValue
} catch {
}
}
Animation Patterns
Timeline Animations
Use createTimeline() for choreographed sequences:
import { createTimeline, engine } from "@opentui/core"
engine.attach(renderer)
const animTarget = { value: 0 }
const timeline = createTimeline({
duration: 2000,
loop: false,
onComplete: () => {
renderer.removeFrameCallback(frameCallback)
renderer.dropLive()
},
})
timeline.add(animTarget, {
value: 100,
duration: 2000,
ease: "inOutQuad",
onUpdate: () => {
progressBar.setProgress(animTarget.value)
},
})
renderer.requestLive()
renderer.setFrameCallback(async (_dt: number) => {
})
timeline.play()
Cleanup:
cleanupFns.push(
() => { try { timeline.pause() } catch {} },
() => { try { engine.unregister(timeline) } catch {} },
() => { try { renderer.removeFrameCallback(frameCallback) } catch {} },
() => { try { renderer.dropLive() } catch {} }
)
Interval Animations
For simple loops (spinners, tickers):
const intervalId = setInterval(() => {
if (destroyed) return
frameIndex = (frameIndex + 1) % frames.length
try {
renderable.content = frames[frameIndex]
} catch {
destroy()
}
}, 80)
cleanupFns.push(() => clearInterval(intervalId))
Cascade Animations
Triple-nested setTimeout pattern for staggered effects:
items.forEach((item, i) => {
setTimeout(() => {
if (unmounted) return
item.backgroundColor = yellow
setTimeout(() => {
if (unmounted) return
item.backgroundColor = green
setTimeout(() => {
if (unmounted) return
item.backgroundColor = normal
}, 100)
}, 100)
}, i * 300)
})
Scrolling Pattern
Pool-based rendering for efficient scrolling:
const maxVisibleLines = 20
const rows: TextRenderable[] = []
let scrollOffset = 0
for (let i = 0; i < maxVisibleLines; i++) {
rows.push(new TextRenderable(ctx, { id: `row-${i}`, content: "" }))
}
function renderRows() {
const total = allEvents.length
const endIdx = Math.max(0, total - scrollOffset)
const startIdx = Math.max(0, endIdx - maxVisibleLines)
for (let i = 0; i < maxVisibleLines; i++) {
const eventIdx = startIdx + i
if (eventIdx < endIdx) {
rows[i].content = formatEvent(allEvents[eventIdx])
} else {
rows[i].content = ""
}
}
}
switch (key.name) {
case "up":
if (scrollOffset < maxOffset) {
scrollOffset++
renderRows()
}
break
case "down":
if (scrollOffset > 0) {
scrollOffset--
renderRows()
}
break
case "f":
scrollOffset = 0
renderRows()
break
}
Theme System Pattern
Two-layer theme architecture for swappable color schemes:
Base Palette
export const myThemeColors = {
bg: "#0f0f0f",
surface: "#1a1a2e",
border: "#2a2a4a",
borderFocus: "#7c3aed",
text: "#e2e8f0",
textDim: "#64748b",
green: "#22c55e",
yellow: "#eab308",
red: "#ef4444",
purple: "#7c3aed",
cyan: "#06b6d4",
} as const
Semantic Tokens
export const theme = {
colors: activeColors,
status: {
completed: activeColors.green,
running: activeColors.yellow,
failed: activeColors.red,
},
surface: {
background: activeColors.bg,
elevated: activeColors.surface,
border: activeColors.border,
},
text: {
primary: activeColors.text,
secondary: activeColors.textDim,
},
}
Components reference semantic tokens:
backgroundColor: theme.surface.elevated
fg: theme.text.primary
Swap themes: Change base palette, semantic mappings update automatically.
Common Patterns
Mode Switching
Use mode enums for complex state:
type DashboardMode = "normal" | "split" | "detail"
let mode: DashboardMode = "normal"
if (mode === "detail") {
if (key.name === "escape") {
exitDetailMode()
return
}
}
if (key.name === "c") {
triggerCascade()
return
}
Dynamic Layouts
Use imperative containers for add/remove:
const mainContainer = new BoxRenderable(ctx, { id: "main" })
mainContainer.add(childRenderable)
mainContainer.remove("child-id")
if (mode === "split") {
mainContainer.add(logStream.container)
} else {
mainContainer.remove("log-stream-id")
}
Live Data Streams
Poll files for new data:
class JSONLStream {
private position = 0
private watcher: ReturnType<typeof setInterval> | null = null
async readNew(): Promise<any[]> {
const file = Bun.file(this.file)
const size = await file.size
if (size <= this.position) return []
const slice = file.slice(this.position, size)
const text = await slice.text()
this.position = size
return text.split('\n').map(line => JSON.parse(line))
}
startPolling(intervalMs: number, onEvents: (events: any[]) => void) {
this.watcher = setInterval(async () => {
const events = await this.readNew()
if (events.length > 0) onEvents(events)
}, intervalMs)
}
stop() {
if (this.watcher) clearInterval(this.watcher)
}
}
Production Patterns
These patterns were developed across 5 sessions building the Maximus Loop TUI (~5,000 LOC). They address the hardest problems in production TUI development: live data, screen transitions, shared config, and concurrency.
File Watcher with Debounce + Fallback
fs.watch is unreliable — it fires duplicate events, misses events on some filesystems, and crashes without error handlers. This pattern handles all of that:
import { watch, type FSWatcher } from "fs"
export type WatchEvent = "plan-changed" | "progress-changed" | "logs-changed"
type Callback = () => void
export class TuiFileWatcher {
private watchers: FSWatcher[] = []
private listeners = new Map<WatchEvent, Set<Callback>>()
private debounceTimers = new Map<WatchEvent, ReturnType<typeof setTimeout>>()
private fallbackTimer: ReturnType<typeof setInterval> | null = null
private started = false
constructor(private config: AppConfig) {
for (const event of ["plan-changed", "progress-changed", "logs-changed"] as const) {
this.listeners.set(event, new Set())
}
}
on(event: WatchEvent, callback: Callback): void {
this.listeners.get(event)?.add(callback)
}
off(event: WatchEvent, callback: Callback): void {
this.listeners.get(event)?.delete(callback)
}
private emit(event: WatchEvent): void {
const existing = this.debounceTimers.get(event)
if (existing) clearTimeout(existing)
this.debounceTimers.set(event, setTimeout(() => {
this.debounceTimers.delete(event)
for (const cb of this.listeners.get(event) ?? []) {
try { cb() } catch { }
}
}, 50))
}
start(): void {
if (this.started) return
this.started = true
try {
const w = watch(this.config.planPath, (eventType) => {
if (eventType === "change") this.emit("plan-changed")
})
w.on("error", () => { })
this.watchers.push(w)
} catch { }
try {
const w = watch(this.config.logsDir, (_eventType, filename) => {
if (filename?.endsWith(".jsonl")) this.emit("logs-changed")
})
w.on("error", () => {})
this.watchers.push(w)
} catch { }
this.fallbackTimer = setInterval(() => {
this.emit("plan-changed")
this.emit("progress-changed")
}, 5000)
}
stop(): void {
this.started = false
for (const w of this.watchers) {
try { w.close() } catch { }
}
this.watchers = []
for (const timer of this.debounceTimers.values()) clearTimeout(timer)
this.debounceTimers.clear()
if (this.fallbackTimer) {
clearInterval(this.fallbackTimer)
this.fallbackTimer = null
}
}
}
Key lessons learned:
- Always add
.on("error") to fs.watch — without it, a watched file being deleted crashes the entire process
- Debounce at 50ms — file writes trigger 2-4 events; 50ms collapses them into one
- Fallback poll at 5s — fs.watch misses events on some Linux filesystems (NFS, Docker volumes)
- Don't fallback-poll directories — directory scans are expensive; only poll individual files
- try/catch the
watch() call — file may not exist yet when watcher starts
Using the watcher in screens:
export function createMyScreen(
renderer: CliRenderer,
ctx: RenderContext,
config: AppConfig,
watcher: TuiFileWatcher,
): Screen {
const onDataChanged = () => { reloadData() }
watcher.on("plan-changed", onDataChanged)
cleanupFns.push(() => watcher.off("plan-changed", onDataChanged))
}
Detail Drilldown Pattern
List-to-detail navigation using container swapping. This was implemented identically in 3 screens:
let mode: "list" | "detail" = "list"
let detailContainer: BoxRenderable | null = null
let detailScrollOffset = 0
let detailScrollMax = 0
let detailRenderScroll: (() => void) | null = null
const mainBox = new BoxRenderable(ctx, {
id: "screen-main",
width: "100%",
flexGrow: 1,
flexDirection: "column",
})
mainBox.add(listContainer)
async function enterDetailMode(index: number) {
if (index < 0 || index >= items.length) return
mode = "detail"
try { mainBox.remove("item-list") } catch { }
let extraData: ExtraData | undefined
try {
extraData = await loadExtraData(config.dataPath)
if (unmounted) return
} catch { }
if (unmounted) return
detailContainer = new BoxRenderable(ctx, {
id: "detail-view",
width: "100%",
flexGrow: 1,
flexDirection: "column",
paddingY: 1,
})
detailContainer.add(new TextRenderable(ctx, {
id: "detail-title",
content: ` ${items[index].title}`,
fg: theme.text.primary,
attributes: TextAttributes.BOLD,
}))
const scrollItems = buildScrollContent(items[index], extraData)
const maxVisible = 10
const pool: TextRenderable[] = []
detailScrollOffset = 0
detailScrollMax = Math.max(0, scrollItems.length - maxVisible)
for (let i = 0; i < maxVisible; i++) {
const row = new TextRenderable(ctx, { id: `detail-row-${i}`, content: "" })
pool.push(row)
detailContainer.add(row)
}
function renderScroll() {
try {
for (let i = 0; i < maxVisible; i++) {
const idx = detailScrollOffset + i
if (idx < scrollItems.length) {
pool[i].content = scrollItems[idx].text
pool[i].fg = scrollItems[idx].bold ? theme.text.primary : theme.text.secondary
pool[i].attributes = scrollItems[idx].bold ? TextAttributes.BOLD : 0
} else {
pool[i].content = ""
}
}
} catch { }
}
detailRenderScroll = renderScroll
renderScroll()
mainBox.add(detailContainer)
}
function exitDetailMode() {
if (!detailContainer) return
try { mainBox.remove("detail-view") } catch { }
detailContainer = null
detailRenderScroll = null
detailScrollOffset = 0
detailScrollMax = 0
mode = "list"
mainBox.add(listContainer)
renderRows()
}
const keyHandler = (key: KeyEvent) => {
if (unmounted) return
if (mode === "detail") {
if (key.name === "escape") { exitDetailMode(); return }
if (key.name === "down" && detailRenderScroll) {
if (detailScrollOffset < detailScrollMax) {
detailScrollOffset++
detailRenderScroll()
}
return
}
if (key.name === "up" && detailRenderScroll) {
if (detailScrollOffset > 0) {
detailScrollOffset--
detailRenderScroll()
}
return
}
return
}
switch (key.name) {
case "return":
if (selectedIndex >= 0) enterDetailMode(selectedIndex)
break
case "up":
break
case "down":
break
}
}
Key lessons learned:
- Container swapping, not visibility toggling — remove list from mainBox, add detail. OpenTUI doesn't have a visibility property.
- Check
unmounted after every await — user can switch screens during async data loading. Without this guard, you'll mutate destroyed renderables.
- Detail mode swallows all keys — the
return at the end of the detail block prevents keys from leaking to list mode.
- Update footer hints per mode —
"[↑↓] navigate [enter] details [esc] back" tells users what's available.
- Unified scroll pool — use a single pool for all scrollable content (criteria, test steps, history). Don't create separate scroll areas.
Shared Config + Watcher Wiring
Multi-screen apps need centralized config and a shared watcher. This is the production pattern:
export interface AppConfig {
dir: string
planPath: string
progressPath: string
logsDir: string
}
export function createConfig(dir?: string): AppConfig {
const base = dir ?? process.env.APP_DIR ?? `${process.cwd()}/.app`
return {
dir: base,
planPath: `${base}/plan.json`,
progressPath: `${base}/progress.md`,
logsDir: `${base}/logs`,
}
}
const config = createConfig()
const watcher = new TuiFileWatcher(config)
const screenFactories = [
createDashboardScreen,
createOutputScreen,
createFailuresScreen,
createPlanViewerScreen,
]
const screens = screenFactories.map(factory =>
factory(renderer, ctx, config, watcher)
)
renderApp()
watcher.start()
Screen factory signature:
export function createMyScreen(
renderer: CliRenderer,
ctx: RenderContext,
config: AppConfig,
watcher: TuiFileWatcher,
): Screen {
}
Key lessons learned:
- Config is created once in main, passed everywhere — no screen reads env vars directly.
- Watcher is shared — one watcher instance, multiple screen subscribers. Don't create per-screen watchers.
- Start watcher after first render — otherwise watcher events fire before UI is ready.
- Screens must unsubscribe on unmount — theme switching destroys and recreates all screens. Without
watcher.off() in unmount, old callbacks fire on destroyed renderables.
Reload with Concurrency Guards
When watcher fires during an async reload, you get overlapping reloads that corrupt state:
let isReloading = false
async function reloadData() {
if (unmounted) return
if (isReloading) return
isReloading = true
try {
const data = await loadData(config.dataPath)
if (unmounted) return
if (mode === "list") {
items = data.items
renderRows()
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Unknown error"
try {
statusText.content = `Error: ${msg}`
statusText.fg = theme.status.failed
} catch { }
} finally {
isReloading = false
}
}
watcher.on("data-changed", () => { reloadData() })
Key lessons learned:
- Guard with
isReloading flag — watcher debounce is 50ms, but data loading can take longer. Without the guard, two reloads run simultaneously and race on items state.
- Check mode before UI update — if user is in detail view when data changes, don't overwrite the detail view. Update the underlying data silently.
- Wrap in try/finally — always release the
isReloading flag, even on error. Otherwise the screen stops updating permanently.
Type Safety
RenderContext Cast
OpenTUI's CliRenderer implements RenderContext. Centralize the cast:
const renderer = await createCliRenderer({ exitOnCtrlC: true })
const ctx = renderer as RenderContext
const screen = createMyScreen(renderer, ctx)
Known Type Issues
OpenTUI v0.1.79 has incomplete types. Known gaps:
1. CliRenderer → RenderContext cast:
const renderer = await createCliRenderer({ exitOnCtrlC: true })
const ctx = renderer as RenderContext
This is the only type workaround in the Maximus Loop TUI (5,000 LOC). Centralize it in your main entry point and pass ctx to all factories.
2. Timeline onUpdate callback (if using timeline animations):
timeline.add(target, {
value: 100,
duration: 2000,
onUpdate: () => { }
})
Rules:
- Use
@ts-expect-error with explanatory comment, not as any
as any breaks strict mode — always prefer targeted suppression
- Centralize casts in one location (entry point), not scattered across files
Performance Best Practices
Pool-Based Rendering
Don't: Create new renderables on every update
function render() {
container.clear()
for (const item of items) {
container.add(new TextRenderable(ctx, { content: item }))
}
}
Do: Reuse fixed pool
const rows: TextRenderable[] = []
for (let i = 0; i < maxVisible; i++) {
rows.push(new TextRenderable(ctx, { id: `row-${i}` }))
}
function render() {
for (let i = 0; i < maxVisible; i++) {
rows[i].content = items[i]?.text || ""
}
}
Debounce Expensive Operations
Guard against rapid state changes:
let updating = false
function onEvent() {
if (updating) return
updating = true
renderAllRows()
setTimeout(() => { updating = false }, 16)
}
Common Pitfalls
Double Cleanup
Problem: Screen unmount called twice during transitions
function renderApp() {
screens[activeIndex].unmount()
}
keyHandler = (key) => {
screens[activeIndex].unmount()
renderApp()
}
Solution: Call unmount only once (in keyHandler, not renderApp)
Memory Leaks in Theme Switching
Problem: Only active screen unmounted
screens[activeIndex].unmount()
screens = createAllScreensWithNewTheme()
Solution: Unmount ALL screens
screens.forEach(s => s.unmount())
screens = createAllScreensWithNewTheme()
Stale Closure References
Problem: Closures capture initial values
let count = 0
setTimeout(() => {
console.log(count)
}, 1000)
Solution: Use object references
const state = { count: 0 }
setTimeout(() => {
console.log(state.count)
}, 1000)
Additional Resources
Reference Files
For detailed patterns and advanced techniques:
references/advanced-patterns.md - Complex layouts, multi-mode screens, performance optimization, error recovery
Example Files
Working examples in examples/:
scrollable-list.ts - Pool-based scrolling implementation
theme-system.ts - Runtime theme switching with cleanup
Quick Start
Basic Screen
import { Box, Text } from "@opentui/core"
import type { CliRenderer, RenderContext } from "@opentui/core"
export interface Screen {
render(): import("@opentui/core").VNode
unmount(): void
footerHints: string
}
export function createMyScreen(renderer: CliRenderer, ctx: RenderContext): Screen {
let cleanupFns: Array<() => void> = []
function render() {
cleanupFns.forEach(fn => fn())
cleanupFns = []
return Box(
{ width: "100%", height: "100%", backgroundColor: "#0f0f0f" },
Text({ content: "Hello OpenTUI", fg: "#00ff00" })
)
}
function unmount() {
cleanupFns.forEach(fn => fn())
cleanupFns = []
}
return { render, unmount, footerHints: "[q] quit" }
}
Basic Component
import { TextRenderable } from "@opentui/core"
import type { RenderContext } from "@opentui/core"
export interface SpinnerHandle {
renderable: TextRenderable
start(): void
stop(): void
destroy(): void
}
export function createSpinner(ctx: RenderContext, opts: { id: string }): SpinnerHandle {
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
let frameIndex = 0
let interval: ReturnType<typeof setInterval> | null = null
let destroyed = false
const renderable = new TextRenderable(ctx, {
id: opts.id,
content: frames[0],
})
function start() {
if (interval || destroyed) return
interval = setInterval(() => {
if (destroyed) return
frameIndex = (frameIndex + 1) % frames.length
try {
renderable.content = frames[frameIndex]
} catch {
destroy()
}
}, 80)
}
function stop() {
if (interval) {
clearInterval(interval)
interval = null
}
}
function destroy() {
stop()
destroyed = true
}
return { renderable, start, stop, destroy }
}
Framework Constraints
Pre-1.0 Status
@opentui/core is version 0.1.x (pre-1.0). Expect:
- Incomplete TypeScript types (use
@ts-expect-error with comments)
- Breaking changes between versions
- Limited documentation (rely on examples from Maximus Loop TUI POC)
Bun-Only
OpenTUI requires Bun runtime (not Node.js):
- Native Zig renderer compiled for Bun
- Use
Bun.file() for file I/O
- Use
Bun.Glob() for file pattern matching
TTY Requirement
OpenTUI requires interactive terminal (TTY):
- Won't work in background processes
- Won't work in CI/CD without PTY
- Test manually, not via automated headless tests
Best Practices Summary
✅ DO:
- Use factory pattern for components (
create*() returning Handle)
- Track ALL cleanup in
cleanupFns[] array
- Guard mutations with
if (destroyed) return
- Use pool-based rendering for lists
- Mix declarative VNodes with imperative Renderables
- Centralize themes with semantic tokens
- Use
@ts-expect-error for known type gaps (not as any)
- Add
.on("error") handlers to all fs.watch() watchers
- Check
unmounted after every await in async functions
- Use concurrency guards (
isReloading flag) for watcher-triggered reloads
- Create config once in main, pass to all screens
- Unsubscribe from watcher events in
unmount()
❌ DON'T:
- Create new renderables in tight loops
- Forget to clean up intervals, listeners, frame callbacks
- Mutate renderables after unmount
- Use
as any (breaks strict mode)
- Skip the
destroyed guard in async callbacks
- Call unmount multiple times
- Create per-screen file watchers (share one watcher instance)
- Update list UI while in detail mode (check
mode before render)
- Skip the
fs.watch error handler (crashes the process)
- Fallback-poll directories (too expensive; only poll individual files)
Getting Help
For detailed implementations, consult:
- Maximus Loop TUI POC — Reference implementation at
github.com/itsdevcoffee/maximus-loop (branch: tui-poc, directory: tui/src/)
references/advanced-patterns.md — Complex patterns from production use
examples/ — Working code you can copy and adapt