feat(opentui): Activate the OpenTUI backend behind QWEN_TUI_RENDERER - #10739
Conversation
goalCommand returns only goal_control or a message, so the route table declared a result kind no code path can produce. The parity test asserts the declared kinds by set equality, so it is updated with the registry.
…(Batch 6) Wire the OpenTUI renderer end to end while ink stays the default: the interactive entry dispatches on QWEN_TUI_RENDERER, gated to runtimes that can load the native FFI, and a failed renderer probe falls back to ink silently. The entry mounts the composition root with the ink-parity seams (runtime sidecar, session registration, update notices, two-press exit, early-input injection, resume replay, render-error echo), a live-turn driver folds model stream events into the transcript, and the Batch 5 deny-everything confirmation bridge is replaced with real tool / shell / action dialogs so model turns can be approved interactively.
|
|
|
Thanks for the Batch 6 activation PR! Template looks good ✓ Problem: real and evidenced. The tracking issue documents observed ink flicker on Warp/Tabby/Windows PowerShell with measurements (ink's erase-then-rewrite frames vs OpenTUI's zero erase sequences), plus the maintenance burden of the ~1000-line ink patch. This batch closes a concrete gap: until now the OpenTUI backend was reachable only from unit tests — Direction: aligned. This is the "Renderer activation" row of the Phase 1 plan in #8662, following five batches that already merged with maintainer review; ink stays the default and the new backend is opt-in with silent fallback on any failure. One housekeeping observation: #8662 was closed as completed by the Batch 5 merge (#10696), but its checklist still lists this batch and the Build & CI batch as pending — the tracking issue may deserve a reopen (or a successor) so the remaining work stays visible. The superseded predecessor #8677 is also still open. CHANGELOG signal is not applicable here (internal renderer migration). Size: no core-module paths touched — all changes live in Approach: scope matches the planned batch (renderer dispatch + runtime gate + Risk: no elevated risk signals — none of the revert-correlated paths are touched, the default renderer stays ink, and every failure path (unsupported runtime, failed renderer probe, renderer construction throw) falls back to ink. Moving on to code review. 🔍 中文说明感谢提交 Batch 6 激活 PR! 模板完整 ✓ 问题:真实且有证据。跟踪 issue 记录了在 Warp/Tabby/Windows PowerShell 上观测到的 ink 闪烁,并附有测量数据(ink 的"擦除后整帧重写" vs OpenTUI 的零擦除序列),以及约 1000 行 ink 补丁的维护负担。本批关闭了一个具体缺口:此前 OpenTUI 后端只能在单测中触达—— 方向:对齐。这是 #8662 Phase 1 计划中的"Renderer activation"一行,此前五个批次已经过维护者评审并合入;ink 保持默认,新后端为可选启用,任何失败都会静默回落。一个事务性观察:#8662 被 Batch 5 的合入(#10696)以"已完成"关闭,但其清单仍列着本批与 Build & CI 批未完成——跟踪 issue 或许值得重开(或开后续 issue),让剩余工作保持可见。被取代的前身 #8677 也仍处于打开状态。CHANGELOG 信号在此不适用(内部渲染器迁移)。 规模:未触及核心模块路径——所有改动位于 方案:范围与计划批次一致(渲染器分发 + 运行时门控 + 风险:无升级风险信号——未触及任何与回滚相关的高风险路径,默认渲染器保持 ink,所有失败路径(不受支持的运行时、渲染器探测失败、渲染器构造抛错)均回落 ink。 进入代码审查。🔍 — Qwen Code · qwen3.8-max Reviewed at |
The ink tree registers a SessionEnd hook cleanup in AppContainer, which the OpenTUI entry bypasses entirely — user-configured SessionEnd hooks would silently never run under QWEN_TUI_RENDERER=opentui. Mirror the ink cleanup in the entry's exit chain.
No caller ever supplied it: the entry syncs the host's streaming state through the shell's streaming prop instead.
|
Post-merge-push self-audit notes and answers to the triage checkpoints: Self-audit fixes (pushed after
Triage checkpoint (1) — the folded Confirmed: commit Triage checkpoint (2) — U-6/U-7 status Explicitly deferred, not wired here:
All three remain tracked in #8662 with their rationale. |
Code reviewThe approach lands cleanly. The selection module is dependency-free and conservative (exact flag match, case-insensitive, trimmed; unsupported runtime → ink with a reason string), the entry probe wraps The seam implementations check out against what main already provides: the removed Two commits landed while this review was in flight; both are small, both reviewed as part of this pass: the SessionEnd-hook fix registers a cleanup that fires Two observations, neither blocking:
sequenceDiagram
participant P1 as llm.tsx main
participant P2 as renderer-selection
participant P3 as start-opentui-ui
participant P4 as OpenTuiApp shell
participant P5 as live-turn driver
participant P6 as dialogs-confirm
P1->>P2: selectTuiRenderer
alt flag set and runtime supported
P1->>P3: startOpenTuiUI
P3->>P3: createCliRenderer probe
alt probe throws
P3-->>P1: returns false, fall back to ink
else probe succeeds
P3->>P4: mount composition root
P5->>P4: fold stream events into transcript
P5->>P4: waiting call parked in awaiting approval
P4->>P6: render confirmation dialog
P6-->>P5: onConfirm outcome settles the call
end
else flag unset or runtime unsupported
P1->>P1: ink path unchanged
end
Files changed (14)
TestingThis is an unattended CI run, so evidence below is the PR's own CI read via the API — no PR code was built or executed here. The main unit-suite and integration legs are still running at review time; the table updates in place once CI settles. Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 The macOS/Windows unit legs are skipped (not failed) and the on-demand lanes ( Sandboxed verification would settle the behavioral claims this diff cannot prove statically: 中文说明代码审查方案落地干净。选择模块无依赖且保守(精确匹配标志值、忽略大小写、去空白;运行时不受支持 → 回落 ink 并给出原因字符串),入口探测包裹了 接缝实现与 main 已提供的能力核对无误:被移除的 评审过程中新增了两个提交,均已纳入本次审查:SessionEnd 钩子修复注册了一个清理回调,通过钩子系统触发 两点观察,均不阻塞:
测试本次为无人值守 CI 运行,以下证据来自 API 读取的 PR 自身 CI——未在此构建或执行任何 PR 代码。主单测与集成腿在评审时仍在运行;CI 结束后表格会就地更新。macOS/Windows 单测腿为 skipped(非失败),按需车道( — Qwen Code · qwen3.8-max Reviewed at |
|
Confidence: 4/5 — clean static review of a well-scoped planned batch; the remaining reservations are housekeeping, not code. Honest read: this is the strongest shape of a large PR — every line belongs to one coherent job (making the opt-in backend actually bootable), the seams Batch 5 left exposed are implemented exactly where the plan said they would land, and the safety design is conservative in the right places: a dependency-free gate, dynamic imports so the default path never even resolves the new backend, a renderer probe whose failure mode is "ink, silently, with a debug log", and confirmation dialogs where every branch settles. Going back to what I would have written before reading the diff — I wouldn't have written it materially differently, and I didn't find a simpler path the PR missed. The two commits that landed mid-review (SessionEnd hook parity, dead-option removal) were both in the right direction. What keeps this at 4 rather than 5: (1) the tracking issue #8662 lists U-6/U-7/U-9 under Batch 6, they aren't in this PR, and since #8662 got closed by the Batch 5 merge the deferred parity gaps (G-1 auth auto-open, G-2 suggestions) currently have no open tracking issue — that's a visibility problem for Phase 2/3, not a defect here; (2) the boot/confirmation/exit behaviors rest on the author's tmux capture plus unit tests, which is fine for an opt-in experimental flag, but a maintainer should drive Verdict: approve. The CI unit-suite and integration legs are still running on the reviewed commit, so approval is deferred until CI lands green on 中文说明置信度:4/5 —— 对一个范围清晰的计划批次完成了干净的静态审查;剩余顾虑是事务性的,不是代码问题。 直说:这是大型 PR 最好的形态——每一行都服务于同一件连贯的事(让可选后端真正可启动),Batch 5 暴露的接缝按计划落在预定位置,安全设计在正确的地方保守:无依赖的门控、动态导入使默认路径根本不解析新后端、渲染器探测的失败模式是"静默回落 ink 并写调试日志"、确认对话框每个分支都会落定。回到我在读 diff 之前会写的方案——我不会写得更不同,也没找到 PR 遗漏的更简路径。评审期间新增的两个提交(SessionEnd 钩子平价、移除死选项)方向都对。 没到 5 分的原因:(1) 跟踪 issue #8662 把 U-6/U-7/U-9 列在 Batch 6,它们不在本 PR 中,而 #8662 已被 Batch 5 合入关闭,推迟的平价缺口(G-1 认证自动打开、G-2 建议)当前没有开放的跟踪 issue——这对 Phase 2/3 是可见性问题,对本批不是缺陷;(2) 启动/确认/退出行为依赖作者的 tmux 截图加单测,对可选实验标志这没问题,但在 Phase 3 规划把这些主张当作既成事实之前,维护者应驱动 结论:批准。被审提交上的单测与集成腿仍在运行,因此批准推迟到 CI 在该提交上全绿之后。 — Qwen Code · qwen3.8-max Reviewed at |
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
qqqys
left a comment
There was a problem hiding this comment.
One confirmed Critical (live-reproduced at 2057cbbd). Everything else in the diff reviewed clean — details below.
Critical: the ink fallback is dead code for the failure class it exists for — module evaluation throws before the renderer probe runs
Location: packages/cli/src/llm.tsx:1209
The dispatch block promises (its own comment): "every other case — including a failed renderer probe inside the entry — falls through to ink", and start-opentui-ui.tsx repeats "startup must never die here". That contract is broken. The entry chunk's module graph evaluates theme.ts at import time, whose module-scope buildSyntax() calls SyntaxStyle.fromStyles → resolveRenderLib() → throws when the OpenTUI native FFI is unavailable. The await import('./ui/opentui/start-opentui-ui.js') rejects, there is no try/catch around it in llm.tsx, and the process dies with An unexpected critical error occurred — startOpenTuiUI's createCliRenderer try/catch (the advertised fallback) is never reached because module evaluation precedes it.
Trigger on a gate-passing runtime: QWEN_TUI_RENDERER=opentui on Node ≥ 26.4.0 without --experimental-ffi. @opentui/core@0.5.8's own README states Node support requires that flag, and the PR's gate comment acknowledges it — but nothing checks process.execArgv, nothing passes the flag, and users are never told. On a stock Node 26.4+ launch, require('node:ffi') throws → unsupported backend → import-time crash → CLI does not start. Same crash for Bun ≥ 1.3 / flagged Node when the native binary fails to load (partial install, unsupported platform/libc).
Live repro at this head (this host runs Node 24.18.1, below the gate, so I relaxed only the MIN_NODE_VERSION constant in a scratch build to make the FFI path reachable — the mechanism is version-independent once the gate passes):
QWEN_TUI_RENDERER=opentui node dist/cli.js
→ An unexpected critical error occurred:
Error: Failed to initialize OpenTUI render library: OpenTUI native FFI is not available for this runtime yet
at resolveRenderLib (chunks/start-opentui-ui-*.js)
at SyntaxStyle.create → SyntaxStyle.fromStyles → buildSyntax (module scope)
[process exits; ink never boots]
Control runs at the unpatched head on the same host: default boot → ink ✓; QWEN_TUI_RENDERER=opentui on Node 24 → gate refuses, ink boots, zero stderr noise ✓ (the version gate itself works — the hole is everything it lets through).
Fix direction: wrap the dynamic import (and the startOpenTuiUI call) in llm.tsx in try/catch — on rejection, debug-log + stderr warn + fall through to the ink branch. That covers every module-scope failure mode, present and future. (Making theme.ts's SYNTAX lazy would also work but leaves the next module-scope landmine unguarded.) Optionally also log selectTuiRenderer().reason — gate refusals are currently silent even in debug output.
What verified clean at head
- Full
packages/cliOpenTUI suite: 64 files / 1001 tests green (incl. newlive-turn5,dialogs-confirm3,renderer-selection, updatedopentui-app-shell);tsc --noEmiton the cli package clean. /goalroute-table change is a correction, not a regression: inkgoalCommandnever emitssubmit_prompt, and the routes table is declarative (only the parity test readsresults).livePhaseremoval: zero remaining consumers repo-wide.- Confirmation dialogs: every path settles (settledRef guard, Esc→Cancel on all three dialogs, empty-questions settles Cancel from an effect, multi-select Enter requires non-empty),
hideAlwaysAllownarrowed safely, bodies row-bounded. live-turn: queue semantics (tool-boundary steering drain, rest-becomes-next-turn), abort→interrupted settle, image failures surface as notices.- llm.tsx dispatch otherwise mirrors the ink tail exactly (
clearCorruptionEnvVars(); return;), and the dynamic imports keep the ink path free of the FFI module graph. - The red
Test (ubuntu-latest, Node 22.x)lane is the known pre-existing main break (ChatEditor.test.tsx:425duplicatelanguagedeclaration — the fix is open in #10729); 5468/5468 web-shell tests pass, PR touches no web-shell file.
Non-blocking (experimental-flag batch, fine to defer): a waiting tool-confirmation dialog is not auto-pruned when its turn is aborted mid-park — it settles manually with one extra Esc, no hang.
中文
一个已确认的 Critical(在 2057cbbd 上实机复现),其余部分评审通过。
Critical:ink 回退对它本要覆盖的故障类别是死代码——模块求值在渲染器探测之前就已抛错。 llm.tsx:1209 的动态 import('./ui/opentui/start-opentui-ui.js') 会在模块图求值时执行 theme.ts 模块级的 buildSyntax() → SyntaxStyle.fromStyles → FFI 不可用时抛错,import 直接 reject,llm.tsx 没有 try/catch,进程以 “An unexpected critical error occurred” 退出;startOpenTuiUI 里 createCliRenderer 的 try/catch(宣传的回退路径)根本执行不到。触发条件:门控通过但 FFI 不可用——最主要的是 Node ≥26.4.0 未带 --experimental-ffi(@opentui/core 0.5.8 README 明确要求该 flag,而没有任何地方检查或传递它),以及原生二进制加载失败(部分安装、不支持的平台/libc)。已在本机实机复现(仅放宽版本门控常量以触达 FFI 路径;机制与版本无关)。默认路径和门控拒绝路径都验证正常。修复方向: 在 llm.tsx 给动态 import(及 startOpenTuiUI 调用)包 try/catch,失败时写日志并回落 ink。
本地验证:opentui 全套 1001/1001 通过、cli tsc 干净;/goal 路由表改动是修正而非回归;livePhase 无残留消费方;确认对话框每条路径都会落定。红色 Test(ubuntu) 是 main 上已有的 ChatEditor 重复声明问题(#10729 修复中),与本 PR 无关。
— qqqys periodic review round · qwen-code 0.22.3 · verified at 2057cbbd
| './ui/opentui/renderer-selection.js' | ||
| ); | ||
| if (selectTuiRenderer().renderer === 'opentui') { | ||
| const { startOpenTuiUI } = await import( |
There was a problem hiding this comment.
This await import rejects when the OpenTUI FFI is unavailable on a gate-passing runtime: the entry chunk evaluates theme.ts at module scope (buildSyntax() → SyntaxStyle.fromStyles → resolveRenderLib() throws), so the failure happens before startOpenTuiUI's createCliRenderer try/catch can run — the ink fallback below is unreachable for this failure class and the process exits with An unexpected critical error occurred. Reproduced live at this head (scratch build with only MIN_NODE_VERSION relaxed to reach the FFI path on Node 24): QWEN_TUI_RENDERER=opentui → immediate crash, ink never boots. Real-world trigger: Node ≥ 26.4.0 without --experimental-ffi (required per @opentui/core's README, referenced in the gate comment, but never checked or passed), or a failed native-binary load on Bun/Node. Fix: try/catch around this import (and the call) → warn + fall through to ink.
There was a problem hiding this comment.
Fixed in 5b1dd15. The dispatch block now wraps the entry import and the boot call in try/catch: a module-evaluation failure (theme.ts SYNTAX → resolveRenderLib() throwing on a gate-passing runtime with a broken FFI) lands there, warns on stderr, and falls through to ink. The sibling contract inside the entry is pinned by tests in start-opentui-ui.test.tsx (renderer-creation rejection → false; boot-body rejection → teardown + false).
|
Qwen Code review did not complete successfully. The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with |
qwen-code-dev-bot
left a comment
There was a problem hiding this comment.
Reviewed at head 66674aeb. One blocker, independently confirmed still standing at this head (the 12 commits since the review are all upstream merges; the finding and its thread are untouched).
Critical — the ink fallback is unreachable for the module-evaluation failure class, exactly on the runtimes the gate passes. renderer-selection.ts gates on version numbers only (Node >= 26.4.0 / Bun >= 1.3.0); its own docblock notes Node needs --experimental-ffi, but nothing probes that. When the gate says opentui, llm.tsx:1209 runs await import('./ui/opentui/start-opentui-ui.js') with no surrounding try/catch; that module's graph reaches theme.ts, whose module-scope export let SYNTAX = buildSyntax('dark') calls SyntaxStyle.fromStyles → SyntaxStyle.create() → resolveRenderLib() (verified against the published @opentui/core@0.5.8 dist) — which throws when the native FFI cannot load. Module evaluation precedes every try/catch inside startOpenTuiUI, so the process dies instead of falling through to ink, contradicting the dispatch comment ("including a failed renderer probe inside the entry — falls through to ink"). Trigger: stock Node ≥ 26.4 without --experimental-ffi and QWEN_TUI_RENDERER=opentui, or any native-binary load failure on a gate-passing runtime.
Expected fix: make the entry's import of the OpenTUI module graph itself guarded (catch the import rejection and fall through to ink, optionally with one debug line so the misconfigured opt-in is discoverable) — and/or make the gate's support probe actually test native loadability rather than the version floor alone. Whichever way, a runtime that passes selectTuiRenderer() must never be able to crash startup.
No other blocking issues found in my pass; CI on this head has no failures.
yiliang114
left a comment
There was a problem hiding this comment.
Review findings on 66674ae (all verified against the PR head; new/changed module tests pass 53/53 locally — the items below are untested gaps, not test failures):
P2 — llm.tsx entry import is unguarded (seconding the open thread). await import('./ui/opentui/start-opentui-ui.js') has no try/catch; theme.ts builds SYNTAX via SyntaxStyle.fromStyles at module scope (theme.ts:154), so on a runtime that passes the version gate but fails FFI load, the import rejects before startOpenTuiUI's createCliRenderer probe (start-opentui-ui.tsx:279) ever runs, crashing instead of falling back to ink. The 'silent fallback' promise in the dispatch comment holds only for failures inside the entry.
P2 — live-turn.ts resetTranscript (~line 226) never aborts the in-flight turn. Body is only setItems/setWaitingCalls; no abortRef.current?.abort(). A session switch/resume mid-turn strands streamingRef=true: the old turn keeps folding events into the new session's transcript, the parked confirmation is dropped unsettled, and every later submit goes to the steering queue instead of starting a turn.
P2 — live-turn.ts interrupt (~line 222) leaves waitingCalls mounted. The abort path settles open tools as interrupted but never clears waitingCalls, so Ctrl+C while a tool call awaits approval leaves the confirmation dialog over a settled turn; choosing an outcome then invokes details.onConfirm on a cancelled scheduler call.
P2 — transcript-view.tsx ToolCard (~lines 247/250) renders description and suffix without sanitizeTerminalText while every other text path in the file sanitizes. description is rebuilt from tool args in messages.tsx (e.g. run_shell_command's command), so model/tool-controlled escape sequences reach the TUI raw — violates the migration's mandatory-sanitiser rule.
P3 — renderer-selection parseVersion drops pre-release suffixes ('1.3.0-beta' compares equal to the floor '1.3.0'); treat pre-release as strictly below the floor.
P3 — dialogs-confirm AskUserQuestionFlow: a question with an empty options array is unanswerable forward (Enter blocked at selected.size === 0; the settle guard only covers questions.length === 0) — user is forced to Esc the whole call.
Also worth a Batch-6 check: whether the tool scheduler actually cancels an awaiting_approval call on the abort signal — if it doesn't, Esc/Ctrl+C at a parked confirmation cannot end the turn until the dialog is answered. Not approving until at least the four P2s are addressed.
chiga0
left a comment
There was a problem hiding this comment.
R1 review @ 66674aebf6 — the standing Critical is still live, plus a second escape from the same fallback contract
Round note: only one review exists on this PR (qqqys, CHANGES_REQUESTED at 2057cbbd), and the head moved since. My account had not reviewed it, so this is round 1 from here. I am not re-filing his finding as my own — I re-checked it at the current head, and it stands.
The merge-deciding item (qqqys's llm.tsx:1209 Critical) is unfixed at 66674aebf6. The only commits between his head and this one are a Merge branch 'main' plus upstream PRs (61697df9b0, 359bda4b16, 431ff3a11f, 8b72f4d456) — no fix. Both ends of his mechanism are still exactly as he described at this head: llm.tsx wraps neither await import() in try/catch (the dispatch block at :1200-1226 is bare, its own comment still promising that "a failed renderer probe inside the entry … falls through to ink"), and theme.ts:154 still evaluates export let SYNTAX: SyntaxStyle = buildSyntax('dark'); at module scope, reachable transitively because opentui-error-boundary.tsx imports ./theme.js. Independently confirmed: @opentui/react's own esgen/tree-sitter*.native files on this machine are 0-byte Git-LFS pointers, so a FFI-less runtime is a real environment, not a hypothetical.
What I add is a second, separate escape from the same contract (inline below): even if module evaluation were made lazy, startOpenTuiUI's own docblock ("startup must never die here") holds only for the constructor, and the teardown that would restore the terminal is armed 28 lines after the last awaited step.
Closures — four findings from my earlier batches are fixed here, verified at head:
| earlier finding | status | evidence |
|---|---|---|
| #10696 R1-1 — extension-consent request stored, never read (the wedge twin) | fixed | opentui-host.ts:333-337 now routes through deps.confirmations and .then((confirmed) => value.onConfirm(confirmed)); the unread field is gone |
#10696 R1-2 — startNewSession: () => {} at the /resume and /branch commit point |
fixed | opentui-app-shell.tsx:258-261 delegates to onStartNewSession, and when absent it notifies "Session state was not re-keyed for the new session." instead of returning silently — the right shape |
#10696 (first pass) / 63aa2272 — presentShell never settling |
fixed, and the invariant is now written down | dialogs-confirm.tsx:19-20: "Every code path calls onConfirm — a request that never settles would hang the whole turn." |
#10383 R5-4 — /goal declaring a submit_prompt result it cannot return |
fixed | commands-registry.ts +1/-4 in this diff, matching the body's note |
One risk from that chain does not close here: session-switch.ts:201/:365 on main still set uiSwapped = true only after the post-re-key display steps (introduced in #10383, merged 8096ece889). This batch is what mounts them — start-opentui-ui.tsx:248 supplies onTranscriptReset and opentui-host.ts implements SessionSwitchHost — and I had already probed (3/3, in #10696) that host.notify() and resetTranscript() rethrow caller-supplied code synchronously. Still latent behind the runtime floor, but no longer hypothetical glue.
What I ran (linux / node v24.20.0 / vitest 3.2.7). Executed the gate against head source with 11 crafted inputs — the selection logic is correct: node 22.20.0 / 22.0.0 / 24.20.0 → ink; 26.4.0, 26.4, 26.10.0 → opentui (compareVersions('26.10.0','26.4.0') === 1, so numeric not lexical); bun 1.3.14 with emulated node 24 → opentui (Bun branch wins as intended); bun 1.2.0 → ink; openui typo → ink; " OPENTUI " → opentui; unset → ink. Nothing to fix there.
CI cannot reach the code this PR activates, and the body does not say so. The floor is Node ≥ 26.4.0 / Bun ≥ 1.3.0; .nvmrc is 22, engines.node is >=22.0.0, and the Test matrix is '22.x' only, with no Bun job. So the green Test (ubuntu-latest, Node 22.x) is not evidence about any of the ~1500 new backend lines — the gate returns ink before @opentui is even imported. The body discloses the local-Node half ("Local Node is 24.x … the Node leg of the gate is unit-tested only"); the CI half is unstated. Related: start-opentui-ui.tsx — 393 lines, the only production entrypoint this PR adds, and the exact file the fallback contract lives in — has no test file at head (git ls-tree lists only the .tsx), which is why neither escape below is caught by anything.
Not reviewed / not verified by me: dialogs-confirm.tsx (589), transcript-view.tsx (516) and live-turn.ts (257) — the settlement/hang contract and render-index behaviour there were being audited separately and I am not clearing them; treat them as unvisited, not as clean. packages/cli src/ui/opentui/ was still running when this posted, so I have no independent test-count baseline. tsc and eslint were not run by me. No macOS/Windows/Bun runtime test on my side — the Bun smoke in the body is yours.
Reviewed with AI assistance.
| // over stdin; the decoded text is injected into the composer after mount. | ||
| const capturedText = drainCapturedInputAsText(); | ||
|
|
||
| const root = createRoot(renderer); |
There was a problem hiding this comment.
[R1-1 · major] "Startup must never die here" holds only for the constructor — the 28 lines after this point are unguarded, and the code that would restore the terminal is armed after them.
witness: read-traced end to end at 66674aebf6; not executed (mocking @opentui's native renderer is the same gap the PR's own e2e notes run into).
The function docblock at :266-268 says it returns false "when the native renderer cannot be created … startup must never die here", and the PR headline generalises that to "any failure … falls back to ink silently, so the default experience can never regress". The actual guard is try :279 / catch :281 / return false :286 — scoped to createCliRenderer alone. From here through :335 nothing is wrapped: createRoot(renderer) (:308), root.render(...) (:309), startPostRenderPrefetches (:329), plus getCliVersion(), OpenTuiRuntime.create and startPressureMonitor() above it. llm.tsx awaits startOpenTuiUI with no try/catch either, so any throw there escapes past the fallback instead of reaching it.
The ordering is the part worth fixing structurally rather than patching line by line: the cleanup that calls root.unmount(), renderer.destroy() and runtime.shutdown() is registered at :336 — after every one of those steps. So a throw at :308/:309 kills startup with a native renderer already live and no registered teardown, which is the case where the user's terminal is most likely left in raw/alt mode with no qwen --resume <id> echo. The person affected is exactly the one who did the safe thing: set an experimental env var on a runtime the gate approved.
Two lines fix the shape of it: move the registerCleanup block to immediately after renderer is successfully constructed (so teardown is armed before anything that can throw), and widen the existing catch to cover the rest of the body so return false stays the only non-success exit.
I checked one candidate and it is not a thrower, so nobody wastes time there: writeRuntimeSidecar has its own try (opentui-runtime.ts:119), so the sidecar write cannot escape.
Note this is a separate path from the await import() rejection qqqys flagged at llm.tsx:1209 — fixing module-evaluation laziness would not cover this one, and vice versa. Both live in the same "falls back silently" promise.
There was a problem hiding this comment.
Fixed in 5b1dd15. Everything past createCliRenderer is inside one try/catch; the catch tears down root → renderer → runtime (best-effort), warns on stderr and returns false, so no path can leave the terminal under OpenTUI's control while reporting success. start-opentui-ui.test.tsx pins it: a sidecar-write rejection unmounts/destroys/shuts down, arms zero cleanups, and returns false.
| const { selectTuiRenderer } = await import( | ||
| './ui/opentui/renderer-selection.js' | ||
| ); | ||
| if (selectTuiRenderer().renderer === 'opentui') { |
There was a problem hiding this comment.
[R1-2 · minor] The gate-rejection path — the one every currently-supported runtime takes — says nothing at all, and the PR description has the two fallback paths swapped.
witness: executed (gate probed with 11 inputs at head) + grep for any consumer of selection.reason.
selectTuiRenderer() returns a reason whose own doc says "Human-readable reason, for debug logging. Never shown to the user" (renderer-selection.ts:33), and renderer-selection.test.ts:101 even asserts its wording (toContain('native FFI')). But nothing reads it: this line destructures only .renderer, and there is no other selectTuiRenderer consumer in packages/cli/src outside the module and its test. So on Node 22/24 — i.e. .nvmrc, engines.node >=22.0.0, and every CI runner — QWEN_TUI_RENDERER=opentui is accepted by the user, rejected by the gate, and produces no output whatsoever: the CLI looks like the flag was ignored, which is the same symptom as the bug the PR's own "Before" section describes ("the flag was inert").
The asymmetry is what makes it worth a line, because the code already knows how to do this: the construction-failure path prints — start-opentui-ui.tsx:282-285 writes Warning: OpenTUI renderer unavailable — <msg> (falling back to ink) to stderr. So the loud path is the rare one and the mute path is the universal one, while the Risk section says the opposite ("a failed renderer probe falls back to ink silently"). Both statements can't be right; the silent-vs-loud split is worth deciding on purpose.
Cheapest resolution, either direction: if (selection.renderer !== 'opentui' && process.env.QWEN_TUI_RENDERER) debugLogger.info(selection.reason) at the call site — or drop the "for debug logging" promise from the docblock. Note the current if (selectTuiRenderer().renderer === 'opentui') form also recomputes the selection rather than holding it, so a named local would let the reason be logged without calling the gate twice.
There was a problem hiding this comment.
Fixed in 5b1dd15: the refusal path now logs selection.reason to the debug log (STARTUP tag), and the boot-failure path warns on stderr. The PR description's swapped fallback paths are corrected in the body.
chiga0
left a comment
There was a problem hiding this comment.
R1 addendum @ 66674aebf6 — one blocker in the newly activated path, plus the settlement contract has no test
Supplements my earlier review on this head; nothing below repeats it. dialogs-confirm.tsx, transcript-view.tsx and live-turn.ts were the three files I explicitly declined to clear, and the blocker is in that set.
R1-3 (blocker, inline) — submit_prompt's modelOverride / refreshContextFilesOnWrite / onComplete are produced by this batch's dispatcher and dropped at opentui-app-shell.tsx:337, while live-session.ts:251/:478 still waits for them. Directly contradicts the body's claim that "once booted, the session runs entirely on the new backend".
R1-4 (major, inline) — deleting the onConfirm double-settle guard leaves the suite green (3/3); the 589-line confirmation file is never rendered by any test.
Also established by reading, deferred as one-liners so they do not open threads: live-turn.ts:179-187 re-submits the surviving queue as a new turn after Esc, where ink drains it back into the composer (AppContainer.tsx:3157-3174); live-turn.ts:140-145 mid-turn steering reaches the model but emits no user item and no chat record, so it vanishes on --resume (ink: use-llm-stream.ts:3342-3352); dialogs-confirm.tsx:131-144 TextBody throws away tailWindow's hiddenCount, so a >20-line plan is approved without a cue that lines were hidden, while DiffBody:112-116 keeps its cue and ink opens the full plan for exactly that reason (ToolConfirmationMessage.tsx:59-76); settle() at dialogs-confirm.tsx:280-288 never calls ideClient.resolveDiffFromCli, the only closer core has for a CLI-opened IDE diff (coreToolScheduler.ts:4077-4094, :4119-4121). The body's "Deliberate parity gaps … not silently dropped" list does not cover any of these four.
Two things I could not settle and am not filing: resetTranscript clearing waitingCalls without settling them is reachable only through the session-switch path and I could not prove a parked call exists at that moment; and transcript-view.tsx:77 has no memo/virtualisation while apply(ev) fires per token — real cost unknown because @opentui/react is not installed here (node_modules/@opentui has only core-linux-x64), so I have no measurement to stand on.
Not covered: no mutations run against live-turn.test.ts internals beyond the reads above; tsc / eslint not run; the src/ui/opentui/ suite baseline had not finished when my first comment posted.
Reviewed with AI assistance.
| async (prompt: PartListUnion, promptId: string) => { | ||
| const abort = new AbortController(); | ||
| abortRef.current = abort; | ||
| setBusy(true); |
There was a problem hiding this comment.
[R1-3 · blocker] The three fields this batch's own dispatcher produces for submit_prompt are dropped at the shell seam, so a command that names a model runs on the session default.
witness: every link read at head 66674aebf6; the untested half is executed (see the note under this comment).
Writer commands-dispatch.ts:596-608 returns {kind:'submit_prompt', content, modelOverride?, refreshContextFilesOnWrite?, onComplete?} — all three optional fields are built here and asserted by that file's own green tests (commands-dispatch.test.ts:808-826, :601-628). Consumer live-session.ts:251 is getModelName: () => options?.modelOverride ?? config.getModel() and :478 gates the memory-instruction refresh on options?.refreshContextFilesOnWrite — the layer fully supports them and is reached only through this options object.
Nothing carries them across. opentui-app-shell.tsx:337 is if (onSubmitPrompt) onSubmitPrompt(outcome.content); — content only, the other three discarded at the seam — and live-turn.ts:193-194 submit(content, imagePaths) has no parameter that could hold them, so the options object built at :151-162 carries promptId / drainSteering / onWaitingCall and nothing else. livePromptEvents has exactly one production caller, so there is no second door that restores them.
Trigger -> wrong outcome: run a skill or /learn whose SubmitPromptResult sets modelOverride; the turn goes out on config.getModel() instead of the named model, silently. /remember's context-file refresh never fires, so the next pass still carries the old GEMINI.md instructions. A skill's onComplete callbacks never run at all. The ink path threads all three (hooks/use-llm-stream.ts:932, :1350, :1597-1599, :4105-4111).
Why CI cannot see it: live-session.test.ts:251-262 passes the override by calling livePromptEvents directly, so the broken seam is skipped and the suite stays green in both directions. Fix is one widened call — thread the three fields from applyOutcome into submit — plus a test that drives the shell's submit_prompt case rather than the inner function.
Anchored here rather than at opentui-app-shell.tsx:337 because that line is unchanged context in this diff.
There was a problem hiding this comment.
Fixed in 5b1dd15. The shell now forwards {modelOverride, refreshContextFilesOnWrite, onComplete} as a third argument on the onSubmitPrompt seam; the entry passes them into submit → runTurn → LivePromptOptions (both fields already existed there). onComplete fires once after a successful turn and never on error/abort (ink use-llm-stream submitPromptOnCompleteRef parity). Regression tests: opentui-app-shell.test.tsx (outcome → seam forwarding, with and without options).
| const settledRef = useRef(false); | ||
| const settle = useCallback( | ||
| (outcome: ToolConfirmationOutcome, payload?: ToolConfirmationPayload) => { | ||
| if (settledRef.current) return; |
There was a problem hiding this comment.
[R1-4 · major] Nothing in the suite renders these dialogs, so the "every code path calls onConfirm" invariant this file states in its own header is unchecked.
witness: executed — mutation matrix, linux / node v24.20.0 / vitest 3.2.7.
dialogs-confirm.test.tsx is 81 lines / 3 tests against 589 lines of source, and all three exercise buildOutcomeOptions, a 24-line pure function. No component is mounted, no key is sent, onConfirm is never invoked. Deleting the double-settle guard is invisible:
M1 :282 if (settledRef.current) return; -> if (false) return;
dialogs-confirm.test.tsx: Tests 3 passed (3) # SURVIVED
That matters specifically because the batch's headline claim is that tool calls can now be approved on this backend, and because the hang this file is designed to avoid is the exact failure mode two earlier rounds of this migration already produced (presentShell never settling, and the extension-consent slot nobody read — both fixed in the parent commits). The settlement contract is the file's reason to exist and it has no test.
The third test also cannot fail for the thing its title names: it asserts only that the option list contains ProceedOnce and Cancel, which hold whether or not the hideAlwaysAllow narrowing at :88-89 exists. Rendering OpenTuiToolConfirmation once and driving Enter / Esc would kill every mutation in the file.
There was a problem hiding this comment.
Fixed in 5b1dd15. dialogs-confirm.test.tsx now renders OpenTuiToolConfirmation: Esc → Cancel, Enter → highlighted outcome, ask_user_question Enter → ProceedOnce with the {answers} payload, a question with no options → Cancel, and the double-settle guard (Esc, Enter, Esc ⇒ exactly one onConfirm).
…rity Address the Batch 6 review findings: - llm.tsx wraps the opentui entry load/boot in try/catch — importing the entry evaluates modules whose module scope touches the native FFI, so a gate-passing runtime with a broken FFI now falls back to ink instead of crashing; the gate refusal reason goes to the debug log. - startOpenTuiUI guards everything past renderer creation: a boot failure tears the renderer/root/runtime down and returns false. - The live turn is generation-guarded: resetTranscript settles parked confirmations as Cancel (waking the scheduler queue), aborts, clears the steering queue, and invalidates the in-flight turn's late events and queue resubmits. - Ctrl+C follows ink's handleExit cascade: close the parked confirmation first, interrupt an in-flight turn second, arm the two-press exit guard only when idle. - submit_prompt outcomes carry modelOverride/refreshContextFilesOnWrite/ onComplete through the shell seam into the turn (fired once after a successful turn, ink parity) instead of being dropped. - ToolCard description/suffix render sanitized; compareVersions breaks numeric ties semver-style so a pre-release of a floor is rejected; a question with no options settles as cancel. - Tests: tool-confirmation settle paths incl. the double-settle guard, entry fallback contract, submit-option forwarding, prerelease ordering.
|
@yiliang114 All findings from the three reviews (qqqys, dev-bot, yiliang114, plus my own R1/R1-addendum) are addressed in 5b1dd15. Verification after the fix: Mapping finding → fix
CI-reachability disclosure (stated so nobody over-reads the green check): the PR's CI matrix runs Node 22 and has no Bun leg, so The four small deferred items from my own review are registered as U-11…U-14 on #8662 so they are tracked rather than silently dropped. |
qwen-code-dev-bot
left a comment
There was a problem hiding this comment.
Re-reviewed at head 5b1dd15e.
- My round's blocker is fixed exactly as specified: the dispatch in
llm.tsxnow wraps the entry import and the boot call in one try/catch — a module-evaluation throw (theme.ts's top-levelbuildSyntax→resolveRenderLib) logs to debug, warns on stderr, and falls through to ink, so a runtime that passes the version gate can no longer crash startup; the new entry tests drive the rejection path. - Also verified here: everything past
createCliRendereris inside one guarded block with best-effort teardown (R1-1), the gate-rejection reason is now debug-logged (R1-2), thesubmit_promptper-turn options (modelOverride/refreshContextFilesOnWrite/onComplete) travel the full seam chain — shell forwards them, the entry passes them on, andlivePromptEventsconsumes them (R1-3), and the confirmation dialogs now have real render tests (R1-4). The pre-release tie-break in the version compare (1.3.0-beta < 1.3.0) fails conservative. - The five review threads carry per-finding fix replies at this head and remain open only as bookkeeping; CI has no failures (head freshly pushed, lanes still running); per the channel convention the call is on the review itself.
yiliang114
left a comment
There was a problem hiding this comment.
All four P2s and both P3s from my earlier review are fixed in 5b1dd15, each verified against the new head:
- Entry import guarded: llm.tsx wraps the dynamic import AND startOpenTuiUI in try/catch (module-scope FFI failures included), logs, warns on stderr, and falls through to ink; startOpenTuiUI additionally treats everything past renderer creation (sidecar I/O, first render) as fallible and tears down before returning false.
- resetTranscript now bumps a turn-generation counter, aborts the in-flight controller, settles parked confirmations as Cancel (waking the scheduler queue), clears the steering queue and waiting calls, and resets busy synchronously — late events/settles from the stale turn are dead on arrival via the seq checks in runTurn.
- Ctrl+C settles the first parked confirmation before interrupting the turn, so no dialog outlives its turn.
- ToolCard description and suffix are sanitized like every other text path.
- compareVersions breaks numeric ties semver-style (1.3.0-beta < 1.3.0).
- AskUserQuestionFlow settles a question with an empty options list defensively.
Verified locally on 5b1dd15: live-turn + dialogs-confirm + renderer-selection + app-shell + start-opentui-ui tests 47/47.
qqqys
left a comment
There was a problem hiding this comment.
Re-reviewed at head 5b1dd15e. My round-1 Critical is fixed and the full blocker list from every review round is verified closed at this head.
My round-1 Critical (ink fallback dead for the module-evaluation failure class) — fixed as specified. The dispatch in llm.tsx now wraps the entry import() and the startOpenTuiUI call in one try/catch (verified at head lines 1208-1237): a module-scope throw from the entry's FFI-touching graph (theme.ts SYNTAX → resolveRenderLib()) logs to the debug log, warns on stderr, and falls through to ink, so a gate-passing runtime with a broken FFI can no longer crash startup. The gate-refusal reason is now debug-logged too. The renderer-selection.js import sits outside the try/catch but that module is verifiably dependency-free (no @opentui import, no module-scope side effects), so it carries no FFI risk. Dispatch tail mirrors the ink path exactly (clearCorruptionEnvVars(); return;, same args, same initializationResult! usage as the ink branch).
Other historical blockers, each verified in the current head's code:
- Entry body past
createCliRendereris inside a second try/catch with best-effort teardown (root.unmount → renderer.destroy → runtime.shutdown) returningfalse; pinned bystart-opentui-ui.test.tsx(creation rejection → false with zero cleanups armed; boot-body rejection → teardown + false; success → cleanups armed). resetTranscript(live-turn.ts) now invalidates the in-flight generation via the turn-seq counter, settles every parked confirmation asCancel(waking the scheduler queue), aborts, clears the steering queue and waiting calls, and resets busy synchronously — late events/settles from the stale turn are dead on arrival via theseqchecks inrunTurn.- Ctrl+C follows ink's
handleExitcascade in the entry: settle the first parked confirmation as Cancel, else interrupt the in-flight turn, else arm the two-press exit guard — so no path aborts the signal while a call is parked without settling it. - ToolCard
descriptionandsuffixare sanitized in transcript-view.tsx like every other text path. compareVersionsbreaks numeric ties semver-style (1.3.0-betasorts below1.3.0, asserted by test incl. the floor gate rejecting a pre-release of the floor).AskUserQuestionFlowsettles a question with an empty options array as Cancel from an effect (guard now covers!question?.options?.length, not just the empty question list); render-tested.submit_prompt'smodelOverride/refreshContextFilesOnWrite/onCompletetravel the full seam: shellapplyOutcomeforwards them as the thirdonSubmitPromptargument, the entry threads them intosubmit→runTurn→livePromptEvents, andonCompletefires once after a successful turn, never on error/abort (ink parity). Shell tests assert the forwarding with and without options.- The confirmation dialogs are now rendered in tests: Esc→Cancel, Enter→highlighted outcome, ask_user_question Enter→ProceedOnce with the
{answers}payload, no-options→Cancel, and the double-settle guard (Esc, Enter, Esc ⇒ exactly oneonConfirm).
Independent Critical-only pass at this head: no new blocking defects found. Every dialog path settles; the modal queue keeps presentShell/presentAction referentially stable; the composer is unmounted while a confirmation is active so key handlers cannot collide; default ink path is untouched unless QWEN_TUI_RENDERER=opentui on a supported runtime.
CI at this head: no failing or cancelled checks; Test (ubuntu-latest, Node 22.x) still pending at review time (not treated as a gate per review policy). Note for merge awareness: the CI matrix runs Node 22 with no Bun leg, so the activated renderer path itself is exercised by unit tests and local smoke runs, not CI — the green lanes assert the default-ink behavior is untouched.
…0774) (QwenLM#10781) The prepared package is now 97.18 MiB, 1.18 MiB over the 96 MiB budget, so assertPreparedPackageSize throws and the docker build fails. The E2E sandbox:docker leg builds its image by running this script, so both of its build attempts fail and the step exits 1 on every runner, while the sandbox:none and macOS legs, which never package, stay green. The OpenTUI backend (QwenLM#10739) added a 1.91 MiB chunk to a bundle that was already within 1.4 MiB of the ceiling. Bump to 112 MiB, the same ~15 MiB of headroom for normal growth that the 85 -> 96 MiB bump in bcf5b7b left. Co-authored-by: qwen-autofix[bot] <qwen-autofix[bot]@users.noreply.github.com>
|
Released in v0.23.0. |
What this PR does
Batch 6 of the ink→OpenTUI migration: the OpenTUI backend becomes actually bootable. The interactive entry now dispatches on an explicit environment flag, only on runtimes that can load the native renderer (Bun at/above its floor, or a Node new enough for the FFI path), and control returns to ink through two distinct doors: the gate refusing (the silent path every default session takes) or the OpenTUI boot failing (module load, renderer creation, or anything after it), which warns on stderr before falling back — so the default experience can never regress. Once booted, the session runs entirely on the new backend: the composition root mounts with its ink-parity seams (runtime sidecar, session registration, update notices, the two-press exit guard, early-input injection, resume replay, and the exit-echo chain), a live-turn driver folds model stream events into the transcript with mid-turn queueing and Esc-interrupt, the transcript renders every history kind (markdown, thinking blocks, tool cards with diffs/todos/ANSI, goal cards, retries, compaction), and the previous deny-everything confirmation bridge is replaced with real tool / shell / action dialogs, so tool calls can be approved once, always-allowed per project or user, or declined — including the sequential ask_user_question flow. A small /goal route fix (dropping a phantom submit_prompt result) is folded in per maintainer request.
Why it's needed
Until now the OpenTUI backend existed only behind unit tests: users could not opt in, and the shell refused every confirmation, so no real work could ever be approved on it. This is the activation step of the approved Phase 1 plan (tracking issue #8662): ink stays the default renderer, and OpenTUI becomes an opt-in experimental path that can be smoke-tested on real hardware. Nothing changes for anyone who does not set the flag.
Reviewer Test Plan
How to verify
npm run build && npm run bundle, thenQWEN_TUI_RENDERER=opentui bun dist/cli.js. Expect the shell to boot (no banner yet — transcript view only), the composer placeholder to render, and a runtime sidecar JSON to appear next to the session's chat log.--approval-mode defaultand ask the model to create a file; the write-confirmation dialog should appear with the diff preview and four outcome rows. Enter approves and the turn continues; Esc declines and the tool card flips to the declined marker. (Read-only shell commands likeecho/catbypass confirmation by core's read-only AST gate — ink parity, not a gap.)Evidence (Before & After)
Before:
QWEN_TUI_RENDERER=opentuihad no effect — no reader existed; the flag was inert and the backend unbootable.After (tmux capture under Bun 1.3.14,
--approval-mode default, model asked to create a file):Approving ran the tool (
✓ WriteFile, file landed on disk) and the turn continued; Esc cancelled it and the file was not created. Two-press exit printed code 130 and the resume hint. Full smoke log:.qwen/e2e-tests/2026-09-01-opentui-batch6-activation-smoke.md(not committed; available on request).Tested on
Environment (optional)
Bun 1.3.14 against the esbuild bundle (
npm run build && npm run bundle), driven through tmux. Local Node is 24.x, below the renderer's runtime floor, so the Node leg of the gate is unit-tested only.Risk & Scope
@opentui/*'s native surface) and by local Bun/Node-26 smoke runs, not by CI; the green checks assert the default-ink behavior is untouched.Linked Issues
Part of #8662 (ink→OpenTUI migration, Phase 1). Supersedes the deny-stub confirmation bridge shipped in the Batch 5 composition root.
中文说明
这个 PR 做了什么
ink→OpenTUI 迁移的 Batch 6:OpenTUI 后端真正可启动。交互入口现在根据显式环境变量分发渲染器,且只在能加载原生渲染器的运行时上启用(Bun 达到下限版本,或 Node 新到支持 FFI 路径);控制权回到 ink 只有两扇门:版本门拒绝(每次默认会话都走的静默路径),或 OpenTUI 启动失败(模块加载、渲染器构造及其后的一切),后者在回落前会在 stderr 告警——默认体验不可能回退。启动后,会话完全运行在新后端上:组合根挂载全套 ink 对齐接缝(runtime sidecar、会话注册、更新通知、两次按键退出守卫、早期输入注入、恢复回放、退出回显链),live-turn 驱动器把模型流事件折叠进转录并支持回合中排队与 Esc 中断,转录渲染所有历史类型(markdown、思考块、含 diff/todos/ANSI 的工具卡片、goal 卡片、重试、压缩),之前"一律拒绝"的确认桥被真实的工具 / shell / action 对话框取代——工具调用可以被单次批准、按项目或用户永久允许、或拒绝,包括顺序式 ask_user_question 流程。另按维护者要求折入了一个 /goal 路由小修复(去掉幻影 submit_prompt 结果)。
为什么需要
此前 OpenTUI 后端只存在于单测之后:用户无法启用,壳体会拒绝所有确认,真实工作根本无法在其上被批准。这是已批准的 Phase 1 计划(跟踪 issue #8662)中的激活步骤:ink 保持默认,OpenTUI 成为可选的实验路径,可在真实硬件上冒烟。不设置该标志的用户不受任何影响。
评审测试计划
如何验证
npm run build && npm run bundle,然后QWEN_TUI_RENDERER=opentui bun dist/cli.js。应看到壳体启动(暂无横幅——只有转录视图)、编辑器占位符渲染、会话聊天记录旁出现 runtime sidecar JSON。--approval-mode default运行并请模型创建文件;应出现带 diff 预览和四个选项的写确认对话框。Enter 批准后回合继续;Esc 拒绝后工具卡片翻为已拒绝标记。(只读 shell 命令如echo/cat会被 core 的只读 AST 门自动放行——与 ink 一致,不是缺口。)证据(前后对比)
之前:
QWEN_TUI_RENDERER=opentui无效——没有读取者,标志是惰性的,后端无法启动。之后(Bun 1.3.14 下 tmux 截图,
--approval-mode default,要求模型创建文件):见上方英文部分的对话框截图。批准后工具执行(✓ WriteFile,文件落盘)且回合继续;Esc 取消且文件未创建。两次按键退出打印 130 码与恢复提示。测试环境
Bun 1.3.14 + esbuild bundle,经 tmux 驱动。本机 Node 为 24.x,低于渲染器运行时下限,因此 Node 一路仅单测覆盖。
风险与范围
@opentui/*原生面)和本地 Bun/Node-26 冒烟覆盖,而非 CI;绿色检查断言的是默认 ink 行为未被触碰。关联 Issue
#8662(ink→OpenTUI 迁移,Phase 1)的一部分。取代 Batch 5 组合根中的拒绝桩确认桥。