System Operations Manual

OpenTUI Explained

How OpenTUI works. Learn about terminal internals, buffers, and rendering.

Document
OTUI/INT-26A
Zig native core + TypeScript bindings
Section 01

How terminals work

A terminal acts as a grid of cells. Each cell displays a character with specific colors and styles. Unlike graphical apps that draw pixels, terminal apps write bytes to stdout. The terminal reads these bytes to decide what to show.

Interactive Terminal Grid

Click a cell to see its full data structure. Toggle "Data" mode to see raw values.

Cell Data Structure (0, 0)
Character (u32)
'H' = 0x0048
Foreground ([4]f32 RGBA)
[1.0, 1.0, 1.0, 1.0]
Background ([4]f32 RGBA)
[0.0, 0.0, 0.0, 1.0]
Attributes (u32)
0x00000000 = NONE
SoA Memory Layout (separate arrays per field)
char[]
fg[]
bg[]
attrs[]

Text and control bytes

Everything in a terminal is text plus control bytes. Many controls use escape sequences starting with ESC (0x1B). Simpler control bytes like CR, LF, and TAB handle cursor movement and layout.

Your App
OpenTUI
Output
stdout
PTY
Pseudo-terminal
Terminal
Interprets bytes
Display
Pixels on screen
Section 02

ANSI escape codes

