fix(tui): backfill unresolved overlay growth

Amp-Thread-ID: https://ampcode.com/threads/T-019ff125-f53d-739b-8462-6027fe70f914
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Shawn Koh
2026-08-12 10:14:43 +08:00
co-authored by Amp
parent c9e8d3791e
commit 7ec85280ec
2 changed files with 125 additions and 6 deletions
+29 -6
View File
@@ -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 = {
@@ -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"];