Repository navigation
perry/tui: native TUI engine (zero-flicker, double-buffered, cell-level dirty tracking) #358
Description
Tracking issue for a Perry-native TUI engine, exposed as the built-in perry/tui module. Surfaced in discussion #333 (TUI/CLI support) after pushback that ink "was pretty bad" — and the complaints turn out to be architectural, not personal taste.
Why a native option, not just ink (#348)
The flicker problem in ink isn't a config knob — it's the renderer's design. Stock ink does full component-tree traversal + complete buffer regeneration on every state change, with no cell-level dirty tracking and no double buffering. log-update.ts issues \e[2K\e[1A per line and rewrites everything; on a 60-line UI that's 60+ erase sequences per update at 32ms throttle. Long views flicker badly (ink#359, open since 2020). It got bad enough that Anthropic rewrote the renderer from scratch for Claude Code v2.0.10 (Oct 2025) — kept React + Yoga + ANSI as the shell, replaced the critical path with packed typed arrays, pool-based string interning, and double buffering. Their writeup: "started with Ink, then forked it beyond recognition."
So Perry's TUI story should be two tracks in parallel, not one:
- Track A (Compile
ink(React-based TUI framework) end-to-end viaperry.compilePackages#348) — wire ink as acompilePackagestarget so existing ink apps Just Work. Perry stays the runtime, ink stays the library. - Track B (this issue) — first-party
perry/tui: same architectural pattern asperry/uidoes for native GUI, but for the terminal. Cell-level dirty tracking + double buffering from day one, designed not to have ink's flicker problem. For users who want zero-flicker out of the box and don't care which JS lib is under the hood, this becomes the recommended path.
Architecture (matches perry/ui shape)
TS (declarative) → HIR → Codegen → calls into perry-tui (Rust) → terminal
├─ layout engine (Taffy)
├─ cell grid + dirty tracking
├─ double-buffered renderer
└─ ANSI emitter (minimal escape sequences)
- Runtime crate — new
crates/perry-tui(Rust). Cell grid as packedVec<Cell>(no per-cell allocation per frame). Double buffer: render to back buffer, diff against front, emit only the minimal ANSI to reconcile changed cells. SIGWINCH handler + graceful resize. - Layout — Taffy (Rust-native flexbox/grid, used by Bevy / Dioxus). Avoids the Yoga FFI/cross-compile mess Compile
ink(React-based TUI framework) end-to-end viaperry.compilePackages#348 has to solve. Same flexbox mental model ink users are used to. - TS surface — declarative widget set exposed via
import { Box, Text, render, useInput, useEffect } from "perry/tui". JSX-friendly;perry compilelowers to native widget calls the same wayperry/uidoes (HIRNativeMethodCalldispatch). - State / hooks — small built-in hook surface (
useState,useEffect,useInput,useStdout) — no React, no react-reconciler dep. The whole point is to skip the "JS object per cell per frame" tax. If users want React semantics, they use ink (Track A). - Widget set (v1) —
Box,Text,Spacer,Input(single-line),TextArea,List,Select,Spinner,ProgressBar.TableandTabsv1.5.
Depends on
- TUI gap: readline + tty.setRawMode + raw-mode stdin reader #347 — raw-mode stdin / readline /
tty.setRawMode/tty.isatty/process.stdout.columns/rows/ SIGWINCH. Same primitives ink needs; this issue can't render keystrokes until at least Phase 2 of TUI gap: readline + tty.setRawMode + raw-mode stdin reader #347 lands.
Acceptance
- A 30-line
perry/tuicounter program (increment on+, decrement on-, quit onq) compiles viaperry compile, produces a single-file native binary, runs interactively. - A streaming-log viewer that appends 1000 lines/sec to a scrollable region shows no visible flicker on Terminal.app / iTerm2 / Windows Terminal / GNOME Terminal. (This is the explicit anti-goal vs ink — if
perry/tuiflickers on this workload, the design is wrong.) - Resize (SIGWINCH) reflows correctly without artifacts.
- Layout matches Taffy's flexbox semantics (verified against a small parity suite —
display: flex,flex-direction,justify-content,align-items,flex-grow). perry/tuiadds zero npm dependencies — it's a built-in module likeperry/ui/perry/thread.
Out of scope (separate follow-ups)
- React compatibility shim — if there's demand for "use React with
perry/tui's renderer", that's a v2 conversation. v1 ships with Perry's own minimal hook API. - Mouse input — niche enough to defer.
- Image / sixel / kitty graphics protocol — defer.
- ink-style component library compat (
ink-table,ink-spinner, etc.) — those users should use ink via Compileink(React-based TUI framework) end-to-end viaperry.compilePackages#348.
Why both tracks, not one
ink ships ~3.1M weekly downloads vs react-blessed's ~640 — it's the dominant JS TUI library and a lot of existing apps need it. But the framework wars in this space aren't settled (ink's flicker, no React 19 yet, heavy dep tree; Anthropic's rewrite; OpenTUI; Ratatui-bindings experiments). Perry shouldn't try to settle them. #347 makes Perry the runtime that any of them can target; this issue gives Perry users a first-party option that doesn't have ink's known architectural papercuts.
Activity
feat(tty): closes #347 — Phase 3 isatty + columns/rows + SIGWINCH (v0…
feat(tui): #358 Phase 1 — perry/tui native engine v0.1 (v0.5.469)
v0.5.473 — #358 Phase 2 (perry/tui state + input + run loop) + iOS de…
feat(tui): #358 Phase 3 — Taffy flexbox layout (v0.5.474)
fix(codegen): closes #392 followup — route Map/Set field mutations on…
feat(tui): #358 Phase 4.5 — Spinner + Input + List + Select + TextAre…
feat(tui): closes #405 — Phase 3.5 BoxStyle additions (v0.5.775)
feat(tui): closes #679 — ink-API ergonomics layer (all 5 phases, v0.5…
fix(tui): #679 follow-up — register GC root scanners for hook + state…
fix(tui): #679 follow-ups — runtime-array Box children + hook/state G…
MehmetAliPolat1515 commentedon Aug 22, 2026
As someone who uses the TUI feature, I think it would be good to develop and make improvements.