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 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user