Skip to content

fix(tui): skip cross-group tool merge in <Static> mode to eliminate screen flash#4795

Merged
tanzhenxin merged 9 commits into
QwenLM:mainfrom
zzhenyao:fix/compact-mode-merge-flash
Jun 8, 2026
Merged

fix(tui): skip cross-group tool merge in <Static> mode to eliminate screen flash#4795
tanzhenxin merged 9 commits into
QwenLM:mainfrom
zzhenyao:fix/compact-mode-merge-flash

Conversation

@zzhenyao

@zzhenyao zzhenyao commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Fixes the full-screen flash that occurs in compact mode every time a batch of tool calls completes:

  1. Skip data-level merge in <Static> mode: When useTerminalBuffer is false, mergedHistory returns the raw history array unchanged, so <Static> never sees a key count change. The merge still runs in VP mode where items can be updated in place.
  2. Remove the refreshStatic detection useEffect: With no merge happening, the length-mismatch detector that called refreshStatic() is dead code. Removing it also eliminates unnecessary re-renders during streaming.
  3. Add compactInline setting: A new boolean (default false) for future use — when enabled, skips cross-group merging even in VP mode. Read from settings at startup, passed through CompactModeContext. Not toggled by Ctrl+O.

HistoryItemDisplay also gets a small cleanup: the isHiddenInCompact early-return now runs before the useMemo for itemForDisplay, avoiding unnecessary work for items that won't render.

Fixes: #4794 #4561 #3979

Why it's needed

In compact mode, mergeCompactToolGroups merges consecutive tool groups into fewer items. This changes the history array length. Ink's <Static> component is append-only — when the number of keys changes, it can't do its normal incremental update.

A useEffect in MainContent.tsx detected the length mismatch and called refreshStatic(), which writes clearTerminal to stdout and remounts the entire <Static> tree. This didn't prevent the flash.

Reviewer Test Plan

Evidence (Before & After)

Before:

ui_1

After:

ui_2

How to Verify

  1. Build and run

    npm run build
    npm start
  2. Trigger sequential tool calls in compact mode

    • Enable compact mode: Ctrl+O
    • Run: Do not think. First run ls packages/cli/src/ui/components/ to see what files are there, then read the first 3 lines of the first file, and finally use wc -l to count the total lines of that file.
    • Verify: no full-screen flash when tools complete
    • Verify: each tool group renders independently
  3. Regression

    • Non-compact mode: all functionality works as before
中文

此 PR 做了什么

修复紧凑模式下每批工具调用完成时的全屏闪烁:

  1. <Static> 模式下跳过数据层合并:当 useTerminalBuffer 为 false 时,mergedHistory 直接返回原始 history 数组,<Static> 不会感知到 key 数量变化。VP 模式下合并仍然生效(因为 VP 可以原地更新 item)。
  2. 移除 refreshStatic 检测 useEffect:既然不再合并,检测数组长度不匹配并调用 refreshStatic() 的代码就是死代码。移除后也减少了流式输出期间的不必要重渲染。
  3. 新增 compactInline 设置:新的布尔值(默认 false),为后续使用预留——启用后即使在 VP 模式下也跳过跨组合并。从 settings 读取,通过 CompactModeContext 传递,不受 Ctrl+O 切换。

HistoryItemDisplay 也有一个小清理:isHiddenInCompact 的提前返回现在移到了 itemForDisplayuseMemo 之前,避免对不会渲染的 item 做不必要的计算。

为什么需要

紧凑模式下,mergeCompactToolGroups 将连续的工具组合并为更少的 item。这改变了 history 数组的长度。Ink 的 <Static> 组件是 append-only 的——当 key 数量变化时,无法做正常的增量更新。

MainContent.tsx 中的 useEffect 检测到长度不匹配后调用 refreshStatic(),该函数向 stdout 写入 clearTerminal 并重新挂载整个 <Static> 树。

Reviewer 测试计划

证据(修改前 vs 修改后)

修改前

  • 紧凑模式开启:每批工具调用完成时,整个屏幕清空重绘

修改后

  • 紧凑模式开启:工具组独立渲染,无全屏重绘

如何验证

  1. 构建并运行

    npm run build
    npm start
  2. 在紧凑模式下触发串行工具调用

    • 开启紧凑模式:Ctrl+O
    • 运行:不要思考,先运行 ls packages/cli/src/ui/components/ 看有哪些文件,然后读取其中第一个文件的前3行,最后用 wc -l 统计该文件总行数
    • 验证:工具完成时无全屏闪烁
    • 验证:每个工具组独立渲染
  3. 回归测试

    • 非紧凑模式:所有功能正常

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Hey @zzhenyao — noting this is still a draft, so sharing gate findings early for discussion.

