Skip to content

fix(cli): close the OpenTUI renderer's parity gaps against ink - #11630

Merged
chiga0 merged 13 commits into
mainfrom
fix/opentui-parity-defects
Sep 11, 2026
Merged

fix(cli): close the OpenTUI renderer's parity gaps against ink#11630
chiga0 merged 13 commits into
mainfrom
fix/opentui-parity-defects

Conversation

@chiga0

@chiga0 chiga0 commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Closes the gap between the OpenTUI renderer and the existing ink renderer across the transcript, the composer, the footer, the dialogs and the keyboard, and adds the one startup approval this renderer was missing entirely. Every defect here was found by driving both renderers through the same scripted terminal sessions and comparing the captured frames line by line, so the list reflects what a user actually sees rather than what the code appears to do.

The largest correction is to how a running tool reports its output. The scheduler hands the shell the whole accumulated display on every progress event, so a card that treated each event as an increment repainted what it already had and grew the text on every tick. Tool output is now modelled as the snapshot it actually is: the card replaces what it shows, the throttle that avoids a redundant repaint compares lengths rather than trailing text, and the string written into the model's history is the same string the card shows, so the two cannot drift apart. The tail-deduplication this made unnecessary is gone.

A tool result that carries a structured payload is now recognised by one precedence shared by every path that can produce one, so a to-do list renders as a to-do list instead of as the raw JSON dump it was falling back to, and a diff or an ANSI payload keeps its own rendering. Before this the live path and the settled path disagreed about which shape a result had, and the live path lost.

Switching the approval mode to one that would not have parked a waiting confirmation now releases the confirmations it would not have parked in the first place, instead of leaving them on screen asking for a decision the new mode has already taken. The release set is the same set ink computes, including the restriction of the edit-mode release to editing tools.

A committed thinking row now names how long the model thought, using the same one-second threshold below which ink says the thought was brief rather than reporting a duration, and the key its own hint advertises for expanding thinking is bound, so the hint is not offering a keystroke that does nothing.

A project that declares gated MCP servers now asks for approval before starting them. This renderer had no such dialog at all, so a server declared in the project's own configuration was never offered and simply stayed disconnected, with nothing on screen to explain why. The queue, the per-server summary, the persisted decision and the approve-all path reuse the policy ink already uses; only the view is new, and it is ranked above both the shell and the tool confirmation, where ink ranks it.

The transcript and the composer now sit inside the horizontal margins ink uses. That single change is what makes the completion dropdown, the attachment list and the message columns land where ink puts them. The dropdown's own internal arithmetic is untouched, because it was already computing the right widths from a box that was two columns too narrow on each side.

Status rows gained the prefixes ink draws. The warning row had none at all, so a wrapped warning lost its marker and overflowed the row by a column. The error row now puts its retry hint on the same line instead of a second one, matching ink, with the prefix pinned so a wrapped body cannot squeeze it away.

The footer's loading indicator moved to ink's indent, its second row joins segments the way ink does, its token estimate is the real one rather than a placeholder, and an armed quit warning now renders even while a dialog, a confirmation or a dropdown has the rest of the footer hidden. Before this, the gate that hid the footer also hid the warning, so a first Ctrl+C with a dialog open produced no feedback whatsoever and the user had no way to learn that a second press would exit.

Shift+Tab now cycles the approval mode from the top level rather than from the composer, so it keeps working while a dialog or a confirmation has the composer unmounted — which is exactly the state the on-screen hint advertises it in. The Windows fallback that also accepts a bare Tab, for terminals that cannot tell the two apart, is narrowed to a bare Tab only, so a terminal that can tell them apart no longer advances the mode twice for one keystroke.

The context-files announcement is latched with the predicate ink uses and re-armed when the transcript resets, so starting a new session inside an already-running renderer announces its context files again instead of staying silent for the rest of the process.

Extension refresh notices that latched before this renderer mounted are replayed on mount, so the one notice telling the user to reload their plugins is no longer dropped by a subscription that started too late. The replay is keyed on the owner of the latch rather than on the construction of the dispatcher, because the latch outlives it.

The model dialog carries the three guards ink has. A successful pick no longer also announces the model that survived, and neither a second Escape nor a second Enter landing while an apply is still in flight can announce a second time or start a second switch.

Dialog selection markers use ink's chevron rather than a filled bullet, and the authentication dialog takes the shared icon glyphs, which also brings the text-presentation selector those icons carry. Transcript items take the per-type top margin ink gives them, so a run of items of the same type no longer reads as one block. The confirmation dialog offers the per-type approval options ink offers, and the shell parser is warmed before first paint so the initial prompt is not charged for it.

Adds a bilingual design document recording each decision, the coverage boundary and the follow-ups.

Why it's needed

The OpenTUI renderer is reachable today, but switching to it gave a visibly different and in places broken experience. A running shell command repainted its output on every progress tick. A to-do list rendered as a JSON dump. Pressing Ctrl+C once with a dialog open silently did nothing. Cycling to a mode that auto-approves left the confirmation on screen anyway. A new session in an already-running process never announced its context files. A project's own MCP servers were never offered for approval and never connected. The mode-cycling hint the footer advertised did not work in the dialogs where it was displayed. None of this was caught by the unit tests, because each component was individually correct — the defects were in how the pieces were composed and in assumptions about what upstream hands them.

Reviewer Test Plan

How to verify

Run the CLI with the OpenTUI renderer and walk the following; each is a behaviour that was wrong before this branch.

  1. Ask for a shell command that produces a few lines of output and watch the tool card while it runs. The output should appear once and grow. Before, each progress tick appended the whole accumulated display again, so the same lines showed up repeatedly and concatenated.
  2. Ask for something that writes a to-do list. The card should show the list with ink's three status glyphs, not a JSON dump of the tool's arguments and result.
  3. Park a tool confirmation, then cycle the approval mode to one that auto-approves. The parked confirmation should be released and the turn should finish, rather than staying on screen waiting for a decision that is no longer needed.
  4. In a project directory that declares an MCP server needing approval, start a session. A dialog should offer approve, approve-all and reject, naming the server and its transport. Rejecting should leave a readable notice; approving should connect the server.
  5. Press Ctrl+C once while any dialog is open, for instance the help overlay. The quit warning should appear in the footer. Press it again to exit, or wait for the window to expire and confirm the warning clears itself.
  6. Open the help overlay and press Shift+Tab. The approval mode shown in the footer should advance, and the overlay itself should return to its general tab. Both happen at once, which is also what ink does.
  7. Cycle the mode all the way around and confirm it wraps from the last mode back to plan mode rather than stopping.
  8. Open the model dialog, pick a model and press Enter twice quickly. Exactly one switch should happen and exactly one confirmation line should be printed. Then reopen it and press Escape twice: the kept-model line should appear once.
  9. In a directory with context files, start a session, then clear it and start another. Both sessions should announce their context files.
  10. Type a slash and an @-mention and check the dropdown's left edge lines up with the composer's text, and that a long mention label is not truncated to half the row.
  11. Narrow the terminal until a warning message wraps and confirm it keeps its leading marker and does not spill a column past the right edge.
  12. Trigger a model error and confirm the error and its retry hint render on one line, as ink does.
  13. With a model that emits reasoning, let a thought finish and confirm the row reports its duration, and that a thought under a second reads as brief. Press the key the row's own hint names and confirm thinking expands and collapses. This one cannot be verified against the scripted harness used for the evidence below, because that harness's fake model has no reasoning field.

Evidence (Before & After)

Captured with a scripted pty harness that drives both renderers through 18 scenarios and writes every frame to a text grid: 36 legs, 96 frames, no leg in error. Below is the same scenario, same terminal size, same fake model, before this branch and after — the tool has just printed one line. Long absolute paths and the branch name in the footer are elided with ; nothing else is edited.

Before (a running tool card, the footer, and the status rows), from the pre-fix capture:

Update check skipped (registry error) — run /update to retry.
> run the echo tool
✓ Shell echo ACCEPT_TOOL_RAN_7731
  ACCEPT_TOOL_RAN_7731ACCEPT_TOOL_RAN_7731
◆ ACCEPT_TURN_COMPLETE_7732
…
 ──────────────────────────────────────────────────────────────────────────────────────────────────
 *   Type your message or @path/to/file
 ──────────────────────────────────────────────────────────────────────────────────────────────────
 YOLO mode
 ➜ work · git:(…) · fake-model
 YOLO mode

After (same scenario), with the ink leg underneath for reference:

  ●︎ Extensions changed on disk. Run /reload-plugins to apply updates.
  △︎ Update check skipped (registry error) — run /update to retry.
  ●︎ Read context files: …/AGENTS.md, …/output-language.md

  > run the echo tool
  ✓ Shell echo ACCEPT_TOOL_RAN_7731
    ACCEPT_TOOL_RAN_7731

  ◆︎ ACCEPT_TURN_COMPLETE_7732
…
────────────────────────────────────────────────────────────────────────────────────────────────────
*   Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────
  ➜ work · git:(…) · fake-model
  YOLO mode (shift + tab to cycle)
  ●︎ Extensions changed on disk. Run /reload-plugins to apply updates.
  ●︎ Read context files: …/AGENTS.md, …/output-language.md

  > run the echo tool
  ✓ Shell echo ACCEPT_TOOL_RAN_7731
    ACCEPT_TOOL_RAN_7731

  ◆︎ ACCEPT_TURN_COMPLETE_7732
…
────────────────────────────────────────────────────────────────────────────────────────────────────
*   Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────
  ➜ work · git:(…) · fake-model
  YOLO mode (shift + tab to cycle)

The doubled tool output, the missing prefixes, the missing context-files and extension notices, the duplicated mode segment and the missing cycling hint are all gone. The error row is the same story, and now matches ink character for character:

before:  ✖︎ [API Error: …]          after:  ✕ [API Error: …] (Press Ctrl+Y to retry)
         Press Ctrl+Y to retry              ink:  ✕ [API Error: …] (Press Ctrl+Y to retry)

The to-do list and the new MCP approval dialog, both against the ink leg from the same run:

opentui:  ✓ TodoList Create todos            ink:  ✓ TodoList Create todos
            ●︎  ACCEPT_TODO_ONE                     ●︎  ACCEPT_TODO_ONE
            ◐︎  ACCEPT_TODO_TWO                     ◐︎  ACCEPT_TODO_TWO
            ○︎  ACCEPT_TODO_THREE                   ○︎  ACCEPT_TODO_THREE
opentui: ╭─────────────────────────────────────────────────────────────────────────────────────────────╮
         │ Untrusted MCP server in .mcp.json                                                           │
         │ This workspace declares an MCP server. Approving lets Qwen Code start it and run its tools. │
         │  Approval is bound to this exact configuration — if .mcp.json changes, you will be asked    │
         │ again.                                                                                      │
         │ acceptance-server  node -e (stdio)                                                          │
         │ › 1. Approve this server                                                                    │
         │   2. Approve all pending servers in this workspace                                          │
         │   3. Reject (esc)                                                                           │
         ╰─────────────────────────────────────────────────────────────────────────────────────────────╯

ink:     ╭──────────────────────────────────────────────────────────────────────────────────────────────
         │ Untrusted MCP server in .mcp.json
         │ This workspace declares an MCP server. Approving lets Qwen Code start it and run its tools.
         │ Approval is bound to this exact configuration — if .mcp.json changes, you will be asked
         │ again.
         │ acceptance-server  node -e (stdio)
         │ › 1. Approve this server
         │   2. Approve all pending servers in this workspace
         │   3. Reject (esc)
         ╰──────────────────────────────────────────────────────────────────────────────────────────────

The two dialogs carry the same text. The visible differences are ink clipping its own right-hand corners, because it sets a left margin on a full-width box, and this renderer indenting the wrapped continuation of one sentence by a column that ink does not.

Measured across all 48 frame pairs in the final run, 2 pairs are identical row for row and 46 differ; counting rows present in only one renderer, after normalising the version string away, the total is 359. That is narrower than the 414 the same measurement gave before this branch was rebased onto current main, though the narrowing is mostly main's own ink output moving, not this branch improving, since this branch's source did not change between the two measurements. The divergence is concentrated: five frames account for 162 of those 359 rows, and every other frame differs by 17 rows or fewer. Four of the five are confirmation and question frames that ink renders with no box around them, and together they are 128 rows; those four are the follow-up named below. The fifth is the MCP dialog described just above.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Windows is not tested on a real terminal. The bare-Tab fallback that exists because some Windows terminals cannot distinguish Shift+Tab from Tab is covered by unit tests that pin the platform, including the case where a bare Tab was already spent on a completion and must not also cycle the mode.

Environment

npm run build && npm run bundle, then driven through a pty at 100x40 and 60x24. Typecheck clean over the whole repository, lint and format clean over the touched trees. Unit tests: 470 files, 9160 tests, of which 21 fail — all 21 fail identically on current main from a separate checkout of it, and none of the failing files is touched here. The two files involved are a cursor-rendering suite whose four failures have the same names in both trees, and an authentication-dialog suite in which 12 to 14 of 26 cases time out on both trees, with the exact subset varying between runs of the same tree; run alone, the two trees produce byte-identical failure sets.

Risk & Scope

  • Main risk or tradeoff: the tool-output change alters both what a running card shows and what is written to the model history for shell tools, since both now come from one string. This is the point of the change — they disagreed before — but it is the widest behavioural surface in the PR. The new MCP approval dialog sits on a security-relevant path: it reuses the policy, the persisted decision and the queue that ink already uses and adds only a view, so the decision logic is not new, but it is the first time this renderer can approve a server at all.
  • Not validated / out of scope: colour is not verified in any frame, because the harness compares text grids rather than attributes, so the palette choices made here are reasoned from ink's source and not observed. The thinking-row changes have no frame evidence at all — the harness's fake model has no reasoning field, so no scenario produces a thinking row — and their keystroke wiring has no unit coverage either, because the suite that would carry it mocks the keyboard hook out. The MCP dialog is frame-verified only for the reject path with a single server; approve, approve-all and the multi-server queue are never rendered in any captured frame, because approving would start the declared command. The queue badge, the authentication checkboxes, the dialog-open quit-warning path and the double-Enter model guard are covered by unit tests only, as no scripted scenario produces them. Only two terminal sizes were driven. One intermittent defect is recorded rather than fixed: in one of four observations of one scenario, this renderer printed the extension-refresh notice twice, where ink printed it once. It is not reproducible on demand from the same bundle, it is not a double subscription, and it is the inherent trade of replaying a latch that was set before mount — the replay turns "sometimes zero notices" into "sometimes two". Structurally left to follow-ups, and the largest remaining divergence: ink renders a tool, shell or question confirmation inline in the transcript with no box around it, while this renderer draws a full-width bordered box with a title and a navigation hint ink does not show, and writes an awaiting-approval note where ink puts a trailing arrow beside the pending row; those four frames are 36% of all remaining row divergence. Also left to follow-ups: the banner stays pinned instead of scrolling out of the viewport as ink's does, dialogs are overlays that do not reflow the transcript, the tool card does not print the call's arguments inline, a settled card keeps the position it was created at rather than moving after notices that arrived meanwhile, and ink's Waiting for user confirmation spinner row has no counterpart here. Not ported: ink's queued-message body and its edit hint above the composer, and the footer's background-task, MCP-health, worktree, workflow and skill-review segments. A replayed extension notice cannot carry its reason, because the latch it replays from exposes none. This renderer deliberately keeps its own wording for a skipped update check rather than adopting ink's update-failed line. On Windows a terminal that delivers Shift+Tab as a bare Tab while a dialog has the composer unmounted will not cycle; closing that without risking a double cycle on other platforms needs a guarantee about key-handler ordering that the underlying library does not give.
  • Breaking changes / migration notes: none. One line is added to the core package's public surface — a re-export of the shell parser's initialiser so the renderer can warm it before first paint. It ships in the same commit as its only consumer, so no commit leaves an unused export behind. It evaluates no module the core entry did not already evaluate.

