These docs describe the main branch, including unreleased changes. Read the 0.6.0 docs.

Renderer

CliRenderer owns one terminal session, its root renderable, frame scheduling, input parsing, and native output boundary.

Create a renderer#

createCliRenderer() runs asynchronous terminal setup and returns a CliRenderer. The returned renderer has a root property.

import { TextRenderable, createCliRenderer } from "@opentui/core"

const renderer = await createCliRenderer({
  exitOnCtrlC: true,
  targetFps: 30,
})

renderer.root.add(new TextRenderable(renderer, { content: "Hello" }))

The renderer implements RenderContext, so imperative renderables receive the renderer as their first constructor argument. The root always tracks the renderer’s current render width and height.

Read Lifecycle and cleanup before you add application shutdown logic.

Choose a screen mode#

screenMode controls which terminal area OpenTUI owns.

Mode Behavior
"alternate-screen" Default. Uses the terminal’s alternate screen and restores the main screen when the renderer exits.
"main-screen" Uses a reserved region on the main screen. This is not an inline renderer.
"split-footer" Uses a footer on the main screen. footerHeight defaults to 12, and OpenTUI caps the effective footer at the terminal height.

In split-footer mode, renderer.width and renderer.height describe the footer render region. terminalWidth and terminalHeight describe the complete terminal.

externalOutputMode controls application writes through the configured stdout.write path. It does not change renderer frame bytes or stderr.

  • "passthrough" is the default outside split-footer mode.
  • "capture-stdout" is the split-footer default and is valid only in that mode.
  • Captured writes become ordered scrollback commits above the footer.

Writing to scrollback#

The renderer also supplies structured scrollback writers and surfaces. Use the canonical custom renderables guide for off-screen rendering and the Buffer API for buffer ownership.

Captured writes, writer snapshots, and surface commits wait in one queue until frames present them, in order. Captured stdout is never refused. One writer snapshot or surface commit larger than the Session output capacity divided by 24 bytes (about 349,000 cells by default) throws.

Custom streams#

Set stdin and stdout for an SSH channel, pseudo-terminal, or another transport. Initial dimensions use stdout.columns and stdout.rows, then width and height, then 80 by 24.

Call renderer.requestResize(width, height) when a custom terminal changes size. It debounces requests (100 ms by default, set with the debounceDelay option) and applies only the latest size. If frame output is pending, it waits for that output to finish. In split-footer mode, it skips the debounce. Call renderer.resize(width, height) to resize at once. It still waits while a frame paints, while frame output is pending, or while split-footer scrollback is pending. The resize event reports accepted size changes. OpenTUI listens for SIGWINCH only when it uses process.stdout.

Custom stdout uses Session-owned ordered output and defaults to remote: true. These remote custom streams do not forward local terminal environment values by default.

Set forwardEnvKeys only for values that the remote terminal should inherit. Read Terminal capabilities and Environment variables for forwarding rules. Read How Core uses native for Session output ownership and completion.

Choose a render schedule#

The initial control state is demand-driven. Tree mutations call requestRender() and schedule a one-shot frame.

Call start() for continuous rendering. targetFps controls its steady rate, and maxFps caps immediate extra frames.

renderer.start()

renderer.targetFps = 60
renderer.maxFps = 120

renderer.pause()
renderer.start()

pause() stops continuous rendering. It enters EXPLICIT_PAUSED, but later mutations can still request one-shot frames.

Use start() to resume after pause(). requestLive() cannot resume EXPLICIT_PAUSED or EXPLICIT_STOPPED.

Live rendering#

requestLive() and dropLive() are balanced ownership calls for custom loops. The first live request starts an idle renderer. The final drop returns an auto-started renderer to demand-driven mode.

A registered Timeline owns its live request when the timeline engine is attached. Do not call requestLive() for that timeline. See Animation and Timeline.

suspend() releases active terminal input modes and stops rendering. resume() restores the control state that existed before suspension.

Use await renderer.idle() to wait until demand-driven work settles. getSchedulerState() reports running, rendering, and scheduled-work state for diagnostics.

Cooperative frames#

Set nativeSceneWorkBudget to a positive unsigned 32-bit integer to let a frame yield to the event loop during preparation. The budget counts preparation visits, view-preparation entries, and feedback records between yields. Without this option, preparation does not yield.

If a change made while the frame waits at a yield affects layout, preparation restarts once. The rest of that frame runs without yields.

The budget does not limit elapsed time or cells. Yoga, sibling sorting, publication of the prepared tree, painting, and output encoding still run synchronously. Painting runs in one native call and never yields, so no event-loop work can change the scene while native code paints. The scheduler also cannot interrupt an application callback.