Template mostly solid ✓ — "Tested on" OS table and "Risk & Scope" section from the template are missing; easy to fill in before marking ready.

On direction: This solves a real, multi-reported pain point — compact mode screen flash has been filed at least three times (#4794, #4561, #3979). Clearly within scope. No direct Claude Code CHANGELOG hit for "compact mode flicker" specifically, but compaction-related rendering fixes are common territory. The fix addresses the root cause cleanly: Static can't handle key-count changes, so don't give it key-count changes.

On approach: The core fix (skip merge in Static mode + remove the dead refreshStatic useEffect) is correct and focused. But compactInline is a concern — it's a new setting plumbed through settingsSchema, CompactModeContext, AppContainer, MainContent, settings.schema.json, and 4 test files, yet the actual within-group compact rendering isn't implemented. Per AGENTS.md "Simplicity First" — minimum code that solves the problem, nothing speculative.

Have you considered deferring compactInline to the follow-up PR that implements the rendering? Without it, the core fix is ~3 files (MainContent.tsx, HistoryItemDisplay.tsx, and their tests) instead of 10, and ~40 lines instead of ~120. The merge-skip condition just becomes !uiState.useTerminalBuffer — no new context field, no new setting, no test churn for the new context shape.

Flagging this before diving into code review. The fundamental approach is sound — just want to see if we can ship the fix without the future flag. 🎯

中文说明

@zzhenyao — 注意到这还是个 draft,先分享 Stage 1 的发现供讨论。

模板基本完整 ✓ — 缺少 模板 中的 "Tested on" 操作系统表格和 "Risk & Scope" 部分,标记 ready 前补上即可。

方向: 解决了一个真实且多次报告的问题 — 紧凑模式闪屏至少在 #4794#4561#3979 中被提过。完全在项目范围内。Claude Code 的 CHANGELOG 没有直接关于"紧凑模式闪烁"的条目,但压缩相关的渲染修复是常见领域。修复方式直击根因:Static 无法处理 key 数量变化,那就别给它 key 数量变化。

方案: 核心修复(Static 模式下跳过合并 + 移除失效的 refreshStatic useEffect)正确且聚焦。但 compactInline 值得商榷 — 一个新设置穿透了 settingsSchema、CompactModeContext、AppContainer、MainContent、settings.schema.json 和 4 个测试文件,但实际的组内紧凑渲染并未实现。按 AGENTS.md "Simplicity First" — 解决问题的最少代码,不做投机性开发。

有没有考虑把 compactInline 推迟到实现渲染的后续 PR?去掉它后,核心修复只需 ~3 个文件(MainContent.tsx、HistoryItemDisplay.tsx 及其测试)而非 10 个,~40 行而非 ~120 行。合并跳过条件简化为 !uiState.useTerminalBuffer — 无需新的 context 字段、新设置、测试改动。

在深入代码审查前先提出这个。根本方案是对的 — 只是想看看能不能不挂未来标记就发布修复。🎯

Qwen Code · qwen3.7-max

@zzhenyao

zzhenyao commented Jun 5, 2026

Copy link
Copy Markdown
Contributor Author

Have you considered deferring compactInline to the follow-up PR that implements the rendering? Without it, the core fix is ~3 files (MainContent.tsx, HistoryItemDisplay.tsx, and their tests) instead of 10, and ~40 lines instead of ~120. The merge-skip condition just becomes !uiState.useTerminalBuffer — no new context field, no new setting, no test churn for the new context shape.

Good point on simplicity. The reason I kept compactInline in this PR rather than deferring: it's the rollback valve. The cross-group merge behavior is the current default — some users may find it works fine in VP mode or prefer the merged view. Shipping the fix without a way to toggle back means we're permanently removing a feature.


compactInline : false (default) = existing behavior preserved, no behavior change for anyone.



@zzhenyao zzhenyao marked this pull request as ready for review June 5, 2026 06:28
@zzhenyao zzhenyao changed the title fix(cli): skip cross-group tool merge in <Static> mode to eliminate screen flash fix(tui): skip cross-group tool merge in <Static> mode to eliminate screen flash Jun 5, 2026
@zzhenyao

zzhenyao commented Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

@wenshao Hi, would you have time to take a look at this PR?

Reproduction steps for the flash:

From the qwen-code repo root, run npm run build and npm start. In the CLI press Ctrl+O to turn on compact mode, then paste the following prompt as-is and send:

Do not think. First run ls packages/cli/src/ui/components/ to see what files are there, then read the first 3 lines of the first file, and finally use wc -l to count the total lines of that file.

You will see the screen clear and re-render every time a batch of tool calls finishes. The flash gets worse with longer history because refreshStatic clears and remounts the entire Static tree.

The root cause is that mergeCompactToolGroups reduces the number of items in the history array, and Ink Static is append-only so it can not handle key-count changes incrementally. Skipping the cross-group merge in Static mode avoids the length change entirely, so no flash.

xaelistic pushed a commit to xaelistic/qwen-code that referenced this pull request Jun 7, 2026
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>

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

Two additional observations that could not be anchored to diff lines:

Stale comments referencing removed refreshStatic heuristic. The comment at MainContent.tsx:172-173 ("Summaries for absorbed call IDs are dropped during merge so refreshStatic fires") and two comments in mergeCompactToolGroups.ts:177,201 still reference the refreshStatic detection mechanism that this PR removes. These should be updated to reflect the new approach (skip merge entirely rather than detect-and-refresh).

No tests for new behavior. All test changes are mechanical (compactInline: false in existing providers). No test exercises compactInline: true or useTerminalBuffer: false with compactMode: true to verify that mergeCompactToolGroups is correctly skipped. Since compactInline is described as "for future use," this may be intentional, but consider adding at least one regression test for the !useTerminalBuffer path — that is the core bug fix.

— qwen3.7-plus via Qwen Code /review

Comment thread packages/cli/src/config/settingsSchema.ts Outdated
Comment thread packages/cli/src/ui/components/MainContent.tsx Outdated
Comment thread packages/cli/src/ui/components/HistoryItemDisplay.tsx
zzhenyao added 5 commits June 8, 2026 07:53
…ression test

- Remove references to deleted refreshStatic heuristic in MainContent.tsx and mergeCompactToolGroups.ts, reflecting the new skip-merge approach
- Add regression test for compactMode + !useTerminalBuffer path to ensure consecutive tool_groups are not merged in Static mode
compactInline is read via useState with no dynamic setter, so changes
require a CLI restart to take effect.
- Set compactInline requiresRestart to true (no dynamic setter exists)
- Split combined skip-merge condition into two distinct branches: !useTerminalBuffer (bug fix for Static append-only flash) and compactInline (user preference for per-group display)
- Set compactInline requiresRestart to true (no dynamic setter exists)
- Split combined skip-merge condition into two distinct branches: !useTerminalBuffer (bug fix for Static append-only flash) and compactInline (user preference for per-group display)
- Gate absorbedCallIds on useTerminalBuffer && !compactInline so that standalone summary labels render in Static mode where <Static> cannot repaint committed items
Comment thread packages/cli/src/ui/components/MainContent.test.tsx
Comment thread packages/cli/src/ui/components/MainContent.tsx
Comment thread packages/cli/src/ui/components/MainContent.test.tsx

@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] Stale comment in mergeCompactToolGroups.test.ts:397 references the removed refreshStatic heuristic: "MainContent's refreshStatic heuristic would never fire". The useEffect was removed in this PR. This line is outside the PR diff so it could not be anchored inline. Suggested update: "the summary is consumed by the compact header and dropped from merged output to avoid double-displaying the label."

