mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
docs(design): Align OpenTUI migration notes with the landed batches (#10724)
* docs(opentui): Record the migration status through the composition root The design doc still described the state of 2026-08-28, with only the infra batch landed. Record the five batches now on main, name the seams the composition root leaves to renderer activation, and list the two items deferred to that batch. * docs(opentui): Record the composition-root contracts and correct stale activation scope The design doc described QWEN_TUI_RENDERER as an existing opt-in and the activation batch as carrying runtime fixes that already shipped in #10128. Both drifted from the code while the batches landed, which is the kind of claim a reviewer had to catch on #10696. State the contracts the composition-root review settled so the activation batch inherits them instead of rediscovering them. * docs(opentui): Record measured runtime status and what the batch reviews kept finding Three claims in the design doc described instruments and gates that are not in the tree: the session-replay harness (issue #10005 is still open, nothing measures flicker today) and plain-Node loadability, which 0.5.8 fails on Node 24 — verified locally, not just reported in review. The recurring finding classes are recorded so the activation batch does not re-earn them.
This commit is contained in:
1 parent
d6c0390816
commit
180468306c
1 file changed
+151
-41
@@ -1,16 +1,20 @@
|
||||
# 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.
|
||||
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.
|
||||
|
||||
## 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
|
||||
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.
|
||||
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.
|
||||
|
||||
## Why migrate
|
||||
|
||||
@@ -122,7 +126,7 @@ The pieces, in landing order:
|
||||
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
|
||||
- **Foundation modules** (landed, #10146). 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
|
||||
@@ -132,19 +136,28 @@ The pieces, in landing order:
|
||||
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.
|
||||
- **Live-session & input** (landed, #10368). 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** (landed, #10383). The dialog family and dialog
|
||||
data, the folder-trust gate, session rewind, the slash-command
|
||||
registry/dispatch surface, the help overlay.
|
||||
- **Backend composition root** (landed, #10696). The OpenTUI app shell:
|
||||
command bridge, dialog mount, error boundary, runtime sidecar. It is a
|
||||
thin assembly, not a copy of `AppContainer`: the transcript view, model
|
||||
turn, session re-key and composer control stay explicit seams for the
|
||||
activation batch to own.
|
||||
- **Renderer activation** (next). Renderer dispatch plus the runtime gate
|
||||
and the entry wiring into `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.
|
||||
- **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).
|
||||
@@ -152,11 +165,50 @@ The pieces, in landing order:
|
||||
### 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.
|
||||
`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.
|
||||
|
||||
### Composition-root contracts
|
||||
|
||||
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:
|
||||
|
||||
1. **A seam may be unwired, never parked.** Every confirmation or resolution
|
||||
the root owns must settle unconditionally — the stub bridge denies
|
||||
outright, and denies again if the bridge itself rejects. A resolver left
|
||||
in a slot nothing reads is not a missing feature: the slash gateway's
|
||||
busy flag never clears, so every later command is refused until restart.
|
||||
2. **No silent no-op for an action the user took.** Where the owner does not
|
||||
exist yet, the root reports through the visible notice channel instead of
|
||||
an empty arrow, so the missing owner is named rather than the input
|
||||
vanishing.
|
||||
3. **Seams carry structured data, not invented formats.** Pasted image paths
|
||||
travel beside the prompt text as their own argument; constructing the
|
||||
image part belongs to the entry layer. Folded into the text, the entry
|
||||
would have to reverse a private encoding.
|
||||
4. **Isolate caller-owned steps inside another layer's commit window.**
|
||||
`/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.
|
||||
5. **Callback identity at the mount is a contract, not a perf note.** The
|
||||
host memo and dispatcher effect key on the callbacks they are given, so
|
||||
the mount site must pass stable refs (U-8) — inline callbacks would
|
||||
rebuild the host and re-run the interactive command loader per render.
|
||||
|
||||
## Rollout phases and gates
|
||||
|
||||
@@ -175,7 +227,9 @@ so plain-Node loadability is a Phase 3 gate (below), not an assumption.
|
||||
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;
|
||||
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;
|
||||
- explicit drop / replace / defer-with-tracking-issue decisions for the
|
||||
degraded modes: legacy scrollback mode, iTerm2 inline images,
|
||||
screen-reader support.
|
||||
@@ -184,23 +238,58 @@ so plain-Node loadability is a Phase 3 gate (below), not an assumption.
|
||||
|
||||
## 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).
|
||||
- **The PTY harness is the intended shared acceptance instrument — and does
|
||||
not exist yet.** The plan is for both renderers to be exercised through the
|
||||
same terminal-level harness, with flicker metrics (erase counts,
|
||||
full-screen clears, frame patterns) as the objective measure and an ink
|
||||
baseline on current main so every later phase is measured against numbers,
|
||||
not anecdote. That is #10005, still open: no metrics library, runner, or
|
||||
recorded baseline is in the tree, `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.
|
||||
- **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.
|
||||
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 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.
|
||||
|
||||
### What the reviews kept finding
|
||||
|
||||
Recurring across the five landed batches, and the activation batch will draw
|
||||
on the same classes:
|
||||
|
||||
- **An assertion outrunning the code.** A PR body, docblock, or test header
|
||||
claiming a mechanism that was not there — the `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.
|
||||
- **Tests that cannot fail.** In the dialogs-and-commands batch, tested logic
|
||||
killed 15/15 injected mutants while five untested modules survived 5/5 with
|
||||
the whole 927-test suite green, and one _tested_ dialog hook still carried
|
||||
a re-sync assertion that no change could turn red. Name-only dialog stubs
|
||||
did the same job in the composition-root batch, hiding three dead-end
|
||||
callbacks. A stub that records nothing asserts nothing.
|
||||
- **Silent success at a seam.** Empty arrows, optional chains, and
|
||||
written-but-never-read slots, in both renderer and error paths. This is what
|
||||
the contracts section exists to forbid.
|
||||
- **Untrusted text reaching the terminal.** Diff bodies, MCP output, and
|
||||
projections skipping the sanitiser — filed repeatedly in the foundation and
|
||||
live-session batches. The specific paths named there escape today, so read
|
||||
this as a class to re-check on every new projection, not an open hole.
|
||||
- **Parity copies with no drift guard.** The same rule living twice, byte
|
||||
identical today, nothing asserting it stays that way — the migration's own
|
||||
failure mode, raised in the dialogs-and-commands review. It needs an
|
||||
assertion, not a comment.
|
||||
- **Deferrals with no address.** Items acknowledged in a review thread as
|
||||
"valid, deferred" were never registered as an issue or a ledger row, so no
|
||||
later batch owns them. The migration's U-xx numbering covers #10696
|
||||
onward; the earlier test-hardening and dead-code deferrals still do not.
|
||||
|
||||
## Accepted trade-offs (documented, unchanged)
|
||||
|
||||
- mermaid degrades to a code block for now;
|
||||
@@ -216,6 +305,21 @@ so plain-Node loadability is a Phase 3 gate (below), not an assumption.
|
||||
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.
|
||||
- Composition-root callback identity (#8662 U-8) — the shell memoizes its
|
||||
host and dispatcher effect on the callback props it is given, so the
|
||||
activation batch must pass stable identities at the mount site; no mount
|
||||
site existed to fix this in the composition-root batch.
|
||||
- Settings sub-dialog routing and composer ownership (#8662 U-9) — deferred
|
||||
from the composition-root batch: the dialog mount has no channel to switch
|
||||
to a sub-dialog and the composer belongs to the entry layer, so those
|
||||
settings rows and the arena picker report an unwired seam instead of
|
||||
acting.
|
||||
- Fatal render-error parity at the mount (#8662 U-10) — the composition root
|
||||
wraps its subtree in the boundary with no props, while ink's call site
|
||||
passes the exit-echo flag and an error hook that logs and schedules a
|
||||
graceful exit; nothing outside the boundary's own test reads the OpenTUI
|
||||
render-error store. Both ends live in the entry, so only the activation
|
||||
batch can wire and verify them.
|
||||
- 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.
|
||||
@@ -224,9 +328,15 @@ so plain-Node loadability is a Phase 3 gate (below), not an assumption.
|
||||
|
||||
## 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) |
|
||||
| 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) |
|
||||
Reference in new issue
Block a user