Merge PR #8070: fix(tui): clip direct Kitty placements when image blocks straddle the viewport top (@voonfoo)
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed inline images rendering permanently cropped on Kitty direct-placement terminals (WezTerm, Warp) when an image block straddled the viewport top during streaming: placements are now clipped to the visible slice at write time, and a placement id whose cells reached native scrollback is never re-used ([#8070](https://github.com/can1357/oh-my-pi/pull/8070) by [@voonfoo](https://github.com/voonfoo))
|
||||
|
||||
## [17.2.12] - 2026-08-08
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface ImageOptions {
|
||||
|
||||
const EMPTY_IDS: readonly number[] = [];
|
||||
const EMPTY_TRANSMITS: readonly string[] = [];
|
||||
const EMPTY_STALE_EPOCHS: ReadonlyArray<{ imageId: number; lastEpoch: number }> = [];
|
||||
const SAVE_CURSOR = "\x1b7";
|
||||
const RESTORE_CURSOR = "\x1b8";
|
||||
// Direct placements reserve height with leading zero-width rows. Keep them
|
||||
@@ -38,6 +39,24 @@ const RESERVED_IMAGE_ROW = "\x1b[0m";
|
||||
/** Default count of inline images kept as live graphics before older ones fall back to text. */
|
||||
export const DEFAULT_MAX_INLINE_IMAGES = 8;
|
||||
|
||||
/** Per-image direct-placement emit state tracked by {@link ImageBudget}. */
|
||||
interface PlacementEmitState {
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
/** Current placement-id (`p=`) generation. */
|
||||
epoch: number;
|
||||
/** First frame row the current epoch's last emit attached cells to. */
|
||||
lastAttachTopFrameRow: number | undefined;
|
||||
/**
|
||||
* Whether any cell attached by the current epoch's last emit has entered
|
||||
* native scrollback. Set by {@link ImageBudget.observeCommitWatermark}
|
||||
* comparing each frame's raw commit target against the attach top —
|
||||
* era-local comparisons, so a divergence recommit that rewinds and
|
||||
* re-advances the ledger is detected the moment it re-crosses the attach
|
||||
* top, and a stale pre-rewind peak can never re-trigger.
|
||||
*/
|
||||
cellsArchived: boolean;
|
||||
}
|
||||
let nextImageBudgetSeed = Math.floor(Math.random() * 0xffffff);
|
||||
function nextImageIdSeed(): number {
|
||||
nextImageBudgetSeed = (nextImageBudgetSeed + 0x10000) & 0xffffff;
|
||||
@@ -96,6 +115,22 @@ export class ImageBudget {
|
||||
// id so a partial pass reproduces the on-screen live/text split without a
|
||||
// full, correctly-ordered walk.
|
||||
#suppressedIds = new Set<number>();
|
||||
/**
|
||||
* Per-image direct-placement emit state: source pixel geometry for the
|
||||
* renderer's clipped source rectangle, plus the placement-id epoch (see
|
||||
* {@link resolvePlacementEmit}). Entries deliberately live as long as the
|
||||
* terminal's own placement registry for the image — they are the ledger the
|
||||
* destructive-clear sweep uses to delete every registry entry an image ever
|
||||
* placed — and die with it on demotion purge (`d=I`) or full cleanup.
|
||||
*/
|
||||
#placementState = new Map<number, PlacementEmitState>();
|
||||
/**
|
||||
* States with an un-archived live attach top — the only ones a frame's
|
||||
* commit watermark can affect. {@link observeCommitWatermark} runs every
|
||||
* rendered frame, so it scans this set (bounded by concurrently live
|
||||
* placements) instead of every image ever registered.
|
||||
*/
|
||||
#watchedPlacements = new Set<PlacementEmitState>();
|
||||
|
||||
constructor(cap: number = DEFAULT_MAX_INLINE_IMAGES, requestRender: () => void = () => {}) {
|
||||
this.#cap = normalizeCap(cap);
|
||||
@@ -191,6 +226,7 @@ export class ImageBudget {
|
||||
this.#purgeIds.push(id);
|
||||
// d=I frees the data too, so the image must re-transmit if it returns.
|
||||
this.#transmitted.delete(id);
|
||||
this.#deletePlacementState(id);
|
||||
this.#forgetKeyForId(id);
|
||||
}
|
||||
this.#onTerminal = this.#planned;
|
||||
@@ -223,6 +259,8 @@ export class ImageBudget {
|
||||
this.#pendingTransmits = [];
|
||||
this.#keyToId.clear();
|
||||
this.#idToKey.clear();
|
||||
this.#placementState.clear();
|
||||
this.#watchedPlacements.clear();
|
||||
return ids;
|
||||
}
|
||||
|
||||
@@ -231,6 +269,116 @@ export class ImageBudget {
|
||||
return !this.#transmitted.has(imageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a direct-placement image's source pixel geometry so the renderer
|
||||
* can clip its placement to the visible slice at write time; cleared when
|
||||
* the image is purged from the terminal store.
|
||||
*/
|
||||
registerPlacementGeometry(imageId: number, widthPx: number, heightPx: number): void {
|
||||
const state = this.#placementState.get(imageId);
|
||||
if (state) {
|
||||
state.widthPx = widthPx;
|
||||
state.heightPx = heightPx;
|
||||
return;
|
||||
}
|
||||
this.#placementState.set(imageId, {
|
||||
widthPx,
|
||||
heightPx,
|
||||
epoch: 1,
|
||||
lastAttachTopFrameRow: undefined,
|
||||
cellsArchived: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record this frame's native-scrollback commit target (the frame-row count
|
||||
* that is committed once the frame's writes land). Called once per rendered
|
||||
* frame — including frames that emit no placements — so an epoch whose rows
|
||||
* commit while its line is never rewritten is still flagged before the next
|
||||
* re-emission.
|
||||
*/
|
||||
observeCommitWatermark(committedTo: number): void {
|
||||
if (committedTo < 0 || this.#watchedPlacements.size === 0) return;
|
||||
for (const state of this.#watchedPlacements) {
|
||||
if (state.lastAttachTopFrameRow !== undefined && committedTo > state.lastAttachTopFrameRow) {
|
||||
// Latched: the flag only clears when the next emit consumes it,
|
||||
// so the state needs no further per-frame scans until then.
|
||||
state.cellsArchived = true;
|
||||
this.#watchedPlacements.delete(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the placement id and geometry for a direct-placement emit whose
|
||||
* topmost attached cell sits at `attachTopFrameRow` — the first frame row
|
||||
* the placement covers, i.e. the block's first *visible* row, not its
|
||||
* origin (-1 when the writer has no frame-space position: alt-screen,
|
||||
* resize, ConPTY-truncated replays). `committedTo` is this frame's commit
|
||||
* target in the same frame-row space (-1 when unknown).
|
||||
*
|
||||
* Invariant: a placement id may be re-used (Kitty replace strips that id's
|
||||
* cells everywhere, scrollback included) only while none of the cells it
|
||||
* attached have entered native scrollback. The epoch — the `p=` id —
|
||||
* advances exactly when the archived flag says otherwise; rewrites with no
|
||||
* commit progression keep replacing the same id in place.
|
||||
*/
|
||||
resolvePlacementEmit(
|
||||
imageId: number,
|
||||
attachTopFrameRow: number,
|
||||
committedTo: number,
|
||||
): { placementId: number; widthPx: number; heightPx: number } | null {
|
||||
const state = this.#placementState.get(imageId);
|
||||
if (!state) return null;
|
||||
// Frames that commit as they write (seam/full-paint chunk passes) pass
|
||||
// their own commit target; fold it in before deciding, so a commit that
|
||||
// lands in the same frame as the re-emission still advances the epoch.
|
||||
if (committedTo >= 0 && state.lastAttachTopFrameRow !== undefined && committedTo > state.lastAttachTopFrameRow) {
|
||||
state.cellsArchived = true;
|
||||
this.#watchedPlacements.delete(state);
|
||||
}
|
||||
if (state.cellsArchived) {
|
||||
state.epoch += 1;
|
||||
state.cellsArchived = false;
|
||||
state.lastAttachTopFrameRow = undefined;
|
||||
}
|
||||
if (attachTopFrameRow >= 0) {
|
||||
state.lastAttachTopFrameRow = attachTopFrameRow;
|
||||
this.#watchedPlacements.add(state);
|
||||
}
|
||||
return { placementId: state.epoch, widthPx: state.widthPx, heightPx: state.heightPx };
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart every placement epoch after a destructive history clear (`CSI 3 J`
|
||||
* full paint). The clear destroys all placement cells — scrollback rows are
|
||||
* gone and the replay rewrites the viewport — so no archive remains to
|
||||
* protect. Reverting to epoch 1 lets the replay's placements replace the
|
||||
* terminal's stale registry entries; the returned list names every image
|
||||
* and the highest epoch it reached so the caller can delete all of its
|
||||
* registry entries explicitly (`d=i` keeps the transmitted data) — an image
|
||||
* absent from the replay never re-places, so even its epoch-1 entry must go.
|
||||
*/
|
||||
resetPlacementEpochs(): ReadonlyArray<{ imageId: number; lastEpoch: number }> {
|
||||
let stale: Array<{ imageId: number; lastEpoch: number }> | undefined;
|
||||
for (const [imageId, state] of this.#placementState) {
|
||||
stale ??= [];
|
||||
stale.push({ imageId, lastEpoch: state.epoch });
|
||||
state.epoch = 1;
|
||||
state.lastAttachTopFrameRow = undefined;
|
||||
state.cellsArchived = false;
|
||||
}
|
||||
this.#watchedPlacements.clear();
|
||||
return stale ?? EMPTY_STALE_EPOCHS;
|
||||
}
|
||||
|
||||
#deletePlacementState(imageId: number): void {
|
||||
const state = this.#placementState.get(imageId);
|
||||
if (!state) return;
|
||||
this.#watchedPlacements.delete(state);
|
||||
this.#placementState.delete(imageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a one-time transmit for `imageId`. No-op if already transmitted, so a
|
||||
* repeated call (e.g. a width-change re-render) never re-sends the data.
|
||||
@@ -407,9 +555,17 @@ export class Image implements Component {
|
||||
// Direct placement: return `rows` lines so TUI accounts for image
|
||||
// height. First (rows-1) lines are empty (TUI clears them); the last
|
||||
// saves the final-row cursor, moves up to the image origin, emits the
|
||||
// image sequence, then restores the final-row cursor. Save/restore is
|
||||
// required because CUU clamps at the viewport top when leading rows are
|
||||
// clipped away.
|
||||
// image sequence, then restores the final-row cursor. When the block
|
||||
// straddles the viewport top, the renderer rewrites this line to the
|
||||
// visible slice (encodeKittyPlacementLine) from the geometry
|
||||
// registered below.
|
||||
if (this.#imageId != null && this.#budget !== undefined) {
|
||||
this.#budget.registerPlacementGeometry(
|
||||
this.#imageId,
|
||||
this.#dimensions.widthPx,
|
||||
this.#dimensions.heightPx,
|
||||
);
|
||||
}
|
||||
lines = [];
|
||||
for (let i = 0; i < result.rows - 1; i++) {
|
||||
lines.push(RESERVED_IMAGE_ROW);
|
||||
|
||||
@@ -731,6 +731,80 @@ export function encodeKittyPlacement(options: {
|
||||
return wrapTmuxPassthroughIfNeeded(`\x1b_G${params.join(",")}\x1b\\`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact shape of the direct-placement line {@link Image} emits as its block's
|
||||
* last row: optional `ESC 7` + `CUU(rows-1)` prefix, the {@link encodeKittyPlacement}
|
||||
* APC, optional `ESC 8` suffix. tmux-passthrough-wrapped lines deliberately do
|
||||
* not match (passthrough placements stay untouched).
|
||||
*/
|
||||
const KITTY_DIRECT_PLACEMENT_LINE =
|
||||
/^(?:\x1b7(?:\x1b\[(\d+)A)?)?\x1b_Ga=p,q=2,C=1,i=(\d+)(?:,p=(\d+))?(?:,c=(\d+))?(?:,r=(\d+))?\x1b\\(?:\x1b8)?$/;
|
||||
|
||||
export interface ParsedKittyPlacementLine {
|
||||
imageId: number;
|
||||
placementId: number | undefined;
|
||||
columns: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a frame line that consists solely of a Kitty direct placement (the
|
||||
* last line of an {@link Image} block). Returns null for anything else —
|
||||
* placeholder grids, tmux-wrapped placements, sixel/iTerm2 payloads — so
|
||||
* callers fall back to writing the line verbatim.
|
||||
*/
|
||||
export function parseKittyDirectPlacementLine(line: string): ParsedKittyPlacementLine | null {
|
||||
const m = KITTY_DIRECT_PLACEMENT_LINE.exec(line);
|
||||
if (!m) return null;
|
||||
const columns = m[4] !== undefined ? Number(m[4]) : 0;
|
||||
const rows = m[5] !== undefined ? Number(m[5]) : 0;
|
||||
if (columns <= 0 || rows <= 0) return null;
|
||||
return {
|
||||
imageId: Number(m[2]),
|
||||
placementId: m[3] !== undefined ? Number(m[3]) : undefined,
|
||||
columns,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild an {@link Image} direct-placement line for the viewport row it is
|
||||
* written at. The component-rendered line encodes `CUU(rows-1)`, which clamps
|
||||
* at the viewport top once the block's leading rows have scrolled out — the
|
||||
* placement then re-anchors the full image shifted down over foreign rows.
|
||||
* Anchor at the block's first *visible* row instead, clipping the source
|
||||
* rectangle (`y=`/`h=`, image pixels) to the visible bottom slice.
|
||||
*/
|
||||
export function encodeKittyPlacementLine(options: {
|
||||
imageId: number;
|
||||
placementId: number;
|
||||
columns: number;
|
||||
/** Total cell rows of the image block. */
|
||||
rows: number;
|
||||
/** Viewport row the block's last line is being written at. */
|
||||
screenRow: number;
|
||||
/** Source image height in pixels, for the clipped source rectangle. */
|
||||
imageHeightPx: number;
|
||||
}): string {
|
||||
// Without a source pixel height the slice cannot be expressed — emit the
|
||||
// component's own full form (status quo) rather than squashing the whole
|
||||
// image into the reduced row count.
|
||||
const clippable = options.imageHeightPx > 0;
|
||||
const hiddenRows = clippable ? Math.max(0, options.rows - 1 - options.screenRow) : 0;
|
||||
const visibleRows = options.rows - hiddenRows;
|
||||
const params: string[] = ["a=p", "q=2", "C=1", `i=${options.imageId}`, `p=${options.placementId}`];
|
||||
params.push(`c=${options.columns}`, `r=${visibleRows}`);
|
||||
if (hiddenRows > 0) {
|
||||
const srcY = Math.floor((options.imageHeightPx * hiddenRows) / options.rows);
|
||||
params.push(`y=${srcY}`, `h=${Math.max(1, options.imageHeightPx - srcY)}`);
|
||||
}
|
||||
// No tmux passthrough: inside tmux the component's own line arrives
|
||||
// wrapped, never parses, and never reaches this rewrite.
|
||||
const apc = `\x1b_G${params.join(",")}\x1b\\`;
|
||||
const cuu = visibleRows - 1;
|
||||
return cuu > 0 ? `\x1b7\x1b[${cuu}A${apc}\x1b8` : apc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kitty graphics delete command for a single image id. Uses `d=I` (capital)
|
||||
* which removes the image and every one of its placements — on screen *and* in
|
||||
@@ -742,6 +816,16 @@ export function encodeKittyDeleteImage(imageId: number): string {
|
||||
return wrapTmuxPassthroughIfNeeded(`\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single placement of an image (`d=i`, lowercase): removes its cells
|
||||
* and registry entry but keeps the transmitted data, so a later `a=p` under a
|
||||
* fresh placement id needs no retransmit. Used to clear stale placement-epoch
|
||||
* entries after a destructive history clear.
|
||||
*/
|
||||
export function encodeKittyDeletePlacement(imageId: number, placementId: number): string {
|
||||
return wrapTmuxPassthroughIfNeeded(`\x1b_Ga=d,d=i,i=${imageId},p=${placementId},q=2\x1b\\`);
|
||||
}
|
||||
|
||||
export function encodeITerm2(
|
||||
base64Data: string,
|
||||
options: {
|
||||
|
||||
+97
-16
@@ -25,8 +25,11 @@ import { LoopWatchdog } from "./loop-watchdog";
|
||||
import { isConPTYHosted, setAltScreenActive, type Terminal } from "./terminal";
|
||||
import {
|
||||
encodeKittyDeleteImage,
|
||||
encodeKittyDeletePlacement,
|
||||
encodeKittyPlacementLine,
|
||||
ImageProtocol,
|
||||
isInsideTerminalMultiplexer,
|
||||
parseKittyDirectPlacementLine,
|
||||
setCellDimensions,
|
||||
setTerminalImageProtocol,
|
||||
shouldEnableSynchronizedOutputByDefault,
|
||||
@@ -2133,7 +2136,13 @@ export class TUI extends Container {
|
||||
buffer += "\r";
|
||||
for (let i = firstChanged; i <= lastChanged; i++) {
|
||||
if (i > firstChanged) buffer += "\r\n";
|
||||
buffer += this.#lineRewriteSequence(this.#preparedFrame[segment.start + i] ?? "", width);
|
||||
buffer += this.#lineRewriteSequence(
|
||||
this.#preparedFrame[segment.start + i] ?? "",
|
||||
width,
|
||||
screenStart + i,
|
||||
segment.start + i,
|
||||
this.#committedRows,
|
||||
);
|
||||
}
|
||||
const cursorControl = this.#cursorControlSequence(
|
||||
cursorPos,
|
||||
@@ -2796,8 +2805,39 @@ export class TUI extends Container {
|
||||
};
|
||||
}
|
||||
|
||||
#terminalLine(line: string): string {
|
||||
if (TERMINAL.isImageLine(line)) return line;
|
||||
/**
|
||||
* Rewrite a Kitty direct-placement line for the viewport row it is written
|
||||
* at, clipping to the visible slice (see {@link encodeKittyPlacementLine})
|
||||
* under the placement id resolved by the budget's epoch tracking (see
|
||||
* {@link ImageBudget.resolvePlacementEmit}). `screenRow` -1 (write position
|
||||
* unknown) and non-placement image lines (placeholder grids, sixel, iTerm2,
|
||||
* tmux-wrapped) pass through verbatim.
|
||||
*/
|
||||
#imageLineSequence(line: string, screenRow: number, frameRow: number, committedTo: number): string {
|
||||
if (screenRow < 0) return line;
|
||||
const parsed = parseKittyDirectPlacementLine(line);
|
||||
if (!parsed) return line;
|
||||
// The emitted placement attaches from the block's first *visible* row
|
||||
// (the clip drops the rows above the viewport), so epoch tracking keys
|
||||
// on that row — not the block origin, which may be long committed.
|
||||
const placement = this.#imageBudget.resolvePlacementEmit(
|
||||
parsed.imageId,
|
||||
frameRow >= 0 ? frameRow - Math.min(parsed.rows - 1, screenRow) : -1,
|
||||
committedTo,
|
||||
);
|
||||
if (!placement) return line;
|
||||
return encodeKittyPlacementLine({
|
||||
imageId: parsed.imageId,
|
||||
placementId: placement.placementId,
|
||||
columns: parsed.columns,
|
||||
rows: parsed.rows,
|
||||
screenRow,
|
||||
imageHeightPx: placement.heightPx,
|
||||
});
|
||||
}
|
||||
|
||||
#terminalLine(line: string, screenRow = -1, frameRow = -1, committedTo = -1): string {
|
||||
if (TERMINAL.isImageLine(line)) return this.#imageLineSequence(line, screenRow, frameRow, committedTo);
|
||||
const coalesced = coalesceAdjacentSgr(line);
|
||||
return coalesced + (line.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
|
||||
}
|
||||
@@ -3208,6 +3248,12 @@ export class TUI extends Container {
|
||||
} else {
|
||||
this.#imageBudget.takePurgeIds();
|
||||
}
|
||||
// Feed this frame's commit target to the placement-epoch tracker before
|
||||
// any placement resolves against it — an epoch whose rows commit during
|
||||
// frames that never rewrite its line must still advance on the next
|
||||
// re-emission, and the raw per-frame value keeps the check correct
|
||||
// across committed-ledger rewinds.
|
||||
this.#imageBudget.observeCommitWatermark(chunkTo);
|
||||
|
||||
// 6. Emit.
|
||||
if (intent.kind === "fullPaint") {
|
||||
@@ -3506,8 +3552,10 @@ export class TUI extends Container {
|
||||
return col;
|
||||
}
|
||||
|
||||
#lineRewriteSequence(line: string, width: number): string {
|
||||
if (TERMINAL.isImageLine(line)) return ERASE_LINE + line;
|
||||
#lineRewriteSequence(line: string, width: number, screenRow = -1, frameRow = -1, committedTo = -1): string {
|
||||
if (TERMINAL.isImageLine(line)) {
|
||||
return ERASE_LINE + this.#imageLineSequence(line, screenRow, frameRow, committedTo);
|
||||
}
|
||||
const terminalLine = this.#terminalLine(line);
|
||||
const asciiWidth = this.#ansiAsciiLineWidth(line, width);
|
||||
if (asciiWidth !== undefined) {
|
||||
@@ -3674,7 +3722,17 @@ export class TUI extends Container {
|
||||
// Clear native history without blanking the live viewport first. The
|
||||
// replay below rewrites every visible row from home, including blanks,
|
||||
// so terminals without DEC 2026 never expose an ED2-cleared frame.
|
||||
// The clear also destroys every placement cell, so placement epochs
|
||||
// restart and every registry entry each image ever placed is deleted
|
||||
// explicitly (`d=i` keeps the transmitted data, so the replay needs
|
||||
// no retransmit). Deleting epoch 1 too matters for images absent from
|
||||
// the replay — nothing would ever replace their stale entry.
|
||||
buffer += "\x1b[H\x1b[3J";
|
||||
for (const { imageId, lastEpoch } of this.#imageBudget.resetPlacementEpochs()) {
|
||||
for (let placementId = 1; placementId <= lastEpoch; placementId++) {
|
||||
buffer += encodeKittyDeletePlacement(imageId, placementId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Best-effort: push the pre-paint screen into scrollback on
|
||||
// terminals that implement kitty's ED 22
|
||||
@@ -3711,20 +3769,31 @@ export class TUI extends Container {
|
||||
// each row must self-clear stale cells left by the previous viewport.
|
||||
for (let i = 0; i < chunkTo; i++) {
|
||||
if (i > 0) buffer += "\r\n";
|
||||
const writeRow = Math.min(i, height - 1);
|
||||
buffer += options.clearScrollback
|
||||
? this.#lineRewriteSequence(frame[i] ?? "", width)
|
||||
: this.#terminalLine(frame[i] ?? "");
|
||||
? this.#lineRewriteSequence(frame[i] ?? "", width, writeRow, i, chunkTo)
|
||||
: this.#terminalLine(frame[i] ?? "", writeRow, i, chunkTo);
|
||||
}
|
||||
for (let screenRow = 0; screenRow < height; screenRow++) {
|
||||
if (chunkTo + screenRow > 0) buffer += "\r\n";
|
||||
const line = visibleTexts ? (visibleTexts[screenRow] ?? "") : (window[screenRow] ?? "");
|
||||
buffer += options.clearScrollback ? this.#lineRewriteSequence(line, width) : this.#terminalLine(line);
|
||||
const writeRow = Math.min(chunkTo + screenRow, height - 1);
|
||||
const frameRow = windowTop + screenRow;
|
||||
buffer += options.clearScrollback
|
||||
? this.#lineRewriteSequence(line, width, writeRow, frameRow, chunkTo)
|
||||
: this.#terminalLine(line, writeRow, frameRow, chunkTo);
|
||||
}
|
||||
} else {
|
||||
// ConPTY-truncated replay: leading rows were dropped, so frame-space
|
||||
// positions are unknown — placements still clip to the write row but
|
||||
// skip epoch bookkeeping.
|
||||
for (let i = 0; i < paintLines.length; i++) {
|
||||
if (i > 0) buffer += "\r\n";
|
||||
const line = visibleTexts && i >= visibleStart ? visibleTexts[i - visibleStart] : (paintLines[i] ?? "");
|
||||
buffer += options.clearScrollback ? this.#lineRewriteSequence(line, width) : this.#terminalLine(line);
|
||||
const writeRow = Math.min(i, height - 1);
|
||||
buffer += options.clearScrollback
|
||||
? this.#lineRewriteSequence(line, width, writeRow, -1, chunkTo)
|
||||
: this.#terminalLine(line, writeRow, -1, chunkTo);
|
||||
}
|
||||
}
|
||||
buffer += fillSequence;
|
||||
@@ -3922,7 +3991,7 @@ export class TUI extends Container {
|
||||
let buffer = `${this.#paintBeginSequence + altEnter}\x1b[H`;
|
||||
for (let r = 0; r < height; r++) {
|
||||
if (r > 0) buffer += "\r\n";
|
||||
buffer += this.#lineRewriteSequence(window[r] ?? "", width);
|
||||
buffer += this.#lineRewriteSequence(window[r] ?? "", width, r, -1, this.#committedRows);
|
||||
}
|
||||
// Park the hardware cursor at the real content bottom, not the padded
|
||||
// viewport bottom: a later height shrink would otherwise scroll the live
|
||||
@@ -3988,7 +4057,7 @@ export class TUI extends Container {
|
||||
let buffer = `${this.#paintBeginSequence}\x1b[H`;
|
||||
for (let r = 0; r < height; r++) {
|
||||
if (r > 0) buffer += "\r\n";
|
||||
buffer += this.#lineRewriteSequence(fitted[r], width);
|
||||
buffer += this.#lineRewriteSequence(fitted[r], width, r, -1, -1);
|
||||
}
|
||||
buffer += this.#paintEndSequence;
|
||||
this.terminal.write(buffer);
|
||||
@@ -4067,7 +4136,7 @@ export class TUI extends Container {
|
||||
const moveToBottom = height - 1 - currentScreenRow;
|
||||
if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`;
|
||||
for (let r = height - scroll; r < height; r++) {
|
||||
buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width)}`;
|
||||
buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width, height - 1, windowTop + r, chunkTo)}`;
|
||||
}
|
||||
// Rewrite any remaining changed rows after the shift.
|
||||
let firstChanged = -1;
|
||||
@@ -4084,7 +4153,7 @@ export class TUI extends Container {
|
||||
buffer += "\r";
|
||||
for (let r = firstChanged; r <= lastChanged; r++) {
|
||||
if (r > firstChanged) buffer += "\r\n";
|
||||
buffer += this.#lineRewriteSequence(window[r] ?? "", width);
|
||||
buffer += this.#lineRewriteSequence(window[r] ?? "", width, r, windowTop + r, chunkTo);
|
||||
}
|
||||
cursorFromRow = windowTop + lastChanged;
|
||||
}
|
||||
@@ -4153,7 +4222,13 @@ export class TUI extends Container {
|
||||
}
|
||||
for (let r = firstChanged; r <= lastChanged; r++) {
|
||||
if (r > firstChanged) buffer += "\r\n";
|
||||
buffer += this.#lineRewriteSequence(fillTexts ? fillTexts[r - firstChanged] : (window[r] ?? ""), width);
|
||||
buffer += this.#lineRewriteSequence(
|
||||
fillTexts ? fillTexts[r - firstChanged] : (window[r] ?? ""),
|
||||
width,
|
||||
r,
|
||||
windowTop + r,
|
||||
this.#committedRows,
|
||||
);
|
||||
}
|
||||
buffer += fillSequence;
|
||||
// Never park below real content (a height shrink would scroll live
|
||||
@@ -4184,12 +4259,18 @@ export class TUI extends Container {
|
||||
let wroteLine = false;
|
||||
for (let i = chunkFrom; i < chunkTo; i++) {
|
||||
if (wroteLine) buffer += "\r\n";
|
||||
buffer += this.#lineRewriteSequence(frame[i] ?? "", width);
|
||||
buffer += this.#lineRewriteSequence(frame[i] ?? "", width, Math.min(i - chunkFrom, height - 1), i, chunkTo);
|
||||
wroteLine = true;
|
||||
}
|
||||
for (let screenRow = 0; screenRow < height; screenRow++) {
|
||||
if (wroteLine) buffer += "\r\n";
|
||||
buffer += this.#lineRewriteSequence(window[screenRow] ?? "", width);
|
||||
buffer += this.#lineRewriteSequence(
|
||||
window[screenRow] ?? "",
|
||||
width,
|
||||
Math.min(chunkTo - chunkFrom + screenRow, height - 1),
|
||||
windowTop + screenRow,
|
||||
chunkTo,
|
||||
);
|
||||
wroteLine = true;
|
||||
}
|
||||
const parkUp = height - 1 - (contentBottomRow - windowTop);
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import { Container, type NativeScrollbackLiveRegion, type RenderScheduler, TUI } from "@oh-my-pi/pi-tui";
|
||||
import { Image, ImageBudget } from "@oh-my-pi/pi-tui/components/image";
|
||||
import { Text } from "@oh-my-pi/pi-tui/components/text";
|
||||
import { getKittyGraphics, setKittyGraphics } from "@oh-my-pi/pi-tui/kitty-graphics";
|
||||
import {
|
||||
type CellDimensions,
|
||||
encodeKittyPlacementLine,
|
||||
getCellDimensions,
|
||||
ImageProtocol,
|
||||
parseKittyDirectPlacementLine,
|
||||
setCellDimensions,
|
||||
TERMINAL,
|
||||
wrapTmuxPassthrough,
|
||||
} from "@oh-my-pi/pi-tui/terminal-capabilities";
|
||||
import { VirtualTerminal } from "./virtual-terminal";
|
||||
|
||||
type MutableTerminalInfo = { id: string; imageProtocol: ImageProtocol | null };
|
||||
const terminal = TERMINAL as unknown as MutableTerminalInfo;
|
||||
|
||||
const BASE64_ONE_PIXEL_PNG =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGNgAAAAAgABSK+kcQAAAABJRU5ErkJggg==";
|
||||
|
||||
// Direct-placement contract: a straddling image block must be re-anchored at
|
||||
// its first visible row with the source rectangle clipped to the visible
|
||||
// slice, and a placement id whose cells reached native scrollback must never
|
||||
// be re-used (Kitty replace semantics strip the old placement everywhere,
|
||||
// scrollback included — the permanently cropped images on WezTerm).
|
||||
|
||||
const originalProtocol = TERMINAL.imageProtocol;
|
||||
const originalTerminalId = terminal.id;
|
||||
const originalGraphics = { ...getKittyGraphics() };
|
||||
let originalCellDims: CellDimensions;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCellDims = getCellDimensions();
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
terminal.imageProtocol = ImageProtocol.Kitty;
|
||||
terminal.id = "wezterm";
|
||||
setKittyGraphics({ unicodePlaceholders: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setCellDimensions(originalCellDims);
|
||||
terminal.imageProtocol = originalProtocol;
|
||||
terminal.id = originalTerminalId;
|
||||
setKittyGraphics(originalGraphics);
|
||||
});
|
||||
|
||||
describe("kitty direct-placement wire format", () => {
|
||||
it("round-trips the exact line Image renders for a direct placement", () => {
|
||||
const budget = new ImageBudget(8, () => {});
|
||||
const image = new Image(
|
||||
BASE64_ONE_PIXEL_PNG,
|
||||
"image/png",
|
||||
{ fallbackColor: t => t },
|
||||
{ maxWidthCells: 4, maxHeightCells: 6, budget, imageKey: "roundtrip" },
|
||||
{ widthPx: 40, heightPx: 60 },
|
||||
);
|
||||
const imageId = budget.acquireId("roundtrip");
|
||||
budget.beginPass();
|
||||
const lines = image.render(40);
|
||||
budget.endPass();
|
||||
expect(lines.length).toBe(6);
|
||||
const parsed = parseKittyDirectPlacementLine(lines[lines.length - 1]!);
|
||||
expect(parsed).toEqual({ imageId, placementId: imageId, columns: 4, rows: 6 });
|
||||
});
|
||||
|
||||
it("rejects non-placement image lines", () => {
|
||||
// Placeholder virtual placement (a=p,U=1 lead) is not a direct placement.
|
||||
expect(parseKittyDirectPlacementLine("\x1b_Ga=p,U=1,q=2,i=5,p=5,c=4,r=4\x1b\\")).toBeNull();
|
||||
// tmux-wrapped placements stay untouched.
|
||||
expect(parseKittyDirectPlacementLine(wrapTmuxPassthrough("\x1b_Ga=p,q=2,C=1,i=5,p=5,c=4,r=4\x1b\\"))).toBeNull();
|
||||
// Transmit-and-display and plain text never match.
|
||||
expect(parseKittyDirectPlacementLine("\x1b_Ga=T,f=100,q=2,C=1,c=4,r=4;AAAA\x1b\\")).toBeNull();
|
||||
expect(parseKittyDirectPlacementLine("plain text")).toBeNull();
|
||||
});
|
||||
|
||||
it("encodes the anchored, clipped, and last-row-only placement forms", () => {
|
||||
const base = { imageId: 7, columns: 4, rows: 6, imageHeightPx: 60 };
|
||||
// Whole block visible (last line at viewport row 9): full anchored form.
|
||||
expect(encodeKittyPlacementLine({ ...base, placementId: 1, screenRow: 9 })).toBe(
|
||||
"\x1b7\x1b[5A\x1b_Ga=p,q=2,C=1,i=7,p=1,c=4,r=6\x1b\\\x1b8",
|
||||
);
|
||||
// Straddling (last line at row 3): two rows hidden above, four visible —
|
||||
// the source slice starts at 60*2/6 = 20px.
|
||||
expect(encodeKittyPlacementLine({ ...base, placementId: 2, screenRow: 3 })).toBe(
|
||||
"\x1b7\x1b[3A\x1b_Ga=p,q=2,C=1,i=7,p=2,c=4,r=4,y=20,h=40\x1b\\\x1b8",
|
||||
);
|
||||
// Only the last row visible: no cursor movement, bottom slice only.
|
||||
expect(encodeKittyPlacementLine({ ...base, placementId: 3, screenRow: 0 })).toBe(
|
||||
"\x1b_Ga=p,q=2,C=1,i=7,p=3,c=4,r=1,y=50,h=10\x1b\\",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Unit tests cover only the epoch transitions the TUI integration below cannot
|
||||
// reach deterministically (ledger rewinds, position-less emits, purge
|
||||
// lifecycle, reset reporting). The ordinary advance/stability arithmetic is
|
||||
// proven end-to-end by the integration tests' exact placement-id assertions.
|
||||
describe("ImageBudget placement epochs", () => {
|
||||
it("skips epoch bookkeeping for emits without a frame position", () => {
|
||||
const budget = new ImageBudget(8, () => {});
|
||||
budget.registerPlacementGeometry(5, 40, 60);
|
||||
expect(budget.resolvePlacementEmit(5, 10, 4)?.placementId).toBe(1);
|
||||
// Alt-screen/resize emit: unknown position, unknown commits.
|
||||
expect(budget.resolvePlacementEmit(5, -1, -1)?.placementId).toBe(1);
|
||||
// The unknown emit must not have overwritten the tracked attach top.
|
||||
expect(budget.resolvePlacementEmit(5, 10, 12)?.placementId).toBe(2);
|
||||
});
|
||||
|
||||
it("returns null for unregistered ids and after a full purge", () => {
|
||||
const budget = new ImageBudget(8, () => {});
|
||||
expect(budget.resolvePlacementEmit(9, 0, 0)).toBeNull();
|
||||
budget.registerPlacementGeometry(9, 40, 60);
|
||||
budget.enqueueTransmit(9, "seq");
|
||||
expect(budget.resolvePlacementEmit(9, 0, -1)).not.toBeNull();
|
||||
budget.takeAllTransmittedIds();
|
||||
expect(budget.resolvePlacementEmit(9, 0, -1)).toBeNull();
|
||||
});
|
||||
|
||||
it("detects archived cells across commit-ledger rewinds without churning afterwards", () => {
|
||||
const budget = new ImageBudget(8, () => {});
|
||||
budget.registerPlacementGeometry(5, 40, 60);
|
||||
// Placement 1 attaches from row 100 while commits sit at 90.
|
||||
expect(budget.resolvePlacementEmit(5, 100, 90)?.placementId).toBe(1);
|
||||
// Later frames (no re-emission) commit past the attach top...
|
||||
budget.observeCommitWatermark(120);
|
||||
// ...then a divergence recommit rewinds the ledger to 50. The archived
|
||||
// cells are physically in scrollback, so the re-emit must not re-use
|
||||
// placement 1 (Kitty replace would strip the archive).
|
||||
expect(budget.resolvePlacementEmit(5, 60, 50)?.placementId).toBe(2);
|
||||
// The stale pre-rewind peak must NOT keep advancing the epoch: rewrites
|
||||
// with no commit progression replace placement 2 in place.
|
||||
expect(budget.resolvePlacementEmit(5, 60, 50)?.placementId).toBe(2);
|
||||
budget.observeCommitWatermark(55);
|
||||
expect(budget.resolvePlacementEmit(5, 60, 50)?.placementId).toBe(2);
|
||||
// The recommit re-crossing the new attach top advances exactly once.
|
||||
budget.observeCommitWatermark(61);
|
||||
expect(budget.resolvePlacementEmit(5, 60, 50)?.placementId).toBe(3);
|
||||
});
|
||||
|
||||
it("reports every image with its highest epoch when resetting", () => {
|
||||
const budget = new ImageBudget(8, () => {});
|
||||
budget.registerPlacementGeometry(5, 40, 60);
|
||||
budget.registerPlacementGeometry(7, 40, 60);
|
||||
expect(budget.resolvePlacementEmit(5, 10, 4)?.placementId).toBe(1);
|
||||
expect(budget.resolvePlacementEmit(5, 12, 12)?.placementId).toBe(2);
|
||||
expect(budget.resolvePlacementEmit(7, 30, 12)?.placementId).toBe(1);
|
||||
// Every image is reported — an image absent from the replay never
|
||||
// re-places, so even its epoch-1 registry entry must be deleted.
|
||||
expect(budget.resetPlacementEpochs()).toEqual([
|
||||
{ imageId: 5, lastEpoch: 2 },
|
||||
{ imageId: 7, lastEpoch: 1 },
|
||||
]);
|
||||
// After the reset both images are back at epoch 1.
|
||||
expect(budget.resolvePlacementEmit(5, 10, -1)?.placementId).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TUI direct-placement clipping", () => {
|
||||
/**
|
||||
* Deterministic render driver: every scheduled callback (immediate and
|
||||
* delayed) queues here and `pump()` drains it to a fixed point, so each
|
||||
* mutation renders exactly once per pump with no wall-clock coalescing.
|
||||
*/
|
||||
function makeManualScheduler(): { scheduler: RenderScheduler; pump: () => void } {
|
||||
let now = 0;
|
||||
const queue: Array<{ callback: () => void; canceled: boolean }> = [];
|
||||
const enqueue = (callback: () => void) => {
|
||||
const entry = { callback, canceled: false };
|
||||
queue.push(entry);
|
||||
return entry;
|
||||
};
|
||||
return {
|
||||
scheduler: {
|
||||
now: () => now,
|
||||
scheduleImmediate: (callback: () => void) => {
|
||||
enqueue(callback);
|
||||
},
|
||||
scheduleRender: (callback: () => void, _delayMs: number) => {
|
||||
const entry = enqueue(callback);
|
||||
return {
|
||||
cancel: () => {
|
||||
entry.canceled = true;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
pump: () => {
|
||||
for (let guard = 0; guard < 20 && queue.length > 0; guard++) {
|
||||
const batch = queue.splice(0, queue.length);
|
||||
now += 50;
|
||||
for (const entry of batch) {
|
||||
if (!entry.canceled) entry.callback();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class PinnedLiveBlock extends Container implements NativeScrollbackLiveRegion {
|
||||
finalized = false;
|
||||
getNativeScrollbackLiveRegionStart(): number | undefined {
|
||||
return this.finalized ? undefined : 0;
|
||||
}
|
||||
isNativeScrollbackLiveRegionPinned(): boolean {
|
||||
return !this.finalized;
|
||||
}
|
||||
}
|
||||
|
||||
interface CapturedPlacement {
|
||||
cuu: number;
|
||||
imageId: number;
|
||||
placementId: number;
|
||||
rows: number;
|
||||
srcY: number | undefined;
|
||||
}
|
||||
|
||||
function capturePlacements(output: string, imageId: number): CapturedPlacement[] {
|
||||
const re = /(?:\x1b7(?:\x1b\[(\d+)A)?)?\x1b_Ga=p,q=2,C=1,i=(\d+),p=(\d+),c=\d+,r=(\d+)(?:,y=(\d+),h=\d+)?\x1b\\/g;
|
||||
const captured: CapturedPlacement[] = [];
|
||||
for (const m of output.matchAll(re)) {
|
||||
if (Number(m[2]) !== imageId) continue;
|
||||
captured.push({
|
||||
cuu: m[1] !== undefined ? Number(m[1]) : 0,
|
||||
imageId: Number(m[2]),
|
||||
placementId: Number(m[3]),
|
||||
rows: Number(m[4]),
|
||||
srcY: m[5] !== undefined ? Number(m[5]) : undefined,
|
||||
});
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
|
||||
/** Placement ids deleted for `imageId` via `d=i` in `output`, in order. */
|
||||
function captureDeletes(output: string, imageId: number): number[] {
|
||||
const re = /\x1b_Ga=d,d=i,i=(\d+),p=(\d+),q=2\x1b\\/g;
|
||||
const deleted: number[] = [];
|
||||
for (const m of output.matchAll(re)) {
|
||||
if (Number(m[1]) === imageId) deleted.push(Number(m[2]));
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 40×12 TUI with a captured write stream, a manual scheduler, one 4×6-cell
|
||||
* direct-placement image (in a pinned live block when `pinned`), and a
|
||||
* trailing stream Text whose growth walks the block out of the viewport.
|
||||
*/
|
||||
function makeHarness(key: string, opts: { pinned?: boolean } = {}) {
|
||||
const term = new VirtualTerminal(40, 12);
|
||||
const writes: string[] = [];
|
||||
const realWrite = term.write.bind(term);
|
||||
vi.spyOn(term, "write").mockImplementation((data: string) => {
|
||||
writes.push(data);
|
||||
realWrite(data);
|
||||
});
|
||||
const { scheduler, pump } = makeManualScheduler();
|
||||
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
||||
const image = new Image(
|
||||
BASE64_ONE_PIXEL_PNG,
|
||||
"image/png",
|
||||
{ fallbackColor: t => t },
|
||||
{ maxWidthCells: 4, maxHeightCells: 6, budget: tui.imageBudget, imageKey: key },
|
||||
{ widthPx: 40, heightPx: 60 },
|
||||
);
|
||||
const imageId = tui.imageBudget.acquireId(key);
|
||||
const stream = new Text("", 0, 0);
|
||||
const block = new PinnedLiveBlock();
|
||||
tui.addChild(new Text("header", 0, 0));
|
||||
if (opts.pinned) {
|
||||
block.addChild(new Text("tool-head", 0, 0));
|
||||
block.addChild(image);
|
||||
tui.addChild(block);
|
||||
} else {
|
||||
tui.addChild(image);
|
||||
}
|
||||
tui.addChild(stream);
|
||||
const lines: string[] = [];
|
||||
return {
|
||||
tui,
|
||||
writes,
|
||||
pump,
|
||||
imageId,
|
||||
block,
|
||||
image,
|
||||
output: () => writes.join(""),
|
||||
streamLines(count: number) {
|
||||
for (let n = 0; n < count; n++) {
|
||||
lines.push(`streaming line ${lines.length + 1}`);
|
||||
stream.setText(lines.join("\n"));
|
||||
tui.requestRender();
|
||||
pump();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("clips straddling re-emits to the visible slice, then archives under a fresh id on finalize", () => {
|
||||
const h = makeHarness("clip", { pinned: true });
|
||||
try {
|
||||
h.tui.start();
|
||||
h.pump();
|
||||
|
||||
// Frame layout: header(1) + tool-head(1) + image block rows 2..7.
|
||||
// Stream one line per frame until the frame is 10 rows taller than
|
||||
// the viewport — the block walks out of the top of the window and,
|
||||
// because the pinned live region blocks commits, every slid frame
|
||||
// takes the in-place full-window rewrite that re-emits the placement.
|
||||
h.streamLines(20);
|
||||
|
||||
const placements = capturePlacements(h.output(), h.imageId);
|
||||
expect(placements.length).toBeGreaterThan(0);
|
||||
for (const p of placements) {
|
||||
// The anchor CUU never exceeds the rows the placement actually
|
||||
// spans — the pre-fix failure shape was cuu=5 with r=6 emitted at
|
||||
// a viewport row < 5, which the terminal clamps and re-anchors
|
||||
// shifted (the permanently cropped image).
|
||||
expect(p.cuu).toBe(p.rows - 1);
|
||||
if (p.rows < 6) {
|
||||
// Clipped: the source rectangle starts exactly at the hidden slice.
|
||||
expect(p.srcY).toBe(Math.floor((60 * (6 - p.rows)) / 6));
|
||||
} else {
|
||||
expect(p.srcY).toBeUndefined();
|
||||
}
|
||||
}
|
||||
// The walk-out must actually have produced clipped emissions.
|
||||
expect(placements.some(p => p.rows < 6)).toBe(true);
|
||||
// Pinned region ⇒ nothing committed past the block origin ⇒ the
|
||||
// placement id never advances.
|
||||
expect(new Set(placements.map(p => p.placementId))).toEqual(new Set([1]));
|
||||
|
||||
// Finalize the block: the seam rewrite commits its rows through the
|
||||
// screen. That commit passes placement 1's attach top, so the archive
|
||||
// copy written into scrollback must carry a fresh placement id —
|
||||
// replacing placement 1 later would strip the committed cells.
|
||||
h.writes.length = 0;
|
||||
h.block.finalized = true;
|
||||
h.tui.invalidate();
|
||||
h.tui.requestRender();
|
||||
h.pump();
|
||||
|
||||
const committed = capturePlacements(h.output(), h.imageId);
|
||||
expect(committed.length).toBeGreaterThan(0);
|
||||
const archive = committed[committed.length - 1]!;
|
||||
// Exactly one epoch advance: the finalize commit bumps 1 → 2, no churn.
|
||||
expect(archive.placementId).toBe(2);
|
||||
// The archive copy is the full image, not a clipped slice.
|
||||
expect(archive.rows).toBe(6);
|
||||
expect(archive.srcY).toBeUndefined();
|
||||
} finally {
|
||||
h.tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("bumps the epoch when an in-window rewrite re-emits after mid-stream commits passed the origin", () => {
|
||||
const h = makeHarness("midstream");
|
||||
try {
|
||||
h.tui.start();
|
||||
h.pump();
|
||||
|
||||
// Frame layout: header(1) + image rows 1..6. Unpinned streaming:
|
||||
// scroll-appends commit rows past the block origin while the
|
||||
// placement-1 cells scroll natively (no re-emission).
|
||||
h.streamLines(10);
|
||||
const beforeOverlay = capturePlacements(h.output(), h.imageId);
|
||||
expect(new Set(beforeOverlay.map(p => p.placementId))).toEqual(new Set([1]));
|
||||
|
||||
// An overlay frame forces the in-place full-window rewrite — the
|
||||
// in-window diff path re-emits the straddling placement line with
|
||||
// committedTo = the already-advanced committed row count.
|
||||
h.writes.length = 0;
|
||||
const overlay = h.tui.showOverlay(new Text("OVERLAY", 0, 0), { anchor: "top-left", width: "100%" });
|
||||
h.pump();
|
||||
overlay.hide();
|
||||
h.pump();
|
||||
|
||||
const after = capturePlacements(h.output(), h.imageId);
|
||||
expect(after.length).toBeGreaterThan(0);
|
||||
for (const p of after) {
|
||||
// Commits passed the origin before this emit: placement 1 is
|
||||
// scrollback archive and must not be replaced.
|
||||
expect(p.placementId).toBe(2);
|
||||
// The block straddles the window top, so the re-emit is clipped.
|
||||
expect(p.rows).toBeLessThan(6);
|
||||
expect(p.srcY).toBe(Math.floor((60 * (6 - p.rows)) / 6));
|
||||
expect(p.cuu).toBe(p.rows - 1);
|
||||
}
|
||||
// Show + hide are two rewrites with no commit progression between
|
||||
// them: both must replace placement 2 exactly — repeated overlay
|
||||
// toggles must not mint a fresh placement per frame (#8057 review).
|
||||
expect(new Set(after.map(p => p.placementId))).toEqual(new Set([2]));
|
||||
} finally {
|
||||
h.tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("restarts epochs on a destructive clear, deleting exactly the ids each image ever placed", () => {
|
||||
const h = makeHarness("stale");
|
||||
// A second image that never advances past epoch 1: its delete set pins
|
||||
// that the reset sweep uses per-image history, not a global maximum.
|
||||
const calm = new Image(
|
||||
BASE64_ONE_PIXEL_PNG,
|
||||
"image/png",
|
||||
{ fallbackColor: t => t },
|
||||
{ maxWidthCells: 4, maxHeightCells: 6, budget: h.tui.imageBudget, imageKey: "calm" },
|
||||
{ widthPx: 40, heightPx: 60 },
|
||||
);
|
||||
const calmId = h.tui.imageBudget.acquireId("calm");
|
||||
h.tui.addChild(calm);
|
||||
try {
|
||||
h.tui.start();
|
||||
h.pump();
|
||||
// 4 lines: frame = header(1) + image(6) + stream(4) + calm(6) = 17
|
||||
// rows against a 12-row viewport, so the first image straddles the
|
||||
// window top (visible rows 5..6) while calm stays fully in-window.
|
||||
h.streamLines(4);
|
||||
// Drive the first image to epoch 2: an overlay frame re-emits its
|
||||
// straddling placement after commits passed the origin. Calm's rows
|
||||
// never commit, so its re-emits keep replacing placement 1.
|
||||
const overlay = h.tui.showOverlay(new Text("OVERLAY", 0, 0), { anchor: "top-left", width: "100%" });
|
||||
h.pump();
|
||||
overlay.hide();
|
||||
h.pump();
|
||||
|
||||
// Destructive replay: ED3 wipes every placement cell, so the replay
|
||||
// must delete each image's stale registry entries (d=i keeps the
|
||||
// data) and re-place under epoch 1 instead of stranding one entry
|
||||
// per reset (Codex review on #8057).
|
||||
h.writes.length = 0;
|
||||
h.tui.resetDisplay();
|
||||
h.pump();
|
||||
|
||||
const output = h.output();
|
||||
expect(output).toContain("\x1b[3J");
|
||||
// Exactly [1, 2]: a p=3 delete would betray epoch churn upstream —
|
||||
// and epoch 1 is included, so an image absent from the replay
|
||||
// leaves nothing behind.
|
||||
expect(captureDeletes(output, h.imageId)).toEqual([1, 2]);
|
||||
// The never-advanced image deletes exactly its epoch-1 entry.
|
||||
expect(captureDeletes(output, calmId)).toEqual([1]);
|
||||
for (const id of [h.imageId, calmId]) {
|
||||
const replay = capturePlacements(output, id);
|
||||
expect(replay.length).toBeGreaterThan(0);
|
||||
expect(replay[replay.length - 1]!.placementId).toBe(1);
|
||||
}
|
||||
} finally {
|
||||
h.tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user