Skip to content

fix(cli): prevent transcript re-scroll on tab-switch in terminal multiplexers - #6055

Closed
chiga0 wants to merge 1 commit into
QwenLM:mainfrom
chiga0:fix/ink-resize-replay
Closed

fix(cli): prevent transcript re-scroll on tab-switch in terminal multiplexers#6055
chiga0 wants to merge 1 commit into
QwenLM:mainfrom
chiga0:fix/ink-resize-replay

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 when you switch back to a tab running a long task in terminal multiplexers (tmux/cmux/screen).

Patches upstream ink 7.0.3 (via patch-package) so Ink.resized() returns early when the terminal dimensions are unchanged, and tracks lastTerminalHeight alongside the existing lastTerminalWidth.

Why it's needed

In non-VP (default) mode the whole history lives in Ink's <Static> region, accumulated in this.fullStaticOutput. When the dynamic (non-<Static>) frame is taller than the viewport, renderInteractiveFrame takes its overflow path and writes clearTerminal + this.fullStaticOutput + output — i.e. it clears the screen+scrollback and re-prints the entire session transcript, which visibly scrolls from top to bottom (and takes a while on long sessions).

resized() unconditionally calls onRender() on every resize/SIGWINCH, even when dimensions didn't change. Terminal multiplexers routinely re-send SIGWINCH with identical dimensions when a pane regains focus, so each switch-back into a running task triggered this full replay. A same-dimension resize has no layout work to do, and multiplexers preserve pane content across focus, so the correct behavior is to do nothing.

This is not caused by #6015 (the non-VP scroll-snapback / wheel PR) — that PR never touched resized() / fullStaticOutput / shouldClearTerminalForFrame; it shares only the same overflow mechanism. This is stock upstream ink behavior.

Reviewer Test Plan

How to verify

Fake-TTY harness (viewport 10 rows, dynamic frame 14 rows → overflow), emit a resize with unchanged dimensions:

clearTerminal emitted history lines re-emitted
before 12 / 12 (full replay)
after 0 / 12 (no-op)

A genuine dimension change still relayouts + renders (the early-return only fires when both columns and rows are unchanged). Existing rendering suites pass (InlineParallelAgentsDisplay, ScrollableList, VirtualizedList incl. auto-scroll = 46 tests). Patch applies cleanly from pristine (patch-packageink@7.0.3 ✔); the existing package.json exports hunk is preserved.

Manual: in tmux/cmux, non-VP mode, run a long task, scroll to the bottom, switch to another tab and back. Before: the transcript replays from the top and scrolls for a while before reaching the bottom. After: the view stays put.

Evidence (Before & After)

N/A — non-UI code change in vendored ink; behavior confirmed via fake-TTY harness output above.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment

Local runtime: npm run dev; fake-TTY harness for automated verification. No sandbox involved — patch-only change to vendored ink.

Risk & Scope

  • Main risk: behavior changes only for resize events with byte-identical dimensions (previously a full re-render, now a no-op — correct, since there is no layout change and multiplexers retain pane content). Real resizes are unaffected.
  • Not validated / out of scope: verified the mechanism (same-dimension resize ⇒ full fullStaticOutput replay) and that this fix makes that case a no-op, with a fake-TTY harness. Could not run real cmux locally to confirm its tab-switch emits a same-dimension SIGWINCH (the common case for tmux/screen). If cmux instead emits a changed-dimension resize on switch, a deeper follow-up is needed (avoid re-emitting the full fullStaticOutput on the overflow-clear path). Please sanity-check on the original cmux setup.
  • Breaking changes: none — patch-only change to vendored ink; no app/API/settings changes.

Linked Issues

Ref #6015 (related non-VP scroll-snapback fix; different root cause — see "Why it's needed" above).

中文说明

本 PR 做了什么

修复了在终端复用器(tmux/cmux/screen)中切换回正在运行长任务的标签页时,整个会话记录从上到下重新滚动的问题。

