Skip to content

fix(cli): OpenTUI transcript visibility for commands, /clear and steers - #10947

Merged
chiga0 merged 7 commits into
mainfrom
feat/opentui-batch9-transcript-visibility
Sep 4, 2026
Merged

fix(cli): OpenTUI transcript visibility for commands, /clear and steers#10947
chiga0 merged 7 commits into
mainfrom
feat/opentui-batch9-transcript-visibility

Conversation

@chiga0

@chiga0 chiga0 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

The OpenTUI renderer kept two histories and painted only one of them. Everything a slash command reported — an approval-mode change, a status block, a rejected submission, a hook failure, a quit summary — was recorded in a store no view reads, so the command ran, changed real state, and left the screen untouched. This makes a recorded item become a transcript row at the moment it is recorded, through one mapping that decides every kind of item the command layer can write and is checked for exhaustiveness at compile time. Kinds the live model turn already renders are deliberate no-ops so nothing paints twice. Four kinds the existing renderer draws with components this transcript has no row shape for — an advisor review, two arena completion cards, a session recap — are written here too and stay invisible, exactly as before; the design doc names each producer and registers them as one follow-up.

Clearing the conversation emptied the recorded history but left the visible transcript standing, which the session-switch contract already described as clearing both. It now clears both. Clearing also surfaced a crash that had been hiding behind the invisible transcript: the command context handed out one host method detached from its receiver, so the clear command threw before it cleared anything, and the only thing the user saw was the error. The receiver is now bound where the context is built.

A message typed while the model is still working now appears as its own row once the turn accepts it. Previously the queue counter dropped and the text disappeared without a trace. Each steered message renders with its own read cards immediately before its own row, matching the order the existing renderer produces. The row is recorded as not-a-standalone-turn, the way the existing renderer records it. Neither renderer's row drawing reads that flag, so the row renders the same whichever way it is set; what the flag decides is whether the steer counts as a turn — it stays out of the rewind list. Making the recorded invocation visible exposed two row-count differences in the other direction, now matched to the existing renderer: a command that expands into a prompt of its own shows only the invocation the user typed, never the expansion, and two identical messages steered back to back are one row.

Submitting an image the pipeline cannot read now discloses the supported formats, on both the fresh-submit path and the mid-turn path. Previously such an image reached the model with no notice at all.

Why it's needed

The renderer migration's promise is that switching renderers does not change what the user sees. Silent command output is the most visible break of that promise: a user runs a command, the command works, and the terminal says nothing. It survived every batch so far because no test asserted on a command's rendered output — the assertions looked at internal stores, which were always correct. This PR adds the missing bridge and, with it, the first interactive spec that waits for a command's output to actually reach the screen.

Reviewer Test Plan

How to verify

Run the CLI under the OpenTUI renderer and check four behaviours:

  1. Command output is visible. At an idle prompt run /about. Expect the full status block in the transcript — version, runtime, auth, model, session id, memory usage. Before this PR: nothing at all appears. The same holds for /approval-mode plan, which should echo the invocation and then report the new mode.
  2. Clearing empties the screen. Fill the transcript with a couple of commands, then run /clear. Expect an empty transcript and no error row. Before this PR: an error row about an undefined object, and the transcript still standing.
  3. A mid-turn message shows up. Submit a prompt that keeps the model busy, then type a second message while it streams. Expect the second message to appear as its own user row once the current tool batch finishes, drawn as an ordinary user row and preceded by its own read cards if it mentioned a file. Before this PR: the queued counter dropped and the text vanished.
  4. An unreadable image is disclosed. Attach an image format outside the supported set and submit. Expect an info row naming the supported formats before the request goes out; the image is still forwarded, as in the existing renderer.

Behaviour 1 is the first thing this spec covers, but it is not covered by the PR's own check run: the workflow that carries the spec triggers on pushes to main, on the nightly schedule, and on demand — never on pull_request. So a reviewer sees either the local runs quoted below, a manual dispatch against this branch (run 33830499451), or the run that lands on main after merge. What the spec asserts: the boot screen does not contain a field label only /about renders, then it waits for that label after the command and asserts the model was never called — so the row can only have come from the command. The wait accepts either that field label or ink's unknown-command text and re-sends on the latter, inside a bounded window: ink loads its slash-command registry in an async effect that gates nothing, so a command sent the moment the prompt appears can land on an empty registry — a boot race unrelated to this change, which otherwise turns the ink leg flaky as soon as two spec files boot CLIs concurrently. The OpenTUI leg does not need the retry because it reloads the registry and re-parses whenever nothing matched. The window stays discriminating: a projection regression produces neither string, so the wait throws instead of spinning — measured by neutering the projection, which fails the OpenTUI leg and leaves the ink leg green.

Evidence (Before & After)

Before (from the filed report): /approval-mode plan changes the approval mode and changes nothing on screen.

After, OpenTUI (bun + strict) at the PR's base commit, in a 100-column tmux pane: /about renders the projected status block, then /clear empties the transcript, leaving only the composer and no error row. The Base URL field is elided below because it names a private endpoint; every other line is verbatim.

● Status
  Qwen Code: 0.23.0 (661f41eef0)
  Runtime: Node.js v24.3.0 / npm 11.12.1
  LSP: disabled
  OS: darwin arm64 (25.6.0)
  Auth: API Key - openai
  Model: Kimi-K3
  Fast Model: Kimi-K3
  Session ID: 44ff2d25-3ecb-434c-8d20-2064d644e45e
  Sandbox: no sandbox
  Proxy: no proxy
  Memory Usage: 304.3 MB

--- after /clear: 4 non-blank pane lines, no error row ---
 ──────────────────────────────────────────────────────────────────────────────────────────────────
 >   Type your message or @path/to/file
 ──────────────────────────────────────────────────────────────────────────────────────────────────
 Auto mode

Tested on

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

Windows and Linux are left to CI.

Environment (optional)

Local bundle under both renderer legs (QWEN_TUI_RENDERER=ink on node, =opentui with strict mode on bun), plus the tmux smoke quoted above.

Risk & Scope

  • Main risk or tradeoff: the projection runs on every recorded item, so a kind that the live turn already renders would paint twice. The mapping lists those kinds as explicit no-ops with the reason next to each, and the exhaustiveness check means a newly added item kind fails the build rather than silently disappearing.
  • Not validated / out of scope: a steered message is now visible but is still not written to the chat recording, so a resumed session would not contain it — filed as a follow-up. One interactive spec spawns its own process without the renderer matrix and so exercises the wrong renderer on the OpenTUI leg; also filed rather than folded in, because wiring it up may turn it red for reasons unrelated to this change.
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #10905. Part of the renderer migration tracked in #8662.

中文说明

这个 PR 做了什么

OpenTUI 渲染器一直维护两份历史,却只渲染其中一份。斜杠命令汇报的所有内容——审批模式变更、状态块、被拒绝的提交、hook 失败、退出摘要——都被写进一个没有视图读取的存储里,于是命令确实执行了、确实改变了真实状态,屏幕上却什么都不动。本 PR 让「记录一条历史项」的同一时刻就把它变成一条转录行,走的是一张对命令层能写出的每一种历史项类型都做出明确判定的映射表,并在编译期做穷尽性检查。模型回合自身已经渲染的那些类型被刻意留成空操作,避免同一行画两遍。另有四种类型——advisor 的评审、arena 的两张完成卡片、会话回顾——旧渲染器是用专门的组件画的,这边的转录还没有对应的行形状:它们在本渲染器下同样会被写入,也同样仍然不可见(与本 PR 之前一致)。设计文档逐条写明了它们的生产者,并登记为一个后续项。

清空会话此前只清空了记录用的历史,可见的转录仍然留在屏幕上——而会话切换的契约本来就写明两者都要清。现在两者都清。清空还暴露出一个一直藏在「不可见转录」背后的崩溃:命令上下文把宿主的某个方法以脱离接收者的形式交了出去,于是清空命令在清任何东西之前就抛错,用户唯一看到的就是那条错误。现在接收者在构建上下文处绑定。

模型仍在工作时输入的消息,现在会在回合接受它之后显示为独立的一行。此前队列计数消失、文本无痕无踪。每条中途消息都会先渲染自己的读取卡片、紧跟着自己的那一行,与既有渲染器的顺序一致。这一行按既有渲染器同样的方式被记为「不是独立的用户回合」。两边渲染用户行的代码都不读这个标记,所以这一行无论标记取何值渲染结果都相同;这个标记决定的是中途消息算不算一个回合——它因此不会进入回退(rewind)列表。让「被记录的调用」可见之后,也暴露出两个反方向的行数差异,现已与既有渲染器对齐:会展开成自己那段提示词的命令只显示用户真正敲下的调用、不显示展开结果;连续两次中途投喂同一条文本只渲染一行。

提交流水线读不了的图片时,现在会披露受支持的格式,新提交与中途两条路径都覆盖。此前这种图片会毫无提示地直达模型。

为什么需要

渲染器迁移的承诺是:换渲染器不改变用户看到的东西。命令输出静默是对这个承诺最显眼的破坏——用户敲了命令,命令生效了,终端却一声不响。它能一直存活到今天,是因为没有任何测试断言过命令的渲染输出:断言看的都是内部存储,而内部存储一直是对的。本 PR 补上缺失的这座桥,并带来第一个「等命令输出真正上屏」的交互测试。

评审测试计划

如何验证

在 OpenTUI 渲染器下运行 CLI,确认四个行为:

  1. 命令输出可见。 空闲提示符下执行 /about,预期转录里出现完整状态块——版本、运行时、鉴权、模型、会话 ID、内存占用。本 PR 之前:什么都不出现。/approval-mode plan 同理,应先回显调用、再汇报新模式。
  2. 清空会清屏。 先用几条命令填满转录,再执行 /clear,预期转录为空且没有错误行。本 PR 之前:出现一条「undefined is not an object」错误行,转录原封不动。
  3. 中途消息会出现。 提交一个会让模型持续忙碌的提示,然后在流式过程中再输入一条消息,预期当前工具批次结束后该消息作为一条普通用户行出现;如果它提到了文件,它自己的读取卡片会紧挨在这一行之前。本 PR 之前:队列计数消失、文本不见。
  4. 读不了的图片会被披露。 附加一个不在受支持集合内的图片格式并提交,预期请求发出前出现一行 info,列出受支持的格式;图片本身仍会照常转发,与既有渲染器一致。

