fix(tui): replay unresolved width epochs

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 14:40:36 +08:00
co-authored by Amp
parent 3c26443405
commit dffa4fac03
3 changed files with 150 additions and 12 deletions
+10
View File
@@ -25,11 +25,14 @@ export class Text implements Component {
#paddingY: number; // Top/bottom padding
#customBgFn?: (text: string) => string;
#styleFn?: (text: string) => string;
#widthEpochRevision = 0;
#ignoreTight = false;
setIgnoreTight(ignore: boolean): this {
if (this.#ignoreTight === ignore) return this;
this.#ignoreTight = ignore;
this.#widthEpochRevision++;
this.invalidate();
return this;
}
@@ -60,15 +63,21 @@ export class Text implements Component {
this.#cachedWidth = undefined;
this.#cachedWidthEpoch = undefined;
this.#cachedLines = undefined;
this.#widthEpochRevision++;
return true;
}
getNativeScrollbackWidthEpochRevision(): number {
return this.#widthEpochRevision;
}
setCustomBgFn(customBgFn?: (text: string) => string): void {
this.#customBgFn = customBgFn;
this.#cachedText = undefined;
this.#cachedWidth = undefined;
this.#cachedWidthEpoch = undefined;
this.#cachedLines = undefined;
this.#widthEpochRevision++;
}
/**
@@ -83,6 +92,7 @@ export class Text implements Component {
this.#cachedWidth = undefined;
this.#cachedWidthEpoch = undefined;
this.#cachedLines = undefined;
this.#widthEpochRevision++;
return this;
}
+26 -6
View File
@@ -1287,6 +1287,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 unresolved captured source boundary was replayed from row zero. While
// its live region remains pinned, advance the baseline only through rows
// actually emitted; a reported final seam may otherwise skip deferred rows.
#widthEpochReplayUnresolved = false;
// 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.
@@ -3495,14 +3499,15 @@ export class TUI extends Container {
if (resizeEventOccurred) this.#forgetHardwareCursorState();
const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
const widthEpochOccurred = widthChanged || (resizeEventOccurred && this.#multiplexerWidthEpochPending);
const widthEpochBoundary = this.#multiplexerWidthEpochBoundary;
const widthEpochSourceBoundary = widthEpochOccurred
? this.resolveNativeScrollbackWidthEpoch(this.#multiplexerWidthEpochBoundary)
? this.resolveNativeScrollbackWidthEpoch(widthEpochBoundary)
: undefined;
const widthEpochCurrentRows = widthEpochOccurred
? this.#getNativeScrollbackWidthEpochCurrentRows(this.#multiplexerWidthEpochBoundary)
? this.#getNativeScrollbackWidthEpochCurrentRows(widthEpochBoundary)
: undefined;
const widthEpochAppendOnly = widthEpochOccurred
? this.#isNativeScrollbackWidthEpochAppendOnly(this.#multiplexerWidthEpochBoundary)
? this.#isNativeScrollbackWidthEpochAppendOnly(widthEpochBoundary)
: true;
if (resizeEventOccurred) {
this.#multiplexerWidthEpochBoundary = undefined;
@@ -3657,6 +3662,14 @@ export class TUI extends Container {
this.#widthEpochOverlayReplayPending = true;
}
const replayUnresolvedOverlayFrame = widthEpochReset && this.#widthEpochOverlayReplayPending;
const replayUnresolvedWidthEpoch =
replayUnresolvedOverlayFrame ||
(widthEpochReset && liveRegionPinned && this.#widthEpochReplayUnresolved) ||
(widthEpochReset &&
resizeHadPendingRender &&
widthEpochBoundary !== undefined &&
widthEpochSourceBoundary === undefined);
if (replayUnresolvedWidthEpoch) prevWindowTop = 0;
// 4. Classify. A resize is an explicit user gesture: normally the engine
// erases and replays so history rewraps at the new geometry (the reader
@@ -3697,10 +3710,11 @@ 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 = replayUnresolvedOverlayFrame
this.#widthEpochBaselineRows = replayUnresolvedWidthEpoch
? 0
: (widthEpochSourceBoundary ??
(resizeHadPendingRender ? Math.min(frameLength, this.#previousFrameLength) : frameLength));
this.#widthEpochReplayUnresolved = replayUnresolvedWidthEpoch;
windowTop = Math.max(0, frameLength - height);
chunkTo = this.#committedRows;
widthEpochAppendFrom = this.#widthEpochBaselineRows;
@@ -3865,6 +3879,7 @@ export class TUI extends Container {
this.#clearScrollbackOnNextRender = false;
this.#hasEverRendered = true;
this.#widthEpochBaselineRows = undefined;
this.#widthEpochReplayUnresolved = false;
this.#widthEpochOverlayReplayPending = false;
this.#widthEpochCommittedPrefix = undefined;
this.#publishCommittedRows();
@@ -3880,7 +3895,11 @@ export class TUI extends Container {
let scrollRows: number;
let commitFrom: number;
let commitTo: number;
if (logicalAppend && !logicalPrefixAppend) {
if (replayUnresolvedWidthEpoch) {
commitFrom = 0;
commitTo = liveRegionPinned ? Math.min(windowTop, finalBoundary) : windowTop;
scrollRows = commitTo;
} else if (logicalAppend && !logicalPrefixAppend) {
const sourceWindowTop = Math.max(0, widthEpochSourceBoundary - height);
const logicalSuffixRows = Math.max(0, widthEpochCurrentRows - widthEpochSourceBoundary);
const appendWindowMovement = Math.max(0, windowTop - sourceWindowTop);
@@ -3926,10 +3945,11 @@ export class TUI extends Container {
if (!hasVisibleOverlay) {
this.#widthEpochOverlayReplayPending = false;
if (liveRegionPinned) {
this.#widthEpochBaselineRows = widthEpochAppendTo;
this.#widthEpochBaselineRows = this.#widthEpochReplayUnresolved ? commitTo : widthEpochAppendTo;
this.#windowTopRow = logicalAppend ? windowTop : prevWindowTop + scrollRows;
} else {
this.#widthEpochBaselineRows = frameLength;
this.#widthEpochReplayUnresolved = false;
this.#windowTopRow = windowTop;
}
this.#committedRows += scrollRows;
+114 -6
View File
@@ -9,6 +9,7 @@ import {
type RenderTimer,
TUI,
} from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui/components/text";
import { VirtualTerminal } from "./virtual-terminal";
// Regression test for https://github.com/can1357/oh-my-pi/issues/2088
@@ -121,6 +122,20 @@ class RecoveringWrappingLinesComponent extends WrappingLinesComponent implements
}
}
class UnresolvedWrappingLinesComponent extends WrappingLinesComponent implements NativeScrollbackWidthEpoch {
captureNativeScrollbackWidthEpoch(): unknown {
return {};
}
resolveNativeScrollbackWidthEpoch(): undefined {
return undefined;
}
getNativeScrollbackWidthEpochRows(): undefined {
return undefined;
}
}
class WidthLabelComponent implements Component {
invalidate(): void {}
@@ -337,9 +352,10 @@ class WrappingStreamComponent implements Component, NativeScrollbackLiveRegion,
}
}
class PinnedMutableLinesComponent implements Component, NativeScrollbackLiveRegion {
class PinnedMutableLinesComponent implements Component, NativeScrollbackLiveRegion, NativeScrollbackWidthEpoch {
#lines: string[];
#pinned = true;
#finalBoundary = 0;
constructor(lines: string[]) {
this.#lines = [...lines];
@@ -349,6 +365,10 @@ class PinnedMutableLinesComponent implements Component, NativeScrollbackLiveRegi
this.#lines = [...lines];
}
setFinalBoundary(rows: number): void {
this.#finalBoundary = rows;
}
finalize(): void {
this.#pinned = false;
}
@@ -359,8 +379,20 @@ class PinnedMutableLinesComponent implements Component, NativeScrollbackLiveRegi
return this.#lines.map(line => line.slice(0, width));
}
captureNativeScrollbackWidthEpoch(): unknown {
return {};
}
resolveNativeScrollbackWidthEpoch(): undefined {
return undefined;
}
getNativeScrollbackWidthEpochRows(): number {
return this.#lines.length;
}
getNativeScrollbackLiveRegionStart(): number | undefined {
return this.#pinned ? 0 : undefined;
return this.#pinned ? this.#finalBoundary : undefined;
}
isNativeScrollbackLiveRegionPinned(): boolean {
@@ -522,6 +554,19 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
vi.restoreAllMocks();
});
it("propagates rendered-height changes from mutable text descendants", () => {
const child = new Text("one", 0, 0);
const container = new Container();
container.addChild(child);
container.render(40);
const initialRevision = container.getNativeScrollbackWidthEpochRevision();
child.setText("one\ntwo");
container.render(40);
expect(container.getNativeScrollbackWidthEpochRevision()).toBeGreaterThan(initialRevision);
});
it("coalesces a burst of multiplexer resize events into a single settled render", async () => {
await withEnvPatch(TMUX_ENV, async () => {
const term = new VirtualTerminal(40, 10, 1000);
@@ -1448,6 +1493,7 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
marker,
).toHaveLength(1);
}
expect(buffer.filter(line => line === "")).toHaveLength(9);
expect(visible(term).slice(-4)).toEqual(["draft-00", "draft-01", "draft-02", "editor"]);
const settledBaseY = term.getBufferPosition().baseY;
@@ -1910,6 +1956,51 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
});
});
it("replays unresolved output queued while a widening epoch reduces reflow rows", async () => {
await withEnvPatch(TMUX_ENV, async () => {
const initial = Array.from(
{ length: 12 },
(_value, index) => `unresolved-initial-${index.toString().padStart(2, "0")} ${"I".repeat(20)}`,
);
const appended = Array.from(
{ length: 8 },
(_value, index) => `unresolved-new-${index.toString().padStart(2, "0")}`,
);
const settledInitial = ["changed-prefix-00", "changed-prefix-01", ...initial.slice(2)];
const term = new VirtualTerminal(17, 6, 10_000);
const component = new UnresolvedWrappingLinesComponent(initial);
const tui = new TUI(term);
tui.addChild(component);
try {
tui.start();
await settle(term);
term.resize(40, 4);
component.setLines([...settledInitial, ...appended]);
tui.requestRender();
await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS);
await settle(term);
const buffer = term.getScrollBuffer().map(line => line.trimEnd());
for (const line of settledInitial.slice(0, 2)) {
expect(
buffer.some(bufferLine => bufferLine.includes(line)),
line,
).toBe(true);
}
for (const line of appended) {
const marker = line;
expect(
buffer.some(bufferLine => bufferLine.includes(marker)),
marker,
).toBe(true);
}
} finally {
tui.stop();
}
});
});
it("backfills unresolved growth queued behind an overlay during width settlement", async () => {
await withEnvPatch(TMUX_ENV, async () => {
const initial = Array.from(
@@ -1991,26 +2082,43 @@ describe("issue #2088: tmux pane-resize race produces viewport flash", () => {
tui.start();
await settle(term);
term.resize(17, 4);
component.setLines(final);
tui.requestRender();
await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS);
await settle(term);
component.setLines(final);
tui.requestRender(true);
expect(term.getBufferPosition().baseY).toBe(0);
expect(visible(term)).toEqual(final.slice(-4));
term.resize(23, 4);
await Bun.sleep(DEBOUNCE_SETTLE_WAIT_MS);
await settle(term);
expect(term.getBufferPosition().baseY).toBe(0);
expect(visible(term)).toEqual(final.slice(-4));
const extended = [...final, "pinned-10", "pinned-11"];
component.setLines(extended);
component.setFinalBoundary(6);
tui.requestRender(true);
await settle(term);
component.setFinalBoundary(8);
tui.requestRender(true);
await settle(term);
component.finalize();
tui.requestRender(true);
await settle(term);
expect(term.getBufferPosition().baseY).toBe(6);
expect(term.getBufferPosition().baseY).toBe(8);
const buffer = term.getScrollBuffer().map(line => line.trimEnd());
for (const line of final) {
for (const line of extended) {
expect(
buffer.filter(bufferLine => bufferLine === line),
line,
).toHaveLength(1);
}
const finalizedBaseY = term.getBufferPosition().baseY;
tui.requestRender(true);
await settle(term);
expect(term.getBufferPosition().baseY).toBe(finalizedBaseY);
} finally {
tui.stop();
}