Design document: English · 中文

Linked Issues

Part of #8662.

中文说明

这个 PR 做了什么

把 OpenTUI 渲染器与既有 ink 渲染器之间的差距,在会话记录、输入框、页脚、对话框和键盘五条线上补齐,并补上了这个渲染器完全缺失的那一道启动审批。这里的每一个缺陷都是用脚本驱动两个渲染器跑同一批终端会话、再逐行比对截取到的画面发现的,所以这份清单反映的是用户实际看到的东西,而不是代码看上去做了什么。

最大的一处修正是运行中的工具如何汇报自己的输出。调度器在每一个进度事件里交给 shell 的都是到目前为止累积的完整显示内容,所以把每个事件当成增量的卡片会把已经画过的内容再画一遍,文本随每个 tick 增长。工具输出现在按它本来的样子建模为快照:卡片替换自己显示的内容,避免重复绘制的节流改为比较长度而不是比较尾部文本,写进模型历史的字符串与卡片显示的是同一个,两者因此不可能再各自漂移。为此变得多余的一段尾部去重逻辑被删掉了。

带有结构化载荷的工具结果,现在由所有可能产生它的路径共用同一套优先级来识别,于是待办清单渲染成待办清单,而不是退回到它原先那副原始 JSON dump 的样子,diff 与 ANSI 载荷也各自保住自己的渲染。在此之前,实时路径与结算路径对结果的形状判断不一致,而实时路径输了。

把审批模式切到一个本来不会挂起等待确认的模式时,现在会释放那些它本来不会挂起的确认框,而不是让它们继续留在屏幕上、索要一个新模式已经替用户做出过的决定。释放集合与 ink 计算的是同一个,包括编辑模式只对编辑类工具释放这一条限制。

一条已提交的思考现在会报出模型思考了多久,使用 ink 同一个「一秒以下」的阈值——低于它就说「思考很短」而不是报一个时长——并且它自己提示里写的那个用于展开思考的键真的被绑定了,于是那句提示不再是在提供一个按下去什么也不会发生的按键。

声明了受门禁 MCP 服务器的项目,现在会在启动它们之前征求批准。这个渲染器原先根本没有这样一个对话框,所以项目自己配置里声明的服务器从来不会被提供出来,只是静静地保持未连接,屏幕上没有任何东西解释原因。队列、每个服务器的摘要、持久化的决定以及「全部批准」这条路径,都复用 ink 已在使用的策略;只有视图是新的,并且它排在 shell 确认与工具确认之前,与 ink 的排位一致。

会话记录与输入框现在处在 ink 使用的左右边距之内。正是这一处改动让补全下拉、附件列表和消息列落在 ink 放它们的位置。下拉自身的内部算术没有动,因为它本来就算出了正确的宽度,只是依据的那个盒子每边窄了两列。

状态行补上了 ink 会画的前缀。警告行原本一个前缀都没有,所以一条换行的警告会丢掉它的标记,并且向右溢出一列。错误行现在把它的重试提示放在同一行而不是第二行,与 ink 一致,同时把前缀钉住,使换行的正文无法把它挤掉。

页脚的加载指示器移到了 ink 的缩进,第二行按 ink 的方式拼接各段,token 估算改用真实值而不是占位值,并且已经武装的退出警告现在即使在对话框、确认框或下拉把页脚其余部分隐藏时也会渲染。在此之前,隐藏页脚的那个门禁同时把警告也隐藏了,所以在对话框打开时按第一次 Ctrl+C 完全没有任何反馈,用户无从得知再按一次就会退出。

Shift+Tab 现在从顶层而不是从输入框循环审批模式,所以在对话框或确认框把输入框卸载掉的时候它依然有效——而那恰恰是屏幕上的提示所宣称的状态。Windows 上那条同时接受裸 Tab 的兜底(有些终端分不清两者)被收窄为只接受裸 Tab,因此分得清的终端不会再为一次按键把模式推进两次。

上下文文件播报使用 ink 所用的谓词做闩锁,并在会话记录重置时重新武装,所以在已经运行着的渲染器里开启新会话会再次播报它的上下文文件,而不是在进程余下的时间里一直沉默。

在本渲染器挂载之前就已经闩锁上的扩展刷新提示,会在挂载时回放,于是那一条告诉用户去重新加载插件的提示不再因为订阅起得太晚而被丢掉。回放以闩锁的持有者为键,而不是以分发器的构造为键,因为闩锁比它活得久。

model 对话框带上了 ink 有的三道守卫。一次成功的选取不再顺带播报那个存活下来的模型,第二次 Escape、或者在一次 apply 仍在进行中时落下的第二次 Enter,都不会再播报第二次或发起第二次切换。

对话框的选择标记使用 ink 的尖角符而不是实心圆点,认证对话框改用共享的图标字形,这也带上了那些图标所携带的文本呈现选择符。会话条目取得 ink 给它们的按类型上边距,于是连续几条同类型条目不再读作一整块。确认框给出 ink 会给的按类型批准选项,shell 解析器在首帧绘制之前预热,首次提示因此不必为它付费。

新增一份双语设计文档,记录每一个决定、覆盖边界与后续工作。

为什么需要

OpenTUI 渲染器今天是可达的,但切过去会得到一个明显不同、且有些地方是坏的体验。一个运行中的 shell 命令会在每个进度 tick 上重画它的输出。一份待办清单渲染成 JSON dump。在对话框打开时按一次 Ctrl+C 会悄无声息地什么也不做。循环到一个会自动批准的模式,确认框却照样留在屏幕上。已经在运行的进程里开一个新会话,永远不会播报它的上下文文件。项目自己的 MCP 服务器从来不会被提供审批,也从来不连接。页脚所宣称的模式循环提示,恰恰在它显示出来的那些对话框里不起作用。这些都没有被单元测试抓到,因为每个组件单独看都是正确的——缺陷出在这些部件如何组合,以及对上游交给它们的东西所作的假设上。

评审测试计划

如何验证

用 OpenTUI 渲染器运行 CLI,走一遍下面这些;每一条在本分支之前都是错的。

  1. 让它执行一个会输出几行内容的 shell 命令,在运行过程中观察工具卡片。输出应当只出现一次并逐步增长。在此之前,每个进度 tick 都会把累积的完整显示再追加一遍,所以同样的行会反复出现并首尾相接。
  2. 让它写一份待办清单。卡片应当显示这份清单并带上 ink 的三种状态字形,而不是一段工具参数与结果的 JSON dump。
  3. 先把一个工具确认框挂起,然后把审批模式循环到一个会自动批准的模式。被挂起的确认框应当被释放、这一轮应当走完,而不是继续留在屏幕上等一个已经不再需要的决定。
  4. 在一个声明了需要审批的 MCP 服务器的项目目录里启动会话。应当出现一个对话框,提供批准、全部批准与拒绝,并写出服务器名与它的传输方式。拒绝应当留下一条可读的提示;批准应当把服务器连上。
  5. 在任意对话框打开时按一次 Ctrl+C,例如帮助浮层。退出警告应当出现在页脚。再按一次退出,或者等窗口期过去,确认警告会自行清除。
  6. 打开帮助浮层并按 Shift+Tab。页脚显示的审批模式应当推进,同时浮层自身应当退回它的 general 标签页。两件事同时发生,这也正是 ink 的行为。
  7. 把模式循环一整圈,确认它会从最后一个模式绕回 plan 模式,而不是停住。
  8. 打开 model 对话框,选一个模型并快速按两次 Enter。应当只发生一次切换、只打印一行确认。然后重新打开它并按两次 Escape:保留模型那一行只应出现一次。
  9. 在一个有上下文文件的目录里启动会话,然后清空并再启动一次。两个会话都应当播报它们的上下文文件。
  10. 输入一个斜杠和一个 @ 提及,检查下拉的左边缘与输入框文本对齐,并且一个很长的提及标签不会被截断到半行。
  11. 把终端收窄到某条警告消息发生换行,确认它保住了自己的前导标记,并且没有超出右边缘一列。
  12. 触发一个模型错误,确认错误与它的重试提示渲染在同一行,与 ink 一致。
  13. 用一个会输出 reasoning 的模型,让一次思考结束,确认那一行报出了它的耗时,并且一秒以下的思考读作「很短」。按下那一行自己提示里写的键,确认思考会展开与收起。这一条无法用下面证据所用的脚本 harness 验证,因为那个 harness 的假模型没有 reasoning 字段。

证据(前后对比)

用一个脚本化的 pty harness 采集,它驱动两个渲染器跑 18 个场景,并把每一帧写成文本网格:36 条腿、96 帧,没有任何一条腿报错。下面是同一个场景、同一终端尺寸、同一 fake model,在本分支之前与之后的样子——此时工具刚打印出一行。很长的绝对路径与页脚里的分支名用 省略,此外未作任何编辑。

之前(运行中的工具卡片、页脚,以及状态行),取自修复前的采集:

Update check skipped (registry error) — run /update to retry.
> run the echo tool
✓ Shell echo ACCEPT_TOOL_RAN_7731
  ACCEPT_TOOL_RAN_7731ACCEPT_TOOL_RAN_7731
◆ ACCEPT_TURN_COMPLETE_7732
…
 ──────────────────────────────────────────────────────────────────────────────────────────────────
 *   Type your message or @path/to/file
 ──────────────────────────────────────────────────────────────────────────────────────────────────
 YOLO mode
 ➜ work · git:(…) · fake-model
 YOLO mode

之后(同一场景),下面附上 ink 那一腿作为参照:

  ●︎ Extensions changed on disk. Run /reload-plugins to apply updates.
  △︎ Update check skipped (registry error) — run /update to retry.
  ●︎ Read context files: …/AGENTS.md, …/output-language.md

  > run the echo tool
  ✓ Shell echo ACCEPT_TOOL_RAN_7731
    ACCEPT_TOOL_RAN_7731

  ◆︎ ACCEPT_TURN_COMPLETE_7732
…
────────────────────────────────────────────────────────────────────────────────────────────────────
*   Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────
  ➜ work · git:(…) · fake-model
  YOLO mode (shift + tab to cycle)
  ●︎ Extensions changed on disk. Run /reload-plugins to apply updates.
  ●︎ Read context files: …/AGENTS.md, …/output-language.md

  > run the echo tool
  ✓ Shell echo ACCEPT_TOOL_RAN_7731
    ACCEPT_TOOL_RAN_7731

  ◆︎ ACCEPT_TURN_COMPLETE_7732
…
────────────────────────────────────────────────────────────────────────────────────────────────────
*   Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────
  ➜ work · git:(…) · fake-model
  YOLO mode (shift + tab to cycle)

重复的工具输出、缺失的前缀、缺失的上下文文件与扩展提示、重复的模式段以及缺失的循环提示,全部消失了。错误行是同一个故事,并且现在与 ink 逐字一致:

之前:  ✖︎ [API Error: …]           之后:  ✕ [API Error: …] (Press Ctrl+Y to retry)
        Press Ctrl+Y to retry               ink:  ✕ [API Error: …] (Press Ctrl+Y to retry)

待办清单与新增的 MCP 审批对话框,都与同一次采集里的 ink 腿对照:

opentui:  ✓ TodoList Create todos            ink:  ✓ TodoList Create todos
            ●︎  ACCEPT_TODO_ONE                     ●︎  ACCEPT_TODO_ONE
            ◐︎  ACCEPT_TODO_TWO                     ◐︎  ACCEPT_TODO_TWO
            ○︎  ACCEPT_TODO_THREE                   ○︎  ACCEPT_TODO_THREE
opentui: ╭─────────────────────────────────────────────────────────────────────────────────────────────╮
         │ Untrusted MCP server in .mcp.json                                                           │
         │ This workspace declares an MCP server. Approving lets Qwen Code start it and run its tools. │
         │  Approval is bound to this exact configuration — if .mcp.json changes, you will be asked    │
         │ again.                                                                                      │
         │ acceptance-server  node -e (stdio)                                                          │
         │ › 1. Approve this server                                                                    │
         │   2. Approve all pending servers in this workspace                                          │
         │   3. Reject (esc)                                                                           │
         ╰─────────────────────────────────────────────────────────────────────────────────────────────╯

ink:     ╭──────────────────────────────────────────────────────────────────────────────────────────────
         │ Untrusted MCP server in .mcp.json
         │ This workspace declares an MCP server. Approving lets Qwen Code start it and run its tools.
         │ Approval is bound to this exact configuration — if .mcp.json changes, you will be asked
         │ again.
         │ acceptance-server  node -e (stdio)
         │ › 1. Approve this server
         │   2. Approve all pending servers in this workspace
         │   3. Reject (esc)
         ╰──────────────────────────────────────────────────────────────────────────────────────────────

两个对话框承载的文本相同。看得见的差异是 ink 裁掉了自己右侧的两个角(因为它在一个整宽的盒子上设了左边距),以及本渲染器把某一个句子的换行续行多缩进了一列,而 ink 没有。

在最终那次采集的全部 48 对帧上度量,2 对逐行相同、46 对不同;只统计出现在单一渲染器里的行,并把版本号归一化掉之后,总数是 359。这比本分支 rebase 到当前 main 之前同一度量给出的 414 要窄,但这个收窄主要来自 main 自己的 ink 输出动了,而不是本分支变好了——因为两次度量之间本分支的源码没有变化。分歧是集中的:五帧占了这 359 行里的 162 行,其余每一帧的差异都不超过 17 行。这五帧里有四帧是 ink 不套框渲染的确认框与提问框帧,合起来 128 行,正是下面点名的后续工作;第五帧是上文刚描述的 MCP 对话框。

测试环境

OS 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Windows 没有在真实终端上测试。那条因为部分 Windows 终端分不清 Shift+Tab 与 Tab 而存在的裸 Tab 兜底,由钉住平台的单元测试覆盖,其中包括「裸 Tab 已经被一次补全用掉、因此不得同时循环模式」这一种情形。

环境

npm run build && npm run bundle,随后在 100x40 与 60x24 的 pty 下驱动。typecheck 在全仓干净,lint 与格式在所触及的树上干净。单元测试:470 个文件、9160 个用例,其中 21 个失败——这 21 个在从当前 main 单独检出的一份代码上以同样方式失败,而失败的文件没有一个被本 PR 触及。涉及的是两个文件:一个光标渲染套件,它的 4 个失败在两棵树里名字相同;以及一个认证对话框套件,两棵树上 26 个用例里都有 12 到 14 个超时,而确切的子集在同一棵树的两次运行之间也会变;单独运行时,两棵树产出的失败集合逐字相同。

