fix(tui): reset viewport state after shrink
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
- Fixed blockquote text color breaking after inline links (and other inline elements) due to missing style restoration prefix
|
- 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 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
|
## [0.62.0] - 2026-03-23
|
||||||
|
|||||||
@@ -874,8 +874,11 @@ export class TUI extends Container {
|
|||||||
if (this.stopped) return;
|
if (this.stopped) return;
|
||||||
const width = this.terminal.columns;
|
const width = this.terminal.columns;
|
||||||
const height = this.terminal.rows;
|
const height = this.terminal.rows;
|
||||||
let viewportTop = Math.max(0, this.maxLinesRendered - height);
|
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
|
||||||
let prevViewportTop = this.previousViewportTop;
|
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;
|
let hardwareCursorRow = this.hardwareCursorRow;
|
||||||
const computeLineDiff = (targetRow: number): number => {
|
const computeLineDiff = (targetRow: number): number => {
|
||||||
const currentScreenRow = hardwareCursorRow - prevViewportTop;
|
const currentScreenRow = hardwareCursorRow - prevViewportTop;
|
||||||
@@ -896,10 +899,6 @@ export class TUI extends Container {
|
|||||||
|
|
||||||
newLines = this.applyLineResets(newLines);
|
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
|
// Helper to clear scrollback and viewport and render all new lines
|
||||||
const fullRender = (clear: boolean): void => {
|
const fullRender = (clear: boolean): void => {
|
||||||
this.fullRedrawCount += 1;
|
this.fullRedrawCount += 1;
|
||||||
@@ -919,7 +918,8 @@ export class TUI extends Container {
|
|||||||
} else {
|
} else {
|
||||||
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
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.positionHardwareCursor(cursorPos, newLines.length);
|
||||||
this.previousLines = newLines;
|
this.previousLines = newLines;
|
||||||
this.previousWidth = width;
|
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
|
// No changes - but still need to update hardware cursor position if it moved
|
||||||
if (firstChanged === -1) {
|
if (firstChanged === -1) {
|
||||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||||
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
|
this.previousViewportTop = prevViewportTop;
|
||||||
this.previousHeight = height;
|
this.previousHeight = height;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1004,6 +1004,11 @@ export class TUI extends Container {
|
|||||||
let buffer = "\x1b[?2026h";
|
let buffer = "\x1b[?2026h";
|
||||||
// Move to end of new content (clamp to 0 for empty content)
|
// Move to end of new content (clamp to 0 for empty content)
|
||||||
const targetRow = Math.max(0, newLines.length - 1);
|
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);
|
const lineDiff = computeLineDiff(targetRow);
|
||||||
if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
|
if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
|
||||||
else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
|
else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
|
||||||
@@ -1034,16 +1039,14 @@ export class TUI extends Container {
|
|||||||
this.previousLines = newLines;
|
this.previousLines = newLines;
|
||||||
this.previousWidth = width;
|
this.previousWidth = width;
|
||||||
this.previousHeight = height;
|
this.previousHeight = height;
|
||||||
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
|
this.previousViewportTop = prevViewportTop;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if firstChanged is above what was previously visible
|
// Differential rendering can only touch what was actually visible.
|
||||||
// Use previousLines.length (not maxLinesRendered) to avoid false positives after content shrinks
|
// If the first changed line is above the previous viewport, we need a full redraw.
|
||||||
const previousContentViewportTop = Math.max(0, this.previousLines.length - height);
|
if (firstChanged < prevViewportTop) {
|
||||||
if (firstChanged < previousContentViewportTop) {
|
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
|
||||||
// First change is above previous viewport - need full re-render
|
|
||||||
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${previousContentViewportTop})`);
|
|
||||||
fullRender(true);
|
fullRender(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1175,7 +1178,7 @@ export class TUI extends Container {
|
|||||||
this.hardwareCursorRow = finalCursorRow;
|
this.hardwareCursorRow = finalCursorRow;
|
||||||
// Track terminal's working area (grows but doesn't shrink unless cleared)
|
// Track terminal's working area (grows but doesn't shrink unless cleared)
|
||||||
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
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
|
// Position hardware cursor for IME
|
||||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||||
|
|||||||
@@ -398,4 +398,112 @@ describe("TUI differential rendering", () => {
|
|||||||
|
|
||||||
tui.stop();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user