@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed WezTerm inline Kitty image rendering so reserved row clears do not erase all but the top strip of tool image previews ([#5618](https://github.com/earendil-works/pi/issues/5618)).
|
||||
|
||||
## [0.79.1] - 2026-06-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -13,24 +13,42 @@ import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth
|
||||
|
||||
const KITTY_SEQUENCE_PREFIX = "\x1b_G";
|
||||
|
||||
function extractKittyImageIds(line: string): number[] {
|
||||
interface KittyImageHeader {
|
||||
ids: number[];
|
||||
rows: number;
|
||||
}
|
||||
|
||||
function parseKittyImageHeader(line: string): KittyImageHeader | undefined {
|
||||
const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);
|
||||
if (sequenceStart === -1) return [];
|
||||
if (sequenceStart === -1) return undefined;
|
||||
|
||||
const paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length;
|
||||
const paramsEnd = line.indexOf(";", paramsStart);
|
||||
if (paramsEnd === -1) return [];
|
||||
if (paramsEnd === -1) return undefined;
|
||||
|
||||
const ids: number[] = [];
|
||||
let rows = 1;
|
||||
const params = line.slice(paramsStart, paramsEnd);
|
||||
for (const param of params.split(",")) {
|
||||
const [key, value] = param.split("=", 2);
|
||||
if (key !== "i" || value === undefined) continue;
|
||||
const id = Number(value);
|
||||
if (Number.isInteger(id) && id > 0 && id <= 0xffffffff) {
|
||||
return [id];
|
||||
if (value === undefined) continue;
|
||||
const numberValue = Number(value);
|
||||
if (!Number.isInteger(numberValue) || numberValue <= 0 || numberValue > 0xffffffff) continue;
|
||||
if (key === "i") {
|
||||
ids.push(numberValue);
|
||||
} else if (key === "r") {
|
||||
rows = numberValue;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
return { ids, rows };
|
||||
}
|
||||
|
||||
function extractKittyImageIds(line: string): number[] {
|
||||
return parseKittyImageHeader(line)?.ids ?? [];
|
||||
}
|
||||
|
||||
function extractKittyImageRows(line: string): number {
|
||||
return parseKittyImageHeader(line)?.rows ?? 1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1021,14 +1039,41 @@ export class TUI extends Container {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private expandLastChangedForKittyImages(firstChanged: number, lastChanged: number): number {
|
||||
let expandedLastChanged = lastChanged;
|
||||
for (let i = firstChanged; i < this.previousLines.length; i++) {
|
||||
if (extractKittyImageIds(this.previousLines[i]).length > 0) {
|
||||
expandedLastChanged = Math.max(expandedLastChanged, i);
|
||||
}
|
||||
private getKittyImageReservedRows(lines: string[], index: number, maxIndex = lines.length - 1): number {
|
||||
const rows = extractKittyImageRows(lines[index] ?? "");
|
||||
if (rows <= 1) return 1;
|
||||
|
||||
const maxRows = Math.min(rows, maxIndex - index + 1, lines.length - index);
|
||||
let reservedRows = 1;
|
||||
while (reservedRows < maxRows) {
|
||||
const line = lines[index + reservedRows] ?? "";
|
||||
if (isImageLine(line) || visibleWidth(line) > 0) break;
|
||||
reservedRows++;
|
||||
}
|
||||
return expandedLastChanged;
|
||||
return reservedRows;
|
||||
}
|
||||
|
||||
private expandChangedRangeForKittyImages(
|
||||
firstChanged: number,
|
||||
lastChanged: number,
|
||||
newLines: string[],
|
||||
): { firstChanged: number; lastChanged: number } {
|
||||
let expandedFirstChanged = firstChanged;
|
||||
let expandedLastChanged = lastChanged;
|
||||
const expandForLines = (lines: string[]): void => {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (extractKittyImageIds(lines[i]).length === 0) continue;
|
||||
const blockEnd = i + this.getKittyImageReservedRows(lines, i) - 1;
|
||||
if (i >= firstChanged || (i <= lastChanged && blockEnd >= firstChanged)) {
|
||||
expandedFirstChanged = Math.min(expandedFirstChanged, i);
|
||||
expandedLastChanged = Math.max(expandedLastChanged, blockEnd);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
expandForLines(this.previousLines);
|
||||
expandForLines(newLines);
|
||||
return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged };
|
||||
}
|
||||
|
||||
private deleteChangedKittyImages(firstChanged: number, lastChanged: number): string {
|
||||
@@ -1247,7 +1292,9 @@ export class TUI extends Container {
|
||||
lastChanged = newLines.length - 1;
|
||||
}
|
||||
if (firstChanged !== -1) {
|
||||
lastChanged = this.expandLastChangedForKittyImages(firstChanged, lastChanged);
|
||||
const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines);
|
||||
firstChanged = expandedRange.firstChanged;
|
||||
lastChanged = expandedRange.lastChanged;
|
||||
}
|
||||
const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;
|
||||
|
||||
@@ -1350,9 +1397,31 @@ export class TUI extends Container {
|
||||
const renderEnd = Math.min(lastChanged, newLines.length - 1);
|
||||
for (let i = firstChanged; i <= renderEnd; i++) {
|
||||
if (i > firstChanged) buffer += "\r\n";
|
||||
buffer += "\x1b[2K"; // Clear current line
|
||||
const line = newLines[i];
|
||||
const isImage = isImageLine(line);
|
||||
const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i, renderEnd) : 1;
|
||||
if (imageReservedRows > 1) {
|
||||
const imageStartScreenRow = i - viewportTop;
|
||||
if (imageStartScreenRow < 0 || imageStartScreenRow + imageReservedRows > height) {
|
||||
logRedraw(
|
||||
`kitty image pre-clear would scroll (${imageStartScreenRow} + ${imageReservedRows} > ${height})`,
|
||||
);
|
||||
fullRender(true);
|
||||
return;
|
||||
}
|
||||
|
||||
buffer += "\x1b[2K";
|
||||
for (let row = 1; row < imageReservedRows; row++) {
|
||||
buffer += "\r\n\x1b[2K";
|
||||
}
|
||||
buffer += `\x1b[${imageReservedRows - 1}A`;
|
||||
buffer += line;
|
||||
buffer += `\x1b[${imageReservedRows - 1}B`;
|
||||
i += imageReservedRows - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer += "\x1b[2K"; // Clear current line
|
||||
if (!isImage && visibleWidth(line) > width) {
|
||||
// Log all lines to crash file for debugging
|
||||
const crashLogPath = path.join(os.homedir(), ".pi", "agent", "pi-crash.log");
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import type { Terminal as XtermTerminalType } from "@xterm/headless";
|
||||
import { deleteKittyImage, encodeKitty } from "../src/terminal-image.ts";
|
||||
import { Image } from "../src/components/image.ts";
|
||||
import {
|
||||
deleteKittyImage,
|
||||
encodeKitty,
|
||||
resetCapabilitiesCache,
|
||||
setCapabilities,
|
||||
setCellDimensions,
|
||||
} from "../src/terminal-image.ts";
|
||||
import { type Component, TUI } from "../src/tui.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
@@ -65,6 +72,86 @@ function getCellItalic(terminal: VirtualTerminal, row: number, col: number): num
|
||||
}
|
||||
|
||||
describe("TUI Kitty image cleanup", () => {
|
||||
it("clears reserved Kitty image rows before drawing appended image placements", async () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
try {
|
||||
const terminal = new LoggingVirtualTerminal(40, 10);
|
||||
const tui = new TUI(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["before"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
terminal.clearWrites();
|
||||
|
||||
const image = new Image(
|
||||
"AAAA",
|
||||
"image/png",
|
||||
{ fallbackColor: (value) => value },
|
||||
{ maxWidthCells: 2 },
|
||||
{ widthPx: 20, heightPx: 20 },
|
||||
);
|
||||
const imageLines = image.render(40);
|
||||
const imageSequence = imageLines[0];
|
||||
component.lines = ["before", ...imageLines, "after"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const writes = terminal.getWrites();
|
||||
assert.ok(
|
||||
writes.includes(`\x1b[2K\r\n\x1b[2K\x1b[1A${imageSequence}\x1b[1B`),
|
||||
"reserved rows should be cleared before the image placement is drawn",
|
||||
);
|
||||
assert.ok(
|
||||
!writes.includes(`${imageSequence}\r\n\x1b[2K`),
|
||||
"reserved row clears must not run after the image placement is drawn",
|
||||
);
|
||||
|
||||
tui.stop();
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to full redraw when Kitty image pre-clear would scroll", async () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
try {
|
||||
const terminal = new LoggingVirtualTerminal(40, 2);
|
||||
const tui = new TUI(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["before"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
const redrawsBeforeImage = tui.fullRedraws;
|
||||
terminal.clearWrites();
|
||||
|
||||
const image = new Image(
|
||||
"AAAA",
|
||||
"image/png",
|
||||
{ fallbackColor: (value) => value },
|
||||
{ maxWidthCells: 3 },
|
||||
{ widthPx: 30, heightPx: 30 },
|
||||
);
|
||||
component.lines = ["before", ...image.render(40), "after"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.ok(tui.fullRedraws > redrawsBeforeImage, "unsafe image pre-clear should force a full redraw");
|
||||
assert.ok(terminal.getWrites().includes("\x1b[2J"), "fallback should clear and fully redraw");
|
||||
|
||||
tui.stop();
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes changed image ids before drawing moved placements", async () => {
|
||||
const terminal = new LoggingVirtualTerminal(40, 10);
|
||||
const tui = new TUI(terminal);
|
||||
|
||||
Reference in New Issue
Block a user