Start integrating KittyGraphics stuff into imageAddon as suggested

This commit is contained in:
Anthony Kim
2026-01-29 00:53:10 -08:00
parent 492d950b64
commit cb8159c4f2
31 changed files with 1034 additions and 1765 deletions
-5
View File
@@ -41,9 +41,6 @@ jobs:
./addons/addon-image/lib/* \
./addons/addon-image/out/* \
./addons/addon-image/out-*/* \
./addons/addon-kitty-graphics/lib/* \
./addons/addon-kitty-graphics/out/* \
./addons/addon-kitty-graphics/out-*/* \
./addons/addon-ligatures/lib/* \
./addons/addon-ligatures/out/* \
./addons/addon-ligatures/out-*/* \
@@ -219,8 +216,6 @@ jobs:
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-fit
- name: Integration tests (addon-image)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-image
- name: Integration tests (addon-kitty-graphics)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-kitty-graphics
- name: Integration tests (addon-progress)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-progress
- name: Integration tests (addon-search)

Before

Width:  |  Height:  |  Size: 123 B

After

Width:  |  Height:  |  Size: 123 B

Before

Width:  |  Height:  |  Size: 131 B

After

Width:  |  Height:  |  Size: 131 B

+13 -1
View File
@@ -8,6 +8,7 @@ import type { ImageAddon as IImageApi } from '@xterm/addon-image';
import { IIPHandler } from './IIPHandler';
import { ImageRenderer } from './ImageRenderer';
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
import { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';
import { SixelHandler } from './SixelHandler';
import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';
@@ -22,7 +23,9 @@ const DEFAULT_OPTIONS: IImageAddonOptions = {
storageLimit: 128,
showPlaceholder: true,
iipSupport: true,
iipSizeLimit: 20000000
iipSizeLimit: 20000000,
kittySupport: true,
kittySizeLimit: 20000000
};
// max palette size supported by the sixel lib (compile time setting)
@@ -144,6 +147,15 @@ export class ImageAddon implements ITerminalAddon , IImageApi {
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
);
}
// Kitty graphics handler
if (this._opts.kittySupport) {
const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, this._storage!, terminal);
this._handlers.set('kitty', kittyHandler);
this._disposeLater(
terminal._core._inputHandler._parser.registerApcHandler(0x47, kittyHandler)
);
}
}
// Note: storageLimit is skipped here to not intoduce a surprising side effect.
+4 -2
View File
@@ -8,7 +8,7 @@ import { IDisposable, IMarker, Terminal } from '@xterm/xterm';
// private imports from base repo we build against
import { Attributes, BgFlags, Content, ExtFlags, UnderlineStyle } from 'common/buffer/Constants';
import type { AttributeData } from 'common/buffer/AttributeData';
import type { IParams, IDcsHandler, IOscHandler, IEscapeSequenceParser } from 'common/parser/Types';
import type { IParams, IDcsHandler, IOscHandler, IApcHandler, IEscapeSequenceParser } from 'common/parser/Types';
import type { IBufferLine, IExtendedAttrs, IInputHandler } from 'common/Types';
import type { ITerminal, ReadonlyColorSet } from 'browser/Types';
import type { IRenderDimensions } from 'browser/renderer/shared/Types';
@@ -22,7 +22,7 @@ export const enum Cell {
}
// export some privates for local usage
export { AttributeData, IParams, IDcsHandler, IOscHandler, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet };
export { AttributeData, IParams, IDcsHandler, IOscHandler, IApcHandler, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet };
/**
* Plugin ctor options.
@@ -38,6 +38,8 @@ export interface IImageAddonOptions {
sixelSizeLimit: number;
iipSupport: boolean;
iipSizeLimit: number;
kittySupport: boolean;
kittySizeLimit: number;
}
export interface IResetHandler {
@@ -0,0 +1,494 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
*
* Kitty graphics protocol APC handler.
* Implements IApcHandler to integrate with xterm.js parser.
*/
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt } from '../Types';
import { ImageRenderer } from '../ImageRenderer';
import { ImageStorage, CELL_SIZE_DEFAULT } from '../ImageStorage';
import {
KittyAction,
KittyFormat,
KittyCompression,
IKittyCommand,
IPendingTransmission,
IKittyImageData,
BYTES_PER_PIXEL_RGB,
BYTES_PER_PIXEL_RGBA,
ALPHA_OPAQUE,
parseKittyCommand
} from './KittyGraphicsTypes';
/**
* Kitty graphics protocol handler.
*
* Handles APC sequences for the Kitty graphics protocol, supporting:
* - Image transmission (a=t)
* - Image transmission + display (a=T)
* - Query (a=q)
* - Delete (a=d)
*
* TODO: File transmission (t=f) is not supported since browsers cannot access the filesystem.
* Explore using the File System Access API for opt-in filesystem access when available.
*/
export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
private _aborted = false;
private _data: string = '';
/**
* Pending chunked transmissions keyed by image ID.
* ID 0 is used for transmissions without an explicit ID (the "anonymous" transmission).
*/
private _pendingTransmissions: Map<number, IPendingTransmission> = new Map();
private _nextImageId = 1;
/**
* Stored images - kept for protocol compliance (transmit without display, then display later).
*/
private _images: Map<number, IKittyImageData> = new Map();
/**
* Cached decoded bitmaps.
*/
private _decodedImages: Map<number, ImageBitmap> = new Map();
constructor(
private readonly _opts: IImageAddonOptions,
private readonly _renderer: ImageRenderer,
private readonly _storage: ImageStorage,
private readonly _coreTerminal: ITerminalExt
) {}
public reset(): void {
// Clear pending transmissions
this._pendingTransmissions.clear();
// Clear stored images
this._images.clear();
// Close decoded bitmaps
for (const bitmap of this._decodedImages.values()) {
bitmap.close();
}
this._decodedImages.clear();
}
/**
* Called at the start of a new APC sequence.
*/
public start(): void {
this._aborted = false;
this._data = '';
}
/**
* Called for each chunk of data in the APC sequence.
* Accumulates codepoints as a string.
*/
public put(data: Uint32Array, start: number, end: number): void {
if (this._aborted) return;
// Check size limit
if (this._data.length + (end - start) > this._opts.kittySizeLimit) {
console.warn('[KittyHandler] Data exceeds size limit, aborting');
this._aborted = true;
return;
}
// Convert Uint32Array codepoints to string
// TODO: This atob + charCodeAt pattern has bad runtime. Consider using the wasm-based
// base64 decoder from xterm-wasm-parts which supports chunked data ingestion.
// For now, we accumulate as string and decode at the end.
for (let i = start; i < end; i++) {
this._data += String.fromCodePoint(data[i]);
}
}
/**
* Called at the end of the APC sequence.
*/
public end(success: boolean): boolean | Promise<boolean> {
if (this._aborted || !success) {
return true;
}
return this._handleCommand(this._data);
}
/**
* Handle a complete Kitty graphics command.
*/
private _handleCommand(data: string): boolean | Promise<boolean> {
const semiIdx = data.indexOf(';');
const controlData = semiIdx === -1 ? data : data.substring(0, semiIdx);
const payload = semiIdx === -1 ? '' : data.substring(semiIdx + 1);
const cmd = parseKittyCommand(controlData);
cmd.payload = payload;
const action = cmd.action ?? 't';
switch (action) {
case KittyAction.TRANSMIT:
return this._handleTransmit(cmd);
case KittyAction.TRANSMIT_DISPLAY:
return this._handleTransmitDisplay(cmd);
case KittyAction.QUERY:
return this._handleQuery(cmd);
case KittyAction.DELETE:
return this._handleDelete(cmd);
default:
return true;
}
}
private _handleTransmit(cmd: IKittyCommand): boolean {
const payload = cmd.payload ?? '';
const pendingKey = cmd.id ?? 0;
const isMoreComing = cmd.more === 1;
const pending = this._pendingTransmissions.get(pendingKey);
if (pending) {
pending.data += payload;
if (isMoreComing) {
return true;
}
const originalCmd = pending.cmd;
const fullPayload = pending.data;
this._pendingTransmissions.delete(pendingKey);
const id = originalCmd.id ?? this._nextImageId++;
const image: IKittyImageData = {
id,
data: fullPayload,
width: originalCmd.width ?? 0,
height: originalCmd.height ?? 0,
format: (originalCmd.format ?? KittyFormat.PNG) as 24 | 32 | 100,
compression: originalCmd.compression ?? ''
};
this._images.set(image.id, image);
if (originalCmd.action === KittyAction.TRANSMIT_DISPLAY) {
// Fire-and-forget the display, transmit itself succeeded
void this._displayImage(image, originalCmd.columns, originalCmd.rows);
return true;
}
cmd.id = id;
return true;
}
if (isMoreComing) {
this._pendingTransmissions.set(pendingKey, {
cmd: { ...cmd },
data: payload
});
return true;
}
const id = cmd.id ?? this._nextImageId++;
const image: IKittyImageData = {
id,
data: payload,
width: cmd.width ?? 0,
height: cmd.height ?? 0,
format: (cmd.format ?? KittyFormat.PNG) as 24 | 32 | 100,
compression: cmd.compression ?? ''
};
this._images.set(image.id, image);
cmd.id = id;
return true;
}
private _handleTransmitDisplay(cmd: IKittyCommand): boolean | Promise<boolean> {
const pendingKey = cmd.id ?? 0;
const wasPendingBefore = this._pendingTransmissions.has(pendingKey);
this._handleTransmit(cmd);
if (cmd.more === 1) {
return true;
}
if (wasPendingBefore) {
return true;
}
const id = cmd.id!;
const image = this._images.get(id);
if (image) {
return this._displayImage(image, cmd.columns, cmd.rows);
}
return true;
}
/**
* Handle query action (a=q).
*/
private _handleQuery(cmd: IKittyCommand): boolean {
const id = cmd.id ?? 0;
const quiet = cmd.quiet ?? 0;
const payload = cmd.payload || '';
if (!payload) {
this._sendResponse(id, 'OK', quiet);
return true;
}
try {
const binaryString = atob(payload);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const format = cmd.format || KittyFormat.RGBA;
if (format === KittyFormat.PNG) {
this._sendResponse(id, 'OK', quiet);
} else {
const width = cmd.width || 0;
const height = cmd.height || 0;
if (!width || !height) {
this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);
return true;
}
const bytesPerPixel = format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
const expectedBytes = width * height * bytesPerPixel;
if (bytes.length < expectedBytes) {
this._sendResponse(id, `EINVAL:insufficient pixel data, got ${bytes.length}, expected ${expectedBytes}`, quiet);
return true;
}
this._sendResponse(id, 'OK', quiet);
}
} catch (e) {
const errorMsg = e instanceof Error ? e.message : 'unknown error';
this._sendResponse(id, `EINVAL:${errorMsg}`, quiet);
}
return true;
}
private _handleDelete(cmd: IKittyCommand): boolean {
const id = cmd.id;
if (id !== undefined) {
this._images.delete(id);
const bitmap = this._decodedImages.get(id);
if (bitmap) {
bitmap.close();
this._decodedImages.delete(id);
}
} else {
this._images.clear();
for (const bitmap of this._decodedImages.values()) {
bitmap.close();
}
this._decodedImages.clear();
}
return true;
}
/**
* Send a response back to the client.
*/
private _sendResponse(id: number, message: string, quiet: number): void {
const isOk = message === 'OK';
if (isOk && quiet === 1) return;
if (!isOk && quiet === 2) return;
const response = `\x1b_Gi=${id};${message}\x1b\\`;
this._coreTerminal._core.coreService.triggerDataEvent(response);
}
/**
* Decode and display an image using the shared ImageStorage.
*/
private _displayImage(image: IKittyImageData, columns?: number, rows?: number): boolean | Promise<boolean> {
return this._decodeAndDisplay(image, columns, rows)
.then(() => true)
.catch(err => {
console.warn('[KittyHandler] Failed to decode/display image:', err);
return true;
});
}
private async _decodeAndDisplay(image: IKittyImageData, columns?: number, rows?: number): Promise<void> {
let bitmap = this._decodedImages.get(image.id);
if (!bitmap) {
bitmap = await this._decodeImage(image);
this._decodedImages.set(image.id, bitmap);
}
// Calculate display size
let w = bitmap.width;
let h = bitmap.height;
if (columns || rows) {
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
if (columns) {
w = columns * cw;
}
if (rows) {
h = rows * ch;
}
// Maintain aspect ratio if only one dimension specified
if (columns && !rows) {
h = Math.round(w * (bitmap.height / bitmap.width));
} else if (rows && !columns) {
w = Math.round(h * (bitmap.width / bitmap.height));
}
}
// Check pixel limit
if (w * h > this._opts.pixelLimit) {
console.warn('[KittyHandler] Image exceeds pixel limit');
return;
}
// Use shared ImageStorage to add the image
// This handles cursor movement and integration with the terminal
if (w !== bitmap.width || h !== bitmap.height) {
// Resize if needed
const resized = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
this._storage.addImage(resized);
} else {
this._storage.addImage(bitmap);
}
}
/**
* Decode base64 image data into an ImageBitmap.
*/
private async _decodeImage(image: IKittyImageData): Promise<ImageBitmap> {
const format = image.format;
const base64Data = image.data;
// TODO: This atob + charCodeAt loop has bad runtime and creates memory pressure with large
// images. Consider using the wasm-based base64 decoder from xterm-wasm-parts.
const binaryString = atob(base64Data);
let bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
if (image.compression === KittyCompression.ZLIB) {
bytes = await this._decompressZlib(bytes) as Uint8Array<ArrayBuffer>;
}
if (format === KittyFormat.PNG) {
const blob = new Blob([bytes], { type: 'image/png' });
// Safari fallback pattern (from IIPHandler)
if (!window.createImageBitmap) {
const url = URL.createObjectURL(blob);
const img = new Image();
return new Promise<ImageBitmap>((resolve, reject) => {
img.addEventListener('load', () => {
URL.revokeObjectURL(url);
const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);
canvas.getContext('2d')?.drawImage(img, 0, 0);
createImageBitmap(canvas).then(resolve).catch(reject);
});
img.addEventListener('error', reject);
img.src = url;
});
}
return createImageBitmap(blob);
}
// Raw pixel data (RGB or RGBA)
const width = image.width;
const height = image.height;
if (!width || !height) {
throw new Error('Width and height required for raw pixel data');
}
const bytesPerPixel = format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
const expectedBytes = width * height * bytesPerPixel;
if (bytes.length < expectedBytes) {
throw new Error(`Insufficient pixel data: got ${bytes.length}, expected ${expectedBytes}`);
}
// Convert to RGBA ImageData
// TODO: For RGBA, use bytes directly. For RGB, use Uint32Array bit manipulation
// for 5-6x speedup.
const pixelCount = width * height;
const data = new Uint8ClampedArray(pixelCount * BYTES_PER_PIXEL_RGBA);
const isRgba = format === KittyFormat.RGBA;
let srcOffset = 0;
let dstOffset = 0;
for (let i = 0; i < pixelCount; i++) {
data[dstOffset ] = bytes[srcOffset ]; // R
data[dstOffset + 1] = bytes[srcOffset + 1]; // G
data[dstOffset + 2] = bytes[srcOffset + 2]; // B
data[dstOffset + 3] = isRgba ? bytes[srcOffset + 3] : ALPHA_OPAQUE;
srcOffset += bytesPerPixel;
dstOffset += BYTES_PER_PIXEL_RGBA;
}
return createImageBitmap(new ImageData(data, width, height));
}
/**
* Decompress zlib/deflate compressed data using the browser's DecompressionStream API.
*/
private async _decompressZlib(compressed: Uint8Array): Promise<Uint8Array> {
try {
return await this._decompress(compressed, 'deflate');
} catch {
return await this._decompress(compressed, 'deflate-raw');
}
}
private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise<Uint8Array> {
const ds = new DecompressionStream(format);
const writer = ds.writable.getWriter();
writer.write(new Uint8Array(compressed) as Uint8Array<ArrayBuffer>);
writer.close();
const chunks: Uint8Array[] = [];
const reader = ds.readable.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
/**
* Get stored images (for testing/debugging).
*/
public get images(): ReadonlyMap<number, IKittyImageData> {
return this._images;
}
}
@@ -0,0 +1,80 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { parseKittyCommand, KittyAction, KittyFormat } from './KittyGraphicsTypes';
describe('KittyGraphicsTypes', () => {
describe('parseKittyCommand', () => {
it('should parse control data with action and format', () => {
const cmd = parseKittyCommand('a=T,f=100');
assert.strictEqual(cmd.action, 'T');
assert.strictEqual(cmd.format, 100);
});
it('should parse control data with all options', () => {
const cmd = parseKittyCommand('a=t,f=32,i=5,s=10,v=20,c=3,r=2,m=1,q=2');
assert.strictEqual(cmd.action, 't');
assert.strictEqual(cmd.format, 32);
assert.strictEqual(cmd.id, 5);
assert.strictEqual(cmd.width, 10);
assert.strictEqual(cmd.height, 20);
assert.strictEqual(cmd.columns, 3);
assert.strictEqual(cmd.rows, 2);
assert.strictEqual(cmd.more, 1);
assert.strictEqual(cmd.quiet, 2);
});
it('should handle empty control data', () => {
const cmd = parseKittyCommand('');
assert.strictEqual(cmd.action, undefined);
assert.strictEqual(cmd.format, undefined);
});
it('should parse transmit action', () => {
const cmd = parseKittyCommand('a=t,f=100');
assert.strictEqual(cmd.action, KittyAction.TRANSMIT);
assert.strictEqual(cmd.format, KittyFormat.PNG);
});
it('should parse delete action', () => {
const cmd = parseKittyCommand('a=d,i=5');
assert.strictEqual(cmd.action, KittyAction.DELETE);
assert.strictEqual(cmd.id, 5);
});
it('should parse empty action as empty string', () => {
const cmd = parseKittyCommand('a=,f=100');
assert.strictEqual(cmd.action, '');
assert.strictEqual(cmd.format, 100);
});
it('should leave action undefined when key is not present', () => {
const cmd = parseKittyCommand('f=100,i=5');
assert.strictEqual(cmd.action, undefined);
assert.strictEqual(cmd.format, 100);
assert.strictEqual(cmd.id, 5);
});
it('should parse compression key', () => {
const cmd = parseKittyCommand('a=t,f=32,o=z');
assert.strictEqual(cmd.action, 't');
assert.strictEqual(cmd.format, 32);
assert.strictEqual(cmd.compression, 'z');
});
it('should parse x and y offset', () => {
const cmd = parseKittyCommand('a=T,x=10,y=20');
assert.strictEqual(cmd.x, 10);
assert.strictEqual(cmd.y, 20);
});
it('should handle keys without values', () => {
const cmd = parseKittyCommand('a=t,f=,i=5');
assert.strictEqual(cmd.action, 't');
assert.ok(isNaN(cmd.format!));
assert.strictEqual(cmd.id, 5);
});
});
});
@@ -0,0 +1,156 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
*
* Kitty graphics protocol types, constants, and parsing utilities.
*/
/**
* Kitty graphics protocol action types.
* See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.
*/
export const enum KittyAction {
TRANSMIT = 't',
TRANSMIT_DISPLAY = 'T',
QUERY = 'q',
PLACEMENT = 'p',
DELETE = 'd'
}
/**
* Kitty graphics protocol format types.
* See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
*/
export const enum KittyFormat {
RGB = 24,
RGBA = 32,
PNG = 100
}
/**
* Kitty graphics protocol compression types.
* See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.
*/
export const enum KittyCompression {
NONE = '',
ZLIB = 'z'
}
/**
* Kitty graphics protocol control data keys.
* See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
*/
export const enum KittyKey {
// Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)
ACTION = 'a',
// Image format (24=RGB, 32=RGBA, 100=PNG)
FORMAT = 'f',
// Image ID for referencing stored images
ID = 'i',
// Source image width in pixels
WIDTH = 's',
// Source image height in pixels
HEIGHT = 'v',
// The left edge (in pixels) of the image area to display
X_OFFSET = 'x',
// The top edge (in pixels) of the image area to display
Y_OFFSET = 'y',
// Number of terminal columns to display the image over
COLUMNS = 'c',
// Number of terminal rows to display the image over
ROWS = 'r',
// More data flag (1=more chunks coming, 0=final chunk)
MORE = 'm',
// Compression type (z=zlib). This is essential for chunking larger images.
COMPRESSION = 'o',
// Quiet mode (1=suppress OK responses, 2=suppress error responses)
QUIET = 'q'
}
// Pixel format constants
export const BYTES_PER_PIXEL_RGB = 3;
export const BYTES_PER_PIXEL_RGBA = 4;
export const ALPHA_OPAQUE = 255;
/**
* Parsed Kitty graphics command.
*/
export interface IKittyCommand {
action?: string;
format?: number;
id?: number;
width?: number;
height?: number;
x?: number;
y?: number;
columns?: number;
rows?: number;
more?: number;
quiet?: number;
compression?: string;
payload?: string;
}
/**
* Pending chunked transmission state.
* Stores metadata from the first chunk while accumulating payload data.
*/
export interface IPendingTransmission {
/** The parsed command from the first chunk (contains action, format, dimensions, etc.) */
cmd: IKittyCommand;
/** Accumulated base64 payload data */
data: string;
}
/**
* Stored Kitty image data.
*/
export interface IKittyImageData {
id: number;
data: string;
width: number;
height: number;
format: 24 | 32 | 100;
compression?: string;
}
/**
* Parses Kitty graphics control data into a command object.
*/
export function parseKittyCommand(data: string): IKittyCommand {
const cmd: IKittyCommand = {};
const parts = data.split(',');
for (const part of parts) {
const eqIdx = part.indexOf('=');
if (eqIdx === -1) continue;
const key = part.substring(0, eqIdx);
const value = part.substring(eqIdx + 1);
// Handle string keys first
if (key === KittyKey.ACTION) {
cmd.action = value;
continue;
}
if (key === KittyKey.COMPRESSION) {
cmd.compression = value;
continue;
}
const numValue = parseInt(value);
switch (key) {
case KittyKey.FORMAT: cmd.format = numValue; break;
case KittyKey.ID: cmd.id = numValue; break;
case KittyKey.WIDTH: cmd.width = numValue; break;
case KittyKey.HEIGHT: cmd.height = numValue; break;
case KittyKey.X_OFFSET: cmd.x = numValue; break;
case KittyKey.Y_OFFSET: cmd.y = numValue; break;
case KittyKey.COLUMNS: cmd.columns = numValue; break;
case KittyKey.ROWS: cmd.rows = numValue; break;
case KittyKey.MORE: cmd.more = numValue; break;
case KittyKey.QUIET: cmd.quiet = numValue; break;
}
}
return cmd;
}
+253 -2
View File
@@ -23,6 +23,8 @@ export interface IImageAddonOptions {
sixelSizeLimit: number;
iipSupport: boolean;
iipSizeLimit: number;
kittySupport: boolean;
kittySizeLimit: number;
}
// eslint-disable-next-line
@@ -73,6 +75,10 @@ const TESTDATA_IIP: [string, [number, number]][] = [
[readFileSync('./addons/addon-image/fixture/iip/w3c_png.iip', { encoding: 'utf-8' }), [72, 48]]
];
// Kitty graphics test images
const KITTY_BLACK_1X1_BASE64 = readFileSync('./addons/addon-image/fixture/kitty/black-1x1.png').toString('base64');
const KITTY_RGB_3X1_BASE64 = readFileSync('./addons/addon-image/fixture/kitty/rgb-3x1.png').toString('base64');
let ctx: ITestContext;
test.beforeAll(async ({ browser }) => {
ctx = await createTestContext(browser);
@@ -134,7 +140,9 @@ test.describe('ImageAddon', () => {
storageLimit: 128,
showPlaceholder: true,
iipSupport: true,
iipSizeLimit: 20000000
iipSizeLimit: 20000000,
kittySupport: true,
kittySizeLimit: 20000000
};
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
});
@@ -149,7 +157,9 @@ test.describe('ImageAddon', () => {
storageLimit: 10,
showPlaceholder: false,
iipSupport: false,
iipSizeLimit: 1000
iipSizeLimit: 1000,
kittySupport: false,
kittySizeLimit: 1000
};
await ctx.page.evaluate(opts => {
(window as any).imageAddonCustom = new ImageAddon(opts.opts);
@@ -294,6 +304,247 @@ test.describe('ImageAddon', () => {
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[4][1]);
});
});
test.describe('Kitty graphics support', () => {
test('stores 1x1 black PNG with a=T (transmit and display)', async () => {
const seq = `\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
await ctx.proxy.write(seq);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [1, 1]);
});
test('stores 3x1 RGB PNG with a=T', async () => {
const seq = `\x1b_Ga=T,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`;
await ctx.proxy.write(seq);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [3, 1]);
});
test('transmit only (a=t) does not display but stores in handler', async () => {
const seq = `\x1b_Ga=t,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
await ctx.proxy.write(seq);
await timeout(100);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
});
test('uses specified image ID', async () => {
const seq = `\x1b_Ga=t,f=100,i=42;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
await ctx.proxy.write(seq);
await timeout(100);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(42)`), true);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(1)`), false);
});
test('assigns auto-incrementing IDs when not specified', async () => {
await ctx.proxy.write(`\x1b_Ga=t,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await ctx.proxy.write(`\x1b_Ga=t,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 2);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(1)`), true);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(2)`), true);
});
test('defaults to transmit action when action is omitted', async () => {
const seq = `\x1b_Gf=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
await ctx.proxy.write(seq);
await timeout(100);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
});
test('ignores command when action is empty string', async () => {
const seq = `\x1b_Ga=,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
await ctx.proxy.write(seq);
await timeout(100);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0);
});
test('handles chunked transmission (m=1)', async () => {
const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
const part2 = KITTY_BLACK_1X1_BASE64.substring(half);
const seq1 = `\x1b_Ga=T,f=100,i=99,m=1;${part1}\x1b\\`;
const seq2 = `\x1b_Ga=T,f=100,i=99;${part2}\x1b\\`;
await ctx.proxy.write(seq1);
await timeout(50);
strictEqual(await getImageStorageLength(), 0);
await ctx.proxy.write(seq2);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
});
test('verifies chunked data is assembled correctly', async () => {
const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
const part2 = KITTY_BLACK_1X1_BASE64.substring(half);
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=99,m=1;${part1}\x1b\\`);
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=99;${part2}\x1b\\`);
await timeout(100);
const storedData = await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.get(99).data`);
strictEqual(storedData, KITTY_BLACK_1X1_BASE64);
});
test('delete command (a=d) removes specific image by id', async () => {
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=10;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(50);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
await ctx.proxy.write(`\x1b_Ga=d,i=10\x1b\\`);
await timeout(50);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0);
});
test('delete command (a=d) removes all images when no id specified', async () => {
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=2;${KITTY_RGB_3X1_BASE64}\x1b\\`);
await timeout(50);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 2);
await ctx.proxy.write(`\x1b_Ga=d\x1b\\`);
await timeout(50);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0);
});
});
test.describe('Kitty query support (a=q)', () => {
test('responds with OK for capability query without payload', async () => {
let response = '';
await ctx.page.evaluate(() => {
(window as any).kittyResponse = '';
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
});
await ctx.proxy.write('\x1b_Gi=31,a=q;\x1b\\');
await timeout(100);
response = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response, '\x1b_Gi=31;OK\x1b\\');
});
test('responds with OK for valid PNG query', async () => {
let response = '';
await ctx.page.evaluate(() => {
(window as any).kittyResponse = '';
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
});
await ctx.proxy.write(`\x1b_Gi=42,a=q,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
response = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response, '\x1b_Gi=42;OK\x1b\\');
});
test('query does NOT store the image (unlike transmit)', async () => {
await ctx.page.evaluate(() => {
(window as any).term.onData(() => { /* consume response */ });
});
await ctx.proxy.write(`\x1b_Gi=50,a=q,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(50)`), false);
});
test('responds with error for invalid base64', async () => {
let response = '';
await ctx.page.evaluate(() => {
(window as any).kittyResponse = '';
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
});
await ctx.proxy.write('\x1b_Gi=60,a=q,f=100;!!!invalid!!!\x1b\\');
await timeout(100);
response = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response.startsWith('\x1b_Gi=60;EINVAL:'), true);
});
test('responds with error for RGB data without dimensions', async () => {
let response = '';
await ctx.page.evaluate(() => {
(window as any).kittyResponse = '';
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
});
await ctx.proxy.write('\x1b_Gi=70,a=q,f=24;AAAA\x1b\\');
await timeout(100);
response = await ctx.page.evaluate('window.kittyResponse');
strictEqual(response, '\x1b_Gi=70;EINVAL:width and height required for raw pixel data\x1b\\');
});
test('suppresses OK response when q=1', async () => {
await ctx.page.evaluate(() => {
(window as any).kittyGotResponse = false;
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
});
await ctx.proxy.write(`\x1b_Gi=80,a=q,q=1,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
test('suppresses error response when q=2', async () => {
await ctx.page.evaluate(() => {
(window as any).kittyGotResponse = false;
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
});
await ctx.proxy.write('\x1b_Gi=90,a=q,q=2,f=100;!!!invalid!!!\x1b\\');
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
});
test.describe('Kitty pixel verification', () => {
test('renders 1x1 black PNG at cursor position', async () => {
const seq = `\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
await ctx.proxy.write(seq);
await timeout(100);
const pixel = await ctx.page.evaluate(() => {
const canvas = (window as any).imageAddon.getImageAtBufferCell(0, 0);
if (!canvas) return null;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
return Array.from(ctx.getImageData(0, 0, 1, 1).data);
});
deepStrictEqual(pixel, [0, 0, 0, 255]);
});
test('renders 3x1 RGB PNG (red, green, blue pixels)', async () => {
const seq = `\x1b_Ga=T,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`;
await ctx.proxy.write(seq);
await timeout(100);
const pixels = await ctx.page.evaluate(() => {
const canvas = (window as any).imageAddon.getImageAtBufferCell(0, 0);
if (!canvas) return null;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const imageData = ctx.getImageData(0, 0, 3, 1).data;
return {
red: Array.from(imageData.slice(0, 4)),
green: Array.from(imageData.slice(4, 8)),
blue: Array.from(imageData.slice(8, 12))
};
});
deepStrictEqual(pixels?.red, [255, 0, 0, 255]);
deepStrictEqual(pixels?.green, [0, 255, 0, 255]);
deepStrictEqual(pixels?.blue, [0, 0, 255, 255]);
});
});
});
/**
+9
View File
@@ -76,6 +76,15 @@ declare module '@xterm/addon-image' {
iipSupport?: boolean;
/** IIP sequence size limit (default 20000000 bytes). */
iipSizeLimit?: number;
/**
* Kitty graphics protocol settings
*/
/** Whether Kitty graphics protocol is enabled (default is true). */
kittySupport?: boolean;
/** Kitty image size limit in bytes (default 20000000 bytes). */
kittySizeLimit?: number;
}
export class ImageAddon implements ITerminalAddon {
-19
View File
@@ -1,19 +0,0 @@
Copyright (c) 2025, The xterm.js authors (https://github.com/xtermjs/xterm.js)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
-47
View File
@@ -1,47 +0,0 @@
# @xterm/addon-kitty-graphics
An addon for [xterm.js](https://github.com/xtermjs/xterm.js) that adds support for the [Kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/).
## Install
```bash
npm install --save @xterm/addon-kitty-graphics @xterm/xterm
```
## Usage
```typescript
import { Terminal } from '@xterm/xterm';
import { KittyGraphicsAddon } from '@xterm/addon-kitty-graphics';
const terminal = new Terminal();
const kittyGraphicsAddon = new KittyGraphicsAddon();
terminal.loadAddon(kittyGraphicsAddon);
```
## Features
This addon implements the Kitty graphics protocol, allowing applications to display images directly in the terminal using APC (Application Program Command) escape sequences.
### Supported Features
- PNG image transmission (f=100)
- Direct RGB/RGBA pixel data (f=24, f=32)
- Image placement at cursor position
- Basic query support (a=q)
### Protocol Format
The Kitty graphics protocol uses APC escape sequences:
```
<ESC>_G<key>=<value>,<key>=<value>,...;<base64 data><ESC>\
```
Key parameters:
- `a`: Action (t=transmit, T=transmit+display, q=query)
- `f`: Format (100=PNG, 24=RGB, 32=RGBA)
- `i`: Image ID
- `m`: More data follows (1=yes, 0=no)
See the [Kitty graphics protocol documentation](https://sw.kovidgoyal.net/kitty/graphics-protocol/) for full details.
-27
View File
@@ -1,27 +0,0 @@
{
"name": "@xterm/addon-kitty-graphics",
"version": "0.1.0",
"author": {
"name": "The xterm.js authors",
"url": "https://xtermjs.org/"
},
"main": "lib/addon-kitty-graphics.js",
"module": "lib/addon-kitty-graphics.mjs",
"types": "typings/addon-kitty-graphics.d.ts",
"repository": "https://github.com/xtermjs/xterm.js/tree/master/addons/addon-kitty-graphics",
"license": "MIT",
"keywords": [
"terminal",
"xterm",
"xterm.js",
"kitty",
"graphics"
],
"scripts": {
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
"start": "node ../../demo/start"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 938 KiB

File diff suppressed because it is too large Load Diff
@@ -1,188 +0,0 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { Terminal } from 'browser/public/Terminal';
import { KittyGraphicsAddon } from './KittyGraphicsAddon';
import { parseKittyCommand } from './KittyApcHandler';
/**
* Write data to terminal and wait for completion.
*/
function writeP(terminal: Terminal, data: string | Uint8Array): Promise<void> {
return new Promise(r => terminal.write(data, r));
}
// Test image: 1x1 black PNG (captured from `send-png fixture/black-1x1.png`)
// Get the below base64-encoded PNG file by: `python3 send-png addons/addon-kitty-graphics/fixture/black-1x1.png`
const BLACK_1X1_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAMAAAAoyzS7AAAAA1BMVEUAAACnej3aAAAACklEQVR4nGNgAAAAAgABSK+kcQAAAAt0RVh0Q29tbWVudAAA1LTqjgAAAApJREFUeJxjYAAAAGQA2AAAAAt0RVh0Q29tbWVudAAA1LTqjg5JREFUAAAAASUVORK5CYII=';
// Test image: 3x1 RGB PNG (red, green, blue pixels)
const RGB_3X1_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAMAAAABCAMAAAAsPuSGAAAACVBMVEX/AAAA/wAAAP8tSs2KAAAADElEQVR4nGNgYGQCAAAIAAQ24LCmAAAAHXRFWHRTb2Z0d2FyZQBAbHVuYXBhaW50L3BuZy1jb2RlY/VDGR4AAAAASUVORK5CYII=';
// Currently tests the flow: write escape sequence -> addon stores image.
// Pixel-level verification of rendered images is done in Playwright tests.
describe('KittyGraphicsAddon', () => {
let terminal: Terminal;
let addon: KittyGraphicsAddon;
beforeEach(() => {
terminal = new Terminal({ cols: 80, rows: 24, allowProposedApi: true });
addon = new KittyGraphicsAddon({ debug: false });
terminal.loadAddon(addon);
});
describe('parseKittyCommand', () => {
it('should parse control data with action and format', () => {
const cmd = parseKittyCommand('a=T,f=100');
assert.equal(cmd.action, 'T');
assert.equal(cmd.format, 100);
});
it('should parse control data with all options', () => {
const cmd = parseKittyCommand('a=t,f=32,i=5,s=10,v=20,c=3,r=2,m=1,q=2');
assert.equal(cmd.action, 't');
assert.equal(cmd.format, 32);
assert.equal(cmd.id, 5);
assert.equal(cmd.width, 10);
assert.equal(cmd.height, 20);
assert.equal(cmd.columns, 3);
assert.equal(cmd.rows, 2);
assert.equal(cmd.more, 1);
assert.equal(cmd.quiet, 2);
});
it('should handle empty control data', () => {
const cmd = parseKittyCommand('');
assert.equal(cmd.action, undefined);
assert.equal(cmd.format, undefined);
});
it('should parse transmit action', () => {
const cmd = parseKittyCommand('a=t,f=100');
assert.equal(cmd.action, 't');
assert.equal(cmd.format, 100);
});
it('should parse delete action', () => {
const cmd = parseKittyCommand('a=d,i=5');
assert.equal(cmd.action, 'd');
assert.equal(cmd.id, 5);
});
it('should parse empty action as empty string', () => {
const cmd = parseKittyCommand('a=,f=100');
assert.equal(cmd.action, '');
assert.equal(cmd.format, 100);
});
it('should leave action undefined when key is not present (parser only)', () => {
const cmd = parseKittyCommand('f=100,i=5');
assert.equal(cmd.action, undefined);
assert.equal(cmd.format, 100);
assert.equal(cmd.id, 5);
});
});
describe('APC handler', () => {
it('should store image when transmit+display sequence is written', async () => {
// Write Kitty graphics sequence: ESC _ G <control>;<payload> ESC \
const sequence = `\x1b_Ga=T,f=100;${BLACK_1X1_BASE64}\x1b\\`;
await writeP(terminal, sequence);
// Addon should have stored the image
assert.equal(addon.images.size, 1);
const image = addon.images.get(1)!;
assert.exists(image);
assert.equal(image.format, 100); // PNG format
assert.equal(image.data, BLACK_1X1_BASE64);
});
it('should store RGB image with correct payload', async () => {
const sequence = `\x1b_Ga=T,f=100;${RGB_3X1_BASE64}\x1b\\`;
await writeP(terminal, sequence);
assert.equal(addon.images.size, 1);
const image = addon.images.get(1)!;
assert.equal(image.data, RGB_3X1_BASE64);
});
it('should use explicit image id when provided', async () => {
const sequence = `\x1b_Ga=T,f=100,i=42;${BLACK_1X1_BASE64}\x1b\\`;
await writeP(terminal, sequence);
assert.equal(addon.images.size, 1);
assert.isTrue(addon.images.has(42));
assert.isFalse(addon.images.has(1));
});
it('should handle transmit-only (a=t) without display', async () => {
const sequence = `\x1b_Ga=t,f=100;${BLACK_1X1_BASE64}\x1b\\`;
await writeP(terminal, sequence);
// Image should still be stored??
assert.equal(addon.images.size, 1);
});
it('should default to transmit action when action is omitted', async () => {
// No a= key - should default to 't' (transmit)
const sequence = `\x1b_Gf=100;${BLACK_1X1_BASE64}\x1b\\`;
await writeP(terminal, sequence);
// Image should be stored (transmit action)
assert.equal(addon.images.size, 1);
});
it('should ignore command when action is empty string', async () => {
// a= with no value is invalid - should be ignored
const sequence = `\x1b_Ga=,f=100;${BLACK_1X1_BASE64}\x1b\\`;
await writeP(terminal, sequence);
// Empty action is invalid, command should be ignored
assert.equal(addon.images.size, 0);
});
it('should delete image by id', async () => {
// First store an image with id=5
await writeP(terminal, `\x1b_Ga=T,f=100,i=5;${BLACK_1X1_BASE64}\x1b\\`);
assert.equal(addon.images.size, 1);
// Delete it
await writeP(terminal, `\x1b_Ga=d,i=5\x1b\\`);
assert.equal(addon.images.size, 0);
});
it('should delete all images when no id specified', async () => {
// Store multiple images
await writeP(terminal, `\x1b_Ga=T,f=100,i=1;${BLACK_1X1_BASE64}\x1b\\`);
await writeP(terminal, `\x1b_Ga=T,f=100,i=2;${RGB_3X1_BASE64}\x1b\\`);
assert.equal(addon.images.size, 2);
// Delete all
await writeP(terminal, `\x1b_Ga=d\x1b\\`);
assert.equal(addon.images.size, 0);
});
it('should handle chunked transmission (m=1 flag)', async () => {
// Split payload into chunks using m=1 (more data coming)
const half = Math.floor(BLACK_1X1_BASE64.length / 2);
const chunk1 = BLACK_1X1_BASE64.substring(0, half);
const chunk2 = BLACK_1X1_BASE64.substring(half);
// First chunk with m=1
await writeP(terminal, `\x1b_Ga=t,f=100,i=10,m=1;${chunk1}\x1b\\`);
// Image not complete yet (pending)
assert.equal(addon.images.size, 0);
// Final chunk without m=1
await writeP(terminal, `\x1b_Ga=t,f=100,i=10;${chunk2}\x1b\\`);
// Now image should be stored
assert.equal(addon.images.size, 1);
const image = addon.images.get(10)!;
assert.equal(image.data, BLACK_1X1_BASE64);
});
});
});
@@ -1,71 +0,0 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
*/
import type { Terminal, ITerminalAddon, IDisposable } from '@xterm/xterm';
import type { KittyGraphicsAddon as IKittyGraphicsApi, IKittyGraphicsOptions, IKittyImage } from '@xterm/addon-kitty-graphics';
import { KittyImageRenderer } from './KittyImageRenderer';
import type { ITerminalExt } from './Types';
import { KittyApcHandler } from './KittyApcHandler';
export class KittyGraphicsAddon implements ITerminalAddon, IKittyGraphicsApi {
private _terminal: ITerminalExt | undefined;
private _apcHandler: IDisposable | undefined;
private _kittyApcHandler: KittyApcHandler | undefined;
private _renderer: KittyImageRenderer | undefined;
private _images: Map<number, IKittyImage> = new Map();
private _decodedImages: Map<number, ImageBitmap> = new Map();
private _debug: boolean;
constructor(options?: IKittyGraphicsOptions) {
this._debug = options?.debug ?? false;
}
public get images(): ReadonlyMap<number, IKittyImage> {
return this._images;
}
public activate(terminal: Terminal): void {
this._terminal = terminal as ITerminalExt;
this._renderer = new KittyImageRenderer(terminal);
if (this._debug) {
console.log('[KittyGraphicsAddon] Registering APC handler for G (0x47)');
}
this._kittyApcHandler = new KittyApcHandler(
this._images,
this._decodedImages,
this._renderer,
this._terminal,
this._debug
);
// Register APC handler for 'G' (0x47) - Kitty graphics protocol
// APC sequence format: ESC _ G <data> ESC \
// TODO: Follow jerch's feedback: The string-based handler interface is limited to 10MB
// and has bad runtime due to string conversion overhead. Implement IApcHandler interface with
// start/put/end methods to receive raw Uint32Array codepoints without copying.
// See SixelHandler and IIPHandler.
this._apcHandler = terminal.parser.registerApcHandler(0x47, (data: string) => {
return this._kittyApcHandler?.handle(data) ?? true;
});
}
public dispose(): void {
this._apcHandler?.dispose();
this._kittyApcHandler?.clearPendingTransmissions();
this._renderer?.dispose();
this._images.clear();
// Close all decoded bitmaps
for (const bitmap of this._decodedImages.values()) {
bitmap.close();
}
this._decodedImages.clear();
this._terminal = undefined;
this._kittyApcHandler = undefined;
}
}
@@ -1,237 +0,0 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
*/
import type { Terminal, IDisposable } from '@xterm/xterm';
/**
* A placed image in the terminal.
*/
export interface IPlacedImage {
/** The decoded image bitmap */
bitmap: ImageBitmap;
/** Column position in terminal */
col: number;
/** Row position in terminal (relative to buffer, not viewport) */
row: number;
/** Width to render (in pixels, 0 = original) */
width: number;
/** Height to render (in pixels, 0 = original) */
height: number;
/** The image ID for reference */
id: number;
}
/**
* Handles canvas layer management and image rendering for Kitty graphics.
*
* Similar to ImageRenderer in addon-image but simplified for Kitty protocol.
*/
export class KittyImageRenderer implements IDisposable {
private _canvas: HTMLCanvasElement | undefined;
private _ctx: CanvasRenderingContext2D | null | undefined;
private _terminal: Terminal;
private _placements: Map<number, IPlacedImage> = new Map();
private _placementIdCounter = 0;
private _renderDisposable: IDisposable | undefined;
private _resizeDisposable: IDisposable | undefined;
constructor(terminal: Terminal) {
this._terminal = terminal;
}
public dispose(): void {
this._renderDisposable?.dispose();
this._resizeDisposable?.dispose();
// Close all bitmaps
for (const placement of this._placements.values()) {
placement.bitmap.close();
}
this._placements.clear();
if (this._canvas) {
this._canvas.remove();
this._canvas = undefined;
this._ctx = undefined;
}
}
/**
* Initialize the canvas layer. Called when first image is placed.
*/
public ensureCanvasLayer(): void {
if (this._canvas) {
return;
}
// Access internal screenElement
const core = (this._terminal as any)._core;
const screenElement = core?.screenElement;
if (!screenElement) {
console.warn('[KittyGraphicsAddon] Cannot create canvas: no screenElement');
return;
}
// Get dimensions from terminal
const dimensions = this._terminal.dimensions;
const width = dimensions?.css.canvas.width || 800;
const height = dimensions?.css.canvas.height || 600;
// Create canvas
this._canvas = document.createElement('canvas');
this._canvas.width = width;
this._canvas.height = height;
this._canvas.classList.add('xterm-kitty-graphics-layer');
// Position absolutely over the terminal
this._canvas.style.position = 'absolute';
this._canvas.style.top = '0';
this._canvas.style.left = '0';
this._canvas.style.pointerEvents = 'none';
this._canvas.style.zIndex = '10';
screenElement.appendChild(this._canvas);
this._ctx = this._canvas.getContext('2d', { alpha: true });
// Hook into render events to redraw when terminal scrolls
this._renderDisposable = this._terminal.onRender(() => this._draw());
// Handle resize
this._resizeDisposable = this._terminal.onResize(() => this._resizeCanvas());
}
/**
* Get cell dimensions from terminal.
*/
public getCellSize(): { width: number, height: number } {
const dimensions = this._terminal.dimensions;
return {
width: dimensions?.css.cell.width || 9,
height: dimensions?.css.cell.height || 17
};
}
/**
* Place a decoded image at cursor position.
* @param bitmap - The decoded ImageBitmap to place
* @param id - The image ID this placement belongs to
* @param col - Optional column position (defaults to cursor X)
* @param row - Optional row position (defaults to cursor Y + baseY)
* @param width - Optional width in pixels (0 = original)
* @param height - Optional height in pixels (0 = original)
*/
public placeImage(bitmap: ImageBitmap, id: number, col?: number, row?: number, width?: number, height?: number): number {
this.ensureCanvasLayer();
const buffer = this._terminal.buffer.active;
const placementId = ++this._placementIdCounter;
const placement: IPlacedImage = {
bitmap,
col: col ?? buffer.cursorX,
row: row ?? (buffer.cursorY + buffer.baseY),
width: width || 0,
height: height || 0,
id
};
this._placements.set(placementId, placement);
this._draw();
return placementId;
}
/**
* Remove a placed image.
*/
public removePlacement(placementId: number): void {
const placement = this._placements.get(placementId);
if (placement) {
this._placements.delete(placementId);
this._draw();
}
}
/**
* Remove all placements for an image ID.
*/
public removeByImageId(imageId: number): void {
const toDelete: number[] = [];
for (const [placementId, placement] of this._placements) {
if (placement.id === imageId) {
toDelete.push(placementId);
}
}
for (const id of toDelete) {
this._placements.delete(id);
}
if (toDelete.length > 0) {
this._draw();
}
}
/**
* Clear all images.
*/
public clearAll(): void {
this._placements.clear();
if (this._ctx && this._canvas) {
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
}
}
/**
* Redraw all placed images.
*/
private _draw(): void {
if (!this._ctx || !this._canvas) {
return;
}
// Clear canvas
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
const buffer = this._terminal.buffer.active;
const viewportStartRow = buffer.baseY;
const viewportRows = this._terminal.rows;
const cellSize = this.getCellSize();
// Draw each placement that's visible
for (const placement of this._placements.values()) {
// Check if placement is in viewport
const relativeRow = placement.row - viewportStartRow;
if (relativeRow < 0 || relativeRow >= viewportRows) {
continue; // Not in viewport
}
const x = placement.col * cellSize.width;
const y = relativeRow * cellSize.height;
const width = placement.width || placement.bitmap.width;
const height = placement.height || placement.bitmap.height;
this._ctx.drawImage(placement.bitmap, x, y, width, height);
}
}
/**
* Called when terminal resizes.
*/
private _resizeCanvas(): void {
if (!this._canvas) {
return;
}
const dimensions = this._terminal.dimensions;
const width = dimensions?.css.canvas.width || 800;
const height = dimensions?.css.canvas.height || 600;
if (this._canvas.width !== width || this._canvas.height !== height) {
this._canvas.width = width;
this._canvas.height = height;
this._draw();
}
}
}
-11
View File
@@ -1,11 +0,0 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
*/
import type { Terminal } from '@xterm/xterm';
/**
Question: Can I delete this since we switched from triggerDataEvent to this._terminal.input
*/
export interface ITerminalExt extends Terminal {}
@@ -1,27 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es2021",
"lib": ["dom", "es2015"],
"rootDir": ".",
"outDir": "../out",
"sourceMap": true,
"removeComments": true,
"strict": true,
"types": ["../../../node_modules/@types/mocha"],
"paths": {
"browser/*": ["../../../src/browser/*"],
"vs/*": ["../../../src/vs/*"],
"@xterm/addon-kitty-graphics": ["../typings/addon-kitty-graphics.d.ts"]
}
},
"include": ["./**/*", "../../../typings/xterm.d.ts"],
"references": [
{
"path": "../../../src/browser"
},
{
"path": "../../../src/vs"
}
]
}

Some files were not shown because too many files have changed in this diff Show More