From f9f7763f4c2be3dbaac8202b3e76d648cc84b3d8 Mon Sep 17 00:00:00 2001 From: rlaope Date: Fri, 14 Aug 2026 10:21:16 +0900 Subject: [PATCH] fix(tui): keep shrunken tails at buffer bottom --- packages/tui/src/changes.md | 32 ++++++ packages/tui/src/tui-main-screen.ts | 9 ++ packages/tui/src/tui.ts | 124 ++++++++++++++++++++++-- packages/tui/test/sgr-row-clear.test.ts | 16 +-- packages/tui/test/tui-render.test.ts | 101 +++++++++---------- packages/tui/test/tui-shrink.test.ts | 98 ++++++++++++++++++- 6 files changed, 309 insertions(+), 71 deletions(-) diff --git a/packages/tui/src/changes.md b/packages/tui/src/changes.md index 998d293b7b..cdaff5a460 100644 --- a/packages/tui/src/changes.md +++ b/packages/tui/src/changes.md @@ -1,5 +1,37 @@ # TUI delta rendering fork changes +## 2026-08-14: main-screen shrink keeps the document tail at the buffer bottom + +### What changed + +- Stable-size, non-multiplexer main-screen renders now retain a tracked blank gap at the first changed document row + when a mounted tail shrinks. The physical frame stays the same length, so the editor/status/footer remain on the + terminal's bottom row without clearing or replaying scrollback. +- Later document growth consumes the gap before it can extend the terminal buffer. Cursor coordinates, memoized raw + lines, Kitty image row boundaries, lifecycle resets, and main-screen/fullscreen state transfers account for the gap. +- Default scrollback replay no longer emits `ESC[3J`; the explicit legacy-mux renderer keeps its previous byte shape. +- Headless-xterm regressions cover a selector-shaped grow/shrink/regrow cycle, bounded repaint work, cursor placement, + zero trailing blank rows, no screen/scrollback clears, and no duplicated history. + +### Why + +Inline selectors and autocomplete temporarily add rows above the editor tail. Removing those rows previously moved the +rendered tail upward in place while the terminal buffer bottom stayed fixed, leaving a scrollable blank region below +Senpi. Clearing the screen fixed the geometry only by destroying scrollback, while replaying the document duplicated +history and made repaint cost scale with the transcript. + +### Why this cannot be expressed externally + +The fix depends on private renderer snapshots, viewport offsets, normalization memo state, hardware-cursor rows, and +terminal diff planning. Components can change their rendered line count but cannot preserve the physical line-space +mapping or safely alter the emitted cursor operations. + +### Expected merge conflict zones + +- HIGH: `packages/tui/src/tui.ts` around main-screen render state, normalization input, scrollback replay, and + `doRender()` cursor extraction. +- LOW: `packages/tui/src/tui-main-screen.ts` render-state transfer fields and shrink/render regression assertions. + ## 2026-08-05: dead-terminal raw-mode restoration is best-effort during shutdown ### What changed diff --git a/packages/tui/src/tui-main-screen.ts b/packages/tui/src/tui-main-screen.ts index 68ebc1a06f..798deb817a 100644 --- a/packages/tui/src/tui-main-screen.ts +++ b/packages/tui/src/tui-main-screen.ts @@ -3,12 +3,15 @@ import { TuiBase } from "./tui.ts"; export interface TuiMainScreenRenderState { previousLines: string[]; + previousRawLines: string[]; previousWidth: number; previousHeight: number; cursorRow: number; hardwareCursorRow: number; maxLinesRendered: number; previousViewportTop: number; + tailAnchorGap: number; + tailAnchorGapIndex: number; } /** TUI implementation that renders into the terminal's main screen and scrollback. */ @@ -18,17 +21,21 @@ export class TuiMainScreen extends TuiBase { captureRenderState(): TuiMainScreenRenderState { return { previousLines: [...this.previousLines], + previousRawLines: [...this.previousRawLines], previousWidth: this.previousWidth, previousHeight: this.previousHeight, cursorRow: this.cursorRow, hardwareCursorRow: this.hardwareCursorRow, maxLinesRendered: this.maxLinesRendered, previousViewportTop: this.previousViewportTop, + tailAnchorGap: this.tailAnchorGap, + tailAnchorGapIndex: this.tailAnchorGapIndex, }; } restoreRenderState(state: TuiMainScreenRenderState): void { this.previousLines = state.previousLines.map((line) => (isImageLine(line) ? "" : line)); + this.previousRawLines = state.previousRawLines.map((line) => (isImageLine(line) ? "" : line)); this.previousKittyImageIds = new Set(); this.previousWidth = state.previousWidth; this.previousHeight = state.previousHeight; @@ -36,5 +43,7 @@ export class TuiMainScreen extends TuiBase { this.hardwareCursorRow = state.hardwareCursorRow; this.maxLinesRendered = state.maxLinesRendered; this.previousViewportTop = state.previousViewportTop; + this.tailAnchorGap = state.tailAnchorGap; + this.tailAnchorGapIndex = state.tailAnchorGapIndex; } } diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 2e92f0a1d6..e1e4b4f447 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -557,7 +557,7 @@ export abstract class TuiBase extends Container { abstract readonly mode: TuiMode; public terminal: Terminal; protected previousLines: string[] = []; - private previousRawLines: string[] = []; + protected previousRawLines: string[] = []; private normalizeMemo = new Map(); protected previousKittyImageIds = new Set(); protected previousWidth = 0; @@ -579,6 +579,9 @@ export abstract class TuiBase extends Container { private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off) protected maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered) protected previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves + // Blank rows retained above a shrunken tail so the terminal buffer bottom does not move away from the UI. + protected tailAnchorGap = 0; + protected tailAnchorGapIndex = 0; protected fullRedrawCount = 0; private muxViewportRepaintCount = 0; private overWideCrashDumpWritten = false; @@ -1031,6 +1034,8 @@ export abstract class TuiBase extends Container { this.hardwareCursorRow = 0; this.maxLinesRendered = 0; this.previousViewportTop = 0; + this.tailAnchorGap = 0; + this.tailAnchorGapIndex = 0; } renderNow(force = false): void { @@ -1102,6 +1107,8 @@ export abstract class TuiBase extends Container { this.hardwareCursorRow = 0; this.maxLinesRendered = 0; this.previousViewportTop = 0; + this.tailAnchorGap = 0; + this.tailAnchorGapIndex = 0; } private cancelRenderTimer(): void { @@ -1506,6 +1513,104 @@ export abstract class TuiBase extends Container { this.previousRawLines = rawLines; } + private resetTailAnchorGap(): void { + this.tailAnchorGap = 0; + this.tailAnchorGapIndex = 0; + } + + private getPreviousDocumentLength(): number { + return Math.max(0, this.previousRawLines.length - this.tailAnchorGap); + } + + private getPreviousDocumentLine(index: number): string { + const rawIndex = index < this.tailAnchorGapIndex ? index : index + this.tailAnchorGap; + return this.previousRawLines[rawIndex] ?? ""; + } + + private findFirstDocumentChange(lines: string[]): number { + const previousLength = this.getPreviousDocumentLength(); + const sharedLength = Math.min(previousLength, lines.length); + for (let index = 0; index < sharedLength; index++) { + if (this.getPreviousDocumentLine(index) !== lines[index]) { + return index; + } + } + return sharedLength; + } + + private clampTailAnchorGapIndex(lines: string[], requestedIndex: number): number { + let gapIndex = Math.max(0, Math.min(requestedIndex, lines.length - 1)); + for (let index = 0; index < gapIndex; index++) { + if (!isImageLine(lines[index] ?? "")) continue; + const imageEnd = index + this.getKittyImageReservedRows(lines, index); + if (gapIndex < imageEnd) { + gapIndex = index; + break; + } + } + return gapIndex; + } + + /** Keep shrink/regrowth frames the same physical length while a bottom-anchored tail remains mounted. */ + private applyTailAnchorGap( + lines: string[], + height: number, + stableDimensions: boolean, + muxSession: boolean, + ): string[] { + if ( + !stableDimensions || + muxSession || + this.clearOnShrink || + this.previousRawLines.length === 0 || + lines.length === 0 + ) { + this.resetTailAnchorGap(); + return lines; + } + + const previousDocumentLength = this.getPreviousDocumentLength(); + const lineCountDelta = lines.length - previousDocumentLength; + const firstChanged = lineCountDelta === 0 ? -1 : this.findFirstDocumentChange(lines); + const previousTail = this.previousRawLines[this.previousRawLines.length - 1] ?? ""; + const tailWasAnchored = + this.previousLines.length === this.previousViewportTop + height && + (isImageLine(previousTail) || visibleWidth(previousTail) > 0); + + let nextGap = this.tailAnchorGap; + let nextGapIndex = this.tailAnchorGapIndex; + if (lineCountDelta < 0) { + if (nextGap > 0) { + nextGap -= lineCountDelta; + if (firstChanged < nextGapIndex) { + nextGapIndex += lineCountDelta; + } + } else if (tailWasAnchored && firstChanged < lines.length) { + nextGap = -lineCountDelta; + nextGapIndex = firstChanged; + } + } else if (lineCountDelta > 0 && nextGap > 0) { + nextGap = Math.max(0, nextGap - lineCountDelta); + if (firstChanged < nextGapIndex) { + nextGapIndex += lineCountDelta; + } + } + + if (nextGap === 0) { + this.resetTailAnchorGap(); + return lines; + } + + this.tailAnchorGap = nextGap; + this.tailAnchorGapIndex = this.clampTailAnchorGapIndex(lines, nextGapIndex); + + return [ + ...lines.slice(0, this.tailAnchorGapIndex), + ...Array.from({ length: this.tailAnchorGap }, () => ""), + ...lines.slice(this.tailAnchorGapIndex), + ]; + } + private normalizeLine(line: string): { line: string; normalized: boolean } { if (isImageLine(line)) { return { line, normalized: false }; @@ -1701,8 +1806,8 @@ export abstract class TuiBase extends Container { return Array.from({ length: height }, (_, row) => lines[viewportTop + row] ?? ""); } - private shouldPreserveMuxScrollback(): boolean { - return this.#muxDetector() && !useLegacyMuxRender(); + private isMuxSession(): boolean { + return this.#muxDetector(); } private createViewportInsertScrollPlan( @@ -1811,7 +1916,7 @@ export abstract class TuiBase extends Container { ): void { let buffer = TUI.FRAME_BEGIN; buffer += this.deleteKittyImages(this.previousKittyImageIds); - if (!this.shouldPreserveMuxScrollback()) { + if (this.isMuxSession() && useLegacyMuxRender()) { buffer += "\x1b[3J"; } @@ -2001,7 +2106,13 @@ export abstract class TuiBase extends Container { } // Extract cursor position before applying line resets (marker must be found first) - const cursorPos = this.extractCursorPosition(newLines, height); + let cursorPos = this.extractCursorPosition(newLines, height); + const muxSession = this.isMuxSession(); + const preserveMuxScrollback = muxSession && !useLegacyMuxRender(); + newLines = this.applyTailAnchorGap(newLines, height, !widthChanged && !heightChanged, muxSession); + if (cursorPos && this.tailAnchorGap > 0 && cursorPos.row >= this.tailAnchorGapIndex) { + cursorPos = { row: cursorPos.row + this.tailAnchorGap, col: cursorPos.col }; + } const rawLines = newLines; const normalizedLines = this.applyViewportLineResets( @@ -2011,7 +2122,6 @@ export abstract class TuiBase extends Container { !widthChanged && !heightChanged, ); newLines = normalizedLines.lines; - const preserveMuxScrollback = this.shouldPreserveMuxScrollback(); // Helper to clear scrollback and viewport and render all new lines const fullRender = (clear: boolean, clearScrollback = clear): void => { @@ -2149,7 +2259,9 @@ export abstract class TuiBase extends Container { // No changes - but still need to update hardware cursor position if it moved if (firstChanged === -1) { this.positionHardwareCursor(cursorPos, newLines.length); + this.setPreviousLines(newLines, rawLines); this.previousViewportTop = prevViewportTop; + this.previousWidth = width; this.previousHeight = height; return; } diff --git a/packages/tui/test/sgr-row-clear.test.ts b/packages/tui/test/sgr-row-clear.test.ts index 06bd76e063..87d0de032d 100644 --- a/packages/tui/test/sgr-row-clear.test.ts +++ b/packages/tui/test/sgr-row-clear.test.ts @@ -112,23 +112,25 @@ describe("TUI row clears reset stale SGR state", () => { }); it("resets after row clears on scrollback replay", async () => { - const terminal = new LoggingVirtualTerminal(72, 6); + const terminal = new LoggingVirtualTerminal(72, 5); const tui = new TUI(terminal); const component = new ExpandableTranscriptComponent(); tui.addChild(component); - component.setExpanded(true); + component.setExpanded(false); tui.start(); - await terminal.waitForRender(); + tui.renderNow(); + await terminal.flush(); armStaleSgr(terminal); try { - component.setExpanded(false); - tui.requestRender(); - await terminal.waitForRender(); + component.setExpanded(true); + tui.renderNow(); + await terminal.flush(); const writes = terminal.getWrites(); - assert.ok(writes.includes("\x1b[3J"), "scenario should use scrollback replay"); + assert.ok(writes.includes("session title"), "scenario should use scrollback replay"); + assert.ok(!writes.includes("\x1b[3J"), "scrollback replay should preserve existing history"); assertEveryRowClearResets(writes, "scrollback replay"); } finally { tui.stop(); diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index 755c64242b..1bd6721824 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -212,6 +212,11 @@ async function runStreamingFlickerBudget(): Promise { return metrics; } +async function renderImmediately(tui: TUI, terminal: VirtualTerminal): Promise { + tui.renderNow(); + await terminal.flush(); +} + async function withEnv(updates: Record, run: () => Promise): Promise { const previousValues = new Map(); for (const [key, value] of Object.entries(updates)) { @@ -753,7 +758,7 @@ describe("TUI viewport remap for above-viewport growth", () => { tui.stop(); }); - it("replays scrollback without viewport clear when collapse changes hidden rows", async () => { + it("keeps a hidden collapsed tail anchored without replay", async () => { const terminal = new LoggingVirtualTerminal(72, 6); const tui = new TUI(terminal); const component = new ExpandableTranscriptComponent(); @@ -762,30 +767,28 @@ describe("TUI viewport remap for above-viewport growth", () => { // given component.setExpanded(true); tui.start(); - await terminal.waitForRender(); + await renderImmediately(tui, terminal); terminal.clearWrites(); const initialFullRedraws = tui.fullRedraws; + const initialBufferLength = terminal.getScrollBuffer().length; // when component.setExpanded(false); - tui.requestRender(); - await terminal.waitForRender(); + await renderImmediately(tui, terminal); // then const writes = terminal.getWrites(); assert.strictEqual(tui.fullRedraws, initialFullRedraws, "Collapse should not full-redraw the viewport"); assert.ok(!writes.includes("\x1b[2J"), "Collapse should not clear the viewport"); - assert.ok(writes.includes("\x1b[3J"), "Collapse should reset stale scrollback"); + assert.ok(!writes.includes("\x1b[3J"), "Collapse should not clear scrollback"); + assert.strictEqual(writes, "", "A fully hidden shrink should not replay the document"); + const scrollback = terminal.getScrollBuffer(); + assert.strictEqual(scrollback.length, initialBufferLength, "Collapse should not extend the terminal buffer"); assert.strictEqual( - countOccurrences(writes, "\x1b[?2026h"), - countOccurrences(writes, "\x1b[?2026l"), - "Collapse should keep DECSET 2026 begin/end balanced", - ); - assert.deepStrictEqual( - getScrollbackSuffix(terminal.getScrollBuffer(), 8), - ["session title", "tools", "tail row 0", "tail row 1", "tail row 2", "tail row 3", "tail row 4", "tail row 5"], - "Latest canonical scrollback segment should be collapsed", + scrollback.filter((line) => line === "session title").length, + 1, + "Collapse should not duplicate scrolled-out history", ); assert.deepStrictEqual(terminal.getViewport(), [ "tail row 0", @@ -799,7 +802,7 @@ describe("TUI viewport remap for above-viewport growth", () => { tui.stop(); }); - it("keeps viewport stable across flicker-free Ctrl+O replay toggles", async () => { + it("keeps viewport stable across gap-backed Ctrl+O toggles", async () => { const terminal = new LoggingVirtualTerminal(72, 6); const tui = new TUI(terminal); const component = new ExpandableTranscriptComponent(); @@ -809,7 +812,7 @@ describe("TUI viewport remap for above-viewport growth", () => { // given component.setExpanded(false); tui.start(); - await terminal.waitForRender(); + await renderImmediately(tui, terminal); terminal.clearWrites(); const initialFullRedraws = tui.fullRedraws; @@ -817,8 +820,7 @@ describe("TUI viewport remap for above-viewport growth", () => { // when for (const expanded of [true, false, true, false, true, false]) { component.setExpanded(expanded); - tui.requestRender(); - await terminal.waitForRender(); + await renderImmediately(tui, terminal); assert.deepStrictEqual(terminal.getViewport(), expectedViewport); } @@ -826,7 +828,12 @@ describe("TUI viewport remap for above-viewport growth", () => { const writes = terminal.getWrites(); assert.strictEqual(tui.fullRedraws, initialFullRedraws, "Ctrl+O toggles should not full-redraw the viewport"); assert.ok(!writes.includes("\x1b[2J"), "Ctrl+O toggles should not clear the viewport"); - assert.ok(writes.includes("\x1b[3J"), "Ctrl+O toggles should reset stale scrollback"); + assert.ok(!writes.includes("\x1b[3J"), "Ctrl+O toggles should not clear scrollback"); + assert.strictEqual( + terminal.getScrollBuffer().filter((line) => line === "session title").length, + 1, + "Ctrl+O toggles should not replay scrolled-out history", + ); assert.strictEqual( countOccurrences(writes, "\x1b[?2026h"), countOccurrences(writes, "\x1b[?2026l"), @@ -836,7 +843,7 @@ describe("TUI viewport remap for above-viewport growth", () => { tui.stop(); }); - it("does not append duplicate transcript copies during hidden Ctrl+O replay", async () => { + it("does not append duplicate transcript copies during hidden Ctrl+O toggles", async () => { const terminal = new LoggingVirtualTerminal(72, 6); const tui = new TUI(terminal); const component = new ExpandableTranscriptComponent(); @@ -845,29 +852,30 @@ describe("TUI viewport remap for above-viewport growth", () => { component.setExpanded(false); tui.start(); - await terminal.waitForRender(); + await renderImmediately(tui, terminal); terminal.clearWrites(); for (const expanded of [true, false, true, false, true, false]) { component.setExpanded(expanded); - tui.requestRender(); - await terminal.waitForRender(); + await renderImmediately(tui, terminal); assert.deepStrictEqual(terminal.getViewport(), expectedViewport); } const writes = terminal.getWrites(); const scrollback = terminal.getScrollBuffer(); - assert.ok(!writes.includes("\x1b[2J"), "Hidden replay should not clear the visible viewport"); - assert.ok(writes.includes("\x1b[3J"), "Hidden replay should reset stale scrollback before replaying"); + assert.ok(!writes.includes("\x1b[2J"), "Hidden toggles should not clear the visible viewport"); + assert.ok(!writes.includes("\x1b[3J"), "Hidden toggles should not clear scrollback"); assert.ok( scrollback.length <= 24, - `Hidden replay should keep scrollback bounded to one transcript copy, got ${scrollback.length} rows`, - ); - assert.deepStrictEqual( - getScrollbackSuffix(scrollback, 8), - ["session title", "tools", "tail row 0", "tail row 1", "tail row 2", "tail row 3", "tail row 4", "tail row 5"], - "Latest canonical scrollback segment should match the collapsed transcript", + `Hidden toggles should keep scrollback bounded to one transcript copy, got ${scrollback.length} rows`, ); + for (const line of ["session title", "expanded tool detail 0", "tail row 5"]) { + assert.strictEqual( + scrollback.filter((bufferLine) => bufferLine === line).length, + 1, + `Hidden toggles should not duplicate ${line}`, + ); + } tui.stop(); }); @@ -880,7 +888,7 @@ describe("TUI viewport remap for above-viewport growth", () => { component.setExpanded(false); tui.start(); - await terminal.waitForRender(); + await renderImmediately(tui, terminal); assert.ok( terminal.getScrollBuffer().includes("read collapsed lib.rs:210-329"), @@ -895,8 +903,7 @@ describe("TUI viewport remap for above-viewport growth", () => { const initialFullRedraws = tui.fullRedraws; component.setExpanded(true); - tui.requestRender(); - await terminal.waitForRender(); + await renderImmediately(tui, terminal); const scrollback = terminal.getScrollBuffer(); assert.strictEqual( @@ -905,7 +912,7 @@ describe("TUI viewport remap for above-viewport growth", () => { "Offscreen expansion should not full-redraw the viewport", ); assert.ok(!terminal.getWrites().includes("\x1b[2J"), "Offscreen expansion should not clear the viewport"); - assert.ok(terminal.getWrites().includes("\x1b[3J"), "Offscreen expansion should reset stale scrollback"); + assert.ok(!terminal.getWrites().includes("\x1b[3J"), "Offscreen expansion should preserve scrollback"); assert.deepStrictEqual( getScrollbackSuffix(scrollback, 20), [ @@ -1315,7 +1322,7 @@ describe("TUI differential rendering", () => { tui.stop(); }); - it("clears stale content when maxLinesRendered was inflated by a transient component", async () => { + it("keeps stale content out when a second shrink enlarges the tail gap", async () => { const terminal = new VirtualTerminal(40, 10); const tui: TUI = new TuiMainScreen(terminal); const chat = new TestComponent(); @@ -1331,20 +1338,17 @@ describe("TUI differential rendering", () => { chat.lines = longChat; editor.lines = editorLines; tui.start(); - await terminal.waitForRender(); + await renderImmediately(tui, terminal); editor.lines = selectorLines; - tui.requestRender(); - await terminal.waitForRender(); + await renderImmediately(tui, terminal); editor.lines = editorLines; - tui.requestRender(); - await terminal.waitForRender(); + await renderImmediately(tui, terminal); const redrawsBeforeSwitch = tui.fullRedraws; chat.lines = shortChat; - tui.requestRender(); - await terminal.waitForRender(); + await renderImmediately(tui, terminal); assert.strictEqual(tui.fullRedraws, redrawsBeforeSwitch, "Branch switch should stay on the differential path"); @@ -1356,18 +1360,7 @@ describe("TUI differential rendering", () => { 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", - ]); + assert.deepStrictEqual(viewport, ["", "", "", "", "", "", "", "Editor 0", "Editor 1", "Editor 2"]); tui.stop(); }); diff --git a/packages/tui/test/tui-shrink.test.ts b/packages/tui/test/tui-shrink.test.ts index dac98a4ee5..3fc0760cc4 100644 --- a/packages/tui/test/tui-shrink.test.ts +++ b/packages/tui/test/tui-shrink.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import type { Component, TUI } from "../src/tui.ts"; +import { type Component, CURSOR_MARKER, type TUI } from "../src/tui.ts"; import { TuiMainScreen } from "../src/tui-main-screen.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; @@ -11,6 +11,10 @@ class Lines implements Component { this.lines = lines; } + setLines(lines: string[]): void { + this.lines = lines; + } + render(): string[] { return this.lines; } @@ -18,22 +22,108 @@ class Lines implements Component { invalidate(): void {} } +class LoggingVirtualTerminal extends VirtualTerminal { + private writes: string[] = []; + + override write(data: string): void { + this.writes.push(data); + super.write(data); + } + + getWrites(): string { + return this.writes.join(""); + } + + clearWrites(): void { + this.writes = []; + } +} + +async function renderNow(tui: TUI, terminal: VirtualTerminal): Promise { + tui.renderNow(); + await terminal.flush(); +} + +function trailingBlankRows(lines: string[]): number { + let count = 0; + for (let index = lines.length - 1; index >= 0 && lines[index]?.trim() === ""; index--) { + count += 1; + } + return count; +} + describe("TUI shrinking content", () => { + it("keeps a shrunken document tail flush with the buffer bottom", async () => { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TuiMainScreen(terminal); + const history = Array.from({ length: 12 }, (_, index) => `history ${index}`); + const collapsedTail = [`${CURSOR_MARKER}editor`, "status", "footer"]; + const visibleCollapsedTail = ["editor", "status", "footer"]; + const expandedTail = Array.from({ length: 8 }, (_, index) => `selector ${index}`); + const content = new Lines([...history, ...collapsedTail]); + tui.addChild(content); + + await renderNow(tui, terminal); + content.setLines([...history, ...expandedTail]); + await renderNow(tui, terminal); + assert.strictEqual(trailingBlankRows(terminal.getScrollBuffer()), 0, "expanded tail should be flush"); + const expandedBufferLength = terminal.getScrollBuffer().length; + terminal.clearWrites(); + + content.setLines([...history, ...collapsedTail]); + await renderNow(tui, terminal); + + const shrinkWrites = terminal.getWrites(); + assert.strictEqual(trailingBlankRows(terminal.getScrollBuffer()), 0, "shrunken tail should stay flush"); + assert.deepStrictEqual(terminal.getViewport().slice(-3), visibleCollapsedTail); + assert.strictEqual(terminal.getCursorPosition().y, 7, "hardware cursor should follow the anchored editor row"); + assert.ok(!shrinkWrites.includes("\x1b[2J"), "shrink should not clear the screen"); + assert.ok(!shrinkWrites.includes("\x1b[3J"), "shrink should not clear scrollback"); + assert.ok( + shrinkWrites.split("\x1b[2K").length - 1 <= terminal.rows, + "shrink repaint work should be bounded by the viewport", + ); + for (const line of ["history 0", ...visibleCollapsedTail]) { + assert.strictEqual( + terminal.getScrollBuffer().filter((bufferLine) => bufferLine === line).length, + 1, + `shrink should not replay ${line}`, + ); + } + + const renderState = tui.captureRenderState(); + tui.stop({ preserveScreen: true }); + const restoredTui = new TuiMainScreen(terminal); + restoredTui.restoreRenderState(renderState); + restoredTui.addChild(content); + + content.setLines([...history, "popup 0", "popup 1", ...collapsedTail]); + await renderNow(restoredTui, terminal); + assert.strictEqual( + terminal.getScrollBuffer().length, + expandedBufferLength, + "growth should consume the restored blank gap before extending scrollback", + ); + assert.strictEqual(trailingBlankRows(terminal.getScrollBuffer()), 0, "tail should stay flush after regrowth"); + assert.deepStrictEqual(terminal.getViewport().slice(-3), visibleCollapsedTail); + + restoredTui.stop(); + }); + it("clears all rendered lines when content shrinks to zero", async () => { const terminal = new VirtualTerminal(40, 10); const tui: TUI = new TuiMainScreen(terminal); const content = new Lines(["first", "second", "third"]); tui.addChild(content); tui.start(); - await terminal.waitForRender(); + await renderNow(tui, terminal); assert.ok(terminal.getViewport().some((line) => line.includes("first"))); assert.ok(terminal.getViewport().some((line) => line.includes("second"))); assert.ok(terminal.getViewport().some((line) => line.includes("third"))); tui.clear(); - tui.requestRender(); - await terminal.waitForRender(); + await renderNow(tui, terminal); const viewport = terminal.getViewport(); assert.ok(!viewport.some((line) => line.includes("first")), "first line should be cleared");