风险与范围

  • 主要风险或取舍:工具输出这处改动同时改变了运行中的卡片显示什么、以及 shell 工具写进模型历史什么,因为两者现在来自同一个字符串。这正是改动的目的——在此之前它们是不一致的——但这也是本 PR 里行为面最宽的一处。新增的 MCP 审批对话框落在一条与安全相关的路径上:它复用 ink 已在使用的策略、持久化决定与队列,只新增视图,所以决定逻辑不是新的,但这是本渲染器第一次能够批准一个服务器。
  • 未验证 / 不在范围内:任何一帧都没有验证颜色,因为 harness 比对的是文本网格而不是属性,所以这里做出的配色选择是从 ink 的源码推出来的,而非观察得来。思考行那几处改动完全没有任何帧证据——harness 的假模型没有 reasoning 字段,所以没有任何场景能产生一条思考行——它们的按键接线也没有单测覆盖,因为本该承载它的那个套件把键盘 hook mock 掉了。MCP 对话框只在「单个服务器 + 拒绝」这条路径上有帧验证;批准、全部批准与多服务器队列在任何采集到的帧里都没有渲染过,因为批准会真的去启动那个声明的命令。队列徽标、认证勾选框、对话框打开时的退出警告路径、以及双击 Enter 的 model 守卫,都只有单元测试覆盖,因为没有脚本场景能产生它们。只驱动了两种终端尺寸。有一处偶发缺陷是记录而非修复:在某个场景的四次观测里有一次,本渲染器把扩展刷新提示打印了两遍,而 ink 只打印一遍。它无法用同一个 bundle 按需复现,也不是双重订阅,它是「回放一个在挂载之前就已闩锁上的状态」这件事固有的取舍——回放把「有时零条提示」换成了「有时两条」。结构性地留给后续工作、并且是剩余分歧里最大的一块:ink 把工具、shell 或提问确认直接内联渲染在会话记录里、外面不套框,而本渲染器画一个整宽的带框盒子,带上标题和一条 ink 不显示的导航提示,并且在 ink 放一个行末箭头的地方写了一条等待批准的注记;那四帧占全部剩余行分歧的 36%。同样留给后续工作:banner 保持钉住而不像 ink 那样滚出视口,对话框是不重排会话记录的浮层,工具卡片不把调用的参数行内打印出来,一张已结算的卡片停留在它被创建时的位置、而不是排在期间到达的提示之后,以及 ink 那条 Waiting for user confirmation 转轮行在这里没有对应物。未移植:ink 的排队消息正文与它在输入框上方的编辑提示,以及页脚的后台任务、MCP 健康、worktree、workflow 与 skill 评审各段。回放的扩展提示带不上它的原因,因为它据以回放的闩锁没有暴露原因。本渲染器刻意保留自己那条「更新检查已跳过」的措辞,而不采用 ink 的更新失败那一行。在 Windows 上,一个把 Shift+Tab 送成裸 Tab 的终端,在对话框已把输入框卸载时不会循环;要在不让其他平台冒双重循环风险的前提下补上这一点,需要底层库给出关于按键处理顺序的保证,而它没有。
  • 破坏性变更 / 迁移说明:无。core 包的公开面上增加了一行——把 shell 解析器的初始化函数重新导出,以便渲染器在首帧绘制前预热它。它与唯一的消费方在同一个提交里,因此没有任何提交会把一个未使用的导出留在身后。它不会让 core 入口去求值任何它原本不曾求值的模块。

设计文档:English · 中文

关联 Issue

属于 #8662 的一部分。

The OpenTUI dialog offered one generic four-row list for every tool
confirmation. That cost three things users rely on when deciding whether
to grant access.

The always-allow rows did not say what they were allowing, so approving
"Always allow in this project" gave no hint that the grant covers every
`touch` invocation rather than the one command on screen. They were also
offered in an untrusted folder, where a durable permission rule for a
workspace the user has not trusted is not a decision the dialog should
put in front of them.

Plan and edit confirmations got the wrong choices outright: a plan was
never offered the chance to restore the approval mode it replaced, and an
edit was offered the project/user-scoped persistence outcomes instead of
the session-wide allow-always that an edit actually supports.

Approval rows are numbered again, and a digit key picks one, matching the
inline prompt. Each type now also shows its own question line, so an
execution approval names the command root being permitted.
The OpenTUI renderer constructor installs a bare `globalThis.window` to
hang its requestAnimationFrame shim on. web-tree-sitter's UMD wrapper
probes `window.document.currentScript` when it is first evaluated, so
the first dynamic import after that point throws, and the parser latches
that failure permanently for the rest of the process.

Every OpenTUI session therefore silently lost AST-based shell analysis:
permission rules came back empty, so always-allow confirmations lost
their scope and persisted nothing, and read-only detection and
command-safety classification dropped to their conservative fallbacks.

Initialise the parser before the renderer exists, while `window` is
still undefined. Adds a one-line re-export from the core package index;
web-tree-sitter stays dynamically imported, so the deferred-runtime
invariant is unchanged.
The status row was truncated to a single line, so a narrow terminal dropped
the model segment that ink wraps onto a second row. The hint row now carries
the approval-mode name the composer stopped drawing, since ink uses that text
as an aria-label rather than a visible row and this renderer has no aria
surface. The hint row is still truncated, so it cannot grow the footer
mid-turn.

The loading indicator advertised an output-token estimate it never received:
the character counter and the receiving flag were declared as props but no
caller set them, so the estimate stayed pinned at zero and the direction arrow
always pointed down. Both now come from the live turn, counting model text,
thoughts and tool-call arguments the way ink does, and flipping back to the
waiting phase when tool results go to the model.

The footer also hid while a completion list was open only in ink; here it
drew under the dropdown and pushed the composer around.
The slash dropdown never populated a command's source badge, so skill, plugin
and MCP rows lost the marker ink draws and the shared label column was sized
without counting it. Descriptions were emitted verbatim, so a multi-line
command blurb kept its frontmatter line breaks and an over-wide one spilled
onto a second row, doubling the height of the popup. The row budget also
ignored that the dropdown nests its own side margins inside the composer's,
which left two columns too many for the description and wrapped its tail.

A label and its argument hint were concatenated into one run, so a hint too
long for the column word-wrapped the whole string and grew the row to three
lines where ink produces two. They are now laid out as separate children and
broken at the column edge, which puts the continuation at the hint's own
offset exactly as ink does.

Shift+Tab was unbound, so the footer advertised a cycle shortcut that did
nothing and the mode could only be changed through the dialog. The shell now
holds the mode the way ink's indicator does: it cycles on the keystroke,
adopts the dialog's choice into the same state, reports a refused change
instead of repainting a mode it does not hold, and explains an entry into auto
mode — including at startup, where no keystroke ever runs. Windows accepts a
bare Tab because some terminals there cannot distinguish the two, and the
completion consumers both claim the key first, so a Tab spent accepting a
suggestion never reaches the cycle.

The cycle order now comes from the shared list of modes rather than a copy of
the enum's declaration order, so it cannot drift from the one ink walks.
Both of ink's routes into auto mode gate the entry notices on the session not
already holding it. The rotation could never violate that, so the guard looked
redundant here and was left out — but the approval-mode dialog opens with the
current mode already selected, so a bare Enter re-picks it. The first-time
message survives that because it is acknowledged in settings; the notice listing
the allow rules auto mode stripped does not, and would reprint on every re-pick.

The gate now sits where both routes meet, and the comment that claimed ink's
slash command had no such gate is corrected: it does.
The dropdown sized its label column the same way whatever was being completed.
That is right for slash commands, where every row shares one half-width command
column so the descriptions line up, but a file list is not a command list: ink
only shares a column there when a row carries a description to line up against,
and lets a plain path take the whole row. Clamping it to half the width instead
wrapped any path longer than that onto a second row, mid-word, so one entry
occupied two lines and pushed the rest of the list down.

The column now follows the mode being completed, and the cell keeps the shared
width only where a description has to line up against it. The minimum readable
description width that caps the shared column moves next to the other constants
the two renderers have to agree on, rather than being copied out of one of them.
…column

The label column gained a branch for rows that carry a description, mirroring
the other renderer. That branch cannot fire here: this renderer's `@` completion
only ever asks the file index, and its results carry no description, so the only
rows that reach the dropdown with one are slash-command rows — which already take
the shared half-width column. The column is therefore either that column or none,
and the minimum readable description width that capped the dead branch goes back
to being local to the renderer that uses it.
Every popup this shell renders spanned the terminal edge to edge, while ink
wraps its popups in a two-column margin and caps their width, so a border ran
from column 2 to column 97 and stopped there. The wrapper now supplies both,
which is what makes a dialog read as a dialog rather than a full-screen mode.
The confirmations stay outside it: their body measures the terminal width to
estimate how its text wraps, so narrowing the box without also narrowing that
measurement would corrupt the estimate.

The help dialog opened on its command list rather than its overview, and its
tab keys did not match the ones the footer advertises. Both now follow ink:
the overview is the opening tab, Tab and Shift+Tab cycle it in the two
directions the hint promises, the arrow and page keys move the command window
and are inert on a tab that has none, and no other key closes or navigates.
Closing on a bare letter key meant a typo dismissed the dialog.

That command window was a fixed eighteen rows whatever the terminal height,
which is more than the body budget leaves once the tab's own introduction line,
its gap and the scroll hint are counted. The overflow was resolved by dropping
the gap under the introduction and clipping the hint away entirely, so the
scroll position the hint reports was simply absent. The window is now sized to
what the budget leaves after that chrome, and paging moves by the window that
is actually on screen. Below a 42-row terminal this shows a shorter list than
ink does, which is the smaller loss: ink keeps its eighteen rows and clips the
hint instead.

The model dialog drew its detail rule twenty characters wide against ink's
full-width one, and showed no line under a model's title. ink folds the runtime
and discontinued markers into the row description as well as the title, so a
runtime model with nothing of its own to say still gets an explanatory line;
the entries now carry that, and the rule is spelled out to the frame's inner
width because there is no single-sided border here to draw one with.
A frame-by-frame comparison of both renderers over 14 pty scenarios
turned up a set of divergences that were invisible in unit tests. This
groups them by topic.

Tool output is a snapshot, not an increment. Core hands the shell the
whole accumulated display on every progress event, so a running tool
card that appended painted the streamed text once per event. The event
field is renamed to say so, the card replaces instead of accumulates,
and the throttle that stops a redundant re-emit now compares lengths.
Shell mode loses its tail-deduplication tail and writes the same string
to the card and to the model history, so the two cannot disagree.

The transcript and the composer get ink's horizontal margins, which
makes the dropdown, the attachments and the body columns land where ink
puts them. The status rows gain the prefixes ink draws -- a warning row
had none at all -- and the error row collapses its hint onto the same
line instead of a second one, with the prefix pinned so a wrapped body
cannot shrink it away.

The footer's loading indicator moves to ink's indent, and an armed quit
warning now renders even while a dialog, a confirmation or a dropdown
has the rest of the footer hidden. Previously the gate that hid the
footer also hid the warning, so pressing Ctrl+C once with a dialog open
gave no feedback at all.

Shift+Tab cycles the approval mode from the top level, so it keeps
working while a dialog or a confirmation has the composer unmounted --
the on-screen hint advertises it in exactly those states. The Windows
fallback that also accepts a bare Tab is narrowed to bare Tab only,
since a terminal that can tell them apart would otherwise cycle twice.

The context-files announcement is latched with the predicate ink uses
and re-armed when the transcript resets, so a new session in the same
renderer announces its context files again.

The model dialog carries the three guards ink has. A successful pick no
longer also announces the model that survived, and neither a second
Escape nor a second Enter landing mid-apply can announce or start a
second switch.

Dialog selection markers use ink's chevron rather than a filled bullet,
and the auth dialog takes the shared icon glyphs.

Adds the bilingual design doc recording each decision, the coverage
boundary and the follow-ups.
ink leaves a blank row above most conversation items and decides it per
item type in its history renderer; this renderer printed every item
flush against the one before it, so a completed turn read as one
unbroken block where ink reads as separated rows.

The comparison harness had been folding the gap away: it reduces each
captured frame to its non-blank rows, so a capture could be reported
byte-identical while the two renderers disagreed on every vertical gap
between items. Measuring the transcript region alone, as a run-length
pattern of blank and content rows, isolates it and is unaffected by the
two renderers anchoring to opposite ends of the screen. After the fix
that pattern matches ink's group for group in every capture that
carries a conversation item.

Each item is now wrapped in a box carrying the margin resolved from its
kind. Two kinds have no ink counterpart -- a task card, because ink
renders a subagent as the tool that spawned it, and an image row,
because ink draws images inline inside the message that carries them --
and both take zero so they stay flush against the tool row beside them.
The margin sits on the wrapper while the per-item row cap applies to the
item's own content, so the two are additive and neither can clip the
other.

The design documents record the mapping in both languages, along with
two limits of the evidence: layout props are unobservable in the unit
test runtime, so this is verified by frames only, and the blank rows ink
shows around its banner come from how it batches permanent writes rather
than from a layout rule, so they are deliberately not reproduced.
… tool-result paths

An approval-mode switch now confirms the calls it would not have parked.
ink pairs the switch with the release; the selection rule deciding which
calls qualify was already ported here but had no caller, so the renderer
had the rule and not the behaviour.

A committed thought names its duration the way ink does, and the key its
collapsed hint advertises is now bound at the entry, so it still lands
while a dialog or a confirmation owns the screen.

A tool result's structured payload is resolved in one place. Five of the
six paths that turn a result display into events flattened straight to
text, which put a todo list's raw JSON on screen, on resume and on
replay, while the card's own checkbox-list renderer sat unused.
The follow-up list reported two of ink's authentication progress screens as
missing here, on the grounds that a login could not be completed without
them. Neither branch is reachable in ink: no provider in the registry
declares that auth type, so the single write site for the pending auth type
cannot produce it, and the external-auth state those screens read is
assigned null at both of its write sites. Record the check so the omission
is not re-reported as a gap.

The MCP server startup approval in the same bullet did survive: ink opens
that dialog whenever its approval queue is non-empty and this renderer has
no counterpart, so it stays.
A server list checked into a project was never offered for approval in
this renderer, so a gated server stayed silently disconnected and
nothing told the user why or what to do about it. ink opens the dialog
whenever its approval queue is non-empty.

The queue, the decision that persists against a hash of the config, the
un-gating and the reconnect all live in a renderer-agnostic hook, so
this is a view and a mount point rather than a second copy of the
policy. It ranks above the shell and tool confirmations, as in ink.

Geometry was measured against ink at a hundred columns rather than
assumed, and the first attempt was wrong twice: inset three with a box
one narrower than the shared popup width, and numbered rows. Declining
lands on the same composer and footer row in both legs. Two residuals
are recorded instead of matched — ink's right border is clipped away by
its own overflowing margin, and one wrapped body line carries an extra
leading space here.
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

COMMENT — no Critical found in what I read, but I could not complete the code gates on this diff within budget, so I am escalating rather than approving.

Scope note: 55 files, +4248/-548, roughly 1300 lines of production logic once the two design docs (+1208) and the ~1750 lines of test are set aside. I spent the budget on the surfaces where a defect would be silent and structural — a cross-module symbol move, a cross-module event-field rename, the new core export, and the approval rework — and cleared the first three. What follows separates what is verified from what is not.

Prior review history — nothing to re-verify

No review of any state has been filed on this PR and there are no review comments. The only thread entry is a triage-running lifecycle notice. There is no historical blocking issue.

Verified clean at this head

