Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions packages/tui/src/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,15 @@ export class TUI extends Container {

newLines = this.applyLineResets(newLines);

// Sync render state for early returns that don't move the viewport.
const commitState = (): void => {
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
this.previousHeight = height;
this.previousViewportTop = prevViewportTop;
};

// Helper to clear scrollback and viewport and render all new lines
const fullRender = (clear: boolean): void => {
this.fullRedrawCount += 1;
Expand Down Expand Up @@ -1035,11 +1044,15 @@ export class TUI extends Container {
this.cursorRow = targetRow;
this.hardwareCursorRow = targetRow;
}
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
this.previousHeight = height;
this.previousViewportTop = prevViewportTop;
commitState();
return;
}

// All changes are strictly above the visible viewport (e.g. a spinner ticking
// offscreen while a tall ui.custom dialog fills the screen). The terminal
// already shows the correct pixels, so just sync state without writing.
if (lastChanged < prevViewportTop && newLines.length === this.previousLines.length) {
commitState();
return;
}

Expand Down
62 changes: 62 additions & 0 deletions packages/tui/test/regression-offscreen-change-full-redraw.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { type Component, TUI } from "../src/tui.js";
import { VirtualTerminal } from "./virtual-terminal.js";

/**
* Regression: when content is taller than the terminal and a component above
* the visible viewport changes (e.g. the Loader spinner ticking while a tall
* ui.custom dialog fills the screen), doRender() hit the
* `firstChanged < prevViewportTop` branch and did a fullRender(true) on every
* frame — manifesting as the screen "scrolling like crazy".
*/

class Lines implements Component {
constructor(public lines: string[]) {}
render(): string[] {
return this.lines;
}
invalidate() {}
}

const tick = () => new Promise<void>((r) => process.nextTick(r));

describe("TUI differential render with offscreen changes", () => {
it("does not full-redraw when the only change is above the viewport", async () => {
const rows = 10;
const terminal = new VirtualTerminal(80, rows);
const tui = new TUI(terminal);

// chat + spinner are pushed above the viewport by a tall dialog
const spinner = new Lines(["⠋ Working"]);
const dialog = new Lines(Array.from({ length: rows + 2 }, (_, i) => `dialog ${i}`));
tui.addChild(new Lines(["chat 0", "chat 1", "chat 2"]));
tui.addChild(spinner);
tui.addChild(dialog);

tui.start();
await tick();
await terminal.flush();

const initialViewport = terminal.getViewport();
assert.ok(!initialViewport.some((l) => l.includes("Working")), "precondition: spinner is offscreen");
assert.ok(
initialViewport.some((l) => l.includes(`dialog ${rows + 1}`)),
"precondition: dialog bottom visible",
);

const redrawsBefore = tui.fullRedraws;

for (const f of ["⠙", "⠹", "⠸", "⠼", "⠴"]) {
spinner.lines = [`${f} Working`];
tui.requestRender();
await tick();
}
await terminal.flush();

assert.strictEqual(tui.fullRedraws, redrawsBefore, "offscreen-only change must not trigger fullRender");
assert.deepStrictEqual(terminal.getViewport(), initialViewport, "viewport must remain stable");

tui.stop();
});
});
Loading