fix(tui): reset viewport state after shrink

This commit is contained in:
Mario Zechner
2026-03-26 10:40:10 +01:00
parent 7e94d36a44
commit cb4e4d8c9a
3 changed files with 128 additions and 16 deletions

View File

@@ -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

View File

@@ -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);

View File

@@ -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();
});
});