Deep Dive

ARCHITECTURE

How Storm renders terminal UI at 60fps with sub-millisecond frame times. Five stages, zero garbage collection, two rendering paths.

Section 01

THE RENDERING
PIPELINE

From React JSX to ANSI escape codes. Every frame passes through five stages. Each one is designed to do as little work as possible.

01

React Reconciler

JSX to Element Tree

Storm uses a custom React reconciler built on the react-reconciler package. There is no DOM. No browser. The reconciler maps JSX elements to internal TuiElement and TuiTextNode objects -- a lightweight, normalized tree representation designed for terminal rendering.

When React reconciles, commitUpdate replaces an element's props object entirely rather than mutating it in place. This means the framework can use reference equality (===) to detect dirty nodes during the paint phase -- if the props object is the same reference, nothing changed.

After React finishes its commit phase, resetAfterCommit triggers the paint pipeline. This is the handoff point between React's world and Storm's rendering engine. React 19's custom reconciler has a known issue where setState from external contexts (stdin, timers) does not flush automatically. Storm works around this with forceReactFlush(), which recreates the root element and calls updateContainerSync + flushSyncWork to synchronously process all pending state updates.

0
DOM Nodes
===
Dirty Detection
Sync
Flush Mode
RECONCILER
<Box flexDirection="row">
+-- <Text bold>
+-- <ScrollView>
| +-- <List>
| +-- <Item> DIRTY
+-- <StatusBar>
</Box>
commitUpdate replaces props
resetAfterCommit triggers paint
02

Layout Engine

Constraint Solving

A pure TypeScript flexbox and CSS Grid implementation. No Yoga. No native dependencies. No compilation step. The layout engine takes the element tree and computes a LayoutResult for every node: position (x, y), dimensions (width, height), inner bounds (accounting for padding), and content overflow dimensions for scroll.

Flexbox supports the full spec: flex-grow, flex-shrink, flex-basis, flex-wrap, gap, six alignment modes for alignItems (start, center, end, stretch, baseline, auto), six modes for justifyContent (start, center, end, space-between, space-around, space-evenly), margin auto centering, padding on all four sides, min/max constraints, percentage sizing, aspect ratio, and RTL direction.

CSS Grid adds gridTemplateColumns, gridTemplateRows, gridAutoFlow (row, column, dense), and gridColumn/gridRow placement. Layout uses dirty flags for incremental computation -- only changed subtrees are recalculated. The entire engine is approximately 1,561 lines of constraint solving.

0
Lines of Code
0
Native Deps
2
Layout Modes
Live Flexbox
flex:2
flex:1
flex:1
gap: 2
gap: 2
CSS Grid
span 2
auto
flexGrow flexShrink wrap gap padding margin:auto min/max % aspectRatio RTL absolute
03

Cell Buffer

Typed Array Storage

The screen is represented as flat typed arrays: Int32Array for foreground and background colors, Uint8Array for attributes, Int32Array for underline colors, and string[] for characters. This is explicitly not an object-per-cell approach. There are no Cell objects allocated during rendering. Zero GC pressure in steady state.

Each cell occupies a fixed index in the flat array: index = y * width + x. A single cell stores: 1 character (supporting full Unicode via grapheme segmentation), 1 foreground color (24-bit RGB packed into Int32), 1 background color (24-bit RGB), 1 underline color, and 1 attribute byte encoding bold, dim, italic, underline, strikethrough, and inverse via bitflags.

The buffer is double-buffered. The DiffRenderer maintains two ScreenBuffer instances (bufferA and bufferB) and alternates between them. Buffer swap is a pointer exchange using copyFrom(), not a full reallocation. On terminal resize, only the mismatched buffer is reallocated. For a 200x50 terminal: 10,000 cells across five typed arrays -- roughly 130KB total, compared to ~500KB for an equivalent object-per-cell design.

0
200x50 Terminal
0
GC per Frame
5
Typed Arrays
2x
Double Buffer
BUFFER.TS
class ScreenBuffer {
// index = y * width + x
private chars: string[];
private fgs: Int32Array;
private bgs: Int32Array;
private attrArr: Uint8Array;
private ulColors: Int32Array;
}
char
fg
bg
attr
ul
Per-cell flat storage -- no objects
Double Buffer
Buffer A
Buffer B
Pointer swap -- zero allocation
04

Diff Engine

Minimal Output Generation

The diff engine compares the front buffer (current frame) against the back buffer (previous frame) cell by cell. It uses a row-level fast path: if an entire row is identical via rowEquals(), it is skipped entirely with zero per-cell overhead. On a typical frame, the vast majority of rows fall into this path.

For rows that do have changes, the engine identifies runs -- maximal sequences of consecutive columns where at least one cell property differs. It then applies gap merging: if two runs are separated by fewer than 4 unchanged cells, they are merged into a single write. Emitting a few extra characters is cheaper than the cursor-repositioning escape sequence that would otherwise be needed.

SGR state tracking avoids redundant ANSI attribute codes: the renderer remembers the last-emitted foreground, background, and text attributes, and only emits escape sequences when they differ from the previous cell. The engine also uses an adaptive WASM/TypeScript strategy: WASM is used when 30% or fewer rows changed (the scroll case, where boundary-crossing overhead is amortized), while the TypeScript path wins on full repaints. For pure scroll operations, Storm detects them and uses DECSTBM (DEC Set Top and Bottom Margins) to trigger the terminal's native hardware scroll regions.

0
Cells Skipped
~0
Cells Written
<4
Gap Threshold
0
WASM Threshold
Cell-Level Diff
46 skipped 4 written
Changed cell (run)
Unchanged (skipped)
<4 gap
= merged into single write
05

Terminal Output

Batched ANSI Sequences

The final stage converts the diff engine's output into a single batch of ANSI escape sequences. Every frame is wrapped in synchronized output (DEC private mode 2026: CSI ?2026h to start, CSI ?2026l to end). This tells the terminal emulator to buffer the entire frame and display it atomically, preventing the visual tearing you get when escape sequences arrive mid-refresh.

Cursor position is optimized: the renderer tracks the last cursor position and emits relative moves rather than absolute positioning when it is cheaper. For each run of changed cells, it emits a cursor-position command, followed by the minimal SGR (Select Graphic Rendition) changes, followed by the character data. The entire frame is built as a string array, joined once, and written with a single stdout.write() call.

