From a418e45eb5bc3fb92d4a90bf9991f0b1dd7d8e91 Mon Sep 17 00:00:00 2001 From: xdimtech Date: Thu, 22 Oct 2026 10:26:27 +0800 Subject: [PATCH] fix(tui): keep scrollback when the document grows above the viewport When a row above the viewport changed in the same frame that the document grew, the main-screen renderer escalated to a full redraw, which clears the screen and scrollback (CSI 2J + CSI 3J) and replays the whole document. While a response streams, a running tool's spinner row sits above the viewport and ticks every 200 ms as tokens are appended, so this ran several times a second. In iTerm2, especially in a tall native-fullscreen window, history disappeared during streaming and came back only after a resize. Growth does not break the viewport mapping: the tail moves down by the appended line count, and rewriting the visible window from the clamp point sends that many line feeds through the bottom row, so the terminal scrolls as far as a plain append would. Only a shrink still needs the full redraw, because rows left the document and the terminal cannot scroll back. The per-frame viewport output is unchanged. As with the existing same-length clamp, rows that already scrolled into scrollback keep the content they had when they left the screen. --- docs/tui-rendering-pipeline.md | 2 +- packages/tui/src/tui-main-screen.ts | 14 ++++-- .../test/main-screen-offscreen-change.test.ts | 49 +++++++++++++++++-- 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/docs/tui-rendering-pipeline.md b/docs/tui-rendering-pipeline.md index b2479fd1..bc35eef9 100644 --- a/docs/tui-rendering-pipeline.md +++ b/docs/tui-rendering-pipeline.md @@ -126,7 +126,7 @@ 5. 行归一化 `applyLineResetsFrom`(`tui-main-screen.ts:224-247`):从 `resetFrom` 起的行做 ANSI 归一化并追加 `SEGMENT_RESET`(`\x1b[0m\x1b]8;;\x07`,`tui.ts:353`),防止样式跨行泄漏;`resetFrom` 之前的行直接引用上一帧。首帧、宽高变化、overlay 切换都会把 `resetFrom` 压到 0。 6. 全量重绘判定(`fullRender(clear)`,`tui-main-screen.ts:386-428`):首帧不擦屏直接写;宽度变化必擦(换行位置全变);高度变化必擦(Termux 软键盘场景例外,否则每次键盘弹出都重放整段历史,`tui-main-screen.ts:456-460`);内容收缩超过历史最高水位且无 overlay 时擦除空行(`clearOnShrink`,`tui.ts:513-515`、`tui-main-screen.ts:465-469`)。擦屏序列是 `\x1b[2J\x1b[H\x1b[3J`(含 scrollback)。 7. 差分区间扫描(`tui-main-screen.ts:473-498`):从 `resetFrom` 扫到两帧最大行数,记 `firstChanged/lastChanged`;纯追加时区间锁在旧长度处(`appendStart` 快速路径)。区间经过 Kitty 图像块时向外扩展,避免从图像块中间重画(`tui-main-screen.ts:284-305`)。 -8. 视口夹取(`tui-main-screen.ts:568-589`):`firstChanged` 落在上一帧视口之上且文档长度没变——只有 scrollback 副本过期,屏幕上行没动——只重绘可见部分;任何长度变化都可能让行在视口下位移,仍走全量重绘。 +8. 视口夹取(`tui-main-screen.ts:568-589`):`firstChanged` 落在上一帧视口之上时,只有 scrollback 副本过期,把起点夹到视口顶只重绘可见部分。文档长度不变时屏幕上行没动;文档变长时重写可见窗口产生的 `\r\n` 数等于追加行数,终端滚动的距离与普通追加相同,视口依然对齐——流式输出时视口上方的 spinner 行每帧都在变,若在这里升级为全量重绘,就会每帧清屏并清空 scrollback。只有文档变短(终端无法反向滚动)或图像块跨越夹取点时才全量重绘。 9. 写入(`tui-main-screen.ts:593-680`):`\x1b[?2026h` 开同步输出 → 视口滚动对齐(必要时 `\r\n` 推进滚动)→ CUU/CUD 移到首变行 → `\r` → 逐行 `\x1b[2K` + 行内容 → 尾部清理旧多出的行 → `\x1b[?2026l`。只写首变行到末变行,单行变化(spinner)不会闪烁全屏。 10. 硬保险(`tui-main-screen.ts:651-678`):任何渲染行 `visibleWidth > width` 直接把全部行写进 `tui-crash.log`,清理终端状态后抛错——指向"组件没截断"这个根因。 11. 更新镜像:`previousLines`、`hardwareCursorRow`、`previousViewportTop`、`previousKittyImageIds`、宽高(`tui-main-screen.ts:737-749`),并按需移动硬件光标(`positionHardwareCursor`,`tui-main-screen.ts:757-788`)。 diff --git a/packages/tui/src/tui-main-screen.ts b/packages/tui/src/tui-main-screen.ts index 61203cc9..f5ff1d31 100644 --- a/packages/tui/src/tui-main-screen.ts +++ b/packages/tui/src/tui-main-screen.ts @@ -542,14 +542,20 @@ export class TuiMainScreen extends TuiBase implements TUI { // destroy and rebuild the user's scrollback several times a second. When the // document kept its length no row moved, so the visible window still maps to the // same terminal rows: keep the stale scrollback and repaint only what is visible. - // Any length change can shift rows under the viewport, so that still needs the - // full redraw. + // + // Growth keeps that mapping too. The tail moved down by the appended line count, and + // rewriting the visible window feeds exactly that many line feeds through the bottom + // row, so the terminal scrolls by the same amount a plain append would. Escalating a + // growing document to a full redraw here would clear and rewrite the scrollback on + // every streamed frame - the case that made history vanish in fullscreen terminals. + // A shrink is different: rows left the document and the terminal cannot un-scroll, + // so the rows above the clamp point would stay stale; that still needs the redraw. if (firstChanged < prevViewportTop) { - const documentLengthChanged = newLines.length !== this.previousLines.length; + const documentShrank = newLines.length < this.previousLines.length; // A kitty image block reaching across the clamp point cannot be repainted from // its middle without corrupting it. const straddlingImage = kittyScanEnabled && this.hasImageLineInRange(firstChanged, prevViewportTop, newLines); - if (documentLengthChanged || straddlingImage) { + if (documentShrank || straddlingImage) { fullRender(true); return; } diff --git a/packages/tui/test/main-screen-offscreen-change.test.ts b/packages/tui/test/main-screen-offscreen-change.test.ts index 04d99bd3..9110665c 100644 --- a/packages/tui/test/main-screen-offscreen-change.test.ts +++ b/packages/tui/test/main-screen-offscreen-change.test.ts @@ -122,16 +122,59 @@ describe("main screen changes above the viewport", () => { harness.tui.stop({ preserveScreen: true }); }); - it("falls back to a full redraw when the document length changes above the viewport", async () => { + it("repaints the visible rows when the document grows above the viewport", async () => { const harness = createHarness(ROWS); const redrawsBefore = harness.tui.fullRedraws; harness.resetWritten(); - // A transcript append shifts every row under it, so the visible window no longer - // maps to the same terminal rows and the clamp would leave the screen stale. + // A transcript append shifts every row under it. The terminal scrolls by the + // appended line count while the visible window is rewritten, so the screen stays + // aligned without a full redraw. harness.transcript.lines = [...harness.transcript.lines, "transcript appended"]; harness.tui.renderNow(); + assert.strictEqual(harness.tui.fullRedraws, redrawsBefore); + assert.ok(!harness.written().includes(CLEAR_SCROLLBACK)); + const viewport = await harness.terminal.flushAndGetViewport(); + assert.strictEqual(viewport[viewport.length - 1], "footer"); + harness.tui.stop({ preserveScreen: true }); + }); + + it("keeps streaming growth above the viewport out of the full-redraw path", async () => { + // The real streaming shape: a running tool row above the viewport ticks at 5 Hz + // while the assistant message appends tokens every frame. + const harness = createHarness(ROWS); + const redrawsBefore = harness.tui.fullRedraws; + harness.resetWritten(); + + for (let tick = 0; tick < 40; tick++) { + harness.status.lines = [`spinner ${tick}`]; + harness.transcript.lines = [...harness.transcript.lines, `token ${tick}`]; + harness.tui.renderNow(); + } + + assert.strictEqual(harness.tui.fullRedraws, redrawsBefore); + assert.ok(!harness.written().includes(CLEAR_SCROLLBACK)); + const viewport = await harness.terminal.flushAndGetViewport(); + assert.strictEqual(viewport[viewport.length - 1], "footer"); + const scrollback = harness.terminal.getScrollBuffer(); + assert.ok( + scrollback.some((line) => line.trimEnd() === "transcript 0"), + "history stays in scrollback", + ); + harness.tui.stop({ preserveScreen: true }); + }); + + it("falls back to a full redraw when the document shrinks above the viewport", async () => { + const harness = createHarness(ROWS); + const redrawsBefore = harness.tui.fullRedraws; + harness.resetWritten(); + + // Rows left the document and the terminal cannot un-scroll, so the clamp point + // would leave the rows above it stale. + harness.transcript.lines = harness.transcript.lines.slice(0, -1); + harness.tui.renderNow(); + assert.strictEqual(harness.tui.fullRedraws, redrawsBefore + 1); const viewport = await harness.terminal.flushAndGetViewport(); assert.strictEqual(viewport[viewport.length - 1], "footer");