Skip to content

fix(cli): clip live markdown to the viewport to stop non-VP scrollback replay - #6081

Merged
wenshao merged 9 commits into
QwenLM:mainfrom
chiga0:fix/non-vp-pending-overflow-clip
Jul 1, 2026
Merged

fix(cli): clip live markdown to the viewport to stop non-VP scrollback replay#6081
wenshao merged 9 commits into
QwenLM:mainfrom
chiga0:fix/non-vp-pending-overflow-clip

Conversation

@chiga0

@chiga0 chiga0 commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Stops the whole transcript from re-scrolling top→bottom in non-VP (default) mode — most visibly when a long task runs in a terminal-multiplexer (tmux/cmux) tab and you switch away and back.

Bounds the live (pending) markdown message height to the viewport budget with maxHeight + overflow="hidden".

Root cause

In non-VP mode the committed transcript lives in ink's <Static> region; only the live/pending items form the dynamic (non-<Static>) frame. A long streaming assistant message rendered all of its lines — code blocks already self-truncate while pending, but plain prose / lists / tables had no overall height cap — so the dynamic frame grew past the terminal height.

Once the dynamic frame exceeds the viewport, ink's renderInteractiveFrame takes its overflow path and writes:

clearTerminal + this.fullStaticOutput + output

i.e. it clears the screen+scrollback and re-streams the entire transcript on every repaint — which during streaming is every token. While the tab is focused this is continuous flicker/scroll; in a multiplexer the backgrounded tab keeps doing it, so switching back replays the transcript top→bottom for a while before settling.

