fix(tui): fixed inline image demotion replay during resize viewport fast-path

- ImageBudget gained a stable partial-pass mode that replayed the last committed suppressed-image id set during beginPass(true), avoiding reordered call-order decisions.
- TUI resize viewport painting switched to beginPass(true) and stopped calling endPass so tail-only drag repaints no longer mutated image demotion state.
- An ImageBudget test now verifies stable partial passes preserved live/text decisions by id and did not trigger purge or reset in the next full pass.
This commit is contained in:
can1357
2026-06-13 22:40:53 +02:00
parent e6f08311f0
commit ec312a2e81
4 changed files with 75 additions and 9 deletions
+5
View File
@@ -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
+32 -3
View File
@@ -76,6 +76,16 @@ export class ImageBudget {
#transmitted = new Set<number>();
/** 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<number>();
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;
}
+9 -6
View File
@@ -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;
+29
View File
@@ -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", () => {