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");