Control sequences tell the terminal what to do. The most common type is the CSI sequence. It starts with \x1b[ (ESC + [) and ends with a command character. Other sequences start with \x1b] (OSC) or \x1bP (DCS).

ANSI sequence structure

Move cursor to row 5, column 10
1B
[
5
;
10
H
ESC + [ + row; col H

Common escape sequences

// Cursor control
pub const hideCursor = "\x1b[?25l";
pub const showCursor = "\x1b[?25h";
pub const home = "\x1b[H";

// Screen control
pub const clear = "\x1b[2J";
pub const reset = "\x1b[0m";

// Text attributes
pub const bold = "\x1b[1m";
pub const italic = "\x1b[3m";
pub const underline = "\x1b[4m";

// True color (24-bit RGB)
// Foreground: ESC[38;2;R;G;Bm
// Background: ESC[48;2;R;G;Bm

Create an ANSI sequence

Interactive ANSI Builder
Foreground Color
Background Color
Text Attributes
Preview
Hello
Generated ANSI
Byte Sequence
Section 03

Cell buffer memory layout

OpenTUI uses a Structure of Arrays (SoA) layout. This means it stores each field in a separate array, rather than grouping fields into a structure for each cell. This layout improves cache speed and lets the CPU process data faster.

Cell structure

Each cell in the buffer
char
u32 Unicode codepoint or encoded grapheme ID
fg
[4]f32 RGBA foreground (0.0 - 1.0)
bg
[4]f32 RGBA background (0.0 - 1.0)
attributes
u32 Packed: text attrs (8 bits) + link ID (24 bits)

Attribute bit packing

Text attributes pack into the lower 8 bits of a u32 using bit flags. The remaining 24 bits store a link ID. This saves space and allows fast bitwise comparison.

Attribute bits (lower 8 bits of u32)
7
6
5
4
3
2
1
0
0
0
0
0
0
0
0
0
Bit 0: Bold Bit 1: Dim Bit 2: Italic Bit 3: Underline Bit 4: Blink Bit 5: Inverse Bit 6: Hidden Bit 7: Strikethrough

Click bits to toggle them:

Memory layout: SoA vs AoS

// Structure of Arrays - what OpenTUI uses
pub const OptimizedBuffer = struct {
    buffer: struct {
        char: []u32,        // All characters contiguous
        fg: []RGBA,          // All foregrounds contiguous
        bg: []RGBA,          // All backgrounds contiguous
        attributes: []u32,  // All attributes contiguous
    },
    width: u32,
    height: u32,
    // ...
};

// Index calculation: row-major order
fn coordsToIndex(x: u32, y: u32) u32 {
    return y * self.width + x;
}
Section 04

Grapheme clusters

One character on screen can use multiple Unicode codepoints. For example, emojis with skin tones or flags use several codepoints but appear as one symbol. These groups are called grapheme clusters. OpenTUI stores these clusters in a separate pool and refers to them by ID.

Grapheme Examples
A
Simple ASCII (1 codepoint, 1 cell)
U+0041
0x41
direct
CJK character (1 codepoint, 2 cells wide)
U+4E2D
0x4E2D
start
CONT
continuation
👨‍👩‍👧‍👦
Family emoji (7 codepoints, 2 cells wide)
U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466
0x8XXXXXXX
grapheme start
0xCXXXXXXX
continuation

Grapheme ID encoding

When a character is a grapheme cluster, the char field stores a pool ID instead of a codepoint. The top 2 bits indicate the type:

zig grapheme.zig:9-31 (constants), :321-335 (functions)
// Bit 31-30 encoding:
// 00xxxxxx = direct Unicode codepoint
// 10xxxxxx = grapheme start (pool ID in lower bits)
// 11xxxxxx = continuation cell

pub const CHAR_FLAG_GRAPHEME: u32 = 0x8000_0000;
pub const CHAR_FLAG_CONTINUATION: u32 = 0xC000_0000;

pub fn isGraphemeChar(c: u32) bool {
    return (c & 0xC000_0000) == CHAR_FLAG_GRAPHEME;
}

pub fn graphemeIdFromChar(c: u32) u32 {
    return c & 0x03FF_FFFF;  // Lower 26 bits
}

The grapheme pool

Grapheme byte sequences live in a slab allocator with size classes: 8, 16, 32, 64, and 128 bytes. The 26-bit ID encodes the class (3 bits), generation (7 bits), and slot index (16 bits).

ID bit layout
class
3 bits
generation
7 bits
slot_index
16 bits
Section 05

Differential rendering

OpenTUI uses double buffering. It draws to a "next" buffer, then compares that with the "current" buffer. It only writes the changed cells to the terminal. This is important because a standard 80x24 terminal contains 1,920 cells.

Why speed matters

Terminals are character devices, not block devices. Unlike a GPU that updates a whole screen at once, a terminal sends one byte at a time. This happens over a serial connection.

The program does not know about the terminal emulator. It only sees stdout. Characters, cursor movements, and color codes all flow through this serial pipe. This uses bandwidth. Look at the cell skip logic in renderer.zig:611-626:

if (!force) {
    const charEqual = currentCell.?.char == nextCell.?.char;
    const attrEqual = currentCell.?.attributes == nextCell.?.attributes;

    if (charEqual and attrEqual and
        buf.rgbaEqual(currentCell.?.fg, nextCell.?.fg, colorEpsilon) and
        buf.rgbaEqual(currentCell.?.bg, nextCell.?.bg, colorEpsilon))
    {
        // Cell unchanged - skip it entirely, write zero bytes
        continue;
    }
}

When a cell hasn't changed, the renderer writes nothing—no cursor move, no character, no color codes. The colorEpsilon comparison even skips cells where colors are nearly identical, avoiding churn from floating-point precision.

~
Full redraws are slow
A 232x55 terminal has 12,760 cells. Updating 60 frames per second sends about 765 KB/s of data just for characters. Adding colors increases this to megabytes per second. This causes lag on slow connections.
Interactive Diff Visualization
Current Buffer (on screen)
Next Buffer (to draw)
currentFg
#888888
currentBg
#000000
Position
(0, 0)
Status
idle
ANSI Output Stream 0 bytes
Run diff to see output...
Naive approach (every cell)
0 bytes
Optimized (diff + state machine)
0 bytes
Cells changed: 0/32
Color codes skipped: 0
Savings: 0%

How the diff works

zig renderer.zig:574-730 (simplified)
fn prepareRenderFrame(self: *CliRenderer, force: bool) void {
    var currentFg: ?RGBA = null;
    var currentBg: ?RGBA = null;
    
    for (0..self.height) |y| {
        for (0..self.width) |x| {
            const currentCell = self.currentRenderBuffer.get(x, y);
            const nextCell = self.nextRenderBuffer.get(x, y);
            
            // Skip if unchanged
            if (!force and cellsEqual(currentCell, nextCell)) {
                continue;
            }
            
            // Only emit color codes if color changed
            if (!colorsMatch(currentFg, nextCell.fg)) {
                ansi.moveToOutput(writer, x + 1, y + 1);
                ansi.fgColorOutput(writer, nextCell.fg);
                currentFg = nextCell.fg;
            }
            
            // Write character
            writer.writeAll(nextCell.char);
            
            // Update current buffer to match
            self.currentRenderBuffer.set(x, y, nextCell);
        }
    }
}
i
Out-of-band vs in-band
GPU rendering uses out-of-band commands (system calls to the GPU driver). Terminal rendering is entirely in-band—control sequences are mixed with content in the same byte stream. Terminal emulators parse this stream and translate it to pixel rendering.
Section 06

The game loop

OpenTUI renders frames like a game engine. In each frame, it runs callbacks, calculates layout, draws to the buffer, checks for changes, and outputs them. This loop handles both animations and static UI.

Frame Timeline

Playback slows the visualization only; target FPS stays accurate.

0ms 1000ms 2000ms
Inside a frame waiting...
Animations
Callbacks
Lifecycle
Layout
Render
Output
work: - wait: - = 33ms target (30 FPS)
delay = max(1, targetFrameTime - frameTime)
Mode: IDLE
Frames: 0
Playback FPS: 0

Two rendering modes

Rendering all the time wastes resources because most UIs stay static. OpenTUI uses two modes to save power:

On-demand (IDLE)
State changes call requestRender().
One frame executes, then loop stops.
No CPU usage when nothing changes.
Continuous (RUNNING)
Animations call requestAnimationFrame().
Loop runs at target FPS (default 30).
Stops automatically when animations end.

The six phases

1
Animations
2
Callbacks
3
Lifecycle
4
Layout
5
Render
6
Output
Section 07

Architecture

OpenTUI combines TypeScript and Zig. TypeScript manages components and easy-to-use APIs. Zig handles fast rendering. The two languages talk through a Foreign Function Interface (FFI).

Layer Stack with FFI Boundary
TS
Component Layer
Renderables, layout props, event handling, React-like API
draw(), setCell() events, layout
TS
Renderer (TypeScript)
Game loop, Yoga layout, frame scheduling, animation callbacks
render(), bufferPtr stats, capabilities
FFI BOUNDARY (bun:ffi)
Zig
OptimizedBuffer
Cell storage, alpha blending, scissor clipping, grapheme pool
prepareRenderFrame()
Zig
CliRenderer (Native)
Double buffering, diff algorithm, ANSI generation, output buffer
write() ANSI bytes
TTY
Terminal Emulator
Interprets ANSI, renders glyphs, handles input

Key data flow

Component
draw()
Buffer
setCell()
Diff
current ≠ next
ANSI
\x1b[...
stdout
write()
Section 08

Input handling

Terminals also use escape sequences for input. Regular keys send raw UTF-8 bytes. Special keys and mouse actions send multi-byte sequences. OpenTUI turns these bytes into events.

Input Parsing Demo
Click here, then press any key
1
Physical Key Press
-
2
Bytes from stdin (may arrive separately)
-
3
Parsed Event
-
Parser State Machine
GROUND
ESCAPE
CSI_ENTRY
CSI_PARAM
CSI_FINAL
Escape sequences like arrow keys arrive as multiple bytes. The parser accumulates them before emitting an event.
Move or click mouse here to see SGR events
-

Mouse event parsing

Mouse events use SGR (Select Graphic Rendition) extended format. The escape sequence encodes button, position, and modifiers. This demo shows pixel positions for clarity; real SGR events report 1-based cell coordinates.

text SGR Mouse Format
// Mouse click at x=10, y=5
\x1b[<0;10;5M  // Button down
\x1b[<0;10;5m  // Button up (lowercase m)

// Button code bits:
// Bits 0-1: button (0=left, 1=middle, 2=right)
// Bit 2: Shift held
// Bit 3: Alt held
// Bit 4: Ctrl held
// Bit 5: Motion event
// Bit 6: Scroll event
Section 09

FFI boundary

Zig pointers become opaque ptr types in TypeScript. You can pass them around, but you cannot read them directly from JavaScript.

The FFI Pattern
export fn createRenderer(
    width: u32, 
    height: u32,
    testing: bool
) ?*renderer.CliRenderer {
    const pool = gp.initGlobalPool(globalArena);
    return renderer.CliRenderer.create(
        globalAllocator, width, height, pool, testing
    ) catch return null;
}
// bun:ffi declaration
createRenderer: {
  args: ["u32", "u32", "bool"],
  returns: "ptr",  // Opaque pointer!
},

// Usage
this.rendererPtr = lib.createRenderer(
    width, height, testing
);
i
Environment variables
OTUI_DEBUG_FFI=true - Log all FFI calls
OTUI_TRACE_FFI=true - Full tracing with timing
Section 10

Renderable lifecycle

You need to understand the lifecycle to build custom components. This diagram shows the process from creation to deletion.

Interactive Lifecycle Diagram
1
Construction
Yoga node created, event handlers attached
produces: YogaNode, eventMap
new Box()
renderable instance
2
Add to Tree
parent.add(child) - Yoga node inserted
produces: parent-child link, dirty layout
parent.add()
each frame (if dirty)
3
Lifecycle Pass
onLifecyclePass() - pre-render updates
produces: state updates, animations
loop phase 3
computed dimensions
4
Layout Pass
calculateLayout() → updateFromLayout()
produces: x, y, width, height
loop phase 4
layout computed
5
Render Pass
renderBefore → renderSelf → renderAfter
produces: cells in buffer
loop phase 5
user action / unmount
6
Destruction
destroy() → destroySelf() → cleanup
frees: YogaNode, listeners, children
destroy()
Connection to Game Loop
Section 11

Hit testing

The hit grid is an array of IDs the size of the screen. Each cell holds the ID of the component at that spot. Mouse events check this grid to find which component to target.

Hit Grid Visualization

Each cell shows the renderable ID at that position. Click to see which component owns it.

Double buffering

The hit grid uses two buffers to prevent race conditions: mouse events read from currentHitGrid while rendering writes to nextHitGrid. After the frame completes, the buffers swap atomically.

Mouse click
checkHit(x, y)
Read
currentHitGrid
Write
nextHitGrid
Render
addToHitGrid()

Render list gating

Hit grid registration happens inside render(), which is only called for renderables in the render list. The render list is built during the layout pass (updateLayout), which both propagates positions and collects renderables for drawing. Each parent calls _getVisibleChildren() to decide which children to traverse. The default returns all children. A parent that returns [] hides its entire subtree from the render list.

Hidden children still exist in the tree and receive layout position updates, but they have no hit grid entry. Mouse clicks at their screen position will resolve to the parent (or nothing), and text selection cannot reach their content. This is how ScrollBoxRenderable culls off-screen children—they re-enter the render list when they scroll into view.

Parent
_getVisibleChildren()
Included
render() + addToHitGrid()
Parent
_getVisibleChildren()
Excluded
updateFromLayout() only

Scissor clipping

Scissor Clipping Demo

The scissor region is fixed. Use ▲/▼ to scroll content. Cells outside the scissor are faded—their hit ID becomes 0.

scissor
scroll
0
Section 12

Debugging tools

OpenTUI includes built-in debugging tools.

Debug overlay

ts Toggle with F12 or programmatically
// Toggle overlay (shows FPS, frame times, memory)
renderer.toggleDebugOverlay();

// Configure position
renderer.setDebugOverlay({ 
  enabled: true, 
  corner: 1  // 0=topLeft, 1=topRight, 2=bottomLeft, 3=bottomRight
});

Buffer dumping

ts Dump internal state to files
renderer.dumpBuffers();      // Writes buffer state to file
renderer.dumpHitGrid();      // Logs hit grid to console
renderer.dumpStdoutBuffer(); // Writes raw ANSI output

Test renderer

ts From testing/test-renderer.ts
const { 
  renderer, 
  mockInput, 
  mockMouse, 
  renderOnce, 
  captureCharFrame,  // Get text content as string
  captureSpans       // Get styled spans with colors
} = await createTestRenderer({ width: 80, height: 24 });

await renderOnce();
const frame = captureCharFrame();
const spans = captureSpans();
Section 13

File map

A guide to important files in the codebase.

lib.zig FFI exports - the TS/Zig boundary
renderer.zig CliRenderer, double buffering, diff algorithm
buffer.zig OptimizedBuffer, Cell, drawing primitives
ansi.zig ANSI escape sequence constants and generators
grapheme.zig GraphemePool, ID encoding, cluster handling
text-buffer.zig UnifiedTextBuffer, rope data structure
terminal.zig Capability detection, terminal setup
Core TypeScript (packages/core/src/)
renderer.ts Game loop, frame scheduling, Yoga integration
Renderable.ts Base component class, lifecycle, layout
zig.ts FFI bindings using bun:ffi
buffer.ts TypeScript wrapper for OptimizedBuffer
Box.ts Basic container with border/background
Text.ts Text rendering with styled content
TextBufferRenderable.ts Base for text components, wrapping, selection