Merge PR #8070: fix(tui): clip direct Kitty placements when image blocks straddle the viewport top (@voonfoo)

This commit is contained in:
can1357
2026-08-11 15:06:13 +02:00
5 changed files with 796 additions and 19 deletions
+4
View File
@@ -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
+159 -3
View File
@@ -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);
+84
View File
@@ -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
View File
@@ -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);
+452
View File
@@ -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();
}
});
});