The normalizeDescription move is complete — no build break. It moved from packages/cli/src/ui/components/SuggestionsDisplay.tsx to packages/cli/src/ui/utils/suggestions.ts, and the old file now imports rather than re-exports it, so any surviving importer of the old path would fail to compile. There is exactly one importer repo-wide, the co-located SuggestionsDisplay.test.tsx, and this PR updates it — the import list drops the symbol and the now-misplaced describe('normalizeDescription') block is removed. The normalizeDescription in packages/core/src/memory/indexer.ts:153 is a separate file-local function, not a reference to this one, so there is no collision.

The tool-output field rename is consistent end to end. shell-mode.ts switched from emitting { type: 'tool-output', id, delta } to { …, output: cumulative }, which renames a field on a union consumed in four places. All four agree at this head: the union declares { type: 'tool-output'; id: string; output: string } (streaming-model.ts:34), the reducer reads event.output (streaming-model.ts:194), live-session-model.ts:325 reads ev.output, live-session.ts:856 and transcript-adapter.ts:138 both emit output. No producer or consumer is left on delta.

The switch from tail-append to whole-output is not a duplication bug. streaming-model.ts:194 assigns items[index] = { ...tool, output: event.output } and live-session-model.ts:325 assigns output: for both tool-output and tool-result, so both are replacement semantics. That is what makes the new finalOutput = prefixText + mainContent in shell-mode.ts correct rather than doubling the streamed text, and the LLM-history write is byte-identical to before — it previously passed ${prefixText}${mainContent} and now passes finalOutput, which is that same string.

The new core export resolves. packages/core/src/index.ts:721 adds export { initParser as initShellAstParser } from './utils/shellAstParser.js', and shellAstParser.ts:587 declares export async function initParser(): Promise<void>. It is the only initShellAstParser in the barrel, so the aliased named export cannot collide with the file's surrounding export * statements.

Gate 1 — the approval rework is security-bearing and I could not certify it fail-closed

dialogs-confirm.tsx (+299/-38) and opentui-dialog-mount.tsx (+105/-26) replace what the file's own header describes as the "deny-everything confirmation bridge" that Batch 5 shipped, with real per-type approval options resolving through confirmationDetails.onConfirm. That trade is the point of the change and is reasonable — but it removes a blanket fail-closed guarantee and replaces it with per-branch behaviour, which is exactly the kind of surface that needs branch-level confirmation rather than a skim.

What I did not establish, and what a clearance needs: that every non-affirmative path resolves to ToolConfirmationOutcome.Cancel and never to a Proceed* outcome. Concretely — the default: arm of the per-type switch (dialogs-confirm.tsx:230) for a confirmation type this renderer has no case for; dialog dismiss, escape, and focus-loss; an empty or filtered-to-empty options array; and the hideAlwaysAllow path (:241-242) that suppresses the always-allow rows for explicit-interaction and PM-ask rules, where a persisted allow rule must not be created. The new gated-MCP-server approval in opentui-dialog-mount.tsx needs the same treatment, since it adds a prompt where previously there was none.

This is not a defect claim — I found no evidence of a fail-open path. It is the one place in the diff where an error would be silent, security-relevant, and invisible to the parity snapshots, and I ran out of budget before reading the 946-line file properly.

Gate 2 — the rest of the production delta is unread

Not examined: opentui-app-shell.tsx (+254/-38), opentui-footer.tsx (+127/-36), input-prompt.tsx (+118/-46), transcript-view.tsx (+76/-33), event-adapter.ts (+50/-41), live-turn.ts (+55/-2), messages.tsx, help-overlay.tsx, help-content.ts, dialog-data.ts, dialogs-model/auth/misc/modes/arena, dialogs-shared.tsx, input-prompt-model.ts, client-tool-run.ts, live-session.ts, start-opentui-ui.tsx, streaming-model.ts beyond the union and reducer case above, and commands-dispatch.ts beyond the two changes noted below.

Two things I read in passing and did not fully verify, both worth a second look by whoever picks this up: commands-dispatch.ts now calls refreshNeededListener() synchronously from the constructor when the extension-refresh latch is already set, which runs before the renderer has mounted; and shell-mode.ts changed the streamed event from an append-delta to a full-output replacement, which is consistent with the reducers I checked but is a contract change for any consumer outside the four.

Gate 3 — nothing has certified the build at this head

Every substantive lane is still pending: Test (ubuntu-latest, Node 22.x), Lint & Static, Integration Tests (no-AK, No Sandbox), OpenTUI no-flicker gate and TUI parity snapshots (ink vs opentui). Only Classify PR, both Desktop Shell jobs, assign, authorize, label and Remind on force-push have completed, all green. Pending checks are not a blocker under this gate and I did not wait on them, but they matter more than usual here: this diff renames a field on a cross-module event union and moves an exported symbol between modules, which are precisely the two shapes typecheck exists to catch. I verified both by reading rather than relying on a green lane; a completed Lint & Static would confirm the other ~50 files I did not read.

Gate 4 — the repository's own core-infrastructure rule points to escalation

The diff adds an export to packages/core/src/index.ts, the public barrel, and changes both packages/core and packages/cli, so it is a cross-package change touching a core path. Under AGENTS.md's two-tier gate that is Tier 2: clearance requires 100% confidence with every downstream consumer named, and any doubt escalates. The 1000+ production-line advisory also applies. It is fix-typed rather than refactor, so the large-scope hard block does not engage. Gates 1 and 2 are that doubt.

What would let this clear

The cheapest path is a completed Lint & Static plus Test at this head, which retires Gate 3 outright, plus a branch-level confirmation of the Cancel-on-every-non-affirmative-path property in Gate 1 — the default: arm, dismiss/escape, empty options, and the hideAlwaysAllow rule. Gate 2 then reduces to a maintainer's judgement about whether a renderer-parity sweep of this size needs a full read before merge, which is a scope question rather than a defect.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR — the evidence bar here is unusually high. A scripted pty harness driving both renderers through 18 scenarios (36 legs, 96 frames) with the before/after grids pasted inline rather than asserted makes the problem statement easy to accept, and the places where a claim has no frame evidence are named as such instead of being glossed. That honesty is what makes the rest of the description trustworthy.

Template — complete ✓, including the bilingual section and the design doc in both languages.

