Skip to content

fix(cli): debounce resize repaint and clear stale scrollback on settle#4919

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
wsyjh8:fix/4891-resize-settle-repaint
Jun 12, 2026
Merged

fix(cli): debounce resize repaint and clear stale scrollback on settle#4919
wenshao merged 1 commit into
QwenLM:mainfrom
wsyjh8:fix/4891-resize-settle-repaint

Conversation

@wsyjh8

@wsyjh8 wsyjh8 commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Replaces the per-event repaint on terminal width change (cursorTo(0,0)+eraseDown + <Static> remount, from #3967) with a 200 ms trailing-edge debounce: when the width settles, perform one full refreshStatic() (clearTerminal incl. ESC[3J) + remount. A window-drag's resize burst now yields a single repaint at the final width; a drag that returns to the starting width yields none. The debounce lives in a new hook, useResizeSettleRepaint, extracted for testability (see below).

Why it's needed

Fixes #4891. Root cause is the interaction of three things on the default rendering path (ui.useTerminalBuffer=false):

  1. useTerminalSize does not debounce — a window drag fires dozens of resize events at intermediate widths.
  2. fix(cli): replace clearTerminal with targeted repaint on resize #3967 (4bab7a1a) replaced the full clearTerminal (whose ESC[3J cleared scrollback) with cursorTo(0,0)+eraseDown, which only erases the visible viewport.
  3. Each width change remounts <Static>, restarting the [Bug] Switching from compact to verbose mode via Ctrl+O freezes the CLI in long conversations #3899 progressive replay.

When the next resize lands mid-replay, chunks that already scrolled above the viewport are unreachable by eraseDown and stay in scrollback at that instant's width — one stranded fragment per event, i.e. the reported alternating narrow/wide segments. (tmux likely doesn't reproduce because pane resizes lack the high-frequency burst and don't reflow scrollback.)

Why a new hook: ink-testing-library's rerender doesn't flush update-time passive effects, so the rerender → effect → setTimeout chain can't be observed in AppContainer.test.tsx; renderHook is this repo's idiomatic pattern for timer hooks.

Reviewer Test Plan

Automated

bash
npm run typecheck --workspace=@qwen-code/qwen-code
npx vitest run packages/cli/src/ui/hooks/useResizeSettleRepaint.test.ts
npx vitest run packages/cli/src/ui/AppContainer.test.tsx packages/cli/src/ui/components/MainContent.test.tsx

Manual (macOS — requires a real window drag)

  1. qwen --approval-mode yolo in Terminal.app or iTerm2
  2. Send a prompt that triggers several tool calls
  3. While they stream, drag the window edge 3–5 times (narrow → wide → narrow)
  4. Scroll to the top after completion

Expected: all content at the final width — one brief clear+redraw ~200 ms after the drag settles, no mixed-width fragments.

Evidence (Before & After)

Before: the issue's mixed-width scrollback segments. After: single-width history once the resize settles — we have no macOS machine to record this. @tanzhenxin, could you verify with the steps above?

Tested on

OS | Status -- | -- 🍏 macOS | ⚠️ needs a real window drag — requesting reporter verification 🪟 Windows | ✅ full automated verification (see Environment) 🐧 Linux | ⚠️ via CI

Environment (optional)

Windows 10, Node v22.22.1, npm 10.5.2. Typecheck clean on both workspaces; prettier + eslint clean on changed files. Full @qwen-code/qwen-code Vitest suite: 7521/7563 tests passed (40 skipped); the 2 failing files are unrelated — src/i18n/mustTranslateKeys.test.ts fails identically on clean origin/main, and src/ui/components/InputPrompt.test.tsx (#4171) passes in isolation on both branches (flaked only under the full parallel run). Touched suites all pass: AppContainer (79), MainContent (13, unmodified), useResizeSettleRepaint (6). Full npm run preflight is delegated to PR CI.

Risk & Scope

Main risk or tradeoff: the full-screen flash #3967 removed returns, but at most once per resize gesture (vs. every event before #3967, vs. never-but-incorrect after); the settle-time ESC[3J clears pre-session scrollback — same as the pre-#3967 per-event behavior and today's /clear; during the ~200 ms settle window the static region is briefly stale while ink reflows the dynamic region live.

Not validated / out of scope: VP mode (ui.useTerminalBuffer=true) unchanged — refreshStatic already guards the physical write there, and a new test pins it; no changes to useTerminalSize or the #3899 replay machinery; macOS visual verification pending.

Breaking changes / migration notes: none. One existing test renamed (…just because width changed…synchronously on width change) — assertions unchanged; it pins the synchronous half of the new contract, with the settle-time half covered by useResizeSettleRepaint.test.ts.

Linked Issues

Fixes #4891. Possibly related (not claimed as duplicates): #3213, #3824.

中文说明

做了什么: 把宽度变化时的逐事件重绘(#3967cursorTo+eraseDown)替换为 200ms 尾沿防抖,settle 后只做一次完整 clearTerminal(含 ESC[3J)+ remount;防抖抽成 useResizeSettleRepaint hook 以便确定性单测。

为什么: 修复 #4891。无防抖的 resize 事件风暴 × #3967 只擦可视区的重绘 × #3899 渐进重放被反复打断 → 已滚出可视区的内容以旧宽度永久滞留 scrollback,每个事件滞留一层,即 issue 中的宽窄交替碎片。

风险与范围: 全屏闪烁回归,但每次拖拽手势最多一次;settle 时的 ESC[3J 会清除会话前 scrollback(与 #3967 之前每个事件的行为、当前 /clear 一致)。不修改 VP 模式、useTerminalSize#3899 重放机制;macOS 视觉验证待 reporter 协助。无破坏性变更;一个既有测试改名,断言不变。

关联: Fixes #4891;可能相关(非重复):#3213#3824

Dragging a terminal window edge during streaming left fragmented content
at mixed widths in the scrollback on macOS Terminal.app / iTerm2.

Root cause (legacy `ui.useTerminalBuffer=false` path, the default):
- A window drag fires dozens of `resize` events with intermediate widths
  (useTerminalSize is intentionally undebounced; other consumers want the
  live value).
- PR QwenLM#3967 (4bab7a1) changed the width-change repaint from a full
  clearTerminal to cursorTo(0,0)+eraseDown, which only erases the visible
  viewport and cannot reach output already scrolled into the scrollback.
- Each width change also restarts the QwenLM#3899 progressive <Static> replay.
  When the next resize arrives mid-replay, chunks already scrolled above
  the viewport are unreachable by eraseDown and stay in scrollback at
  whatever width the terminal had at that instant — one stranded layer
  per event, i.e. the reported alternating narrow/wide box-border segments.

Fix: debounce the resize repaint to the trailing edge of the burst
(RESIZE_REPAINT_SETTLE_MS = 200ms). On width change we schedule a timer;
the effect cleanup cancels it when the next width change (or unmount)
arrives, so only the last event fires. On settle we call the existing
refreshStatic() — a full clearTerminal (incl. ESC[3J, which clears
scrollback) plus a <Static> remount — wiping the stale fragments and
re-emitting the history exactly once at the final width. A drag that
returns to the starting width fires nothing (the settled-width ref is
only updated when the repaint actually fires).

The debounce logic lives in a small, dedicated hook
(useResizeSettleRepaint) so it can be unit-tested deterministically with
renderHook + fake timers; the inline AppContainer effect could not be
exercised through ink-testing-library, which does not flush update-time
passive effects. Behavior is unchanged.

Trade-offs (intentional):
- The full-screen flash QwenLM#3967 removed now happens at most once per resize
  gesture (vs. every event pre-QwenLM#3967, vs. never-correct post-QwenLM#3967).
- The settle-time ESC[3J clears pre-session scrollback — identical to what
  every resize event did before QwenLM#3967 and what /clear / refreshStatic
  callers still do today; strictly less destructive than pre-QwenLM#3967.
- During the ~200ms drag window the static region is briefly stale while
  ink reflows the dynamic region live; the final state is correct.

Scope: no change to useTerminalSize, the VP (useTerminalBuffer=true)
rendering path, or the QwenLM#3899 progressive-replay machinery.

Fixes QwenLM#4891

@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 issues found. LGTM! Clean trailing-edge debounce that correctly coalesces resize bursts, with good test coverage for the key scenarios (mount no-op, burst coalescing, drag-back cancellation, unmount cleanup). The hook extraction for testability and the ref-based settled-width tracking are well-designed. -- qwen-code 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 issues found. The debounce logic is clean, cleanup on unmount is correct, the VP-mode guard is properly handled by the existing refreshStatic implementation, and the test suite comprehensively covers the hook's contract. LGTM.

— claude-opus-4-6 via Qwen Code /review

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

@wenshao

wenshao commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

macOS Real-Run Verification Report (PR #4919)

The PR asks for macOS verification with a real window drag (the author has no macOS machine). This report provides exactly that, on macOS Darwin 25.5.0 / Node v22.22.2 / Terminal.app, at two levels: byte-level (what escape sequences the TUI actually writes on resize, captured from a real pty) and window-level (real Terminal.app windows resized by scripted bounds changes — real SIGWINCH bursts, real scrollback reflow — then the tab's full scrollback inspected for #4891's mixed-width fragments).

Because main has moved 43 commits past the PR base (including commits touching AppContainer.tsx), all runtime tests ran on the auto-merged tree (main @ 963fc543d + PR decea67e8; merges cleanly) — i.e. the tree that would actually land — with vanilla origin/main as the before build.

Static checks (PR head decea67e8)

  • Author-listed suites: 98/98useResizeSettleRepaint 6, AppContainer 79, MainContent 13 (matches the PR's numbers).
  • npm run typecheck --workspace=@qwen-code/qwen-code clean; eslint clean on all 4 changed files.
  • Hook logic read-through: mount no-op, trailing-edge timer with cleanup-cancel, return-to-settled-width no-op, unmount cancel — all four pinned by the new tests; the "referentially stable refreshStatic" requirement the hook documents is satisfied at the AppContainer call site (useCallback whose deps don't change on resize).
  • CI at time of writing: macOS / Ubuntu tests, Lint, CodeQL all green; Windows test still pending.

1. Byte-level: what the TUI writes on resize (real pty, tmux harness)

Harness: the built TUI runs in a real pty; a driver issues real TIOCSWINSZ + SIGWINCH width-change bursts and records every output chunk with a monotonic timestamp. Markers: full clear = ESC[3J (only emitted by clearTerminal); old per-event repaint = cursorTo(0,0)+eraseDown (ESC[1;1H ESC[J]).

Scenario (same script, both builds) before (origin/main) after (merge tree)
Burst A: 4 width changes, 60 ms apart 4 × per-event cursorTo+eraseDown (at +8/72/131/195 ms — one per event) 1 × full clear, ~206 ms after the last change
Burst B: single width change 1 × per-event repaint, immediate 1 × full clear, +210 ms
Burst C: width away → back to settled width, gaps < 200 ms 2 × per-event repaints 0 writes (return-to-start no-op, as designed)
Full-clear (ESC[3J) total 0 2
Per-event repaint total 7 0

This is the PR's contract verified on the wire: per-event viewport-only erases are gone; one full clear (including scrollback wipe) fires once per settled gesture at the documented ~200 ms trailing edge; a gesture that returns to the starting width emits nothing.

2. Window-level: real Terminal.app, real resizes, scrollback inspected (#4891 repro)

Each build ran --approval-mode yolo -i "<prompt streaming 100 numbered lines>" in a fresh Terminal.app window; the window was then resized through 6 real bounds changes (~120 ms apart, ending at a different width than it started); after completion the tab's entire scrollback (history) was dumped and analyzed. (tmux can't reproduce #4891 — the PR notes this correctly — hence real windows for the visual half; the after burst fired while the response was still streaming, the before burst right after streaming; the stranding mechanism is per-resize-event and showed up regardless.)

Scrollback metric before (origin/main) after (merge tree)
Banner copies stranded in scrollback 7 1
Same banner box rendered at different widths 4+ widths (47 / 52 / 58 / 69 cols across stranded copies, interleaved with replayed response lines) 1 width
Occurrences of the streamed line 17 (should be 1) 7 1
Full-width separators multiple widths single width (97 cols, the final width)
Total scrollback lines for the same conversation 662 125

The before run is a faithful reproduction of #4891 on macOS — alternating-width duplicated segments stranded above the viewport. The after run leaves a single clean final-width history: the settle-time ESC[3J genuinely wipes the stranded fragments in a real terminal, not just in unit tests.

Notes for the record

  • Trade-offs behave as the PR describes: the post-settle clear is visible as one brief flash per gesture, and pre-session scrollback is cleared on settle (same as /clear). Observed, not measured further.
  • VP mode (ui.useTerminalBuffer=true) was not runtime-tested here; the new AppContainer unit test pins that refreshStatic skips the physical clear in VP mode.
  • The runtime "after" build being the merge tree also means the PR composes correctly at runtime with the main UI changes that landed since its base (e.g. feat(daemon): merge daemon-mode feature batch into main #4490's AppContainer touches) — built, booted, and exercised without issues.
  • Process nit, zero impact on the code: the PR body is pasted as raw HTML (with styling classes/SVG fragments), which makes the rendered description and any future gh pr view parsing noisier than the repo's usual markdown template. Worth a quick re-paste as plain markdown if you get the chance.

Verdict

Verified on macOS — recommend merge (pending the Windows CI leg finishing). The mechanism is exactly as designed at the byte level (single trailing-edge full clear per gesture, return-to-width no-op, old per-event erase gone), and the user-visible #4891 symptom — reproduced on main in a real Terminal.app window — is eliminated on the merged tree. This closes the macOS verification gap called out in the PR's own test plan.

Repro
# builds
git worktree add /tmp/qwen4919/before origin/main --detach
git worktree add /tmp/qwen4919/after  <pr-head> --detach && (cd /tmp/qwen4919/after && git merge origin/main)
# npm install && npm run build in each

# byte level (inside tmux): python pty harness
#   - spawn `node packages/cli/dist/index.js` in a pty (TERM=xterm-256color)
#   - bursts: TIOCSWINSZ + SIGWINCH; widths 110/100/90/80 @60ms, then 120, then 90→120 @100ms
#   - log timestamped chunks; count ESC[3J vs ESC[1;1H+ESC[J per phase

# window level (real Terminal.app):
#   osascript 'do script' a window running:
#     node <build>/packages/cli/dist/index.js --approval-mode yolo -i \
#       'Print the numbers 1 to 100 ... then print DONE-MARKER. Do not use any tools.'
#   6 × `set bounds of window id W to {...}` @120ms while streaming
#   dump `history of tab 1 of window id W`; count banner copies, rule widths, duplicate lines
中文版(完整翻译)

macOS 真实运行验证报告(PR #4919

PR 明确请求 macOS 真实窗口拖拽验证(作者没有 macOS 机器)。本报告在 macOS Darwin 25.5.0 / Node v22.22.2 / Terminal.app 上提供了这一验证,分两个层面:字节级(从真实 pty 捕获 TUI 在 resize 时实际写出的转义序列)和窗口级(用脚本驱动真实 Terminal.app 窗口 bounds 变化——真实 SIGWINCH 风暴、真实 scrollback 回流——然后检查整个 scrollback 中 #4891 的混宽碎片)。

由于 main 已领先 PR base 43 个提交(其中包含触碰 AppContainer.tsx 的提交),所有运行时测试都在自动合并树上进行(main @ 963fc543d + PR decea67e8,可干净合并)——即真正会落地的树——并以原生 origin/main 作为 before 构建。

静态检查(PR head decea67e8

  • 作者列出的套件:98/98 —— useResizeSettleRepaint 6、AppContainer 79、MainContent 13(与 PR 数字一致)。
  • cli workspace tsc --noEmit 干净;4 个改动文件 eslint 零问题。
  • Hook 逻辑通读:首挂载 no-op、尾沿定时器 + cleanup 取消、回到已 settle 宽度 no-op、卸载取消——四项均被新测试钉住;hook 文档要求的"refreshStatic 引用稳定"在 AppContainer 调用点成立(useCallback 依赖在 resize 时不变)。
  • 截稿时 CI:macOS / Ubuntu 测试、Lint、CodeQL 全绿;Windows 测试仍在运行

1. 字节级:TUI 在 resize 时实际写了什么(真实 pty,tmux harness)

Harness:构建后的 TUI 跑在真实 pty 里;驱动器发出真实 TIOCSWINSZ + SIGWINCH 宽度变化序列,并以单调时间戳记录每个输出块。标记:全清 = ESC[3J(仅 clearTerminal 发出);旧的逐事件重绘 = cursorTo(0,0)+eraseDown

结果(同一脚本驱动两个构建):burst A(4 次变宽,间隔 60ms)——before 为 4 次逐事件重绘(+8/72/131/195ms,每事件一次),after 为 1 次全清,距最后一次变化约 206ms;burst B(单次变宽)——before 立即 1 次逐事件重绘,after 为 1 次全清(+210ms);burst C(变走又在 200ms 内变回)——before 2 次逐事件重绘,after 0 次写出(按设计的回程 no-op)。合计:ESC[3J] before 0 次 / after 2 次;逐事件重绘 before 7 次 / after 0 次。

这正是 PR 声明的契约在线上的验证:逐事件的仅视口擦除消失;每个 settle 手势恰好一次全清(含 scrollback 擦除),落在文档化的 ~200ms 尾沿;回到起始宽度的手势什么都不发。

2. 窗口级:真实 Terminal.app、真实缩放、检查 scrollback(#4891 复现)

每个构建在全新 Terminal.app 窗口中运行 --approval-mode yolo -i "<流式输出 100 个编号行的 prompt>";随后窗口经历 6 次真实 bounds 变化(约 120ms 间隔,结束宽度不同于起始);完成后导出整个 scrollback(history)分析。(tmux 无法复现 #4891——PR 的说明是对的——所以视觉部分用真实窗口;after 的缩放发生在流式输出进行中,before 在流式刚结束后;搁浅机制按 resize 事件逐个发生,两种时机都能表现。)

scrollback 指标 beforeorigin/main after(合并树)
搁浅在 scrollback 的 banner 拷贝 7 1
同一 banner 框出现的不同宽度 4 种以上(47/52/58/69 列,且与重放的响应行交错) 1 种
流式行 17 的出现次数(应为 1) 7 1
全宽分隔线 多种宽度 单一宽度(97 列,最终宽度)
同一会话的 scrollback 总行数 662 125

before#4891 在 macOS 上的忠实复现——宽窄交替、内容重复的片段滞留在视口上方。after 只留下一份干净的最终宽度历史:settle 时的 ESC[3J 在真实终端里确实清掉了搁浅碎片,而不只是在单测里。

备忘

  • 取舍与 PR 描述一致:settle 后的全清表现为每手势一次短暂闪烁,且会清掉会话前的 scrollback(与 /clear 一致)。仅观察确认,未进一步测量。
  • VP 模式ui.useTerminalBuffer=true)本次未做运行时测试;新增的 AppContainer 单测钉住了 VP 模式下 refreshStatic 跳过物理清屏。
  • 运行时 "after" 构建即合并树,同时说明 PR 与 base 之后落地的 main UI 改动(如 feat(daemon): merge daemon-mode feature batch into main #4490AppContainer 的触碰)在运行时组合无虞——构建、启动、运行全程正常。
  • 流程小事,不影响代码:PR 描述是以原始 HTML(带样式类/SVG 片段)粘贴的,渲染效果和未来 gh pr view 解析都会比仓库常规 markdown 模板嘈杂。有空的话建议用纯 markdown 重贴一次。

结论

macOS 验证通过——建议合并(待 Windows CI 跑完)。机制在字节层面与设计完全一致(每手势单次尾沿全清、回程 no-op、旧的逐事件擦除消失),且在真实 Terminal.app 窗口中于 main 上复现的 #4891 用户可见症状在合并树上已消除。这补上了 PR 测试计划中点名的 macOS 验证缺口。

@wenshao wenshao merged commit 12bc80c into QwenLM:main Jun 12, 2026
32 checks passed
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.

Terminal resize during streaming leaves fragmented content at wrong widths in scrollback

3 participants