fix(tui): retain hidden resize 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 12:50:05 +08:00
parent c6cae4ff61
commit 1f736aa439
2 changed files with 102 additions and 18 deletions
+40 -11
View File
@@ -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
+62 -7
View File
@@ -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();
}