docs(design): OpenTUI migration design and architecture notes (#10343)

* docs(design): publish the OpenTUI migration design and architecture notes

Reference document for the batched TUI migration (#8662), requested in the
maintainer direction call: motivation and measurements, the dependency-
direction invariant and its CI gate, the layering from streaming model to
renderer dispatch, the seven-batch landing plan with acceptance criteria,
the rollout phases with explicit flip gates, and the tracked deferrals.

* docs(design): stats is a derived output field, not a StreamEvent member

The streaming-model section listed stats among the folded input events,
but the landed StreamEvent union has no stats member — task-end derives
the stats line as an output field on the task history item. Rephrased so
readers grepping for a stats event find the actual shape.
This commit is contained in:
ChiGao authored and GitHub committed 2026-08-29 02:13:55 +00:00
1 parent 045ae1fc2b
commit 48ec008345
1 file changed
+232
@@ -0,0 +1,232 @@
# OpenTUI Migration — Design and Architecture
Tracking: [#8662](https://github.com/QwenLM/qwen-code/issues/8662). Status
2026-08-28: Phase 1 in progress; the infra batch has landed on main, the
foundation-modules batch is under review.
## Goal
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 stays 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.
## Why migrate
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:
- **Flicker.** ink's write pattern is erase-then-full-rewrite every frame.
Measured on real terminals (Warp, Tabby, Windows PowerShell), including
with ink's `incrementalRendering` enabled: the erase-then-write structure
remains, so the flicker class remains.
- **Mouse is second-class.** Selection/copy and click interactions lag a
native terminal; the virtual-viewport mouse path has known parity gaps.
- **Maintenance ceiling.** The ink patch and the virtualized list block
rendering improvements; #6137, #8580 and #8659 were all filed against the
already-patched build, which is the most direct evidence that the
incremental path has plateaued.
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.
## Key decisions
1. **Full replacement over incremental patching** — the ink patch has hit its
ceiling (evidence above), so the target is a renderer swap, not more
patching. Approved by the maintainers for Phase 1 on 2026-08-26.
2. **OpenTUI + React track** — the `@opentui/react` binding. The Solid track
(`@opentui/solid`) is deferred indefinitely; both tracks would double the
surface for one user-visible outcome.
3. **1:1 parity as the acceptance bar** — the goal is "exactly the product,
new renderer", not a redesign. Every behavior difference must be a
documented, tracked decision (see "Accepted trade-offs"), never an
accident.
4. **Batched, additive-only landing** — seven batches in dependency order,
each a self-contained PR with its own acceptance criteria. Until the
renderer-activation batch, no batch touches a reachable ink code path.
5. **A machine-checked dependency direction** — the renderer is an
outer layer; the business core stays framework-neutral, enforced by a CI
gate rather than by convention (below).
## Architecture
### Dependency direction
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:
- **Rule 1 — `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.
- **Rule 2 — `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.
### Layering
```
┌───────────────────────────────┐
│ 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:
- **Streaming model** (landed, infra batch). A pure, immutable reducer that
folds live stream events (user, thinking, text, tool/task lifecycle, done)
into ordered history items; the `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).
- **Foundation modules** (under review). The renderer-agnostic services the
later batches build on: the theme family, accessibility (plain-text
transcript projection + screen-reader path), clipboard (OSC 52 with
multiplexer handling plus platform fallback), key-map, mouse hit-testing
and caret placement, link detection/OSC 8, early-input handling, exit
guard/lifecycle, kitty keyboard negotiation, the event adapter (session
events → display payloads), item projection (history items → text for
a11y/clipboard), slash-command dispatch into the existing command
registry, and the dialog scaffolding with the theme dialog as its first
consumer.
- **Live-session & input.** Live-session stream fold wiring, message
rendering, transcript adapter with resume/session-switch, sticky todos,
the composer, mouse rows/scrollbar/selection with multi-click select,
diff rendering, session compaction.
- **Dialogs & commands.** The dialog family and dialog data, the
folder-trust gate, session rewind, the slash-command registry/dispatch
surface, the help overlay.
- **Backend composition root.** The OpenTUI app shell: command bridge,
dialog mount, error boundary, runtime sidecar.
- **Renderer activation.** Renderer dispatch plus the runtime gate and the
entry wiring into `startInteractiveUI`; small shared exports and runtime
fixes. **Default renderer stays ink.** OpenTUI becomes reachable only
through the flag.
- **Build & CI.** Bundle asset pipeline for the OpenTUI runtime assets, the
CI legs (e2e, tui-parity), renderer-matrix integration tests, and the
parity tooling (codemod, PTY harness).
### Renderer selection and runtime gate
Selection is environment-driven, not config-file-driven:
`QWEN_TUI_RENDERER=opentui` opts in; anything else (or unset) is ink. 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.
## Rollout phases and gates
1. **Land the code** (current) — the seven batches above. Additive-only; ink
untouched; each PR lands on its own review merits.
2. **Real-device validation** — flag-driven use on real terminals. Preview
builds are published for hands-on testing; confirmed parity gaps are
tracked in the tracking issue and must be closed before the flip:
- G-1 first-run auth onboarding dialog auto-open
- G-2 follow-up prompt suggestions (composer ghost text)
- G-3 persistent update-notification bar
3. **Flip the default** — a small PR switches the default renderer after all
of:
- G-1 through G-3 closed;
- real-device validation on the terminals the original flicker reports
came from — Windows PowerShell, web-based terminals, tmux < 3.5 — not
only the ones already tested (Warp/Tabby/macOS);
- the renderer demonstrated loadable under plain Node (`node:ffi`): boot
plus smoke, not assumed. Bun-only is not acceptable as the default;
- explicit drop / replace / defer-with-tracking-issue decisions for the
degraded modes: legacy scrollback mode, iTerm2 inline images,
screen-reader support.
4. **Remove ink** — delete the ink renderer, the ink patch, and the
virtualized-list/viewport mode once OpenTUI is the stable default.
## Verification strategy
- **The PTY harness is the shared acceptance instrument.** Both renderers
are exercised through the same terminal-level harness; flicker metrics
(erase counts, full-screen clears, frame patterns) are the objective
measure. An ink baseline on current main is recorded so every later phase
is measured against numbers, not anecdote (#10005 tracks the metrics
library, runner scenarios, and fixtures).
- **Per-batch acceptance criteria** (each landing PR): build and typecheck
clean across workspaces; ESLint + Prettier clean; the full `packages/cli`
vitest suite green; the default (ink) path byte-for-byte unchanged —
batches that add flag-gated code touch zero reachable ink code paths. The
activation batch additionally smoke-tests the flag under Bun (boot,
dialog, live turn, exit drain); the build/CI batch runs the OpenTUI e2e
leg green on CI.
- **1:1 parity audits** — screen-by-screen comparison against ink, plus a
reverse audit pass, with surviving differences landing as tracked gaps
(G-series) rather than silent drift.
## Accepted trade-offs (documented, unchanged)
- mermaid degrades to a code block for now;
- legacy terminal-scrollback mode dropped (single scrollbox);
- iTerm2 inline images not supported (kitty/sixel/blocks only);
- screen-reader support to be evaluated before the default flip.
## Deferred items (tracked, land with their batches)
- The `remend` dependency — deferred from the infra batch; lands with the
first batch that carries its consumer.
- Ink-side wiring of the streaming model — lands with the batch that carries
the ink consumers.
- ESLint rules for OpenTUI JSX — deferred from the infra batch to the
foundation-modules batch, the first batch carrying OpenTUI JSX sources.
- Gate hardening noted in the infra PR's second review round: JSX implicit
runtime imports, triple-slash/JSDoc type references, UTF-16 sources, and
tsconfig `baseUrl` bare-specifier resolution.
- Track-2 (`@opentui/solid`) and the original proposal's M4 A/B evaluation
remain deferred.
## Related PRs
| Batch | PR |
| --------------------------------------------------- | ---------------------------------------- |
| Infra | #10134 (merged) |
| Foundation modules | #10146 |
| OpenTUI runtime npm packaging | #9885 (rebases after the build/CI batch) |
| Original implementation (superseded by the batches) | #8677 (draft) |