diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 4e7c142cb..c14d06c8e 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Fixed + +- Fixed live transcript rows duplicating into native scrollback during a non-multiplexer resize drag: the viewport fast-path repaint now parks the hardware cursor at the real content bottom (mirroring the authoritative paint) instead of the padded viewport bottom, so a subsequent height shrink no longer scrolls live rows into history before the settle replay +- Fixed inline images flipping to their text fallback during a non-multiplexer resize drag: the viewport fast path now drives the image budget as a stable partial pass that replays the committed per-image live/text split by id, instead of deriving it from the reversed, tail-only walk order + ## [15.12.5] - 2026-06-13 ### Added diff --git a/packages/tui/src/components/image.ts b/packages/tui/src/components/image.ts index 01d0f3e10..8d7ede105 100644 --- a/packages/tui/src/components/image.ts +++ b/packages/tui/src/components/image.ts @@ -76,6 +76,16 @@ export class ImageBudget { #transmitted = new Set(); /** Transmit sequences (full base64) to write once, before this frame's placements. */ #pendingTransmits: string[] = []; + // True while the in-flight pass is a partial/throwaway pass (the + // non-multiplexer resize viewport fast path) that walks only the visible + // tail, bottom-up. Such a pass cannot derive display order from observe() + // call order, so its suppression decisions replay the committed split below. + #stablePass = false; + // Image ids shown as text in the frame currently on the terminal: the + // display-order prefix [0, #onTerminal) of the last full pass, snapshotted by + // id so a partial pass reproduces the on-screen live/text split without a + // full, correctly-ordered walk. + #suppressedIds = new Set(); constructor(cap: number = DEFAULT_MAX_INLINE_IMAGES, requestRender: () => void = () => {}) { this.#cap = normalizeCap(cap); @@ -117,18 +127,32 @@ export class ImageBudget { return this.#nextId++; } - /** Begin a render pass. Called by the renderer before composing the frame. */ - beginPass(): void { + /** + * Begin a render pass. Called by the renderer before composing the frame. + * Pass `stable: true` for a partial/throwaway pass that does not walk the + * whole tree in display order (the resize viewport fast path): {@link observe} + * then replays the last committed per-id decision instead of one derived from + * call order, and the pass must NOT be closed with {@link endPass}. + */ + beginPass(stable = false): void { this.#passIds.length = 0; - this.#applyingReset = this.#cap > 0 && this.#planned > this.#onTerminal; + this.#stablePass = stable; + this.#applyingReset = !stable && this.#cap > 0 && this.#planned > this.#onTerminal; } /** * Record an image in display order and report whether it must render its text * fallback this frame. Called by every {@link Image} during render — including * on a cache hit, so the image keeps its display-order slot. + * + * During a `stable` pass ({@link beginPass}) the call order and visible subset + * are not authoritative, so the decision is the committed on-terminal split + * (`#suppressedIds`) keyed by id — order- and partiality-independent. */ observe(imageId: number): boolean { + if (this.#stablePass) { + return this.#cap > 0 && this.#suppressedIds.has(imageId); + } const index = this.#passIds.length; this.#passIds.push(imageId); return this.#cap > 0 && index < this.#planned; @@ -155,6 +179,11 @@ export class ImageBudget { reset = true; } this.#reconcile(total); + // Snapshot the committed display-order suppression by id: the prefix + // [0, #onTerminal) is what the terminal currently shows as text. Partial + // passes replay this per id (see #stablePass) instead of re-deriving it + // from a reversed, tail-only walk. + this.#suppressedIds = new Set(this.#passIds.slice(0, this.#onTerminal)); return reset; } diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 50a5ba6d9..372853fb4 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -2805,13 +2805,16 @@ export class TUI extends Container { */ #renderResizeViewport(width: number, height: number): void { if (width <= 0 || height <= 0) return; - // Tail renders call block.render(), which can push image ids onto the - // budget's in-flight pass. Reset the pass each frame so a long drag does + // Tail renders call block.render(), which observes inline images on the + // budget. This is a STABLE (partial) pass: the tail walk is bottom-up and + // sees only the visible subset, so display-order-by-call-order is wrong + // here — `beginPass(true)` makes observe() replay the last committed + // live/text split per image id instead, so images keep their on-screen + // state through the drag. Reset the pass each frame so a long drag does // not accumulate; never endPass() here — that mutates the demotion ledger - // off a partial (tail-only) walk. The settle paint's own - // beginPass()/endPass() is the authoritative accounting, and its - // beginPass() wipes whatever these frames observed. - this.#imageBudget.beginPass(); + // off a partial walk. The settle paint's own beginPass()/endPass() is the + // authoritative accounting, and its beginPass() wipes these frames. + this.#imageBudget.beginPass(true); const { window, contentRows } = this.#composeResizeViewport(width, height); this.#emitResizeViewport(window, height, contentRows); this.#resizeViewportPaintCount += 1; diff --git a/packages/tui/test/image-budget.test.ts b/packages/tui/test/image-budget.test.ts index 6f27e36a8..ebd0bc1aa 100644 --- a/packages/tui/test/image-budget.test.ts +++ b/packages/tui/test/image-budget.test.ts @@ -139,6 +139,35 @@ describe("ImageBudget", () => { const result = pass(budget, 3); expect(result.suppressed).toEqual([false, false, false]); }); + + it("replays the committed live/text split by id during a stable (partial) pass", () => { + const budget = new ImageBudget(2, () => {}); + // Settle to the steady split for 4 images at cap 2: oldest two (ids 1,2) + // demoted to text, newest two (ids 3,4) live. + pass(budget, 4); // threshold rises to 2 + pass(budget, 4); // applies the demotion of ids 1,2 + expect(pass(budget, 4).suppressed).toEqual([true, true, false, false]); + + // The resize fast path observes the visible tail bottom-up and only a + // subset of images. A stable pass must therefore decide live/text by the + // committed per-id split, NOT by call order: observing the newest images + // first (4, then 3) must still report them live, and the oldest text — + // the index-based path would wrongly suppress whichever arrives first. + budget.beginPass(true); + expect(budget.observe(4)).toBe(false); // newest, stays live + expect(budget.observe(3)).toBe(false); // stays live + expect(budget.observe(2)).toBe(true); // committed text + expect(budget.observe(1)).toBe(true); // committed text + // An id with no committed state (a brand-new image) defaults to live. + expect(budget.observe(99)).toBe(false); + + // The stable pass left the ledger untouched: the next full pass reports + // the same split and schedules no purge or redraw. + const after = pass(budget, 4); + expect(after.suppressed).toEqual([true, true, false, false]); + expect(after.reset).toBe(false); + expect(after.purge).toEqual([]); + }); }); describe("encodeKittyDeleteImage", () => {