Rendering pipeline
Changing a renderable requests a frame. In each frame, CliRenderer runs your JavaScript callbacks and hooks.
Native code lays out and paints the native scene. Then the Session sends the changed cells to the terminal.
Core does not give application code a diff of the frame. Native code draws built-in renderables without a JavaScript call for each one.
This page follows the complete Core frame. How Core uses native explains the bridge from renderables to native nodes with a working paint-hook example. Native rendering explains the native scene without the TypeScript integration.
1. Retained state becomes dirty#
The native scene is the tree that native code keeps between frames. Each visual Renderable has one scene node in it.
The scene node owns the Yoga node and the paint state. The JavaScript object keeps listeners, hooks, focus, and application identity.
Ordinary style and paint setters validate the value and then stage the change in a buffer.
Core flushes staged changes, which sends all of them to native code in one call. It flushes before each frame step and before each native read that needs current values.
Node creation, tree changes such as add() and remove(), and text or resource replacement use their own immediate native calls.
A setter requests a render when it changes visual state.
In demand-driven mode, Core combines repeated render requests into one later frame. In continuous mode, Core runs this pipeline at the configured frame rate.
See Renderer for control states and scheduling.
2. The host runs frame callbacks#
CliRenderer.loop() starts the frame. It runs these JavaScript steps in order:
- It runs renderer-owned animation-frame callbacks.
- It runs registered frame callbacks (
setFrameCallback). It awaits each returned promise before it continues. - It calls
NativeScene.paint(). That method runs pending lifecycle passes (onLifecyclePass) in a plain JavaScript loop. Text, Markdown, and LineNumber use lifecycle passes.
NativeScene.paint() then flushes staged changes and calls the first frame step.
A frame step is one call to the native frame-step function (sceneFrameStep).
3. Native code prepares layout and painting#
In a frame step, native code first prepares the frame:
- It runs Yoga when layout is dirty or the renderer size changed. Yoga uses a point scale factor of
1, so layout rounds to whole terminal cells. - It prepares the visible tree: screen geometry, sibling
zIndexorder, clipping, inherited opacity, and ScrollBox viewports.
Some JavaScript hooks must run after layout. Native code does not call them directly. It returns one request for each hooked node. Core runs that hook and then calls the frame step again:
- An update hook (
onUpdate) runs once per frame for each visible node that defines it. A ScrollBox that culls its children skips it for children outside the viewport. - A resize hook (
onResizeandresizelisteners) runs when a visible node’s size changes. - The root’s
LAYOUT_CHANGEDevent fires after Yoga computes a new layout.
If these hooks change layout, native code runs Yoga and prepares the tree again.
Core allows at most 8 layout rounds and 65_536 host requests in one frame. A frame that goes over a limit fails before it paints.
JavaScript does not walk the tree to collect computed layout.
Built-in text and editor measurement runs natively. A custom measurement provider (setMeasureProvider()) is a synchronous callback. Native code calls it on the same thread while Yoga runs.
Preparation can yield to the event loop only when you set the renderer option nativeSceneWorkBudget. See cooperative frames.
See Layout and custom measurement for sizing and completed-layout reads.
4. The scene composes terminal cells#
A paint hook is a renderBefore, renderSelf, or renderAfter method. How native code paints depends on whether a visible node has a paint hook.
If no visible node has a paint hook, native code paints the whole frame into nextRenderBuffer. It does this in the same frame step that finishes preparation, and returns DONE.
If any visible node has a paint hook, native code does not paint yet. It returns one RECORD request for the whole frame. Then:
- Core reads the paint slots once (
sceneFrameGetPaintSlots). Each slot is a visible node with paint hooks, in paint order. - Core runs each node’s paint hooks in paint order. Each hook receives a recording buffer, unless it belongs to a buffered custom renderable (see below). Draw calls on a recording buffer add bytes to a JavaScript recording and make no native call.
- Core flushes staged changes and calls the frame step again with the recording.
- Native code paints the whole frame in one pass. It plays each node’s recording at that node’s position in paint order, and returns
DONE.
A recording buffer cannot read cells. buffers, withBuffers(), getRealCharBytes(), and getSpanLines() throw on it.
Color and text changes that a paint hook makes appear in the same frame. Layout changes appear in the next frame.
A node that a hook destroys during the hook batch does not paint.
Painting runs in one native call. It never yields and never pauses for JavaScript. While native code paints, it also fills the hit grid, which is the native grid for mouse hit testing. It does this only when mouse input is on.
A buffered custom renderable’s hooks draw into its private OptimizedBuffer with direct native calls. Core records one draw of that buffer, and native code composes it into the next buffer.
Buffering does not combine the renderable’s child subtree into one surface.
The cell surface stores character or grapheme references, foreground, background, attributes, and image reservations. Read Text and terminal cells for cell ownership.
After native painting, CliRenderer runs post-process functions (addPostProcessFn) on nextRenderBuffer. Then it draws the console overlay.
Use the Buffer API for checked drawing and scoped cell access.
5. Native code encodes and submits output#
Next, Core commits the frame to the Session. If the debug overlay is on, native code draws it into the next buffer first.
The native renderer compares current and next rows. It skips equal rows and compares the remaining cells by character, colors, and full attributes.
For changed cells, native code emits cursor movement, color state, attributes, hyperlinks, and encoded text. It also handles cursor and mouse-pointer state.
currentRenderBuffer holds the cells that the encoder compares against. It can change before the Session queues or presents output, so it is not a copy of the last presented frame.
The encoder clears the next buffer after encoding.
An unchanged frame can emit no terminal bytes. A forced repaint, palette change, resize, or image transition can require broader output.
The Session owns the ordered output queue. Core delivers the queued bytes in one of two ways:
- For ordinary main-process
process.stdout, native code writes the bytes. Before that, Core waits for earlier queued or corked JavaScript writes. It does this with an empty write that has no output ticket. - Custom streams, Node worker stdout, and stdout handles that native code does not support use copied output. Core copies each chunk out of the Session and writes it to the
Writable.
6. Output completion publishes the frame#
On the native stdout path, native code records completion directly. On the copied path, each Writable write callback acknowledges one output ticket.
Both paths use the same presentation endpoint: the end of the frame’s bytes in the output queue.
When completed output reaches that endpoint, native code publishes the frame’s hit grid, image metadata, and frame statistics.
Core then rechecks hover if mouse input is on, and emits its frame event.
This endpoint does not prove that a remote terminal visibly displayed the bytes.
Output pressure, scheduling yields, and terminal transitions can delay completion. See Frames and output for admission, cancellation, and transport errors.
Image protocol resolution#
An image placement always has a block-cell fallback. Automatic protocol choice checks a configured override, Kitty support, Sixel support, then blocks.
Automatic Sixel also needs terminal pixel geometry. OpenTUI uses blocks until valid geometry arrives.
Capability replies arrive after renderer creation. An early frame can use blocks, then a later capability response can request a repaint with another protocol.
Automatic mode uses blocks under tmux because native graphics need pane-aware handling. Explicit protocol requests have different failure behavior.
See Image for fitting, protocol requests, tmux constraints, and Sixel limits. See Terminal capabilities for detection timing.
Resize behavior#
A resize does not rebuild the retained tree. Existing renderable instances remain attached with their current application state.
Native code checks and accepts the new buffer size first. Then Core updates renderer.width and renderer.height.
currentRenderBuffer and nextRenderBuffer stay the same objects and report the new size.
The next layout pass uses the new size.
An active buffer lease, such as a running withBuffers() callback, keeps the old storage allocated until it ends. That old storage is no longer current.
Computed positions and sizes can change. Buffered renderables resize their frame buffers, then run resize hooks and listeners.
Image pixel geometry becomes unknown during resize. OpenTUI queries it again, so automatic Sixel can temporarily use blocks.
Inspect frames#
currentRenderBuffer and nextRenderBuffer are renderer-owned OptimizedBuffer values.
You can draw into nextRenderBuffer only while a painted frame waits for commit. Use a post-process function for this. A draw at other times throws.
You can read both buffers in a post-process function or between frames.
Outside a post-process function, a read throws while a frame attempt is active (for example, in onUpdate), while the previous frame’s output is still pending, or while the terminal is not active.
Each read borrows the storage only for the synchronous call.
getRealCharBytes() resolves cell characters to text. getSpanLines() groups a buffer’s cells by colors and base attributes for diagnostics.
getSpanLines() uses native per-cell byte lengths to preserve multi-code-point graphemes and continuation cells.
It masks attributes to the low eight bits and exposes image fallback glyphs. Structured span capture therefore loses hyperlink and image identity.
Use character frames for visual text assertions and span capture for basic style diagnostics. Do not use span capture as a lossless buffer serialization.
See Testing and Rendering diagnostics for supported inspection tools.
Next#
Read Renderer for public scheduling and events. Read How Core uses native for the binding’s hooks, resources, and scheduling waits.