(This is the same shouldClearTerminalForFrame mechanism as #5798/#6015; #6015 windowed the agent panel — this caps the streaming message, the other unbounded live content.)

The fix

MarkdownDisplay, when isPending and a height budget is known (constrainHeight on, non-VP), wraps the rendered content in:

<Box flexDirection="column" maxHeight={availableTerminalHeight} overflow="hidden">

maxHeight clips only when the content is genuinely too tall (short messages render unpadded), keeping the dynamic frame within the viewport so the overflow path never fires. The full message still renders uncapped once it commits to <Static>.

Reviewer Test Plan

End-to-end (the live symptom), verified

Preloaded a process.stdout.write hook (logs writes containing \x1b[2J) on the real built CLI and ran it in cmux; sent a long pure-text streaming prompt (print 1..400):

build full-transcript replays during streaming
before 239 / ~7s (byte size growing with the message)
after (this PR) 0

Output still commits in full to <Static>; replays only ever fired while the pending frame overflowed.

Automated

npx vitest run packages/cli/src/ui/utils/MarkdownDisplay.test.tsx
# 105 passed (incl. new: long pending message clipped to availableTerminalHeight;
#                       short pending message not padded)

Adjacent suites green (HistoryItemDisplay, ConversationMessages). eslint + tsc clean.

Risk & UX

  • Only affects the live/pending markdown frame in non-VP; the committed <Static> render is unchanged, and tool output (already MaxSizedBox-bounded) is unaffected.
  • During streaming a message taller than the viewport, the live preview is now clipped to the budget (consistent with how pending code blocks already truncate); the complete message appears in scrollback the moment it commits. VP mode is unaffected.

Linked

Refs #5798

🤖 Generated with Qwen Code

@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

(Re-run — current diff: 2 files, +203/−1, contiguous head-slice approach, head 1cf52d80d)

Thanks for the PR, @chiga0!

Template looks good ✓ — all required sections present with variant headings ("Root cause" for "Why it's needed", "Risk & UX" for "Risk & Scope").

On direction: squarely in scope. The non-VP scrollback replay in terminal multiplexers is a real, severe rendering bug — ink's shouldClearTerminalForFrame fires on every streamed token when the pending frame overflows, causing the entire transcript to re-stream. Same family as #5798/#6015. CHANGELOG has prior entries in this area (e.g. "prevent scroll snap-back and flicker in non-VP mode" #5799).

On approach: minimal and focused — 2 files, +203/−1, targets exactly the unbounded live content path. This version uses contiguous head-slicing (allLines.slice(0, pendingLineBudget)) with a 2-row reservation for the "generating more" cue and inner block budgets — directly addressing the decimation concern from the earlier overflow="hidden" revision. The MainContent.tsx backstop from earlier iterations was reverted, keeping the change tightly scoped to MarkdownDisplay. No scope creep.

Moving on to code review. 🔍

中文说明

(重新运行 — 当前 diff:2 文件,+203/−1,连续头部切片方案,head 1cf52d80d

感谢贡献,@chiga0

模板完整 ✓(标题使用了变体名称——"Root cause" 代替 "Why it's needed","Risk & UX" 代替 "Risk & Scope")。

方向:完全在范围内。终端复用器中的非 VP 回滚重播是一个真实且严重的渲染 bug——当 pending 帧溢出时,ink 的 shouldClearTerminalForFrame 在每个流式 token 上触发,导致整个 transcript 被重新推送。与 #5798/#6015 属于同一类问题。CHANGELOG 中有该领域的历史条目(如 "prevent scroll snap-back and flicker in non-VP mode" #5799)。

方案:最小且聚焦——2 个文件,+203/−1,精确针对无界实时内容路径。这个版本使用了连续头部切片allLines.slice(0, pendingLineBudget)),并预留 2 行空间给 "generating more" 提示和内部块预算——直接解决了早期 overflow="hidden" 版本的抽稀问题。早期迭代中的 MainContent.tsx 聚合兜底已被回退,改动严格限于 MarkdownDisplay。没有范围蔓延。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

(Re-run on head 1cf52d80d — contiguous head-slice approach)

2a. Code review

Independent proposal (before reading the diff): given the root cause — pending markdown renders all lines, overflows ink's non-<Static> frame, triggering shouldClearTerminalForFrame on every token — the fix should bound the live preview height. The cleanest approach is to slice the source lines to a contiguous head before rendering (not clip with ink's overflow="hidden" which decimates rows). Gate the clip on isPending so committed <Static> renders are unaffected. Add a "... generating more ..." cue consistent with pending code blocks.

Comparison with the diff: the PR's approach matches this exactly — allLines.slice(0, pendingLineBudget) with isPending gating and a cue. The 2-row reservation (availableTerminalHeight - 2) is a nice touch to prevent double-cue stacking when a code/math block near the boundary hits its own inner truncation. MIN_PENDING_CONTENT_LINES = 1 handles the degenerate floor case cleanly.

Reuse check: MaxSizedBox was considered in earlier iterations but not used here — the head-slice approach is simpler and avoids the row-decimation issue. No duplication with existing utilities.

No critical blockers found. The code is correct, well-commented, and follows project conventions. The new MIN_PENDING_CONTENT_LINES constant is appropriately scoped (not coupled to MaxSizedBox's floor).

2b. Real-scenario testing

Unit tests (worktree, head 1cf52d80d)

MarkdownDisplay.test.tsx: 115/115 pass (incl. 7 new clip tests)
MainContent.test.tsx:     14/14 pass
eslint:                   clean on changed files

New tests cover: long-clip contiguous head, no-pad short messages, committed messages unclipped, code fence at clip boundary, math block double-cue prevention, no-budget passthrough, and degenerate floor. The mutation check is solid — reverting the fix causes the "clips a long pending message" test to fail (as expected).

Tmux E2E (Linux CI — non-VP, tmux 80×30, fake OpenAI SSE)

Ran the dev build from the worktree in tmux with 400-line streaming output (80ms intervals). Tested both with and without the fix (A/B by swapping MarkdownDisplay.tsx to the merge-base version):

BEFORE (main branch MarkdownDisplay.tsx, 400-line stream):
  ESC[2J (clearTerminal): 0
  ESC[3J (clearScrollback): 0
  PTY bytes: 49,626

AFTER (PR head, 400-line stream):
  ESC[2J (clearTerminal): 0
  ESC[3J (clearScrollback): 0
  PTY bytes: 22,092 (2.2× less — normal incremental vs whole-transcript re-stream)

Note: The clearTerminal storm did not reproduce on this Linux CI environment (likely due to ink rendering timing differences vs macOS). However, the PTY byte reduction (49.6 KB → 22.1 KB) confirms the fix reduces output volume — consistent with the PR's mechanism.

Multiple independent verifications by the PR author and maintainer on macOS confirm the storm is eliminated:

Verifier Build ESC[2J count Method
PR author (chiga0) before 239 stdout hook, cmux
PR author (chiga0) after 0 same
Maintainer (wenshao) before 218 stdout hook, tmux 80×30
Maintainer (wenshao) after 0 same
Maintainer (wenshao, 2nd run) before 77 PTY pipe-pane
Maintainer (wenshao, 2nd run) after 0 same

All verifiers confirm the complete message still commits to <Static> in full. ✅

中文说明

(在 head 1cf52d80d 上重新运行 — 连续头部切片方案)

2a. 代码审查

独立方案(阅读 diff 之前):根因是 pending markdown 渲染所有行,导致 ink 的非 <Static> 帧溢出,在每个 token 上触发 shouldClearTerminalForFrame——修复应限制实时预览高度。最简洁的方案是在渲染前对源行做连续头部切片(而非用 ink 的 overflow="hidden" 裁剪,那会抽稀行)。以 isPending 为门控,确保提交的 <Static> 渲染不受影响。添加与 pending 代码块一致的 "... generating more ..." 提示。

与 diff 对比:PR 的方案与此完全吻合——allLines.slice(0, pendingLineBudget)isPending 门控和提示。2 行预留空间(availableTerminalHeight - 2)是精妙之处,可防止当代码/数学块靠近边界时触发自身内部截断而出现双重提示。MIN_PENDING_CONTENT_LINES = 1 干净地处理了退化下限情况。

复用检查:早期迭代中考虑过 MaxSizedBox 但未在此使用——头部切片方案更简单且避免了行抽稀问题。不存在与现有工具的重复。

未发现关键阻塞问题。 代码正确、注释充分、符合项目规范。新增的 MIN_PENDING_CONTENT_LINES 常量作用域恰当(不与 MaxSizedBox 的下限耦合)。

2b. 真实场景测试

单元测试(worktree, head 1cf52d80d

MarkdownDisplay.test.tsx: 115/115 通过(含 7 个新增裁剪测试)
MainContent.test.tsx:     14/14 通过
eslint:                   改动文件干净

新增测试覆盖:长消息连续头部裁剪、短消息不补齐、已提交消息不裁剪、代码围栏在裁剪边界、数学块双提示防止、无预算直通、退化下限。变异检查可靠——回退修复后 "clips a long pending message" 测试会失败(符合预期)。

Tmux E2E(Linux CI — 非 VP, tmux 80×30, 伪 OpenAI SSE)

在 tmux 中用 400 行流式输出(80ms 间隔)从 worktree 运行 dev 构建。分别测试了有修复和无修复(A/B 通过将 MarkdownDisplay.tsx 换为 merge-base 版本):

修复前(main 分支 MarkdownDisplay.tsx,400 行流式):
  ESC[2J(clearTerminal):0
  ESC[3J(clearScrollback):0
  PTY 字节:49,626

修复后(PR head,400 行流式):
  ESC[2J(clearTerminal):0
  ESC[3J(clearScrollback):0
  PTY 字节:22,092(减少 2.2 倍——正常增量 vs 整个 transcript 重流)

说明: clearTerminal 风暴在此 Linux CI 环境中未复现(可能是 ink 渲染时序与 macOS 不同)。但 PTY 字节减少(49.6 KB → 22.1 KB)证实修复减少了输出量——与 PR 的机制一致。

PR 作者和维护者在 macOS 上的多次独立验证确认风暴已消除:

验证者 构建 ESC[2J 次数 方法
PR 作者 (chiga0) 修复前 239 stdout hook, cmux
PR 作者 (chiga0) 修复后 0 同上
维护者 (wenshao) 修复前 218 stdout hook, tmux 80×30
维护者 (wenshao) 修复后 0 同上
维护者 (wenshao, 第2次) 修复前 77 PTY pipe-pane
维护者 (wenshao, 第2次) 修复后 0 同上

所有验证者确认完整消息仍完整提交到 <Static>。✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

(Re-run on head 1cf52d80d)

Stepping back to look at the whole picture:

This PR fixes a real, severe bug — the scrollback replay storm in terminal multiplexers that re-streams the entire transcript on every token during long streaming messages. The root cause analysis is precise (ink's shouldClearTerminalForFrame overflow path), and the fix is the minimum viable change: a contiguous head-slice of source lines, gated on isPending, with a 2-row reservation to avoid double-cue stacking. The committed <Static> output is unaffected.

The approach has evolved well through review iterations. The earlier overflow="hidden" approach decimated rows; this version slices contiguously. The MainContent.tsx aggregate backstop was reverted — the per-item clip is sufficient and simpler. The test suite is thorough (7 new cases covering edge cases like code fences at boundaries and degenerate floors), with mutation testing confirming the assertions are load-bearing.

Three independent verifications (PR author + 2 maintainer runs on macOS) all confirm the storm drops to 0 with no data loss. The PTY byte reduction I observed on Linux CI (49.6 KB → 22.1 KB) is consistent with the mechanism even though the storm itself didn't reproduce on this platform.

Verdict: safe to merge. The fix is focused, well-tested, and verifiably eliminates a severe UX bug without regressions. No concerns that would block shipping.

中文说明

(在 head 1cf52d80d 上重新运行)

退一步看全貌:

这个 PR 修复了一个真实且严重的 bug——终端复用器中的 scrollback 重放风暴,在长流式消息期间每个 token 都会重新推送整个 transcript。根因分析精确(ink 的 shouldClearTerminalForFrame 溢出路径),修复是最小可行改动:对源行做连续头部切片,以 isPending 为门控,预留 2 行空间避免双重提示堆叠。提交的 <Static> 输出不受影响。

方案在审查迭代中不断完善。早期的 overflow="hidden" 方案会抽稀行;这个版本连续切片。MainContent.tsx 聚合兜底已被回退——按项裁剪已足够且更简单。测试套件全面(7 个新用例覆盖代码围栏边界和退化下限等边界情况),变异测试确认断言是承重的。

三次独立验证(PR 作者 + 维护者 2 次在 macOS 上)均确认风暴降至 0 且无数据丢失。我在 Linux CI 上观察到的 PTY 字节减少(49.6 KB → 22.1 KB)与机制一致,尽管风暴本身在此平台上未复现。

结论:可以合并。 修复聚焦、测试充分、可验证地消除了一个严重的 UX bug 且无回归。没有阻塞发布的顾虑。

Qwen Code · qwen3.7-max

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

LGTM, looks ready to ship. ✅

DragonnZhang
DragonnZhang previously approved these changes Jun 30, 2026

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

LGTM. Clean, minimal fix with correct guard conditions (isPending + availableTerminalHeight !== undefined), proper floor via Math.max(MINIMUM_MAX_HEIGHT, ...), and well-established overflow="hidden" pattern already used throughout the codebase. The flexDirection="column" on the wrapper is consistent with all parent layouts. Tests cover both the clipping and non-padding cases.

@wenshao
wenshao added this pull request to the merge queue Jun 30, 2026
@wenshao
wenshao removed this pull request from the merge queue due to a manual request Jun 30, 2026

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

No review findings. Downgraded from Approve to Comment: CI still running.

Clean, minimal fix — the <Box maxHeight overflow="hidden"> guard correctly targets the non-Static frame overflow path that triggers ink's shouldClearTerminalForFrame. Tests cover both the clipping and no-padding cases. tsc + eslint clean, all 105 tests pass.

— qwen3.7-max via Qwen Code /review

@wenshao
wenshao added this pull request to the merge queue Jun 30, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jun 30, 2026
@wenshao

wenshao commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

1 similar comment
@wenshao

wenshao commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

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

LGTM, looks ready to ship. ✅

@wenshao

wenshao commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Local verification report (real binaries · tmux · streaming fake-OpenAI)

I verified this PR end-to-end on macOS by building two real bundled CLIsorigin/main (before) and this PR head 3f39d2e12 (after) — and A/B-testing them in tmux (80×30, non-VP / default mode) against a fake OpenAI SSE endpoint that streams a long plain-text message. A --require preload hook counts every process.stdout.write containing \x1b[2J (= ansiEscapes.clearTerminal, the first bytes of ink's overflow-path write clearTerminal + fullStaticOutput + output).

TL;DR

  • The bug and root cause are real, and the fix eliminates the storm. The "scroll replay" is ink's <Static>-overflow path firing on every streamed token; bounding the pending frame stops it.
  • Automated tests pass (105/105) and the key new test is load-bearing (reverting the fix fails it).
  • No data loss — the full message still commits to scrollback intact.
  • ⚠️ One quality issue (preview-only, non-blocking): the chosen <Box maxHeight overflow="hidden"> makes ink decimate the streaming preview (drops evenly-spaced interior lines) instead of clipping to a contiguous window — so the "consistent with how pending code blocks already truncate" claim doesn't hold. The codebase's own MaxSizedBox (which this PR already imports MINIMUM_MAX_HEIGHT from) clips contiguously.

1) E2E: clearTerminal "scroll replay" storm — before vs after

Real binaries in tmux, one long streaming assistant message (plain prose, no code fence):

build clearTerminal (\x1b[2J) writes during one streaming message
before (origin/main) 90, 87 (two runs)
after (this PR) 0, 0 (two runs)

Before-build log shows each replay re-streaming the whole transcript, with byte size growing as the message grows — a ~25 KB message caused 1.26 MB of cumulative re-writes:

CLEAR #85 bytes=24330 cumBytes=1136498
CLEAR #87 bytes=24802 cumBytes=1186102
CLEAR #89 bytes=25215 cumBytes=1236119   ← byte size grows with the message

After: 0 such writes; the composer stays pinned and the complete message (lines 1…N) commits to scrollback identically to before. This matches your root-cause write-up exactly (shouldClearTerminalForFramewasOverflowing || (isOverflowing && hadPreviousFrame) in node_modules/ink/build/ink.js).

2) Automated tests + mutation

  • vitest run …/MarkdownDisplay.test.tsx105 passed.
  • Mutation (load-bearing check): revert only the source fix, keep the new tests → "clips a long pending message" fails (expected 60 to be less than or equal to 10). ✔️ Confirms the guard works.
  • The implementation correctly uses maxHeight (not height) — a short pending message renders at natural height (1 row), no padding. ✔️ (verified directly)

3) ⚠️ Finding — the clip decimates the live preview (not a contiguous clip)

<Box maxHeight overflow="hidden"> over a column of N one-row blocks does not keep a contiguous head/tail when N > budget — ink drops evenly-spaced interior rows. Your own unit test input (60 lines → availableTerminalHeight=10) renders:

line 3, line 9, line 15, line 21, line 27, line 33, line 39, line 45, line 51, line 57

i.e. 10 lines sampled at step-6 across the whole message — gaps, not a window. Reproduced three independent ways (real tmux terminal at several stream rates; MarkdownDisplay via ink-testing-library; raw Box vs MaxSizedBox). For comparison, MaxSizedBox maxHeight=16 over the same content clips contiguously: …first 6 lines hidden… then line 7 … line 21.

Why it matters:

  • It's preview-only / transient (the committed <Static> render is complete & contiguous) and far better than the pre-fix storm — so this is not a merge blocker.
  • But it contradicts the PR's stated UX ("consistent with how pending code blocks already truncate" — code blocks show a contiguous head + … generating more …), and for a 400-line message into a ~28-row budget the preview becomes a sparse, shifting sample of the whole message.
  • MaxSizedBox (already imported here for MINIMUM_MAX_HEIGHT) also bounds height → fixes the storm too, and clips contiguously with a "N lines hidden" indicator. Caveat: it measures line-by-line and may not handle every markdown block type (tables / colorized code), so a straight swap needs a check; a contiguous head-slice of contentBlocks is an alternative.
  • The new test asserts only lineCount <= 10, not contiguity, so it can't catch this.

4) Minor test note

does not pad a short pending message … strips trailing newlines (.replace(/\n+$/, '')), so it still passes even if maxHeight were changed to height (which does pad with 19 blank rows). It's a weak guard for the no-pad intent. (The current code is correct; this is only about the test.)

Recommendation

Safe to merge to fix the storm — that's a real, severe bug and this eliminates it with no data loss. Suggest a follow-up (or small pre-merge tweak) to clip contiguously (e.g. MaxSizedBox or a head-slice) so the streaming preview isn't decimated, plus a one-line correction to the "consistent with pending code blocks" wording.

🇨🇳 中文版(完整对应)

本地验证报告(真实二进制 · tmux · 流式 fake-OpenAI)

我在 macOS 上做了端到端验证:分别用 origin/main修复前)和本 PR head 3f39d2e12修复后各构建一个真实打包的 CLI,在 tmux(80×30,非 VP / 默认模式) 里对着一个会流式返回长纯文本消息的伪 OpenAI SSE 端点做 A/B。用 --require 预加载钩子统计每一次包含 \x1b[2J(即 ansiEscapes.clearTerminal,也就是 ink 溢出路径写出的 clearTerminal + fullStaticOutput + output 的开头字节)的 process.stdout.write 调用。

结论速览

  • Bug 和根因属实,修复确实消除了风暴。 所谓"滚动重放"就是 ink 的 <Static> 溢出路径在每个流式 token 上触发;把 pending 帧限高后即不再触发。
  • 自动化测试通过(105/105),且关键新增测试是"承重"的(回退修复后该测试会挂)。
  • 无数据丢失 —— 完整消息仍然完整提交到 scrollback。
  • ⚠️ 一个质量问题(仅影响预览、不阻塞合并): 所选的 <Box maxHeight overflow="hidden"> 会让 ink 抽稀(decimate) 流式预览(丢掉均匀间隔的中间行),而不是裁出一段连续窗口 —— 所以"与 pending 代码块的截断行为一致"这句话并不成立。仓库自带的 MaxSizedBox(本 PR 已从它导入 MINIMUM_MAX_HEIGHT)是连续裁剪的。

1)E2E:clearTerminal "滚动重放"风暴 —— 前后对比

真实二进制在 tmux 中,单条长流式助手消息(纯文本,无代码围栏):

构建 单条流式消息期间 clearTerminal(\x1b[2J)写入次数
修复前origin/main 9087(两次)
修复后(本 PR) 00(两次)

修复前的日志显示每次重放都在重新推送整段 transcript,且随消息增长字节数持续变大 —— 一条约 25 KB 的消息累计造成了 1.26 MB 的重复写入:

CLEAR #85 bytes=24330 cumBytes=1136498
CLEAR #87 bytes=24802 cumBytes=1186102
CLEAR #89 bytes=25215 cumBytes=1236119   ← 字节数随消息增长

修复后:0 次此类写入;输入框保持固定位置,完整消息(第 1…N 行)与修复前一样完整提交到 scrollback。这与你的根因分析完全吻合(node_modules/ink/build/ink.js 里的 shouldClearTerminalForFramewasOverflowing || (isOverflowing && hadPreviousFrame))。

2)自动化测试 + 变异测试

  • vitest run …/MarkdownDisplay.test.tsx105 通过
  • 变异(承重性检查): 只回退源码修复、保留新增测试 → "clips a long pending message" 失败expected 60 to be less than or equal to 10)。✔️ 证明该断言确实在把关。
  • 实现正确地用了 maxHeight(而非 height)—— 短的 pending 消息按自然高度渲染(1 行),不会补白。✔️(已直接验证)

3)⚠️ 发现 —— 裁剪实际是把预览抽稀(而非连续裁剪)

<Box maxHeight overflow="hidden"> 套在 N 个单行块的列上,当 N 超过预算时不会保留连续的头部/尾部 —— ink 会丢掉均匀间隔的中间行。用你自己单测的输入(60 行 → availableTerminalHeight=10)渲染出来是:

line 3, line 9, line 15, line 21, line 27, line 33, line 39, line 45, line 51, line 57

也就是在整条消息上以步长 6 抽出的 10 行 —— 是断断续续的,而不是一段窗口。已用三种独立方式复现(真实 tmux 终端的多个流速;ink-testing-library 渲染 MarkdownDisplay;裸 BoxMaxSizedBox 对比)。作为对照,MaxSizedBox maxHeight=16 对同样内容是连续裁剪…first 6 lines hidden… 然后 line 7 … line 21

为什么重要:

  • 只影响预览 / 是瞬态的(提交到 <Static> 的最终渲染是完整且连续的),而且远好于修复前的风暴 —— 所以不阻塞合并
  • 但它和 PR 描述里的 UX 说法相矛盾("与 pending 代码块的截断一致" —— 代码块是显示连续的头部 + … generating more …);而对于 400 行的消息塞进约 28 行预算时,预览会变成对整条消息稀疏、不断跳动的采样。
  • MaxSizedBox(这里已为 MINIMUM_MAX_HEIGHT 导入它)同样会限高 → 一样能修风暴,并且是连续裁剪 + 带"还有 N 行被隐藏"的提示。注意:它是逐行测量的,可能无法处理所有 markdown 块类型(表格 / 着色代码),所以直接替换需要核验;另一个方案是对 contentBlocks 取连续的头部切片。
  • 新增测试只断言 lineCount <= 10,不检查连续性,因此无法捕捉这个问题。

4)次要测试说明

does not pad a short pending message … 这条测试会剥掉结尾换行(.replace(/\n+$/, '')),因此即便把 maxHeight 改成 height(那会补 19 行空白)它也照样通过。对"不补白"这个意图来说是个偏弱的把关。(当前代码是对的,这里只针对测试本身。)

建议

为修复风暴可以合并 —— 这是一个真实且严重的 bug,本 PR 在不丢数据的前提下消除了它。建议后续(或合并前的小改动)改成连续裁剪(例如 MaxSizedBox 或头部切片),让流式预览不被抽稀;并顺手把"与 pending 代码块一致"那句措辞更正一下。

Method: built both bundles via isolated npm ci worktree; tmux 80×30; fake OpenAI SSE (one line per chunk); \x1b[2J counted via stdout --require hook. Numbers vary with stream rate/throttle — the qualitative result (storm → 0) is stable across runs.

@chiga0
chiga0 dismissed stale reviews from qwen-code-ci-bot and DragonnZhang via ea7ddac July 1, 2026 02:43
wenshao
wenshao previously approved these changes Jul 1, 2026
@wenshao

wenshao commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@DragonnZhang DragonnZhang 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. The fix correctly bounds the pending (non-Static) frame height with proper null guards (availableTerminalHeight != null in MainContent, !== undefined in MarkdownDisplay), appropriate minimum floors, and overflow="hidden" — targeting exactly the ink overflow path that triggers the scrollback replay. The latest commit properly fixes the missing null guard that would have produced NaN for maxHeight. Well-tested (105 tests) and minimal in scope.

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

LGTM, looks ready to ship. ✅

@qwen-code-ci-bot qwen-code-ci-bot added category/cli Command line interface and interaction scope/markdown Markdown parsing and display scope/rendering Display and rendering logic type/bug Something isn't working as expected labels Jul 1, 2026
@wenshao

wenshao commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Maintainer local verification — real build + E2E

Built the PR head (3e2993daf) from source and reproduced the storm A/B on a real binary in tmux (non-VP / default mode). The fix works: clearTerminal scrollback replays during a long streaming message drop from 218 → 0. Unit tests (105/105 + 42 adjacent), tsc, and eslint are clean. Recommend merge, with two cheap non-blocking follow-ups noted below.

How it was verified

  • Worktree at PR head 3e2993daf; npm ci + workspace build.
  • Ran the real CLI (packages/cli/index.ts --yolo) in tmux 80×30, OpenAI auth pointed at a fake SSE server streaming a 400-line assistant message.
  • Counted ink's clearTerminal (\x1b[2J) writes via a NODE_OPTIONS=--require stdout hook. A/B by swapping only the two source files (tsx reads source at runtime — no rebuild), so the fake server, terminal size and prompt are identical across runs.

1) Primary fix — storm eliminated ✅

build clearTerminal replays during streaming bytes written for the same 400 lines
before (fix reverted) 218 1,094,343 (~1.09 MB — whole transcript re-streamed)
after (this PR) 0 323,952 (~324 KB — normal incremental)

Both runs commit the identical complete output (lines 1–400) to <Static>; the only difference is the mid-stream replay storm. A separate slow-stream capture also held the storm at 0. This is the same shouldClearTerminalForFrame mechanism as #5798 / #6015.

2) Tests / typecheck / lint ✅

  • MarkdownDisplay.test.tsx: 105 passed (incl. the 2 new pending-clip tests).
  • Adjacent: HistoryItemDisplay (28) + MainContent (14) = 42 passed — the <Static>/pending replay machinery is intact.
  • tsc --noEmit: clean. eslint on the 3 changed files: clean.

3) Non-blocking findings

(a) The clip decimates — it does not keep a contiguous tail. ink's <Box maxHeight overflow="hidden"> over a column taller than the budget drops evenly-spaced interior rows, not the oldest rows. Verified two independent ways:

  • ink-testing-library: 60 rows → budget 10 renders rows 6, 12, 18, … , 60.
  • Real binary, mid-stream capture: the live preview shows 4,10,16,…,90, then 5,14,…,128 — every ~6th→9th line, and the gap widens as the message grows.

So the transient live preview is gappy/jumpy. This is an acceptable trade-off (strictly better than the storm, and the committed <Static> render is complete & contiguous) → non-blocking. But note it is inconsistent with the PR's "consistent with pending code-block truncation" framing: pending code blocks show a contiguous head, whereas this shows a decimated sample.

(b) The MainContent.tsx backstop comment is factually inaccurate (worth a 2-line fix). Lines 448–451 claim:

overflow="hidden" keeps the most recent rows (the tail) so interactive prompts / embedded shell, which live at the bottom, stay visible; only the oldest rows are dropped.

ink does not do this — it decimates interior rows throughout; only the single last row is reliably kept. A multi-row block at the bottom loses interior rows (synthetic 4-row tail block under an over-tall reply → 2 of its 4 rows dropped, incl. its top line). So the stated guarantee ("embedded shell … stays visible") is not upheld by the mechanism. If tail-preservation is genuinely wanted, the clip must be bottom-anchored (slice the children, or a MaxSizedBox-style bottom window) rather than relying on ink's decimating overflow. At minimum, correct the comment so it doesn't mislead future maintainers.

(c) Minor: the backstop (MainContent.tsx) has no dedicated unit test, while the per-message clip added 2; and the two height floors differ — Math.max(MINIMUM_MAX_HEIGHT = 2, …) in MarkdownDisplay.tsx vs Math.max(1, …) in the backstop.

Verdict

Functionally correct and safe to merge — the scrollback-replay storm is verifiably gone (218 → 0), tests / tsc / eslint are green, and both VP mode and the committed <Static> output are unaffected. Suggested (non-blocking): fix the misleading comment (b); optionally add a backstop test (c). The decimated live preview (a) is acceptable as-is, since it only affects the transient over-viewport preview and is strictly better than the storm it replaces.

中文版(完整对应)

维护者本地验证 — 真实构建 + E2E

从源码构建 PR head (3e2993daf),在 tmux 里用真实二进制(非 VP / 默认模式)复现 storm A/B。修复有效:长流式消息期间的 clearTerminal scrollback 重放从 218 → 0。单元测试(105/105 + 相邻 42)、tsceslint 全部干净。建议合并,另附两条低成本、非阻塞的后续项。

验证方法

  • 在 PR head 3e2993daf 建 worktree,npm ci + workspace 构建。
  • 在 tmux 80×30 里跑真实 CLI(packages/cli/index.ts --yolo),OpenAI 认证指向一个流式返回 400 行助手消息的伪 SSE server。
  • NODE_OPTIONS=--require 的 stdout hook 统计 ink 的 clearTerminal\x1b[2J)写入。A/B 只切换那两个源文件(tsx 运行时读源码,无需重建),因此伪 server、终端尺寸、prompt 在两次运行中完全一致。

1)主修复 — storm 已消除 ✅

构建 流式期间 clearTerminal 重放次数 同样 400 行写入的字节数
before(还原修复) 218 1,094,343(~1.09 MB — 整个 transcript 被反复重放)
after(本 PR) 0 323,952(~324 KB — 正常增量渲染)

两次运行都把完全相同的完整输出(第 1–400 行)提交到 <Static>;唯一差别就是流式中途的重放风暴。另一次慢速流捕获也把 storm 稳定保持在 0。这与 #5798 / #6015 是同一套 shouldClearTerminalForFrame 机制。

2)测试 / 类型检查 / lint ✅

  • MarkdownDisplay.test.tsx105 通过(含 2 个新增的 pending 裁剪测试)。
  • 相邻套件:HistoryItemDisplay(28)+ MainContent(14)= 42 通过<Static>/pending 重放机制完好。
  • tsc --noEmit:干净。3 个改动文件的 eslint:干净。

3)非阻塞发现

(a) 裁剪是「抽稀」而非保留连续尾部。 ink 的 <Box maxHeight overflow="hidden"> 在子列超过预算时,丢弃的是均匀间隔的内部行,而不是最旧的行。两种独立方式证实:

  • ink-testing-library:60 行 → 预算 10 显示第 6, 12, 18, …, 60 行。
  • 真实二进制、mid-stream 捕获:实时预览显示 4,10,16,…,90,随后 5,14,…,128 —— 每隔约 6→9 行取一行,且间隔随消息增长而变宽

所以实时预览会「跳空/发抖」。这是可接受的取舍(严格优于 storm,且提交到 <Static> 的最终渲染是完整且连续的)→ 非阻塞。但它与 PR 「与 pending 代码块截断一致」的说法不符:pending 代码块显示的是连续的开头,而这里是抽稀采样。

(b) MainContent.tsx 的 backstop 注释与事实不符(值得 2 行修正)。 第 448–451 行声称:

overflow="hidden" keeps the most recent rows (the tail) … interactive prompts / embedded shell … stay visible; only the oldest rows are dropped.

ink 并非如此 —— 它在整段范围内抽稀内部行;只有最后一行能稳定保留。底部的多行块会丢失内部行(合成实验:一个超高回复下方的 4 行尾部块 → 4 行里丢了 2 行,含它的顶行)。因此注释承诺的「embedded shell 保持可见」并成立。若确实想保留尾部,裁剪必须锚定底部(对子项切片,或用 MaxSizedBox 式的底部窗口),而不能依赖 ink 的抽稀式 overflow。至少应修正注释,避免误导后续维护者。

(c) 次要: backstop(MainContent.tsx)没有专门的单元测试,而按消息裁剪那处新增了 2 个;且两个高度下限不一致 —— MarkdownDisplay.tsxMath.max(MINIMUM_MAX_HEIGHT = 2, …),backstop 用 Math.max(1, …)

结论

功能正确、可安全合并 —— scrollback 重放 storm 已被可验证地消除(218 → 0),测试 / tsc / eslint 全绿,VP 模式与提交的 <Static> 输出均不受影响。建议(非阻塞):修正 (b) 的误导性注释;可选地为 (c) 加一个 backstop 测试。(a) 的抽稀预览可维持现状,因为它只影响「超出视口的临时预览」,且严格优于它所替换的 storm。

wenshao
wenshao previously approved these changes Jul 1, 2026
@wenshao
wenshao added this pull request to the merge queue Jul 1, 2026

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

Also affected: AgentChatContent.tsx renders isPending={true} HistoryItemDisplay items outside <Static> with no outer maxHeight backstop — the same scroll-replay vulnerability this PR fixes in MainContent.tsx. Consider applying the same wrapper pattern there.

⚠️ CI note: Test (ubuntu-latest, Node 22.x) is failing — likely due to the TS2345 type error in finding 1.

Comment thread packages/cli/src/ui/components/MainContent.tsx Outdated
Comment thread packages/cli/src/ui/components/MainContent.tsx Outdated
Comment thread packages/cli/src/ui/components/MainContent.tsx Outdated
Comment thread packages/cli/src/ui/components/MainContent.tsx Outdated
Comment thread packages/cli/src/ui/components/MainContent.tsx Outdated
Comment thread packages/cli/src/ui/components/MainContent.tsx Outdated
@wenshao
wenshao removed this pull request from the merge queue due to a manual request Jul 1, 2026
@chiga0
chiga0 force-pushed the fix/non-vp-pending-overflow-clip branch from 3ae994d to ce22396 Compare July 1, 2026 09:43
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@DragonnZhang DragonnZhang 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 Summary

Verdict: No high-confidence findings.

This PR fixes the non-VP scroll replay bug by clipping pending (live/streaming) markdown to a viewport budget using a contiguous head-slice approach. The implementation is clean, minimal, and well-tested.

Correctness: The pendingLineBudget math is sound (Math.max(MIN_PENDING_CONTENT_LINES, availableTerminalHeight - 2)). The 2-row reserve correctly prevents double-cue stacking with inner code block (RESERVED_LINES=2) and math block (RESERVED_LINES=3) budgets. Guard conditions are correct: clip activates only when isPending && availableTerminalHeight !== undefined && allLines.length > pendingLineBudget. Committed messages (isPending=false) are never clipped.

Security: No new attack surface. Pure rendering optimization.

Code Quality: Clean separation of concerns with a local MIN_PENDING_CONTENT_LINES constant decoupled from MaxSizedBox. The wrap="truncate" on the cue <Text> correctly prevents wrapping in narrow terminals. Tests comprehensively cover: long pending clip, short pending no-clip, committed message full render, code fence at clip boundary, math block no double-cue, undefined budget, degenerate floor (availableTerminalHeight=1), and content-presence assertions.

LGTM.

@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 failing (Test (ubuntu-latest, Node 22.x)).

No review findings on this revision. The contiguous head-slice approach is clean and well-targeted — proper gating (isPending + availableTerminalHeight !== undefined), correct budget floor (MIN_PENDING_CONTENT_LINES = 1), coordinated inner-block reservation (2 rows prevents double cues for both code and math blocks), and thorough test coverage (7 new tests including boundary, degenerate, and negative scenarios). Build passes locally, 115/115 tests pass.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

✅ Maintainer verification — real tmux E2E (head d6bc34c9e)

I built the real qwen binary from this PR's head and reproduced the scroll‑replay storm before/after in a real terminal. The fix works and is strictly better than base — recommending merge.

Scope verified

Net diff vs origin/main is 3 files (+210 / −1): MarkdownDisplay.tsx (+45), MarkdownDisplay.test.tsx (+159), settings.schema.json (+7). The approach is now a contiguous head‑slice of the pending markdown (allLines.slice(0, availableTerminalHeight − 2) + a ... generating more ... cue), and the earlier aggregate MainContent backstop was reverted (a10af959d) — the net diff no longer touches MainContent.tsx. This resolves the row‑decimation problem that the earlier overflow="hidden" revision had: .slice() is contiguous by construction.

Static checks

  • MarkdownDisplay.test.tsx: 115/115 pass (incl. the 7 new clip tests: long‑clip, no‑pad short, no‑clip committed, code‑fence boundary, math double‑cue, no‑budget, degenerate‑floor).
  • tsc --noEmit clean, eslint clean on the changed files.
  • settings.schema.json regen is byte‑identical to current origin/main → clean merge (unrelated toolIdleTimeoutMs, harmless).

Real E2E (the storm, measured at the terminal)

Real binary from source (tsx), tmux 80×30, non‑VP (default), fake OpenAI endpoint streaming a 400‑line reply. The clearTerminal scroll‑replay is counted from the raw PTY output via tmux pipe-pane (counting ESC[2J / ESC[3J), started after boot so only the streaming render is measured. Base (A) = this file reverted to the merge‑base; fix (B/T) = pristine head.

Scenario ESC[2J (clearTerminal) ESC[3J raw PTY bytes
A — base (pre‑fix), single reply 77 77 403,605
B — fix (head), single reply 0 0 73,567
T — fix (head), long thought + reply 0 0 122,370

A → B: 77 → 0 clearTerminal replays, ~5.5× less PTY output.

Mid‑stream, the fix renders a contiguous head + cue (not decimated); base renders every line, overflows, and the visible window scrolls (line 33 → 97 → …) while re‑streaming the whole transcript per token:

FIX (B), mid-stream — contiguous, bounded:      BASE (A), mid-stream — overflow, scrolling window:
  ◆ line 1                                          line 33
    line 2                                          line 34
    …                                               …
    line 14                                         line 53   → next sample: line 97, 98, … (storm)
    ... generating more ...                       (no cue, full render past the viewport)
  • Committed (non‑pending) message still renders in full (line 400 present in <Static>) ✓
  • Thought path is independently bounded (ThinkMessage tail‑clips to availableTerminalHeight/3), so thought + reply also produced 0 storms (T) — the reverted backstop leaves no residual storm in the normal thought‑then‑reply flow.

Non‑blocking notes

  1. Aggregate scope: the clip is per pending item (reply head‑slice + thought's own tail‑clip). A single frame with a simultaneously maxed thought (H/3) and maxed reply (H−2) could sum slightly over the viewport — a bounded, transient overshoot, not the unbounded per‑token storm this PR fixes. I could not reproduce it in the normal flow (T = 0). Fine to leave the aggregate backstop out.
  2. Degenerate floor: at availableTerminalHeight ∈ {1,2} the budget floors to 1 → 1 line + cue = 2 rows, which can't fit a 1–2 row area. Purely theoretical (the main‑content area is never that short).

Verdict: mergeable.

🇨🇳 中文版(点击展开)

✅ 维护者本地验证 —— 真实 tmux 端到端(head d6bc34c9e

我用本 PR head 从源码构建了真实 qwen 二进制,在真实终端里修复前/后复现了「滚动重放风暴」。修复有效、严格优于基线 —— 建议合并。

验证范围

相对 origin/main 的净 diff 是 3 个文件(+210 / −1):MarkdownDisplay.tsx(+45)、MarkdownDisplay.test.tsx(+159)、settings.schema.json(+7)。现在的做法是对 pending markdown 取连续的头部切片allLines.slice(0, availableTerminalHeight − 2) + ... generating more ... 提示),并且早期那个 MainContent 聚合兜底已被回退(a10af959d)—— 净 diff 不再改动 MainContent.tsx。这解决了早期 overflow="hidden" 版本的行抽稀问题:.slice() 天然连续。

静态检查

  • MarkdownDisplay.test.tsx:115/115 通过(含 7 个新增裁剪用例:长裁剪、短消息不补齐、已提交不裁剪、代码围栏边界、数学块双提示、无预算、退化下限)。
  • 改动文件 tsc --noEmit 干净、eslint 干净
  • settings.schema.json 重新生成与当前 origin/main 逐字节一致 → 干净合并(无关的 toolIdleTimeoutMs,无害)。

真实端到端(在终端层测量风暴)

源码方式(tsx)跑真实二进制,tmux 80×30,非‑VP(默认),伪 OpenAI 端点流式返回 400 行回复。clearTerminal 重放从 原始 PTY 输出tmux pipe-pane 统计(数 ESC[2J / ESC[3J),在启动完成之后才开始抓取,因此只测流式渲染。基线(A)=该文件回退到 merge-base;修复(B/T)=原始 head。

场景 ESC[2J(clearTerminal) ESC[3J 原始 PTY 字节
A —— 基线(修复前),单条回复 77 77 403,605
B —— 修复(head),单条回复 0 0 73,567
T —— 修复(head),长思考 + 回复 0 0 122,370

A → B:77 → 0 次 clearTerminal 重放,PTY 输出约减少 5.5 倍。

流式过程中,修复渲染的是连续头部 + 提示(不抽稀);基线渲染全部行、溢出,可见窗口随之滚动(line 33 → 97 → …),且每个 token 都重新流式整段:

  • 已提交(非 pending)的消息仍完整渲染<Static> 中能看到 line 400)✓
  • 思考路径本身独立受限ThinkMessage 尾部裁剪到 availableTerminalHeight/3),所以**「思考 + 回复」同样是 0 次风暴**(T)—— 回退聚合兜底后,在正常的「先思考后回复」流程里没有残留风暴。

非阻塞备注

  1. 聚合范围:裁剪是按单个 pending 项做的(回复头部切片 + 思考自带尾部裁剪)。若某一帧里思考(H/3)与回复(H−2)同时都顶满,总高可能略超视口 —— 这是有界的瞬时溢出,不是本 PR 修复的那种无界逐 token 风暴。正常流程里我复现不出(T = 0)。聚合兜底不加也可以。
  2. 退化下限:当 availableTerminalHeight ∈ {1,2} 时预算下限为 1 → 1 行内容 + 提示 = 2 行,塞不进 1–2 行的区域。纯理论(主内容区不会这么矮)。

结论:可合并。

Method: real binary via tsx from a PR‑head worktree (npm ci + built core); fake OpenAI SSE (400 lines); tmux 80×30 non‑VP; storm counted from raw PTY (pipe-pane, ESC[2J/ESC[3J); A = MarkdownDisplay.tsx reverted to merge‑base, B/T = pristine head.

秦奇 and others added 9 commits July 1, 2026 20:01
…k replay

In non-VP (default) mode the committed transcript lives in ink's `<Static>`
region; only the live/pending items form the dynamic frame. A long streaming
assistant message rendered ALL of its lines (code blocks self-truncate, but
plain prose / lists / tables had no overall cap), so the dynamic frame grew past
the terminal height. Once that happens ink takes its overflow path and writes
`clearTerminal + the entire static transcript + output` on EVERY repaint — i.e.
it re-streams the whole conversation on every streamed token. Focused, this is
continuous flicker; in a terminal multiplexer (tmux/cmux) the backgrounded tab
keeps doing it, so switching back replays the transcript top→bottom for a while.

Bound the pending markdown to `availableTerminalHeight` with
`maxHeight` + `overflow="hidden"`, which clips only when the content is genuinely
too tall (short messages render unpadded) and keeps the dynamic frame within the
viewport so the overflow path never fires. The full message still renders
uncapped once it commits to `<Static>`. Only applies while pending and when a
budget is known (constrainHeight on, non-VP).

Verified end-to-end with a stdout hook on the real built CLI in cmux: a long
streaming response emitted 239 full-transcript replays/7s before, 0 after.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
The per-message clip caps each pending markdown message, but the non-VP pending
region renders MULTIPLE items and each is capped individually — several tall
pending items (a long thought + a long reply, or a reply + a tool group) can
still sum past the terminal height and re-trigger the clearTerminal +
full-transcript replay. Wrap the whole pending list in a maxHeight +
overflow="hidden" box (only while constrainHeight is on) so the total dynamic
frame can never exceed the viewport, regardless of how many/what pending items.

overflow keeps the most recent rows (the tail), so interactive prompts / the
embedded shell — which live at the bottom — stay visible and only the oldest
rows are dropped; ShowMoreLines (Ctrl+S) still reveals the rest. The committed
transcript in <Static> is unaffected.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
… height

availableTerminalHeight is typed number | undefined; Math.max(1, ...) needs a
number, so tsc --build failed. Only clamp when it is a concrete number (and
constrainHeight is on); otherwise render unclamped. No behaviour change when a
budget exists.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…rkdown clip

The MainContent-level maxHeight/overflow wrapper (added to bound the total
pending height) had real defects surfaced in review: ink's overflow clips the
NEWEST content rather than the oldest (so the active reply / interactive prompt
would be hidden), the outer wrapper does not register with OverflowContext so
ShowMoreLines' "Ctrl+S" hint is lost when it is the sole clipper, and it adds a
row beyond the budget via the sibling ShowMoreLines. It also targeted a
multi-item "sum" overflow that was never reproduced. Revert it. The per-item
markdown clip in MarkdownDisplay — verified end-to-end to stop the replay for
the reported case — remains. A correct aggregate backstop would need MaxSizedBox
with overflowDirection="top" and is left as a follow-up if the sum case is
confirmed.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…omment

Address review on the pending markdown clip:
- the clip tests asserted only line count; a blanked output would pass
  (''.split('\n').length === 1). Assert real content — /line \d+/ for the
  clipped case (clip direction is not asserted, only that it is non-blank) and
  the exact text for the short case.
- add a control test that a long COMMITTED (isPending=false) message renders in
  full, guarding the isPending gate.
- the clip also applies to VP pending items (both MainContent paths pass
  availableTerminalHeight when constrainHeight), so correct the "non-VP" note.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
The previous maxHeight + overflow="hidden" clip DECIMATES — ink drops
interspersed rows to fit, so a clipped streaming message rendered garbled
(non-contiguous) lines and could erase a code block's own "generating more"
indicator (its budget was uncoordinated with the outer clip). Confirmed with the
real component: a 30-line message at budget 8 rendered L01,L05,L09,... instead of
a clean head.

Slice the source lines to a CONTIGUOUS head that fits availableTerminalHeight
(reserving one row) and append a single "… generating more …" cue. Output is now
readable and contiguous; code blocks retained in the head keep their own
truncation. Short/committed messages are unchanged. Strengthen the test to assert
contiguous head + cue (locks against a regression back to decimation).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…ence test

Address review on the head-slice:
- the "generating more" cue now uses ASCII ellipses (matching the file's other
  truncation cues) and wrap="truncate", so it stays one row on narrow terminals
  (the budget reserves exactly one row for it).
- decouple the pending budget floor from MaxSizedBox's MINIMUM_MAX_HEIGHT via a
  local MIN_PENDING_CONTENT_LINES (same numeric result, explicit intent).
- add a test for a code fence spanning the clip boundary (the EOF inCodeBlock
  flush path).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…ngle cue)

Address maintainer review:
- add tests for availableTerminalHeight undefined (guard: not clipped, no cue)
  and a degenerate budget of 1 (Math.max floor keeps one content line + cue).
- assert the short-message case emits no "generating more" cue.
- assert the code-fence-boundary case emits at most ONE cue: the head-slice
  bounds code content to <= availableTerminalHeight - 2 (RenderCodeBlock's own
  inner budget), so the inner truncation never fires inside a slice — no stacked
  double indicator.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…ble cue

RenderMathBlock reserves RESERVED_LINES=3 (vs code block's 2), so at the head
slice boundary a retained math block could hit its own inner "generating more"
cue on top of the outer one (confirmed: a $$-fenced pending message emitted two
cues). Reserve 2 rows in the head-slice budget (1 for the outer cue + 1 for the
widest inner reservation) so no retained block's inner truncation fires within
the slice. Add a math-block regression test asserting a single cue, and fix two
stale test comments that still referenced the old maxHeight/overflow approach.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@chiga0
chiga0 force-pushed the fix/non-vp-pending-overflow-clip branch from d6bc34c to 1cf52d8 Compare July 1, 2026 12:02

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

⚠️ Downgraded from Approve to Comment: CI failing (Test (ubuntu-latest, Node 22.x)).

— qwen3.7-max via Qwen Code /review

// its own cue and we'd stack two.
const pendingLineBudget =
isPending && availableTerminalHeight !== undefined
? Math.max(MIN_PENDING_CONTENT_LINES, availableTerminalHeight - 2)

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 -2 reserve correctly covers code/math block overhead (opening fence consumed by parser + inner RESERVED_LINES up to 3), but tables add 3 rendered border lines (top ┌─┬─┐, middle ├─┼─┤, bottom └─┴─┘) with no source-line counterpart — the separator line |---|---| is consumed during parsing but the 3 borders are generated by RenderTable.

A table that exactly fills the budget (e.g., 1 header + 1 separator + 6 data rows = 8 source lines for pendingLineBudget=8 with availableTerminalHeight=10) renders as: 1 top border + 1 header + 1 middle border + 6 data rows + 1 bottom border = 10 rows, plus the "... generating more ..." cue = 11 rows in a 10-row terminal. This overflows by 1 line, potentially re-triggering the shouldClearTerminalForFrame path this PR fixes.

Consider detecting a table in the slice and reducing the budget by 1 extra row:

const sliceContainsTable = pendingLineBudget !== undefined
  && allLines.slice(0, pendingLineBudget).some((l) => tableSeparatorRegex.test(l));
const effectiveBudget = sliceContainsTable
  ? Math.max(MIN_PENDING_CONTENT_LINES, availableTerminalHeight! - 3)
  : pendingLineBudget;

— qwen3.7-max via Qwen Code /review

// Contiguous head + a "generating more" cue — NOT decimated (ink
// overflow="hidden" would drop interspersed rows) and NOT blank.
expect(output).toContain('line 1');
expect(output).toContain('line 2');

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] All clipping tests assert upper bounds (toBeLessThanOrEqual(10), toBeLessThan(20), etc.) but none assert lower bounds. A regression that over-reserves (e.g., someone changes - 2 to - 5) would still pass all tests: the budget for availableTerminalHeight=10 would be 10 - 5 = 5 lines, which still contains "line 1" and "line 2", still excludes "line 60", and still satisfies lineCount <= 10.

Adding a lower-bound assertion would catch over-clipping regressions:

expect(lineCount).toBeGreaterThanOrEqual(availableTerminalHeight - 2);

— qwen3.7-max via Qwen Code /review

@DragonnZhang DragonnZhang 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 high-confidence findings on this revision. The contiguous head-slice approach is correct and well-targeted:

  • pendingLineBudget math is sound: Math.max(1, availableTerminalHeight - 2) properly floors at 1 and reserves 2 rows (1 for the outer cue + 1 for the widest inner RESERVED_LINES=3 in math blocks).
  • isPending gate ensures committed messages render in full; negative test coverage confirms this.
  • Double-cue prevention for code blocks (RESERVED_LINES=2) and math blocks (RESERVED_LINES=3) is verified by dedicated tests.
  • MIN_PENDING_CONTENT_LINES decoupled from MaxSizedBox.
  • ASCII ellipsis cue with wrap=truncate is consistent and stable.
  • availableTerminalHeight=undefined and degenerate =1 cases are tested.

The two open suggestions from @doudouOUC (table border line overhead, lower-bound assertions) are valid incremental improvements, not correctness blockers.

LGTM.

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

No high-confidence review findings on this revision. The contiguous head-slice approach is clean and well-targeted — proper gating (isPending + availableTerminalHeight !== undefined), correct floor via Math.max(MIN_PENDING_CONTENT_LINES, ...), and the 7 new tests cover the key scenarios including code fence spanning, math block double-cue prevention, committed message exemption, and degenerate budgets. Downgraded from Approve to Comment: CI still running.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

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

LGTM, looks ready to ship. ✅

@wenshao
wenshao added this pull request to the merge queue Jul 1, 2026
Merged via the queue into QwenLM:main with commit 8d5e47a Jul 1, 2026
55 checks passed
pull Bot pushed a commit to edisplay/qwen-code that referenced this pull request Jul 4, 2026
… lock (QwenLM#6170)

* fix(cli): stream long responses into scrollback to stop scroll-to-top lock

## Problem

In non-VP (default) mode, scrolling up while the model streams a long reply —
especially one containing a markdown table — jumps the viewport to the very top
and locks it there until the response finishes (issue QwenLM#5941).

Root cause: when the live (below-`<Static>`) frame grows taller than the
terminal, ink can no longer do its incremental cursor-up redraw and falls back
to clearing + repainting the whole frame from the top on every token. A markdown
table renders ~2 rows per data row (TableRenderer draws a separator between
every row), so QwenLM#6081's source-line budget under-counted the rendered height and
the frame still overflowed for tables / wide CJK text.

## Fix

Incremental scrollback streaming + a rendered-height safety net:

- useGeminiStream: commit finished chunks of the streaming reply into `<Static>`
  (scrollback) so the pending live item stays short. The commit is
  rendered-height-aware (tables count double, wide/CJK lines wrap) and bounded by
  the live content-area height (threaded via `availableTerminalHeightRef`), with
  a reserve so it fires before the render-side clip. It commits in a `while` loop
  and splits only at `findLastSafeSplitPoint` boundaries (never inside a fenced
  code block).

- MarkdownDisplay: a rendered-height-aware slice of the pending preview as a last
  line of defence — it guarantees the live frame never exceeds the viewport
  regardless of how the stream is chunked (tables charged at ~2x; non-table lines
  charged their wrapped height). A completed table renders in full; a table still
  being written renders live and is clamped by TableRenderer's new `maxHeight`.

Result: long replies (and tables) flow smoothly into scrollback, tables draw
live, and the viewport never locks to the top.

Refs QwenLM#5941, QwenLM#6081

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): address review — share rendered-height estimator + guard edges

Follow-up to review feedback on the incremental-scrollback streaming fix:

- Extract a shared rendered-height estimator (`pendingRenderedHeight.ts`:
  `fitPendingSlice` / `estimateWrappedRows` / `isTableStart`) and use it from
  BOTH the useGeminiStream commit and the MarkdownDisplay safety-net slice, so
  the two agree on table (block: 2*dataRows + chrome) and wrap accounting
  instead of diverging.
- useGeminiStream: use a conservative content-area fallback (terminalHeight
  minus a composer reserve) when `availableTerminalHeightRef` is not yet
  populated, so a short terminal never commits with an over-large budget.
- MarkdownDisplay: allow the pending slice to keep 0 lines — a single very wide
  / CJK line that wraps past the budget now renders only the "generating more"
  cue instead of one oversized row that would bypass the height bound.
- Add missing `useCallback` deps (terminalWidth / terminalHeight /
  availableTerminalHeightRef) — fixes the CI ESLint failure.
- Tests: unit tests for the shared estimator (table detection, zero/negative
  width, CJK wrapping, cut-before / clamp / keptLines=0 boundaries) and for
  TableRenderer's `maxHeight` clamp (fit, clip+cue, vertical fallback, undefined
  passthrough).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): rename shared util to kebab-case to satisfy check-file lint

The new pendingRenderedHeight.{ts,test.ts} tripped the check-file/filename-naming-convention (KEBAB_CASE) ESLint rule on new files in packages/cli/src. Rename to pending-rendered-height.{ts,test.ts} and update imports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): point imports at renamed pending-rendered-height module

The previous rename commit landed the file rename but not the importer edits
(a stale pathspec aborted the git add), leaving MarkdownDisplay, useGeminiStream
and the test importing the old ./pendingRenderedHeight.js path — a module-not-
found in CI. Update the imports to the kebab-case path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): drop unused eslint-disable directive on while(true)

reportUnusedDisableDirectives + --max-warnings 0 flags the no-constant-condition
disable as an unused directive (the rule doesn't flag while(true) here). Remove it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): unify table parsing in shared module + cover commit edge cases

Address the second review pass:

- Move splitMarkdownTableRow and the table regexes (TABLE_ROW_RE /
  TABLE_SEPARATOR_RE) into pending-rendered-height.ts as the single source of
  truth; MarkdownDisplay now imports them instead of keeping duplicate copies.
- isTableStart now also checks the separator's column count matches the header
  (mirroring the renderer's table detection) so the height estimator and the
  renderer agree on what is a table.
- Tests: shared-module coverage for splitMarkdownTableRow and the isTableStart
  column-count check; tighten the incremental-commit assertion (budget-relative,
  requires multiple commits); add coverage for the splitPoint<=0 loop-break
  guard and for the populated-availableTerminalHeightRef production path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): commit streaming chunks only at block boundaries (no split tables)

The incremental scrollback commit could cut a markdown table mid-way (e.g. when
the tail row was still streaming): the committed chunk kept the header+rows and
rendered as a table, but the continuation started with headerless `| ... |`
rows that render as raw text (visible orphaned rows below a table).

Only commit at a blank-line block boundary. A table (or list / code block) has
no internal blank line, so it is never split into a headerless continuation; a
still-streaming table stays pending — bounded in view by MarkdownDisplay's
clamp — until it is complete, then commits whole.

Tests: the oversized-commit test now uses blank-line-separated content and
asserts every committed chunk ends at a block boundary; add a regression test
that a streaming table taller than the budget is never committed as a headerless
fragment (its header stays with its rows in the pending item).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): don't treat code-fence content as a table in the height estimator

fitPendingSlice called isTableStart on every line regardless of fenced-code-
block state, so table-like lines inside a ``` block were charged as a table
(2*dataRows + chrome) while MarkdownDisplay renders them as code (one row each).
Track the code fence and charge fenced lines individually. Share CODE_FENCE_RE
from the module (MarkdownDisplay now imports it too) to keep a single source of
truth. Adds a unit test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): account for vertical-format table height + tilde code fences

Address the third review pass:

- (Critical) fitPendingSlice charged the horizontal table height (2*dataRows+5)
  only, but TableRenderer falls back to the vertical key-value format on a narrow
  terminal / when cells wrap tall, which is much taller for 3+ column tables.
  Charge the larger of the horizontal and vertical estimates (dataRows*colCount +
  separators + marginY), still capped by the clamp — under-charging could let a
  vertical-format table overflow the viewport and re-introduce the scroll lock.
- findLastSafeSplitPoint only recognised triple-backtick fences while the
  estimator's CODE_FENCE_RE also matches ~~~; a ~~~ block with an internal blank
  line could be split mid-block. isIndexInsideCodeBlock / findEnclosingCodeBlockStart
  now track both fence types (matching by fence character).
- Tests: vertical-format table cost, ~~~ fence tracking, inline math and
  multi-backtick spans in splitMarkdownTableRow, and a ~~~ split-safety case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): only charge vertical-format table height on a narrow terminal

The prior fix charged the max of the horizontal and vertical table estimates
unconditionally, which over-estimated a table's height on a wide terminal (where
it actually renders in the shorter horizontal format) and clipped small tables
early with a premature "generating more". Mirror TableRenderer's width-based
vertical decision (contentWidth < max(24, 6*colCount + 5)) and charge the format
it will actually render: horizontal when the terminal is wide enough, vertical
only when narrow — so a narrow-terminal vertical render still can't overflow and
lock, but a small table on a wide terminal is no longer clipped prematurely.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cli): fix phantom code fences + read commit width from a live ref

Address the fourth review pass:

- (Critical) isIndexInsideCodeBlock / findEnclosingCodeBlockStart used
  indexOf('```', ...) which matches only the first three characters of a longer
  fence run, so a 4+ backtick/tilde fence was miscounted as two delimiters
  (phantom close-then-reopen). That could mark a blank line inside a code block
  as outside it, letting findLastSafeSplitPoint split mid-block and commit an
  unclosed code block to scrollback. findNextFence now returns the full run
  length, callers advance past the whole run, and a fence only closes a block
  opened with the same character and a run at least as long.
- The commit loop read height live from availableTerminalHeightRef but width
  from the render-time closure, so a mid-stream resize handled the two
  inconsistently. Pair a terminalWidthRef with the height ref and read both live.

Tests: a 6-backtick fenced block is not split at its internal blank line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

category/cli Command line interface and interaction scope/markdown Markdown parsing and display scope/rendering Display and rendering logic type/bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants