From 1c243365606f6629e21f4a99e03b63f9de422921 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 12 Jun 2026 12:59:32 +0200 Subject: [PATCH] fix(tui): keep WezTerm Kitty images visible Closes #5618. --- packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/tui.ts | 103 ++++++++++++++++++++++----- packages/tui/test/tui-render.test.ts | 89 ++++++++++++++++++++++- 3 files changed, 178 insertions(+), 18 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index ef8a1ba1..da5cd46c 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 9f6dda4b..2399564d 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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"); diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index 230a0005..4e31e8f4 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -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);