Other Suggestion-level findings (consolidate 3 early-return guards, compactInline silent no-op without compactMode, compactInline/isHiddenInCompact test gaps) are already covered by existing open inline comments from prior rounds and are not repeated here.

— qwen3.7-plus via Qwen Code /review

@wenshao

wenshao commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Local Verification Report

Branch: fix/compact-mode-merge-flashmain
Environment: macOS Darwin 25.4.0, Node.js local

TypeScript Compilation (tsc --noEmit)

Package PR Branch main Status
packages/core 0 errors 0 errors ✅ Clean
packages/cli 106 errors 106 errors ✅ No new errors (identical)

Unit Tests (vitest)

Test File PR Branch main Status
MainContent.test.tsx 12 passed 11 passed ✅ All pass (+1 new test)
ToolGroupMessage.test.tsx 32 passed ✅ All pass
ToolMessage.test.tsx 35 passed ✅ All pass
SettingsDialog.test.tsx 53 passed ✅ All pass

New test verifies that two consecutive tool_group items are NOT merged in <Static> mode (compactMode=true, useTerminalBuffer=false), confirming 5 items (3 prefix + 2 raw) instead of the pre-fix 4 (3 prefix + 1 merged).

Code Review

Problem: Ink's <Static> component is append-only — once items are committed to the terminal buffer, they can't be updated. When compact mode merged consecutive tool groups (reducing item count), the old code called refreshStatic() / clearTerminal to force a full re-render, causing visible screen flash.