通过 patch-package 修补上游 ink 7.0.3,使 Ink.resized() 在终端尺寸未变时提前返回,并新增 lastTerminalHeight 与已有的 lastTerminalWidth 配套跟踪。

为什么需要修复

在非 VP(默认)模式下,完整历史记录存储在 Ink 的 <Static> 区域(this.fullStaticOutput)。当动态帧(非 <Static> 部分)高度超过视口时,renderInteractiveFrame 走溢出路径,写入 clearTerminal + this.fullStaticOutput + output——即清屏+清滚动缓冲并重新打印整个会话记录,导致可见的从上到下滚动(长会话时耗时明显)。

resized() 在每次 resize/SIGWINCH 时无条件调用 onRender(),即使尺寸没变。终端复用器在面板重新获得焦点时常会发送尺寸完全相同的 SIGWINCH,因此每次切回运行中的任务标签页都会触发完整重放。尺寸未变的 resize 不需要做任何布局工作,且复用器在焦点切换时保留面板内容,所以正确行为是什么都不做。

此问题不是 #6015(非 VP 滚动回弹/滚轮 PR)导致的——那个 PR 从未修改 resized() / fullStaticOutput / shouldClearTerminalForFrame,仅共享了相同的溢出机制。这是上游 ink 的原生行为。

审阅者测试计划

如何验证

Fake-TTY 测试工具(视口 10 行,动态帧 14 行 → 溢出),发送尺寸不变的 resize

触发 clearTerminal 重新输出的历史行
修复前 12 / 12(完整重放)
修复后 0 / 12(无操作)

真正的尺寸变化仍会重新布局和渲染(提前返回仅在 columnsrows 均未变时触发)。现有渲染测试套件全部通过(InlineParallelAgentsDisplayScrollableListVirtualizedList 含自动滚动 = 46 个测试)。补丁从干净状态应用成功(patch-packageink@7.0.3 ✔);保留了现有的 package.json exports hunk。

手动验证:在 tmux/cmux 非VP 模式下,运行长任务,滚动到底部,切换到另一个标签页再切回。修复前: 会话记录从顶部重放并滚动一段时间才到达底部。修复后: 视图保持不动。

测试环境

OS 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

运行环境

本地运行:npm run dev;使用 fake-TTY 工具进行自动化验证。无沙箱——仅修改 vendored ink 补丁。

风险与范围

  • 主要风险: 仅对尺寸完全相同的 resize 事件改变行为(之前是完整重渲染,现在是无操作——正确,因为没有布局变化且复用器保留面板内容)。真正的 resize 不受影响。
  • 未验证/超出范围: 已验证机制(同尺寸 resize ⇒ 完整 fullStaticOutput 重放)及此修复使该情况变为无操作,使用 fake-TTY 工具。无法在本地运行真正的 cmux 来确认其标签切换是否发送同尺寸 SIGWINCH(tmux/screen 的常见情况)。如果 cmux 在切换时发送的是尺寸变化的 resize,则需要更深入的后续修复(避免在溢出清屏路径上重新输出完整 fullStaticOutput)。请在原始 cmux 环境上验证。
  • 破坏性变更: 无——仅修改 vendored ink 补丁,无应用/API/设置变更。

关联 Issue

参考 #6015(相关的非 VP 滚动回弹修复;根因不同——见上方"为什么需要修复")。

…-switch

A resize/SIGWINCH whose dimensions are unchanged still forced Ink's
renderInteractiveFrame down its overflow path, which writes
`clearTerminal + fullStaticOutput + output` — re-streaming the ENTIRE scrollback
so the transcript visibly scrolls from top to bottom. Terminal multiplexers
(tmux / cmux / screen) routinely re-send SIGWINCH with identical dimensions when
a pane regains focus, so every switch back into a tab running a long task
replayed the whole transcript before settling at the bottom.

Patch upstream ink 7.0.3 (via patch-package) so `resized()` returns early when
neither columns nor rows changed: an identical-dimension resize carries no
layout work, and multiplexers preserve pane content across focus, so there is
nothing to repaint. Track `lastTerminalHeight` alongside `lastTerminalWidth`.

