fix(tui): keep kitty image redraws inside TUI

Fixes #4208.
This commit is contained in:
Armin Ronacher
2026-05-07 10:34:37 +02:00
parent 50993d743d
commit b8712457d2
5 changed files with 273 additions and 19 deletions

View File

@@ -1,4 +1,5 @@
import {
allocateImageId,
getCapabilities,
getImageDimensions,
type ImageDimensions,
@@ -26,6 +27,7 @@ export class Image implements Component {
private theme: ImageTheme;
private options: ImageOptions;
private imageId?: number;
private readonly explicitImageId?: number;
private cachedLines?: string[];
private cachedWidth?: number;
@@ -42,12 +44,13 @@ export class Image implements Component {
this.theme = theme;
this.options = options;
this.dimensions = dimensions || getImageDimensions(base64Data, mimeType) || { widthPx: 800, heightPx: 600 };
this.explicitImageId = options.imageId;
this.imageId = options.imageId;
}
/** Get the Kitty image ID used by this image (if any). */
/** Get the explicit Kitty image ID configured for this image (if any). */
getImageId(): number | undefined {
return this.imageId;
return this.explicitImageId;
}
invalidate(): void {
@@ -66,9 +69,13 @@ export class Image implements Component {
let lines: string[];
if (caps.images) {
if (caps.images === "kitty" && this.imageId === undefined) {
this.imageId = allocateImageId();
}
const result = renderImage(this.base64Data, this.dimensions, {
maxWidthCells: maxWidth,
imageId: this.imageId,
moveCursor: false,
});
if (result) {
@@ -77,16 +84,19 @@ export class Image implements Component {
this.imageId = result.imageId;
}
// Return `rows` lines so TUI accounts for image height
// First (rows-1) lines are empty (TUI clears them)
// Last line: move cursor back up, then output image sequence
// Return `rows` lines so TUI accounts for image height.
// First (rows-1) lines are empty and cleared before the image is drawn.
// Last line: move cursor back up, draw the image, then move back down
// for Kitty (this component disables Kitty's terminal-side cursor movement)
// so TUI cursor accounting stays inside the scroll area.
lines = [];
for (let i = 0; i < result.rows - 1; i++) {
lines.push("");
}
// Move cursor up to first row, then output image
const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : "";
lines.push(moveUp + result.sequence);
const rowOffset = result.rows - 1;
const moveUp = rowOffset > 0 ? `\x1b[${rowOffset}A` : "";
const moveDown = caps.images === "kitty" && rowOffset > 0 ? `\x1b[${rowOffset}B` : "";
lines.push(moveUp + result.sequence + moveDown);
} else {
const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename);
lines = [this.theme.fallbackColor(fallback)];

View File

@@ -22,6 +22,8 @@ export interface ImageRenderOptions {
preserveAspectRatio?: boolean;
/** Kitty image ID. If provided, reuses/replaces existing image with this ID. */
imageId?: number;
/** Whether Kitty should apply its default cursor movement after placement. */
moveCursor?: boolean;
}
let cachedCapabilities: TerminalCapabilities | null = null;
@@ -46,10 +48,7 @@ export function detectCapabilities(): TerminalCapabilities {
// sequences differently). Force hyperlinks off whenever we detect them, even
// when the outer terminal would otherwise support OSC 8. Image protocols are
// also unreliable under tmux/screen, so leave `images: null` for safety.
// cmux currently also gets this conservative fallback due to image corruption:
// https://github.com/badlogic/pi-mono/issues/4208
const inTmuxOrScreen =
!!process.env.TMUX || !!process.env.CMUX_WORKSPACE_ID || term.startsWith("tmux") || term.startsWith("screen");
const inTmuxOrScreen = !!process.env.TMUX || term.startsWith("tmux") || term.startsWith("screen");
if (inTmuxOrScreen) {
const trueColor = colorTerm === "truecolor" || colorTerm === "24bit";
return { images: null, trueColor, hyperlinks: false };
@@ -131,12 +130,15 @@ export function encodeKitty(
columns?: number;
rows?: number;
imageId?: number;
/** Whether Kitty should apply its default cursor movement after placement. Default: true. */
moveCursor?: boolean;
} = {},
): string {
const CHUNK_SIZE = 4096;
const params: string[] = ["a=T", "f=100", "q=2"];
if (options.moveCursor === false) params.push("C=1");
if (options.columns) params.push(`c=${options.columns}`);
if (options.rows) params.push(`r=${options.rows}`);
if (options.imageId) params.push(`i=${options.imageId}`);
@@ -173,7 +175,7 @@ export function encodeKitty(
* Uses uppercase 'I' to also free the image data.
*/
export function deleteKittyImage(imageId: number): string {
return `\x1b_Ga=d,d=I,i=${imageId}\x1b\\`;
return `\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`;
}
/**
@@ -181,7 +183,7 @@ export function deleteKittyImage(imageId: number): string {
* Uses uppercase 'A' to also free the image data.
*/
export function deleteAllKittyImages(): string {
return `\x1b_Ga=d,d=A\x1b\\`;
return "\x1b_Ga=d,d=A,q=2\x1b\\";
}
export function encodeITerm2(
@@ -377,8 +379,12 @@ export function renderImage(
const rows = calculateImageRows(imageDimensions, maxWidth, getCellDimensions());
if (caps.images === "kitty") {
// Only use imageId if explicitly provided - static images don't need IDs
const sequence = encodeKitty(base64Data, { columns: maxWidth, rows, imageId: options.imageId });
const sequence = encodeKitty(base64Data, {
columns: maxWidth,
rows,
imageId: options.imageId,
moveCursor: options.moveCursor,
});
return { sequence, rows, imageId: options.imageId };
}

View File

@@ -8,9 +8,26 @@ import * as path from "node:path";
import { performance } from "node:perf_hooks";
import { isKeyRelease, matchesKey } from "./keys.js";
import type { Terminal } from "./terminal.js";
import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
const KITTY_SEQUENCE_PATTERN = /\x1b_G([^;\x1b]*);/g;
function extractKittyImageIds(line: string): number[] {
const ids: number[] = [];
for (const match of line.matchAll(KITTY_SEQUENCE_PATTERN)) {
for (const param of match[1].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 && !ids.includes(id)) {
ids.push(id);
}
}
}
return ids;
}
/**
* Component interface - all components must implement this
*/
@@ -217,6 +234,7 @@ export class Container implements Component {
export class TUI extends Container {
public terminal: Terminal;
private previousLines: string[] = [];
private previousKittyImageIds = new Set<number>();
private previousWidth = 0;
private previousHeight = 0;
private focusedComponent: Component | null = null;
@@ -806,6 +824,48 @@ export class TUI extends Container {
return lines;
}
private collectKittyImageIds(lines: string[]): Set<number> {
const ids = new Set<number>();
for (const line of lines) {
for (const id of extractKittyImageIds(line)) {
ids.add(id);
}
}
return ids;
}
private deleteKittyImages(ids: Iterable<number>): string {
let buffer = "";
for (const id of ids) {
buffer += deleteKittyImage(id);
}
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);
}
}
return expandedLastChanged;
}
private deleteChangedKittyImages(firstChanged: number, lastChanged: number): string {
if (firstChanged < 0 || lastChanged < firstChanged) return "";
const ids = new Set<number>();
const maxLine = Math.min(lastChanged, this.previousLines.length - 1);
for (let i = firstChanged; i <= maxLine; i++) {
for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) {
ids.add(id);
}
}
return this.deleteKittyImages(ids);
}
/** Splice overlay content into a base line at a specific column. Single-pass optimized. */
private compositeLineAt(
baseLine: string,
@@ -918,7 +978,10 @@ export class TUI extends Container {
const fullRender = (clear: boolean): void => {
this.fullRedrawCount += 1;
let buffer = "\x1b[?2026h"; // Begin synchronized output
if (clear) buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
if (clear) {
buffer += this.deleteKittyImages(this.previousKittyImageIds);
buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
}
for (let i = 0; i < newLines.length; i++) {
if (i > 0) buffer += "\r\n";
buffer += newLines[i];
@@ -937,6 +1000,7 @@ export class TUI extends Container {
this.previousViewportTop = Math.max(0, bufferLength - height);
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
this.previousWidth = width;
this.previousHeight = height;
};
@@ -1003,6 +1067,9 @@ export class TUI extends Container {
}
lastChanged = newLines.length - 1;
}
if (firstChanged !== -1) {
lastChanged = this.expandLastChangedForKittyImages(firstChanged, lastChanged);
}
const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;
// No changes - but still need to update hardware cursor position if it moved
@@ -1017,6 +1084,7 @@ export class TUI extends Container {
if (firstChanged >= newLines.length) {
if (this.previousLines.length > newLines.length) {
let buffer = "\x1b[?2026h";
buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
// Move to end of new content (clamp to 0 for empty content)
const targetRow = Math.max(0, newLines.length - 1);
if (targetRow < prevViewportTop) {
@@ -1052,6 +1120,7 @@ export class TUI extends Container {
}
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
this.previousWidth = width;
this.previousHeight = height;
this.previousViewportTop = prevViewportTop;
@@ -1069,6 +1138,7 @@ export class TUI extends Container {
// Render from first changed line to end
// Build buffer with all updates wrapped in synchronized output
let buffer = "\x1b[?2026h"; // Begin synchronized output
buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
const prevViewportBottom = prevViewportTop + height - 1;
const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
if (moveTargetRow > prevViewportBottom) {
@@ -1199,6 +1269,7 @@ export class TUI extends Container {
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
this.previousWidth = width;
this.previousHeight = height;
}