Renderer
The CliRenderer drives OpenTUI. It manages terminal output, handles input events, runs the rendering loop, and provides context for creating renderables.
Creating a renderer
Create a renderer with the async factory function:
import { createCliRenderer } from "@opentui/core"
const renderer = await createCliRenderer({
exitOnCtrlC: true,
targetFps: 30,
})
The factory function does three things:
- Loads the native Zig rendering library
- Configures terminal settings (mouse, keyboard protocol, and the selected screen mode)
- Returns an initialized
CliRendererinstance
Configuration options
This page covers the options that most apps set directly.
CliRendererConfig also includes lower-level hooks for testing and tuning, such as stdin, stdout, clock,
postProcessFns, and prependInputHandlers.
| Option | Type | Default | Description |
|---|---|---|---|
screenMode | ScreenMode | "alternate-screen" | How the renderer uses terminal space (details) |
footerHeight | number | 12 | Requested footer rows for "split-footer" mode |
externalOutputMode | ExternalOutputMode | varies | How writes to stdout.write are handled (details) |
consoleMode | ConsoleMode | "console-overlay" | How the built-in console overlay behaves (details) |
exitOnCtrlC | boolean | true | Call renderer.destroy() when Ctrl+C is pressed |
exitSignals | NodeJS.Signals[] | see below | Signals that trigger cleanup (details) |
clearOnShutdown | boolean | true | Clear the renderer-owned region on suspend() and destroy() |
targetFps | number | 30 | Target frames per second for the render loop |
maxFps | number | 60 | Maximum FPS cap for immediate re-renders |
useMouse | boolean | true | Enable mouse input |
autoFocus | boolean | true | Focus the nearest focusable renderable on left click |
enableMouseMovement | boolean | true | Track mouse movement, not just clicks and scrolls |
useKittyKeyboard | KittyKeyboardOptions | null | {} | Kitty keyboard protocol settings, or null to disable |
backgroundColor | ColorInput | transparent | Background color for the render buffer |
consoleOptions | ConsoleOptions | - | Options forwarded to the built-in console overlay |
openConsoleOnError | boolean | true (dev) | Open the console overlay on uncaught errors |
onDestroy | () => void | - | Run after the renderer finishes cleanup |
You can also change screenMode, footerHeight, externalOutputMode, and consoleMode at runtime through their
matching renderer properties.
Screen modes
The screenMode option controls whether OpenTUI owns the alternate screen or a reserved region of the main screen. You
can also change modes at runtime by setting renderer.screenMode.
"alternate-screen" (default)
Switches to the terminal’s alternate screen buffer. The original scrollback content is preserved and restored when the renderer exits. This is the standard mode for full-screen TUI applications.
const renderer = await createCliRenderer({
screenMode: "alternate-screen",
})
"main-screen"
Renders on the terminal’s main screen without switching buffers. OpenTUI still reserves a render region by scrolling terminal content, so this is not a true scrollback-native inline or direct renderer. Use it when you want the UI to stay on the main screen for testing, benchmarks, or short-lived tools.
const renderer = await createCliRenderer({
screenMode: "main-screen",
})
"split-footer"
Pins the renderer to a reserved footer region at the bottom of the terminal. The area above the footer stays available for normal program output. This is the closest thing OpenTUI currently has to a direct-render mode, but it still uses the same buffered main-screen renderer rather than a separate inline backend.
The footerHeight option controls how many rows the footer requests (default: 12). When using split-footer mode, externalOutputMode defaults to "capture-stdout" so that writes to stdout.write can be replayed above the footer instead of overlapping it.
const renderer = await createCliRenderer({
screenMode: "split-footer",
footerHeight: 20,
})
// Change footer height at runtime
renderer.footerHeight = 15
Split-footer bookkeeping lives in the native renderer. A shared SplitScrollback model tracks the rows it has already published and the current tail column, so the footer pins to the bottom of the terminal as scrollback grows. Captured stdout and scrollback writers both produce styled OptimizedBuffer snapshots. The native side emits these as ANSI alongside the footer repaint in one atomic frame. The renderer flushes pending output before resize, suspend, mode transitions, and destroy.
External output mode
The externalOutputMode option controls what happens to writes that go through the renderer’s configured stdout.write while the renderer is active. It does not change stderr, and it is separate from the built-in console overlay.
The native renderer still paints to the real TTY. externalOutputMode only changes the TypeScript-side stdout.write path.
"capture-stdout": Interceptsstdout.write, queues the text, and flushes it above the footer during split-footer renders. Only valid whenscreenModeis"split-footer"."passthrough": Leavesstdout.writeuntouched. Output goes directly to the terminal.
The default depends on the screen mode: "capture-stdout" for split-footer, "passthrough" for everything else.
// Captured stdout appears above the footer
const renderer = await createCliRenderer({
screenMode: "split-footer",
externalOutputMode: "capture-stdout",
})
// Switch at runtime
renderer.externalOutputMode = "passthrough"
Console mode
The consoleMode option controls the built-in console overlay (TerminalConsole).
"console-overlay"(default): Captures console output (console.log, console.error, etc.) and renders it in a toggleable panel within the TUI."disabled": Hides and deactivates the overlay surface.
Current caveat: consoleMode only changes the overlay surface.
If you need plain console.* behavior, set OTUI_USE_CONSOLE=false.
const renderer = await createCliRenderer({
screenMode: "split-footer",
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
})
Writing to scrollback
In split-footer mode with externalOutputMode: "capture-stdout", the renderer also owns a programmatic append path above the footer. Use it when you want rich, styled output in scrollback instead of raw console.log text — colors, wrapping, syntax-highlighted code, and markdown. Every append goes through the same FIFO queue as captured stdout, so ordering stays deterministic even when both sources interleave.
Both APIs require screenMode: "split-footer" and externalOutputMode: "capture-stdout". They throw otherwise.
renderer.writeToScrollback(writer)
Render a renderable tree into an off-screen buffer and commit it as one scrollback snapshot.
import { TextRenderable } from "@opentui/core"
renderer.writeToScrollback((ctx) => {
const root = new TextRenderable(ctx.renderContext, {
id: "api-response",
position: "absolute",
left: 0,
top: 0,
width: ctx.width,
height: 1,
content: "api responded in 12ms",
fg: "#8BD5CA",
})
return {
root,
width: ctx.width,
height: 1,
startOnNewLine: true,
trailingNewline: true,
}
})
The writer receives a ScrollbackRenderContext:
| Field | Type | Description |
|---|---|---|
width | number | Current renderer width |
widthMethod | WidthMethod | Grapheme width method the renderer is using |
tailColumn | number | Column where the previous scrollback commit ended |
renderContext | RenderContext | Context you pass to new renderables inside the snapshot |
And returns a ScrollbackSnapshot:
| Field | Type | Description |
|---|---|---|
root | Renderable | Top renderable that will be drawn into the snapshot buffer |
width | number | Optional; defaults to the root’s width, capped to renderer width |
height | number | Optional; defaults to the root’s measured height |
rowColumns | number | Optional explicit width for tail-column tracking (used for partial-row commits) |
startOnNewLine | boolean | Insert a newline before this commit when the previous commit ended mid-row (default: true) |
trailingNewline | boolean | Append a newline after the final row of this commit (default: true) |
teardown | () => void | Optional cleanup hook invoked after the snapshot is rendered (e.g., dispose a Solid subtree) |
Use startOnNewLine: false plus trailingNewline: false for inline output — for example, an icon prefix followed by streaming text.
renderer.createScrollbackSurface(options?)
For streaming output that you want to render many times before committing (token-by-token code highlighting, markdown blocks settling as they parse), use a ScrollbackSurface. The surface renders into a backing buffer. You can re-render the tree in place and then commit specific row ranges to scrollback.
const surface = renderer.createScrollbackSurface({ startOnNewLine: true })
const code = new CodeRenderable(surface.renderContext, {
id: "streamed-code",
content: "",
filetype: "typescript",
syntaxStyle,
width: "100%",
streaming: true,
treeSitterClient,
})
surface.root.add(code)
code.content = "const x = 1"
await surface.settle() // waits for pending Tree-sitter highlighting
// Commit the rows we've finished highlighting; the rest can keep re-rendering
surface.commitRows(0, surface.height)
// Later, when the stream is done:
surface.destroy()
| Method | Description |
|---|---|
render() | Measure, lay out, and render the current tree into the backing buffer |
settle(timeoutMs?) | Render, then wait for any in-flight Tree-sitter highlights before returning |
commitRows(start, endExclusive, options?) | Copy a row range out of the backing buffer and enqueue it as a scrollback commit |
destroy() | Tear down the surface and its backing buffer |
commitRows throws if you call it before render(), or if the renderer’s width or widthMethod changed since the last render(). Re-render before committing fresh rows in either case.
For React and Solid, use the binding-level helpers that wrap writeToScrollback with JSX support. See createScrollbackWriter / writeSolidToScrollback in the Solid docs.
The root renderable
Every renderer has a root property. It is a special RootRenderable at the top of the component tree:
import { Box, Text } from "@opentui/core"
// Add components to the root
renderer.root.add(Box({ width: 40, height: 10, borderStyle: "rounded" }, Text({ content: "Hello, OpenTUI!" })))
The root renderable fills the entire terminal and adjusts when you resize it.
Render loop control
You can use these control modes:
Automatic mode (default)
If you do not call start(), the renderer re-renders only when the component tree changes:
const renderer = await createCliRenderer()
renderer.root.add(Text({ content: "Static content" })) // Triggers render
Continuous mode
Call start() to run the render loop continuously at the target FPS:
renderer.start() // Start continuous rendering
renderer.stop() // Stop the render loop
You can change the render loop cadence at runtime. targetFps sets the steady-state continuous render rate. maxFps caps how often requestRender() can produce an immediate extra frame:
renderer.targetFps = 60
renderer.maxFps = 120
Live rendering
For animations, call requestLive() to enable continuous rendering:
// Request live mode (increments internal counter)
renderer.requestLive()
// When animation completes, drop the request
renderer.dropLive()
Multiple components can request animations at the same time. The renderer stays live until all requests drop.
Pause and suspend
renderer.pause() // Pause rendering (use start() or requestLive() to run it again)
renderer.suspend() // Fully suspend (disables mouse, input, and raw mode)
renderer.resume() // Resume from suspended state
By default, suspending or destroying clears the region OpenTUI owns on the main screen. If you want to keep that
content visible after shutdown, set clearOnShutdown: false:
const renderer = await createCliRenderer({
screenMode: "split-footer",
clearOnShutdown: false,
})
Key properties
| Property | Type | Description |
|---|---|---|
root | RootRenderable | Root of the component tree |
width | number | Current render width in columns |
height | number | Current render height in rows |
console | TerminalConsole | Built-in console overlay |
keyInput | KeyHandler | Keyboard input handler |
isRunning | boolean | Whether the render loop is active |
isDestroyed | boolean | Whether the renderer has been destroyed |
currentFocusedRenderable | Renderable | null | Currently focused component |
screenMode | ScreenMode | Active screen mode; assignable to swap between modes at runtime |
footerHeight | number | Split-footer height; assignable to resize the footer |
externalOutputMode | ExternalOutputMode | Active stdout routing; assignable to switch between capture/passthrough |
consoleMode | ConsoleMode | Active console overlay mode |
themeMode | ThemeMode | null | Detected terminal theme ("dark" / "light") |
targetFps | number | Target render loop FPS; assignable at runtime |
maxFps | number | Upper cap for immediate re-renders; assignable at runtime |
Events
Use renderer.on(event, callback) to subscribe:
| Event | Payload | Description |
|---|---|---|
resize | (width: number, height: number) | Terminal window resized |
focus | () | Terminal window gained focus |
blur | () | Terminal window lost focus |
theme_mode | (mode: "dark" | "light") | Terminal color scheme changed. See Theme mode section. |
capabilities | (caps: Capabilities) | Terminal capabilities detected |
selection | (selection: Selection) | Text selection completed |
destroy | () | Renderer destroyed |
memory:snapshot | (snapshot: MemorySnapshot) | Memory usage snapshot available |
debugOverlay:toggle | (enabled: boolean) | Debug overlay visibility changed |
// Terminal resized
renderer.on("resize", (width, height) => {
console.log(`Terminal size: ${width}x${height}`)
})
// Terminal focus events
renderer.on("focus", () => {
console.log("Terminal gained focus")
})
renderer.on("blur", () => {
console.log("Terminal lost focus")
})
// Renderer destroyed
renderer.on("destroy", () => {
console.log("Renderer destroyed")
})
// Text selection completed
renderer.on("selection", (selection) => {
console.log("Selected text:", selection.getSelectedText())
})
Theme mode
OpenTUI detects the terminal’s preferred color scheme (dark or light) through two mechanisms:
- DEC mode 2031 (
CSI ? 997 ; ... n): terminals that support it report changes live. - OSC 10/11 fallback: if the terminal does not support DEC 2031, OpenTUI inspects the terminal’s foreground and background colors, then derives the mode from the background brightness.
Read the current mode via renderer.themeMode ("dark", "light", or null before detection completes) and subscribe to the theme_mode event for later changes.
import { type ThemeMode } from "@opentui/core"
const mode = renderer.themeMode
renderer.on("theme_mode", (nextMode: ThemeMode) => {
console.log("Theme mode changed:", nextMode)
})
DEC 2031 always wins over the OSC fallback, so a terminal that starts without DEC support but gains it later still reports the correct mode.
waitForThemeMode(timeoutMs?)
Use waitForThemeMode if you need to block briefly for theme detection at startup — for example, to pick a light or dark color palette before the first paint. It resolves with the detected mode, or null if the timeout elapses before either source reports.
const mode = await renderer.waitForThemeMode(1000) // defaults to 1000 ms
Pass 0 to read the current mode synchronously without waiting.
Terminal integration
These methods drive the terminal emulator directly, outside of the render buffer:
Terminal title and background
renderer.setTerminalTitle("OpenTUI – editing notes.md")
// Set the render buffer background. OpenTUI also emits OSC 11
// so the terminal's own background matches.
renderer.setBackgroundColor("#0D1117")
// Reset terminal background to its theme default via OSC 111
renderer.resetTerminalBgColor()
destroy() and suspend() call resetTerminalBgColor() for you. Call it yourself only for cases the renderer does not own — for example, before pausing with SIGTSTP.
OSC 52 clipboard
OpenTUI can set and clear the terminal’s clipboard via OSC 52. This works across SSH sessions and remote editors. Terminals that opt out silently ignore these calls.
if (renderer.isOsc52Supported()) {
renderer.copyToClipboardOSC52("https://opentui.dev")
renderer.clearClipboardOSC52()
}
// Target specific clipboards
renderer.copyToClipboardOSC52("primary", "primary")
renderer.copyToClipboardOSC52("both", "clipboard-primary")
ClipboardTarget values are "clipboard", "primary", or "clipboard-primary". Both methods return false if the renderer cannot write to stdout — for example, after you destroy it.
Raw OSC sequences
Subscribe to raw OSC sequences the terminal emits — palette queries, cursor position reports, custom escape codes. Use this when you integrate OpenTUI with a terminal feature that does not have a dedicated API yet.
const unsubscribe = renderer.subscribeOsc((sequence) => {
// sequence is the full ESC ] ... BEL / ST payload
})
// later
unsubscribe()
Cursor control
Use these methods to control the cursor position and style:
// Position and visibility
renderer.setCursorPosition(10, 5, true)
// Cursor style
//
// Available styles: "default", "block", "underline", "line"
// The default style is "default", which preserves the terminal's native cursor
// style instead of overriding it.
renderer.setCursorStyle({ style: "block", blinking: true }) // Blinking block
renderer.setCursorStyle({ style: "underline", blinking: false }) // Steady underline
renderer.setCursorStyle({ style: "line", blinking: true }) // Blinking line
renderer.setCursorStyle({ style: "default" }) // Reset to terminal's native cursor
// Cursor style with color
renderer.setCursorStyle({
style: "block",
blinking: true,
color: RGBA.fromHex("#FF0000"),
})
// Cursor style with mouse pointer
//
// Types of mouse pointers available: "default", "pointer", "text", "crosshair", "move",
// "not-allowed"
renderer.setCursorStyle({
style: "block",
blinking: false,
cursor: "pointer",
})
Input handling
Add custom input handlers:
renderer.addInputHandler((sequence) => {
if (sequence === "\x1b[A") {
// Up arrow - handle and consume
return true
}
return false // Let other handlers process
})
By default, addInputHandler() appends handlers to the chain and runs them after built-in handlers. Use prependInputHandler() to add a handler at the start of the chain and run it before built-in handlers.
Debug overlay
Use the debug overlay to show FPS, memory usage, and other stats:
renderer.toggleDebugOverlay()
// You can also configure it
import { DebugOverlayCorner } from "@opentui/core"
renderer.configureDebugOverlay({
enabled: true,
corner: DebugOverlayCorner.topRight,
})
Cleanup
Always destroy the renderer when you finish so you restore the terminal state:
renderer.destroy()
Destroying the renderer restores the terminal to its original state, disables mouse tracking, and cleans up resources.
Important: OpenTUI does not automatically clean up on process.exit or unhandled errors. This design gives you control. See Lifecycle for signal handling options and best practices.
Environment variables
See the environment variable reference for the full list.
The renderer-specific ones you will most likely care about are:
OTUI_USE_CONSOLEcontrols globalconsole.*capture.SHOW_CONSOLEopens the built-in console overlay at startup.OTUI_NO_NATIVE_RENDERskips the Zig/native frame renderer. In"split-footer"mode, the current stdout flush path can still write ANSI.OTUI_DUMP_CAPTURESdumps captured stdout and console caches from the renderer exit handler.