From dffa4fac03efe6413e8cd098c3beaa0298cbfb50 Mon Sep 17 00:00:00 2001 From: Shawn Koh Date: Wed, 12 Aug 2026 14:40:36 +0800 Subject: [PATCH] fix(tui): replay unresolved width epochs Amp-Thread-ID: https://ampcode.com/threads/T-019ff125-f53d-739b-8462-6027fe70f914 Co-authored-by: Amp --- packages/tui/src/components/text.ts | 10 ++ packages/tui/src/tui.ts | 32 ++++-- packages/tui/test/issue-2088-repro.test.ts | 120 +++++++++++++++++++-- 3 files changed, 150 insertions(+), 12 deletions(-) diff --git a/packages/tui/src/components/text.ts b/packages/tui/src/components/text.ts index 753c60d35..4016138bd 100644 --- a/packages/tui/src/components/text.ts +++ b/packages/tui/src/components/text.ts @@ -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; } diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index dd66c871b..e63d73c2f 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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; diff --git a/packages/tui/test/issue-2088-repro.test.ts b/packages/tui/test/issue-2088-repro.test.ts index 13d7ecbdc..3ef3a85d2 100644 --- a/packages/tui/test/issue-2088-repro.test.ts +++ b/packages/tui/test/issue-2088-repro.test.ts @@ -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(); }