fix(tui): proper Kitty image ID management and cleanup

- Add allocateImageId() to generate unique image IDs
- Add deleteKittyImage() and deleteAllKittyImages() functions
- Image component now tracks its ID and has dispose() method
- renderImage() returns imageId for tracking
- DOSBox: reuse single image ID for all frames, delete on dispose

Fixes image accumulation hitting terminal quota and lingering
images after component close.
This commit is contained in:
Mario Zechner 2026-01-22 04:39:58 +01:00
parent 6515b1a3dd
commit df1d5c40ea
4 changed files with 81 additions and 6 deletions

View file

@ -8,7 +8,16 @@ import { createRequire } from "node:module";
import { dirname } from "node:path"; import { dirname } from "node:path";
import { deflateSync } from "node:zlib"; import { deflateSync } from "node:zlib";
import type { Component } from "@mariozechner/pi-tui"; import type { Component } from "@mariozechner/pi-tui";
import { Image, type ImageTheme, isKeyRelease, Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui"; import {
allocateImageId,
deleteKittyImage,
Image,
type ImageTheme,
isKeyRelease,
Key,
matchesKey,
truncateToWidth,
} from "@mariozechner/pi-tui";
import type { CommandInterface, Emulators } from "emulators"; import type { CommandInterface, Emulators } from "emulators";
const MAX_WIDTH_CELLS = 120; const MAX_WIDTH_CELLS = 120;
@ -47,6 +56,7 @@ export class DosboxComponent implements Component {
private disposed = false; private disposed = false;
private bundleData?: Uint8Array; private bundleData?: Uint8Array;
private kittyPushed = false; private kittyPushed = false;
private imageId: number;
wantsKeyRelease = true; wantsKeyRelease = true;
@ -60,6 +70,7 @@ export class DosboxComponent implements Component {
this.onClose = onClose; this.onClose = onClose;
this.bundleData = bundleData; this.bundleData = bundleData;
this.imageTheme = { fallbackColor }; this.imageTheme = { fallbackColor };
this.imageId = allocateImageId();
void this.init(); void this.init();
} }
@ -124,7 +135,7 @@ export class DosboxComponent implements Component {
base64, base64,
"image/png", "image/png",
this.imageTheme, this.imageTheme,
{ maxWidthCells: MAX_WIDTH_CELLS }, { maxWidthCells: MAX_WIDTH_CELLS, imageId: this.imageId },
{ widthPx: this.frameWidth, heightPx: this.frameHeight }, { widthPx: this.frameWidth, heightPx: this.frameHeight },
); );
this.version++; this.version++;
@ -186,6 +197,10 @@ export class DosboxComponent implements Component {
dispose(): void { dispose(): void {
if (this.disposed) return; if (this.disposed) return;
this.disposed = true; this.disposed = true;
// Delete the terminal image
process.stdout.write(deleteKittyImage(this.imageId));
if (this.kittyPushed) { if (this.kittyPushed) {
process.stdout.write("\x1b[<u"); process.stdout.write("\x1b[<u");
this.kittyPushed = false; this.kittyPushed = false;

View file

@ -1,4 +1,5 @@
import { import {
deleteKittyImage,
getCapabilities, getCapabilities,
getImageDimensions, getImageDimensions,
type ImageDimensions, type ImageDimensions,
@ -15,6 +16,8 @@ export interface ImageOptions {
maxWidthCells?: number; maxWidthCells?: number;
maxHeightCells?: number; maxHeightCells?: number;
filename?: string; filename?: string;
/** Kitty image ID. If provided, reuses this ID (for animations/updates). */
imageId?: number;
} }
export class Image implements Component { export class Image implements Component {
@ -23,6 +26,7 @@ export class Image implements Component {
private dimensions: ImageDimensions; private dimensions: ImageDimensions;
private theme: ImageTheme; private theme: ImageTheme;
private options: ImageOptions; private options: ImageOptions;
private imageId?: number;
private cachedLines?: string[]; private cachedLines?: string[];
private cachedWidth?: number; private cachedWidth?: number;
@ -39,6 +43,12 @@ export class Image implements Component {
this.theme = theme; this.theme = theme;
this.options = options; this.options = options;
this.dimensions = dimensions || getImageDimensions(base64Data, mimeType) || { widthPx: 800, heightPx: 600 }; this.dimensions = dimensions || getImageDimensions(base64Data, mimeType) || { widthPx: 800, heightPx: 600 };
this.imageId = options.imageId;
}
/** Get the Kitty image ID used by this image (if any). */
getImageId(): number | undefined {
return this.imageId;
} }
invalidate(): void { invalidate(): void {
@ -57,9 +67,17 @@ export class Image implements Component {
let lines: string[]; let lines: string[];
if (caps.images) { if (caps.images) {
const result = renderImage(this.base64Data, this.dimensions, { maxWidthCells: maxWidth }); const result = renderImage(this.base64Data, this.dimensions, {
maxWidthCells: maxWidth,
imageId: this.imageId,
});
if (result) { if (result) {
// Store the image ID for later cleanup
if (result.imageId) {
this.imageId = result.imageId;
}
// Return `rows` lines so TUI accounts for image height // Return `rows` lines so TUI accounts for image height
// First (rows-1) lines are empty (TUI clears them) // First (rows-1) lines are empty (TUI clears them)
// Last line: move cursor back up, then output image sequence // Last line: move cursor back up, then output image sequence
@ -84,4 +102,15 @@ export class Image implements Component {
return lines; return lines;
} }
/**
* Delete the terminal image. Call this when done with the image
* to free terminal resources.
*/
dispose(): void {
if (this.imageId !== undefined) {
process.stdout.write(deleteKittyImage(this.imageId));
this.imageId = undefined;
}
}
} }

View file

@ -51,8 +51,11 @@ export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "
export { ProcessTerminal, type Terminal } from "./terminal.js"; export { ProcessTerminal, type Terminal } from "./terminal.js";
// Terminal image support // Terminal image support
export { export {
allocateImageId,
type CellDimensions, type CellDimensions,
calculateImageRows, calculateImageRows,
deleteAllKittyImages,
deleteKittyImage,
detectCapabilities, detectCapabilities,
encodeITerm2, encodeITerm2,
encodeKitty, encodeKitty,

View file

@ -20,6 +20,8 @@ export interface ImageRenderOptions {
maxWidthCells?: number; maxWidthCells?: number;
maxHeightCells?: number; maxHeightCells?: number;
preserveAspectRatio?: boolean; preserveAspectRatio?: boolean;
/** Kitty image ID. If provided, reuses/replaces existing image with this ID. */
imageId?: number;
} }
let cachedCapabilities: TerminalCapabilities | null = null; let cachedCapabilities: TerminalCapabilities | null = null;
@ -79,6 +81,15 @@ export function resetCapabilitiesCache(): void {
cachedCapabilities = null; cachedCapabilities = null;
} }
// Counter for generating unique image IDs
let nextImageId = 1;
export function allocateImageId(): number {
const id = nextImageId;
nextImageId = (nextImageId % 0xffffffff) + 1; // Wrap around at max uint32
return id;
}
export function encodeKitty( export function encodeKitty(
base64Data: string, base64Data: string,
options: { options: {
@ -122,6 +133,22 @@ export function encodeKitty(
return chunks.join(""); return chunks.join("");
} }
/**
* Delete a Kitty graphics image by ID.
* Uses uppercase 'I' to also free the image data.
*/
export function deleteKittyImage(imageId: number): string {
return `\x1b_Ga=d,d=I,i=${imageId}\x1b\\`;
}
/**
* Delete all visible Kitty graphics images.
* Uses uppercase 'A' to also free the image data.
*/
export function deleteAllKittyImages(): string {
return `\x1b_Ga=d,d=A\x1b\\`;
}
export function encodeITerm2( export function encodeITerm2(
base64Data: string, base64Data: string,
options: { options: {
@ -304,7 +331,7 @@ export function renderImage(
base64Data: string, base64Data: string,
imageDimensions: ImageDimensions, imageDimensions: ImageDimensions,
options: ImageRenderOptions = {}, options: ImageRenderOptions = {},
): { sequence: string; rows: number } | null { ): { sequence: string; rows: number; imageId?: number } | null {
const caps = getCapabilities(); const caps = getCapabilities();
if (!caps.images) { if (!caps.images) {
@ -315,8 +342,9 @@ export function renderImage(
const rows = calculateImageRows(imageDimensions, maxWidth, getCellDimensions()); const rows = calculateImageRows(imageDimensions, maxWidth, getCellDimensions());
if (caps.images === "kitty") { if (caps.images === "kitty") {
const sequence = encodeKitty(base64Data, { columns: maxWidth, rows }); const imageId = options.imageId ?? allocateImageId();
return { sequence, rows }; const sequence = encodeKitty(base64Data, { columns: maxWidth, rows, imageId });
return { sequence, rows, imageId };
} }
if (caps.images === "iterm2") { if (caps.images === "iterm2") {