Architecture

OpenTUI splits responsibility between a native Zig core and a TypeScript component layer. Understanding where the boundary falls is key to working with it effectively.

The Split

graph TD
    subgraph TypeScript
        A[JSX / Imperative API] --> B[Reconciler]
        B --> C[Renderable Tree]
        C --> D[Yoga Layout]
        D --> E[Render Commands]
    end
    subgraph "FFI Boundary"
        E --> F["bun:ffi (~200+ C ABI functions)"]
    end
    subgraph "Zig Native Core"
        F --> G[Double-Buffered Renderer]
        G --> H[Terminal Output]
        F --> I[Rope / TextBuffer / EditBuffer]
        F --> J[Hit Grid]
        F --> K[UTF-8 / Grapheme Processing]
    end

TypeScript owns: component tree management, layout computation (Yoga), event routing, framework integrations (React/Solid), the component API surface.

Zig owns: terminal rendering (escape sequences, frame diffing), text storage (rope data structure), Unicode/grapheme processing (SIMD-accelerated), mouse hit testing, text editing internals (cursor, undo/redo, word wrapping).

The FFI boundary is the zig.ts file (3876 LOC) that wraps ~200+ C ABI functions exported from lib.zig.

Why This Split Matters

The rendering pipeline never crosses the FFI boundary more than once per frame. TypeScript computes layout, builds a list of render commands (draw text at position X, fill rectangle, set scissor rect), then hands the whole batch to Zig. Zig diffs against the previous frame and emits minimal terminal updates. This means:

  1. JavaScript never writes escape sequences directly
  2. The terminal sees only changed cells (not a full redraw)
  3. Text processing (the hot path for editors) runs in native code with SIMD acceleration

Three-Pass Render Pipeline

Every frame goes through three passes:

  1. Layout - calculateLayout() calls Yoga to compute positions and sizes for all nodes in the renderable tree
  2. Collect - updateLayout() reads computed layout back from Yoga and collects render commands (drawText, fillRect, drawBox, pushScissorRect, pushOpacity, etc.)
  3. Native render - Zig processes the commands against a back buffer, diffs with the front buffer, and emits only changed cells to the terminal
sequenceDiagram
    participant TS as TypeScript
    participant Yoga as Yoga Engine
    participant Zig as Zig Renderer
    participant Term as Terminal
    
    TS->>Yoga: calculateLayout()
    Yoga-->>TS: computed positions/sizes
    TS->>TS: updateLayout() - collect render commands
    TS->>Zig: FFI calls (drawText, fillRect, etc.)
    Zig->>Zig: Write to back buffer
    Zig->>Zig: Diff back vs front buffer
    Zig->>Term: Emit changed cells only
    Zig->>Zig: Swap buffers

The Text Editing Stack

Text editing has its own layered architecture, entirely in Zig:

LayerModuleWhat it adds
Storagerope.zig (1220 LOC)Persistent B-tree rope. O(log n) insert/delete. Immutable structural sharing for cheap undo.
Styled texttext-buffer.zig (1180 LOC)Styled text segments (fg, bg, attributes per span). Grapheme-correct operations.
Viewporttext-buffer-view.zig (1371 LOC)Word wrapping with wrap cache, viewport windowing, text selection.
Editingedit-buffer.zig (825 LOC)Cursor management, undo/redo history, word boundary detection.
Editoreditor-view.zig (802 LOC)Viewport scrolling synchronized with cursor position. Powers the Textarea component.

Each layer has its own C ABI exports, so TypeScript can use any level of abstraction. The Text component uses TextBuffer (read-only styled text). The Textarea component uses the full stack through EditorView.

Framework Integration

Both React and Solid target the same underlying Renderable tree:

graph LR
    A["React JSX"] --> B["react-reconciler<br/>(host-config.ts, 278 LOC)"]
    C["Solid JSX"] --> D["createRenderer()<br/>(reconciler.ts, 386 LOC)"]
    B --> E["Renderable Tree<br/>(add/remove/insertBefore)"]
    D --> E
    F["Imperative API<br/>(Box, Text, Input)"] --> E

The reconciler interface is minimal: add(child, index?), remove(id), insertBefore(child, anchor), getChildren(). Both frameworks use a component catalogue - a string-to-constructor mapping that translates JSX tag names (<box>, <text>, <input>) to Renderable subclasses. Users can extend this with extend({ customTag: CustomRenderable }).

Memory Management

Zig uses arena and slab allocators. The mem-registry.zig module tracks allocated objects by numeric handle. TypeScript holds opaque pointers (numbers) and passes them to Zig functions. Deallocation is explicit - TypeScript calls destroy* FFI functions when components unmount (destroyRecursively()). This avoids GC pressure on both sides but means memory leaks if you forget to destroy.

Event Flow

Events flow in the reverse direction of rendering:

stdin bytes
  -> Zig terminal module (parse raw input)
  -> TypeScript stdin-parser (structured KeyEvent/MouseEvent)
  -> Keyboard: focus chain -> focused renderable -> useKeyboard hooks
  -> Mouse: native hit grid lookup -> processMouseEvent (bubbles up tree)
  -> Paste: bracketed paste detection -> usePaste hooks
  -> Resize: SIGWINCH -> Yoga relayout -> re-render

Mouse hit testing is native (Zig hit grid), giving O(1) lookup regardless of tree depth. Keyboard events follow a focus chain - only the focused renderable and its ancestors receive key events.