A resize can change the renderer size while a frame waits at a yield. Core then cancels that frame attempt and starts a new one at the new size. The new attempt keeps all accepted changes and the side effects of callbacks that already ran. Cancellation does not roll anything back. Output that the Session already queued keeps its order and its completion tracking.

Use the renderer-owned animation APIs rather than process-global animation callbacks when you host multiple sessions.

Read capability state#

Capability detection continues after createCliRenderer() returns. The first snapshot includes environment heuristics, but terminal replies arrive asynchronously.

Most capability fields are booleans. false can mean either unsupported or not detected yet. Treat the capabilities event as the source of updated snapshots.

OpenTUI accepts startup capability replies for five seconds. Snapshots can change several times in that window, and again when OpenTUI processes a later terminal response.

import { CliRenderEvents, type TerminalCapabilities } from "@opentui/core"

renderer.on(CliRenderEvents.CAPABILITIES, (capabilities: TerminalCapabilities) => {
  console.log(capabilities.kitty_graphics)
})

Use explicit state fields where available. For example, osc52_support distinguishes "unknown", "supported", and "unsupported".

Read Terminal capabilities for detection timing, remote sessions, and overrides.

Subscribe to central events#

Use renderer.on(event, listener) and remove long-lived listeners with off().

Event Payload Meaning
resize (width, height) The render region changed.
frame { frameId } A frame’s output completed.
render:error { error, renderable } An error stopped a frame. See below.
handler:error { error, event } A mouse handler threw.
external_output CliRendererExternalOutputEvent Core queued a split-footer output snapshot.
focus, blur none The terminal reported window focus or blur.
focused_renderable (current, previous) Renderable keyboard focus changed.
focused_editor (current, previous) Focus moved to or from an editor renderable.
theme_mode "dark" | "light" The detected terminal theme changed.
palette TerminalColors A refreshed terminal palette changed.
capabilities TerminalCapabilities A capability response changed the snapshot.
selection Selection A text selection drag finished.
debugOverlay:toggle boolean Debug-overlay visibility changed.
memory:snapshot memory totals Core collected a configured memory sample.
destroy none Renderer destruction started its published cleanup stage.

The focus and blur events describe terminal-window focus. They do not focus or blur a renderable. See Interaction, focus, and selection.

render:error reports an error that stops a frame, such as an error from a hook, a post-process function, or native painting. renderable is the node whose paint hook threw. It is undefined for other errors, including errors from onUpdate and onResize. Without a render:error listener, the renderer reports the error through its normal error path. An error from a setFrameCallback() callback does not stop the frame. Core logs it and does not emit render:error.

Use renderer-owned services#

The renderer exposes several terminal and application services. Their canonical guides define behavior and cleanup:

Next#

Read Renderables to add UI nodes. Read Rendering pipeline for the complete frame order, or How Core uses native for the scene integration.

Changes0.6.0, 0.5.14, 0.5.12
0.6.0
core: renderer.requestResize(width, height) debounces size changes from a custom terminal and applies only the latest size. See Renderer. (#1479)
core: The nativeSceneWorkBudget option lets a frame yield to the event loop during preparation. Painting still runs in one native call. See Renderer. (#1479)
core: In split-footer mode, a write that continues a scrollback row no longer loses or overwrites a wide character at the end of the row. Tabs in captured stdout count from the start of the terminal row and stop at the last column. (#1597, #1617)
native: In split-footer mode, a scrollback row that fills the terminal width keeps its last cell. Output no longer erases the footer when fewer than two rows remain above it, for example with the default footer in a terminal of 13 rows or fewer. (#1581, #1618)
Added CliRenderer.cancelAnimationFrame, CliRenderer.hasFrameCallback, CliRenderer.requestAnimationFrame, CliRenderer.requestResize, CliRenderer.clock, CliRenderer.closed and 8 more.
Changed CliRenderer.resume, CliRenderer.suspend.
Removed CliRenderer.addToHitGrid, CliRenderer.clearHitGridScissorRects, CliRenderer.dumpBuffers, CliRenderer.dumpOutputBuffer, CliRenderer.popHitGridScissorRect, CliRenderer.pushHitGridScissorRect and 7 more.
0.5.14
core: The renderer repaints after a burst of resize signals that ends at the original terminal size. Before, it treated the burst as a no-op, and a terminal multiplexer could leave part of the screen blank, such as the composer. Split-footer mode cancels a pending debounced resize when it applies a later resize immediately. (#1549)
0.5.12
core: Shrinking the width of a split footer no longer leaves stale rows on screen. Cleanup now starts at the visible footer origin. (#1488)
Changed MousePointerStyle.