Storm also supports OSC 8 hyperlinks (clickable URLs in supported terminals), OSC 11 background color control (setting the terminal's native background to match the app), and OSC 99 (kitty notification protocol) for accessibility announcements. Link ranges are populated during the paint phase and consumed by the diff renderer, which brackets them with the correct OSC 8 open/close sequences.

1
Write per Frame
2026
Sync Mode
OSC
8 / 11 / 99
ANSI Output Stream
\x1b[?2026h
-- sync start --
\x1b[2;3H
\x1b[38;2;130;170;255m
Storm
\x1b[4;7H
\x1b[1m\x1b[38;2;158;206;106m
Ready \x1b[0m
\x1b[?2026l
-- sync end --
\x1b[?2026h
-- sync start --
\x1b[2;3H
\x1b[38;2;130;170;255m
Storm
\x1b[4;7H
\x1b[1m\x1b[38;2;158;206;106m
Ready \x1b[0m
\x1b[?2026l
-- sync end --
DEC 2026 Synchronized Output
OSC 8 Hyperlinks
OSC 11 Background Color
DECSTBM Hardware Scroll
Section 02

DUAL-SPEED
RENDERING

Two rendering paths in one framework. React for structure. Imperative for speed. The paintGeneration counter prevents race conditions between them.

React Path (setState)

~5-10ms
Reconcile
Layout
Paint
Diff
Terminal

The full pipeline. A component calls setState, React reconciles the fiber tree, the layout engine recalculates affected subtrees, the painter writes into the cell buffer, the diff engine finds changes, and ANSI codes are emitted.

Use for: new screens, adding/removing components, structural UI changes.

Imperative Path (requestRender)

<0.5ms
Paint
Diff
Terminal
Skips React entirely

Bypasses React reconciliation and layout. The component mutates props imperatively, then calls requestRender(). Storm jumps straight to the paint phase. This is 10-20x faster than the setState path.

Use for: scroll, animation, cursor blink, live data streams, progress bars.

paintGeneration Counter

Both rendering paths increment a paintGeneration counter. If a React render starts while an imperative render is in flight, the generation counter mismatch causes the stale frame to be discarded. This prevents visual tearing from interleaved render paths without any locking or synchronization overhead.

useTick Hook

The useTick hook exposes both modes to components. In reactive mode, it uses setState for automatic re-renders. In imperative mode, it uses requestRender for high-frequency updates. Same hook, same API -- the mode determines the rendering path.

Section 03

MEMORY
ARCHITECTURE

Typed arrays instead of objects. Zero allocation per frame. Designed for steady-state rendering with no GC pauses.

Object-per-Cell (Traditional)

TRADITIONAL
cells = Array(10000).map(() => ({
char: " ",
fg: 0,
bg: 0,
attrs: 0
}));
~500KB
Memory
10K
Objects / GC

Typed Arrays (Storm)

STORM
chars = new Array<string>(size);
fgs = new Int32Array(size);
bgs = new Int32Array(size);
attrArr = new Uint8Array(size);
ulColors= new Int32Array(size);
~130KB
Memory
0
Objects / GC

Memory Budget by Terminal Size

Double-buffered totals (2 ScreenBuffers). Five typed arrays per buffer: chars (string[]), fgs (Int32Array), bgs (Int32Array), attrArr (Uint8Array), ulColors (Int32Array).

Terminal Cells Single Buffer Double Buffer Object-per-Cell
80 x 24 1,920 ~25KB ~50KB ~96KB
200 x 50 10,000 ~65KB ~130KB ~500KB
300 x 100 30,000 ~195KB ~390KB ~1.5MB

At 60fps, a 200x50 terminal processes 600,000 cells per second with zero garbage collection. The buffer comment in the source says it all: "This eliminates ~30,000 Cell objects per buffer (300x100 terminal), reducing GC pressure by ~90%."

Section 04

FOCUS &
INPUT

A single stdin owner. Mouse-first parsing. Priority-based key handlers. Focus traps that stack.

input

InputManager

Single stdin owner

The InputManager is the single point of contact for process.stdin. All raw data flows through one handler. No multiple listeners competing for stdin. No race conditions.

Data Flow
stdin.on("data")
handleData()
Mouse
Keys
mouse

Mouse-First Parsing

Priority extraction

Mouse escape sequences are consumed before keyboard parsing. This prevents mouse data from appearing as garbage keyboard input -- a common bug in other TUI frameworks.

1st Extract mouse sequences
2nd Parse keyboard events
3rd Handle paste brackets
timer

ESC Buffering

50ms timeout

When an ESC byte arrives, it might be the start of an escape sequence or a standalone Escape keypress. Storm buffers for 50ms before deciding. If more data arrives, it is parsed as a sequence. If not, it is emitted as an Escape key event.

ESC byte
50ms
Sequence
Key: Esc
sort

Priority-Based Key Handlers

event.consumed pattern

Key handlers are sorted by priority. Higher priority handlers run first. When a handler processes a key event, it sets event.consumed = true, and lower-priority handlers skip it. Modal dialogs use priority 1000 to intercept all keys before the underlying UI sees them.

Modal Priority: 1000
Command Palette Priority: 500
Default handlers Priority: 100
center_focus_strong

Focus Management

Stackable focus traps

Focus traps are stackable -- opening a modal pushes a new trap, closing it pops back to the previous focus context. Tab cycling navigates within the active trap. A focus ring provides visual feedback for the currently focused element.

Focus Trap Stack
2
Modal: Confirm Dialog
1
Sidebar Navigation
0
App Root
Section 05

PLUGIN
ARCHITECTURE

Lifecycle hooks, dependency sorting, error isolation, inter-plugin communication, and custom element registration. Ship plugins as npm packages.

Lifecycle Hooks

Every plugin goes through a defined lifecycle. Async setup initializes resources. beforeRender and afterRender bracket each paint. cleanup runs in reverse order with double-cleanup protection.

1
setup(context) — async initialization
2
beforeRender(buffer, ctx)
3
paint() — framework renders
4
afterRender(buffer, ctx)
5
cleanup() — reverse order, once

PluginBus

Inter-plugin communication via pub/sub. Plugins emit data on named channels and subscribe to channels from other plugins. All handlers are called synchronously. Errors in handlers are caught and logged to stderr without crashing the bus.

PLUGIN_BUS
bus.emit("theme:changed", colors);
bus.on("theme:changed", (c) => {
updatePalette(c);
});

Dependency Ordering

Plugins declare dependencies by name. The PluginManager performs topological sorting to determine initialization order. Setup runs sequentially, respecting dependencies. Missing dependencies are validated before any plugin initializes.

sort
Topo Sort
shield
Error Isolation

Custom Element Registration

Plugins can register custom element types with full lifecycle hooks: paint (receives buffer coordinates and React props), mount/unmount (tree insertion/removal), update (prop changes from reconciliation), and onKey (keyboard input when focused).

paint() mount() unmount() update() onKey()

Scoped Plugins

Plugins can be scoped to specific subtrees via pushScope/popScope. A scoped plugin only runs its hooks for components within its scope boundary. This allows different parts of the UI to have different plugin configurations without global interference.

Scope Stack
<EditorScope> + syntax-highlight plugin
<AppScope> + theme, logger plugins