diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index c0bc01e36..d7e4aca56 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1335,8 +1335,14 @@ export class TUI extends Container { ) { return { kind: "historyRebuild" }; } + // POSIX terminals that cannot report viewport position fall through here + // (`canRebuildNativeScrollbackLive` is false): a viewport-only repaint would + // bottom-anchor `newLines` and re-emit the rows between the new and old + // viewport tops on top of the copies the terminal already kept in native + // scrollback. Pad to the previous row count instead and let the next + // checkpoint rebuild (e.g. prompt submit) clean up. this.#markNativeScrollbackDirty(); - return { kind: "viewportRepaint" }; + return { kind: "deferredShrink", paddedLength: this.#previousLines.length }; } const suppressSuffixScroll = this.#suppressNextSuffixScroll; diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index c0cd54602..9d00fd27e 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -1531,6 +1531,56 @@ describe("TUI terminal-state regressions", () => { tui.stop(); } }); + it("defers bottom-anchored shrink when POSIX viewport state is unknown", async () => { + // Repro for #1566 follow-up (kitty/Linux): a bottom-anchored shrink across the + // viewport boundary used to fall through to `viewportRepaint`, which redrew the + // new transcript at `newLength - height` while leaving rows + // `[newLength - height .. prevLength - height - 1]` already in native + // scrollback — they reappeared at the top of the viewport, duplicating two rows + // at the boundary in the captured trace. + const term = new UnknownViewportTerminal(40, 6); + const tui = new TUI(term); + const body = rows("line-", 12); + const component = new MutableLinesComponent([...body, "spinner-row", "spacer-row", "prompt-row"]); + tui.addChild(component); + + try { + tui.start(); + await settle(term); + + component.setLines([...body, "prompt-row"]); + tui.requestRender(); + await settle(term); + + const scrollback = term.getScrollBuffer(); + for (let i = 0; i < body.length; i++) { + const pattern = new RegExp(`\\bline-${i}\\b`); + expect( + countMatches(scrollback, pattern), + `line-${i} must not duplicate at boundary`, + ).toBeLessThanOrEqual(1); + } + + expect(tui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true })).toBe(true); + await settle(term); + expect(visible(term).map(line => line.trim())).toEqual([ + "line-7", + "line-8", + "line-9", + "line-10", + "line-11", + "prompt-row", + ]); + const rebuilt = term.getScrollBuffer(); + for (let i = 0; i < body.length; i++) { + const pattern = new RegExp(`\\bline-${i}\\b`); + expect(countMatches(rebuilt, pattern), `line-${i} appears once post-checkpoint`).toBe(1); + } + } finally { + tui.stop(); + } + }); + it("renders streaming row inserts on WSL Windows Terminal even when viewport probe is unavailable", async () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { configurable: true, value: "linux" });