mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 22:01:41 +00:00
fix(tui): auto-clear empty rows when content shrinks (#1095)
- Add height change detection (analogous to existing width detection) - Auto-detect when content shrinks below maxLinesRendered and trigger full re-render to clear leftover empty rows - Only triggers when no overlays are active (overlays need padding) Fixes empty rows appearing below footer when: - Closing /tree or other selectors - Clearing multi-line editor content - Any component shrinking Also adds regression tests for resize handling and content shrinkage.
This commit is contained in:
parent
bb0c2bf77a
commit
39c898d7c9
2 changed files with 144 additions and 4 deletions
|
|
@ -199,6 +199,7 @@ export class TUI extends Container {
|
||||||
public terminal: Terminal;
|
public terminal: Terminal;
|
||||||
private previousLines: string[] = [];
|
private previousLines: string[] = [];
|
||||||
private previousWidth = 0;
|
private previousWidth = 0;
|
||||||
|
private previousHeight = 0;
|
||||||
private focusedComponent: Component | null = null;
|
private focusedComponent: Component | null = null;
|
||||||
|
|
||||||
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
||||||
|
|
@ -396,6 +397,7 @@ export class TUI extends Container {
|
||||||
if (force) {
|
if (force) {
|
||||||
this.previousLines = [];
|
this.previousLines = [];
|
||||||
this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
|
this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
|
||||||
|
this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
|
||||||
this.cursorRow = 0;
|
this.cursorRow = 0;
|
||||||
this.hardwareCursorRow = 0;
|
this.hardwareCursorRow = 0;
|
||||||
this.maxLinesRendered = 0;
|
this.maxLinesRendered = 0;
|
||||||
|
|
@ -825,8 +827,9 @@ export class TUI extends Container {
|
||||||
|
|
||||||
newLines = this.applyLineResets(newLines);
|
newLines = this.applyLineResets(newLines);
|
||||||
|
|
||||||
// Width changed - need full re-render
|
// Width or height changed - need full re-render
|
||||||
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
|
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 => {
|
||||||
|
|
@ -851,16 +854,24 @@ export class TUI extends Container {
|
||||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||||
this.previousLines = newLines;
|
this.previousLines = newLines;
|
||||||
this.previousWidth = width;
|
this.previousWidth = width;
|
||||||
|
this.previousHeight = height;
|
||||||
};
|
};
|
||||||
|
|
||||||
// First render - just output everything without clearing (assumes clean screen)
|
// First render - just output everything without clearing (assumes clean screen)
|
||||||
if (this.previousLines.length === 0 && !widthChanged) {
|
if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {
|
||||||
fullRender(false);
|
fullRender(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Width changed - full re-render
|
// Width or height changed - full re-render
|
||||||
if (widthChanged) {
|
if (widthChanged || heightChanged) {
|
||||||
|
fullRender(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content shrunk below the working area and no overlays - re-render to clear empty rows
|
||||||
|
// (overlays need the padding, so only do this when no overlays are active)
|
||||||
|
if (newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) {
|
||||||
fullRender(true);
|
fullRender(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -930,6 +941,7 @@ export class TUI extends Container {
|
||||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||||
this.previousLines = newLines;
|
this.previousLines = newLines;
|
||||||
this.previousWidth = width;
|
this.previousWidth = width;
|
||||||
|
this.previousHeight = height;
|
||||||
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
|
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1076,6 +1088,7 @@ export class TUI extends Container {
|
||||||
|
|
||||||
this.previousLines = newLines;
|
this.previousLines = newLines;
|
||||||
this.previousWidth = width;
|
this.previousWidth = width;
|
||||||
|
this.previousHeight = height;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,133 @@ function getCellItalic(terminal: VirtualTerminal, row: number, col: number): num
|
||||||
return cell.isItalic();
|
return cell.isItalic();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("TUI resize handling", () => {
|
||||||
|
it("triggers full re-render when terminal height changes", async () => {
|
||||||
|
const terminal = new VirtualTerminal(40, 10);
|
||||||
|
const tui = new TUI(terminal);
|
||||||
|
const component = new TestComponent();
|
||||||
|
tui.addChild(component);
|
||||||
|
|
||||||
|
component.lines = ["Line 0", "Line 1", "Line 2"];
|
||||||
|
tui.start();
|
||||||
|
await terminal.flush();
|
||||||
|
|
||||||
|
const initialRedraws = tui.fullRedraws;
|
||||||
|
|
||||||
|
// Resize height
|
||||||
|
terminal.resize(40, 15);
|
||||||
|
await terminal.flush();
|
||||||
|
|
||||||
|
// Should have triggered a full redraw
|
||||||
|
assert.ok(tui.fullRedraws > initialRedraws, "Height change should trigger full redraw");
|
||||||
|
|
||||||
|
const viewport = terminal.getViewport();
|
||||||
|
assert.ok(viewport[0]?.includes("Line 0"), "Content preserved after height change");
|
||||||
|
|
||||||
|
tui.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggers full re-render when terminal width changes", async () => {
|
||||||
|
const terminal = new VirtualTerminal(40, 10);
|
||||||
|
const tui = new TUI(terminal);
|
||||||
|
const component = new TestComponent();
|
||||||
|
tui.addChild(component);
|
||||||
|
|
||||||
|
component.lines = ["Line 0", "Line 1", "Line 2"];
|
||||||
|
tui.start();
|
||||||
|
await terminal.flush();
|
||||||
|
|
||||||
|
const initialRedraws = tui.fullRedraws;
|
||||||
|
|
||||||
|
// Resize width
|
||||||
|
terminal.resize(60, 10);
|
||||||
|
await terminal.flush();
|
||||||
|
|
||||||
|
// Should have triggered a full redraw
|
||||||
|
assert.ok(tui.fullRedraws > initialRedraws, "Width change should trigger full redraw");
|
||||||
|
|
||||||
|
tui.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TUI content shrinkage", () => {
|
||||||
|
it("clears empty rows when content shrinks significantly", async () => {
|
||||||
|
const terminal = new VirtualTerminal(40, 10);
|
||||||
|
const tui = new TUI(terminal);
|
||||||
|
const component = new TestComponent();
|
||||||
|
tui.addChild(component);
|
||||||
|
|
||||||
|
// Start with many lines
|
||||||
|
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4", "Line 5"];
|
||||||
|
tui.start();
|
||||||
|
await terminal.flush();
|
||||||
|
|
||||||
|
const initialRedraws = tui.fullRedraws;
|
||||||
|
|
||||||
|
// Shrink to fewer lines
|
||||||
|
component.lines = ["Line 0", "Line 1"];
|
||||||
|
tui.requestRender();
|
||||||
|
await terminal.flush();
|
||||||
|
|
||||||
|
// Should have triggered a full redraw to clear empty rows
|
||||||
|
assert.ok(tui.fullRedraws > initialRedraws, "Content shrinkage should trigger full redraw");
|
||||||
|
|
||||||
|
const viewport = terminal.getViewport();
|
||||||
|
assert.ok(viewport[0]?.includes("Line 0"), "First line preserved");
|
||||||
|
assert.ok(viewport[1]?.includes("Line 1"), "Second line preserved");
|
||||||
|
// Lines below should be empty (cleared)
|
||||||
|
assert.strictEqual(viewport[2]?.trim(), "", "Line 2 should be cleared");
|
||||||
|
assert.strictEqual(viewport[3]?.trim(), "", "Line 3 should be cleared");
|
||||||
|
|
||||||
|
tui.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles shrink to single line", async () => {
|
||||||
|
const terminal = new VirtualTerminal(40, 10);
|
||||||
|
const tui = new TUI(terminal);
|
||||||
|
const component = new TestComponent();
|
||||||
|
tui.addChild(component);
|
||||||
|
|
||||||
|
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
|
||||||
|
tui.start();
|
||||||
|
await terminal.flush();
|
||||||
|
|
||||||
|
// Shrink to single line
|
||||||
|
component.lines = ["Only line"];
|
||||||
|
tui.requestRender();
|
||||||
|
await terminal.flush();
|
||||||
|
|
||||||
|
const viewport = terminal.getViewport();
|
||||||
|
assert.ok(viewport[0]?.includes("Only line"), "Single line rendered");
|
||||||
|
assert.strictEqual(viewport[1]?.trim(), "", "Line 1 should be cleared");
|
||||||
|
|
||||||
|
tui.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles shrink to empty", async () => {
|
||||||
|
const terminal = new VirtualTerminal(40, 10);
|
||||||
|
const tui = new TUI(terminal);
|
||||||
|
const component = new TestComponent();
|
||||||
|
tui.addChild(component);
|
||||||
|
|
||||||
|
component.lines = ["Line 0", "Line 1", "Line 2"];
|
||||||
|
tui.start();
|
||||||
|
await terminal.flush();
|
||||||
|
|
||||||
|
// Shrink to empty
|
||||||
|
component.lines = [];
|
||||||
|
tui.requestRender();
|
||||||
|
await terminal.flush();
|
||||||
|
|
||||||
|
const viewport = terminal.getViewport();
|
||||||
|
// All lines should be empty
|
||||||
|
assert.strictEqual(viewport[0]?.trim(), "", "Line 0 should be cleared");
|
||||||
|
assert.strictEqual(viewport[1]?.trim(), "", "Line 1 should be cleared");
|
||||||
|
|
||||||
|
tui.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("TUI differential rendering", () => {
|
describe("TUI differential rendering", () => {
|
||||||
it("tracks cursor correctly when content shrinks with unchanged remaining lines", async () => {
|
it("tracks cursor correctly when content shrinks with unchanged remaining lines", async () => {
|
||||||
const terminal = new VirtualTerminal(40, 10);
|
const terminal = new VirtualTerminal(40, 10);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue