fix(tui): preserve WezTerm images while scrolling

closes #10319
This commit is contained in:
Armin Ronacher 2026-10-02 18:44:01 +02:00
parent 1387af7b42
commit 672000c80c
5 changed files with 124 additions and 22 deletions

View file

@ -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

View file

@ -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)}`,
};

View file

@ -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) {

View file

@ -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"));

View file

@ -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 {