fix(tui): deferred bottom-anchored shrink on unknown posix viewports

A shrink across the viewport boundary on POSIX terminals that cannot report scrollback position (kitty, plain xterm) fell through to viewportRepaint. Repainting bottom-anchored newLines at newLength - height left rows newLength - height .. prevLength - height - 1 already committed to native scrollback, so they reappeared at the viewport top — two duplicated rows at the scrollback/viewport boundary in bjin's trace.\n\nMark scrollback dirty and emit deferredShrink instead (padding to the previous row count) so no native rows are re-emitted; the next checkpoint rebuild (e.g. prompt submit -> refreshNativeScrollbackIfDirty) cleans up.\n\nFixes #1566
This commit is contained in:
roboomp
2026-05-31 16:47:24 +00:00
parent 38fcd53d64
commit 0887d28c7f
2 changed files with 57 additions and 1 deletions
+7 -1
View File
@@ -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;
@@ -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" });