Fix — two-pronged approach:

  1. Skip cross-group merge in <Static> mode (MainContent.tsx):

    • When !useTerminalBuffer (Static path), mergeCompactToolGroups() is bypassed entirely — raw history items are passed through unchanged
    • When compactInline is enabled, same bypass applies
    • The refreshStatic() / clearTerminal heuristic (useEffect block) is removed entirely — no longer needed since merges don't happen in Static mode
  2. Hide absorbed items at render level (HistoryItemDisplay.tsx):

    • gemini_thought, gemini_thought_content, and absorbed tool_use_summary items return null in compact mode
    • This replaces the previous approach of filtering them at the merge level
  3. New compactInline setting (settingsSchema.ts, CompactModeContext.tsx, AppContainer.tsx):

    • ui.compactInline: boolean (default false) — compact display within each group without cross-group merging
    • Exposed in settings dialog and VS Code schema

Summary line accounting in absorbedCallIds:

  • When no cross-group merge will happen (!useTerminalBuffer || compactInline), returns EMPTY_ABSORBED_CALL_IDS so standalone ● <label> lines can render — correct because <Static> can't repaint committed items

Verdict

Ready to merge — No regressions. All 132 affected tests pass across 4 test files. TSC error count identical to main. Clean fix that eliminates the screen flash root cause rather than working around it.

@zzhenyao

zzhenyao commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

@wenshao Thanks for the review and approval!

Comment thread packages/cli/src/ui/components/MainContent.tsx
Comment thread packages/cli/src/ui/components/MainContent.test.tsx
@zzhenyao

zzhenyao commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review! @wenshao R2 comments addressed:

  • Double-display fix: getCompactLabel now guarded to suppress header label when merge is skipped (Static mode / compactInline). Previously the same label rendered twice — once on the compact header via compactLabel, and once as a standalone ● <label> line below.
  • Test coverage: Added test preserves tool_use_summary as standalone line when merge is skipped (Static mode) verifying that when merge is skipped, absorbedCallIds returns empty, isSummaryAbsorbed returns false, and tool_use_summary items pass through as standalone lines (6 Static items = 3 prefix + tool_group + tool_use_summary + tool_group).

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

Re-review of the current diff (verified line-by-line + against CI)

The structural fix is sound: <Static> is append-only, so skipping the item-count-changing merge is the right fix rather than the previous detect-and-refreshStatic heuristic — and dropping that effect also removes its streaming re-render churn.

Prior [Critical] findings are all resolved in the current diff (flagging this so the stale CHANGES_REQUESTED doesn't cause confusion):

  • status: 'completed' → now ToolCallStatus.Success (TS error gone, CI green).
  • Double-display of the summary label → getCompactLabel is now guarded by !useTerminalBuffer || compactInline, symmetric with the absorbedCallIds guard.
  • Dropped async label in default Static compact mode → absorbedCallIds returns empty in Static, so the standalone ● <label> line renders as a clean append.
  • requiresRestarttrue; stale refreshStatic comments updated.

The Ctrl+O repaint path is independent of the removed effect (it goes through compactToggleHasVisualEffect → refreshStatic in AppContainer), so toggling compact mode in Static still repaints correctly. CI is green on macOS / Ubuntu / Windows.

The one thing I'd still push on: compactInline scope + test gap

compactInline only changes behavior in VP mode. In mergedHistory, if (!useTerminalBuffer) return raw short-circuits first, so on the default Static path compactInline is a silent no-op — it only matters when useTerminalBuffer=true. Two consequences:

  1. Zero test coverage for the path it actually affects. Consider a { compactMode: true, compactInline: true, useTerminalBuffer: true } test asserting two consecutive tool_groups are not merged (the mirror of the new Static-mode test).
  2. The setting description says "Requires compactMode to be enabled" but not that it also requires useTerminalBuffer; a user enabling it in the default config sees no change.

Since it's a "for future use" flag, splitting it into a follow-up PR — keeping this one focused on the flash fix — is also a fine option.

Minor (non-blocking)

  • The PR description says the isHiddenInCompact early-return "runs before the useMemo"; in the code it runs after useMemo(escapeAnsiCtrlCodes) (correctly — moving it earlier would break the Rules of Hooks). So the memo still runs for hidden items; worth fixing the wording.
  • The tool_use_summary JSX guard (!compactMode || !summaryAbsorbed) is now always-true once the early-return handles the compactMode && summaryAbsorbed case — harmless, can be simplified.
  • Release-notes note: default (Static) compact mode now loses cross-group merging; the summary label appears as a standalone ● <label> append line instead of in the merged header. Intentional, but a visible change for default-config users.

Verdict

Approve with minor suggestions — the core fix is correct and verified; the only substantive remaining item is the compactInline scope / test gap above.

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

Approving — core flash fix is correct and verified (all prior Criticals resolved, CI green on all three platforms). The remaining items from my re-review (compactInline test gap / VP-only scope, description wording, redundant guard) are non-blocking nits; feel free to fold the compactInline test into this PR or a follow-up.

@LaZzyMan

LaZzyMan commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

@zzhenyao One more thing — taking a look at your contribution history, You have contributed several high-quality issues and pull requests to the qwencode project. Really appreciate the open-source work!

If you'd like, drop a shipping address at pomelo.lcw@alibaba-inc.com and we'd love to send you some Qwen swag as a small thank-you.

@zzhenyao

zzhenyao commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @LaZzyMan for the review and approve! 🙏

Regarding the missing compactInline tests: this PR is scoped as a quick fix for the TUI screen flash (Static path). The compactInline parameter was added only to avoid regressions and preserve the original cross-group merge behavior (VP with compactInline: true skips cross-group merging). Test coverage and docs for compactInline will be added in a follow-up PR to avoid blocking the main fix.

Also, the shipping address has been sent to your email — please check. Thanks again!


if (item.type !== 'tool_group' || item.tools.length === 0)
return undefined;
// When merge is skipped (Static mode or compactInline), tool_groups
// render via the full ToolGroupMessage path which ignores compactLabel.

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] Misleading comment: states "tool_groups render via the full ToolGroupMessage path which ignores compactLabel," but CompactToolGroupDisplay does use compactLabel (line 166-167: compactLabel ? renderSummaryHeader(...) : renderDefaultHeader(...)). The label doesn't appear because getCompactLabel returns undefined here, not because the rendering path ignores the prop. A future maintainer reading this comment might conclude compactLabel is unused and pass a value without realizing it would cause double-display.

Suggested change
// render via the full ToolGroupMessage path which ignores compactLabel.
// When merge is skipped (Static mode or compactInline), return undefined
// so CompactToolGroupDisplay falls back to its default "Tool × N" header.
// The standalone `● <label>` line in HistoryItemDisplay is the label's
// only path to the screen; providing compactLabel here would cause
// double-display.
if (!uiState.useTerminalBuffer || compactInline) return undefined;

— qwen3.7-max via Qwen Code /review

// 3 prefix items (header / debug / notifications) + 2 raw history items
// The 2 tool_groups should NOT be merged into 1.
expect(staticItemsSpy.mock.calls.at(-1)?.[0]).toHaveLength(5);
// Verify both tool_group ids are present via historyItemDisplayPropsSpy.

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] This test (and the preceding one at line 585) asserts item count and IDs via historyItemDisplayPropsSpy, but never asserts the compactLabel prop value. The new getCompactLabel early-return at MainContent.tsx:273 (if (!uiState.useTerminalBuffer || compactInline) return undefined) is therefore untested. If a future change accidentally makes getCompactLabel return a value in Static mode, the label would double-display — but these tests would still pass.

Consider adding:

// Verify compactLabel is undefined for all rendered tool_groups in Static mode.
const compactLabels = historyItemDisplayPropsSpy.mock.calls
  .map((call) => call[0].compactLabel)
  .filter((label) => label !== undefined);
expect(compactLabels).toHaveLength(0);

— qwen3.7-max via Qwen Code /review

@zzhenyao

zzhenyao commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the R3 review @wenshao!

The two suggestions (comment fix + compactLabel test assertion) are small but valid. Can I address them in a follow-up PR to keep this one focused on the flash fix?


@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 issues found. LGTM! ✅ — qwen3.7-max via Qwen Code /review

@tanzhenxin tanzhenxin added the type/bug Something isn't working as expected label Jun 8, 2026
@tanzhenxin tanzhenxin merged commit 59051b1 into QwenLM:main Jun 8, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BUG: Compact mode tool merge causes full-screen flash on every tool batch

5 participants