diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 1fff65456..c07489091 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1309,12 +1309,56 @@ export class TUI extends Container { 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) - // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var - if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) { - logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`); - fullRender(true); + const redrawViewport = (nextViewportTop: number): void => { + let buffer = "\x1b[?2026h"; + buffer += this.deleteChangedKittyImages(prevViewportTop, prevViewportTop + height - 1); + + let screenRow = 0; + while (screenRow < height) { + const lineIndex = nextViewportTop + screenRow; + const line = newLines[lineIndex] ?? ""; + const isImage = isImageLine(line); + const imageReservedRows = isImage + ? this.getKittyImageReservedRows(newLines, lineIndex, nextViewportTop + height - 1) + : 1; + + buffer += `\x1b[${screenRow + 1};1H`; + buffer += "\x1b[2K"; + + if (isImage && imageReservedRows > 1 && screenRow + imageReservedRows <= height) { + for (let row = 1; row < imageReservedRows; row++) { + buffer += `\x1b[${screenRow + row + 1};1H`; + buffer += "\x1b[2K"; + } + buffer += `\x1b[${screenRow + 1};1H`; + buffer += line; + screenRow += imageReservedRows; + continue; + } + + buffer += line; + screenRow += 1; + } + + buffer += "\x1b[?2026l"; + this.terminal.write(buffer); + this.cursorRow = Math.max(0, newLines.length - 1); + this.hardwareCursorRow = nextViewportTop + height - 1; + this.maxLinesRendered = newLines.length; + this.previousViewportTop = nextViewportTop; + this.positionHardwareCursor(cursorPos, newLines.length); + this.previousLines = newLines; + this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousWidth = width; + this.previousHeight = height; + }; + + const shrinkClearEnabled = + this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0; + const shrinkViewportTop = Math.max(0, newLines.length - height); + if (shrinkClearEnabled && shrinkViewportTop < prevViewportTop) { + logRedraw(`clearOnShrink viewport redraw (maxLinesRendered=${this.maxLinesRendered})`); + redrawViewport(shrinkViewportTop); return; } @@ -1364,7 +1408,11 @@ export class TUI extends Container { const targetRow = Math.max(0, newLines.length - 1); if (targetRow < prevViewportTop) { logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`); - fullRender(true); + if (shrinkClearEnabled) { + redrawViewport(shrinkViewportTop); + } else { + fullRender(true); + } return; } const lineDiff = computeLineDiff(targetRow); @@ -1375,7 +1423,11 @@ export class TUI extends Container { const extraLines = this.previousLines.length - newLines.length; if (extraLines > height) { logRedraw(`extraLines > height (${extraLines} > ${height})`); - fullRender(true); + if (shrinkClearEnabled) { + redrawViewport(shrinkViewportTop); + } else { + fullRender(true); + } return; } const clearStartOffset = newLines.length === 0 ? 0 : 1; @@ -1408,7 +1460,11 @@ export class TUI extends Container { // 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); + if (shrinkClearEnabled) { + redrawViewport(shrinkViewportTop); + } else { + fullRender(true); + } return; } diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index ab038ac79..35c5ee41f 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -61,8 +61,12 @@ async function withEnv(updates: Record, run: () = } } +function getXterm(terminal: VirtualTerminal): XtermTerminalType { + return (terminal as unknown as { xterm: XtermTerminalType }).xterm; +} + function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number { - const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm; + const xterm = getXterm(terminal); const buffer = xterm.buffer.active; const line = buffer.getLine(buffer.viewportY + row); assert.ok(line, `Missing buffer line at row ${row}`); @@ -402,8 +406,8 @@ describe("TUI resize handling", () => { }); describe("TUI content shrinkage", () => { - it("clears empty rows when content shrinks significantly", async () => { - const terminal = new VirtualTerminal(40, 10); + it("clears empty rows differentially when content shrinks significantly", async () => { + const terminal = new LoggingVirtualTerminal(40, 10); const tui = new TUI(terminal); tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var) const component = new TestComponent(); @@ -415,14 +419,16 @@ describe("TUI content shrinkage", () => { await terminal.waitForRender(); const initialRedraws = tui.fullRedraws; + terminal.clearWrites(); // Shrink to fewer lines component.lines = ["Line 0", "Line 1"]; tui.requestRender(); await terminal.waitForRender(); - // Should have triggered a full redraw to clear empty rows - assert.ok(tui.fullRedraws > initialRedraws, "Content shrinkage should trigger full redraw"); + assert.strictEqual(tui.fullRedraws, initialRedraws, "Content shrinkage should stay on the differential path"); + assert.ok(!terminal.getWrites().includes("\x1b[2J"), "Differential shrink should not clear the whole screen"); + assert.ok(!terminal.getWrites().includes("\x1b[3J"), "Differential shrink should not clear scrollback"); const viewport = terminal.getViewport(); assert.ok(viewport[0]?.includes("Line 0"), "First line preserved"); @@ -434,6 +440,93 @@ describe("TUI content shrinkage", () => { tui.stop(); }); + it("preserves detached scrollback viewport when clearOnShrink redraws the viewport", async () => { + const terminal = new LoggingVirtualTerminal(20, 5); + const tui = new TUI(terminal); + tui.setClearOnShrink(true); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = Array.from({ length: 20 }, (_, i) => `Line ${i}`); + tui.start(); + await terminal.waitForRender(); + + const initialRedraws = tui.fullRedraws; + terminal.clearWrites(); + const xterm = getXterm(terminal); + xterm.scrollLines(-10); + const viewportYBefore = xterm.buffer.active.viewportY; + const viewportBefore = terminal.getViewport(); + + component.lines = Array.from({ length: 18 }, (_, i) => `Line ${i}`); + tui.requestRender(); + await terminal.waitForRender(); + + assert.strictEqual(tui.fullRedraws, initialRedraws, "Tall shrink should avoid the full-render path"); + assert.ok(!terminal.getWrites().includes("\x1b[2J"), "Tall shrink should not clear the whole screen"); + assert.ok(!terminal.getWrites().includes("\x1b[3J"), "Tall shrink should not clear scrollback"); + assert.strictEqual( + xterm.buffer.active.viewportY, + viewportYBefore, + "Detached viewport position should be preserved", + ); + assert.deepStrictEqual(terminal.getViewport(), viewportBefore); + + tui.stop(); + }); + + it("keeps the bottom pinned when clearOnShrink redraws the viewport", async () => { + const terminal = new LoggingVirtualTerminal(20, 5); + const tui = new TUI(terminal); + tui.setClearOnShrink(true); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = Array.from({ length: 20 }, (_, i) => `Line ${i}`); + tui.start(); + await terminal.waitForRender(); + + const initialRedraws = tui.fullRedraws; + terminal.clearWrites(); + + component.lines = Array.from({ length: 18 }, (_, i) => `Line ${i}`); + tui.requestRender(); + await terminal.waitForRender(); + + assert.strictEqual(tui.fullRedraws, initialRedraws, "Tall shrink should avoid the full-render path"); + assert.ok(!terminal.getWrites().includes("\x1b[2J"), "Tall shrink should not clear the whole screen"); + assert.ok(!terminal.getWrites().includes("\x1b[3J"), "Tall shrink should not clear scrollback"); + assert.deepStrictEqual(terminal.getViewport(), ["Line 13", "Line 14", "Line 15", "Line 16", "Line 17"]); + + tui.stop(); + }); + + it("redraws shrink viewport rows independently when lines fill the terminal width", async () => { + const terminal = new LoggingVirtualTerminal(20, 5); + const tui = new TUI(terminal); + tui.setClearOnShrink(true); + const component = new TestComponent(); + tui.addChild(component); + const makeLine = (index: number): string => `Line ${index.toString().padStart(2, "0")} ${"x".repeat(12)}`; + + component.lines = Array.from({ length: 20 }, (_, i) => makeLine(i)); + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + component.lines = Array.from({ length: 18 }, (_, i) => makeLine(i)); + tui.requestRender(); + await terminal.waitForRender(); + + assert.ok(!terminal.getWrites().includes("\r\n"), "Viewport redraw should not depend on newline row advances"); + assert.deepStrictEqual( + terminal.getViewport(), + [13, 14, 15, 16, 17].map((index) => makeLine(index)), + ); + + tui.stop(); + }); + it("handles shrink to single line", async () => { const terminal = new VirtualTerminal(40, 10); const tui = new TUI(terminal);