mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-18 22:04:46 +00:00
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:
parent
6515b1a3dd
commit
df1d5c40ea
4 changed files with 81 additions and 6 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue