TUI Framework Spike — Results (ADR-14 / TUI_DESIGN §15)
Date: 2026-08-09
Environment: macOS 27.0 (Apple Silicon), Node v25.9.0 (spike targets Node ≥ 22), pty xterm-256color 100×30 via node-pty, sources run with tsx.
Candidate naming. This spike uses the task's labels, which are swapped relative to TUI_DESIGN §15.1:
| This document | Implementation | TUI_DESIGN §15.1 label |
|---|---|---|
Candidate A — candidate-a/main.ts |
custom ANSI renderer (print-once scrollback + damage-tracked live region + DEC 2026 frames + 16 ms coalescing) | candidate (b) |
Candidate B — candidate-b/main.tsx |
Ink 7.1.1 control (<Static> for settled content + bounded live tree) |
candidate (a) |
Both candidates render the identical shared demo model (shared/demo.ts): ~2 KB of markdown-ish text streamed at ~30 deltas/s per turn (8-char deltas / 33 ms), settled-block scanning, tool one-liners settling mid-stream, a live status line with a real elapsed timer, a composer echoing keystrokes during streaming, and a status bar. Esc interrupts, restores the terminal, and exits cleanly. Instrumentation is shared (shared/metrics.ts).
1. Decision table (7 criteria × 2 candidates)
| # | Criterion (§15.3) | Candidate A — custom ANSI | Candidate B — Ink 7 | Verified how |
|---|---|---|---|---|
| 1 | Flicker-free streaming | Pass (proxy) — 9,083/9,084 writes are DEC 2026-wrapped frames; 0 full-screen clears; per-row damage repaints (avg 1.9 rows/frame) | Pass (proxy) — Ink 7 also wraps every frame in DEC 2026 (8,905 markers ≈ 8.9K frames); 0 full-screen clears; but rewrites the whole live region every frame (avg 8.8 erase-line ops/frame) | Programmatic ANSI-op audit of pty capture. Human eyeball + asciinema pass on the 6-terminal matrix still required (§4) |
| 2 | Stable input line / caret | Pass — every one of 9,083 frames ends by parking the hardware cursor at the composer caret column (CSI nG + show-cursor + sync-off verified in capture) |
Fail as-stock — Ink hides the hardware cursor at mount (1× ?25l) and only restores it at exit; there is no caret at all during the run. A synthetic caret (drawn glyph) or manual post-frame cursor writes would be required |
Capture audit: cursor-park sequence count (A), cursor hide/show count (B) |
| 3 | Input latency < 16 ms p95 | Pass — p50 0.055 ms, p95 0.104 ms, max 0.42 ms (4,232 samples, 0 unresolved) | Pass — p50 1.31 ms, p95 2.07 ms, max 4.35 ms (4,225 samples, 0 unresolved); stable over time (p95 last-30 s ≈ first-30 s) | In-process process.hrtime.bigint(): stdin-byte arrival → first stdout.write() containing the new composer text, typing at 30 cps during full-rate streaming (§3.2) |
| 4 | Long-session memory: flat slope, < 150 MB | Pass — RSS 72 → 78 MB over 180 s (sequential run: 62 → 67 MB); heap flat at ~9.6 MB; residual RSS creep ≈ +0.3–1.2 MB/min and decelerating | Fail — RSS 126 → 272 MB over 180 s (crossed 150 MB in ~20–30 s of load); heap 93 MB and climbing; fitted slope +48.6 MB/min (last-60 s +11.1 MB/min, still positive) | 1 s process.memoryUsage() sampling; least-squares slope; raw timelines in out/metrics-*.json |
| 5 | Terminal-native selection/copy | Pass (proxy) — settled fenced code block appears byte-identical in ANSI-stripped capture; settled content emitted once via plain scrollback writes | Pass (proxy) — same check passes; <Static> output is plain and printed once |
Driver check settledFenceBlockByteIdentical vs FENCE_TEXT source. Human select/copy/paste/diff mid-stream still required (§4) |
| 6 | Node-only (npm i -g, no Bun, no native build) |
Pass — zero runtime dependencies; plain Node + ANSI | Pass — ink 7.1.1 + react 19 install without a native build step (Yoga ships as WASM). (node-pty is a driver-only dev dependency, not part of either renderer) | npm install on this machine; dependency audit |
| 7 | Resize integrity | Pass — 4 live pty resizes mid-stream (100×30→120×30→60×30→200×45→100×30): real SIGWINCH handled, full live repaint at new width, 0 over-width lines emitted, no corrupted-frame sequences, no crash, run completed | Pass — same plan: Ink re-lays-out on resize, 0 over-width lines, no crash | node-pty resize() during streaming + per-write visible-width audit against current cols. Human visual check recommended |
Score: A passes 7/7 (2 pending human confirmation); B passes 5/7, failing #4 (memory) outright and #2 (caret) as-stock.
2. Raw numbers (final 180 s runs, identical workload: 20 turns, 381 settled blocks, ~5,400 synthetic keystrokes, 4 resizes)
| Metric | A — custom ANSI | B — Ink 7 |
|---|---|---|
| Echo latency p50 / p95 / max (ms) | 0.055 / 0.104 / 0.42 | 1.31 / 2.07 / 4.35 |
| Latency samples / unresolved | 4,232 / 0 | 4,225 / 0 |
| RSS start → end (MB) | 72.0 → 78.1 | 126.1 → 272.0 |
| RSS fitted slope (MB/min, last ⅔ of run) | +0.62 | +48.64 |
| Heap at end (MB) | 9.6 | 93.4 |
| stdout writes / sec | 50.5 | 152.7 |
| Bytes written (180 s) | 1.10 MB | 3.04 MB |
| DEC 2026 synchronized frames | 9,083 (= every frame) | 8,905 (= every frame, ~3 writes each) |
| Live rows rewritten per frame (avg) | 1.9 (damage-tracked; 8,121 partial vs 962 full frames) | ~8.8 (entire live region, every frame) |
Full-screen clears (CSI 2J/3J) |
0 | 0 |
| Over-width lines after resizes | 0 | 0 |
| Settled fence block byte-identical in scrollback | yes | yes |
| First-party source lines | 229 (renderer ≈ 120 of them) | 157 (+ ink + react dependency surface) |
Corroborating runs: 60 s and 120 s runs show the same shape (A: 67–76 MB flat-ish, p95 ≈ 0.1 ms; B: slope +56 to +70 MB/min, p95 ≈ 2 ms). Raw JSON: out/summary-a.json, out/summary-b.json (60/120 s snapshots preserved as out/summary-*-60s/120s/180s.json), full pty captures in out/capture-*.txt.
3. Methodology (and honest limitations)
3.1 Harness
driver/run.ts spawns each candidate inside a real pty (node-pty, xterm-256color, 100×30) so raw mode, SIGWINCH, and TTY code paths are genuine. It injects one keystroke every 33 ms (30 cps) from t = 1.5 s, presses Enter every 40 chars (submits the composer as a settled user message — this also exercises settling during streaming), resizes the pty four times mid-stream, and sends Esc at the end. All measurement is in-process in the candidate (shared/metrics.ts), written to out/metrics-<cand>.json on clean Esc teardown.
Note: node-pty 1.1.0's prebuilt spawn-helper ships without its executable bit on this setup; the harness required a one-time chmod +x node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper.
3.2 Latency definition
Latency = process.hrtime.bigint() at the stdin 'data' event → the first stdout.write() whose payload contains the updated composer text ("❯ " + composer). This measures renderer-side latency (what the framework controls) up to the write-enqueue; kernel/pty/terminal paint time is excluded for both candidates equally, so the comparison is fair but the absolute numbers are a lower bound on glass-to-glass latency.
Two measurement artifacts were found and fixed during the spike — worth recording because naive numbers were wildly wrong:
- Trailing-space needles. Ink/Yoga trims trailing whitespace at line ends, so an expectation created by a space keystroke never matches any frame and jammed the FIFO (apparent p95 of 1.8 s that was pure artifact — echo was actually ~1–2 ms, confirmed via
driver/debug-ink-input.tsx). Fix: space keystrokes are not measured; needles aretrimEnd()ed. Applied identically to both candidates. - CR merged into string input. Under momentary event-loop lag the pty can coalesce
\r+ the next char into one chunk, which Ink delivers as string input withkey.return === false; the raw\rthen polluted the composer and the needle. Fix: embedded CR/LF in string input is treated as Enter (matching candidate A's decoder).
After both fixes: 0 unresolved expectations in every reported run for both candidates.
3.3 Memory
process.memoryUsage() sampled every 1 s inside each candidate; slope is a least-squares fit over the last two-thirds of the run. Candidate B's growth is consistent with Ink's <Static> design: settled items remain children of the reconciler/DOM tree for the life of the process, so retained state is O(session), exactly the risk TUI_DESIGN §0.3 called out. This attribution is a hypothesis (heap profiling was not done); the measured RSS/heap growth itself is not in question. Some of B's RSS may be reclaimable under GC pressure, but A under the identical GC policy stays ~flat at < 80 MB, so the comparison stands.
3.4 What ran headless vs. what needs a human
Everything in §1–§2 ran headless in a pty (this machine, no human at a screen). The following cannot be signed off programmatically and needs a human pass (TUI_DESIGN §15.2 scenario 7):
- Flicker (criterion 1): run each candidate interactively and scrub a recording frame-by-frame on Terminal.app, iTerm2, kitty, Alacritty, VS Code terminal, and inside tmux:sh
cd prototypes/tui-spike npm install asciinema rec -c "npm run a" /tmp/spike-a.cast # then: npm run b # type while it streams; press Esc to finish; scrub the .cast frame-by-frame - Selection/copy (criterion 5): while a turn is streaming, select the settled ```ts code block with the mouse, copy, paste to a file, and diff against
FENCE_TEXTinshared/demo.ts. - Resize (criterion 7): drag the window narrow/wide mid-stream and confirm no corrupted rows and a clean live region at the new width.
- Caret (criterion 2), B: observe that Ink shows no cursor while candidate A keeps a hardware caret sitting at the composer position.
4. Recommendation
Candidate A — the custom ANSI renderer. This confirms TUI_DESIGN §0.3's prior, now with measurements:
- B fails a hard criterion. Memory is not flat and exceeds the 150 MB cap within ~30 s of this workload (272 MB at 3 min, still climbing). KHAELOR sessions run for hours; O(session) retained render state is disqualifying without forking Ink's
<Static>/DOM internals — the exact ~100-file-fork trap Hermes fell into (HERMES §8.1). - B has no caret. Stock Ink hides the hardware cursor for its full lifetime. A real composer would need a synthetic caret or hand-written post-frame cursor writes — at which point we are already reimplementing candidate A's paint discipline inside Ink.
- A's margins are wide everywhere. p95 echo 0.104 ms (154× inside the 16 ms budget) vs 2.07 ms; 78 MB flat vs 272 MB climbing; 1.9 rows repainted per frame vs 8.8; 2.8× fewer bytes written; zero runtime dependencies vs the ink+react surface.
- Owned-code cost is small. The entire renderer (live region, damage tracking, DEC 2026 frames, coalescing, settled printing, caret parking) is ~120 lines in this spike; the real Phase 2 cost remains the line editor, exactly as §0.3 predicted — and that cost is identical under Ink, which provides no line editor either.
One genuine positive finding for Ink 7 worth recording: it wraps every frame in DEC 2026 synchronized output and its input-to-write latency (p95 2 ms) comfortably meets the budget. If it did not retain settled content and did not hide the cursor, it would have been viable. Per §15.4, the losing candidate's adapter is to be deleted once the human terminal-matrix pass confirms criterion 1 and 5 for candidate A.
5. Artifacts
| Path | Purpose |
|---|---|
candidate-a/main.ts |
Custom ANSI renderer + demo |
candidate-b/main.tsx |
Ink 7 control + same demo |
shared/demo.ts |
Shared mocked event script, composer model, settled-block scanner |
shared/metrics.ts |
Shared instrumentation (latency, memory, ANSI-op audit) |
shared/ansi.ts |
ANSI helpers (strip, width, wrap) |
driver/run.ts |
pty measurement driver (npm run measure:a / measure:b, DURATION_MS env) |
driver/debug-ink-input.tsx |
Diagnostic used to attribute the latency artifacts (§3.2) |
out/summary-*.json, out/metrics-*.json, out/capture-*.txt |
Raw measurements and full pty captures |
Author: Simon-Pierre Boucher · contact@spboucher.ai