fix(cli): debounce resize repaint and clear stale scrollback on settle#4919
Conversation
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
No review findings. Downgraded from Approve to Comment: CI still running. LGTM! ✅ — qwen3.7-max via Qwen Code /review
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 Static checks (PR head
|
| 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 newAppContainerunit test pins thatrefreshStaticskips the physical clear in VP mode. - The runtime "after" build being the merge tree also means the PR composes correctly at runtime with the
mainUI changes that landed since its base (e.g. feat(daemon): merge daemon-mode feature batch into main #4490'sAppContainertouches) — 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 viewparsing 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 ——
useResizeSettleRepaint6、AppContainer79、MainContent13(与 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 指标 | before(origin/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 之后落地的
mainUI 改动(如 feat(daemon): merge daemon-mode feature batch into main #4490 对AppContainer的触碰)在运行时组合无虞——构建、启动、运行全程正常。 - 流程小事,不影响代码:PR 描述是以原始 HTML(带样式类/SVG 片段)粘贴的,渲染效果和未来
gh pr view解析都会比仓库常规 markdown 模板嘈杂。有空的话建议用纯 markdown 重贴一次。
结论
✅ macOS 验证通过——建议合并(待 Windows CI 跑完)。机制在字节层面与设计完全一致(每手势单次尾沿全清、回程 no-op、旧的逐事件擦除消失),且在真实 Terminal.app 窗口中于 main 上复现的 #4891 用户可见症状在合并树上已消除。这补上了 PR 测试计划中点名的 macOS 验证缺口。
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 fullrefreshStatic()(clearTerminalincl.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):useTerminalSizedoes not debounce — a window drag fires dozens ofresizeevents at intermediate widths.4bab7a1a) replaced the fullclearTerminal(whoseESC[3Jcleared scrollback) withcursorTo(0,0)+eraseDown, which only erases the visible viewport.<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
eraseDownand 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
rerenderdoesn't flush update-time passive effects, so thererender → effect → setTimeoutchain can't be observed inAppContainer.test.tsx;renderHookis this repo's idiomatic pattern for timer hooks.Reviewer Test Plan
Automated
Manual (macOS — requires a real window drag)
qwen --approval-mode yoloin Terminal.app or iTerm2Expected: 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
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-codeVitest suite: 7521/7563 tests passed (40 skipped); the 2 failing files are unrelated —src/i18n/mustTranslateKeys.test.tsfails identically on cleanorigin/main, andsrc/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). Fullnpm run preflightis 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[3Jclears 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 —refreshStaticalready guards the physical write there, and a new test pins it; no changes touseTerminalSizeor 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 byuseResizeSettleRepaint.test.ts.Linked Issues
Fixes #4891. Possibly related (not claimed as duplicates): #3213, #3824.
中文说明
做了什么: 把宽度变化时的逐事件重绘(#3967 的
cursorTo+eraseDown)替换为 200ms 尾沿防抖,settle 后只做一次完整clearTerminal(含ESC[3J)+ remount;防抖抽成useResizeSettleRepainthook 以便确定性单测。为什么: 修复 #4891。无防抖的 resize 事件风暴 × #3967 只擦可视区的重绘 × #3899 渐进重放被反复打断 → 已滚出可视区的内容以旧宽度永久滞留 scrollback,每个事件滞留一层,即 issue 中的宽窄交替碎片。
风险与范围: 全屏闪烁回归,但每次拖拽手势最多一次;settle 时的
ESC[3J会清除会话前 scrollback(与 #3967 之前每个事件的行为、当前/clear一致)。不修改 VP 模式、useTerminalSize、#3899 重放机制;macOS 视觉验证待 reporter 协助。无破坏性变更;一个既有测试改名,断言不变。关联: Fixes #4891;可能相关(非重复):#3213、#3824。