diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index a3c832786..dd66c871b 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -732,10 +732,15 @@ export class Container ? currentRows.length !== captured.rowCount : getNativeScrollbackWidthEpochRevision(captured.component) !== captured.revision) ) { - if (currentRows?.length === captured.rowCount) { - rows += currentRows.length; - continue; + let capturedRows = 0; + for (let index = trailingIndex; index < marker.trailing.length; index++) { + capturedRows += marker.trailing[index]!.rowCount; } + let settledRows = 0; + for (let index = currentIndex; index < this.#memoChildLines.length; index++) { + settledRows += this.#memoChildLines[index]!.length; + } + rows += Math.min(capturedRows, settledRows); break; } rows += currentRows.length; @@ -793,7 +798,9 @@ export class Container (captured.revision === undefined ? currentRows.length !== captured.rowCount : getNativeScrollbackWidthEpochRevision(captured.component) !== captured.revision); - if (changed && captured.hadRows && currentRows?.length === captured.rowCount) return false; + if (changed && (captured.hadRows || marker.trailing.slice(trailingIndex + 1).some(child => child.hadRows))) { + return false; + } } const previousRows = source?.resolveNativeScrollbackWidthEpoch(marker.childBoundary); const currentRows = source?.getNativeScrollbackWidthEpochRows(); @@ -1299,6 +1306,10 @@ export class TUI extends Container { // opaque marker survives width reflow and resolves after the settled render. #multiplexerWidthEpochBoundary: unknown; #multiplexerWidthEpochPending = false; + // Normal-buffer boundary borrowed by a fullscreen alt overlay. If the host + // resizes while the transcript is hidden, this remains the physical seam + // from before the overlay instead of adopting hidden growth on exit. + #altWidthEpochBoundary: unknown; // Frame row currently mapped to screen row 0. Monotonic between full // paints: a shrink never re-exposes scrolled-off rows (they cannot be @@ -1516,19 +1527,24 @@ export class TUI extends Container { for (let trailingIndex = 0; trailingIndex < marker.trailing.length; trailingIndex++) { const captured = marker.trailing[trailingIndex]!; const candidate = this.#frameSegments[marker.sourceIndex + 1 + trailingIndex]; - // Changed/removed tails are not cross-width comparable. Treat their - // entire settled contribution as new in the current boundary: this can - // conservatively duplicate rows, but cannot omit displaced transcript. + // Changed/removed tails are not individually cross-width comparable. + // Preserve the shared physical row count of the remaining tail as one + // span; only aggregate height growth belongs to the current suffix. if ( candidate?.component !== captured.component || (captured.revision === undefined ? candidate.rowCount !== captured.rowCount : candidate.widthEpochRevision !== captured.revision) ) { - if (candidate?.rowCount === captured.rowCount) { - rows += candidate.rowCount; - continue; + let capturedRows = 0; + for (let index = trailingIndex; index < marker.trailing.length; index++) { + capturedRows += marker.trailing[index]!.rowCount; } + let settledRows = 0; + for (let index = marker.sourceIndex + 1 + trailingIndex; index < this.#frameSegments.length; index++) { + settledRows += this.#frameSegments[index]!.rowCount; + } + rows += Math.min(capturedRows, settledRows); break; } rows += candidate.rowCount; @@ -1566,7 +1582,12 @@ export class TUI extends Container { (captured.revision === undefined ? current.rowCount !== captured.rowCount : current.widthEpochRevision !== captured.revision); - if (changed && captured.rowCount > 0 && current?.rowCount === captured.rowCount) return false; + if ( + changed && + (captured.rowCount > 0 || marker.trailing.slice(trailingIndex + 1).some(segment => segment.rowCount > 0)) + ) { + return false; + } } const previousRows = source?.resolveNativeScrollbackWidthEpoch(marker.childBoundary); const currentRows = source?.getNativeScrollbackWidthEpochRows(); @@ -2041,6 +2062,12 @@ export class TUI extends Container { if (this.#altEnterWidth === this.terminal.columns && this.#altEnterHeight !== this.terminal.rows) { this.#altToggleResizesInPlace = true; } + if (this.#previousWidth > 0 && this.terminal.columns !== this.#previousWidth) { + this.#multiplexerWidthEpochPending = true; + if (this.#multiplexerWidthEpochBoundary === undefined) { + this.#multiplexerWidthEpochBoundary = this.#altWidthEpochBoundary; + } + } this.#resizeEventPending = true; this.requestRender(); return; @@ -3323,6 +3350,7 @@ export class TUI extends Container { this.#altPreviousLines = []; this.#altEnterWidth = width; this.#altEnterHeight = height; + this.#altWidthEpochBoundary = this.captureNativeScrollbackWidthEpoch(); } else if (!wantAlt && this.#altActive) { const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : ""; const enhancementExit = this.#keyboardEnhancementExit(); @@ -3340,6 +3368,7 @@ export class TUI extends Container { this.#altActive = false; this.#altMouseTrackingActive = false; this.#altPreviousLines = []; + this.#altWidthEpochBoundary = undefined; // A resize while on the alt buffer reflowed the terminal's saved // normal screen; it no longer matches our accounting, so force the // geometry rebuild path instead of a stale diff. A pure height change diff --git a/packages/tui/test/issue-2088-repro.test.ts b/packages/tui/test/issue-2088-repro.test.ts index 50b7d23e0..13d7ecbdc 100644 --- a/packages/tui/test/issue-2088-repro.test.ts +++ b/packages/tui/test/issue-2088-repro.test.ts @@ -1092,6 +1092,51 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => { }); }); + it("retains transcript growth hidden by a fullscreen overlay across resize", async () => { + await withEnvPatch(TMUX_ENV, async () => { + const term = new VirtualTerminal(40, 6, 10_000); + const tui = new TUI(term); + const transcript = new WrappingStreamComponent(); + for (let index = 0; index < 8; index++) { + transcript.append(`falt-${index.toString().padStart(2, "0")} ${"A".repeat(46)}`); + } + tui.addChild(transcript); + + try { + tui.start(); + await settle(term); + const overlay = tui.showOverlay(new MutableLinesComponent(["fullscreen-overlay"]), { + width: "100%", + maxHeight: "100%", + margin: 0, + fullscreen: true, + }); + tui.requestRender(true); + await settle(term); + + term.resize(17, 6); + transcript.append(`falt-final ${"F".repeat(46)}`); + tui.requestRender(); + await settle(term); + overlay.hide(); + tui.requestRender(true); + await settle(term); + + const buffer = term.getScrollBuffer().map(line => line.trimEnd()); + for (let index = 0; index < 8; index++) { + const marker = `falt-${index.toString().padStart(2, "0")}`; + expect( + buffer.filter(line => line.includes(marker)), + marker, + ).toHaveLength(1); + } + expect(buffer.filter(line => line.includes("falt-final"))).toHaveLength(1); + } finally { + tui.stop(); + } + }); + }); + it("retains transcript rows displaced by a trailing root that grows during resize settlement", async () => { await withEnvPatch(TMUX_ENV, async () => { const term = new VirtualTerminal(40, 6, 10_000); @@ -1204,7 +1249,7 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => { }); }); - it("preserves populated tails after a replaced empty root", async () => { + it("preserves populated tails after an empty root gains rows", async () => { await withEnvPatch(TMUX_ENV, async () => { const term = new VirtualTerminal(40, 6, 10_000); const tui = new TUI(term); @@ -1222,7 +1267,7 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => { tui.start(); await settle(term); term.resize(17, 6); - emptyStatus.setLines([]); + emptyStatus.setLines(["status"]); tui.requestComponentRender(emptyStatus); await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS); await settle(term); @@ -1235,7 +1280,7 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => { marker, ).toHaveLength(1); } - expect(visible(term).at(-1)).toBe("editor"); + expect(visible(term).slice(-2)).toEqual(["status", "editor"]); } finally { tui.stop(); } @@ -1331,8 +1376,10 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => { const editor = new RevisionMutableLinesComponent(["editor"]); const editorRoot = new Container(); editorRoot.addChild(editor); + editorRoot.addChild(new MutableLinesComponent(["nested-footer"])); tui.addChild(transcript); tui.addChild(editorRoot); + tui.addChild(new MutableLinesComponent(["root-footer"])); try { tui.start(); @@ -1352,7 +1399,15 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => { marker, ).toHaveLength(1); } - expect(visible(term).slice(-4)).toEqual(["draft-00", "draft-01", "draft-02", "editor"]); + expect(buffer.filter(line => line === "")).toHaveLength(9); + expect(visible(term)).toEqual([ + "draft-00", + "draft-01", + "draft-02", + "editor", + "nested-footer", + "root-footer", + ]); } finally { tui.stop(); } @@ -1443,7 +1498,7 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => { }); }); - it("preserves populated nested tails after a replaced empty child", async () => { + it("preserves populated nested tails after an empty child gains rows", async () => { await withEnvPatch(TMUX_ENV, async () => { const term = new VirtualTerminal(40, 6, 10_000); const tui = new TUI(term); @@ -1463,7 +1518,7 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => { tui.start(); await settle(term); term.resize(17, 6); - emptyStatus.setLines([]); + emptyStatus.setLines(["status"]); tui.requestComponentRender(emptyStatus); await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS); await settle(term); @@ -1476,7 +1531,7 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => { marker, ).toHaveLength(1); } - expect(visible(term).at(-1)).toBe("editor"); + expect(visible(term).slice(-2)).toEqual(["status", "editor"]); } finally { tui.stop(); }