Verified with a fake-TTY harness: before, a same-dimension resize while the
dynamic frame exceeds the viewport emits `clearTerminal` + the full static
scrollback (12/12 history lines re-emitted); after, it is a no-op (0 re-emitted).
A genuine dimension change still relayouts and renders as before.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@chiga0 chiga0 changed the title fix(cli): skip no-op terminal resize to stop scrollback replay on tab-switch fix(cli): prevent transcript re-scroll on tab-switch in terminal multiplexers Jun 30, 2026
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ (has all substantive sections — What, Why, Reviewer Test Plan, Risk & Scope. Minor: the "Tested on" OS table and 中文说明 are missing, but the verification content is solid.)

On direction: this solves a real, annoying problem — tmux/cmux users switching back to a tab and watching the entire transcript scroll from top to bottom. The project already has resize-related work (useResizeSettleRepaint for #4891, viewport rendering for #6015), so this area is well-trodden. Claude Code's CHANGELOG has multiple tmux/scrollback fixes (pane launch, clipboard, sync-output artifacts), confirming terminal multiplexer UX is a shared concern across agent CLIs. Aligned.

On approach: this is about as minimal as it gets — 48 lines in a single patch file, touching only ink's resized() method. The early-return on same-dimension resize is the correct fix: no layout work needed, multiplexers preserve pane content, so re-rendering is pure waste that triggers the fullStaticOutput overflow path. I considered whether this could live in the application layer instead (e.g., extending useResizeSettleRepaint to also track height), but that hook runs at the React level and wouldn't prevent ink's lower-level resized() from firing the expensive clear+re-emit. The ink-level fix is the right place.

One note: the PR honestly flags that it couldn't verify in real cmux — just the mechanism via a fake-TTY harness. That's a fair caveat and worth a maintainer sanity-check on a real multiplexer setup before merging.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓(核心部分齐全——What、Why、Reviewer Test Plan、Risk & Scope。小问题:缺少"Tested on"系统测试表和中文说明,但验证内容充分。)

方向:解决了一个真实的痛点——tmux/cmux 用户切回标签时看到整个会话记录从顶部滚到底部。项目已有相关 resize 处理工作(#4891useResizeSettleRepaint#6015 的视口渲染),说明这个方向是持续关注区域。Claude Code 的 CHANGELOG 也有多个 tmux/scrollback 修复,确认终端复用器 UX 是 agent CLI 的共同关注点。方向对齐。

方案:非常精简——48 行,单文件 patch,只修改 ink 的 resized() 方法。相同尺寸 resize 时提前返回是正确的修复:无布局工作要做,复用器保留面板内容,重新渲染只会触发 fullStaticOutput 溢出路径。考虑过是否可以在应用层处理(例如扩展 useResizeSettleRepaint 同时跟踪高度),但该 hook 在 React 层运行,无法阻止 ink 底层 resized() 触发昂贵的清除+重发。ink 层修复是正确的位置。

注意:PR 坦诚说明无法在真实 cmux 中验证——只通过假 TTY 测试验证了机制。这是合理的提醒,合并前值得维护者在真实复用器环境中确认。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal (before reading the diff): the bug is in ink's resized() handler unconditionally calling onRender() on same-dimension SIGWINCH events. The fix should track height alongside the existing width tracking, and return early when both are unchanged. This must be done at the ink level (patch) since the application-level useResizeSettleRepaint hook only debounces width changes and can't prevent ink's lower-level clear+re-emit.

Comparison with the diff: the PR's approach matches my independent proposal exactly. The implementation is surgical and correct:

  • lastTerminalHeight field declared alongside lastTerminalWidth (line 148)
  • Initialized in create() via getWindowSize().rows (line 231) — same pattern as width
  • resized() destructures both columns and rows from a single getWindowSize() call (no extra syscall)
  • Early-return when both dimensions match (lines 272–279) — the core fix
  • lastTerminalHeight updated after real resizes (line 286) — keeps state consistent

The 8-line comment explaining the multiplexer SIGWINCH behavior is genuinely useful context for future maintainers — not over-commenting.

No correctness bugs, no security issues, no regressions. No reuse concerns — this touches ink internals where no shared utility exists.

Test Results

Check Result
Patch applies ink@7.0.3 ✔ (patch-package 8.0.1)
Build passes
useResizeSettleRepaint (6 tests) all pass
ScrollableList all pass
VirtualizedList (incl. auto-scroll) all pass
Full CLI suite 8037 passed, 16 failed (all pre-existing ERR_MODULE_NOT_FOUND in settings/pidfile — unrelated to this patch)

Tmux Testing (limited)

The resize-replay bug is a visual scrolling animation that requires a real terminal to observe. capture-pane takes static snapshots and cannot capture the animation. I attempted SIGWINCH delivery to a running interactive session — the pre/post captures are byte-identical (the final state is unchanged; only the intermediate animation differs).

The PR author verified the mechanism with a fake-TTY harness (same-dimension resize → clearTerminal emitted before, not emitted after). Full visual confirmation in real cmux/tmux still needs a maintainer with a multiplexer setup.

Before (installed qwen — no patch): interactive session runs, SIGWINCH sent, static capture unchanged (animation not capturable):

$ qwen -p 'count from 1 to 30'
1
2
...
30
$  # SIGWINCH sent here — no visible state change in static capture

After (this PR): patch applies, build succeeds, all rendering tests pass. The resized() early-return logic is verified correct by code inspection — a same-dimension SIGWINCH now returns before reaching onRender(), preventing the fullStaticOutput overflow path entirely.

中文说明

代码审查

独立方案(阅读 diff 前):bug 在 ink 的 resized() 处理器中,对相同尺寸的 SIGWINCH 事件无条件调用 onRender()。修复应同时跟踪高度和宽度,两者均未变化时提前返回。必须在 ink 层(patch)修复,因为应用层的 useResizeSettleRepaint hook 只防抖宽度变化,无法阻止 ink 底层的清除+重发。

与 diff 对比: PR 方案与我的独立方案完全一致。实现精准且正确:

  • lastTerminalHeight 字段与 lastTerminalWidth 并列声明
  • create() 中通过 getWindowSize().rows 初始化——与宽度相同模式
  • resized() 从单次 getWindowSize() 调用解构 columnsrows(无额外系统调用)
  • 两个维度都匹配时提前返回——核心修复
  • 真实 resize 后更新 lastTerminalHeight——保持状态一致

解释复用器 SIGWINCH 行为的注释对未来维护者有实际价值。

无正确性 bug、无安全问题、无回归。

测试结果

  • Patch 应用:ink@7.0.3 ✔
  • 构建:通过
  • useResizeSettleRepaint(6 个测试):全部通过
  • ScrollableList:全部通过
  • VirtualizedList(含自动滚动):全部通过
  • 完整 CLI 测试套件:8037 通过,16 失败(均为 settings/pidfile 中的预存 ERR_MODULE_NOT_FOUND——与此 patch 无关)

Tmux 测试(受限)

resize-replay bug 是需要在真实终端中观察的视觉滚动动画。capture-pane 拍摄静态快照,无法捕获动画。向运行中的交互式会话发送 SIGWINCH——前后捕获字节相同(最终状态不变,仅中间动画不同)。

PR 作者通过假 TTY 测试验证了机制(相同尺寸 resize → 修复前发出 clearTerminal,修复后不发出)。在真实 cmux/tmux 中的完整视觉确认仍需拥有复用器环境的维护者验证。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Stepping back: this is a clean, minimal patch that solves a real user-facing problem. The scrollback-replay-on-tab-switch is the kind of bug that makes tmux users question whether the tool is stable — it's visually jarring even if functionally harmless.

The approach is exactly what I'd have proposed independently: track height alongside the existing width tracking in ink's resized(), return early when both are unchanged. Every line in the diff is necessary. No drive-by refactors, no scope creep. The 8-line comment explaining multiplexer SIGWINCH behavior is genuinely useful for the next person who touches this code.

What I like: the PR author is honest about what they could and couldn't verify. They built a fake-TTY harness to confirm the mechanism (same-dimension resize → fullStaticOutput replay), showed that the fix eliminates it, and explicitly flagged that real cmux verification is still needed. That's the right level of rigor for a contributor who can't access the original reporter's setup.

My one reservation: this patches a vendored dependency (ink 7.0.3). If ink releases 7.0.4 with internal changes, this patch may need updating. But the patch is well-isolated (single method, clear intent), and the existing package.json exports hunk shows the project is already comfortable maintaining ink patches. The maintenance cost is proportional to how often ink changes resized() — which is rare.

The tmux visual test couldn't be automated in CI (the bug is an animation, not a state change), but the mechanism is verified, the code is correct, and all rendering tests pass. I'm comfortable approving.

One request for the maintainer: if possible, do a quick sanity check in real tmux/cmux before merging — just switch tabs a few times during a long session and confirm the transcript stays put. The author was transparent that they couldn't verify this themselves.

Approving. ✅

中文说明

总结:这是一个干净、精简的 patch,解决了真实的用户可见问题。tab 切换时的 scrollback 重播是那种让 tmux 用户怀疑工具稳定性的 bug——视觉上令人不安,即使功能上无害。

方案与我的独立提案完全一致:在 ink 的 resized() 中同时跟踪高度和宽度,两者未变时提前返回。diff 中每一行都是必要的。没有顺手重构,没有范围蔓延。解释复用器 SIGWINCH 行为的 8 行注释对下一个接触此代码的人真正有用。

优点:PR 作者坦诚说明了能验证和不能验证的内容。他们构建了假 TTY 测试工具确认机制(相同尺寸 resize → fullStaticOutput 重播),证明修复消除了该问题,并明确指出仍需要真实 cmux 验证。对于无法访问原始报告者环境的贡献者来说,这是正确的严谨程度。

一个保留意见:这修改了 vendored 依赖(ink 7.0.3)。如果 ink 发布 7.0.4 并更改内部实现,此 patch 可能需要更新。但 patch 隔离良好(单一方法,意图清晰),现有的 package.json exports hunk 表明项目已经习惯维护 ink patches。维护成本与 ink 更改 resized() 的频率成正比——很少见。

tmux 视觉测试无法在 CI 中自动化(bug 是动画,不是状态变化),但机制已验证,代码正确,所有渲染测试通过。可以批准。

对维护者的一个请求:如果可能,在合并前在真实 tmux/cmux 中快速确认——在长会话中切换几次标签,确认记录保持不动。作者坦诚说明自己无法验证这一点。

批准 ✅

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

@chiga0
chiga0 marked this pull request as draft June 30, 2026 08:45
@chiga0

chiga0 commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

Closing after verification. The root cause of the reported 'transcript replays top→bottom on tab-switch' is NOT a resize: it's that ink re-emits clearTerminal + fullStaticOutput on every re-render whenever the dynamic (non-Static) frame exceeds the viewport (confirmed via fake-TTY). On real terminals SIGWINCH only fires on an actual size change — verified that cmux sends none on tab/workspace switch, and tmux sends none on a same-dimension resize — so the same-dimension-resize path this PR guarded is not reachable in practice, and preventing the overflow in the first place subsumes it anyway. Will address the real cause (keep the non-VP live frame within the viewport) in a separate PR.

@chiga0 chiga0 closed this 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 issues found. The patch is a clean, minimal fix that correctly addresses the transcript re-scroll bug in terminal multiplexers. The early-return guard logic is sound — verified that TmuxBackend's force-repaint path is unaffected, onRender() side effects are irrelevant for no-op resizes, and initialization ordering is correct. Downgraded from Approve to Comment: CI still running.

— qwen3.7-max via Qwen Code /review

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.

2 participants