7.5 KiB
Core Gotchas
Runtime Environment
Use Bun, Not Node.js
OpenTUI is built for Bun. Always use Bun commands:
# CORRECT
bun install @opentui/core
bun run src/index.ts
bun test
# WRONG
npm install @opentui/core
node src/index.ts
npx jest
Bun APIs to Use
Prefer Bun's built-in APIs:
// CORRECT - Bun APIs
Bun.file("path").text() // Instead of fs.readFile
Bun.serve({ ... }) // Instead of express
Bun.$`ls -la` // Instead of execa
import { Database } from "bun:sqlite" // Instead of better-sqlite3
// WRONG - Node.js patterns
import fs from "node:fs"
import express from "express"
Avoid process.exit()
Never use process.exit() directly - it prevents proper terminal cleanup and can leave the terminal in a broken state (alternate screen mode, raw input mode, etc.).
// WRONG - Terminal may be left in broken state
if (error) {
console.error("Fatal error")
process.exit(1)
}
// CORRECT - Use renderer.destroy() for cleanup
if (error) {
console.error("Fatal error")
await renderer.destroy()
process.exit(1) // Only after destroy
}
// BETTER - Let destroy handle exit
const renderer = await createCliRenderer({
exitOnCtrlC: true, // Handles Ctrl+C properly
})
// For programmatic exit
renderer.destroy() // Cleans up and exits
renderer.destroy() restores the terminal to its original state before exiting.
Environment Variables
Bun auto-loads .env files. Don't use dotenv:
// CORRECT
const apiKey = process.env.API_KEY
// WRONG
import dotenv from "dotenv"
dotenv.config()
Debugging TUIs
Cannot See console.log Output
OpenTUI captures console output for the debug overlay. You can't see logs in the terminal while the TUI is running.
Solutions:
-
Use the console overlay:
const renderer = await createCliRenderer() renderer.console.show() console.log("This appears in the overlay") -
Toggle with keyboard:
renderer.keyInput.on("keypress", (key) => { if (key.name === "f12") { renderer.console.toggle() } }) -
Write to a file:
import { appendFileSync } from "node:fs" function debugLog(msg: string) { appendFileSync("debug.log", `${new Date().toISOString()} ${msg}\n`) } -
Disable console capture:
OTUI_USE_CONSOLE=false bun run src/index.ts
Reproduce Issues in Tests
Don't guess at bugs. Create a reproducible test:
import { test, expect } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
test("reproduces the issue", async () => {
const { renderer, snapshot } = await createTestRenderer({
width: 40,
height: 10,
})
// Setup that reproduces the bug
const box = new BoxRenderable(renderer, { ... })
renderer.root.add(box)
// Verify with snapshot
expect(snapshot()).toMatchSnapshot()
})
Focus Management
Components Must Be Focused
Input components only receive keyboard input when focused:
const input = new InputRenderable(renderer, {
id: "input",
placeholder: "Type here...",
})
renderer.root.add(input)
// WRONG - input won't receive keystrokes
// (no focus call)
// CORRECT
input.focus()
Focus in Nested Components
When a component is inside a container, focus the component directly:
const container = new BoxRenderable(renderer, { id: "container" })
const input = new InputRenderable(renderer, { id: "input" })
container.add(input)
renderer.root.add(container)
// WRONG
container.focus()
// CORRECT
input.focus()
// Or use getRenderable
container.getRenderable("input")?.focus()
// Or use delegate (constructs)
const form = delegate(
{ focus: "input" },
Box({}, Input({ id: "input" })),
)
form.focus() // Routes to the input
Build Requirements
Zig is Required
Native code compilation requires Zig:
# Install Zig first
# macOS
brew install zig
# Linux
# Download from https://ziglang.org/download/
# Then build
bun run build
When to Build
- TypeScript changes: NO build needed (Bun runs TS directly)
- Native code changes: Build required
# Only needed when changing native (Zig) code
cd packages/core
bun run build
Common Errors
"Cannot read properties of undefined"
Usually means a renderable wasn't added to the tree:
// WRONG - not added to tree
const text = new TextRenderable(renderer, { content: "Hello" })
// text.someMethod() // May fail
// CORRECT
const text = new TextRenderable(renderer, { content: "Hello" })
renderer.root.add(text)
text.someMethod()
Layout Not Updating
Yoga layout is calculated lazily. Force a recalculation:
// After changing layout properties
box.setWidth(newWidth)
renderer.requestRender()
Text Overflow/Clipping
Text doesn't wrap by default. Set explicit width:
// May overflow
const text = new TextRenderable(renderer, {
content: "Very long text that might overflow the terminal...",
})
// Contained within width
const text = new TextRenderable(renderer, {
content: "Very long text that might overflow the terminal...",
width: 40, // Will clip or wrap based on parent
})
Colors Not Showing
Check terminal capability and color format:
// CORRECT formats
fg: "#FF0000" // Hex
fg: "red" // CSS color name
fg: RGBA.fromHex("#FF0000")
// WRONG
fg: "FF0000" // Missing #
fg: 0xFF0000 // Number (not supported)
Performance
Avoid Frequent Re-renders
Batch updates when possible:
// WRONG - multiple render calls
item1.setContent("...")
item2.setContent("...")
item3.setContent("...")
// BETTER - single render after all updates
// (OpenTUI batches automatically, but be mindful)
items.forEach((item, i) => {
item.setContent(data[i])
})
Minimize Tree Depth
Deep nesting impacts layout calculation:
// Avoid unnecessary wrappers
// WRONG
Box({}, Box({}, Box({}, Text({ content: "Hello" }))))
// CORRECT
Box({}, Text({ content: "Hello" }))
Use display: none
Hide elements instead of removing/re-adding:
// For toggling visibility
element.setDisplay("none") // Hidden
element.setDisplay("flex") // Visible
// Instead of
parent.remove(element)
parent.add(element)
Testing
Test Runner
Use Bun's test runner:
import { test, expect, beforeEach, afterEach } from "bun:test"
test("my test", () => {
expect(1 + 1).toBe(2)
})
Test from Package Directories
Run tests from the specific package directory:
# CORRECT
cd packages/core
bun test
# For native tests
cd packages/core
bun run test:native
Filter Tests
# Bun test filter
bun test --filter "component name"
# Native test filter
bun run test:native -Dtest-filter="test name"
Keyboard Handling
Key Names
Common key names for KeyEvent.name:
// Letters/numbers
"a", "b", ..., "z"
"1", "2", ..., "0"
// Special keys
"escape", "enter", "return", "tab", "backspace", "delete"
"up", "down", "left", "right"
"home", "end", "pageup", "pagedown"
"f1", "f2", ..., "f12"
"space"
// Modifiers (check boolean properties)
key.ctrl // Ctrl held
key.shift // Shift held
key.meta // Alt held
key.option // Option held (macOS)
Key Event Types
renderer.keyInput.on("keypress", (key) => {
// eventType: "press" | "release" | "repeat"
if (key.eventType === "repeat") {
// Key being held down
}
})