diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 252056aa2..8fd2597a1 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1182,6 +1182,10 @@ export class TUI extends Container { // index in an in-place resize session. This is the current-width frame // baseline; only later physical-row growth may advance the append ledger. #widthEpochBaselineRows: number | undefined; + // An overlay-covered width reset with unresolved pending growth owes a + // conservative replay from row zero. Sticky across later covered resizes — + // even if their source boundary resolves — until an uncovered paint pays it. + #widthEpochOverlayReplayPending = false; // Same-width snapshots physically appended after a width transition. The // ordinary committed prefix includes opaque old-width native rows and can // no longer be indexed against the reflowed frame; this local ledger lets @@ -2198,7 +2202,7 @@ export class TUI extends Container { // the same `#prepareForcedRender(!isMultiplexerSession())` path via // `requestRender(true)`, so the clear-scrollback intent is preserved. if (this.#multiplexerResizeTimer) { - this.#armMultiplexerResizeTimer({ clearScrollback: !isMultiplexerSession() }); + this.#armMultiplexerResizeTimer({ clearScrollback: !isMultiplexerSession(), hasPendingRender: true }); return; } this.#prepareForcedRender(!isMultiplexerSession()); @@ -2223,6 +2227,7 @@ export class TUI extends Container { if (this.#multiplexerResizeTimer) { this.#armMultiplexerResizeTimer({ clearScrollback: options?.clearScrollback === true, + hasPendingRender: true, }); return; } @@ -3467,6 +3472,14 @@ export class TUI extends Container { break; } } + // Without a logical source boundary, pending growth folded into an + // overlay-covered width reset cannot be separated from reflow. Replay + // conservatively from row zero after the overlay closes: duplication is + // preferable to dropping rows that were never emitted anywhere. + if (widthEpochReset && hasVisibleOverlay && widthEpochSourceBoundary === undefined && resizeHadPendingRender) { + this.#widthEpochOverlayReplayPending = true; + } + const replayUnresolvedOverlayFrame = widthEpochReset && this.#widthEpochOverlayReplayPending; // 4. Classify. A resize is an explicit user gesture: normally the engine // erases and replays so history rewraps at the new geometry (the reader @@ -3507,9 +3520,10 @@ export class TUI extends Container { // Components without the source contract retain the conservative // legacy fallback, but never compare cross-width counts when a marker // resolved successfully. - this.#widthEpochBaselineRows = - widthEpochSourceBoundary ?? - (resizeHadPendingRender ? Math.min(frameLength, this.#previousFrameLength) : frameLength); + this.#widthEpochBaselineRows = replayUnresolvedOverlayFrame + ? 0 + : (widthEpochSourceBoundary ?? + (resizeHadPendingRender ? Math.min(frameLength, this.#previousFrameLength) : frameLength)); windowTop = Math.max(0, frameLength - height); chunkTo = this.#committedRows; widthEpochAppendFrom = this.#widthEpochBaselineRows; @@ -3674,13 +3688,17 @@ export class TUI extends Container { this.#clearScrollbackOnNextRender = false; this.#hasEverRendered = true; this.#widthEpochBaselineRows = undefined; + this.#widthEpochOverlayReplayPending = false; this.#widthEpochCommittedPrefix = undefined; this.#publishCommittedRows(); if (!firstPaint && frameLength > height) this.#armPostFullPaintSettle(); return; } if (this.#widthEpochBaselineRows !== undefined) { - const logicalAppend = widthEpochSourceBoundary !== undefined && widthEpochCurrentRows !== undefined; + const logicalAppend = + !replayUnresolvedOverlayFrame && + widthEpochSourceBoundary !== undefined && + widthEpochCurrentRows !== undefined; const logicalPrefixAppend = logicalAppend && widthEpochAppendOnly; let scrollRows: number; let commitFrom: number; @@ -3729,6 +3747,7 @@ export class TUI extends Container { }); this.#pendingAltExit = ""; if (!hasVisibleOverlay) { + this.#widthEpochOverlayReplayPending = false; if (liveRegionPinned) { this.#widthEpochBaselineRows = widthEpochAppendTo; this.#windowTopRow = logicalAppend ? windowTop : prevWindowTop + scrollRows; @@ -3764,7 +3783,11 @@ export class TUI extends Container { // The overlay freezes commits and subsequent hidden-growth movement, // but the resize itself changed physical-row coordinates. Rebase the // window reference once so growth backfills from the settled width. - this.#windowTopRow = logicalAppend ? Math.max(0, widthEpochSourceBoundary! - height) : windowTop; + this.#windowTopRow = replayUnresolvedOverlayFrame + ? 0 + : logicalAppend + ? Math.max(0, widthEpochSourceBoundary! - height) + : windowTop; } if (widthEpochReset) { this.#widthEpochCommittedPrefix = { diff --git a/packages/tui/test/issue-2088-repro.test.ts b/packages/tui/test/issue-2088-repro.test.ts index 3d18e57df..b2cffa794 100644 --- a/packages/tui/test/issue-2088-repro.test.ts +++ b/packages/tui/test/issue-2088-repro.test.ts @@ -93,6 +93,34 @@ class WrappingLinesComponent implements Component { } } +class RecoveringWrappingLinesComponent extends WrappingLinesComponent implements NativeScrollbackWidthEpoch { + #resolveAttempts = 0; + #lastRows = 0; + + override render(width: number): string[] { + const rows = super.render(width); + this.#lastRows = rows.length; + return rows; + } + + captureNativeScrollbackWidthEpoch(): unknown { + return {}; + } + + resolveNativeScrollbackWidthEpoch(): number | undefined { + this.#resolveAttempts++; + return this.#resolveAttempts === 1 ? undefined : this.#lastRows; + } + + getNativeScrollbackWidthEpochRows(): number { + return this.#lastRows; + } + + isNativeScrollbackWidthEpochAppendOnly(): boolean { + return true; + } +} + class WidthLabelComponent implements Component { invalidate(): void {} @@ -1418,6 +1446,74 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => { }); }); + it("backfills unresolved growth queued behind an overlay during width settlement", async () => { + await withEnvPatch(TMUX_ENV, async () => { + const initial = Array.from( + { length: 8 }, + (_value, index) => `initial-${index.toString().padStart(2, "0")} ${"I".repeat(20)}`, + ); + const appended = Array.from( + { length: 8 }, + (_value, index) => `hidden-${index.toString().padStart(2, "0")} ${"H".repeat(20)}`, + ); + const term = new VirtualTerminal(17, 6, 10_000); + const component = new RecoveringWrappingLinesComponent(initial); + const tui = new TUI(term); + tui.addChild(component); + + try { + tui.start(); + await settle(term); + const overlay = tui.showOverlay(new MutableLinesComponent(["overlay"]), { + anchor: "top-left", + row: 1, + col: 1, + }); + await settle(term); + + const writes = captureWrites(term); + term.resize(40, 6); + component.setLines([...initial, ...appended]); + tui.requestRender(true); + await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS); + await settle(term); + + expect(writes.join("")).not.toContain("\r\n"); + const coveredBaseY = term.getBufferPosition().baseY; + writes.length = 0; + + // A later pure width reset under the same overlay must retain the + // conservative replay debt established by the growth frame. + term.resize(50, 6); + await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS); + await settle(term); + expect(term.getBufferPosition().baseY).toBe(coveredBaseY); + expect(writes.join("")).not.toContain("\r\n"); + + writes.length = 0; + overlay.hide(); + term.resize(60, 6); + tui.requestRender(true); + await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS); + await settle(term); + + expect(term.getBufferPosition().baseY).toBeGreaterThan(coveredBaseY); + expect(writes.join("")).toContain("\r\n"); + const buffer = term.getScrollBuffer().map(line => line.trimEnd()); + for (const line of appended) { + const marker = line.slice(0, line.indexOf(" ")); + expect( + buffer.filter(bufferLine => bufferLine.includes(marker)), + marker, + ).toHaveLength(1); + } + expect(visible(term)).toEqual(appended.slice(-6)); + } finally { + tui.stop(); + } + }); + }); + it("defers pinned live-region growth until width-epoch finalization", async () => { await withEnvPatch(TMUX_ENV, async () => { const initial = ["pinned-00", "pinned-01"];