Problem — observed, not theoretical. Each defect in the description has either a frame capture or an explicit reason it could not be captured (the reasoning-row changes have no frames because the harness's fake model has no reasoning field; the MCP dialog is only frame-verified on the single-server reject path because approving would really launch the declared command). The parity gaps are also registered upstream in #8662, the open tracking issue for this migration.

Direction — aligned. The renderer is opt-in behind QWEN_TUI_RENDERER=opentui, and #8662 puts real-device parity validation as the gate before the default flips. A renderer whose chrome diverges from ink cannot be validated against ink, so this is prerequisite work for the migration's own phase gate rather than a detour. One over-parity worth naming: the new MCP startup-approval dialog is a capability this renderer never had, not a pixel-alignment fix — see Approach.

Size — 4,796 changed lines across 55 files:

lines files
production logic 1,755 30
tests 1,833 23
design docs (.md) 1,208 2
generated / schema 0 0

Core is touched, but only by packages/core/src/index.ts (+1/-0): a single re-export of initParser as initShellAstParser. Everything else is packages/cli. Because production lines are 500+ on a PR that reaches into packages/core/src/**, this escalates for maintainer awareness under the two-tier core gate, and the 1,000+ large-PR advisory applies too. To be clear about what that escalation is actually driven by — the core footprint is one line, not core risk; the size is entirely in the renderer. The title is fix(cli):, not refactor, so the large-refactor hard block does not apply.

Approach — the individual fixes are well-targeted, and several are genuine de-duplications rather than added surface: the locally-copied APPROVAL_MODE_CYCLE array is replaced with core's own APPROVAL_MODES (enum order verified identical, so the cycle cannot drift), and the three separate copies of the diff → todos → ANSI precedence collapse into one extractStructuredResult. Fewer sources of truth is the right direction.

The scope question is bundling, not any single fix. Roughly fifteen independent defects land together across the transcript, composer, footer, dialogs and keyboard, plus one new feature. Two things I'd genuinely consider splitting out:

  • The MCP startup-approval dialog. This is the first time the renderer can approve a server, it sits on a trust boundary, and by the PR's own account only the single-server reject path has frame evidence — approve, approve-all and the multi-server queue have unit tests only. That deserves its own review pass rather than riding inside a pixel-parity sweep.
  • The model-dialog guards and the normalizeDescription move are unrelated to each other and to parity frames. The move is small and defensible (it lets the opentui composer reuse the ink helper instead of forking it), but it is a drive-by relative to the stated goal.

If you cut 80% of this — the tool-output snapshot fix, the footer/status prefixes, the Ctrl+C quit warning, the Shift+Tab top-level binding — would the renderer already be usable for Phase 2 validation? Probably close. That is the argument for splitting; it is a question, not a blocker.

Risk — no Stage 1e high-risk path matched (shell-mode.ts is not the shell.ts the pattern targets, and nothing touches the shell execution service, MCP client/pool, or the streaming/parser layers). Two places still deserve disproportionate reviewer time:

  1. The MCP approval dialog — a trust path, per above.
  2. The tool-output snapshot change — it alters both what the running card shows and the string written into model history, which now come from one variable. The author flags this as the widest behavioural surface in the PR and that read is correct. It does look like the right fix: history and display previously disagreed by construction.

Moving on to code review. 🔍

中文说明

感谢贡献 —— 这个 PR 的证据标准相当高。用脚本化的 pty harness 驱动两个渲染器跑 18 个场景(36 条 leg、96 帧),并把前后帧网格直接贴在正文里而不是口头断言,这让问题描述很容易被接受;而那些没有帧证据的地方也被明确点名,而不是含糊带过。正是这种坦诚让描述的其余部分值得信任。

模板 —— 完整 ✓,包含中英双语说明和两种语言的设计文档。

问题 —— 是已观测到的缺陷,不是理论性加固。描述里的每个缺陷都配有帧捕获,或明确说明了为什么无法捕获(思考行没有帧,是因为 harness 的假模型没有 reasoning 字段;MCP 对话框只在「单服务器 + 拒绝」这条路径上有帧验证,因为批准会真的去启动声明的命令)。这些 parity 缺口也已登记在 #8662 —— 本次迁移的开放跟踪 issue。

方向 —— 对齐。渲染器目前由 QWEN_TUI_RENDERER=opentui 选择性开启,而 #8662 把真机 parity 验证作为切换默认渲染器之前的闸门。一个 chrome 与 ink 存在分歧的渲染器无法拿 ink 来验证,所以这是迁移自身阶段闸门的前置工作,而不是岔路。有一处「超出 parity」值得点名:新增的 MCP 启动审批对话框是这个渲染器从未有过的能力,不是像素对齐修复 —— 见「方案」。

规模 —— 55 个文件、4,796 行改动:生产逻辑 1,755 行(30 个文件)、测试 1,833 行(23 个文件)、设计文档 1,208 行(2 个文件)、生成/schema 0 行。

确实触及了 core,但只有 packages/core/src/index.ts(+1/-0):把 initParserinitShellAstParser 重新导出这一行。其余全在 packages/cli。由于生产行数超过 500 且 PR 触及 packages/core/src/**,按两层 core 闸门规则升级给维护者知悉;1,000+ 的大 PR 提示同样适用。需要说清楚这次升级实际由什么驱动 —— core 的足迹只有一行,不是 core 风险,体量完全在渲染器里。标题是 fix(cli): 而非 refactor,因此大规模重构的硬性拦截不适用。

方案 —— 单点修复都很有针对性,而且有几处是真正的去重而非新增表面:本地复制的 APPROVAL_MODE_CYCLE 数组换成了 core 自己的 APPROVAL_MODES(已核对枚举顺序完全一致,因此循环顺序不会漂移),三份各自独立的 diff → todos → ANSI 优先级判断合并为一个 extractStructuredResult。减少真相来源是对的方向。

范围上的问题是「打包」,而不是任何单个修复。大约十五个互相独立的缺陷一起落地,横跨会话记录、输入框、页脚、对话框与键盘,外加一个新功能。有两处我会认真考虑拆出去:

  • MCP 启动审批对话框。 这是该渲染器第一次能够批准服务器,位于信任边界上;而且按 PR 自己的说明,只有「单服务器 + 拒绝」这条路径有帧证据 —— 批准、全部批准与多服务器队列只有单元测试。它值得单独一轮审查,而不是搭在一个像素 parity 清扫里。
  • model 对话框的守卫与 normalizeDescription 的搬迁彼此无关,也与 parity 帧无关。搬迁本身很小且站得住(让 opentui 输入框复用 ink 的 helper 而不是另起一份),但相对于既定目标属于顺手改动。

如果把这个 PR 砍掉 80% —— 只留工具输出快照修复、页脚/状态行前缀、Ctrl+C 退出警告、Shift+Tab 顶层绑定 —— 渲染器是否已经可用于 Phase 2 验证?大概已经很接近了。这是主张拆分的理由;它是一个问题,不是拦截项。

风险 —— Stage 1e 的高风险路径没有命中(shell-mode.ts 不是该模式所指的 shell.ts,也没有触及 shell 执行服务、MCP client/pool 或流式/解析层)。但仍有两处值得投入不成比例的审查时间:

  1. MCP 审批对话框 —— 如上,这是一条信任路径。
  2. 工具输出快照改动 —— 它同时改变了运行中卡片显示的内容以及写入模型历史的字符串,两者现在来自同一个变量。作者把它标为本 PR 行为面最宽的一处,这个判断是对的。它看起来确实是正确的修复:在此之前,历史与显示在构造上就是不一致的。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 5ed55b0e5bd6fd82187924101eb9a12499e62803 · re-run with @qwen-code /triage

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: No blocking findings on my side (self-authored, so posting as a comment, not an approval).

What I ran

  • Tree at 5ed55b0e, packages/core/dist built in-tree; env -u QWEN_RUNTIME_DIR npx vitest run src/ui/opentui src/ui/utils/suggestions.test.ts src/ui/model/streaming-model.test.ts76 files / 1403 tests all green, including every pin this PR adds.
  • Spot-checks that landed where the body claims: the tool-result precedence now funnels through one toolResultEvent for all four call sites (live, resume, compacted, shell) so it cannot drift; the model-dialog triple-latch (committed/closeLatch/inFlight) matches ink and resets are unnecessary since the mount unmounts; adoptApprovalMode releases exactly what selectAutoApprovals selects (the rule existed, this wires the caller); the Windows bare-Tab fallback is gated on !key.shift with the completion consumers above it returning, so the missing shouldBlockTab guard is genuinely not needed; help scroll clamping kills the held-key runaway.
  • Frame-level e2e I cannot run here (no model auth), so the visual/pty parity conclusions ride on the PR's own harness data (36 legs / 96 frames). CI legs are still in flight at the time of writing.

Re: @qqqys's review — baseline mismatch, none of the four findings exist on the current head

That review states its baseline as 0091971e, a head the 06:40Z force-push replaced with 5ed55b0e. Checked each finding against the current head:

  • R1-1 (update check freezes the renderer at import): the cited chain does not exist here. MCPHealthPill.tsx:24 is a plain if; the file imports only react/ink/theme/useMCPHealth. checkForUpdatesOnStartup and notification-state.ts appear nowhere in the tree. A module-scope-invocation scan over Footer.tsx, MCPHealthPill.tsx, useMCPHealth.ts, updateCheck.ts, handleAutoUpdate.ts returns zero hits — there is no import-time update check on this head to freeze anything.
  • R1-2 (Ctrl+C dead while a dialog is up): fixed, in exactly the shape proposed — start-opentui-ui.tsx:213-236 runs the ink handleExit cascade (parked confirmation settles first as Cancel, then turn interrupt, then the two-press exit window with the hint on the bottom bar).
  • R1-3 / R1-4 (quota / tips substring matching): allocation quota no longer appears anywhere in cli/src, tipRegistry.ts has no text.includes, and neither file is touched by this PR's 55 files. The concern is inherited from main, not introduced here.

Author: worth replying to that review to confirm which findings the squash folded in, so the next retest does not run against a dead commit.

Two non-blocking notes

  1. Esc auto-repeat can skip a gated server (pre-existing, both renderers). useMcpApproval.handleMcpApprovalSelect is async and calls setQueue((q) => q.slice(1)) only after awaiting approvals.setState(...), while current comes from the closure's queue[0]. Two Escape keydowns before the first await settles both act on server A, and the two slice(1) updates drop B with no decision recorded — B is silently absent for the session (not connected, not persisted, re-asked next startup). Ink's MCPServerApprovalDialog.tsx:42-43 wires Escape to the same handler identically, so this PR only extends exposure to the new renderer; parity holds. Suggested fix is hook-side (reject re-entry, or drop by current.name rather than by position).
  2. Stale PR description: it says the worktree isolation change was moved here from #11613, but no worktree files are in the 55-file diff at this head — a casualty of the squashes. Given the cross-baseline confusion already happened once today, I'd update the body (and mention it in the commit).

Disclosure

review-execution was not mounted for this run (no live model credentials): no pty/frame assertions from me. The release path is unit-covered only — the design doc itself flags that at Decision 17; I have no negative-control scenario to add beyond that note.

Overall: the risky parts (mode-switch release, MCP approval priority over the tool confirmation, structured-payload precedence) are the ones I expected to find problems in, and they check out; 1403 tests green.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review

I wrote down what I'd do before reading the diff: fix each defect at its own site, and reuse ink's policy and precedence rather than re-deriving them. The PR matches that and beats it in one respect worth calling out — instead of patching each site it removed the duplication that let the sites drift. Three separate copies of the diff → todos → ANSI precedence became one extractStructuredResult; a locally-copied approval-mode cycle array became core's own APPROVAL_MODES; two re-declared glyph tables went back to ui/constants.ts. Several of these defects were drift between copies, so deleting the copies is the actual fix rather than a tidy-up on top of it.

No critical blockers found. What follows is what I checked, because the useful part of this review is which of the plausible failure modes turn out to be real.

The tool-output snapshot change. All four producers were updated consistently — shell-mode.ts, both sites in live-session.ts, and transcript-adapter.ts — and the field rename deltaoutput is what makes that safe, since TypeScript now forces every producer through. On the consumer side I checked how far it reaches: reduceStreamEvent / reduceStreamEvents have no production callers outside their own module (only the HistoryItem and StreamEvent types are imported elsewhere), so that half is test-only surface. The live behaviour change is entirely in foldLiveEvent. The shell-mode unification is the substantive part and it is correct: finalOutput is now the single string feeding both the tool-result event and addShellCommandToLlmHistory, where before the card got a deduplicated tail and history got prefixText + mainContent — they diverged by construction, not by accident. The throttle predicate cumulative.length > emittedLength is equivalent to the old comparison against emittedText.length.

The one-line core change carries the most consequential fix in the PR, and it is easy to read straight past under a "parity" title. initParser latches failure permanently (parserInitFailed, shellAstParser.ts:580-592), and the renderer installs a bare globalThis.window before web-tree-sitter's UMD wrapper probes window.document.currentScript. Evaluating the parser after that point throws and latches, silently downgrading permission rules, read-only detection and command-safety classification to their regex fallbacks for the rest of the session. Warming it before createCliRenderer() is the right fix. The export itself is safe: initShellAstParser collides with nothing in the barrel; shellAstParser.js is already in the core entry's static closure via ./tools/shell.js (index.ts:200) and ./core/plan-mode-shell-policy.js (index.ts:81), so no new module is evaluated and the PR body's claim holds; and shell-ast-parser-lazy.test.ts builds its own temp esbuild entry, so the laziness invariant it pins is undisturbed.

The confirmation-dialog narrowing does not crash. SelectableConfirmationDetails drops ask_user_question and buildTypePrompt's default returns never, so the question was whether that type can reach the call at runtime and get undefined back. It cannot: at head, OpenTuiToolConfirmation returns early for it at line 544, before the call at 566. Core's union has exactly six members, so the exhaustiveness check is load-bearing rather than decorative. On the security side the change is strictly more restrictive — isTrustedFolder && !hideAlways gates the always-allow rows, so an untrusted folder is no longer offered a durable rule. Per-type labels match ink's ToolConfirmationMessage.tsx verbatim, and buildHumanReadableRuleLabel is exported from core permissions/rule-parser.ts:728. The autoModeFallback splice inserts before Cancel (always present) on a fresh per-call array, so no shared state is mutated.

The MCP dialog adds a view, not a policy. useMcpApproval already exists on main and owns the queue, the hash-bound persisted decision and the reconnect; the PR only renders it. Escape maps to REJECT, so it fails closed, and it is ranked above the tool and shell confirmations as ink ranks it. The first row is highlighted on "Approve this server" by default — that is ink's own ordering and it matches the pasted frame, so it is parity rather than a new default, but it is the kind of thing worth looking at with your own eyes.

Approval-mode release. Reuses selectAutoApprovals, already on main at live-session.ts:171 with tests pinning YOLO = release all, AUTO_EDIT = editing tools only, DEFAULT/AUTO/PLAN = nothing. nextApprovalMode now walks Object.values(ApprovalMode); the enum declaration order in config/approval-mode.ts is PLAN, DEFAULT, AUTO_EDIT, AUTO, YOLO, identical to the deleted array, so the cycle order provably cannot have changed. Both the keystroke and the dialog route funnel through adoptApprovalMode.

The Shift+Tab split is faithful. Ink's useAutoAcceptIndicator guards only the Windows bare-Tab route with shouldBlockTab and never guards Shift+Tab; this reproduces exactly that split. Narrowing the composer fallback with !key.shift is right — without it a Windows terminal that can tell them apart would advance the mode twice for one Shift+Tab. One difference in mechanism: ink uses an explicit predicate, this relies on control-flow ordering (both earlier Tab consumers return). Same effect, marginally more fragile, and the "Tab already spent on a completion" case has a test.

The help-overlay key removals are parity, not a regression. Dropping q, / and j/k looks like a UX loss until you read ink's Help.tsx: it handles only escape, tab/shift+tab (73-78) and up/down/pageup/pagedown (257-267), and its own hint reads "Tab/Shift+Tab to switch tabs · Esc to cancel". Tab order matches HELP_TABS.

The thinking row is character-exact against ink. BRIEF_THOUGHT_THRESHOLD_MS = 1_000 matches ConversationMessages.tsx:344, and both fallbacks match — ink's collapsed branch is completedLabel ?? t('Thinking'), its expanded branch appends the ellipsis. Note the two different formatDuration imports are correct: this file uses utils/displayUtils.js, the same module ink's ThinkMessage uses, while the footer uses utils/formatters.js, the same module ink's LoadingIndicator uses. LiveThinkingItem.durationMs exists and is populated on completion, so the new parameter is live rather than a dead switch.

Removed exports are all clean. MESSAGE_ICON had exactly two consumers, both updated; at head transcript-view.tsx no longer references it and imports ICON from ../constants.js, whose TOOL_STATUS glyphs are identical to the deleted copy. Switching to ICON.* adds U+FE0E — intentional, disclosed, zero-width. normalizeDescription moved to utils/suggestions.ts; its only importer was SuggestionsDisplay.tsx plus its test, both updated. Dropping the t import from input-prompt.tsx is safe — zero remaining t( calls at head. In the footer, wrap-ansi is already a packages/cli dependency (^10.0.0, five other files), formatTokenCount/formatDuration exist as export const in formatters.ts (53/84), every hook runs before the if (!streaming) return null early return so hook order is stable, and the /4 divisor with the / arrow matches ink's LoadingIndicator.tsx:81,101.

I also had the arithmetic and symbol-resolution cross-checked independently against the main checkout and the ink counterparts; that pass reached the same conclusion on all thirteen files it covered and confirmed statusLines[0..1] matches ink's MAX_STATUS_LINES = 2.

Non-blocking observations

  • The help-overlay scroll clamp is incomplete below ~76 columns. The new helpMaxScroll in opentui-dialog-mount.tsx builds its line list at dialogWidth, but HelpOverlay re-wraps at layout.safeWidth = Math.max(72, dialogWidth) (help-content.ts:170, untouched by this PR) and CommandsHelp computes its own clamp from that list. At 76 columns and above the two are identical. Below it, safeWidth is pinned at 72 while dialogWidth keeps shrinking, so the mount's cap is computed from a longer line list than the one actually rendered: a held ↓ inflates helpScroll past the real end, and ↑ then appears dead until it climbs back down — exactly the symptom the new comment says the clamp removes. Nothing renders wrong (the overlay's own Math.min re-clamps), so this is a narrow-terminal interaction papercut rather than a break, and the 72-column floor is pre-existing. But the two line-builds disagreeing is a latent trap; passing one width down, or clamping at the render site only, would close it.
  • Escape during an in-flight model apply is silently swallowed. onClose returns early while modelSelectionInFlightRef is set, so a keypress in that window gives no feedback and the dialog stays put until the apply resolves. That is deliberate and matches ink's guard, and applyModelSelection is a settings write plus a config call, so it should not hang — noting it only because the failure mode, if that promise ever stalls, is a dialog no key can close.
  • descriptionWidth floors at 1 where ink floors at 12. Math.max(columns - 8 - labelColumnWidth, 1) against ink's MIN_DESCRIPTION_WIDTH = 12 (SuggestionsDisplay.tsx:77,163). In a narrow terminal with a wide slash column a description can truncate to one character where ink would keep 12 and shrink the label column instead. The -8 budget itself is right (2+2 dropdown margins, 2 marker, 2 gutter).
  • Verification step 6 does not do what it says from the default state. "Open the help overlay and press Shift+Tab … the overlay itself should return to its general tab." The default tab is now 'general' (changed from 'commands') and Shift+Tab steps backward, so from a freshly opened overlay it lands on custom-commands — away from general. The step only reads correctly if you Tab somewhere else first. Worth rewording so a reviewer following it literally does not think something broke.
  • The duplicate extension notice is a disclosed trade-off, not an oversight. The mount replay trades "sometimes zero notices" for "sometimes two"; the author saw it once in four runs, could not reproduce it on demand, and recorded it. A cosmetic duplicated INFO row, acceptable to land as recorded — but it belongs on Migrate TUI rendering layer from ink to OpenTUI (tracking) #8662's follow-up list so it does not get lost.
  • Strings this PR adds in messages.tsx are untranslated ('Thought briefly', the thinking labels). This matches the surrounding file, which is already entirely untranslated, and ink's own ShellModeIndicator is untranslated too — a pre-existing gap in this renderer, not one introduced here. Noting it only because the PR does wrap the two commands-dispatch.ts strings in t(), so the direction of travel is right.

Two things I raised and then checked rather than leaving as questions. The footer status line now wraps to two rows instead of truncating to one, replacing a comment that cited #8667/#8666 about the footer growing mid-turn; those turned out to be about unbounded growth (the merged #8667 adds a persistent queued-count badge; #8666 is the open bug it works around), the two-row bound matches ink's MAX_STATUS_LINES = 2, and the queued badge lives in the hint row which stays truncated and always renders — so the reasoning holds. And the model-dialog latches never reset, which is safe only because the parent's onClose is setDialog(null) and the mount is keyed on the dialog, so a successful apply always unmounts; worth remembering if that ever changes.

The central change, as a flow

sequenceDiagram
    participant P1 as Scheduler
    participant P2 as shell-mode (producer)
    participant P3 as tool-output event
    participant P4 as foldLiveEvent
    participant P5 as Tool card
    participant P6 as LLM history
    P1->>P2: progress event carrying the whole accumulated display
    P2->>P3: output is the cumulative snapshot (not an increment)
    P3->>P4: the card replaces what it shows
    P4->>P5: rendered output
    P2->>P6: the same finalOutput string
    Note over P5,P6: one string, two consumers, so they cannot drift
Loading
Files changed — production sources (30 of 55 shown; the other 25 are 23 test files and 2 design docs)
File What changed
packages/cli/src/ui/opentui/dialogs-confirm.tsx Per-type confirmation prompts replacing one generic list; folder trust now gates the always-allow rows; new MCP approval view
packages/cli/src/ui/opentui/opentui-app-shell.tsx App-level Shift+Tab, mode-switch release of parked calls, context-files latch, MCP dialog ranking, footer gate, 2-column dialog margin
packages/cli/src/ui/opentui/input-prompt.tsx Composer margins and dropdown geometry, Windows bare-Tab fallback, description truncation, status text moved to the footer
packages/cli/src/ui/opentui/opentui-footer.tsx Real token estimate, animated timer, status-line wrapping, quit warning takes the hint slot, mode glyph and cycle hint
packages/cli/src/ui/opentui/opentui-dialog-mount.tsx Model dialog's three guards, help overlay keys and scroll clamping, model outcomes written to the transcript
packages/cli/src/ui/opentui/transcript-view.tsx Per-type top margins, thinking-row duration, shared ICON glyphs, width budget for the new margins
packages/cli/src/ui/opentui/event-adapter.ts One shared structured-payload precedence via extractStructuredResult and toolResultEvent, replacing three inline copies
packages/cli/src/ui/opentui/live-turn.ts Streaming character counter and receiving flag behind the token estimate
packages/cli/src/ui/opentui/live-session.ts Approval cycle now walks core's APPROVAL_MODES; live chunk path recognises structured payloads
packages/cli/src/ui/opentui/messages.tsx Thought duration labels; local glyph tables deleted in favour of ui/constants.ts
packages/cli/src/ui/opentui/shell-mode.ts Snapshot emission plus one string shared by the card and the LLM history write; tail dedup removed
packages/cli/src/ui/opentui/start-opentui-ui.tsx Shell AST parser warmed before the renderer installs globalThis.window; ctrl+O thought toggle; exitHint moved to the footer
packages/cli/src/ui/opentui/help-content.ts Scroll window derived from the body budget instead of a fixed 18 lines
packages/cli/src/ui/opentui/help-overlay.tsx Windowed scroll, margin moved to the shell wrapper
packages/cli/src/ui/opentui/commands-dispatch.ts Replays a pre-mount extension-refresh latch once, keyed on the latch owner; strings wrapped in t()
packages/cli/src/ui/opentui/client-tool-run.ts Settled client-tool results routed through the shared toolResultEvent
packages/cli/src/ui/opentui/dialogs-shared.tsx dialogAreaWidth helper and chevron selection marker
packages/cli/src/ui/opentui/dialog-data.ts Runtime and discontinued markers folded into the model row description
packages/cli/src/ui/opentui/transcript-adapter.ts Resume path uses the shared structured-payload precedence
packages/cli/src/ui/opentui/dialogs-model.tsx Rule width derived from the dialog content width; two-line option label
packages/cli/src/ui/components/SuggestionsDisplay.tsx normalizeDescription moved out to utils/suggestions.ts
packages/cli/src/ui/utils/suggestions.ts Receives normalizeDescription so the opentui composer can reuse it
packages/cli/src/ui/opentui/live-session-model.ts foldLiveEvent replaces tool output instead of appending
packages/cli/src/ui/opentui/dialogs-auth.tsx Shared icon glyphs and the text-presentation selector they carry
packages/cli/src/ui/opentui/input-prompt-model.ts Command suggestions carry a source badge
packages/cli/src/ui/model/streaming-model.ts tool-output renamed delta to output and the reducer replaces rather than accumulates
packages/cli/src/ui/opentui/dialogs-modes.tsx Selection marker parity
packages/cli/src/ui/opentui/dialogs-misc.tsx Selection marker parity
packages/cli/src/ui/opentui/dialogs-arena.tsx Selection marker parity
packages/core/src/index.ts One re-export of initParser as initShellAstParser so the renderer can warm the parser

Testing evidence

This is an unattended CI run, so I did not build or execute anything from this PR — the evidence below is the PR's own CI, read through the API for the reviewed commit. There are no failures to excerpt: every completed check is green or skipped.

The two checks that matter most for this PR's specific claim are green — TUI parity snapshots (ink vs opentui) is the repo's own ink-vs-opentui frame gate, and OpenTUI no-flicker gate is the renderer's regression gate. Test and Lint & Static being green also independently confirms the export and import moves I traced by hand (MESSAGE_ICON, t, the two formatDuration sources) actually typecheck. The 103 skipped checks are matrix legs, not failures. review-pr and triage are this bot's own orchestration jobs, not PR CI.

Check Conclusion
Test (ubuntu-latest, Node 22.x) success
Lint & Static (ubuntu-latest, Node 22.x) success
Integration Tests (no-AK, No Sandbox) success
TUI parity snapshots (ink vs opentui) success
OpenTUI no-flicker gate success
Desktop Shell (ubuntu-22.04) success
Desktop Shell (windows-2022) success
Classify PR, route, authorize, label, assign success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) in progress
review-pr, triage (bot orchestration, not PR CI) in progress
103 matrix legs skipped

Not verified by me, and not verifiable from the diff or from a green suite: the claims the author themselves flags as having no frame evidence. A passing suite proves the tests pass, not that the untested behaviour holds — and for several of these no test would fail if the behaviour were wrong.

Sandboxed verification would settle this: @qwen-code /tmux — the thinking-row duration and its ctrl+O key wiring have no frame evidence at all (the harness's fake model has no reasoning field, and the suite that would cover the keystroke mocks the keyboard hook), and the MCP dialog's approve, approve-all and multi-server queue paths have never been rendered in a captured frame because approving really launches the declared command. The quit-warning-with-a-dialog-open path, the queue badge, the auth checkboxes, the double-Enter model guard and the Windows bare-Tab narrowing are in the same position: unit tests only, no real terminal. The author has write access, so /tmux needs no sponsorship.

中文说明

代码审查。 我在读 diff 之前先写下了自己的方案:在每个缺陷各自的位置修复,并复用 ink 的策略与优先级判断而不是重新推导。这个 PR 达到了这个标准,并且在一点上做得更好 —— 它没有逐点打补丁,而是删掉了导致各处漂移的重复代码。三份各自独立的 diff → todos → ANSI 优先级判断合并为一个 extractStructuredResult;本地复制的审批模式循环数组换成 core 自己的 APPROVAL_MODES;两份重新声明的字形表回归 ui/constants.ts。其中几个缺陷本身就是副本之间的漂移,所以删除副本才是真正的修复,而不是修复之外的顺手整理。

未发现严重阻塞项。 下面列出我实际核对的内容,因为这次审查真正有价值的是判断哪些看起来可能出问题的地方确实出了问题。

工具输出快照改动。 四个生产方全部一致更新 —— shell-mode.tslive-session.ts 的两处、以及 transcript-adapter.ts —— 而字段从 delta 改名为 output 正是这一点得以保证的原因,TypeScript 现在强制每个生产方都要过一遍。消费侧我核对了影响范围:reduceStreamEvent / reduceStreamEvents 在自身模块之外没有生产调用方(其他地方只导入了 HistoryItemStreamEvent 这两个类型),所以那一半改动只是测试层面的表面。真正的行为改动全在 foldLiveEvent。shell-mode 的统一是实质部分,而且是正确的:finalOutput 现在是同时供给 tool-result 事件与 addShellCommandToLlmHistory 的同一个字符串;在此之前卡片拿到的是去重后的尾部,而历史拿到的是 prefixText + mainContent —— 它们在构造上就是不一致的,不是偶然。节流条件 cumulative.length > emittedLength 与原先对 emittedText.length 的比较等价。

这一行 core 改动承载了本 PR 最要紧的修复,而且在「parity」这个标题下很容易被直接读过去。initParser 会永久闩锁失败(parserInitFailedshellAstParser.ts:580-592),而渲染器会在 web-tree-sitter 的 UMD 包装去探测 window.document.currentScript 之前先装上一个裸的 globalThis.window。在那之后才求值解析器就会抛错并闩锁,从而在余下整个会话里把权限规则、只读检测与命令安全分类静默降级到正则兜底。在 createCliRenderer() 之前预热是正确的修复。导出本身是安全的:initShellAstParser 与 barrel 中任何名字都不冲突;shellAstParser.js 已经通过 ./tools/shell.jsindex.ts:200)与 ./core/plan-mode-shell-policy.jsindex.ts:81)处在 core 入口的静态闭包里,所以没有新模块被求值,PR 正文的说法成立;而 shell-ast-parser-lazy.test.ts 构建的是它自己的临时 esbuild 入口,因此它所钉住的惰性不变量未受干扰。

确认对话框的类型收窄不会崩溃。 SelectableConfirmationDetails 剔除了 ask_user_question,而 buildTypePromptdefault 分支返回 never,所以问题在于运行时是否可能有该类型走到这里并拿回 undefined。答案是不会:在 head 版本上,OpenTuiToolConfirmation 在第 544 行就该类型提前返回,早于第 566 行的调用。core 的联合类型恰好有六个成员,所以这个穷尽性检查是承重的,不是装饰。安全侧上这个改动是严格更收紧的 —— isTrustedFolder && !hideAlways 把关了 always-allow 行,因此不受信任的目录不再会被提供一条持久规则。各类型的选项文案与 ink 的 ToolConfirmationMessage.tsx 逐字一致,buildHumanReadableRuleLabel 由 core 的 permissions/rule-parser.ts:728 导出。autoModeFallback 的 splice 插入在 Cancel 之前(Cancel 恒在),且操作的是每次调用新建的数组,因此不存在共享状态被改写。

MCP 对话框新增的是视图,不是策略。 useMcpApproval 在 main 上已存在,负责队列、与配置哈希绑定的持久化决定以及重连;本 PR 只负责渲染它。Escape 映射为 REJECT,因此是失败即关闭;它的排序高于工具与 shell 确认,与 ink 一致。首行默认高亮在「Approve this server」上 —— 这是 ink 自己的排序,也与贴出的帧一致,所以是 parity 而非新的默认值,但这是那种值得亲眼看一下的地方。

审批模式释放。 复用了 selectAutoApprovals,它已在 main 的 live-session.ts:171,并有测试钉住 YOLO = 全部释放、AUTO_EDIT = 仅编辑类工具、DEFAULT/AUTO/PLAN = 不释放。nextApprovalMode 现在走 Object.values(ApprovalMode)config/approval-mode.ts 里的枚举声明顺序是 PLAN, DEFAULT, AUTO_EDIT, AUTO, YOLO,与被删除的数组完全一致,因此循环顺序可证明没有改变。按键与对话框两条路径都汇入 adoptApprovalMode

Shift+Tab 的拆分是忠实的。 ink 的 useAutoAcceptIndicator 只用 shouldBlockTab 守卫 Windows 裸 Tab 这条路径,从不守卫 Shift+Tab;这里精确复现了同样的拆分。用 !key.shift 收窄输入框的兜底是正确的 —— 否则一个能够区分两者的 Windows 终端会因为一次 Shift+Tab 而把模式推进两次。机制上有一处差别:ink 用显式谓词,这里依赖控制流顺序(前面两个 Tab 消费方都 return)。效果相同,稍微更脆弱一些,而「裸 Tab 已经被一次补全用掉」这种情形有测试覆盖。

help 浮层按键的移除是 parity,不是退化。 去掉 q/j/k 看起来像是体验损失,直到读 ink 的 Help.tsx:它只处理 escapetab/shift+tab(73-78 行)与 up/down/pageup/pagedown(257-267 行),而它自己的提示写的是「Tab/Shift+Tab to switch tabs · Esc to cancel」。标签页顺序与 HELP_TABS 一致。

思考行与 ink 逐字符一致。 BRIEF_THOUGHT_THRESHOLD_MS = 1_000ConversationMessages.tsx:344 相同,两个兜底也都相同 —— ink 折叠分支是 completedLabel ?? t('Thinking'),展开分支追加省略号。注意两处不同的 formatDuration 导入是正确的:本文件用 utils/displayUtils.js,与 ink 的 ThinkMessage 相同;页脚用 utils/formatters.js,与 ink 的 LoadingIndicator 相同。LiveThinkingItem.durationMs 存在且在完成时被填充,所以新增参数是活的,不是一个永远不会被设置的开关。

被删除的导出都是干净的。 MESSAGE_ICON 恰好有两个消费方,两者都已更新;在 head 上 transcript-view.tsx 已不再引用它,并从 ../constants.js 导入 ICON,后者的 TOOL_STATUS 字形与被删除的副本完全一致。改用 ICON.* 会带上 U+FE0E —— 有意、已披露、零宽。normalizeDescription 迁到 utils/suggestions.ts;它唯一的导入方是 SuggestionsDisplay.tsx 及其测试,两者都已更新。从 input-prompt.tsx 去掉 t 导入是安全的,head 上已无任何 t( 调用。页脚方面,wrap-ansi 本就是 packages/cli 的依赖(^10.0.0,另有五个文件在用),formatTokenCount/formatDurationexport const 形式存在于 formatters.ts(53/84 行),所有 hook 都在 if (!streaming) return null 提前返回之前调用因此顺序稳定,而 /4 的 token 除数与 / 箭头与 ink 的 LoadingIndicator.tsx:81,101 一致。

算术与符号解析这部分我还另做了一次独立交叉核对(对照 main 检出与 ink 的对应实现),结论与我一致,覆盖的十三个文件全部干净,并确认 statusLines[0..1] 与 ink 的 MAX_STATUS_LINES = 2 相符。

非阻塞观察:

  • help 浮层的滚动钳制在约 76 列以下不完整。 opentui-dialog-mount.tsx 里新增的 helpMaxScrolldialogWidth 构建行列表,但 HelpOverlay 会按 layout.safeWidth = Math.max(72, dialogWidth)help-content.ts:170,本 PR 未改动)重新折行,而 CommandsHelp 又从那份列表算出自己的钳制值。76 列及以上两者完全相同;低于 76 列时 safeWidth 被钉在 72 而 dialogWidth 继续缩小,于是挂载点的上限是按一份比实际渲染更长的行列表算出来的:按住 ↓ 会把 helpScroll 抬过真实末尾,随后 ↑ 看起来失灵,直到它降回来 —— 正是新注释声称已消除的那个症状。渲染不会出错(浮层自己的 Math.min 会再钳一次),所以这是窄终端下的交互毛刺而非破损,而且 72 列这个下限是既有代码。但两份行构建互相不一致是个潜在陷阱;把同一个宽度往下传、或只在渲染处钳制,都能收口。
  • model 应用进行中时按 Escape 会被静默吞掉。 modelSelectionInFlightRef 置位期间 onClose 提前返回,所以这个窗口里的按键没有任何反馈,对话框会停在原地直到应用完成。这是有意的、也与 ink 的守卫一致,而 applyModelSelection 是一次设置写入加一次 config 调用,本不该挂住 —— 之所以提一句,是因为万一那个 promise 真的停滞,失败形态是一个任何按键都关不掉的对话框。
  • descriptionWidth 下限是 1,而 ink 是 12。 Math.max(columns - 8 - labelColumnWidth, 1) 对应 ink 的 MIN_DESCRIPTION_WIDTH = 12SuggestionsDisplay.tsx:77,163)。在窄终端加宽 slash 列时,描述可能被截到一个字符,而 ink 会保留 12 并转而压缩标签列。-8 这个预算本身是对的(2+2 下拉边距、2 标记、2 间隙)。
  • 验证步骤 6 从默认状态出发并不会得到它所说的结果。「打开 help 浮层并按 Shift+Tab……浮层本身应回到 general 标签页。」默认标签页现在是 'general'(从 'commands' 改来),而 Shift+Tab 是反向步进,所以从刚打开的浮层出发会落到 custom-commands —— 离开 general。只有先 Tab 到别处,这一步才读得通。建议改写措辞,以免照着做的审查者以为出了问题。
  • 重复的扩展提示是已披露的取舍,不是疏漏。 挂载时的回放把「有时零条提示」换成了「有时两条」;作者在四次运行中观测到一次,无法按需复现,并把它记录了下来。这是一条外观性的重复 INFO 行,作为已记录事项落地可以接受 —— 但它应该出现在 Migrate TUI rendering layer from ink to OpenTUI (tracking) #8662 的后续清单上,以免被遗忘。
  • 本 PR 在 messages.tsx 中新增的字符串未走翻译'Thought briefly'、思考行的各个标签)。这与周围文件一致 —— 该文件本来就完全未翻译 —— 而 ink 自己的 ShellModeIndicator 同样未翻译,所以这是该渲染器既有的缺口,不是这里引入的。之所以提一句,是因为本 PR 确实commands-dispatch.ts 的两个字符串包进了 t(),方向是对的。

有两处我先提出、然后去查证而没有留作疑问。页脚状态行现在会折成两行而不是截断成一行,替换掉的那条注释曾引用 #8667/#8666 说明页脚不得在回合中途长高;查证结果是那两个 issue 关心的是无上限增长(已合并的 #8667 增加了一个常驻的排队计数徽标,#8666 是它绕开的那个未关闭 bug),两行这个上限与 ink 的 MAX_STATUS_LINES = 2 相符,而排队徽标位于保持截断且恒定渲染的提示行里 —— 所以这个推理成立。另外 model 对话框的几个闩锁都不复位,其安全性只依赖于父级的 onClosesetDialog(null) 且挂载点以对话框为 key,因此一次成功的应用必然会卸载;如果将来这一点变了,需要记住它。

测试证据。 这是一次无人值守的 CI 运行,所以我没有构建或执行本 PR 的任何代码 —— 下面的证据是 PR 自己的 CI,通过 API 针对被审查的提交读取。没有失败可摘录:所有已完成的检查不是绿就是跳过。

对本 PR 具体主张而言最重要的两个检查是绿的 —— TUI parity snapshots (ink vs opentui) 是仓库自己的 ink 与 opentui 帧比对闸门,OpenTUI no-flicker gate 是该渲染器的回归闸门。TestLint & Static 为绿也独立确认了我手工追踪的那些导出与导入搬迁(MESSAGE_ICONt、两个 formatDuration 来源)确实能通过类型检查。103 个跳过的是矩阵分支,不是失败。review-prtriage 是本机器人自己的编排任务,不是 PR 的 CI。

我未验证、且无法从 diff 或绿色套件验证的部分: 作者自己标明没有帧证据的那些主张。套件通过证明的是测试通过,而不是未被测试的行为成立 —— 而这其中有几项即使行为是错的也不会有任何测试失败。

沙箱化验证可以定这件事:@qwen-code /tmux —— 思考行的时长与其 ctrl+O 按键接线完全没有帧证据(harness 的假模型没有 reasoning 字段,而本该覆盖该按键的套件把键盘 hook mock 掉了),并且 MCP 对话框的批准、全部批准与多服务器队列路径从未在任何采集到的帧里渲染过,因为批准会真的去启动声明的命令。对话框打开时的退出警告路径、队列徽标、认证勾选框、双击 Enter 的 model 守卫,以及 Windows 裸 Tab 收窄,处境相同:只有单元测试,没有真实终端。作者具备写权限,因此 /tmux 无需赞助即可触发。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 5ed55b0e5bd6fd82187924101eb9a12499e62803 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — clean review, capped by policy rather than by doubt: a 1,755-production-line PR that reaches into packages/core/src/** escalates for maintainer awareness, and that escalation forbids an automatic approve no matter how the code read.

Going back to the proposal I wrote before opening the diff — fix each defect at its own site, reuse ink's policy rather than re-deriving it — the PR does that and then goes one better, by deleting the duplicated precedence tables and the copied approval-mode array that let the two renderers drift in the first place. That is the part I'd thank the author for in six months. Most of these defects were not wrong logic in one place; they were two places holding the same fact, and the fix is that there is now one.

I looked hard for the failure modes this shape of PR usually carries and could not find one that blocks. The four producers of the renamed tool-output field all moved together, and the rename is what forces that. The narrowed confirmation union cannot be reached by the type it excludes, because ask_user_question returns early above the call. Every removed export has no surviving importer. The two formatDuration imports that look like a mistake are each faithful to the ink component they mirror. The approval-mode cycle order is provably unchanged, because the enum it now walks declares its members in the same order as the array it replaced. And the trust gating on the confirmation dialog makes it strictly harder to grant a durable rule than before, not easier — the one place this PR touches a security decision, it tightens it.

The core re-export deserves a sentence of its own, because it is one line and it is the most valuable change here. Under this renderer the shell AST parser was being initialised after globalThis.window existed, which makes web-tree-sitter's UMD wrapper throw, and initParser latches that failure for the rest of the process. The consequence is not cosmetic: permission rules, read-only detection and command-safety classification all fall back to their regex approximations silently, for the whole session. That is worth pulling out of a PR titled "parity gaps" and looking at on its own merits.

So why 3/5 and not 4.

The cap is the two-tier core gate, and I want to be precise about what triggered it. The core footprint is a single line; the escalation fires on total production size, not on core risk. That is the rule as written and I am applying it, but a maintainer reading this should know the size is entirely in the renderer and the core change is the safest line in the diff.

Beyond the cap there are two things I genuinely cannot settle from here, and they are the reason this is a defer rather than a rubber stamp:

  1. A set of behavioural claims that nothing currently verifies. The thinking-row duration and its ctrl+O wiring have no frame evidence at all — the capture harness's fake model has no reasoning field, so no scenario can produce a thinking row — and no unit coverage either, because the suite that would carry it mocks the keyboard hook. The MCP dialog's approve, approve-all and multi-server queue paths have never been rendered in a captured frame, because approving really launches the declared command. Those are exactly the paths where a green suite is least informative: nothing in it would fail if the behaviour were wrong. @qwen-code /tmux closes this, and the author has write access so it needs no sponsorship.
  2. Whether this should have been one PR. Roughly fifteen independent defects plus a new capability on a trust boundary. I argued in Stage 1 that the MCP approval dialog wants its own review pass; nothing I found in the code changed my mind, and nothing I found makes it a blocker either. Splitting or not splitting is a call about review debt that belongs to whoever maintains this renderer, not to me.

One specific correction to the record, because it contradicts a claim in the author's self-review. That review says the help scroll clamping "kills the held-key runaway". It does at 76 columns and wider, which is where anyone will normally look. Below that the mount computes its clamp from lines wrapped at dialogAreaWidth while HelpOverlay re-wraps at Math.max(72, dialogWidth), so the two line lists disagree and the runaway the clamp was added to remove can still appear. Nothing renders incorrectly — the overlay re-clamps at the render site — and the 72-column floor predates this PR, so this is a papercut in an already-degraded regime rather than a regression. It is worth knowing that the fix is conditional, and it is a one-line change to make it unconditional.

For the avoidance of doubt about what counts as evidence here: the author's own comment reports 76 files / 1,403 tests green locally. That is the author's claim and I have not treated it as evidence. What I relied on is the PR's CI, which is independent and green on the legs that matter — Test, Lint & Static, Integration Tests, and both TUI parity snapshots (ink vs opentui) and OpenTUI no-flicker gate. One leg (web-shell E2E Smoke) was still running when I wrote this. The green lint and typecheck legs also confirm by compiler what I traced by hand across the export moves.

@qqqys — deferring to you rather than approving. You are the most recent human reviewer and the owner map resolves no area for this PR (it carries no labels), so you are who the deterministic resolver lands on. To be explicit about what I am asking you for: not a second opinion on correctness, which I am reasonably confident about, but the two calls I cannot make — whether the unverified behavioural surface above needs a /tmux run before this lands, and whether a fifteen-defect sweep plus a new trust-path dialog should land as one PR or be split. Assigning so it shows up in your filter.

中文说明

信心度:3/5 —— 审查是干净的,压到 3 分是规则所致而非我存疑:一个 1,755 行生产逻辑、且触及 packages/core/src/** 的 PR 会升级给维护者知悉,而这条升级本身就禁止自动 approve,无论代码读起来多干净。

回到我在打开 diff 之前写下的方案 —— 在每个缺陷各自的位置修复、复用 ink 的策略而不是重新推导 —— 这个 PR 做到了,而且还更进一步:它删掉了那些让两个渲染器互相漂移的重复优先级表和被复制的审批模式数组。这是六个月后我会感谢作者的地方。这些缺陷大多不是某一处逻辑写错了,而是两个地方各存着同一个事实;修复的本质是现在只剩一个。

我认真去找这类形状的 PR 通常会带的失败模式,没找到能构成阻塞的。改名后的 tool-output 字段,四个生产方全部同步迁移,而正是这次改名强制了这一点。收窄后的确认联合类型不可能被它排除掉的那个类型触达,因为 ask_user_question 在调用之前就已提前返回。每一个被删除的导出都不再有存活的导入方。那两处看起来像笔误的 formatDuration 导入,各自都忠实于它所对照的那个 ink 组件。审批模式的循环顺序可证明未变,因为它现在所走的枚举,其成员声明顺序与它替换掉的那个数组一致。而确认对话框上的信任门禁,使得授予一条持久规则比以前更而不是更容易 —— 本 PR 唯一触及安全决策的地方,是往收紧的方向走的。

那一行 core 重新导出值得单独说一句,因为它只有一行,却是这里最有价值的改动。在该渲染器下,shell AST 解析器原本是在 globalThis.window 已经存在之后才初始化的,这会让 web-tree-sitter 的 UMD 包装抛错,而 initParser 会把这次失败闩锁到进程结束。后果不是外观性的:权限规则、只读检测与命令安全分类会全部静默退化到各自的正则近似,并且是整个会话期间。这一点值得从一个标题写着「parity gaps」的 PR 里拎出来,单独按它自己的价值来看。

那么为什么是 3/5 而不是 4。

压分来自两层 core 闸门,我想说清楚触发它的究竟是什么。core 的足迹只有一行;升级是按生产代码总量触发的,不是按 core 风险。规则就是这么写的,我照它执行 —— 但读到这里的人应该知道,体量全在渲染器里,而那处 core 改动是整个 diff 中最安全的一行。

除了这条压分之外,还有两件我在这里确实无法定论的事,它们才是这次「转交」而不是「走过场批准」的原因:

  1. 有一组行为主张目前没有任何东西在验证。 思考行的时长与其 ctrl+O 接线完全没有帧证据 —— 采集 harness 的假模型没有 reasoning 字段,所以没有任何场景能产生一条思考行 —— 也没有单元测试覆盖,因为本该承载它的那个套件把键盘 hook mock 掉了。MCP 对话框的批准、全部批准与多服务器队列路径从未在任何采集到的帧里渲染过,因为批准会真的去启动声明的命令。这些恰恰是绿色套件信息量最低的地方:即使行为是错的,套件里也没有任何东西会失败。@qwen-code /tmux 可以定这件事,而且作者具备写权限,无需赞助。
  2. 这是否本该是一个 PR。 大约十五个互相独立的缺陷,外加一个位于信任边界上的新能力。我在 Stage 1 就主张 MCP 审批对话框值得单独一轮审查;我在代码里的发现没有改变这个看法,但也没有让它变成阻塞项。拆或不拆,是一个关于审查债的判断,属于维护这个渲染器的人,不属于我。

有一处需要更正记录,因为它与作者自评中的说法相左。那份自评说 help 滚动的钳制「消除了按住按键失控」。在 76 列及以上确实如此,而那也正是平时会去看的地方。低于 76 列时,挂载点按 dialogAreaWidth 折出的行来算钳制值,而 HelpOverlayMath.max(72, dialogWidth) 重新折行,于是两份行列表不一致,钳制本该消除的那个失控仍然可能出现。渲染不会出错 —— 浮层在渲染处会再钳一次 —— 而且 72 列这个下限早于本 PR 存在,所以这是在一个本就已退化区间里的毛刺,不是回归。值得知道的是这个修复是有条件的,而让它变成无条件只需一行改动。

为避免「什么才算证据」这件事含混:作者自己的评论报告了本地 76 个文件 / 1,403 个测试全绿。那是作者的主张,我没有把它当作证据。我依据的是 PR 自己的 CI —— 它是独立的,并且在要紧的分支上是绿的:TestLint & StaticIntegration Tests,以及 TUI parity snapshots (ink vs opentui)OpenTUI no-flicker gate 两个闸门。我写这段时还有一个分支(web-shell E2E Smoke)在跑。绿色的 lint 与类型检查分支也用编译器确认了我手工追踪的那些导出搬迁。

@qqqys —— 我转交给你,而不是批准。你是最近一位人类审查者,而 owner 映射对本 PR 解析不出任何领域(它没有标签),所以确定性解析器落到的就是你。我想明确我在请你做什么:不是请你对正确性给第二意见 —— 那一点我比较有把握 —— 而是那两个我做不了的决定:上面那批未被验证的行为面,在落地之前是否需要跑一次 /tmux;以及一次十五个缺陷的清扫外加一个新的信任路径对话框,应该作为一个 PR 落地还是拆开。已把你设为 assignee,方便它出现在你的筛选里。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 5ed55b0e5bd6fd82187924101eb9a12499e62803 · re-run with @qwen-code /triage

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE — no Critical found. I read the surfaces @qqqys escalated on and can retire Gates 1, 2 and 3 at head 5ed55b0e. Gate 1 in particular I traced branch by branch rather than skimmed, since it is the one place in this diff where an error would be silent and security-relevant.

Gate 1 — the approval rework is fail-closed on every non-affirmative path

Certified against the five branches named in the escalating review:

1. The default: arm cannot ship unhandled. dialogs-confirm.tsx:230-232 is const exhaustive: never = details; return exhaustive; — a compile-time exhaustiveness guard, not a runtime fallback. Adding a confirmation type to the union without a case here makes details non-never and fails typecheck, so an unhandled type cannot silently reach production. Lint & Static is green at this head, which means every current member has a case. This is stronger than a runtime return { options: [Cancel] } would have been.

2. Escape and double-settle. dialogs-confirm.tsx:537-541 settles ToolConfirmationOutcome.Cancel on escape, and settle is guarded by settledRef (:528-533), so a keystroke racing a selection cannot resolve the same call twice or upgrade a Cancel into a Proceed.

3. Empty and filtered-to-empty options. dialogs-confirm.tsx:669-673 settles via onAnswered(null)settle(Cancel) when there are no questions or no options, from an effect rather than during render. No path where an empty list resolves to a Proceed or leaves the call unsettled.

4. hideAlwaysAllow cannot create a durable rule, and cannot shift the cursor onto one. dialogs-confirm.tsx:251-252 passes isTrustedFolder && !hideAlways as showAlwaysAllow, which suppresses only the ProceedAlwaysProject / ProceedAlwaysUser pushes (:140-154, :175-179). Cancel is pushed unconditionally afterwards (:155-158, :180-183), so the decline row survives every filter. The AUTO-fallback splice at :261-270 inserts ProceedOnceAndSwitchToDefault at cancelIndex, i.e. immediately before Cancel, so suppressing or inserting rows never moves an existing index onto a more permissive value.

This gate matches ink exactly: components/messages/ToolConfirmationMessage.tsx:256 and :383 use the same isTrustedFolder && !confirmationDetails.hideAlwaysAllow condition, and ink also pushes "Yes, allow once" first. Worth stating explicitly because it is the one behaviour that looks like a loosening: the default selected row is index 0 = ProceedOnce, so a bare Enter approves once. That is pre-existing ink behaviour (RadioSelect starts at 0) rather than something this PR introduces, and it is a one-time allow, never a persisted rule. The old deny-everything bridge was stricter than ink, not correct-by-design — matching ink here is the point of the change.

5. Gated MCP approval adds a view, not a second policy. opentui-app-shell.tsx:493 calls the renderer-agnostic useMcpApproval(config) — the same hook ink drives from AppContainer.tsx:3894 — and this PR does not modify it (git diff on packages/cli/src/ui/hooks/useMcpApproval.ts is empty). dialogs-confirm.tsx:74 imports only the PendingMcpServer type. So the queue, the hash-keyed persisted decision, the un-gating and the reconnect are ink's code verbatim; there is no new route by which a server can start unapproved. The ranking claim is literally implemented: the ternary at opentui-app-shell.tsx:934-963 tests mcpApproval.isMcpApprovalDialogOpen first, then activeToolCall, then activeModal.kind === 'shell'. While the MCP dialog holds the slot the tool confirmation is not rendered, so a waiting call is parked rather than auto-resolved.

Gate 2 — both in-passing concerns check out

The commands-dispatch.ts synchronous replay is not a pre-mount mutation. The dispatcher is constructed inside a useEffect (opentui-app-shell.tsx:595-600), so the constructor's refreshNeededListener()host.addItem() runs post-commit, not during render. The replayedExtensionRefresh WeakSet keyed on the latch owner rather than the construction is deliberate and test-pinned (commands-dispatch.test.ts "replays a latch set before mount once, not once per dispatcher"). I confirmed the latch semantics make this safe rather than lossy: markExtensionsChanged() returns early without re-emitting once latched, so subscribing alone genuinely does drop the startup notice; and a second change after /reload-plugins clears the latch does re-emit and is caught by the live subscription.

The shell-mode.ts contract change is safe because the producer emits snapshots, not deltas. This is the PR's central claim and I verified it from the producer side rather than the reducer side. Every caller of updateOutput passes an accumulated display: core/src/tools/shell.ts:2415-2419 passes cumulativeOutput, tools/agent/agent.ts:1446 passes this.currentDisplay, tools/workflow/workflow.ts:1073 passes buildLivePhaseTreeDisplay(entry). Core's own consumer treats it as a replacement — coreToolScheduler.ts:4934-4937 assigns liveOutput: compactOutput, and the adjacent comment at :4922-4924 describes exactly that as "the accumulated command output". So output: (replace) at live-session.ts:855-856 and shell-mode.ts:120 is correct and the previous delta: was the actual bug. The heartbeat payload (shell.ts:2684) is the one non-display chunk and core already routes it around liveOutput.

Also cleared: normalizeDescription has exactly one importer and the co-located test moved with it; initShellAstParser resolves to shellAstParser.ts:587 with no barrel collision.

One thing neither review flagged, in the PR's favour. The MESSAGE_ICON → shared ICON dedup in messages.tsx is not byte-identical — the shared constants append U+FE0E (constants.ts:36-45). That is the intended fix, not a regression: per constants.ts:31-35, VS15 is zero-width under string-width but forces narrow presentation so CJK terminals stop measuring East-Asian-Width-Ambiguous glyphs as 2 columns against ink's 1. The deleted local copies were the divergence. TOOL_STATUS is value-identical. Separately, the eager initShellAstParser() warm-up (start-opentui-ui.tsx:343-345) is idempotent (shellAstParser.ts:588), hang-proofed by the parserInitFailed short-circuit (:589-593), and .catch()-wrapped so failure only logs — and it prevents web-tree-sitter's UMD wrapper from latching a permanent failure that would silently downgrade permission rules and command-safety classification for the whole session.

Gate 3 — retired by CI

All substantive lanes are green at 5ed55b0e: Lint & Static, Test (ubuntu-latest, Node 22.x), Integration Tests (no-AK, No Sandbox), OpenTUI no-flicker gate and TUI parity snapshots (ink vs opentui). That last one is the meaningful gate for a parity sweep, and it covers the presentational surface I did not read line by line (footer geometry, transcript margins, popup widths).

Gate 4 — scope

1334 production lines excluding tests and docs, fix-typed so the large-scope hard block does not engage; the 1000+ advisory applies and this review is the awareness it asks for. The cross-package surface is one additive line in the core barrel with zero pre-existing consumers, so "name every downstream consumer" is satisfied trivially — an aliased named export can only break on collision, and there is none.

Non-blocking

  • commands-dispatch.ts:196 — the replayedExtensionRefresh WeakSet is never cleared, so if the latch is cleared by /reload-plugins, re-set by a later disk change, and the shell then rebuilds the dispatcher, the rebuilt instance will not re-announce. The live subscription already fired at re-set time so the user still sees one notice; only a rebuild-after-notice loses it, and the transcript may or may not survive that rebuild. Dropping the state from the WeakSet on ExtensionsReloaded would make the replay track the latch's generation rather than its identity. INFO-toast impact only — not worth holding the PR.
  • opentui-app-shell.tsx:934-935 — if isMcpApprovalDialogOpen were ever true with a null currentMcpApproval, the chain falls through to the tool confirmation. The hook should make that unreachable; the fall-through direction shows a dialog rather than hanging, so it is benign either way.

The evidence bar on this PR is unusually good — a scripted pty harness driving both renderers through the same sessions, with the places that have no frame evidence named as such in the design doc instead of glossed. Two residuals are recorded there as unmatched rather than claimed fixed, which is the right call.

@yiliang114

Copy link
Copy Markdown
Collaborator

Follow-up to my approval — one real finding I traced after filing, plus one I checked and cleared. Neither is a merge blocker for me (my APPROVE stands); posting so it is on record and can be fixed here or in a follow-up.

The fold never clears a structured payload, so an ANSI-then-binary tool freezes its card

live-session-model.ts:322-329 sets the structured fields on tool-result but only ever adds them:

const next: LiveToolItem = { ...t, output: ev.type === 'tool-output' ? ev.output : ev.display };
if (ev.type === 'tool-result' && ev.diff)  next.diff  = ev.diff;
if (ev.type === 'tool-result' && ev.todos) next.todos = ev.todos;
if (ev.type === 'tool-result' && ev.ansi)  next.ansi  = ev.ansi;

A later tool-output (or a tool-result with no structured field) updates output but leaves the earlier ansi/todos/diff in place, and ToolCardBody prefers them unconditionally — transcript-view.tsx:372 if (item.todos), :379 if (item.ansi). So once one structured chunk lands, every later plain-text chunk is stored and never rendered.

This is reachable from the shell tool. In core/src/tools/shell.ts's onShellOutputEvent, case 'data' assigns cumulativeOutput = event.chunk (an AnsiOutput when the chunk is an array, :2375/:2479), while case 'binary_detected' assigns the string '[Binary output detected. Halting stream...]' (:2520) and case 'binary_progress' assigns a string progress line (:2526). doUpdate then branches on typeof displayOutput === 'string' (:2418), so a run that streams ANSI and then trips binary detection emits updateOutput({ansiOutput}) followed by updateOutput(string).

On the OpenTUI side that becomes a tool-result carrying ansi, followed by tool-output carrying the binary message. The card keeps rendering the last ANSI grid; the binary-detection line and each binary_progress byte-count update are invisible. It is not transient either — the settled tool-result for a binary command carries a plain string, so extractStructuredResult returns null, none of the three guards fire, and ansi survives into the final card.

Before this PR the live path only special-cased extractFileDiff, so that text was flattened and stayed visible. The regression is new to the widened live precedence, which is otherwise a genuine improvement.

Cheapest fix is to clear the structured fields whenever an event carries plain text — in the tool-output branch, and in the tool-result branch when none of diff/todos/ansi is present. Worth a fold test that feeds ansi then text and asserts the text is what renders.

Checked and cleared: an empty todo_list rendering blank

extractTodos (event-adapter.ts:236-248) returns [] for {type:'todo_list', todos:[]}, which is truthy, so it wins the precedence at :388-389 and emits display:'' — a blank card body, pinned by event-adapter.test.ts:806-813. I initially read this as a regression from the JSON dump the live path used to fall back to, but it is exact ink parity: components/messages/ToolMessage.tsx:204-214 routes any type === 'todo_list' to the todo renderer without an emptiness check, and components/TodoDisplay.tsx:29-31 returns null when todos.length === 0. ink renders nothing for the same payload, so the JSON dump was the divergence and blank is correct. Not a defect — no change needed.

The one residual divergence is narrower: extractTodos filters entries missing id/content/status, so a malformed-but-non-empty list collapses to [] and renders blank, where ink would render rows with undefined fields. Blank is the better outcome there, so I would leave it.

Question: Shift+Tab stays live while the gated-MCP approval is up

The app-shell's new binding at opentui-app-shell.tsx:348-353 is an unconditional useKeyboard, and the MCP approval dialog is rendered in the app shell's own ternary at :934-947 rather than through OpenTuiDialogMount, so it does not sit behind whatever focus gating the dialog mount applies. adoptApprovalMode (:300-322) needs no ink host — it sets local state, emits the AUTO notices off config/settings, and releases parked tool calls through onConfirm — so pressing Shift+Tab while a gated server is awaiting a decision does cycle the mode, including into AUTO or YOLO, without dismissing or resolving the MCP prompt.

This is not a fail-open: the approval queue belongs to useMcpApproval, which adoptApprovalMode never touches, so a gated server still cannot connect without an explicit decision. It is a mode flip under a modal prompt that the prompt does not react to.

Worth confirming whether ink behaves the same way — ink mounts useAutoAcceptIndicator at App level too, and I did not establish whether it is disabled while MCPServerApprovalDialog is open. If ink also stays live here, this is parity and needs nothing; if ink suppresses it, the fix is to gate the binding on mcpApproval.isMcpApprovalDialogOpen the way the composer's bare-Tab fallback is already gated.

@qwen-code-dev-bot qwen-code-dev-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at head 5ed55b0e.

Required CI is green at this commit — Test (ubuntu-latest, Node 22.x), Lint & Static, Integration Tests (no-AK, No Sandbox), web-shell E2E Smoke, TUI parity snapshots (ink vs opentui) and the OpenTUI no-flicker gate all completed successfully, as did both Desktop Shell lanes; only review-pr and triage (the reviewer pipelines) are still running. That retires the "nothing has certified the build at this head" gate from the escalation review — which matters more here than usual precisely because that review flagged a cross-module field rename and a moved exported symbol as the two shapes typecheck exists to catch.

Answering the escalation's Gate 1 directly — the new approval surface is fail-closed by construction, and I read it branch by branch:

  • There is exactly one call site of confirmationDetails.onConfirm in dialogs-confirm.tsx: settle() at :528-535, latched by settledRef so a double keystroke cannot resolve twice.
  • Decline paths all resolve Cancel, not a proceed: Esc at :539-541 with the comment tying it to the rendered No (esc) row; ask_user_question with answers === null at :555; the standalone question/info flows at :746-747 and :812; and the new gated-MCP-server dialog mapping Esc to McpApprovalChoice.REJECT at :885-887, which is ink's escape-to-deny convention for that dialog.
  • The default: arm of the per-type switch is not a runtime fallback at all — it is const exhaustive: never = details; return exhaustive; (:230-233, again at :457), so a confirmation type added later fails the build instead of silently rendering an allow list. The green Lint & Static lane is what makes that a guarantee rather than an intention.
  • hideAlwaysAllow and an untrusted folder cannot create a durable rule: showAlwaysAllow is computed as isTrustedFolder && !hideAlways (:251-253) and only ever removes the always-allow rows; it cannot add one.
  • The lists can never be empty and cannot shift onto a proceed value: every per-type branch pushes Cancel last (:157, :182, :226), and OutcomeSelect carries option.value through to onChoose (:479-486) rather than resolving a selection by position, so a filtered list cannot make row N mean row N+1's outcome.
  • The AUTO-fallback insertion at :258-271 splices ProceedOnceAndSwitchToDefault before Cancel, which is the one place position would matter — and it is a proceed-with-mode-change, not a silent persist.

On the repository's core-infrastructure rule (the escalation's Gate 4): the entire packages/core surface of this diff is one aliased line, index.ts:721 export { initParser as initShellAstParser } from './utils/shellAstParser.js'. Its only production consumer is start-opentui-ui.tsx (import at :45, awaited at :343 behind a .catch), so a failed shell-parser warm-up cannot fail startup, and the name is unique in the barrel so it cannot collide with the surrounding export * lines.

The two structural moves check out independently: the tool-output union now declares output: string and all four producers and consumers use it (streaming-model.ts:34/190, live-session-model.ts:316/325, live-session.ts:856, shell-mode.ts:120, transcript-adapter.ts:136), with delta remaining only on the text/thinking events whose append semantics are unchanged; and normalizeDescription now lives in utils/suggestions.ts with SuggestionsDisplay.tsx importing it and both renderers' consumers (input-prompt.tsx) resolving through it.

No new Critical found. Three honest limits and two carried notes:

  • I did not read the whole ~1300 lines of production logic. I read the security-bearing approval/mount surface, the two cross-module moves, and the event-contract change; the rest is per-component view parity, which is guarded by the two green renderer-specific gates named above rather than by my reading.
  • I could not run the CLI renderer suites here: packages/cli tests fail to collect in this checkout on an unresolvable @modelcontextprotocol/client behind the core barrel. So my test evidence is CI's green Test/parity/no-flicker lanes plus the two prior local runs reported on this PR (76 files / 1403 tests), not a run of mine.
  • The notes already on the PR still stand and are worth acting on: Escape auto-repeat can drop a queued gated MCP server with no decision recorded (async handleMcpApprovalSelect slices by position after an await — pre-existing and identical in ink, so parity holds and this PR only widens exposure; the fix belongs hook-side, rejecting re-entry or dropping by current.name), and the description still claims a worktree-isolation change carried over from #11613 that is not in the 55-file diff at this head.

@chiga0
chiga0 added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit 7156037 Sep 11, 2026
158 of 159 checks passed
@chiga0

chiga0 commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for tracing these after filing — the first one was real and I have fixed it. Since this PR is merged, the fix landed on a follow-up branch as 3afbf9da7c (fix/cli): let a plain-text tool update replace OpenTUI's structured payload) and will come through in the next PR.

1. The fold never clears a structured payload — fixed

Rather than clearing in the two branches you named, the fold now derives diff/todos/ansi from the event itself, so a text-carrying update replaces them by construction and there is no branch that has to remember to clear:

const structured = ev.type === 'tool-result' ? ev : undefined;
const next: LiveToolItem = {
  ...t,
  output: ev.type === 'tool-output' ? ev.output : ev.display,
  diff: structured?.diff,
  todos: structured?.todos,
  ansi: structured?.ansi,
};

Before writing it I checked the two things that decide whether this shape is safe. First, every producer of these events picks the structured form or the flattened form and never emits both for one result — the live chunk mapper, toolResultEvent in event-adapter.ts, and the resume path in transcript-adapter.ts all branch on extractStructuredResult and return exactly one event, and extractStructuredResult itself returns at most one payload. So no result loses a payload it also carried as text. Second, the late-update hazard: if the throttled trailing flush in shell.ts could fire after the command returned, it would deliver a tool-output after the final result and strip a payload that should have stayed. It cannot — cancelTrailingFlush() runs in the finally around the execution handle ("the timer must not fire a stale frame after we've returned"), and the timer is only armed on the non-array branch in the first place, so ANSI chunks never schedule it.

Fold test added as you suggested: ansi then plain text, asserting the grid is gone and the binary notice is what the card holds. I also negative-controlled it — reverting just the fold change fails exactly that one test with expected { grid: [ [ { …(8) } ] ] } to be undefined while the other 50 pass, so it pins this defect and is not vacuous.

One deliberate omission: visionBridgeNotice keeps its existing sticky behaviour. It is an additional notice rendered under the result rather than an alternative rendering of it, and it takes no part in the card's precedence, so clearing it would drop an egress disclosure the user still needs. Happy to be argued out of that if you read it differently.

2. Empty todo_list rendering blank — agreed, no change

Your reading matches mine once I checked the ink side: ToolMessage.tsx routes any type === 'todo_list' to the todo renderer with no emptiness check and TodoDisplay returns null for an empty list, so blank is the parity outcome and the JSON dump was the divergence. I agree the malformed-but-non-empty collapse is better as blank than as rows with undefined fields, so I left extractTodos' filtering alone too.

3. Shift+Tab under the gated-MCP approval — ink stays live as well, so this is parity

You were right that I had not established it; here is the answer. ink does not suppress the binding while MCPServerApprovalDialog is open:

  • AppContainer.tsx:2563 mounts useAutoAcceptIndicator with disabled: agentViewState.activeView !== 'main' — the only thing that disables it is an agent tab, not a dialog.
  • AppContainer.tsx:3907 consumes useMcpApproval in the same component, so both hooks are mounted together for the whole lifetime of the app; the dialog appearing does not unmount the handler.
  • useKeypress is a plain subscribe/unsubscribe broadcast with no focus stack and no dialog gating, so there is no mechanism by which an open dialog could claim the key ahead of it.

So ink also flips the mode under the MCP prompt without the prompt reacting, which matches your observation that it is not a fail-open — the approval queue belongs to useMcpApproval either way. I have therefore not gated the app-shell binding on isMcpApprovalDialogOpen; adding a gate would put this renderer ahead of ink rather than level with it, and the current instruction for this migration is to align to ink and record divergences rather than improve on them. If we later decide the behaviour itself is wrong, it should be fixed on both sides at once.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants