docs/design/2026-08-28-opentui-migration-design.md
Tracking: #8662. Status 2026-09-01: Phase 1 in progress; five of the seven batches are on main (infra, foundation modules, live-session & input, dialogs & commands, backend composition root). Renderer activation is the next batch.
Replace the ink-based TUI renderer with an OpenTUI-based one without any
user-visible regression, landing the implementation on main in reviewable
batches. OpenTUI is opt-in (QWEN_TUI_RENDERER=opentui) and ink remains
the default renderer for all of Phase 1; the default flip and the ink
removal are separate, explicitly gated phases. That opt-in is not a switch a
user can pull yet: the variable appears in no code until the activation
batch adds it, so every batch before it is inert because nothing imports it
— not because a gate routes around it.
The current TUI is ink 7 + React 19 behind a ~1037-line renderer patch and a hand-rolled virtual viewport (~920 lines). Three structural problems are not fixable inside ink:
incrementalRendering enabled: the erase-then-write structure
remains, so the flicker class remains.A source-level study of five terminal UIs with byte-level PTY comparison shows OpenTUI's renderer (Zig native core: row-memcmp fast path, cell-level diff, run coalescing, zero erase sequences, DEC 2026 synchronized output) eliminates the flicker class by construction. A local proof-of-concept measured 0 erase sequences vs ink's ~749 per 6 seconds and no flicker on the terminals that reported it. A replay harness on a real 141-event session confirmed the delta: ink emitted 16 full-screen clears and 67 line erases; OpenTUI emitted zero.
@opentui/react binding. The Solid track
(@opentui/solid) is deferred indefinitely; both tracks would double the
surface for one user-visible outcome.The migration's central invariant: framework dependencies point inward to
the renderer, never outward into the core. Two rules, enforced by
scripts/check-tui-dep-direction.mjs in CI:
packages/core/src is framework-neutral. No imports of ink,
react, solid, or @opentui/* (as whole ecosystems, including scoped
variants), and nothing that reaches into packages/cli — neither relative
paths nor the cli package's own bare name, which resolves through the
workspace symlink.packages/cli/src/ui/model is framework-neutral streaming
state. The same family ban, plus self-containment: no relative import
may resolve outside the directory, so no framework-dependent sibling can
leak in through a relative path.The gate is fail-closed by construction: any symlink under a rule root or in
a rule root's path, any unlistable directory, any skipped-directory name
(node_modules/dist/.git), and an empty root all fail it instead of
silently shrinking the scan. Detection is AST-based (TypeScript compiler),
covering static and dynamic imports, the CommonJS and vitest loading forms,
import-type queries, import-equals, ambient module declarations, and
resolution probes, so comments, strings, and interpolated templates can
neither mask nor fake an import.
┌───────────────────────────────┐
│ entry / startInteractiveUI │
│ renderer dispatch + runtime │
│ gate (Bun, or Node + ffi) │
└──────────┬─────────┬──────────┘
default │ │ QWEN_TUI_RENDERER=opentui
┌──────────▼──┐ ┌──▼─────────────────────────┐
│ ink renderer│ │ OpenTUI backend │
│ (patched) │ │ app shell · dialogs · │
│ │ │ composer · event adapter │
└──────┬──────┘ └──────────┬─────────────────┘
│ │
┌──────▼─────────────────────▼─────────────────┐
│ framework-neutral streaming model │
│ packages/cli/src/ui/model — pure reducer: │
│ stream events → history items (immutable) │
└──────────────────┬───────────────────────────┘
│
┌──────────────────▼───────────────────────────┐
│ packages/core — business core, no framework │
└──────────────────────────────────────────────┘
The pieces, in landing order:
task-end fold also derives each task's
stats line as an output field on the history item (there is no separate
stats input event). Both renderers consume the same fold,
which is what keeps their transcripts structurally identical; the ink-side
wiring lands with the batch that carries its consumers. The model replaces
nothing in ink today — it is additive, and its contract is pinned by an
immutability test matrix (no state or item reachable from an earlier fold
result may ever change).AppContainer: the transcript view, model
turn, session re-key and composer control stay explicit seams for the
activation batch to own.startInteractiveUI, and small shared exports.
Default renderer stays ink. OpenTUI becomes reachable only through the
flag. This is where the composition root's seams get their owners, so the
contracts below become load-bearing here: the mount passes stable callback
identities (#8662 U-8), the confirmation stub becomes a real dialog rather
than keeping its denial, and the boundary is mounted the way ink mounts its
own (#8662 U-10). The runtime fixes this batch used to carry — the Bun
memory flags and the goal-runtime startup wait — landed early in #10128.Selection is environment-driven, not config-file-driven:
QWEN_TUI_RENDERER=opentui opts in; anything else (or unset) is ink. None of
that exists yet — the env read, the dispatcher and the fallback are all
delivered by the activation batch, which is the first code to name the
variable. The activation batch additionally gates on the runtime: OpenTUI's
native core loads under Bun and under Node with node:ffi; where neither
holds, the dispatcher falls back to ink silently. npm remains the primary
install path, so plain-Node loadability is a Phase 3 gate (below), not an
assumption.
Measured status of that assumption: @opentui/core 0.5.8 — the version
pinned in packages/cli — selects its FFI backend with require('node:ffi'),
and that specifier does not resolve on Node 24 (ERR_UNKNOWN_BUILTIN_MODULE;
reported on 24.18.1 during the dialogs-and-commands review and reproduced
locally on 24.15). The native renderer therefore initialises under Bun
only, today. Two consequences: the silent ink fallback above is
load-bearing rather than defensive, and the activation batch is runnable
end-to-end only under Bun until the Node leg is proven.
Settled while reviewing #10696. They are what keeps a deliberately thin root safe to run before its owners exist, and the activation batch inherits them:
/resume and /branch set their commit flag only after the last UI step
returns, so one throwing subscriber rolled core back to the previous
session and deleted the fork just shown. Each step now runs isolated.node:ffi): boot
plus smoke, not assumed. Bun-only is not acceptable as the default — and
Bun-only is where 0.5.8 stands today (measured status above), so this
gate is open, not merely unproven;scripts/ carries only the
dependency-direction gate, and no test drives ink's test renderer. Until it
lands, flicker claims rest on the one-off PTY measurements cited above.packages/cli
vitest suite green; the default (ink) path byte-for-byte unchanged — the
batches landed so far touch zero reachable ink code paths because nothing
outside src/ui/opentui/ imports them. The activation batch additionally
smoke-tests the flag under Bun (boot, dialog, live turn, exit drain). The
build/CI batch wired the renderer matrix into the existing e2e legs and
added the tui-parity gate; its own OpenTUI interactive e2e leg ran on
main, reported four real gaps (approval-mode indicator never drawn,
@file expansion absent, submitted_prompt dropped, and a slash command
submitted mid-turn racing the open stream instead of waiting for idle),
came out of CI until they closed, and is back in #10831 now that they have.Recurring across the five landed batches, and the activation batch will draw on the same classes:
QWEN_TUI_RENDERER opt-in, a
"separate PTY gate" that no workflow or test implements, an image-path
encoding no reader exists for. Grep the claim before writing it, again
after every fix commit.remend dependency — deferred from the infra batch; lands with the
first batch that carries its consumer.baseUrl bare-specifier resolution.@opentui/solid) and the original proposal's M4 A/B evaluation
remain deferred.| Batch | PR |
|---|---|
| Infra | #10134 (merged) |
| Foundation modules | #10146 (merged) |
| Live-session & input | #10368 (merged) |
| Dialogs & commands | #10383 (merged) |
| Backend composition root | #10696 (merged) |
| Renderer activation | next batch |
| OpenTUI runtime npm packaging (not a batch) | #9885 (merged) |
| These design notes (not a batch) | #10343 (merged) |
| Startup robustness: goal-runtime wait, Bun relaunch | #10128 (merged) |
| Original implementation (superseded by the batches) | #8677 (draft) |