From 39c898d7c984201643b1956083021bbb8717b1d2 Mon Sep 17 00:00:00 2001 From: Marc Krenn Date: Fri, 30 Jan 2026 20:45:34 +0100 Subject: [PATCH] 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. --- packages/tui/src/tui.ts | 21 ++++- packages/tui/test/tui-render.test.ts | 127 +++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 92ecd46a..ed9ffd98 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -199,6 +199,7 @@ export class TUI extends Container { public terminal: Terminal; private previousLines: string[] = []; private previousWidth = 0; + private previousHeight = 0; private focusedComponent: Component | null = null; /** 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) { this.previousLines = []; this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear + this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear this.cursorRow = 0; this.hardwareCursorRow = 0; this.maxLinesRendered = 0; @@ -825,8 +827,9 @@ export class TUI extends Container { 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 heightChanged = this.previousHeight !== 0 && this.previousHeight !== height; // Helper to clear scrollback and viewport and render all new lines const fullRender = (clear: boolean): void => { @@ -851,16 +854,24 @@ export class TUI extends Container { this.positionHardwareCursor(cursorPos, newLines.length); this.previousLines = newLines; this.previousWidth = width; + this.previousHeight = height; }; // 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); return; } - // Width changed - full re-render - if (widthChanged) { + // Width or height changed - full re-render + 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); return; } @@ -930,6 +941,7 @@ export class TUI extends Container { this.positionHardwareCursor(cursorPos, newLines.length); this.previousLines = newLines; this.previousWidth = width; + this.previousHeight = height; this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); return; } @@ -1076,6 +1088,7 @@ export class TUI extends Container { this.previousLines = newLines; this.previousWidth = width; + this.previousHeight = height; } /** diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index e22a349e..32ae4ea4 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -22,6 +22,133 @@ function getCellItalic(terminal: VirtualTerminal, row: number, col: number): num 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", () => { it("tracks cursor correctly when content shrinks with unchanged remaining lines", async () => { const terminal = new VirtualTerminal(40, 10);