These docs describe the main branch, including unreleased changes. Read the 0.8.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.

OpenTUI preview

Run this example to open an interactive terminal.

Source code · typescript
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.

ModeBehavior
"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. A writer snapshot or surface commit can hold at most the Session output capacity divided by 24 bytes, about 349,000 cells by default. A larger one throws.

Captured stdout has a limit of 64 MiB, about 9,700 rows at 80 columns. Writer snapshots and surface commits don't count toward it, and the renderer never drops them.

When captured stdout reaches half the limit, stdout.write() returns false. After frames write it below half again, stdout emits 'drain'. A writer that respects backpressure, such as pipe(), then waits for the terminal instead of losing rows. The terminal stream also emits 'drain' for its own writes, and that can wake the writer early. Backpressure therefore can't guarantee that no rows are lost.

If a write takes captured stdout past the limit, the renderer drops the oldest captured rows that it hasn't started to write, until captured stdout is back at half the limit. One [N lines dropped] row takes their place. external_output listeners receive that row like any other captured row.

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.

Synchronized output

OpenTUI wraps each frame in synchronized output (CSI ? 2026 h … CSI ? 2026 l), so the terminal shows the frame whole. synchronizedOutput controls this:

ValueBehavior
"auto"Default. Wraps every frame, except in a remote session (remote, or SSH or mosh detected) whose app runs inside tmux, screen or Zellij on its own host, or whose terminal answered the startup queries without reporting mode 2026.
"always"Wraps every frame.
"never"Never wraps a frame.

tmux answers the mode query from its own state, not the client terminal's, and it redraws every line an update touched when the update ends. When the multiplexer sits between the app and a slow link, those redraws cost bytes on every frame, so "auto" leaves the wrapper out, and the multiplexer may then show partial frames. Set "always" to keep it. OpenTUI detects that multiplexer from TMUX, STY or ZELLIJ in the app's own process environment, and only when it renders to process.stdout. A multiplexer on the client's side of the link, a custom stdout, and the environment option never count. Frames sent before the terminal's replies arrive keep the wrapper.

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.

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().

EventPayloadMeaning
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_outputCliRendererExternalOutputEventCore queued a split-footer output snapshot.
focus, blurnoneThe 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.
paletteTerminalColorsA refreshed terminal palette changed.
capabilitiesTerminalCapabilitiesA capability response changed the snapshot.
selectionSelectionA text selection drag finished.
debugOverlay:togglebooleanDebug-overlay visibility changed.
memory:snapshotmemory totalsCore collected a configured memory sample.
destroynoneRenderer 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.8.0, 0.7.0
0.8.0
core: The new synchronizedOutput option defaults to "auto", which removes the synchronized-output wrapper in some remote sessions after the startup replies arrive. Set "always" for the old behavior, or "never" to never wrap a frame. (#1851)
Added CliRenderer.onAnimationTick, CliRendererConfig.synchronizedOutput, RenderContext.clock, RenderContext.onAnimationTick.
0.7.0
Added CliRendererConfig.experimental_nativeInput.
Removed CliRendererConfig.nativeSceneWorkBudget.