From f92957bc6f5ea67b57af20bc28f1ead4110bbb2d Mon Sep 17 00:00:00 2001 From: Voon Foo Date: Sun, 9 Aug 2026 19:12:24 +0800 Subject: [PATCH] fix(tui): clip direct Kitty placements when image blocks straddle the viewport top - The Image line's CUU(rows-1) clamps at the viewport top once the block's leading rows scroll out, re-anchoring the full image shifted down; Kitty replace-by-id then strips the prior placement's cells, scrollback included - permanently cropped inline images on WezTerm/Warp. - The renderer now rewrites placement lines at write time, clipping the source rectangle (y=/h=) to the visible slice, and advances the placement id (epoch) exactly when cells attached by the current epoch enter native scrollback: every rendered frame feeds its raw commit target to the budget, crossing an epoch's attach top latches a per-image archived flag, and the next emit consumes it. Era-local comparisons keep ledger rewinds (duplication-never- loss recommits) correct by construction; no-commit rewrites (overlay show/hide) keep replacing the same id. The watch set bounds the per-frame scan to live un-archived placements regardless of session length. - Destructive ED3 replays restart epochs and delete every registry entry each image placed (placement-scoped d=i, data retained) before re-placing under epoch 1, so repeated resets cannot strand terminal-side placements. - Verified against WezTerm 20240203 cell-attachment debug logs: straddling frames attach exactly the visible slice, the finalize commit writes a full archive copy under a fresh id, and resetDisplay deletes exactly the ids the image ever placed. --- packages/tui/CHANGELOG.md | 4 + packages/tui/src/components/image.ts | 162 +++++++- packages/tui/src/terminal-capabilities.ts | 84 ++++ packages/tui/src/tui.ts | 113 +++++- packages/tui/test/image-clip.test.ts | 452 ++++++++++++++++++++++ 5 files changed, 796 insertions(+), 19 deletions(-) create mode 100644 packages/tui/test/image-clip.test.ts diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 1e528cd0e..3bd9cd762 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/components/image.ts b/packages/tui/src/components/image.ts index 958062ce4..f2617ac56 100644 --- a/packages/tui/src/components/image.ts +++ b/packages/tui/src/components/image.ts @@ -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(); + /** + * 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(); + /** + * 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(); 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); diff --git a/packages/tui/src/terminal-capabilities.ts b/packages/tui/src/terminal-capabilities.ts index 561bf2ce3..780f062bc 100644 --- a/packages/tui/src/terminal-capabilities.ts +++ b/packages/tui/src/terminal-capabilities.ts @@ -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: { diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 22c4cd8e2..0f5540338 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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); diff --git a/packages/tui/test/image-clip.test.ts b/packages/tui/test/image-clip.test.ts new file mode 100644 index 000000000..cd936bcaf --- /dev/null +++ b/packages/tui/test/image-clip.test.ts @@ -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(); + } + }); +});