Storm is a terminal UI framework. React reconciler on top, cell-based buffer underneath, diff renderer that only writes what changed. No Yoga, no native dependencies (WASM optional).
Every frame:
- React commit -- reconciler (
reconciler/host.ts) mutates the element tree - FrameScheduler (
reconciler/frame-scheduler.ts) -- throttles to maxFps, coalesces rapid commits, detects render loops - RenderPipeline.fullPaint() (
reconciler/render-pipeline.ts) -- orchestrates the rest:- Plugins:
beforeRenderhook - Middleware:
runLayoutpass paint()-- runscomputeLayout()then writes cells into aScreenBuffer- Middleware:
runPaintpass (post-process the buffer) Screen.flush()-- hands buffer toDiffRendererDiffRenderer.render()-- diffs prev vs next buffer, emits minimal ANSI
- Plugins:
- Incremental repaint --
requestRender()triggers a repaint without a React commit. Used by imperative mutation (scroll, animation).
Imperative mutation is the intended path for anything > 10fps. React state updates do not flush synchronously in this reconciler. Mutate refs/props directly, call requestRender().
src/
reconciler/ React reconciler host config, render(), RenderPipeline, FrameScheduler
core/ ScreenBuffer, DiffRenderer, Screen, RenderContext, ErrorBoundary, Plugin, Middleware
layout/ Pure-TS flexbox engine (computeLayout). No native deps.
hooks/ useCleanup, useImperativeAnimation, useInput, useFocus, etc.
components/ Box, Text, ScrollView, TextInput, etc.
widgets/ Higher-level composites (Table, Tabs, Dialog, etc.)
input/ Keyboard/mouse parser, InputManager
context/ TuiContext (requestRender, renderContext, inputManager)
theme/ Color system, ThemeProvider
testing/ TestInputManager, renderToString, fireEvent, SVG snapshots
styles/ Style types and helpers
templates/ App templates
plugins/ Built-in plugins (devtools overlay, etc.)
ssh/ SSH server adapter
Flat packed Uint32Array/Int32Array/Uint8Array storage. One cell = codepoint + fg + bg + attrs + ulColor. Zero per-cell GC pressure. Tracks damage rects and per-row damage columns so the diff renderer only scans what changed. Has fast ASCII path in writeString() (fill-based, compiles to memset). Exposes getRowRaw() for the diff tight loop to skip bounds checks.
Double-buffered. Three render paths picked per-frame:
- Cell-level diff (< 50% rows changed): emits only changed runs with cursor positioning. Primary path for typing/cursor blink.
- Full-line replacement (> 50% changed): cheaper than many cursor jumps.
- Scroll region optimization (DECSTBM): pure scroll of 1-5 lines uses terminal scroll commands + paints only revealed rows.
WASM acceleration optional (Rust render_line, 3.4x faster). Auto-selected for sparse updates (<=30% rows changed). Falls back to TS silently.
computeLayout(node, x, y, availW, availH) -- pure function, returns positioned layout tree. Supports flex direction/wrap, percentage sizes, min/max constraints, padding, margin (incl. auto), gap, align/justify, absolute positioning, grid. No side effects.
Standard react-reconciler host config. Element types: box, text, scroll-view, custom. Tree of TuiElement/TuiTextNode. Lifecycle hooks wired through PluginManager for mount/unmount/update notifications.
React state does not flush synchronously. For scroll, animation, streaming text: mutate the node/ref directly, call requestRender(). The useImperativeAnimation hook wraps this pattern -- setInterval + onTick callback + auto requestRender() + useCleanup for teardown.
- Create
src/components/MyThing.tsx - It receives props, renders
<box>/<text>primitives - For scroll/animation: use
useTui()to getrequestRender, mutate refs imperatively - Export from
src/index.ts - Do NOT return cleanup functions from
useEffect. UseuseCleanup().
- Create
src/hooks/useMyHook.ts - Get context via
useTui()(gives yourequestRender,renderContext,inputManager) - For teardown (timers, listeners): use
useCleanup(), notuseEffectreturn - For animation loops: use
useImperativeAnimationor roll your ownsetInterval+requestRender() - Export from
src/index.ts
src/testing/index.ts provides:
TestInputManager-- mock input. CallpressKey(),click(),paste()to simulate events.renderToString(element, opts)-- renders a component to a plain string (no terminal needed). Set width/height in opts.renderToSvg(element, opts)-- SVG snapshot output for visual regression.fireEvent-- convenience wrappers around TestInputManager.
No real terminal required. Components render into a ScreenBuffer in memory, diff is never written to stdout.
- useEffect cleanup does not fire reliably. Storm monkey-patches
React.useEffectto warn you. UseuseCleanup(). - useState causes full repaints. FrameScheduler warns if you exceed 15 full paints/sec. Use imperative mutation for hot paths.
- requestRender is not React setState. It triggers repaint of the existing tree without a React commit. State changes need
useState; visual updates needrequestRender.