From opentui-dev
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.
How this skill is triggered — by the user, by Claude, or both
Slash command
/opentui-dev:opentui-builderThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
**Framework:** @opentui/core v0.1.79 (tested)
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.
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..." })
// Later:
text.content = "Done!"
text.fg = "#00ff00"
When to use each:
Key insight: Imperative renderables can be children of declarative VNodes — mix both patterns.
Follow this pattern for all reusable components:
export interface ComponentHandle {
container: BoxRenderable | TextRenderable // Include in parent layout
destroy(): void // Cleanup on unmount
// ... component-specific methods
}
export function createComponent(
ctx: RenderContext,
opts: { id: string; /* options */ }
): ComponentHandle {
let destroyed = false
// Create renderables
const container = new BoxRenderable(ctx, { id: opts.id, ... })
// Public API
function destroy() {
destroyed = true
// Clear timers, remove listeners, etc.
}
return { container, destroy }
}
Benefits: Consistent API, proper cleanup, type-safe, composable
All screens implement this interface:
export interface Screen {
render(): VNode // Returns root layout
unmount(): void // Cleanup lifecycle
footerHints: string // Keyboard shortcuts for footer
}
export function createMyScreen(renderer: CliRenderer, ctx: RenderContext): Screen {
let cleanupFns: Array<() => void> = []
let unmounted = false
function render(): VNode {
// Clean up previous render
cleanupFns.forEach(fn => fn())
cleanupFns = []
unmounted = false
// Build UI, register cleanup
const keyHandler = (key: KeyEvent) => { /* ... */ }
renderer.keyInput.on("keypress", keyHandler)
cleanupFns.push(() => renderer.keyInput.off("keypress", keyHandler))
return Box({ /* layout */ })
}
function unmount() {
unmounted = true
cleanupFns.forEach(fn => fn())
cleanupFns = []
}
return { render, unmount, footerHints: "[q] quit" }
}
Critical: Track ALL resources for cleanup to prevent memory leaks:
let cleanupFns: Array<() => void> = []
// Register everything that needs cleanup
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 // Don't mutate destroyed renderables
try {
renderable.content = newValue
} catch {
// Renderable destroyed between check and mutation
}
}
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()
},
})
// @ts-expect-error OpenTUI timeline types don't include onUpdate callback
timeline.add(animTarget, {
value: 100,
duration: 2000,
ease: "inOutQuad",
onUpdate: () => {
progressBar.setProgress(animTarget.value)
},
})
renderer.requestLive()
renderer.setFrameCallback(async (_dt: number) => {
// Runs every frame during animation
})
timeline.play()
Cleanup:
cleanupFns.push(
() => { try { timeline.pause() } catch {} },
() => { try { engine.unregister(timeline) } catch {} },
() => { try { renderer.removeFrameCallback(frameCallback) } catch {} },
() => { try { renderer.dropLive() } catch {} }
)
For simple loops (spinners, tickers):
const intervalId = setInterval(() => {
if (destroyed) return
frameIndex = (frameIndex + 1) % frames.length
try {
renderable.content = frames[frameIndex]
} catch {
destroy() // Renderable destroyed
}
}, 80)
cleanupFns.push(() => clearInterval(intervalId))
Triple-nested setTimeout pattern for staggered effects:
items.forEach((item, i) => {
setTimeout(() => {
if (unmounted) return
item.backgroundColor = yellow // Flash 1
setTimeout(() => {
if (unmounted) return
item.backgroundColor = green // Flash 2
setTimeout(() => {
if (unmounted) return
item.backgroundColor = normal // Settle
}, 100)
}, 100)
}, i * 300) // Stagger by 300ms
})
Pool-based rendering for efficient scrolling:
const maxVisibleLines = 20
const rows: TextRenderable[] = []
let scrollOffset = 0 // 0 = bottom (live), positive = scrolled up
// Create fixed pool
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 = ""
}
}
}
// Keyboard: up/down scrolls, f resumes live
switch (key.name) {
case "up":
if (scrollOffset < maxOffset) {
scrollOffset++
renderRows()
}
break
case "down":
if (scrollOffset > 0) {
scrollOffset--
renderRows()
}
break
case "f":
scrollOffset = 0 // Jump to bottom (live)
renderRows()
break
}
Two-layer theme architecture for swappable color schemes:
// lib/themes/my-theme.ts
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
// lib/theme.ts
export const theme = {
colors: activeColors, // Base palette
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 // NOT colors.surface
fg: theme.text.primary // NOT colors.text
Swap themes: Change base palette, semantic mappings update automatically.
Use mode enums for complex state:
type DashboardMode = "normal" | "split" | "detail"
let mode: DashboardMode = "normal"
// Mode-specific key routing
if (mode === "detail") {
if (key.name === "escape") {
exitDetailMode()
return
}
// Detail mode keys only
}
// Global keys work across all modes
if (key.name === "c") {
triggerCascade()
return
}
Use imperative containers for add/remove:
const mainContainer = new BoxRenderable(ctx, { id: "main" })
// Add child
mainContainer.add(childRenderable)
// Remove child
mainContainer.remove("child-id")
// Swap layouts between modes
if (mode === "split") {
mainContainer.add(logStream.container)
} else {
mainContainer.remove("log-stream-id")
}
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)
}
}
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.
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 {
// Debounce: 50ms window collapses rapid duplicate events
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 { /* listener error — don't crash watcher */ }
}
}, 50))
}
start(): void {
if (this.started) return
this.started = true
// Watch individual files
try {
const w = watch(this.config.planPath, (eventType) => {
if (eventType === "change") this.emit("plan-changed")
})
w.on("error", () => { /* CRITICAL: without this, watcher crashes the process */ })
this.watchers.push(w)
} catch { /* file may not exist yet — fallback covers it */ }
// Watch directory for new files
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 { /* dir may not exist yet */ }
// Fallback poll: covers missed events and missing files
// Only for lightweight checks — skip expensive directory scans
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 { /* already closed */ }
}
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:
.on("error") to fs.watch — without it, a watched file being deleted crashes the entire processwatch() call — file may not exist yet when watcher startsUsing the watcher in screens:
export function createMyScreen(
renderer: CliRenderer,
ctx: RenderContext,
config: AppConfig,
watcher: TuiFileWatcher, // Passed in from main
): Screen {
// ...
const onDataChanged = () => { reloadData() }
watcher.on("plan-changed", onDataChanged)
// MUST clean up listener on unmount
cleanupFns.push(() => watcher.off("plan-changed", onDataChanged))
}
List-to-detail navigation using container swapping. This was implemented identically in 3 screens:
// State
let mode: "list" | "detail" = "list"
let detailContainer: BoxRenderable | null = null
let detailScrollOffset = 0
let detailScrollMax = 0
let detailRenderScroll: (() => void) | null = null
// Wrapper container that swaps between list and detail
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"
// Remove list from main box
try { mainBox.remove("item-list") } catch { /* not present */ }
// Load additional data (async — check unmounted after await)
let extraData: ExtraData | undefined
try {
extraData = await loadExtraData(config.dataPath)
if (unmounted) return // Screen was unmounted during async load
} catch { /* continue without extra data */ }
if (unmounted) return
// Build detail container with sections
detailContainer = new BoxRenderable(ctx, {
id: "detail-view",
width: "100%",
flexGrow: 1,
flexDirection: "column",
paddingY: 1,
})
// Add static content sections...
detailContainer.add(new TextRenderable(ctx, {
id: "detail-title",
content: ` ${items[index].title}`,
fg: theme.text.primary,
attributes: TextAttributes.BOLD,
}))
// Add scrollable pool for long content
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 { /* destroyed */ }
}
detailRenderScroll = renderScroll
renderScroll()
mainBox.add(detailContainer)
}
function exitDetailMode() {
if (!detailContainer) return
try { mainBox.remove("detail-view") } catch { /* not present */ }
detailContainer = null
detailRenderScroll = null
detailScrollOffset = 0
detailScrollMax = 0
mode = "list"
mainBox.add(listContainer)
renderRows()
}
// Keyboard routing MUST split by mode
const keyHandler = (key: KeyEvent) => {
if (unmounted) return
// Detail mode gets priority — intercepts Esc, Up, Down
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 // Swallow all other keys in detail mode
}
// List mode keys
switch (key.name) {
case "return":
if (selectedIndex >= 0) enterDetailMode(selectedIndex)
break
case "up":
// ... list navigation
break
case "down":
// ... list navigation
break
}
}
Key lessons learned:
unmounted after every await — user can switch screens during async data loading. Without this guard, you'll mutate destroyed renderables.return at the end of the detail block prevents keys from leaking to list mode."[↑↓] navigate [enter] details [esc] back" tells users what's available.Multi-screen apps need centralized config and a shared watcher. This is the production pattern:
// lib/app-config.ts
export interface AppConfig {
dir: string
planPath: string
progressPath: string
logsDir: string
}
// 3-tier priority: explicit > env var > CWD default
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`,
}
}
// prod-main.ts — wires config + watcher into all screens
const config = createConfig()
const watcher = new TuiFileWatcher(config)
// ALL screens receive config + watcher as constructor args
const screenFactories = [
createDashboardScreen,
createOutputScreen,
createFailuresScreen,
createPlanViewerScreen,
]
const screens = screenFactories.map(factory =>
factory(renderer, ctx, config, watcher)
)
// Start watcher AFTER initial render
renderApp()
watcher.start()
Screen factory signature:
export function createMyScreen(
renderer: CliRenderer,
ctx: RenderContext,
config: AppConfig, // All file paths come from here
watcher: TuiFileWatcher, // Shared watcher instance
): Screen {
// Use config.planPath, config.progressPath, config.logsDir
// Subscribe to watcher events
// Clean up watcher subscriptions in unmount
}
Key lessons learned:
watcher.off() in unmount, old callbacks fire on destroyed renderables.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 // Prevent concurrent reloads
isReloading = true
try {
const data = await loadData(config.dataPath)
if (unmounted) return // Check again after async
// Only update UI in list mode — don't clobber detail view
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 { /* destroyed */ }
} finally {
isReloading = false
}
}
// Watcher triggers reload
watcher.on("data-changed", () => { reloadData() })
Key lessons learned:
isReloading flag — watcher debounce is 50ms, but data loading can take longer. Without the guard, two reloads run simultaneously and race on items state.isReloading flag, even on error. Otherwise the screen stops updating permanently.OpenTUI's CliRenderer implements RenderContext. Centralize the cast:
// main.ts
const renderer = await createCliRenderer({ exitOnCtrlC: true })
const ctx = renderer as RenderContext
// Pass ctx to all factories
const screen = createMyScreen(renderer, ctx)
OpenTUI v0.1.79 has incomplete types. Known gaps:
1. CliRenderer → RenderContext cast:
// CliRenderer implements RenderContext but types don't reflect it
const renderer = await createCliRenderer({ exitOnCtrlC: true })
const ctx = renderer as RenderContext // Safe cast — only place this is needed
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):
// @ts-expect-error OpenTUI timeline types don't include onUpdate
timeline.add(target, {
value: 100,
duration: 2000,
onUpdate: () => { /* ... */ }
})
Rules:
@ts-expect-error with explanatory comment, not as anyas any breaks strict mode — always prefer targeted suppressionDon't: Create new renderables on every update
function render() {
container.clear()
for (const item of items) {
container.add(new TextRenderable(ctx, { content: item })) // BAD
}
}
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 || "" // Mutate existing
}
}
Guard against rapid state changes:
let updating = false
function onEvent() {
if (updating) return
updating = true
// Expensive update
renderAllRows()
setTimeout(() => { updating = false }, 16) // ~60fps
}
Problem: Screen unmount called twice during transitions
function renderApp() {
screens[activeIndex].unmount() // Called here
// ... render
}
keyHandler = (key) => {
screens[activeIndex].unmount() // AND here before renderApp()
renderApp()
}
Solution: Call unmount only once (in keyHandler, not renderApp)
Problem: Only active screen unmounted
screens[activeIndex].unmount() // Other 6 screens leak
screens = createAllScreensWithNewTheme()
Solution: Unmount ALL screens
screens.forEach(s => s.unmount())
screens = createAllScreensWithNewTheme()
Problem: Closures capture initial values
let count = 0
setTimeout(() => {
console.log(count) // Always 0, even if count changed
}, 1000)
Solution: Use object references
const state = { count: 0 }
setTimeout(() => {
console.log(state.count) // Sees mutations
}, 1000)
For detailed patterns and advanced techniques:
references/advanced-patterns.md - Complex layouts, multi-mode screens, performance optimization, error recoveryWorking examples in examples/:
scrollable-list.ts - Pool-based scrolling implementationtheme-system.ts - Runtime theme switching with cleanupimport { 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" }
}
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 }
}
@opentui/core is version 0.1.x (pre-1.0). Expect:
@ts-expect-error with comments)OpenTUI requires Bun runtime (not Node.js):
Bun.file() for file I/OBun.Glob() for file pattern matchingOpenTUI requires interactive terminal (TTY):
✅ DO:
create*() returning Handle)cleanupFns[] arrayif (destroyed) return@ts-expect-error for known type gaps (not as any).on("error") handlers to all fs.watch() watchersunmounted after every await in async functionsisReloading flag) for watcher-triggered reloadsunmount()❌ DON'T:
as any (breaks strict mode)destroyed guard in async callbacksmode before render)fs.watch error handler (crashes the process)For detailed implementations, consult:
github.com/itsdevcoffee/maximus-loop (branch: tui-poc, directory: tui/src/)references/advanced-patterns.md — Complex patterns from production useexamples/ — Working code you can copy and adaptnpx claudepluginhub itsdevcoffee/devcoffee-agent-skills --plugin opentui-devProvides design patterns for terminal user interfaces: layout paradigms, keyboard navigation, visual systems, and TUI anti-pattern validation. Works with Ratatui, Ink, Textual, Bubbletea, or any TUI framework.
Provides guidance for @mariozechner/pi-tui terminal UI framework with differential rendering, CSI 2026 synchronized output, component interfaces, overlays, focusable IME, and ANSI handling. Useful for building/debugging TUIs, editors, Markdown, select lists in TypeScript/Node.
Scaffolds complete TypeScript TUI for OpenRouter agents like create-react-app for terminals. Generates customizable interface with input styles, tool modes, ASCII banners, streaming output, session persistence, configurable tools. Use for agent projects or coding assistants.