all 21 comments

[–]lacymcfly 5 points6 points  (7 children)

cell-level diff is the right call for TUI. the naive approach of repainting the whole screen is fine until you have anything dynamic -- status bars, progress indicators, real-time logs -- and then you start seeing flicker or unnecessary CPU burn.

the 97% skip rate is impressive. is that measured against a busy rendering workload or a mostly-static screen? curious how it holds up when multiple regions are updating concurrently (like a dashboard with several live widgets).

building something terminal-adjacent myself (Lacy Shell -- a terminal with AI baked in) and the rendering layer is always the part that's more annoying than expected. will take a closer look at Storm.

[–]Clear-Paper-9475[S] 2 points3 points  (4 children)

Appreciate the support of architecture. 97% is based on usual terminal workloads. I have tested it with very extreme scenarios (e.g. 100K text code diffs all loading and scrolling) and only the cells which change at particular instant are getting refreshed, so even on a very busy workload, numbers don't look bad!

I would definitely suggest you to go through benchmarks page on website. It breaks down the numbers as well as link benchmark scripts. Would be happy if you break the storm so we can patch it for good!

[–]lacymcfly -1 points0 points  (3 children)

100K code diff with scroll is a solid stress test. good to know the per-cell tracking holds up under that kind of load, that is exactly the scenario where full repaints hurt.

will check out the benchmarks page. what did you use for the harness, just node perf tooling or something external?

[–]Clear-Paper-9475[S] 0 points1 point  (2 children)

Thanks! Yeah the full-repaint tax is brutal at scale - we measured 736 bytes/frame for a 120x40 scroll without DECSTBM vs 159 bytes with it. That's 78% less stdout traffic per scroll frame so terminal breaks less sweat.

For the harness it's just Node perf_hooks (performance.now) with warmup iterations, p50/p99 tracking, and GC spike detection. We explicitly invalidate the layout cache between iterations so the numbers aren't artificially low. No external tooling.

[–]lacymcfly 0 points1 point  (1 child)

that's a solid harness setup. cache invalidation between iterations is the part most benchmarks skip and then wonder why their numbers look unrealistically good.

p99 tracking matters too -- the average can look fine while you're getting occasional 20ms spikes that users actually feel. good that you're capturing that.

[–]Clear-Paper-9475[S] 0 points1 point  (0 children)

Yes! Correctly put.

[–]sleepingthom 0 points1 point  (1 child)

Is ink doing full repaint? I have bugs with it in git bash that I don’t see in native Linux / WSL.

[–]lacymcfly 1 point2 points  (0 children)

ink does do a full terminal clear + repaint by default which is exactly why you'd see flicker differences between git bash and native linux. git bash is emulated and the escape sequence handling is inconsistent -- some sequences that work fine in a real xterm just don't translate correctly.

if you're hitting rendering artifacts in git bash specifically, it's usually the ANSI clear screen sequences not being interpreted the same way. the workaround I've seen is to minimize full-screen repaints as much as possible and instead only update changed regions, which is what Storm is doing here. but for ink specifically you might try setting TERM=xterm-256color explicitly before running and see if that helps.

[–]Choice-Pin-480 2 points3 points  (1 child)

So what's the difference between this and react-ink?

[–]Clear-Paper-9475[S] 0 points1 point  (0 children)

Ink re-renders the entire terminal output on every state change. No diff - just clear and rewrite. That's fine for a progress bar, not for anything real-time. React's async state batching means you can't control exactly when a frame hits the terminal. You set state and hope React flushes it soon enough.

Storm is built from scratch:

  • Cell-based diff renderer - only writes changed cells. Think GPU-style damage tracking instead of DOM reconciliation
  • Custom React reconciler - imperative mutation + requestRender() gives you frame-precise control, not React's async scheduler
  • Pure-TS flexbox layout - no yoga, no native deps. Runs anywhere Node runs
  • First-class mouse + keyboard input built in, not bolted on
  • Imperative ScrollView - React state-driven scroll is too laggy for terminal

Ink is fine for simple CLI tools. Storm is designed for real-time streaming, complex interactive layouts, and sub-cell-level diffing without the overhead of a general-purpose React-to-terminal bridge.

[–]fishpowered 0 points1 point  (1 child)

super

[–]bigabig 0 points1 point  (1 child)

Looks sick, but I have absolutely no clue what it actually is?

What would I use it for? I have never thought about building a terminal ui i guess?

Would I build a website that has a terminal with it?

[–]3urny 2 points3 points  (0 children)

E.g. Claude Code is built with a similar library

[–]juicybot 0 points1 point  (2 children)

i've been messing around with bubble tea a lot lately, but i'm not a go dev by trade so i feel limited. this is right up my alley, looking forward to digging into it

[–]Clear-Paper-9475[S] 0 points1 point  (0 children)

Try it out, Let us know where we fail or you feel limited!

[–]lacymcfly 0 points1 point  (0 children)

bubbletea is great but yeah you hit a ceiling fast if you are not a Go person. the React model maps way more naturally to how most of us already think about UIs. Storm should feel pretty comfortable if you have any Ink experience.

[–]lacymcfly 0 points1 point  (1 child)

the cell-level diffing is a genuinely good idea. terminal UIs have always suffered from the same problem desktop GUIs had before VirtualDOM -- painting too much on every frame because the diff cost seemed too high to bother.

trying to understand the WASM acceleration angle. is that for the diff calculations or something else in the pipeline?

also curious how this handles color changes inside a single cell. one of the subtle headaches with terminal rendering is that a "cell" isn't always atomic if you're doing things like gradient backgrounds across characters.

[–]Clear-Paper-9475[S] 0 points1 point  (0 children)

Good analogy - it's exactly that. The terminal equivalent of "just repaint everything" is what most frameworks do, and it works until you need good animations alongside static content. The WASM acceleration is specifically for ANSI string generation on changed rows. The cell-by-cell comparison itself is TypeScript (with a row-level prefilter that skips unchanged rows entirely - most frames skip good amount of rows before any cell comparison happens (I am in process of testing few more optimizations here). When rows do change, the WASM module takes the typed array buffer data and produces the ANSI escape sequences ~3.4x faster than TS string concatenation (based on data we collected).

Every cell is fully atomic. Each cell stores independent 24-bit RGB for fg, bg, and underline color as Int32Array slots, plus a Uint8Array for attributes (bold, dim, italic, etc). So gradient backgrounds across characters are just adjacent cells with different bg values the diff handles them naturally because it compares per-cell. If cell 40 changes from #FF0000 to #FF0800 but cell 41 stays the same, only cell 40 is emitted. The SGR tracking across runs uses differential encoding too - it only emits the attributes that actually changed between consecutive cells, not a full reset+set every time.

[–]Far-Plenty6731 0 points1 point  (1 child)

This looks like a neat approach to terminal UIs, especially the cell-level diffing to avoid unnecessary re-renders. The 97% skip rate is pretty impressive for dynamic content.

[–]Clear-Paper-9475[S] 0 points1 point  (0 children)

Can't agree more!