Skip to content

fix(cli): default to virtualized terminal history - #5738

Merged
chiga0 merged 27 commits into
QwenLM:mainfrom
ZevGit:fix/cli-scroll-flicker
Jul 28, 2026
Merged

fix(cli): default to virtualized terminal history#5738
chiga0 merged 27 commits into
QwenLM:mainfrom
ZevGit:fix/cli-scroll-flicker

Conversation

@ZevGit

@ZevGit ZevGit commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Turns virtualized history on by default for interactive CLI sessions. New users and existing users without an explicit setting now get the in-app scrollable history viewport, while users who prefer host terminal scrollback can still opt out by setting ui.useTerminalBuffer to false.

The settings schema, generated IDE schema, tests, and user/design docs are updated so the runtime behavior and documented behavior match.

Why it's needed

The legacy terminal scrollback rendering path can visibly flash or make the host terminal scrollbar jump during long sessions, refreshes, resize settle repaint, compact-mode toggles, and similar TUI updates. The virtualized history path already avoids the physical terminal clear/replay behavior that causes this class of flicker, but it was still opt-in, so most users continued to hit the old path unless they discovered the setting.

Defaulting to the virtualized path makes the stable behavior the common path, reduces repeated flicker reports, and keeps a small compatibility escape hatch for terminals or workflows that still prefer native scrollback.

Reviewer Test Plan

How to verify

Start the interactive CLI with no ui.useTerminalBuffer setting and confirm it opens in the virtualized history flow. Scrolling history should use Shift+Up/Down, PgUp/PgDn, Ctrl+Home/End, or the mouse wheel instead of native terminal scrollback.

Start the interactive CLI with ui.useTerminalBuffer: false and confirm the legacy host scrollback path is still available.

Run the focused unit tests and type/build checks listed below.

Evidence (Before & After)

Before: with no explicit setting, the CLI defaulted to the legacy terminal scrollback path, so refreshes could clear/replay terminal history and produce visible scrollbar jumps in long sessions.

After: a local PTY smoke test launched the built CLI twice. With the setting unset, the captured ANSI stream entered the alternate screen and reached the input prompt. With ui.useTerminalBuffer: false, the captured ANSI stream did not enter the alternate screen and still reached the input prompt.

Verification commands run locally:

cd packages/cli && npx vitest run src/config/settingsSchema.test.ts src/ui/AppContainer.test.tsx src/gemini.test.tsx
Test Files 3 passed (3)
Tests 147 passed (147)

npm run typecheck
passed

npm run build
passed with existing warnings only

git diff --check
passed

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Environment (optional)

Local source checkout, built CLI, sandbox disabled for the PTY smoke. The smoke did not send a model request; it only verified startup rendering and the terminal mode selected by default versus explicit opt-out.

Risk & Scope

  • Main risk or tradeoff: virtualized history uses an in-app viewport and alternate screen by default, so users who rely on native host scrollback or click-drag selection may need the documented opt-out or Shift/Option selection bypass.
  • Not validated / out of scope: exhaustive coverage across every terminal emulator, tmux configuration, and OS combination.
  • Breaking changes / migration notes: no config migration is required; set ui.useTerminalBuffer to false to keep the legacy host scrollback behavior.

Linked Issues

N/A

中文说明

What this PR does

这个 PR 将交互式 CLI 的虚拟化历史视图默认开启。新用户和没有显式配置该选项的现有用户会默认使用应用内可滚动历史视口;如果用户更偏好宿主 terminal scrollback,仍然可以通过设置 ui.useTerminalBufferfalse 选择旧路径。

配置 schema、生成的 IDE schema、测试以及用户/设计文档都同步更新,保证运行时行为和文档描述一致。

Why it's needed

旧的 terminal scrollback 渲染路径在长会话、刷新、resize settle repaint、compact mode 切换等 TUI 更新场景下,可能出现明显闪屏或宿主 terminal 滚动条跳动。虚拟化历史路径已经避免了导致这类 flicker 的物理清屏和历史重放行为,但之前仍是 opt-in,大多数用户如果没有发现这个设置,依然会走旧路径。

把虚拟化路径设为默认,可以让更稳定的行为成为常规路径,减少重复的闪屏反馈,同时保留一个很小的兼容逃生口,给仍然偏好原生 scrollback 的终端或工作流使用。

Reviewer Test Plan

How to verify

在没有设置 ui.useTerminalBuffer 的情况下启动交互式 CLI,确认它进入虚拟化历史流程。历史滚动应使用 Shift+Up/Down、PgUp/PgDn、Ctrl+Home/End 或鼠标滚轮,而不是依赖原生 terminal scrollback。

ui.useTerminalBuffer: false 的情况下启动交互式 CLI,确认旧的宿主 scrollback 路径仍然可用。

运行上面列出的聚焦单测、typecheck 和 build 检查。

Evidence (Before & After)

Before:没有显式配置时,CLI 默认走旧的 terminal scrollback 路径,因此刷新时可能清屏并重放 terminal 历史,在长会话中表现为可见滚动条跳动。

After:本地 PTY smoke test 启动了两次构建后的 CLI。配置未设置时,捕获到的 ANSI 流进入了 alternate screen,并正常到达输入提示。显式设置 ui.useTerminalBuffer: false 时,捕获到的 ANSI 流没有进入 alternate screen,也正常到达输入提示。

本地执行的验证命令:

cd packages/cli && npx vitest run src/config/settingsSchema.test.ts src/ui/AppContainer.test.tsx src/gemini.test.tsx
Test Files 3 passed (3)
Tests 147 passed (147)

npm run typecheck
passed

npm run build
passed with existing warnings only

git diff --check
passed

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Environment (optional)

本地源码 checkout,使用构建后的 CLI,PTY smoke 中关闭 sandbox。这个 smoke 没有发送模型请求,只验证启动渲染以及默认配置和显式 opt-out 分别选择的 terminal mode。

Risk & Scope

  • Main risk or tradeoff:虚拟化历史默认使用应用内视口和 alternate screen,因此依赖宿主原生 scrollback 或 click-drag 选择文本的用户,可能需要使用文档中的 opt-out 或 Shift/Option 选择绕过方式。
  • Not validated / out of scope:没有覆盖所有 terminal emulator、tmux 配置和 OS 组合。
  • Breaking changes / migration notes:不需要配置迁移;设置 ui.useTerminalBufferfalse 可以保留旧的宿主 scrollback 行为。

Linked Issues

N/A

@ZevGit

ZevGit commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

E2E / TUI verification report for this PR:

  • Local PTY smoke launched the built CLI with no ui.useTerminalBuffer setting and confirmed the ANSI stream enters alternate screen and reaches the input prompt.
  • The same smoke launched the built CLI with ui.useTerminalBuffer: false and confirmed it does not enter alternate screen while still reaching the input prompt.
  • This smoke did not send a model request; it only verifies startup rendering mode selection for the default path and explicit opt-out path.

Additional local checks:

cd packages/cli && npx vitest run src/config/settingsSchema.test.ts src/ui/AppContainer.test.tsx src/gemini.test.tsx
Test Files 3 passed (3)
Tests 147 passed (147)

npm run typecheck
passed

npm run build
passed with existing warnings only

git diff --check
passed

Tested locally on macOS. Windows/Linux terminal combinations were not manually tested.

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