行为 1 是这个测试覆盖的第一项,但它并不在本 PR 自己的门禁检查里:承载该测试的 workflow 只在推送到 main、每晚定时任务、以及手动触发时运行,从不因 pull_request 运行。所以评审者能看到的证据有三种:下面引用的本地运行、针对本分支的手动触发、或合并后落在 main 上的那次运行。测试断言的内容是:启动画面包含只有 /about 才会渲染的字段标签,然后在命令之后等待该标签出现,并断言模型从未被调用——因此这一行只可能来自命令本身。这个等待接受两种文本之一:该字段标签,或 ink 的「未知命令」提示;命中后者就在一个有界时间窗内重发。原因是 ink 在一个不受任何门控的异步 effect 里加载斜杠命令注册表,所以提示符一出现就发出的命令可能落在空注册表上——这是与本次改动无关的启动竞态,一旦有两个测试文件并发起 CLI,ink 腿就会因此变抖。OpenTUI 腿不需要重试,因为它在没有匹配到命令时会重新加载注册表并重新解析。这个时间窗仍然具备判别力:投影回归时两种文本都不会出现,于是等待会抛错而不是空转——这一点已用变异实测:把投影掐断后 OpenTUI 腿失败、ink 腿保持绿。

证据(前后对比)

之前(取自提交的 issue 报告):/approval-mode plan 改变了审批模式,屏幕上没有任何变化。

之后,OpenTUI 下先 /about/clear——状态块渲染出来,随后转录为空,只剩输入区,没有错误行(见英文部分的终端截取)。

测试环境

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

Windows 与 Linux 交给 CI。

本地在两条渲染器腿上跑打包产物(node 上 QWEN_TUI_RENDERER=ink,bun 上 =opentui 且开启 strict),外加上面引用的 tmux 冒烟。

风险与范围

  • 主要风险/取舍:投影对每一条被记录的历史项都会执行,因此模型回合已经渲染的类型会画两遍。映射表把这些类型显式列为空操作并逐条写明理由,穷尽性检查保证将来新增的历史项类型会让构建失败,而不是静默消失。
  • 未验证/范围之外:中途消息现在可见,但仍未写入会话录制,因此从录制恢复的会话里不会有它——已单独登记为后续项。有一个交互测试自己起进程、没有走渲染器矩阵,因此在 OpenTUI 腿上实际跑的是另一个渲染器;同样登记而非并入,因为把它接上很可能因与本次改动无关的原因变红。
  • 破坏性变更/迁移说明:无。

关联 Issue

Fixes #10905。属于 #8662 跟踪的渲染器迁移工作。

The U-26 section still read "Registered as U-26" after that number was
retired and folded into U-12 the same day, so it pointed at a record
that does not exist. The U-27 section said ink's warning names the
formats the model cannot read; the shared warning text names the
formats the pipeline supports. The second error mattered more than the
wording: porting U-27 faithfully would have read as a behaviour change
against a claim that was never true.
The renderer kept two histories and painted one. Everything a slash
command reported was recorded in a store no view reads, so a command
ran, changed real state, and left the screen untouched. A recorded item
now becomes a transcript row at the moment it is recorded, through one
mapping that decides every kind the command layer can write and is
checked for exhaustiveness at compile time; the kinds the live model
turn already renders are explicit no-ops with the reason beside each,
so nothing paints twice.

Clearing the conversation emptied the recorded history and left the
visible transcript standing, which the session-switch contract already
described as clearing both. Clearing also exposed a crash hiding behind
the invisible transcript: the command context handed out one host
method detached from its receiver, so the command threw before it
cleared anything and the error was the only thing on screen.

A message typed while the model is still working now appears as its own
row once the turn accepts it, with its own read cards immediately
before it, in the order the existing renderer produces. Previously the
queue counter dropped and the text disappeared without a trace.

Submitting an image the pipeline cannot read now discloses the
supported formats on both the fresh-submit hop and the mid-turn one;
such an image previously reached the model with no notice at all.

Four kinds the existing renderer draws with dedicated components are
written under this renderer too and stay invisible, exactly as before:
each needs a row shape this transcript does not have, and the replay
path never handled them either, so leaving them out keeps live and
replay consistent. The design doc names every producer line and
registers them, with four further findings, as follow-ups.

Closes U-12 (visible half), U-27, U-28, and the two gaps this change's
own smoke turned up. Part of #8662.
@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Sep 3, 2026
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

⚠️ Deferred approval withheld — 1 PR CI workflow run(s) on 401f9eb did not finish green; see the updated table in the Stage 2 comment. Re-run @qwen-code /triage after fixes. finalize run

⚠️ 延迟审批已搁置 —— 401f9eb 有 1 个 PR CI workflow 未以绿色完成,详见 Stage 2 评论中已更新的表格。修复后可重新运行 @qwen-code /triage查看 finalize 运行

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR — this is a clean, well-documented batch.

Template ✓ — every required heading is present, including the bilingual section.

Problem — observed, not theoretical. #10905 is a filed P1 (type/bug) with a concrete repro: /approval-mode plan flips the mode and repaints nothing, because the OpenTUI backend records command output into host.history while renderMain only draws the live-turn transcript. The before/after in the description matches the report.

