From cb4e4d8c9ad574e57228a41b905e25afd62f536c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 26 Mar 2026 10:40:10 +0100 Subject: [PATCH] fix(tui): reset viewport state after shrink --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/tui.ts | 35 +++++---- packages/tui/test/tui-render.test.ts | 108 +++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 16 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index a08dceab..d23d5f5d 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -10,6 +10,7 @@ - Fixed blockquote text color breaking after inline links (and other inline elements) due to missing style restoration prefix - Fixed slash-command Tab completion from immediately chaining into argument autocomplete after completing the command name, restoring flows like `/model` that submit into a selector dialog ([#2577](https://github.com/badlogic/pi-mono/issues/2577)) +- Fixed stale content and incorrect viewport tracking after TUI content shrinks or transient components inflate the working area ([#2126](https://github.com/badlogic/pi-mono/pull/2126) by [@Perlence](https://github.com/Perlence)) ## [0.62.0] - 2026-03-23 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index e6686fce..3fabe53d 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -874,8 +874,11 @@ export class TUI extends Container { if (this.stopped) return; const width = this.terminal.columns; const height = this.terminal.rows; - let viewportTop = Math.max(0, this.maxLinesRendered - height); - let prevViewportTop = this.previousViewportTop; + const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width; + const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height; + const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height; + let prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop; + let viewportTop = prevViewportTop; let hardwareCursorRow = this.hardwareCursorRow; const computeLineDiff = (targetRow: number): number => { const currentScreenRow = hardwareCursorRow - prevViewportTop; @@ -896,10 +899,6 @@ export class TUI extends Container { newLines = this.applyLineResets(newLines); - // Width or height changed - need full re-render - const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width; - const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height; - // Helper to clear scrollback and viewport and render all new lines const fullRender = (clear: boolean): void => { this.fullRedrawCount += 1; @@ -919,7 +918,8 @@ export class TUI extends Container { } else { this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); } - this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); + const bufferLength = Math.max(height, newLines.length); + this.previousViewportTop = Math.max(0, bufferLength - height); this.positionHardwareCursor(cursorPos, newLines.length); this.previousLines = newLines; this.previousWidth = width; @@ -993,7 +993,7 @@ export class TUI extends Container { // No changes - but still need to update hardware cursor position if it moved if (firstChanged === -1) { this.positionHardwareCursor(cursorPos, newLines.length); - this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); + this.previousViewportTop = prevViewportTop; this.previousHeight = height; return; } @@ -1004,6 +1004,11 @@ export class TUI extends Container { let buffer = "\x1b[?2026h"; // Move to end of new content (clamp to 0 for empty content) const targetRow = Math.max(0, newLines.length - 1); + if (targetRow < prevViewportTop) { + logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`); + fullRender(true); + return; + } const lineDiff = computeLineDiff(targetRow); if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`; else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`; @@ -1034,16 +1039,14 @@ export class TUI extends Container { this.previousLines = newLines; this.previousWidth = width; this.previousHeight = height; - this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); + this.previousViewportTop = prevViewportTop; return; } - // Check if firstChanged is above what was previously visible - // Use previousLines.length (not maxLinesRendered) to avoid false positives after content shrinks - const previousContentViewportTop = Math.max(0, this.previousLines.length - height); - if (firstChanged < previousContentViewportTop) { - // First change is above previous viewport - need full re-render - logRedraw(`firstChanged < viewportTop (${firstChanged} < ${previousContentViewportTop})`); + // Differential rendering can only touch what was actually visible. + // If the first changed line is above the previous viewport, we need a full redraw. + if (firstChanged < prevViewportTop) { + logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`); fullRender(true); return; } @@ -1175,7 +1178,7 @@ export class TUI extends Container { this.hardwareCursorRow = finalCursorRow; // Track terminal's working area (grows but doesn't shrink unless cleared) this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); - this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); + this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1); // Position hardware cursor for IME this.positionHardwareCursor(cursorPos, newLines.length); diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index 3570af0c..6d0acd15 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -398,4 +398,112 @@ describe("TUI differential rendering", () => { tui.stop(); }); + + it("full re-renders when deleted lines move the viewport upward", async () => { + const terminal = new VirtualTerminal(20, 5); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = Array.from({ length: 12 }, (_, i) => `Line ${i}`); + tui.start(); + await terminal.flush(); + + const initialRedraws = tui.fullRedraws; + + component.lines = Array.from({ length: 7 }, (_, i) => `Line ${i}`); + tui.requestRender(); + await terminal.flush(); + + assert.ok(tui.fullRedraws > initialRedraws, "Shrink should trigger a full redraw"); + assert.deepStrictEqual(terminal.getViewport(), ["Line 2", "Line 3", "Line 4", "Line 5", "Line 6"]); + + tui.stop(); + }); + + it("appends after a shrink without another full redraw once the viewport is reset", async () => { + const terminal = new VirtualTerminal(20, 5); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = Array.from({ length: 8 }, (_, i) => `Line ${i}`); + tui.start(); + await terminal.flush(); + + const initialRedraws = tui.fullRedraws; + + component.lines = ["Line 0", "Line 1"]; + tui.requestRender(); + await terminal.flush(); + + assert.ok(tui.fullRedraws > initialRedraws, "Shrink should reset the viewport with a full redraw"); + const redrawsAfterShrink = tui.fullRedraws; + + component.lines = ["Line 0", "Line 1", "Line 2"]; + tui.requestRender(); + await terminal.flush(); + + assert.strictEqual(tui.fullRedraws, redrawsAfterShrink, "Append should stay on the differential path"); + assert.deepStrictEqual(terminal.getViewport(), ["Line 0", "Line 1", "Line 2", "", ""]); + + tui.stop(); + }); + + it("clears stale content when maxLinesRendered was inflated by a transient component", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + const chat = new TestComponent(); + const editor = new TestComponent(); + tui.addChild(chat); + tui.addChild(editor); + + const longChat = Array.from({ length: 15 }, (_, i) => `Chat ${i}`); + const shortChat = Array.from({ length: 12 }, (_, i) => `Chat ${i}`); + const editorLines = ["Editor 0", "Editor 1", "Editor 2"]; + const selectorLines = Array.from({ length: 8 }, (_, i) => `Selector ${i}`); + + chat.lines = longChat; + editor.lines = editorLines; + tui.start(); + await terminal.flush(); + + editor.lines = selectorLines; + tui.requestRender(); + await terminal.flush(); + + editor.lines = editorLines; + tui.requestRender(); + await terminal.flush(); + + const redrawsBeforeSwitch = tui.fullRedraws; + chat.lines = shortChat; + tui.requestRender(); + await terminal.flush(); + + assert.ok(tui.fullRedraws > redrawsBeforeSwitch, "Branch switch should trigger a full redraw"); + + const viewport = terminal.getViewport(); + for (let i = 0; i < 10; i++) { + const line = viewport[i] ?? ""; + assert.ok(!line.includes("Chat 12"), `Stale "Chat 12" at viewport row ${i}`); + assert.ok(!line.includes("Chat 13"), `Stale "Chat 13" at viewport row ${i}`); + assert.ok(!line.includes("Chat 14"), `Stale "Chat 14" at viewport row ${i}`); + } + + assert.deepStrictEqual(viewport, [ + "Chat 5", + "Chat 6", + "Chat 7", + "Chat 8", + "Chat 9", + "Chat 10", + "Chat 11", + "Editor 0", + "Editor 1", + "Editor 2", + ]); + + tui.stop(); + }); });