No review findings. Downgraded from Approve to Comment: CI failing: Test (windows-latest, Node 22.x).

CI note: the failing Windows job is in packages/core's gitWorktreeService.symlinks.integ.test.ts (git init -q -b main), outside this PR's changed files.

— GPT-5 Codex via Qwen Code /review

@wenshao

wenshao commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Verification Report — default to virtualized terminal history

Built and exercised the real CLI from the PR head, with a focus on the one thing a default-flip can silently get wrong: leaving a runtime gate behind. Result: correct, complete, and well-tested. The only open item is a product/UX judgement call (alt‑screen now on by default), not a defect.

Environment: PR head ee5d0ea9f, built via npm ci (full tsc build), node packages/cliv0.18.5, Linux / Node 22.x, tmux 3.5a on a dedicated socket.


Scope check

The PR is a focused 10‑file, single‑commit change that flips ui.useTerminalBuffer default false → true. (Note for reviewers: a naïve 3‑dot diff can show an extra acp-integration.test.ts change — that is real‑main being ahead of a stale local ref, not part of this PR. Verified against freshly‑fetched origin/main: the commit touches exactly the 10 files listed in the diff.)

1. Gate‑completeness audit ✅ (avoids the schema‑only trap)

A schema default: in settingsSchema.ts is display‑onlymergeSettings() does not seed settings.merged from it. So flipping only the schema default would be a runtime no‑op and create a "dialog shows on / runtime still off" split (the failure mode seen in #5145).

This PR does not fall into that trap. There are exactly two runtime reads of settings.merged.ui?.useTerminalBuffer, and both are flipped from ?? false to ?? true:

Gate Drives Status
gemini.tsx:367 (useVP) render(..., { alternateScreen: useVP }) ?? false → ?? true
AppContainer.tsx:957 (useTerminalBuffer) VP render path + refreshStatic; propagated to MainContent/ScrollableList via uiState.useTerminalBuffer ?? false → ?? true

MainContent.tsx inherits the value through uiState (no independent read). The settingsSchema.ts + vscode‑ide JSON defaults are flipped too, so the Settings dialog stays consistent with runtime. The serve/routes/workspace-settings.ts reference is only a TUI‑only classification set (its default field auto‑derives from the schema). No ACP / serve / non‑interactive path reads this setting — the change is TUI‑only. Because the gates use ??, an unset key correctly resolves to the new default regardless of merge seeding.

2. Real tmux E2E (A/B) ✅ — the headline

Two server‑side probes were used because they independently exercise the two different gates: #{alternate_on}gemini.tsx, #{mouse_any_flag}AppContainer → ScrollableList. Same built CLI, two isolated $HOMEs, both arms reached the chat prompt cleanly (no auth dialog).

Build / config #{alternate_on} #{mouse_any_flag} meaning
PR + setting unset (new default) 1 1 VP on by default
PR + ui.useTerminalBuffer: false (opt‑out) 0 0 legacy path preserved
base (PR delta reverted in dist) + unset 0 0 proves the 2‑line delta is the sole cause

The third row is a dist‑level swap (?? true?? false on the two compiled lines, same unset $HOME) — isolating the PR's change as the only variable. Both gates verified, escape hatch verified.

3. UX consequence reviewers should weigh ⚠️ (not a bug)

The new default enters the alternate screen. Demonstrated concretely: a host marker printed before launch is hidden while qwen runs (alternate_on=1) and restored after /quit, with the qwen UI gone:

while running:  alternate_on=1  | qwen banner visible | host marker hidden
after /quit:    alternate_on=0  | qwen banner gone     | host marker restored

So on exit, the entire qwen session is wiped from the host terminal scrollback (standard vim/less‑style alt‑screen behavior). This is the intended trade to kill flicker, and the documented opt‑out (ui.useTerminalBuffer: false) works — but it is a meaningful change for users who scroll back through their terminal after quitting, or who rely on native click‑drag selection. Worth a conscious product sign‑off.

4. Unit tests ✅

  • The 3 PR‑touched files: 147 / 147 passed (matches the PR body).
  • Reverse‑audit sweep of 6 default‑flip‑sensitive suites (MainContent, App, DefaultAppLayout, useResizeSettleRepaint, terminalRedrawOptimizer, InputPrompt): 221 / 221 passed. The PR correctly pins useTerminalBuffer: false in the shared AppContainer fixture so legacy‑path tests (e.g. the Terminal resize during streaming leaves fragmented content at wrong widths in scrollback #4891 resize‑clearTerminal contract) stay valid. No collateral breakage found.

5. CI status

Test (ubuntu) and Test (macos) pass. Test (windows) fails, but the failure is gitWorktreeService.symlinks.integ.test.ts (1/15) plus an exit code 127 (shell command‑not‑found) — a Windows symlink/tooling issue in a file this PR does not touch (byte‑identical between merge‑base and PR head). Not a regression from this PR.


Verdict

From a correctness standpoint this is mergeable: the gate audit is clean, both runtime fallbacks and the display defaults are flipped consistently, tests are green on Linux/macOS, and the real‑terminal A/B confirms both the new default and the opt‑out. The remaining decision is the product call on defaulting to the alternate screen (§3). The Windows CI red is pre‑existing/unrelated.

中文版(点击展开)

验证报告 — 默认开启虚拟化历史视图

从 PR head 构建并运行了真实 CLI,重点验证 default 翻转最容易悄悄出错的地方:有没有漏掉某个 runtime gate。结论:实现正确、完整、测试充分。 唯一待定项是一个产品/UX 取舍(默认进入 alt‑screen),而不是缺陷。

环境: PR head ee5d0ea9f,npm ci(完整 tsc 构建),node packages/cliv0.18.5,Linux / Node 22.x,专用 socket 上的 tmux 3.5a

范围确认

这是一个聚焦的 10 文件、单 commit 改动,把 ui.useTerminalBuffer 默认值从 false 翻成 true。(提醒 reviewer:简单的 3‑dot diff 可能多出一个 acp-integration.test.ts 改动 —— 那是真实 origin/main 领先于本地过期 ref 造成的,不属于本 PR。重新 fetch origin/main 后确认:该 commit 恰好只动 diff 列出的 10 个文件。)

1. Gate 完整性审计 ✅(避开了 schema‑only 陷阱)

settingsSchema.ts 里的 default: 只用于显示 —— mergeSettings() 不会用它去 seed settings.merged。所以只翻 schema default 在运行时是 no‑op,会造成"对话框显示开启 / 运行时仍关闭"的割裂(即 #5145 的问题)。

本 PR 没有掉进这个陷阱。读取 settings.merged.ui?.useTerminalBuffer 的 runtime gate 恰好两处,且?? false 翻成了 ?? true:

Gate 控制 状态
gemini.tsx:367(useVP) render(..., { alternateScreen: useVP }) ?? false → ?? true
AppContainer.tsx:957(useTerminalBuffer) VP 渲染路径 + refreshStatic;经 uiState.useTerminalBuffer 传给 MainContent/ScrollableList ?? false → ?? true

MainContent.tsx 通过 uiState 继承该值(没有独立读取)。settingsSchema.ts 和 vscode‑ide 的 JSON 默认值也一并翻转,所以设置对话框与运行时保持一致。serve/routes/workspace-settings.ts 那处只是一个 TUI‑only 分类集合(default 字段自动从 schema 派生)。ACP / serve / 非交互路径都不读这个设置 —— 改动仅限 TUI。由于用的是 ??,未设置的 key 会正确落到新默认值,与 merge 是否 seed 无关。

2. 真实 tmux E2E(A/B)✅ —— 核心证据

用了两个 server 端探针,因为它们分别独立地命中两个不同的 gate:#{alternate_on}gemini.tsx,#{mouse_any_flag}AppContainer → ScrollableList。同一个构建,两个隔离的 $HOME,两个 arm 都干净地到达聊天输入框(无 auth 对话框)。

构建 / 配置 #{alternate_on} #{mouse_any_flag} 含义
PR + 设置未设(新默认) 1 1 默认开启 VP
PR + ui.useTerminalBuffer: false(opt‑out) 0 0 旧路径保留
base(在 dist 里还原 PR 改动)+ 未设 0 0 证明这 2 行改动是唯一原因

第三行是 dist 级替换(把两行编译产物 ?? true?? false,$HOME 同样未设),从而把 PR 改动隔离成唯一变量。两个 gate 都验证通过,逃生开关也验证通过。

3. Reviewer 需要权衡的 UX 影响 ⚠️(非 bug)

新默认会进入 alternate screen。已具体演示:在启动打印的 host marker,在 qwen 运行期间被隐藏(alternate_on=1),/quit 之后被还原,且 qwen 界面消失:

运行期间:  alternate_on=1  | qwen banner 可见 | host marker 被隐藏
/quit 之后: alternate_on=0  | qwen banner 消失 | host marker 还原

也就是说退出时整个 qwen 会话会从宿主终端 scrollback 中抹掉(标准的 vim/less 式 alt‑screen 行为)。这是为消除闪屏而做的取舍,文档里的 opt‑out(ui.useTerminalBuffer: false)有效 —— 但对那些退出后还要在终端往回滚、或依赖原生 click‑drag 选择文本的用户来说是个实质变化。建议产品侧明确签字确认。

4. 单元测试 ✅

  • PR 改动的 3 个文件:147 / 147 通过(与 PR 描述一致)。
  • 对 6 个受 default 翻转影响的测试套件做反向审计扫描(MainContentAppDefaultAppLayoutuseResizeSettleRepaintterminalRedrawOptimizerInputPrompt):221 / 221 通过。PR 正确地在共享的 AppContainer fixture 里钉死 useTerminalBuffer: false,使依赖旧路径的测试(如 Terminal resize during streaming leaves fragmented content at wrong widths in scrollback #4891 的 resize‑clearTerminal 契约)继续有效。未发现连带破坏。

5. CI 状态

Test (ubuntu)Test (macos) 通过Test (windows) 失败,但失败项是 gitWorktreeService.symlinks.integ.test.ts(15 中 1 个)外加 exit code 127(shell command‑not‑found)—— 这是 Windows symlink/工具链问题,而该文件本 PR 完全没动(merge‑base 与 PR head 逐字节相同)。不是本 PR 引入的回归。

结论

从正确性角度,本 PR 可以合并:gate 审计干净,两个 runtime fallback 与显示默认值一致翻转,Linux/macOS 测试全绿,真实终端 A/B 同时验证了新默认与 opt‑out。剩下的只是"是否默认进入 alternate screen"的产品决定(见 §3)。Windows CI 的红是既有/无关问题。

Verified by building the PR head and running it under tmux on Linux/Node 22. Probes: #{alternate_on} (gemini.tsx gate) and #{mouse_any_flag} (AppContainer→ScrollableList gate), plus a dist‑level base A/B isolating the PR delta.

@ZevGit

ZevGit commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on the remaining UX point:

Defaulting ui.useTerminalBuffer to true intentionally makes the interactive CLI enter the in-app virtualized history viewport / alternate screen by default. The tradeoff is explicit: it avoids the legacy terminal clear/replay path that causes flicker and scrollbar jumps, but host terminal scrollback is no longer the default history surface while qwen is running.

The compatibility path is preserved: users who prefer native host scrollback can set ui.useTerminalBuffer to false, and the PR tests that this opt-out still avoids alternate screen. The settings description and user docs also call out the mouse-selection bypass and the fact that the in-app viewport replaces native terminal scrollback while enabled.

I do not plan further code changes for this unless maintainers prefer a more conservative rollout strategy than default-on.

CI note: the current Windows red is in packages/core/src/services/gitWorktreeService.symlinks.integ.test.ts (git init -q -b main) plus follow-on workspace exits. That file is outside this PR's changed files, and Linux/macOS plus lint/CodeQL/no-AK smoke are green, so I am treating the Windows failure as unrelated to this PR.

wenshao
wenshao previously approved these changes Jun 23, 2026
@chiga0

chiga0 commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

关于剩余的用户体验要点,需要跟进:

默认设置ui.useTerminalBuffer为 true 会使交互式 CLI 默认进入应用内的虚拟历史记录视口/备用屏幕。这样做的利弊显而易见:它避免了传统终端的清除/重放路径(该路径会导致闪烁和滚动条跳动),但主机终端的滚动条在 qwen 运行时不再是默认的历史记录界面。

兼容性路径得以保留:偏好原生主机滚动回溯的用户可以进行设置ui.useTerminalBufferfalse并且 PR 测试表明,即使选择禁用此功能,也不会出现屏幕切换的情况。设置说明和用户文档也指出了鼠标选择绕过机制,以及启用此功能后应用内视口会替换原生终端滚动回溯的事实。

除非维护人员更倾向于采用比默认启用更保守的推广策略,否则我不打算对此进行进一步的代码更改。

CI 注:当前 Windows 红色警告位于packages/core/src/services/gitWorktreeService.symlinks.integ.test.ts( git init -q -b main) 以及后续工作区退出项中。该文件不在本 PR 的更改文件范围内,而 Linux/macOS 以及 lint/CodeQL/no-AK smoke 检查结果均为绿色,因此我将 Windows 故障视为与本 PR 无关。

hi, thank you for the PR. But I think it's not time to change the default setting of useTerminalBuffer to true, because there is some problems I found with the virtual scroll witch I need to fix first.

  1. the scrollbar unable to scroll when output is larger than viewport with mouse scroll; and can't drag to scroll too.
  2. the mouse event handler is not correct, so the collapsed thinking block cannot be open with mouse click.
  3. other bugs I'm testing which haven't beed found

If you glad with this, you can help to test and welcome pr with this.

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

[Suggestion] docs/design/virtual-viewport/README.md:326 — The paragraph after the PR-sequence table still reads "V.3 (integration tests) is the remaining critical-path item before flipping the default." However, V.3 remains "pending" in the table while this PR flips the default. Future readers will conclude the default was flipped prematurely or that V.3 was silently dropped. Suggest updating to reflect the actual decision, e.g.: "V.3 (integration tests) remains desirable for long-session regression coverage but is no longer a gating prerequisite for the default flip."

— qwen3.7-max via Qwen Code /review

Comment thread packages/cli/src/ui/AppContainer.tsx Outdated
// re-reading `mergedHistory` / `allVirtualItems` on whatever state
// change triggered refreshStatic (Ctrl+O, model change, etc.).
const useTerminalBuffer = settings.merged.ui?.useTerminalBuffer ?? false;
const useTerminalBuffer = settings.merged.ui?.useTerminalBuffer ?? true;

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.

[Suggestion] Accessibility regression for screen-reader users. Flipping the default to true silently routes screen-reader users into the virtualized-viewport path, which drops the append-only <Static> rendering that screen-reader mode is built around.

Chain: with ui.useTerminalBuffer unset, this is now trueuiState.useTerminalBuffer is trueMainContent takes the if (useVirtualScroll) branch (MainContent.tsx:596) and renders <ScrollableList> with no <Static> (the legacy <Static> branch is at :626). Screen-reader users reach this same MainContent — App.tsx routes them to ScreenReaderAppLayout, which itself renders <MainContent />. Ink's screen-reader render path emits committed history to the terminal only via staticOutput (a <Static> node); with no <Static>, history is no longer presented append-only. Separately, gemini.tsx:367 now passes alternateScreen: true for screen-reader + TTY (Ink's resolveAlternateScreenOption doesn't exclude screen-reader mode), entering the alternate screen that assistive tech / native scrollback rely on.

Before this PR (default false) screen-reader users got the <Static> path. Suggest gating the default on screen-reader mode here (and mirror it at gemini.tsx:367 for alternateScreen):

Suggested change
const useTerminalBuffer = settings.merged.ui?.useTerminalBuffer ?? true;
const useTerminalBuffer =
(settings.merged.ui?.useTerminalBuffer ?? true) && !config.getScreenReader();

No test currently covers the screen-reader + unset-useTerminalBuffer combination.

— claude-opus-4-8[1m] via Qwen Code /qreview

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 90ea976.

Screen-reader mode now stays out of the VP and alternate-screen path in both runtime gates: AppContainer keeps uiState.useTerminalBuffer false and startInteractiveUI passes alternateScreen false when config.getScreenReader() is true. Added regression coverage for screen reader mode with unset useTerminalBuffer in AppContainer and gemini.

@ZevGit

ZevGit commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Updated PR head to 90ea976 after #5751 landed.

Handled review items:

  • Rebased onto latest upstream/main, so the merged fix(cli): stabilize VP mouse interactions #5751 mouse scroll, drag, and collapsed-thinking click fixes are now in the base.
  • Screen-reader mode now keeps the legacy append-only Static path and disables alternate screen instead of silently entering VP mode.
  • Updated the virtual viewport design doc V.3 wording and the user-facing docs/schema text for the screen-reader exception.

Verification:

  • cd packages/cli && npx vitest run src/ui/AppContainer.test.tsx src/gemini.test.tsx src/config/settingsSchema.test.ts: 3 files / 150 tests passed.
  • npx eslint on the changed TS/TSX files: passed.
  • npx prettier --check on changed docs/schema JSON: passed.
  • git diff --check: passed.
  • npm run typecheck: passed.

Local build note:

  • npm run build progressed through CLI/schema generation, then failed in packages/sdk-typescript because the browser daemon SDK bundle is 128651 bytes with a 128000-byte limit. That failure is outside this PR changed files.

@wenshao

wenshao commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Build failure root cause — it's a main regression (SDK bundle over its size cap), not this PR

The failing Test legs on all three platforms (run 28069535002) die at the Install dependencies step. npm ci runs the prepare/build lifecycle (husky && npm run build && npm run bundle), and the build throws:

Error: Browser daemon SDK bundle is 128651 bytes; expected <= 128000
npm error workspace @qwen-code/sdk@0.1.8
npm error command sh -c node scripts/build.js

packages/sdk-typescript/scripts/build.js enforces a hard cap MAX_DAEMON_BROWSER_BUNDLE_BYTES = 125 * 1024 = 128000. The browser daemon bundle is now 128651 bytes — 651 over the limit, so the build aborts on every platform and npm ci fails.

Why this is not your PR:

The fix belongs on main, not in this PR: either trim the daemon browser bundle back under 128000, or bump MAX_DAEMON_BROWSER_BUNDLE_BYTES (e.g. to 126 * 1024) in packages/sdk-typescript/scripts/build.js. Once main is green again, rebasing this PR will clear the failure — there is nothing to change here for it.

中文

构建失败根因 —— 是 main 分支的回归(SDK bundle 超过体积上限),不是本 PR

三个平台失败的 Test 都挂在 Install dependencies 步骤(run 28069535002)。npm ci 会跑 prepare/build 生命周期(husky && npm run build && npm run bundle),构建抛错:

Error: Browser daemon SDK bundle is 128651 bytes; expected <= 128000
npm error workspace @qwen-code/sdk@0.1.8
npm error command sh -c node scripts/build.js

packages/sdk-typescript/scripts/build.js 里有硬上限 MAX_DAEMON_BROWSER_BUNDLE_BYTES = 125 * 1024 = 128000。现在 browser daemon bundle 是 128651 字节,超了 651 字节,于是构建在每个平台都中止,npm ci 失败。

为什么判定与本 PR 无关:

修复应该在 main,不在本 PR: 要么把 daemon browser bundle 压回 128000 以内,要么在 packages/sdk-typescript/scripts/build.js 里上调 MAX_DAEMON_BROWSER_BUNDLE_BYTES(例如调到 126 * 1024)。等 main 重新变绿后,把本 PR rebase 一下就能消除这个失败 —— 本 PR 自身不需要改动。

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

[Suggestion] packages/cli/index.ts (not in diff) — The uncaughtException handler calls process.exit(1) without runExitCleanup(), so instance.unmount() (which writes exitAlternateScreen) never runs. Pre-existing bug, but now that VP is the default and all users enter alt-screen, any crash leaves the terminal stuck in alt-screen with no visible prompt (user must run reset or close the tab).

Fix: add a process.on('exit') handler that unconditionally writes \x1b[?1049l as a safety net, or call runExitCleanup() in the uncaughtException handler.

CI note: 3 Test jobs failing (ubuntu, macos, windows) — pre-existing, not caused by this PR.

— qwen3.7-max via Qwen Code /review

Comment thread packages/cli/src/gemini.tsx Outdated

const useVP = settings.merged.ui?.useTerminalBuffer ?? false;
const useVP =
(settings.merged.ui?.useTerminalBuffer ?? true) &&

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.

[Suggestion] TOCTOU between one-shot alternateScreen and per-render useTerminalBuffer: useVP is computed once here and passed to ink's render(), locking the terminal into alt-screen for the entire session. But AppContainer.tsx:958 recomputes useTerminalBuffer on every render from live settings.merged. If a user toggles useTerminalBuffer off mid-session (the schema declares requiresRestart: false), the React tree switches to the legacy <Static> path while ink stays in alt-screen — history is silently written to the alt-buffer and lost on exit.

Pre-existing, but the default flip makes this the common path. Consider either locking useTerminalBuffer at startup (matching alternateScreen's lifetime), or setting requiresRestart: true so the settings dialog prompts a restart.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 8f5eb98.

The VP decision is now startup-scoped in AppContainer, matching Ink alternateScreen lifetime. ui.useTerminalBuffer is also marked requiresRestart, so SettingsDialog no longer presents this as a live-safe toggle.

Comment thread packages/cli/src/ui/AppContainer.tsx Outdated
// change triggered refreshStatic (Ctrl+O, model change, etc.).
const useTerminalBuffer = settings.merged.ui?.useTerminalBuffer ?? false;
const useTerminalBuffer =
(settings.merged.ui?.useTerminalBuffer ?? true) &&

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.

[Suggestion] The VP-mode guard (settings.merged.ui?.useTerminalBuffer ?? true) && !config.getScreenReader() is duplicated verbatim in gemini.tsx:368. These two sites control different but tightly coupled decisions (ink's alternateScreen vs React rendering path) and must always agree.

Consider extracting a shared helper:

// e.g., packages/cli/src/ui/utils/terminalBuffer.ts
export function shouldUseVirtualViewport(
  settings: LoadedSettings,
  config: Config,
): boolean {
  return (settings.merged.ui?.useTerminalBuffer ?? true) && !config.getScreenReader();
}

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 8f5eb98.

Both runtime gates now use a shared shouldUseVirtualViewport helper, so the alternate-screen decision and React VP path stay coupled.

expect(options).toMatchObject({ alternateScreen: false });
});

it('should not use alternate screen in screen reader mode when VP mode is unset', async () => {

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.

[Suggestion] Test gap: the screen-reader guard is tested when useTerminalBuffer is unset (defaults to true), but not when it's explicitly true. Add a test with useTerminalBuffer: true + getScreenReader(): true to cover the true && !true branch:

it('should not use alternate screen in screen reader mode even when VP mode is explicitly enabled', async () => {
  // useTerminalBuffer: true, getScreenReader: () => true
  // expect alternateScreen: false
});

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Covered in 8f5eb98.

Added the explicit useTerminalBuffer: true + screen-reader case for startInteractiveUI, asserting alternateScreen remains false.

@ZevGit
ZevGit force-pushed the fix/cli-scroll-flicker branch from 90ea976 to 8f5eb98 Compare June 24, 2026 03:28
@ZevGit

ZevGit commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Updated PR head to 8f5eb98 after reviewing the latest comments and CI failure.

What changed:

  • Locked the virtual viewport decision for the running session so it cannot diverge from Ink alternateScreen after a settings update.
  • Marked ui.useTerminalBuffer as restart-required, matching the runtime behavior.
  • Shared the VP decision between gemini.tsx and AppContainer through a small helper.
  • Added a TTY-only exit safety net that restores cursor/alt-screen state if the process exits through an uncaught exception path.
  • Added regression coverage for explicit screen-reader opt-in, startup-mode locking, restart-required schema, and the alt-screen exit fallback.

Verification:

  • cd packages/cli && npx vitest run src/ui/AppContainer.test.tsx src/gemini.test.tsx src/config/settingsSchema.test.ts: 3 files / 153 tests passed.
  • npx eslint on changed TS/TSX files: passed.
  • npx prettier --check on changed docs/schema/helper files: passed.
  • git diff --check: passed.
  • npm run typecheck: passed.
  • npm run build still fails at the known unrelated SDK bundle cap: Browser daemon SDK bundle is 128651 bytes; expected <= 128000, after CLI/schema build has completed.

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

No review findings. LGTM! ✅

All prior suggestions (duplicated VP formula, missing screen-reader test coverage) have been addressed — shared shouldUseVirtualViewport() helper, session-locked VP mode via useState, alternate-screen exit handler, and requiresRestart: true are clean improvements.

— qwen3.7-max via Qwen Code /review

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

CI failed.

@doudouOUC
doudouOUC self-requested a review June 24, 2026 05:15
doudouOUC
doudouOUC previously approved these changes Jun 25, 2026

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

No new code changes since last review (only a merge from main). Re-approving on the updated commit. LGTM! ✅

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code did not run conflict resolution for this request.

PR #5738 comes from ZevGit/qwen-code; this first version only pushes same-repository branches.

@ZevGit

ZevGit commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Synced this branch with upstream main at 69b991aa77 in 66a1499240.

During conflict review, I preserved the current upstream TUI behavior (including transcript streaming context, Kitty protocol handling, mouse selection/click behavior, and startup cleanup) while keeping the VP decision centralized and frozen for the Ink alternate-screen lifetime. The latest upstream also added a live-agent panel row cap; I updated its keyboard-selection path to use the effective uiState.useTerminalBuffer decision instead of the raw setting, so the unset/default-on path cannot address an off-screen row.

Verification on the merged tree:

  • 11 focused test files, 480 tests passed
  • npm run build passed
  • npm run typecheck passed
  • npm run lint passed
  • git diff upstream/main --check passed
  • two clean self-review passes; no Critical correctness issues found

This PR has already gone through more than five review rounds, so I am freezing scope to Critical fixes only. The existing non-blocking suggestions (the configuration reference table default and an optional debug breadcrumb/additional narrow test) are intentionally deferred rather than expanding this PR again. I will monitor the refreshed CI and address only failures attributable to this change.

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

— qwen3.7-max via Qwen Code /review

): boolean {
// The settings loader does not apply schema defaults, so keep this fallback
// in sync with settingsSchema.ts's default for ui.useTerminalBuffer.
return terminalInteractive && (useTerminalBuffer ?? true) && !screenReader;

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.

[Suggestion] The ?? true fallback is a silently-coupled duplicate of settingsSchema.ts's useTerminalBuffer default — Failure scenario: a future maintainer changes the schema default to false (e.g., to stage a rollback) but misses this line; users with no explicit setting still enter VP/alt-screen while the settings dialog shows "Default: false". The test assertion in terminal-buffer.test.ts guards against drift, but the coupling is documentary (comment) rather than structural.

Suggested change
return terminalInteractive && (useTerminalBuffer ?? true) && !screenReader;
const defaultVP = getSettingsSchema().ui.properties.useTerminalBuffer.default as boolean;
return terminalInteractive && (useTerminalBuffer ?? defaultVP) && !screenReader;

— qwen3.7-max via Qwen Code /review

Comment on lines +27 to +31
function isCiEnvironment(env: TerminalEnvironment): boolean {
return Object.keys(env).some(
(key) => isCiEnvKey(key) && isActiveCiValue(env[key]),
);
}

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.

[Suggestion] isCiEnvironment only detects CI via CI, CONTINUOUS_INTEGRATION, and CI_*-prefixed env vars, missing several major CI systems that may not set CI=true (Jenkins: JENKINS_URL/BUILD_NUMBER, TeamCity: TEAMCITY_VERSION, Azure DevOps: TF_BUILD, AWS CodeBuild: CODEBUILD_BUILD_ID) — Failure scenario: a Jenkins pipeline runs qwen inside docker run -it with TTY but no CI env var; isInteractiveTerminal() returns true, Ink enters alt-screen, and the CI log capture misses the entire conversation.

Consider adding vendor-specific CI env var checks:

const VENDOR_CI_KEYS = [
  'JENKINS_URL', 'TEAMCITY_VERSION', 'TF_BUILD',
  'CODEBUILD_BUILD_ID', 'BITBUCKET_BUILD_NUMBER',
];

— qwen3.7-max via Qwen Code /review

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

⚠️ Downgraded from Approve to Comment: CI still running. Reviewed.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Local verification report — default to virtualized terminal history

Built both sides from source and drove the real CLI under a real pty on macOS, focusing on what a default-flip can silently get wrong: a runtime gate left behind, a guard that only exists in the schema, or an interaction that regresses for everyone instead of the opt-in minority.

Result: the implementation is correct and complete. All twelve behavioural cells match intent, the two interaction bugs raised in this thread in June are fixed on the current head, and the 2-gate delta is provably the sole cause of the change. Two items below are worth a conscious call before merge — neither is a defect in this diff.

Environment: PR head 2a097dea8 (unchanged since verification), merge-base 713a083ae. Separate worktrees, own npm ci + npm run build each (no shared node_modules — confirmed every @qwen-code/* entry resolves in-tree and dist/src/ui/utils/terminal-buffer.js exists only in the PR tree). qwen v0.20.1, Node 22.23.1, macOS. Onboarding bypassed with an isolated $HOME; no model requests except where noted.


1 · Gate audit — no schema-only trap

A schema default: is display-only (mergeSettings() does not seed settings.merged from it), so flipping only the schema would be a runtime no-op. This PR does not fall into that trap. Every runtime read now routes through one helper:

Site Reads via Status
startInteractiveUI.tsx:153 shouldUseVirtualViewport(...)render(..., { alternateScreen })
AppContainer.tsx:1122 same helper, locked in useState lazy init, fed by initialUseVirtualViewport
HistoryItemDisplay / InputPrompt / BaseSelectionList / useMouseEvents useVirtualViewport() context
settingsSchema.ts + vscode-ide settings.schema.json default: true, requiresRestart: true

Coupling the two gates through one prop is the right call — alternateScreen is fixed for the lifetime of the Ink instance, so AppContainer's render path must not be able to disagree with it. serve/routes/workspace-settings.ts only classifies the key (TUI-only); no ACP/serve/non-interactive path reads it. StandaloneSessionPicker renders outside the new provider but also outside alternate screen, so its mouse-off default stays correct.

2 · Real A/B matrix — twelve cells, two independent gates

Probes are read straight off the wire: ESC[?1049h (alternate screen ← startInteractiveUI) and ESC[?1002h/?1003h (SGR mouse ← AppContainerVirtualViewportContext). They exercise the two gates independently, so a split between them would show up.

A/B matrix

Every guard is load-bearing, not decorative. Two worth calling out:

  • CI=false correctly stays interactive (alt-screen 1) while CI=true and CI_NAME=buildkite do not. A naive !!process.env.CI would have broken this.
  • Piped stdout is a genuinely reachable case, not just defensive: interactive mode is chosen from process.stdin.isTTY (packages/cli/src/config/config.ts:1714), so qwen > log.txt from a terminal does start the TUI. PR and base produce identical output there (1402 bytes each) — no regression for redirected output.

The base rows are the control: same harness, same unset config, alternate screen off. The delta is the sole cause.

3 · The two blockers raised in this thread — re-tested

Content streamed from a local mock OpenAI server; mouse input delivered as genuine SGR reports into the pty, exactly as a terminal emulator sends them.

mouse interactions

  • Wheel scrolling works: 6 × wheel-up moved the viewport from LINE-036 to LINE-018; wheel-down returned it to exactly LINE-036.
  • Clicking a collapsed thinking block works: ∴ Thought briefly (click or option+t to expand) → expanded with the reasoning text rendered.

Both were listed as reasons to hold the default flip. On this head they behave correctly.

4 · Tests — 276/276 green, and they hold the behaviour in place

All 9 PR-touched test files pass (276 tests). To check the tests are actually load-bearing rather than merely green, I broke one behaviour at a time in the source and re-ran them:

Mutation Result
(useTerminalBuffer ?? true)?? false killed (3 tests)
drop && !screenReader killed (4)
drop terminalInteractive && killed (5)
drop key.startsWith('CI_') killed (1)
treat CI=false as CI killed (1)
drop TERM=dumb check killed (2)
useVirtualViewport fallback overrides context killed (1)
remove the useState session lock survived ⚠️

Finding 1 (test quality, non-blocking). AppContainer.test.tsx"locks terminal buffer mode for the running session" passes unchanged when the useState lock is replaced by a plain recomputed expression. Instrumented to confirm it is a real survivor and not a failed patch: with the lock removed, the asserted value is still true after the settings swap, so the test never observes the un-locked behaviour it is named for.

The lock itself is fine — I verified it in the real app by flipping ui.useTerminalBuffer to false in settings.json mid-session: no alternate-screen exit, no mouse teardown, no render-path switch, UI still healthy. Correct behaviour, consistent with requiresRestart: true. Only the regression test is vacuous.

5 · Exit hygiene with alternate screen on by default

Each run prints host-shell scrollback, launches qwen, terminates it a different way, then prints more shell output. Oracle is xterm's own buffer type — the same state tmux exposes as #{alternate_on}.

exit hygiene

Good news first: /quit, Ctrl+C ×2, and an uncaught exception all restore the terminal cleanly — alternate screen exited, mouse tracking disabled, pre-launch scrollback intact. That specifically disproves the concern raised earlier in this thread that a crash would strand users in the alternate screen; process.exit() still fires the process.on('exit') restore handlers.

Finding 2 (consequence of the default, pre-existing, non-blocking). SIGTERM and SIGHUP do not restore the terminal: the frozen UI keeps the screen, scrollback stays hidden, and SGR mouse reporting is left on so subsequent clicks emit escape bytes into the shell. Reproduced with both node-pty's kill and a real kill(1) against the CLI's own pid.

This is not a regression in this diff — the base build reproduces it identically once ui.useTerminalBuffer: true is set. What the flip changes is the blast radius: opt-in users before, everyone after. It matters for kill, IDE stop buttons, process supervisors and tmux kill-pane; the common exit paths are all clean.

It is also not a one-liner — I tested two candidate fixes and both failed:

  • adding a late process.on('SIGTERM') that writes the restore sequences — no effect;
  • binding this.unmount in ink's signalExit(this.unmount, …) (ink.js:246) — no effect.

The reason ink's own teardown never runs is signal-exit@4, which only fires when it is the sole listener for that signal (listeners.length === count); qwen registers 6 SIGTERM listeners, so it stays passive. Worth its own issue rather than blocking this PR.


Verdict

From a correctness standpoint this is ready to merge: the gate audit is clean, both display and runtime defaults flip consistently, the escape hatch and all four guards (screen-reader, CI, dumb terminal, non-TTY stdout) are verified on a real terminal, tests are green and — with one exception — mutation-proof, and the interaction bugs that motivated holding this back are fixed.

The two open items are (1) a vacuous session-lock test worth tightening, and (2) a pre-existing signal-teardown gap that this flip makes universal. Neither blocks the diff; (2) is the one I'd want tracked before this reaches a release.

中文版(点击展开)

本地验证报告 — 默认开启虚拟化历史视图

从源码构建了 PR 与 base 两侧,并在 macOS 真实 pty 中驱动真实 CLI,重点检查 default 翻转最容易悄悄出错的地方:是否漏掉某个 runtime gate、是否只改了 schema、以及是否让某个交互从"少数 opt-in 用户受影响"变成"所有人受影响"。

结论:实现正确且完整。 12 个行为单元全部符合预期;6 月在本 thread 中提出的两个交互 bug 在当前 head 上已修复;并且已证明这 2 个 gate 的改动是行为变化的唯一原因。下面两项建议在合并前明确确认一次 —— 两者都不是本 diff 的缺陷。

环境: PR head 2a097dea8(验证期间未变动),merge-base 713a083ae。两个独立 worktree,各自 npm ci + npm run build(不共享 node_modules——已确认每个 @qwen-code/* 都解析在本树内,且 dist/src/ui/utils/terminal-buffer.js 只存在于 PR 树)。qwen v0.20.1,Node 22.23.1,macOS。使用隔离 $HOME 跳过 onboarding;除特别说明外不发起模型请求。

1 · Gate 审计 —— 没有掉进 schema-only 陷阱

schema 里的 default: 只用于显示(mergeSettings() 不会用它 seed settings.merged),所以只翻 schema 在运行时是 no-op。本 PR 没有掉进这个陷阱:所有 runtime 读取都收敛到同一个 helper。

位置 读取方式 状态
startInteractiveUI.tsx:153 shouldUseVirtualViewport(...)render(..., { alternateScreen })
AppContainer.tsx:1122 同一 helper,useState 惰性初始化锁定,由 initialUseVirtualViewport 传入
HistoryItemDisplay / InputPrompt / BaseSelectionList / useMouseEvents useVirtualViewport() context
settingsSchema.ts + vscode-ide settings.schema.json default: truerequiresRestart: true

用一个 prop 把两个 gate 耦合起来是正确做法:alternateScreen 在 Ink 实例生命周期内固定,因此 AppContainer 的渲染路径不能与之不一致。serve/routes/workspace-settings.ts 只是把该 key 归类为 TUI-only;ACP / serve / 非交互路径都不读它。StandaloneSessionPicker 渲染在新 provider 之外,但同时也在 alternate screen 之外,所以它的 mouse-off 默认值仍然正确。

2 · 真实 A/B 矩阵 —— 12 个单元,两个独立 gate

探针直接读取 wire 上的字节:ESC[?1049h(alternate screen ← startInteractiveUI)与 ESC[?1002h/?1003h(SGR 鼠标 ← AppContainerVirtualViewportContext)。两者分别命中不同 gate,因此一旦出现割裂会立刻暴露。

其中两点值得特别说明:

  • CI=false 被正确判定为非 CI(alt-screen = 1),而 CI=trueCI_NAME=buildkite 则关闭。如果写成 !!process.env.CI 这里就错了。
  • stdout 被重定向是真实可达的场景,不只是防御性代码:交互模式是根据 process.stdin.isTTY 判断的(packages/cli/src/config/config.ts:1714),所以在终端里执行 qwen > log.txt 确实会进入 TUI。该场景下 PR 与 base 输出完全一致(各 1402 字节),无回归。

base 那两行是对照组:同样的 harness、同样未设置的配置,alternate screen 关闭。证明差异只来自本 PR 的改动。

3 · 本 thread 中提出的两个 blocker —— 重新测试

内容由本地 mock OpenAI 服务流式返回;鼠标事件以真实 SGR 报文写入 pty,与终端模拟器发送的完全一致。

  • 滚轮滚动可用:6 次 wheel-up 把视口从 LINE-036 移到 LINE-018;wheel-down 精确回到 LINE-036
  • 点击折叠的 thinking 块可展开∴ Thought briefly (click or option+t to expand) → 展开并渲染出推理文本。

这两点此前被列为暂缓翻转默认值的理由,在当前 head 上均表现正常。

4 · 测试 —— 276/276 通过,且大部分确实"钉住"了行为

PR 改动的 9 个测试文件全部通过(276 个用例)。为了确认这些测试是真的有约束力而不只是"碰巧绿",我逐个破坏源码中的行为并重跑:

变异 结果
(useTerminalBuffer ?? true)?? false 被杀(3 个用例)
去掉 && !screenReader 被杀(4)
去掉 terminalInteractive && 被杀(5)
去掉 key.startsWith('CI_') 被杀(1)
CI=false 当作 CI 被杀(1)
去掉 TERM=dumb 判断 被杀(2)
useVirtualViewport 让 fallback 覆盖 context 被杀(1)
移除 useState session lock 存活 ⚠️

问题 1(测试质量,不阻塞)。 AppContainer.test.tsx 中的 "locks terminal buffer mode for the running session",在把 useState 锁替换成普通重算表达式后仍然通过。为排除"变异没打上"的可能,我做了插桩确认:移除锁之后,settings 切换后被断言的值依然是 true,也就是说这个测试从未真正观察到它名字所声称的"未锁定"行为。

锁本身没有问题 —— 我在真实应用里验证过:运行中把 settings.jsonui.useTerminalBuffer 改成 false,没有退出 alternate screen、没有关闭鼠标、渲染路径没有切换,UI 正常。行为正确,也与 requiresRestart: true 一致。只是这条回归测试是空转的。

5 · 默认进入 alternate screen 后的退出清理

每次运行都会先打印宿主 shell 的 scrollback,再启动 qwen,用不同方式终止,然后再打印 shell 输出。判据是 xterm 自身的 buffer type —— 即 tmux 的 #{alternate_on}

先说好消息:/quitCtrl+C ×2、以及未捕获异常,三者都能干净地还原终端 —— 退出 alternate screen、关闭鼠标追踪、启动前的 scrollback 完整保留。这一点恰好推翻了本 thread 早前提出的担忧(崩溃会把用户困在 alternate screen):process.exit() 仍会触发 process.on('exit') 上注册的还原逻辑。

问题 2(默认值带来的后果,且为既有问题,不阻塞)。 SIGTERMSIGHUP 不会还原终端:冻结的 UI 继续占据屏幕,scrollback 被隐藏,SGR 鼠标上报仍然开启,之后的点击会把转义字节打进 shell。已分别用 node-pty 的 kill 和对 CLI 真实 pid 执行 kill(1) 复现。

不是本 diff 引入的回归 —— base 构建只要设置 ui.useTerminalBuffer: true 就能一模一样地复现。默认翻转改变的是影响范围:以前只影响 opt-in 用户,之后影响所有人。实际影响场景包括 kill、IDE 停止按钮、进程守护工具和 tmux kill-pane;日常退出路径都是干净的。

同时它不是一行能修好的 —— 我测了两个候选修法,都无效:

  • 在后面追加一个写还原序列的 process.on('SIGTERM') —— 无效;
  • 给 ink 的 signalExit(this.unmount, …)ink.js:246)绑定 this —— 无效。

ink 自身的 teardown 之所以不执行,原因在 signal-exit@4:它只有在自己是该信号的唯一 listener 时才会触发(listeners.length === count),而 qwen 注册了 6 个 SIGTERM listener,所以它保持沉默。建议单开 issue 跟踪,而不是卡住本 PR。

结论

从正确性角度,本 PR 可以合并:gate 审计干净,显示默认值与运行时默认值一致翻转,逃生开关以及四个 guard(screen-reader、CI、dumb terminal、非 TTY stdout)都在真实终端上验证通过,测试全绿且除一处外都能通过变异检验,此前促使暂缓的交互 bug 也已修复。

两个待办项:(1) 一条空转的 session-lock 测试值得收紧;(2) 一个既有的信号清理缺口被这次翻转放大到所有用户。两者都不阻塞本 diff;其中 (2) 建议在发版前有记录地跟踪。

Verified by building both merge-base and PR head in isolated worktrees (independent npm ci), then driving the built CLI under @lydell/node-pty + @xterm/headless on macOS. Probes: raw ESC[?1049h / ESC[?1002h-?1003h off the wire, xterm buffer type as the alternate-screen oracle, a local mock OpenAI server for streamed content, and an 8-mutation matrix against the PR's own tests.

@ZevGit

ZevGit commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on the terminal-teardown findings from the latest real-PTY verification:

I kept both out of this already heavily-reviewed PR so the distinct root causes can be handled and tested cleanly. CI for the current head is green, and the full VP behavior matrix remains verified.

@chiga0 Could you confirm whether #7779 should be fixed before merging this default-on change, or whether both follow-ups should be tracked for the V1 stabilization window? I will keep #5738 scope frozen unless a maintainer classifies one as a merge blocker.

@chiga0

chiga0 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Could you confirm whether #7779 should be fixed before merging this default-on change, or whether both follow-ups should be tracked for the V1 stabilization window?

Both #7779 (Kitty keyboard cleanup ordering) and #7781 (SIGTERM/SIGHUP teardown gap) are pre-existing issues on main — they reproduce with explicit ui.useTerminalBuffer: true on the current merge-base, so they are not introduced by this PR. Neither is a merge blocker; they can be tracked and fixed independently in the V1 stabilization window.

The PR itself has been thoroughly reviewed and verified (multiple real-PTY A/B tests, maintainer sign-off). The only remaining hard blocker is the merge conflict — please rebase onto the latest main and confirm CI is green, then this is good to merge.

@ZevGit

ZevGit commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Synced the branch to upstream/main@fceb75533a via a normal merge commit (d18f38a1db), without rebasing or force-pushing. The code CI is green on this head: Ubuntu Node tests passed in 32m39s and Web Shell E2E passed. The only failed check, review-pr, did not reach checkout or execute review logic; the self-hosted runner failed while cleaning its previous workspace with EACCES: permission denied, rmdir .../.qwen/skills/agent-reproduce-align (workflow job). I attempted to rerun the failed job, but GitHub requires repository admin permission. @chiga0, could a maintainer please rerun that job or trigger a fresh @qwen-code /review? No code change appears necessary for this infrastructure-only failure.

@chiga0

chiga0 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /takeover

@chiga0

chiga0 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code-ci-bot /review

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🚫 Takeover not engaged: fork takeover requires the PR author to hold write access on this repository (author ZevGit currently: read). A maintainer can adopt the PR instead: snapshot the head into an in-repo branch, open a new PR (commit authorship is preserved), and take that over.

中文说明

🚫 未接管:fork 托管要求 PR 作者在本仓库持有 write 及以上权限(作者 ZevGit 当前为:read)。维护者可改用领养:将 head 快照为本仓库分支并另开 PR(commit 署名保留),再对新 PR 执行接管。

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

Review — APPROVE

Reviewed at HEAD d18f38a. No Critical or Major issues. This PR has been through an unusually thorough review cycle, and I independently confirmed that every previously-raised correctness concern is resolved in the current HEAD, and found no new blockers.

What I verified independently

VP decision is correctly startup-scoped and coupled to Ink's alternateScreen lifetime. startInteractiveUI computes useVP = shouldUseVirtualViewport(...) once, passes it as initialUseVirtualViewport, and feeds Ink's render({ alternateScreen: useVP }) (line 236). AppContainer locks the same value via useState(() => initialUseVirtualViewport ?? shouldUseVirtualViewport(...)) — the ?? correctly respects an explicit false. This eliminates the TOCTOU where a live settings read could desync the React render path from the already-entered alternate screen. requiresRestart: true in the schema matches this semantics.

The safety gates are present and correct. shouldUseVirtualViewport = terminalInteractive && (useTerminalBuffer ?? true) && !screenReader, and isInteractiveTerminal = stdoutIsTTY && !isCiEnvironment && TERM !== 'dumb'. This is the fix for the piped/CI history-loss regression (qwen | tee and CI now stay on the legacy append-only <Static> path). Screen-reader mode is excluded from both the VP path and the alternate screen. CI detection (CI / CONTINUOUS_INTEGRATION / CI_* with active values) is a reasonable heuristic, and the non-TTY check provides a second layer of protection.

Consumers are consistently migrated. All mouse-interaction consumers (useMouseEvents, HistoryItemDisplay, InputPrompt, BaseSelectionList) now read useVirtualViewport(settingsFallback) — context-first with a settings fallback for out-of-provider/test rendering. uiState.useTerminalBuffer is the same startup-locked state variable (AppContainer line 1160 → uiState literal line 4268), so InputPrompt's liveAgentPanelMaxRows agrees with the context value.

Test coverage is solid. terminal-buffer.test.ts covers the shouldUseVirtualViewport truth table and isInteractiveTerminal (TTY/dumb/CI). The drift-guard assertion now reads getSettingsSchema()...useTerminalBuffer.default, so a schema/runtime default divergence fails the test rather than relying on the comment. The clearCiEnv test utility correctly saves/restores CI env vars so VP tests don't false-fail on GitHub Actions.

Cross-Validation

Prior finding Status My check
Screen-reader accessibility regression (dropped <Static>) Fixed (90ea976) Confirmed — !screenReader gate in the shared helper
TOCTOU: live useTerminalBuffer vs one-shot alternateScreen Fixed (8f5eb98) Confirmed — startup-locked useState, requiresRestart: true
Piped/CI sessions lose history (ink emits only last frame) Fixed (8632a62) Confirmed — isInteractiveTerminal gate present in both call sites
Redundant/harmful alternate-screen exit handler (aborts later exit listeners) Fixed (604e733, removed) Confirmed — no PR-owned exit listener remains
?? true dual source of truth with schema default Mitigated (8632a62) Confirmed — test now pins against the schema default
Tests false-fail in CI (isTTY=true but CI env set) Fixed (ci-env.ts + 975d216/81daecf) Confirmed — clearCiEnv utility present
debugLogger breadcrumb for VP decision Open (non-blocking) Agree it's discretionary; not a correctness issue
ThinkingViewer useAlternateScreen test Declined by author Reasonable — value derives from the covered startup-locked state

Minor observations (non-blocking)

  • InputPrompt reads uiState.useTerminalBuffer for liveAgentPanelMaxRows but useVirtualViewport(...) for mouseInteractionsEnabled. Both resolve to the same value in-app; the mixed access pattern is only a readability nit.
  • The useTerminalBuffer setting name remains counterintuitive (true = virtualized viewport, not host scrollback), but that naming is pre-existing and out of scope here.

@chiga0

chiga0 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Synced the branch to upstream/main@fceb75533a via a normal merge commit (d18f38a1db), without rebasing or force-pushing. The code CI is green on this head: Ubuntu Node tests passed in 32m39s and Web Shell E2E passed. The only failed check, review-pr, did not reach checkout or execute review logic; the self-hosted runner failed while cleaning its previous workspace with EACCES: permission denied, rmdir .../.qwen/skills/agent-reproduce-align (workflow job). I attempted to rerun the failed job, but GitHub requires repository admin permission. @chiga0, could a maintainer please rerun that job or trigger a fresh @qwen-code /review? No code change appears necessary for this infrastructure-only failure.

hi, cloud you please solve the CI failure?

@chiga0
chiga0 enabled auto-merge July 28, 2026 12:49

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

CI failure is EACCES infrastructure issue on ecs-qwen-runner-sg-12, not a code problem. Fix in PR #7931.

@chiga0
chiga0 added this pull request to the merge queue Jul 28, 2026
Merged via the queue into QwenLM:main with commit ad1b5f3 Jul 28, 2026
39 of 40 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.1.

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.

8 participants