Direction — aligned. This is part of the OpenTUI renderer migration (#8662), whose whole promise is that switching renderers doesn't change what the user sees; a command that runs silently is the most visible break of that promise. It stays inside the renderer seam and touches no auth/sandbox/model/telemetry/release/public contract, so no direction escalation.

Size — not a core change. All seven production files live under packages/cli/src/ui/opentui/**; packages/core is only imported (isSupportedImageMimeType / getUnsupportedImageFormatWarning, already re-exported from core/src/index.ts), never modified, and the diff isn't cross-package. Breakdown: ~254 production logic lines, ~632 test lines, ~353 design-doc lines. Well under every threshold — no maintainer-awareness flag, no large-PR advisory.

Approach — the scope feels right, and it matches what I'd have proposed independently: one total switch (projectItemToStreamEvent) with the house never exhaustiveness check, project-on-write at addItem, and the transcript seam growing to { reset, clear, append }. Kinds the live turn already renders are explicit no-ops, so nothing paints twice. Good reuse rather than new machinery — it leans on the existing projectSpecialItemText and extracts two formatters (formatStopHookLoopText, formatUserPromptSubmitBlocked) so the stream mapper and the projector emit one identical row shape. No drive-by refactors: the two design-doc edits just correct the U-26/U-27 ledger notes this batch closes. I separately verified the load-bearing stability claim — applyEvent and resetTranscript are useCallbacks with empty/stable deps in live-turn.ts, so adding onTranscriptEvent to the transcript memo won't churn the host identity.

Risk — no elevated risk signals; none of the changed files match the revert-correlated high-risk paths.

Moving on to code review. 🔍

中文说明

感谢贡献——这是一批干净、文档完善的改动。

模板 ✓ —— 所有必需小标题齐全,含中英双语部分。

问题 —— 已观测,非理论性。#10905 是一个带具体复现的 P1(type/bug):/approval-mode plan 改变了审批模式却什么都不重绘,因为 OpenTUI 后端把命令输出记录进 host.history,而 renderMain 只画实时回合的 transcript。描述里的前后对比与报告一致。

方向 —— 对齐。这属于 OpenTUI 渲染器迁移(#8662),其核心承诺就是「切换渲染器不改变用户所见」;命令静默运行是对该承诺最显眼的破坏。改动留在渲染器接缝内,不触碰 auth/sandbox/模型/遥测/发布/公共契约,因此无需方向升级。

规模 —— 非核心改动。七个生产文件都在 packages/cli/src/ui/opentui/** 下;packages/core 只被引用isSupportedImageMimeType / getUnsupportedImageFormatWarning,已从 core/src/index.ts 再导出),未被修改,且非跨包。构成:约 254 行生产逻辑、约 632 行测试、约 353 行设计文档。远低于所有阈值——无需维护者关注标记,也无大 PR 提示。

方案 —— 范围合理,与我独立设想的做法一致:一个带 never 穷尽性检查的完整 switchprojectItemToStreamEvent),在 addItem 处「写入即投影」,transcript 接缝扩展为 { reset, clear, append }。实时回合已渲染的类型显式列为空操作,因此不会重复绘制。复用到位而非新造机制——依托既有的 projectSpecialItemText,并抽出两个格式化函数(formatStopHookLoopTextformatUserPromptSubmitBlocked),让流映射器与投影器产出完全一致的行形状。无顺手重构:两处设计文档编辑只是修正本批要关闭的 U-26/U-27 台账备注。我另外核实了关键的稳定性论断——live-turn.tsapplyEventresetTranscript 都是依赖为空/稳定的 useCallback,因此把 onTranscriptEvent 加进 transcript memo 不会让 host 身份抖动。

风险 —— 无升级风险信号;改动文件均未命中与回滚相关的高风险路径。

进入代码审查 🔍

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

Reviewed at 401f9eb5addfd024b83c7027a948a01055d0c614 · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

No blocking issues. I read the production diff against the base and verified the load-bearing claims rather than taking the design doc's word for them:

  • Exhaustiveness is real. projectItemToStreamEvent is a total switch over item.type whose default assigns to const exhaustive: never, so a future history kind that nobody decided a transcript fate for fails the build instead of silently disappearing. This is the house pattern, correctly applied.
  • No double-paint. The kinds the live turn already folds from stream events (tool_group, gemini*, retry_countdown, vision_notice) are explicit null no-ops. The findings-coalescing path only ever touches tool_group, which is a no-op, so projection can't duplicate a coalesced card.
  • Host stability holds (the one regression that would have been easy to introduce). I checked live-turn.ts: apply (the applyEvent the shell wires to onTranscriptEvent) is a useCallback with [] deps and resetTranscript depends only on the equally stable setBusy. So adding onTranscriptEvent to the transcript memo keeps one host identity per session — host.history is not dropped and the dispatcher is not rebuilt each render. The green OpenTUI no-flicker gate corroborates this independently.
  • The /clear crash fix is correct. startNewSession: host.startNewSession?.bind(host) — the method reads this.deps, and the new test declares it as a shorthand method reading this.newSessionIds, so a detached reference genuinely fails there rather than passing by accident.
  • Core imports resolve. isSupportedImageMimeType / getUnsupportedImageFormatWarning come from @qwen-code/qwen-code-core, already re-exported via export * in core/src/index.ts — no core file is modified, so this compiles without touching the barrel.
  • Steer echo order matches ink. The U-12 echo rides the same flat events list as the read cards, pushed per surviving message (cards then row), reproducing ink's accept() interleave rather than grouping all cards then all rows; echo text is the raw queued message, not the expanded parts.

Non-blocking notes, all already documented by the author and worth a maintainer's glance:

  • success maps to the info row (the live model has no success kind), so ink's green SuccessMessage loses its color here — pinned in the table and tests, and only /arena produces it under this renderer.
  • U-34: /advisor, /arena and /recap write four kinds this renderer has no row shape for, so their output stays invisible after this batch — registered as a follow-up, not claimed fixed. Likewise a steered message is now visible but still isn't written to the chat recording (a resumed session won't contain it), also filed.
  • The integration spec's sendAboutUntilRendered carries a bounded 90s retry to absorb an unrelated ink boot race (the slash-command registry loads in an ungated async effect). It stays discriminating — a projection regression produces neither accepted string and throws — but it is extra test complexity bought to keep the ink leg deterministic, not to mask a failure.

The change is minimal for its goal: ~254 production lines, the rest tests and the decision record. No scope creep.

How a recorded item reaches the screen

sequenceDiagram
    participant P1 as User
    participant P2 as Command dispatcher
    participant P3 as OpenTuiAppHost.addItem
    participant P4 as projectItemToStreamEvent
    participant P5 as transcript.append
    participant P6 as live turn applyEvent
    P1->>P2: run slash command
    P2->>P3: addItem (recorded item)
    P3->>P4: project item to stream event
    P4-->>P3: event, or null for no-op kinds
    P3->>P5: append event when non-null
    P5->>P6: onTranscriptEvent folds into the live transcript
    P6-->>P1: the row reaches the screen
Loading
Files changed (production + the new spec; tests/docs grouped)
File What changed
packages/cli/src/ui/opentui/item-projection.ts New total projector mapping every history kind to a stream event or null, never-exhaustive
packages/cli/src/ui/opentui/opentui-host.ts addItem projects and appends; clearItems now clears the visible transcript; seam gains clear/append
packages/cli/src/ui/opentui/opentui-app-shell.tsx transcript memo gains clear (reset with an empty batch) and append (onTranscriptEvent)
packages/cli/src/ui/opentui/start-opentui-ui.tsx Wires onTranscriptEvent to the live turn applyEvent
packages/cli/src/ui/opentui/commands-context.ts Binds startNewSession to the host so /clear no longer throws on a detached receiver
packages/cli/src/ui/opentui/event-adapter.ts Extracts two stop-hook/blocked-prompt formatters, now shared with the projector
packages/cli/src/ui/opentui/live-session.ts U-12 per-message steer echo and U-27 unsupported-image warning on both hops
integration-tests/interactive/command-output-visibility.test.ts Differential spec asserting the /about row reaches the screen and the model was never called
5 unit test files (commands-context, item-projection, live-session, opentui-app-shell, opentui-host) Pin the projection table, host append/clear/no-project paths, steer echo order, image warnings, bound receiver
2 design docs (batch9 new, batch8 note corrections) The decision record and the U-26/U-27 ledger fix

Test evidence — the PR's own CI

This is an unattended CI run, so I did not build or execute any PR code; the evidence below is the PR's own check-runs on 401f9eb5, read through the API. The two gates most specific to this change are already green: OpenTUI no-flicker gate (host churn / repaint stability) and TUI parity snapshots (ink vs opentui) (the projected rows match ink's shape). The unit suite, the typecheck/lint leg (where the never exhaustiveness check actually compiles), and the interactive integration spec are still running. No check has failed. The finalize workflow updates the table below in place once CI settles and performs any deferred approval.

Final CI results for 401f9eb (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Lint & Static (ubuntu-latest, Node 22.x) ❌ failure
Test (ubuntu-latest, Node 22.x) ❌ failure
Classify PR ✅ success
Dependency CVE audit ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
OpenTUI no-flicker gate ✅ success
Secret scan (TruffleHog) ✅ success
TUI parity snapshots (ink vs opentui) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

Sandboxed verification would settle the rest: @qwen-code /tmux — the end-to-end TUI surface beyond behaviour 1 (that /clear visibly empties the transcript with no error row, that a mid-turn steered message appears as its own user row preceded by its read cards, and that an unreadable image discloses the supported formats) is pinned by unit tests and the author's macOS-only local run, but is not driven end-to-end on the OpenTUI leg by CI. The author has write access, so a maintainer can trigger it to watch these as a real user across the renderer matrix.

中文说明

代码审查

无阻断问题。我对照基线阅读了生产代码 diff,并亲自核实了关键论断,而非仅采信设计文档:

  • 穷尽性是真的。 projectItemToStreamEvent 是对 item.type 的完整 switchdefault 分支赋值给 const exhaustive: never,因此将来若新增历史项类型却未决定其 transcript 归属,会让构建失败,而不是静默消失。这是本项目既有范式,应用正确。
  • 不会重复绘制。 实时回合已从流事件折叠的类型(tool_groupgemini*retry_countdownvision_notice)显式返回 null。findings 合并路径只触及 tool_group(空操作),因此投影不会重复已合并的卡片。
  • host 稳定性成立(这是最容易引入的回归)。我查了 live-turn.tsapply(即 shell 接到 onTranscriptEventapplyEvent)是依赖为 []useCallbackresetTranscript 只依赖同样稳定的 setBusy。因此把 onTranscriptEvent 加进 transcript memo 后,每个会话仍只有一个 host 身份——不会丢弃 host.history,也不会每次渲染重建 dispatcher。绿色的 OpenTUI no-flicker gate 独立佐证了这一点。
  • /clear 崩溃修复正确。 startNewSession: host.startNewSession?.bind(host)——该方法读取 this.deps,新测试用读取 this.newSessionIds 的简写方法声明它,因此脱离接收者的引用会真正失败,而非侥幸通过。
  • core 引用可解析。 isSupportedImageMimeType / getUnsupportedImageFormatWarning 来自 @qwen-code/qwen-code-core,已通过 core/src/index.tsexport * 再导出——未修改任何 core 文件,因此无需改动 barrel 即可编译。
  • steer 回显顺序与 ink 一致。 U-12 回显与读取卡片共用同一条扁平 events 列表,按每条存活消息压入(先卡片后行),复现 ink accept() 的交错顺序,而非先全部卡片再全部行;回显文本是原始排队消息,而非展开后的 parts。

非阻断备注(作者均已记录,值得维护者一瞥):

  • success 映射到 info 行(实时模型无 success 类型),因此 ink 的绿色 SuccessMessage 在此失去颜色——已在映射表与测试中固定,且本渲染器下只有 /arena 会产出它。
  • U-34:/advisor/arena/recap 会写入四种本渲染器尚无行形状的类型,故其输出在本批之后仍不可见——已登记为后续项,未声称修复。同样,中途消息现在可见但仍未写入会话录制(恢复的会话不含它),也已登记。
  • 集成测试的 sendAboutUntilRendered 带一个有界 90 秒重试,用于吸收一个与本次改动无关的 ink 启动竞态(斜杠命令注册表在一个不受门控的异步 effect 中加载)。它仍具判别力——投影回归时两种可接受文本都不出现,于是抛错——但这确实是为让 ink 腿确定性、而非为掩盖失败而付出的额外测试复杂度。

改动相对目标是最小的:约 254 行生产代码,其余为测试与决策记录。无范围蔓延。

测试证据——PR 自身 CI

这是无人值守的 CI 运行,因此我未构建或执行任何 PR 代码;下方证据是 PR 自身在 401f9eb5 上的 check-run,经 API 读取。与本次改动最相关的两个门已绿OpenTUI no-flicker gate(host 抖动/重绘稳定性)与 TUI parity snapshots (ink vs opentui)(投影行与 ink 形状一致)。单元测试、typecheck/lint 腿(never 穷尽性检查实际在此编译)以及交互集成测试仍在运行。无失败项。CI 落定后,finalize 工作流会就地更新下方表格并执行任何延迟批准。

(CI 表格见上方英文区的机器可读区域,此处不重复。)

沙箱验证可落实其余部分:@qwen-code /tmux——超出行为 1 的端到端 TUI 表面(/clear 可见地清空 transcript 且无错误行、中途 steer 消息作为独立用户行出现并先于其读取卡片、不可读图片披露所支持格式)由单元测试与作者仅在 macOS 的本地运行固定,但 CI 未在 OpenTUI 腿端到端驱动。作者有写权限,维护者可触发它,以真实用户视角跨渲染器矩阵观察这些行为。

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

Reviewed at 401f9eb5addfd024b83c7027a948a01055d0c614 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — solid, ship-worthy parity fix; the only reservations are divergences the author already named and filed, not defects I found.

Stepping back: this does the unglamorous-but-correct thing. The renderer migration's whole promise is "switching renderers doesn't change what you see", and a command that runs, mutates real state, and says nothing is the most visible break of it — a real P1 with a reproduction, not a hypothesis. The fix matches what I'd have proposed independently, and arguably picks the better of the two seams the design doc weighed: project-on-write at addItem keeps one ordered transcript model instead of interleaving two histories and owning a merge/dedupe story. The total switch with a never default is the right instinct — it turns "someone added a history kind and forgot the transcript" from a silent regression into a build failure.

The things that would have made this dangerous all check out under scrutiny rather than on faith: the new memo dependency doesn't churn the host (verified applyEvent/resetTranscript are stable, and the green no-flicker gate agrees), the core helpers were already public so nothing in packages/core had to change, and the no-op arms are exactly the kinds the live stream already draws, so there's no double-paint. It survived my "maintain this in six months" test — the decision record, the per-kind rationale in the switch comments, and the filed follow-ups (U-34, the steer-not-recorded gap) mean the next person inherits a map, not a mystery.

What keeps it at 4 and not 5 is honest incompleteness the author flagged rather than hid: /advisor, /arena and /recap output stays invisible after this batch, a steered message is visible but still not in the chat recording, and success loses ink's green row. None of these are reasons to hold the PR — they're scoped out and registered — but a maintainer merging this should know it closes the command-output/clear/steer/image gaps, not 100% of the visibility surface. The bounded retry in the integration spec is a small complexity tax paid to keep the ink leg deterministic against an unrelated boot race; it's discriminating, so I'm comfortable with it.

CI is still running on the legs that would compile the exhaustiveness check and drive the new spec, so I'm not approving into a void — approval is deferred until CI lands green on 401f9eb5addfd024b83c7027a948a01055d0c614, at which point the finalize workflow posts the commit-pinned approval. If anything lands red or the head moves, it withholds and flags instead.

中文说明

信心:4/5 —— 扎实、值得发布的 parity 修复;我仅有的保留是作者已点名并登记的差异,而非我发现的缺陷。

退一步看:这件不显眼但正确的事做到了位。渲染器迁移的全部承诺就是「切换渲染器不改变你所见」,而一个运行了、改了真实状态、却什么都不说的命令,是对它最显眼的破坏——这是带复现的真实 P1,不是假设。修复与我独立设想的做法一致,甚至在设计文档权衡的两条接缝里选了更好的那条:在 addItem 处「写入即投影」,保持单一有序的 transcript 模型,而不是交错两条历史并承担合并/去重的包袱。带 never 默认分支的完整 switch 是对的直觉——它把「有人新增了一个历史项类型却忘了 transcript」从静默回归变成构建失败。

会让这类改动变危险的几点,都经得起推敲而非仅凭信任:新的 memo 依赖不会让 host 抖动(已核实 applyEvent/resetTranscript 稳定,绿色的 no-flicker gate 也认同),core 辅助函数本就已公开,故 packages/core 无需任何改动,而空操作分支恰好是实时流已绘制的那些类型,因此不会重复绘制。它通过了我「六个月后维护它」的检验——决策记录、switch 注释里逐类型的理由、以及已登记的后续项(U-34、steer 未录制缺口),意味着接手的人得到的是一张地图,而不是一个谜。

让它停在 4 而非 5 的,是作者坦白而非掩盖的未完成之处:本批之后 /advisor/arena/recap 的输出仍不可见,中途消息可见但仍未进入会话录制,success 失去了 ink 的绿色行。这些都不是扣住 PR 的理由——它们已被划出范围并登记——但合并它的维护者应当知道:它关闭的是命令输出/clear/steer/图片这几个缺口,而非 100% 的可见性表面。集成测试里那个有界重试,是为让 ink 腿在一个无关启动竞态下保持确定性而付出的一点复杂度税;它有判别力,所以我对此安心。

CI 仍在跑那几个会编译穷尽性检查、驱动新测试的腿,所以我不会批准进一个真空——批准延迟到 CI 在 401f9eb5addfd024b83c7027a948a01055d0c614 上全绿,届时 finalize 工作流会发布绑定该提交的批准。若有任一项变红或 head 移动,它会改为保留并标记。

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

Reviewed at 401f9eb5addfd024b83c7027a948a01055d0c614 · re-run with @qwen-code /triage

@chiga0

chiga0 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

E2E test report

Bundle: local npm run build && npm run bundle at the PR head, dist/cli.js. Every number below was re-measured after the branch was fast-forwarded onto 661f41eef0 (main's #10929, which touches ink's slash-command submission path — the same area the new spec's ink leg boots through), so nothing here was carried over from the pre-rebase base.

Interactive specs, both renderer legs

Spec ink (node) OpenTUI (bun + strict)
interactive/command-output-visibility.test.ts ✅ 10114 ms ✅ 6284 ms
interactive/mid-turn-submit-interactive.test.ts ✅ 4/4 ✅ 4/4

Both legs ran the two spec files together: ink 5/5 tests in 19.65 s, OpenTUI 5/5 in 17.40 s. The OpenTUI leg carries QWEN_TUI_RENDERER_STRICT, so a boot-time ink fallback fails the run instead of passing as a false green.

The new spec's signal is 1:1 with the fix (measured)

Neutering the transcript append the projection writes through (opentui-app-shell.tsx), rebuilding, and re-running the same spec on both legs:

Leg Result Failure
OpenTUI 1 failed after 69510 ms across vitest's 3 attempts (retry x2)
ink 1 passed in 8904 ms — unaffected (the mutated file is OpenTUI-only)

The failure is not a generic timeout. It is thrown from the spec's own wait helper, with its own message —

Error: Timeout (20000ms) waiting for: neither the /about row ("Memory Usage")
nor "Unknown command: /about" reached the screen

— and the attached screen dump is the OpenTUI composer (> Type your message or @path/to/file / Auto mode, without the (shift + tab to cycle) suffix ink renders), which is independent evidence the leg really ran OpenTUI.

Dropping the receiver bind behind /clear (commands-context.ts) turns exactly one unit test red — keeps the host receiver on session.startNewSession, with TypeError: Cannot read properties of undefined (reading 'push') — and leaves the file's other six green (1 failed | 6 passed).

Restoring the grouped echo order (all read cards, then all steered rows) turns exactly one unit test red — renders each steered message with its own cards before its own echo — and leaves the file's other 41 green (1 failed | 41 passed).

Moving the image-format disclosure from before the send loop to just after client.sendMessageStream(...) turns exactly one unit test red — discloses an unsupported image format once before the first send, on expected false to be true — and leaves the file's other 41 green. That test's stream mock records, at call time, whether the disclosure has already been yielded, so it pins placement and not just presence; the presence-only assertion it replaced stayed green under the same move, which is why the audit strengthened it.

A boot race the spec had to absorb — and its recovery path, measured

The first verification run failed the ink leg 3/3 attempts with ✕ Unknown command: /about. ink loads its slash-command registry asynchronously and gates nothing on it, so a command typed the moment the prompt appears can land on an empty registry; OpenTUI reloads the registry and re-parses whenever nothing matched, which is why only the ink leg hit it. The same spec run alone on ink passed in 5162 ms but took 10114 ms inside the two-spec run, so the window is load-dependent.

The spec therefore re-sends /about until its row appears, inside a bounded window, so it tests the transcript row rather than ink's boot ordering.

Two things about that retry were measured rather than assumed:

  • It stays discriminating. A projection regression produces neither the field label nor ink's unknown-command text, so the wait throws and leaves the loop — that is the mutation row above, not a spin-to-timeout.

  • The recovery branch actually fires and recovers. Every shipping run rendered on the first send, so the re-send path had never been observed in a green run. Forcing the first send to miss (a temporary bogus command in the spec, since reverted — git diff HEAD is empty again) produced one unknown-command iteration and then rendered the row on the second send, green in 8538 ms; a second probe run reproduced 8538 ms exactly. The captured screen tail carries both the miss and the footer that identifies the renderer:

    > /aboutprobe
    ✕ Unknown command: /aboutprobe
    ────────────────────────────────────────────────
    >   Type your message or @path/to/file
    ────────────────────────────────────────────────
      Auto mode (shift + tab to cycle)
    

    That (shift + tab to cycle) suffix is ink's; OpenTUI deliberately renders only Auto mode, as the /clear capture in the PR description shows.

Unit suite, typecheck, lint

npm run typecheck across all workspaces plus typecheck:integration: clean, 0 error TS, exit 0.

npm run lint:ci (the --max-warnings 0 variant CI runs) fails at this base on one warning this PR does not cause:

packages/cli/src/ui/components/InputPrompt.tsx
  1892:5  warning  React Hook useCallback has a missing dependency:
                    'slashCommands'  react-hooks/exhaustive-deps
✖ 1 problem (0 errors, 1 warning)

That file is untouched here — git status leaves it clean, so what eslint reads is exactly origin/main. The warning arrived with #10929, whose own Lint & Static (ubuntu-latest, Node 22.x) job is recorded fail on the PR that merged it (run 33776576041, job 100720064499, same file, same line, same 1 problem (0 errors, 1 warning)). CI reaches it through the identical command chain — the workflow's Run ESLint step is node scripts/lint.js --eslint, and runESLint() is npm run lint:ci (scripts/lint.js:271-276) — so the gate is red for every head that contains #10929 until slashCommands is added to that dependency array. It is not red for every PR right now, and the distinction is worth stating precisely rather than waving at: of the runs in this window whose Lint & Static came back green, the four I sampled (e1c335df1c, 4bbfa61e8a, 88798521a5, 998472b611) are all reported behind_by ≥ 1 on 661f41eef0 by the compare API, i.e. they predate the commit that introduced the warning. origin/main's tip is still 661f41eef0, so nothing downstream has fixed it either. It is a real omission, not a lint nit: the same commit added a parseSlashCommand(buffer.text, slashCommands) call inside the callback (the call opens at InputPrompt.tsx:1435 and reads slashCommands at :1437), so the handler can close over a stale command list.

It is reported rather than fixed here on purpose. The fix is one line in ink's composer, and this PR's non-causation argument below rests on its production changes being confined to packages/cli/src/ui/opentui/; widening that to an ink file to clear an inherited red would cost more than it buys. Every file this PR does ship is clean under the same linter, measured with both halves of the lint:ci command separately — the 12 changed packages/cli files under eslint --max-warnings 0 --no-warn-ignored (exit 0), and the integration-tests project, which contains the new spec, under eslint integration-tests --max-warnings 0 (exit 0). The pre-commit hook independently ran eslint --fix --max-warnings 0 --no-warn-ignored over the same 13 files and passed on both commits, changing nothing.

The targeted run — all 67 files under packages/cli/src/ui/opentui/, the only production code this PR touches — is green: 1110 tests passed, exit 0, in 27.23 s.

The full suite (the workspace-local node_modules/.bin/vitest run, from packages/cli; 1003 files / 28247 tests) reported 10 failed files / 29 failed tests, exit 1, 910.88 s wall against 4678 s of aggregate test time. All 29 are in files this PR does not touch, and two of them are red on Linux CI at this exact base commit.

Two are inherited, with CI-side proof on main itself at the exact base commit. 661f41eef0 is not only #10929's merge commit — it is still origin/main's tip — and main's own push run at it (run 33776698676, job 100729398193) is failure, ending ❯ src/ui/components/InputPrompt.test.tsx (215 tests | 2 failed) 71980ms, Test Files 1 failed | 1002 passed (1003), Tests 2 failed | 28131 passed | 90 skipped (28223), at :2852:28 and :2880:28. #10929's PR run (33776576041, job 100720064313) ends the same way:

❯ src/ui/components/InputPrompt.test.tsx (215 tests | 2 failed) 67285ms
FAIL … > should submit directly on Enter after arrow-navigate + backspace + retype to perfect match
   ❯ src/ui/components/InputPrompt.test.tsx:2852:28
FAIL … > should submit directly on Enter for a perfect match without prior arrow navigation
   ❯ src/ui/components/InputPrompt.test.tsx:2880:28
Test Files  1 failed | 1002 passed (1003)
     Tests  2 failed | 28131 passed | 90 skipped (28223)

Same file, same two test names, same two line numbers as this machine's run — and as this PR's own CI run, quoted in the next section — on ubuntu/Node 22, on main, before this branch existed. #10929 touched exactly two files, InputPrompt.tsx and InputPrompt.test.tsx, so this is the same inheritance as the lint warning above rather than a second coincidence.

Re-running all ten red files together in isolation (151.06 s) splits them into four that still fail here and six that pass:

File Full run In isolation
src/ui/auth/AuthDialog.test.tsx ❌ 14/25 ❌ 13/25
src/ui/ink-cursor-rendering.test.tsx ❌ 4/28 ❌ 4/28
src/commands/review/capture-local.incremental.test.ts ❌ 3/75
src/ui/components/InputPrompt.test.tsx ❌ 2/215 ❌ 2/215
src/serve/server/git-branch-ops.test.ts ❌ 1/16 ❌ 1/16
src/ui/components/Footer.test.tsx ❌ 1/34
src/commands/review/test-efficacy.integration.test.ts ❌ 1/30
src/serve/routes/workspace-qualified-extensions.test.ts ❌ 1/51
src/serve/routes/scheduled-tasks.test.ts ❌ 1/116
src/serve/workspace-qualified-rest.test.ts ❌ 1/47
29 20

The six that pass in isolation carry 8 of the 9 recovered failures — capture-local.incremental alone took 498 s in the full run — so they are load flakes of a 910 s parallel run. The ninth is AuthDialog, whose failed count moves run to run (14 full, 13 isolated, 12 with the locale pinned below): that file is timing-sensitive on this machine, and the drift is not a signal about this PR because no run of it loads anything this PR changes.

The locale hypothesis was re-tested at this base and rejected again: pinning QWEN_CODE_LANG=en leaves the same three files red (17 failed | 52 passed, exit 1), AuthDialog at 12/25, ink-cursor-rendering at 4/28, git-branch-ops at 1/16.

Non-causation is structural, not inferred. None of the ten files contains the string opentui at all, and none of the seven production modules this PR changes (commands-context, event-adapter, item-projection, live-session, opentui-app-shell, opentui-host, start-opentui-ui) has a single static importer outside packages/cli/src/ui/opentui/ — the only route in is the dynamic import() in the renderer dispatch (llm.tsx:1217, 1223), which is gated on QWEN_TUI_RENDERER=opentui plus a runtime that passes the support probe, and none of the ten so much as mentions llm, case-insensitively. Four files outside the directory mention it at all: llm.tsx and llm.test.tsx (the dispatch and its mock), and ui/model/streaming-model.ts and ui/model/stream-aggregation.ts, which name it in comments only and carry an explicit rule against importing it.

CI on this PR

Run 33779976843 at head 401f9eb5ad, attempt 1: three red gates, two of which are the gates main is red on at this exact base commit. A re-run of only the failed jobs (attempt 2, same head SHA) has since completed: it reproduced those two exactly — same files, same line numbers, same pass/fail totals — and cleared the third, so each bullet below carries a measured attribution rather than a likely one.

Green: web-shell E2E Smoke (ubuntu-latest, Node 22.x) (on attempt 2), Integration Tests (no-AK, No Sandbox), TUI parity snapshots (ink vs opentui), OpenTUI no-flicker gate, Desktop Shell (ubuntu-22.04), Desktop Shell (windows-2022), Secret scan (TruffleHog), Dependency CVE audit, Classify PR, triage, delay-automatic-review. Skipped by the existing CI topology rather than by this PR: Test (windows-latest, Node 22.x), Test (macos-latest, Node 22.x), Integration Tests (CLI, No Sandbox). As of attempt 2 completing, review-pr is the only check still running and the only two red checks on this PR are the two gates main is red on at this same commit.

Test (ubuntu-latest, Node 22.x) — job 100731103546 — inherited, proven. It fails on exactly the pair quoted above and on nothing else:

❯ src/ui/components/InputPrompt.test.tsx (215 tests | 2 failed) 64828ms
FAIL … > should submit directly on Enter after arrow-navigate + backspace + retype to perfect match
   ❯ src/ui/components/InputPrompt.test.tsx:2852:28
FAIL … > should submit directly on Enter for a perfect match without prior arrow navigation
   ❯ src/ui/components/InputPrompt.test.tsx:2880:28
Test Files  1 failed | 1002 passed (1003)
     Tests  2 failed | 28155 passed | 90 skipped (28247)

Same file count and same two line numbers as main's run at 661f41eef0; the pass totals differ only by what this branch adds (28155 here vs 28131 there).

Lint & Static (ubuntu-latest, Node 22.x) — job 100731103733 — inherited, proven. Its entire finding is the single warning quoted above: packages/cli/src/ui/components/InputPrompt.tsx, 1892:5, then ✖ 1 problem (0 errors, 1 warning), ESLint found too many warnings (maximum: 0), exit code 1. No second file, and zero errors.

web-shell E2E Smoke (ubuntu-latest, Node 22.x) — job 100735958307 — not attributed, and deliberately not claimed as inherited. 5 failed, 5 flaky, 40 passed. Every failure is an expect(locator).toBeVisible() that never found [data-web-shell-root] (two of them [data-testid="split-view"] instead), plus one page.waitForFunction: Timeout 10000ms exceeded — the shell root never mounting, across five unrelated specs (collapsed-groups-persist:67, github-prs:117, smoke:130, split-persist:62, standalone:130). From the diff I can say this PR touches zero files under packages/web-shell — none of the 15 it ships is in that package. What I cannot say is that the failure is inherited, because there is no baseline for this job at the base commit: main's push run at 661f41eef0 skipped it, and on #10929's PR run it failed for an unrelated reason (its annotation reads "The self-hosted runner lost communication with the server", and its log blob has already expired). The same job is green on other PRs in this window. So runner contention was the likely reading, but a reading is not a measurement — the re-run supplied it: attempt 2's web-shell E2E Smoke (job 100750641016) is success, 50 passed (1.8m), with no failures and no flaky retries, on the same head SHA. Attempt 1's five failures were contention; this PR does not carry them.

The same attempt re-ran the other two gates and changed nothing. Lint & Static (job 100744951016) reports the same single InputPrompt.tsx:1892:5 warning and the same ✖ 1 problem (0 errors, 1 warning); Test (job 100744951066) reports the same pair at :2852/:2880 with Test Files 1 failed | 1002 passed (1003), Tests 2 failed | 28155 passed | 90 skipped (28247). Attempt 2's remaining jobs — Classify PR, Integration Tests (no-AK, No Sandbox) and both Desktop Shell legs — are success, so the run's overall failure now rests on the two inherited gates alone. (TUI parity snapshots and the OpenTUI no-flicker gate belong to a separate workflow run, 33779976388, green on its first pass and not part of this re-run.)

What that leaves for the deferred approval. The triage stage-3 review withheld its commit-pinned approval pending CI green on 401f9eb5. On the evidence above, the two gates still red are red on origin/main at this same commit — 661f41eef0 is still main's tip, and its own push run 33776698676 fails both — so that gate cannot fire on this head until main moves. Widening this PR would not reach it either: Lint & Static is a one-line dependency-array fix, but Test (ubuntu) fails on two assertions about #10929's own submission behaviour, so clearing that gate means fixing that feature, which is not this diff's business. Stated plainly rather than quietly absorbing an ink-side change to look green. Superseded on 2026-09-04 by the section below: main moved and fixed both gates.

Update after those attempts (2026-09-04)

main fixed the two inherited gates, and the branch now carries the fix. 69c4f1e4bb (#10961) added the dependency array entry and the two fixtures — precisely the InputPrompt.tsx:1892:5 warning and the pair at :2852/:2880. After syncing, the merged tree measures clean here: eslint reports no problems, and InputPrompt.test.tsx is 215/215. The branch also carries 56f75adf29 (#10986) and 60161cb64a (#10969), both of which touch this PR's evidence lanes — see below. Head is now 7dab066266, and both gates completed on it. Lint & Static (ubuntu-latest, Node 22.x) (job 100934556569) is success — the inherited warning is gone, and this diff did not touch it. So is Test (ubuntu-latest, Node 22.x) (job 100934556489). Its log closes the specific pair that was red at the base: src/ui/components/InputPrompt.test.tsx (215 tests) 69137ms with no failure line, and the cli workspace rollup reads Test Files 1007 passed (1007) / Tests 28298 passed | 90 skipped (28388), with the junit summary stating **0** failed outright. Numbers quoted from the archived job log, not from the check annotation.

The new spec has run in CI, under both renderers. Dispatch 33830499451 ran 23766138f1: ✓ interactive/command-output-visibility.test.ts (1 test) 5566ms on E2E Interactive - OpenTUI renderer (bun) (job 100892304566), and ✓ … (1 test) 4951ms on E2E Test (Linux) - sandbox:none - shard 2/3 (job 100892305195) — the ink lanes collect it too, which is the collection map R2-1 corrected the design doc to state. The opentui job nevertheless failed overall, on interactive/context-compress-interactive.test.ts: chat_compression telemetry event was not found on all three attempts, 289 s on the failing case while its sibling case in the same file finished in 79 s. That file is not in this diff, and the run's other eleven jobs were green.

The OpenTUI leg's intermittency is main's, and it is not resolved by #10986. The same leg was green end to end at our base (job 100731177955, context-compress ✓ in 153 s), and it is red on main independently of this PR: run 33834473606, whose head is 56f75adf29, fails mid-turn-submit at its held-response precondition (submitUntilMidTurn, :164) — a different assertion from the one #10986 fixed, and a different file from the one my dispatch lost on. #10986's own message names a second contention class and leaves it out of scope. So: one green observation per renderer, on a leg with known intermittents — not a stability claim about either spec, and no attribution of my run's failure to any specific upstream race.

U-35 closed upstream, not here. The Enter-resolution race this batch registered as a suspicion — "shape matches the ink bug #10926 fixed, not reproduced" — is exactly what 56f75adf29 describes and fixes on main (/quit typed mid-turn splicing to /model quit), with that artifact reddening the leg on 5 of 15 main runs. It was never this PR's to fix; the ledger retires it.

Diff shape at the synced head, superseding the file counts above. The review rounds grew this PR from 15 files to 19 (measured against origin/main at the merge-base, not asserted from the commit message). Of those, two are design documents and one is the new interactive spec; every remaining change is production plus collocated test code under packages/cli/src/ui/opentui/. So both negative claims still hold at this head, not just at 401f9eb5ad: zero files under packages/web-shell, and zero dependency or lockfile files.

A third gate went red on the synced head — Dependency CVE audit, and it is the registry, not this tree. Job 100934461765 fails the root audit and only the root audit:

npm warn audit 400 Bad Request - POST https://registry.npmjs.org/-/npm/v1/security/audits/quick
  message: 'Invalid package tree, run  npm install  to rebuild your package-lock.json'
npm error audit endpoint returned an error

preceded by npm's own This endpoint is being retired. Use the bulk advisory endpoint instead, and every per-package iteration in the same step prints found 0 vulnerabilities — the step still exits 1 because it accumulates the root failure. Four independent measurements say the tree is not the variable: the root package-lock.json is blob b4d9e7e351e9 and package.json is d134c26b6a0a, byte-identical at this head, at the previous head 401f9eb5ad (same check, green), at base 661f41eef0 and at main tip 8a0a9c6614, and this PR ships no dependency file at all. Main tip's own push run fails it with the identical signature (job 100934702845). And it is not just main: from 06:27Z this morning the same Security Checks workflow interleaves greens and reds, then every run from 06:34Z onward is red — 881df6380f, d1e250dd42, 4b9222fca8, 9021f3b542 and 95e15df9f5 among them, none of which is this branch and on each of which Dependency CVE audit is the only failing job. That is what an intermittently-refusing endpoint looks like, and it is already being fixed upstream by #11006, which classifies registry-side refusals apart from advisories and retries them — open, not merged, so this gate stays red for everyone until it lands. Re-running only the failed jobs here is the discriminator: attempt 2 came back success on this same head — same tree, same lockfile blob, only the endpoint's mood different. That settles it as the registry rather than anything in this diff.

Coverage boundary — what this green does not show

  • /clear (U-29/U-30) is not asserted in CI. screen() returns the whole xterm buffer including scrollback, so "the row is gone after /clear" is not expressible through it. Evidence is the unit wiring (clearItemstranscript.clear() → empty reset) plus a local tmux smoke: /about renders the full status block, /clear then leaves only the composer (4 non-blank pane lines) and no error row.
  • The image-format disclosure (U-27) is not asserted in CI. It would need an unsupported image inside a submitted prompt, which the fake server's scripted turns do not model. Evidence is the unit drain tests on both hops.
  • The steered echo (U-12) rides the mid-turn spec's path but no case asserts the row: that spec reads request bodies, and an echo is a transcript row. Evidence is the unit drain tests.
  • Four history kinds are still invisible after this PR. advisor, arena_agent_complete, arena_session_complete and away_recap are written under this renderer — by /advisor, /arena start and /recap — but ink draws them with dedicated components and this transcript has no row shape for them, so the projector returns null for each. They were invisible before this PR too, and the replay path never handled them either (resumeEventsFromSession does not mention them), so live and replay stay consistent. Registered as U-34; the design doc cites each producer line.
  • Formatting is not gated by CI. The workflow's prettier step runs prettier --write . and fails only if the command errors; nothing checks the resulting diff. A stricter local prettier --check . reports style issues in 35 files, none of them in this PR's diff (they are unmodified in the working tree, so they are red at HEAD too). All 15 files this PR ships are clean under it.
  • Windows and Linux were not run locally — left to CI.
  • Three local interactive specs (file-system, context-compress, hooks-command) fail on this machine with "CLI did not start up in interactive mode correctly": they wait for an English UI string without pinning QWEN_CODE_LANG, and this machine's locale is Chinese. Pre-existing and environment-specific; all three pass in CI's English locale. Both specs in the table above pin the language.

@qwen-code-ci-bot qwen-code-ci-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.

Reviewed. Suggestions are inline.

Not explored to full depth (tool budget reached): "agent 1b": none — no Budget gap: lines, as nothing was cut short..

中文说明

已审查。 建议见行内评论。

未探索到全部深度(达到工具调用预算):"agent 1b"none — no Budget gap: lines, as nothing was cut short.

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment thread docs/design/2026-09-03-opentui-batch9-transcript-visibility.md
Comment thread packages/cli/src/ui/opentui/live-session.ts
Comment thread packages/cli/src/ui/opentui/item-projection.test.ts Outdated
Comment thread packages/cli/src/ui/opentui/opentui-host.ts
Comment thread packages/cli/src/ui/opentui/live-session.test.ts
Comment thread packages/cli/src/ui/opentui/opentui-host.ts
Comment thread packages/cli/src/ui/opentui/live-session.ts
Comment thread packages/cli/src/ui/opentui/opentui-app-shell.tsx
Comment thread integration-tests/interactive/command-output-visibility.test.ts
@chiga0

chiga0 commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /tmux

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

tmux real-user testing: cancelled - workflow run

The testing job was cancelled before producing a verdict. See the workflow run for details.

Qwen Code · tmux real-user testing

Project-on-write made the recorded invocation visible, which exposed a
second user row behind every submit_prompt outcome: the live turn echoed
the expanded content the user never typed, where ink adds only the
invocation row. Suppress that echo at the submit seam. Steering the same
text twice also rendered two rows where ink renders one, because stream
echoes never pass through either addItem implementation — collapse them
at the fold, the one chokepoint both echoes share.

Adds the instruments review round 1 asked for: the fileData image
disclosure and its per-message attribution in the steering drain, ink's
blocked-prompt text and redaction at the projection site, the host state
forwarded into the projector context, and the host and seam-callback
identities the transcript memo preserves.

@qwen-code-ci-bot qwen-code-ci-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.

Reviewed. Suggestions are inline.

Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:

  • packages/cli/src/ui/opentui/live-session.ts:331 — [review] D2-1 hasUnsupportedImageFormat is a line-for-line re-implementation of ink's private checkImageFormatsSupport; the two could share one helper
  • packages/cli/src/ui/opentui/live-session.ts:339 — [probe] D2-2 the startsWith('image/') scoping guard of hasUnsupportedImageFormat has no negative test
  • integration-tests/interactive/command-output-visibility.test.ts:52 — [probe] D2-3 the retry predicate scans the whole xterm buffer including scrollback, so a stale ABOUT_UNKNOWN keeps it permanently true
中文说明

已审查。 建议见行内评论。

收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 3 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment thread docs/design/2026-09-03-opentui-batch9-transcript-visibility.md Outdated
The design doc recorded the new interactive spec as collected only by the
opentui e2e job. The ink lanes pass the whole integration root with two
excludes, so they collect it under ink as well — a wrong map that would send
a maintainer bisecting a red ink shard to the wrong job.
@wenshao

wenshao commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Local end-to-end verification

I built a real verification rig for this branch rather than reading the diff, and drove the bundled CLI through a PTY under both renderer legs. Short version: the four headline behaviours all reproduce, the fix is real and my rig discriminates it — and while probing around the edges I found two parity divergences this PR itself introduces, one of which is user-visible on an ordinary path. I'd fix the first before merge; everything else is non-blocking.

Setup

PR head 3c54fc8646
Base d4e3e4fc87 (merge-base with main)
Arms main + OpenTUI, PR + OpenTUI, PR + ink (the parity reference), plus two mutation arms
Driver the repo's own InteractiveSession (node-pty → @xterm/headless) + startFakeOpenAIServer, 100×40, QWEN_CODE_LANG=en
Renderer legs QWEN_E2E_RENDERER=opentui (bun + QWEN_TUI_RENDERER_STRICT=1) and =ink (node)
Model scripted fake OpenAI server — no real provider, so every row on screen is attributable
Host Linux x64, node v24.3.0, bun 1.3.14, retry=0, one fork

Both worktrees were built and bundled from source (npm run build && npm run bundle); every screen below comes from dist/cli.js.

Results

main = base OpenTUI, PR = PR OpenTUI, ink = PR under the ink renderer.

# Behaviour main PR ink
B1 /about status block on screen
B1b /approval-mode plan echoes the invocation and reports the mode ✗ / ✗ ✓ / ✓ ✓ / ✓
B2b /clear with a turn on screen — transcript actually empties ✗ (row stays)
B3 mid-turn steer becomes its own row 0 rows 1 row 1 row
B3 the same steer typed twice → one row, both copies still sent 1 row / sent ✓ 1 row / sent ✓
B5 a prompt-expanding command shows the invocation, not the expansion shows expansion shows /probe shows /probe
B6 an ordinary prompt renders exactly one user row (no double-paint) 1 1 1
B7 8-command sweep (/about /tools /mcp /extensions /context /memory show /approval-mode /model) — all visible, no error row 4/8 (dialogs only) 8/8 8/8
B9 /resume still replays the transcript after clearItems learned to clear it

B1/B1b were run with the model never called (requests.length === 0), so those rows can only have come from the command.

/about before and after

/clear before and after

mid-turn steer before and after

prompt-expanding command before and after

Negative controls

Green on its own proves little, so I broke the fix three ways and re-ran:

  1. .bind(host) reverted, nothing else/clear throws undefined is not an object (evaluating 'this.deps.startNewSession') and clears nothing. That is the crash from OpenTUI: slash command output never reaches the screen (invocation echo + result messages) #10905, reproduced from the PR tree by removing exactly one hunk.

    negative control: bind reverted
  2. projectItemToStreamEvent neutered to return null/about goes invisible again. So B1 tests the projection, not boot output.

  3. Your own spec (command-output-visibility.test.ts) run against main: fails on the OpenTUI leg, passes on the ink leg; against the PR head it passes on both. That is exactly the discrimination the description claims, confirmed independently.

Gates

  • eslint --max-warnings 0 over all 17 changed .ts/.tsx files — clean.
  • tsc --noEmit on packages/cli — clean.
  • The 8 changed test files: 249 passed. The whole src/ui/opentui/ tree plus useHistoryManager / historyUtils: 1170 passed.
  • Exhaustiveness is real: adding one member to the HistoryItemWithoutId union produces src/ui/opentui/item-projection.ts(1196,13): error TS2322: Type 'HistoryItemMutantProbe' is not assignable to type 'never'. A new kind fails the build, as documented.

Findings

F1 — a prompt-expanding command run twice in a row loses its second invocation row (I'd fix before merge)

Repro. .qwen/commands/probe.md with any prompt body; run /probe, wait for the reply, run /probe again.

after the second /probe
ink > /probe · PROBE_REPLY_1 · > /probe · PROBE_REPLY_2
PR, OpenTUI > /probe · PROBE_REPLY_1 · PROBE_REPLY_2no second user row
main, OpenTUI two rows (of the expansion — the bug this PR fixes)

The second reply lands under no prompt at all, which reads worse than the pre-PR expansion row.

Mechanism. OpenTuiAppHost.addItem dedups against this.history, and unlike ink's single store that array never receives the model's replies — only command-written items. So after the first /probe, this.history still ends with the user "/probe" item; the second invocation is judged a consecutive duplicate, is not recorded, and therefore is never projected. invocationEchoed: true then correctly suppresses the live turn's own row, and nothing is left to draw. The dedup is faithful to useHistoryManager line-for-line — it is the store that isn't equivalent.

F2 — a resumed session collapses two adjacent identical user turns into one row

The new collapse lives in foldLiveEvent, which foldBatch — the replay fold behind resetTranscript — also runs. So it applies to recorded history, not just to live steers.

Repro. Two identical prompts whose turns record nothing between them (the "previous session appears to have stopped after user input" shape), then /resume from a fresh CLI:

replayed rows
ink 2
main, OpenTUI 2
PR, OpenTUI 1

Pinned to the mechanism at unit level: foldBatch([user 'x', user 'x']) → 1 item on this branch, 2 on the base.

One change fixes both, and I verified it

Gate the collapse behind an option only the append path sets, and let addItem project unconditionally — the visible fold, not the command-only history, then decides whether a row is a repeat. That is what ink effectively does, since ink dedups against the store it renders.

--- a/packages/cli/src/ui/opentui/live-session-model.ts
+++ b/packages/cli/src/ui/opentui/live-session-model.ts
 export function foldLiveEvent(
   prev: readonly LiveHistoryItem[],
   ev: OpenTuiStreamEvent,
+  options?: { collapseRepeatedUser?: boolean },
 ): readonly LiveHistoryItem[] {
...
-      if (last?.kind === 'user' && last.text === ev.text) return prev;
+      if (
+        options?.collapseRepeatedUser &&
+        last?.kind === 'user' &&
+        last.text === ev.text
+      )
+        return prev;

--- a/packages/cli/src/ui/opentui/live-turn.ts
+++ b/packages/cli/src/ui/opentui/live-turn.ts
-    setItems((prev) => foldLiveEvent(prev, ev));
+    setItems((prev) =>
+      foldLiveEvent(prev, ev, { collapseRepeatedUser: true }),
+    );

--- a/packages/cli/src/ui/opentui/opentui-host.ts
+++ b/packages/cli/src/ui/opentui/opentui-host.ts
       // (move the projection out of the dedup guard: project every item and
       //  let the visible fold collapse a genuine repeat)

Measured on that patched tree: F1 → 2 rows (matches ink), F2 → 2 replayed rows (matches ink and the base), and nothing else moves — B3 still collapses the double steer to one row while both copies still ride to the model, B6 still renders exactly one row, B1/B2/B2b/B5 unchanged. Cost is two unit tests that encode the current behaviour (live-session-model.test.ts "collapses the same user text folded twice in a row" and opentui-host.test.ts "does not append for a consecutive-duplicate user message"); everything else in that tree (1118 of 1120) stays green. Your call on the exact shape — I'm only claiming this one is verified.

F3 — Reviewer Test Plan behaviour 4 is not reproducible (non-blocking, description only)

I could not make the format disclosure appear on any arm, including ink, and I think it cannot appear:

  • @path route — core's read-path gate omits anything outside PIPELINE_IMAGE_MIME_TYPES before it can become an image part (PROVIDER_SAFE_IMAGE_MIME_TYPES, fileUtils.ts:1635). Measured on a vision-capable model (--model kimi-k3): ok.png → forwarded as image_url, no disclosure; odd.tiffImage format image/tiff … cannot be safely sent to the model, no disclosure; bad.avif → same, no disclosure. Since only PIPELINE mimes survive, and all of them are inside SUPPORTED_IMAGE_MIME_TYPES, hasUnsupportedImageFormat can never be true here.
  • composer routeIMAGE_MIME_BY_EXTENSION (live-turn.ts:49) only maps extensions that are already in SUPPORTED_IMAGE_MIME_TYPES; anything else gets its own Unsupported image type: notice instead.

So U-27 is faithful ink parity of code that is currently dead on both entry points — harmless, but the test plan asks a reviewer to observe something they cannot. Relatedly, "the image is still forwarded, as in the existing renderer" is not accurate for non-pipeline formats: core omits the data and substitutes an in-band notice.

F4 — /branch, /fork and /cd self-block under OpenTUI (pre-existing; this PR is what made it visible)

OpenTuiAppHost.isIdle() is !this.processing && !this.streaming, and OpenTuiSlashDispatcher sets processing = true for the whole duration of the command it is running (commands-dispatch.ts:459 → :1001). So context.ui.isIdleRef.current is false inside every command that reads it. ink sets isIdleRef.current = streamingState === StreamingState.Idle (AppContainer.tsx:2407) — model-turn state only.

Observed at an idle prompt after a completed turn: /branch verifybranch✖︎ Cannot branch while a response or tool call is in progress. on both this branch and the base, while the same command opens the branch dialog under ink. branchCommand.ts:29, forkCommand.ts:74, cdCommand.ts:71 and advisor-command.ts:200 all read that ref. Worth its own issue — not this PR's job, but this PR is the reason the error row is now on screen instead of swallowed.

F5 — the sentToModel flip never reaches the transcript row (forward-looking)

updateItem deliberately doesn't project, so the dispatcher's updateItem(invocationItemId, { sentToModel: true }) (commands-dispatch.ts:609 / :825) leaves the projected row at sentToModel: false forever. That's inert today — I checked, session-rewind-model.ts is the only consumer and session-rewind.tsx isn't wired into any shell — but once rewind lands, a submit_prompt command's invocation would be filtered out of the OpenTUI rewind list while ink keeps it. Probably one more line on the U-34 follow-up list.

Also observed, not defects

  • While messages sit in the steering queue, ink renders them under the spinner (⏳ 2 queued plus the texts); OpenTUI still shows only the counter. This PR fixes the post-acceptance row, which is what it claims; the queued preview remains a separate gap.
  • Two identical steers collapse to one row but both copies still reach the model ([{text: STEER}, {text: "\n\n"}, {text: STEER}] in the request body). That matches ink's accept(), so it's intended — just noting it, since "one row" could be misread as "one message".

Not covered

Linux only. I did not exercise /quit summary rendering, the advisor/arena/recap kinds that the design doc registers as U-34, or Windows/macOS.


中文说明

本地端到端验证

我没有只读 diff,而是给这个分支搭了一套真实验证环境,用 PTY 驱动打包后的 CLI,在两条渲染器腿上都跑了一遍。结论先说:四个主打行为全部复现,修复是真的,我的用例也确实能判别它——但在边界处探查时,我发现了本 PR 自身引入的两处 parity 偏差,其中一处在普通路径上用户可见。第一处我建议合并前修掉;其余都不阻塞。

环境

PR head 3c54fc8646
Base d4e3e4fc87(与 main 的 merge-base)
对照臂 main + OpenTUI、PR + OpenTUI、PR + ink(parity 基准),外加两条变异臂
驱动 仓库自带的 InteractiveSession(node-pty → @xterm/headless)+ startFakeOpenAIServer,100×40,QWEN_CODE_LANG=en
渲染器腿 QWEN_E2E_RENDERER=opentui(bun + QWEN_TUI_RENDERER_STRICT=1)与 =ink(node)
模型 脚本化的假 OpenAI 服务器——不接真 provider,因此屏幕上每一行的来源都可追溯
主机 Linux x64、node v24.3.0、bun 1.3.14、retry=0、单 fork

两个 worktree 都从源码 npm run build && npm run bundle,下面所有画面均来自 dist/cli.js

结果

main = base OpenTUI,PR = PR OpenTUI,ink = PR 在 ink 渲染器下。

# 行为 main PR ink
B1 /about 状态块上屏
B1b /approval-mode plan 回显调用汇报新模式 ✗ / ✗ ✓ / ✓ ✓ / ✓
B2b 屏幕上已有一轮内容时执行 /clear,转录真的清空 ✗(内容还在)
B3 中途投喂成为独立的一行 0 行 1 行 1 行
B3 同一条中途消息投两次 → 一行,但两份都发给模型 1 行 / 已发 ✓ 1 行 / 已发 ✓
B5 会展开成提示词的命令显示调用而非展开结果 显示展开 显示 /probe 显示 /probe
B6 普通提示词恰好渲染一行用户行(无重复绘制) 1 1 1
B7 8 条命令扫描(/about /tools /mcp /extensions /context /memory show /approval-mode /model)全部可见、无错误行 4/8(仅弹窗类) 8/8 8/8
B9 clearItems 学会清转录之后,/resume 仍能正常重放

B1/B1b 全程模型零调用(requests.length === 0),因此这些行只可能来自命令本身。

(截图见英文部分。)

反向对照

只看到绿色说明不了什么,所以我用三种方式把修复破坏掉再跑:

  1. 只回退 .bind(host) 这一处/clear 抛出 undefined is not an object (evaluating 'this.deps.startNewSession'),并且什么都没清。这正是 OpenTUI: slash command output never reaches the screen (invocation echo + result messages) #10905 里的崩溃,从 PR 树上仅移除一个 hunk 就复现了。
  2. projectItemToStreamEvent 掐成 return null/about 重新变得不可见。说明 B1 测的是投影,而不是启动输出。
  3. 你自己的那个测试command-output-visibility.test.ts)跑在 main 上:OpenTUI 腿失败、ink 腿通过;跑在 PR head 上两条腿都通过。这正是描述里声称的判别力,独立复核成立。

门禁

  • 对全部 17 个改动的 .ts/.tsx 文件跑 eslint --max-warnings 0 —— 干净。
  • packages/clitsc --noEmit —— 干净。
  • 8 个改动的测试文件:249 通过。整个 src/ui/opentui/ 目录加上 useHistoryManager / historyUtils1170 通过
  • 穷尽性检查是真的:往 HistoryItemWithoutId 联合类型里加一个成员,会得到 src/ui/opentui/item-projection.ts(1196,13): error TS2322: Type 'HistoryItemMutantProbe' is not assignable to type 'never'。新增类型确实会让构建失败,与文档一致。

发现

F1 —— 连续两次执行同一个「展开成提示词」的命令,第二次的调用行丢失(建议合并前修)

复现。.qwen/commands/probe.md(提示词内容随意);执行 /probe,等回复,再执行一次 /probe

第二次 /probe 之后
ink > /probe · PROBE_REPLY_1 · > /probe · PROBE_REPLY_2
PR,OpenTUI > /probe · PROBE_REPLY_1 · PROBE_REPLY_2 —— 没有第二条用户行
main,OpenTUI 两行(内容是展开结果——正是本 PR 要修的问题)

第二条回复底下没有任何提示词,观感比修之前的「展开行」还差。

机理。 OpenTuiAppHost.addItem 是对着 this.history 去重的,而与 ink 的单一存储不同,这个数组永远收不到模型的回复——里面只有命令写入的项。于是第一次 /probe 之后,this.history 的末尾仍然是那条 user "/probe";第二次调用被判定为连续重复,既没被记录、也就从未被投影。此时 invocationEchoed: true 又(正确地)压掉了 live turn 自己的那一行,最后什么都不剩。这段去重逐行忠实于 useHistoryManager——不等价的是存储本身。

F2 —— 恢复会话时,两条相邻且相同的用户回合被折叠成一行

新的折叠写在 foldLiveEvent 里,而 foldBatch——resetTranscript 背后的重放 fold——也会走它。于是它作用到了录制历史上,而不只是实时的中途投喂。

复现。 两条相同的提示词,且两轮之间没有任何东西被录进去(也就是「上一次会话在用户输入后停住」的形态),然后用一个全新的 CLI 执行 /resume

重放出的行数
ink 2
main,OpenTUI 2
PR,OpenTUI 1

在单测层面钉死了机理:foldBatch([user 'x', user 'x']) 在本分支得到 1 项,在 base 上得到 2 项。

一处改动同时修掉两个,并且我实测过

把折叠放到只有 append 路径才开启的开关后面,并让 addItem 无条件投影——由可见的 fold(而不是只装命令的 history)来判定某一行是不是重复。这也正是 ink 的实际语义,因为 ink 是对着它自己渲染的那个存储去重的。

(补丁见英文部分。)

在打了该补丁的树上实测:F1 → 2 行(与 ink 一致),F2 → 重放 2 行(与 ink 和 base 一致),而其他一切不动——B3 仍然把两次相同投喂折叠成一行、两份仍然都发给模型,B6 仍然恰好一行,B1/B2/B2b/B5 均不变。代价是两个编码了当前行为的单测(live-session-model.test.ts 的 "collapses the same user text folded twice in a row" 与 opentui-host.test.ts 的 "does not append for a consecutive-duplicate user message"),该目录下其余全绿(1120 中的 1118)。具体怎么改由你定——我只声明这一种方案是我实测过的。

F3 —— 评审测试计划的行为 4 无法复现(不阻塞,属描述问题)

我在任何一条臂上都没能让格式披露出现,包括 ink;而且我认为它根本出不来:

  • @path 路径——core 的读取路径门在图片能变成 image part 之前,就把 PIPELINE_IMAGE_MIME_TYPES 之外的一切剔除了(PROVIDER_SAFE_IMAGE_MIME_TYPES,fileUtils.ts:1635)。在支持视觉的模型上实测(--model kimi-k3):ok.png → 以 image_url 正常转发、无披露;odd.tiffImage format image/tiff … cannot be safely sent to the model、无披露;bad.avif → 同上、无披露。既然只有 PIPELINE 集合的 mime 能活下来,而它们全部落在 SUPPORTED_IMAGE_MIME_TYPES 之内,hasUnsupportedImageFormat 在这里永远为假。
  • 输入框附件路径——IMAGE_MIME_BY_EXTENSION(live-turn.ts:49)只映射了本来就在 SUPPORTED_IMAGE_MIME_TYPES 里的扩展名;其余会走它自己的 Unsupported image type: 提示。

所以 U-27 是对一段目前在两个入口上都已死亡的代码做的忠实 ink parity——无害,但测试计划要求评审者去观察一个观察不到的现象。相关地,「图片本身仍会照常转发,与既有渲染器一致」对非 pipeline 格式并不准确:core 会丢掉数据并替换成一条 in-band 提示。

F4 —— OpenTUI 下 /branch/fork/cd 会自我阻塞(既有问题;本 PR 只是让它显形)

OpenTuiAppHost.isIdle()!this.processing && !this.streaming,而 OpenTuiSlashDispatcher 在它正在执行的那条命令的整个生命周期里都把 processing 置为 true(commands-dispatch.ts:459 → :1001)。于是在每一个读取 isIdleRef 的命令内部context.ui.isIdleRef.current 都是 false。ink 那边是 isIdleRef.current = streamingState === StreamingState.Idle(AppContainer.tsx:2407)——只反映模型回合状态。

实测:一轮完成后在空闲提示符下执行 /branch verifybranch✖︎ Cannot branch while a response or tool call is in progress.本分支和 base 都一样,而同一条命令在 ink 下会正常打开 branch 弹窗。branchCommand.ts:29forkCommand.ts:74cdCommand.ts:71advisor-command.ts:200 都读这个 ref。值得单开一个 issue——不是本 PR 的活,但正因为本 PR,这条错误行才不再被吞掉。

F5 —— sentToModel 的翻转到不了转录行(前瞻性)

updateItem 刻意不投影,因此 dispatcher 的 updateItem(invocationItemId, { sentToModel: true })(commands-dispatch.ts:609 / :825)会让投影出的那一行永远停在 sentToModel: false。今天这没有影响——我查过,session-rewind-model.ts 是唯一的消费者,而 session-rewind.tsx 还没有接进任何 shell——但等 rewind 接上以后,submit_prompt 命令的调用会在 OpenTUI 的回退列表里被过滤掉,而 ink 会保留它。大概值得在 U-34 后续项里再加一行。

另外观察到的,不算缺陷

  • 消息还待在投喂队列里时,ink 会把它们渲染在 spinner 下方(⏳ 2 queued 加上原文);OpenTUI 目前仍只显示计数。本 PR 修的是「被接受之后」的那一行,与它声称的一致;队列预览仍是另一个独立缺口。
  • 两条相同的中途投喂折叠成一行,但两份都会到达模型(请求体里是 [{text: STEER}, {text: "\n\n"}, {text: STEER}])。这与 ink 的 accept() 一致,属预期——只是提一句,免得「一行」被读成「一条消息」。

未覆盖

仅 Linux。/quit 摘要渲染、设计文档登记为 U-34 的 advisor/arena/recap 几类、以及 Windows/macOS 均未验证。


🤖 Generated with Claude Code — Claude Opus 5 (1M context)

@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.

Reviewed at head 7dab0662.

  • Threads: all 10 resolved, including ci-bot's round-2 inline suggestions and the author fix-notes; the last formal review (round 2, at 3c54fc86) posted no blockers. Everything since 3c54fc86 is two merges of main plus docs — I checked the PR's semantic markers still stand at this head after the merges folded main's own OpenTUI changes (#10986's input-prompt rework) into the same directory: the no-echo collapse at the shared fold chokepoint, invocationEchoed on the submit path, transcript.clear/append wiring, the U-27 checks on both fresh-query and steering hops, and the per-message cards-then-row ordering all read as claimed against the checked-out head.
  • My own pass found no new Criticals. The riskiest semantic is deliberately ink's: two adjacent identical user items collapse to one row — it is placed at the single chokepoint both echoes share, matches the addItem parity the body documents, and costs only a transcript row (the second submit still runs); the same for the sentToModel: false steer echo (rewind-list exclusion, as the existing renderer records). The projection map is compile-time exhaustive with the four invisible kinds named in the design doc rather than silently swallowed, and the detached-this fix for startNewSession is the minimal bind at the context's construction site.
  • Evidence quality: real terminal captures of /about and /clear at the base commit, the mutation-measured discriminating window for the new interactive spec, and an honestly disclosed limitation (that spec runs post-merge/schedule, not on pull_request) — with the retry-window semantics against the ink boot race reasoned through rather than papered over.
  • CI facts on this head: 13 checks pass, zero fail; Test/Lint/CVE/review-pr still in flight (the focused suites for these files run there); the earlier withheld deferred approval was against the pre-merge head and re-ran. Per the channel convention the call is on the review itself.

@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.

Reviewed at head 7dab0662. No historical blocking issues exist on this PR (both automated rounds were Suggestion-only COMMENTED reviews, and every item was addressed with verification), and my independent Critical-only pass over the production hunks finds no blocking defects.

Critical-only pass — verified at this head:

  • Prompt redaction preserved on both paths: the user_prompt_submit_blocked rendering now goes through one shared formatter (formatUserPromptSubmitBlocked in event-adapter.ts, sanitizeSensitiveText applied) used by both the live stream mapper and the new item projector — the second call site can no longer drift from the redaction.
  • projectItemToStreamEvent is an exhaustive switch over every history kind with explicit, documented decisions for the no-op kinds; blocked prompts become the redacted warning row.
  • U-27 image-format disclosure is a faithful ink parity port (hasUnsupportedImageFormat, image/-scoped, core's acceptance set) applied once on the fresh hop and per surviving message on the steering hop, each emitting the INFO row without dropping the part.
  • U-12 steer echoes ride the tool boundary as sentToModel: false rows in ink accept() order, and the restored-hop case yields none (aborted hops never reach accept) — pinned by tests.
  • Consecutive-duplicate suppression now exists at both chokepoints, matching ink's addItem: the fold's user arm returns prev for an identical consecutive text (verified in live-session-model.ts), and the host's addItem skips history write and projection alike (verified at head lines 199-224).
  • submit_prompt outcomes set invocationEchoed, so submit() skips the duplicate user row for generated (never-typed) content — ink parity, with the typed-prompt echo preserved.
  • /clear contract: startNewSession is bound in the command context (the bare-reference throw that left the transcript standing is closed), clearItems completes the contract by clearing the visible transcript, and the entry wires onTranscriptEvent into the shell. Host/dispatcher identity across re-renders is pinned by tests.

Historical items: round 1's nine Suggestions (U-30 doc traceability, fileData coverage, blocked-prompt projection pin, ctx-dependent host projection, per-message steering granularity, the two-rows submit_prompt echo, steer-echo dedupe, host-identity pin, spec-collection disclosure) are all addressed per the thread replies and present at this head; round 2's single doc-lane item was fixed in 7dab0662 after verification against e2e.yml. The deferred round-2 probes (shared image-format helper, startsWith scoping) are explicitly non-blocking.

CI at this head: the only failing check is Dependency CVE audit, which is not introduced by this PR — the diff touches no dependency manifest, and the job log shows both completed scans reporting 0 vulnerabilities; the exit-1 comes from the registry's legacy audit endpoint returning 400 Bad Request on the request itself (the endpoint's own retirement notice is in the log). Test (ubuntu-latest, Node 22.x), Lint & Static and review-pr are still pending, which does not gate this review per policy; the OpenTUI no-flicker gate, TUI parity snapshots and the no-AK integration lane pass.

@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.

No blocking findings. (Approval not recorded from this account — I'm the PR author.)

Scope: all 19 files reviewed at head 7dab0662 (the 407-line design doc skimmed for claims; specs read at assertion level). NOT covered — the PR's own mutation claim (neutering the projection fails the OpenTUI leg, ink stays green) not re-executed (needs a re-bundle) · Windows/Linux locally · no e2e-leg run exists at this final head (see below).

Checked at head:

  • Exhaustiveness by construction: projectItemToStreamEvent is a total switch with a never default; every no-op kind carries a stated reason (live-stream duplicates would double-render; help renders through the dialog overlay; advisor/arena/recap kinds = registered U-34 gap, unchanged visibility-wise). A new history kind fails the build rather than vanishing silently — matches the design claim.
  • No double render / no dropped row across the two stores: host.addItem collapses an identical consecutive user item in history and skips the transcript append in the same branch; foldLiveEvent collapses identical consecutive user rows in the transcript — so two identical steers = one row on both stores. invocationEchoed suppresses the live-turn user row exactly for submit_prompt outcomes (whose typed invocation is echoed via host history), and never for plain prompts. Traced all three row-producing paths (live stream, host projection, steer echo) through the fold.
  • Steer echo shape matches ink: per-message read cards immediately before that message's own row (ink accept() order), sentToModel: false keeps it out of the rewind list, and a restored hop discards cards+rows as a unit.
  • /clear: the crash was a real receiver-detachment (host.startNewSession passed bare into a context that /clear calls before ui.clear()) — now bound at the construction site; transcript.clear routes through the live-turn reset with an empty batch, which also drops a stray steering queue. Both histories clear, matching the SessionSwitchHost contract the code cites.
  • U-27 disclosure: fresh hop checks resolved parts once before the send loop (tool continuations never checked, as ink); steering hop checks per surviving message; warning names the narrow set while the check uses the wide set — the ported quirk matches core's helper.
  • Leg evidence, honestly read: in dispatched run 33830499451 the new spec passed (its 11 files: 1 failed | 9 passed | 1 skipped; the failure is context-compress … forward /compress instructions, 289 s with retries, at pre-head 1cdb70d7). Neighbouring main runs show the flake pattern is pre-existing and moves around (33834473606 failed on different interactive tests; 33839728395 fully green). Two evidence nits, not code defects: (a) the run is red overall and the body cites it without saying so; (b) no leg run exists at the final head — worth one more dispatch before merge since the leg is exactly this feature's proof surface.

Ran (linux, head 7dab0662): vitest src/ui/opentui67 files, 1125/1125 passed.

Cross-check: the ci-bot's two rounds are Suggestion-only (doc disposition of U-30, untested fileData branch, projection-test assertions, spec-collection notes) and nothing on record contradicts the pass above; @qqqys and the dev-bot approved this head with the suggestion items addressed.

Reviewed with AI assistance.

@chiga0
chiga0 added this pull request to the merge queue Sep 4, 2026
Merged via the queue into main with commit 9c320cb Sep 4, 2026
142 of 144 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.23.1.

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

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OpenTUI: slash command output never reaches the screen (invocation echo + result messages)

5 participants