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:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user