diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index fa6ef5698..ed4c8aaa9 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed fullscreen Kitty images collapsing to a one-row strip after scrolling in WezTerm ([#10319](https://github.com/earendil-works/pi/issues/10319)). + ## [1.0.0] - 2026-10-01 ### Added diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index a84d22c57..e720c3665 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -332,6 +332,7 @@ export interface KittyImagePlacement { transmissionGeneration: number; transmissionBytes: number; estimatedDecodedBytes: number; + rows: number; sequence: string; replacementLine: string; } @@ -349,13 +350,27 @@ export function registerKittyImageMetadata(metadata: KittyImageMetadata): void { } } -function getRegisteredKittyImageMetadata(line: string): RegisteredKittyImageMetadata | undefined { - const controls = /\x1b_G([^;]*);/.exec(line)?.[1]; - if (!controls) return undefined; +function getRegisteredKittyImageMetadataFromControls(controls: string): RegisteredKittyImageMetadata | undefined { const imageId = /(?:^|,)i=(\d+)(?:,|$)/.exec(controls)?.[1]; return imageId === undefined ? undefined : kittyImageMetadata.get(Number.parseInt(imageId, 10)); } +function getRegisteredKittyImageMetadata(line: string): RegisteredKittyImageMetadata | undefined { + const controls = /\x1b_G([^;]*);/.exec(line)?.[1]; + return controls === undefined ? undefined : getRegisteredKittyImageMetadataFromControls(controls); +} + +function getExplicitKittyImageRows(controls: string): number | undefined { + const value = /(?:^|,)r=(\d+)(?:,|$)/.exec(controls)?.[1]; + if (value === undefined) return undefined; + const rows = Number.parseInt(value, 10); + return rows > 0 ? rows : undefined; +} + +function getKittyImageRowsFromControls(controls: string, fallbackRows: number): number { + return getExplicitKittyImageRows(controls) ?? fallbackRows; +} + export function getKittyImageMetadata(line: string): KittyImageMetadata | undefined { const metadata = getRegisteredKittyImageMetadata(line); if (!metadata) return undefined; @@ -388,11 +403,21 @@ const KITTY_PLACEMENT_CONTROL_KEYS = new Set([ "V", ]); +/** Read the number of rows covered by an image placement without scanning its payload. */ +export function getKittyImagePlacementRows(line: string): number | undefined { + const controls = /\x1b_G([^;]*);/.exec(line)?.[1]; + if (controls === undefined) return undefined; + const explicitRows = getExplicitKittyImageRows(controls); + if (explicitRows !== undefined) return explicitRows; + return getRegisteredKittyImageMetadataFromControls(controls)?.rows; +} + /** Build a placement-only command for an image line emitted by {@link renderImage}. */ export function getKittyImagePlacement(line: string): KittyImagePlacement | undefined { const match = /\x1b_G([^;]*);/.exec(line); - const metadata = getRegisteredKittyImageMetadata(line); - if (!match || !metadata) return undefined; + if (!match) return undefined; + const metadata = getRegisteredKittyImageMetadataFromControls(match[1]); + if (!metadata) return undefined; let commandStart = match.index; let commandControls = match[1]; @@ -418,6 +443,7 @@ export function getKittyImagePlacement(line: string): KittyImagePlacement | unde transmissionGeneration: metadata.transmissionGeneration, transmissionBytes: transmissionEnd - match.index, estimatedDecodedBytes: metadata.widthPx * metadata.heightPx * 4, + rows: getKittyImageRowsFromControls(match[1], metadata.rows), sequence, replacementLine: `${line.slice(0, match.index)}${sequence}${line.slice(transmissionEnd)}`, }; diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 8e534a359..c0631f12f 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -25,6 +25,7 @@ import { deleteKittyImage, getCapabilities, getKittyImagePlacement, + getKittyImagePlacementRows, type ImageProtocol, isImageLine, setCapabilities, @@ -1693,10 +1694,25 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { const fullRedraw = this.previousScreen.length === 0 || this.previousScreenWidth !== width || this.previousScreenHeight !== height; - const imagesNeedRedraw = screen.some( - (line, row) => - line !== this.previousScreen[row] && (isImageLine(line) || isImageLine(this.previousScreen[row] ?? "")), + const changedRows = screen.map((line, row) => line !== this.previousScreen[row]); + const imageAnchorsNeedRedraw = screen.some( + (line, row) => changedRows[row] && (isImageLine(line) || isImageLine(this.previousScreen[row] ?? "")), ); + const isWezTerm = Boolean(process.env.WEZTERM_PANE) || process.env.TERM_PROGRAM?.toLowerCase() === "wezterm"; + const imageCellsNeedRedraw = + !imageAnchorsNeedRedraw && + isWezTerm && + this.imageProtocol === "kitty" && + changedRows.some(Boolean) && + screen.some((line, row) => { + const placementRows = getKittyImagePlacementRows(line); + if (placementRows === undefined) return false; + for (let coveredRow = row; coveredRow < row + placementRows; coveredRow++) { + if (changedRows[coveredRow]) return true; + } + return false; + }); + const imagesNeedRedraw = imageAnchorsNeedRedraw || imageCellsNeedRedraw; const redrawImages = fullRedraw || imagesNeedRedraw; const hadUploadedKittyImages = this.uploadedKittyImages.size > 0; const preparedKittyScreen = @@ -1718,24 +1734,31 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } buffer += preparedKittyScreen.evictedImageDeletion; - // WezTerm erases intersecting Kitty image cells when a later EL clears a covered row. - // Only separate clearing from drawing for WezTerm frames that place images; preserve the - // existing interleaved output for text-only frames and every other terminal. - const clearRowsBeforeKittyImages = - redrawImages && - this.imageProtocol === "kitty" && - screen.some(isImageLine) && - (Boolean(process.env.WEZTERM_PANE) || process.env.TERM_PROGRAM?.toLowerCase() === "wezterm"); - if (clearRowsBeforeKittyImages) { + // WezTerm erases intersecting Kitty image cells when a later row write touches a covered row. + // Draw image placements after every clear and text write so nothing later intersects them; preserve + // the existing interleaved output for text-only frames and every other terminal. + const drawKittyImagesLast = + redrawImages && this.imageProtocol === "kitty" && screen.some(isImageLine) && isWezTerm; + if (drawKittyImagesLast) { for (let row = 0; row < height; row++) { if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue; buffer += `\x1b[${row + 1};1H\x1b[2K`; } - } - - for (let row = 0; row < height; row++) { - if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue; - buffer += `\x1b[${row + 1};1H${clearRowsBeforeKittyImages ? "" : "\x1b[2K"}${preparedKittyScreen.lines[row] ?? ""}`; + for (let row = 0; row < height; row++) { + if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue; + if (isImageLine(preparedKittyScreen.lines[row] ?? "")) continue; + buffer += `\x1b[${row + 1};1H${preparedKittyScreen.lines[row] ?? ""}`; + } + for (let row = 0; row < height; row++) { + if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue; + if (!isImageLine(preparedKittyScreen.lines[row] ?? "")) continue; + buffer += `\x1b[${row + 1};1H${preparedKittyScreen.lines[row] ?? ""}`; + } + } else { + for (let row = 0; row < height; row++) { + if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue; + buffer += `\x1b[${row + 1};1H\x1b[2K${preparedKittyScreen.lines[row] ?? ""}`; + } } if (cursorPos) { diff --git a/packages/tui/test/terminal-image.test.ts b/packages/tui/test/terminal-image.test.ts index f2cf7b601..940bb70a0 100644 --- a/packages/tui/test/terminal-image.test.ts +++ b/packages/tui/test/terminal-image.test.ts @@ -18,6 +18,7 @@ import { getCapabilities, getKittyImageMetadata, getKittyImagePlacement, + getKittyImagePlacementRows, hyperlink, imageFallback, isImageLine, @@ -463,6 +464,11 @@ describe("Kitty image cursor movement", () => { assert.ok(sequence.startsWith("\x1b_Ga=T,f=100,q=2,C=1,c=2,r=2;")); }); + it("reads explicit placement rows without registered metadata", () => { + const sequence = encodeKitty("AAAA", { columns: 2, rows: 3, moveCursor: false }); + assert.strictEqual(getKittyImagePlacementRows(sequence), 3); + }); + it("suppresses Kitty replies for delete commands", () => { assert.strictEqual(deleteKittyImage(42), "\x1b_Ga=d,d=I,i=42,q=2\x1b\\"); assert.strictEqual(deleteAllKittyImages(), "\x1b_Ga=d,d=A,q=2\x1b\\"); @@ -532,8 +538,10 @@ describe("Kitty image cursor movement", () => { const line = `left ${cropKittyImageLine(transmission, 2, 1)} right`; const placement = getKittyImagePlacement(line); assert.ok(placement); + assert.strictEqual(getKittyImagePlacementRows(line), 1); assert.strictEqual(placement.transmissionBytes, line.length - "left ".length - " right".length); assert.strictEqual(placement.estimatedDecodedBytes, 100 * 100 * 4); + assert.strictEqual(placement.rows, 1); assert.strictEqual(placement.sequence, "\x1b_Ga=p,q=2,C=1,c=3,i=42,y=66,h=34,r=1\x1b\\"); assert.strictEqual(placement.replacementLine, `left ${placement.sequence} right`); assert.ok(!placement.replacementLine.includes("AAAA")); diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 5c927535c..2a2ea5490 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -1062,6 +1062,47 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("redraws WezTerm Kitty images after writes to covered rows", async () => { + // Regression test for #10319: a scrollbar update below an unchanged image anchor erased its cells. + const weztermPane = process.env.WEZTERM_PANE; + let tui: TuiAltScreen | undefined; + process.env.WEZTERM_PANE = "1"; + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + try { + const terminal = new RecordingTerminal(20, 4); + const imageId = 10319; + const imageLine = encodeKitty("AAAA", { columns: 2, rows: 3, imageId, moveCursor: false }); + registerKittyImageMetadata({ imageId, columns: 2, rows: 3, widthPx: 100, heightPx: 100 }); + let coveredLine = ""; + tui = new TuiAltScreen(terminal); + tui.setLayoutRoot({ + render: () => [imageLine, coveredLine, "", "after"], + invalidate: () => {}, + }); + tui.start(); + await terminal.waitForRender(); + const eventCount = terminal.events.length; + + coveredLine = "changed"; + tui.requestRender(); + await terminal.waitForRender(); + const redrawWrites = terminal.events + .slice(eventCount) + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + const placementIndex = redrawWrites.indexOf("\x1b_Ga=p,q=2"); + assert.ok(redrawWrites.includes("\x1b_Ga=d,d=a,q=2\x1b\\")); + assert.ok(placementIndex > redrawWrites.indexOf("changed")); + assert.ok(!redrawWrites.includes("\x1b_Ga=T")); + } finally { + tui?.stop(); + resetCapabilitiesCache(); + if (weztermPane === undefined) delete process.env.WEZTERM_PANE; + else process.env.WEZTERM_PANE = weztermPane; + } + }); + it("reuses moved Kitty images without dropping HStack siblings", async () => { setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); try {