Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 232 additions & 0 deletions docs/design/2026-08-28-opentui-migration-design.md
Original file line number Diff line number Diff line change
@@ -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) |
Loading