diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 3fabe53dd7c..92d07f5dd2e 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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; @@ -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; } diff --git a/packages/tui/test/regression-offscreen-change-full-redraw.test.ts b/packages/tui/test/regression-offscreen-change-full-redraw.test.ts new file mode 100644 index 00000000000..8e725fb6b92 --- /dev/null +++ b/packages/tui/test/regression-offscreen-change-full-redraw.test.ts @@ -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((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(); + }); +});