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:
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user