diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03f289b9..c40e86ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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) diff --git a/addons/addon-kitty-graphics/fixture/black-1x1.png b/addons/addon-image/fixture/kitty/black-1x1.png similarity index 100% rename from addons/addon-kitty-graphics/fixture/black-1x1.png rename to addons/addon-image/fixture/kitty/black-1x1.png diff --git a/addons/addon-kitty-graphics/fixture/rgb-3x1.png b/addons/addon-image/fixture/kitty/rgb-3x1.png similarity index 100% rename from addons/addon-kitty-graphics/fixture/rgb-3x1.png rename to addons/addon-image/fixture/kitty/rgb-3x1.png diff --git a/addons/addon-image/src/ImageAddon.ts b/addons/addon-image/src/ImageAddon.ts index 167e93a7..0d040601 100644 --- a/addons/addon-image/src/ImageAddon.ts +++ b/addons/addon-image/src/ImageAddon.ts @@ -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. diff --git a/addons/addon-image/src/Types.ts b/addons/addon-image/src/Types.ts index 60de0f3d..480a4853 100644 --- a/addons/addon-image/src/Types.ts +++ b/addons/addon-image/src/Types.ts @@ -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 { diff --git a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts new file mode 100644 index 00000000..97ef8704 --- /dev/null +++ b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts @@ -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 = new Map(); + private _nextImageId = 1; + + /** + * Stored images - kept for protocol compliance (transmit without display, then display later). + */ + private _images: Map = new Map(); + + /** + * Cached decoded bitmaps. + */ + private _decodedImages: Map = 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 { + if (this._aborted || !success) { + return true; + } + + return this._handleCommand(this._data); + } + + /** + * Handle a complete Kitty graphics command. + */ + private _handleCommand(data: string): boolean | Promise { + 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 { + 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 { + 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 { + 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 { + 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; + } + + 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((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 { + 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 { + const ds = new DecompressionStream(format); + const writer = ds.writable.getWriter(); + writer.write(new Uint8Array(compressed) as Uint8Array); + 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 { + return this._images; + } +} diff --git a/addons/addon-image/src/kitty/KittyGraphicsTypes.test.ts b/addons/addon-image/src/kitty/KittyGraphicsTypes.test.ts new file mode 100644 index 00000000..1bbfac67 --- /dev/null +++ b/addons/addon-image/src/kitty/KittyGraphicsTypes.test.ts @@ -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); + }); + }); +}); diff --git a/addons/addon-image/src/kitty/KittyGraphicsTypes.ts b/addons/addon-image/src/kitty/KittyGraphicsTypes.ts new file mode 100644 index 00000000..7afb5b3b --- /dev/null +++ b/addons/addon-image/src/kitty/KittyGraphicsTypes.ts @@ -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; +} diff --git a/addons/addon-image/test/ImageAddon.test.ts b/addons/addon-image/test/ImageAddon.test.ts index a26ba0f4..a7bb2543 100644 --- a/addons/addon-image/test/ImageAddon.test.ts +++ b/addons/addon-image/test/ImageAddon.test.ts @@ -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]); + }); + }); }); /** diff --git a/addons/addon-image/typings/addon-image.d.ts b/addons/addon-image/typings/addon-image.d.ts index 48ba488c..856063e3 100644 --- a/addons/addon-image/typings/addon-image.d.ts +++ b/addons/addon-image/typings/addon-image.d.ts @@ -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 { diff --git a/addons/addon-kitty-graphics/LICENSE b/addons/addon-kitty-graphics/LICENSE deleted file mode 100644 index a6db9edc..00000000 --- a/addons/addon-kitty-graphics/LICENSE +++ /dev/null @@ -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. diff --git a/addons/addon-kitty-graphics/README.md b/addons/addon-kitty-graphics/README.md deleted file mode 100644 index 650f84e0..00000000 --- a/addons/addon-kitty-graphics/README.md +++ /dev/null @@ -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: - -``` -_G=,=,...;\ -``` - -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. diff --git a/addons/addon-kitty-graphics/package.json b/addons/addon-kitty-graphics/package.json deleted file mode 100644 index 5bb560e7..00000000 --- a/addons/addon-kitty-graphics/package.json +++ /dev/null @@ -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" - } -} diff --git a/addons/addon-kitty-graphics/src/19616.jpg b/addons/addon-kitty-graphics/src/19616.jpg deleted file mode 100644 index fc521ace..00000000 Binary files a/addons/addon-kitty-graphics/src/19616.jpg and /dev/null differ diff --git a/addons/addon-kitty-graphics/src/KittyApcHandler.ts b/addons/addon-kitty-graphics/src/KittyApcHandler.ts deleted file mode 100644 index b622556c..00000000 --- a/addons/addon-kitty-graphics/src/KittyApcHandler.ts +++ /dev/null @@ -1,618 +0,0 @@ -/** - * Copyright (c) 2025 The xterm.js authors. All rights reserved. - * @license MIT - * - * Kitty graphics protocol types, constants, parsing, and APC handler. - */ - -import type { IKittyImage } from '@xterm/addon-kitty-graphics'; - -/** - * 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 where the format for *-bit came from. - */ -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. - */ -interface IPendingTransmission { - /** The parsed command from the first chunk (contains action, format, dimensions, etc.) */ - cmd: IKittyCommand; - /** Accumulated base64 payload data */ - data: string; -} - -/** - * Terminal interface for sending responses. - * Question from Anthony: Do we really need this... - */ -interface ITerminal { - input(data: string, wasUserInput: boolean): void; -} - -/** - * Renderer interface for image display. - */ -interface IKittyRenderer { - getCellSize(): { width: number, height: number }; - placeImage(bitmap: ImageBitmap, id: number, col?: number, row?: number, width?: number, height?: number): number; - removeByImageId(id: number): void; - clearAll(): void; -} - -/** - * 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; -} - -/** - * Handles Kitty graphics protocol APC sequences. - * Manages parsing, chunked transmissions, and dispatching to action handlers. - * - * TODO: Go over this with Daniel: Like SixelHandler, this receives direct references to storage - * and terminal rather than using callbacks. - * - * TODO: File transmission (t=f) is not supported since browsers cannot access the filesystem. - * Maybe we need something from VS Code? - */ -export class KittyApcHandler { - /** - * Pending chunked transmissions keyed by image ID. - * ID 0 is used for transmissions without an explicit ID (the "anonymous" transmission). - */ - private _pendingTransmissions: Map = new Map(); - private _nextImageId = 1; - - constructor( - private readonly _images: Map, - private readonly _decodedImages: Map, - private readonly _renderer: IKittyRenderer | undefined, - private readonly _terminal: ITerminal | undefined, - private readonly _debug: boolean = false - ) {} - - /** - * Handle a Kitty graphics APC sequence. - * @param data - The data portion of the APC sequence (after 'G') - * @returns true if handled successfully - */ - public handle(data: string): 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; - - if (this._debug) { - console.log('[KittyApcHandler] Received command:', cmd); - } - - 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; - } - } - - /** - * Clear all pending transmissions. Called on dispose. - */ - public clearPendingTransmissions(): void { - this._pendingTransmissions.clear(); - } - - private _handleTransmit(cmd: IKittyCommand): boolean { - const payload = cmd.payload ?? ''; - const pendingKey = cmd.id ?? 0; - - // larger image would require chunking. - const isMoreComing = cmd.more === 1; - const pending = this._pendingTransmissions.get(pendingKey); - - if (pending) { - pending.data += payload; - - if (this._debug) { - console.log(`[KittyApcHandler] Chunk continuation for id=${pendingKey}, total size=${pending.data.length}, more=${isMoreComing}`); - } - - if (isMoreComing) { - return true; - } - - const originalCmd = pending.cmd; - const fullPayload = pending.data; - this._pendingTransmissions.delete(pendingKey); - - const id = originalCmd.id ?? this._nextImageId++; - - const image: IKittyImage = { - 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 (this._debug) { - console.log(`[KittyApcHandler] Stored chunked image ${id}, payload size=${fullPayload.length}`); - } - - if (originalCmd.action === KittyAction.TRANSMIT_DISPLAY) { - this._displayImage(image, originalCmd.columns, originalCmd.rows); - } - - cmd.id = id; - return true; - } - - if (isMoreComing) { - this._pendingTransmissions.set(pendingKey, { - cmd: { ...cmd }, - data: payload - }); - - if (this._debug) { - console.log(`[KittyApcHandler] Started chunked transmission, id=${pendingKey}, format=${cmd.format}, compression=${cmd.compression}, size=${cmd.width}x${cmd.height}, initial payload=${payload.length}`); - } - - return true; - } - - const id = cmd.id ?? this._nextImageId++; - - const image: IKittyImage = { - 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); - - if (this._debug) { - console.log(`[KittyApcHandler] Stored image ${id}`); - } - - cmd.id = id; - return true; - } - - private _handleTransmitDisplay(cmd: IKittyCommand): 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) { - this._displayImage(image, cmd.columns, cmd.rows); - } - - return true; - } - - /** - * Handle query action (a=q). - * - * Per the Kitty graphics protocol documentation: - * "Sometimes, using an id is not appropriate... In that case, you can use the - * query action, set a=q. Then the terminal emulator will try to load the image - * and respond with either OK or an error, as above, but it will not replace an - * existing image with the same id, nor will it store the image." - */ - private _handleQuery(cmd: IKittyCommand): boolean { - const id = cmd.id ?? 0; - const quiet = cmd.quiet ?? 0; - - if (this._debug) { - console.log(`[KittyApcHandler] Query received, id=${id}, quiet=${quiet}`); - } - - 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); - // Close and remove decoded bitmap - const bitmap = this._decodedImages.get(id); - if (bitmap) { - bitmap.close(); - this._decodedImages.delete(id); - } - // Remove from renderer - this._renderer?.removeByImageId(id); - } else { - this._images.clear(); - // Close all decoded bitmaps - for (const bitmap of this._decodedImages.values()) { - bitmap.close(); - } - this._decodedImages.clear(); - // Clear all from renderer - this._renderer?.clearAll(); - } - - return true; - } - - /** - * Send a response back to the client via the terminal's input method. - */ - private _sendResponse(id: number, message: string, quiet: number): void { - const isOk = message === 'OK'; - if (isOk && quiet === 1) { - return; - } - if (!isOk && quiet === 2) { - return; - } - - if (!this._terminal) { - return; - } - - const response = `\x1b_Gi=${id};${message}\x1b\\`; - this._terminal.input(response, false); - - if (this._debug) { - console.log(`[KittyApcHandler] Sent response: i=${id};${message}`); - } - } - - /** - * Decode and display an image at the cursor position. - */ - private _displayImage(image: IKittyImage, columns?: number, rows?: number): void { - if (!this._renderer) { - return; - } - - const storedImage = this._images.get(image.id); - if (!storedImage) { - if (this._debug) { - console.log(`[KittyApcHandler] No image found for id=${image.id} after transmit`); - } - return; - } - - this._decodeAndDisplay(storedImage, columns, rows).catch(err => { - if (this._debug) { - console.error(`[KittyApcHandler] Failed to decode/display image ${image.id}:`, err); - } - }); - } - - /** - * Decode an image and display it at the cursor position. - */ - private async _decodeAndDisplay(image: IKittyImage, columns?: number, rows?: number): Promise { - if (!this._renderer) { - return; - } - - let bitmap = this._decodedImages.get(image.id); - - if (!bitmap) { - bitmap = await this._decodeImage(image); - this._decodedImages.set(image.id, bitmap); - - if (this._debug) { - console.log(`[KittyApcHandler] Decoded image ${image.id}: ${bitmap.width}x${bitmap.height}`); - } - } - - let width = 0; - let height = 0; - if (columns || rows) { - const cellSize = this._renderer.getCellSize(); - if (columns) { - width = columns * cellSize.width; - } - if (rows) { - height = rows * cellSize.height; - } - if (width && !height) { - height = Math.round(width * (bitmap.height / bitmap.width)); - } else if (height && !width) { - width = Math.round(height * (bitmap.width / bitmap.height)); - } - } - - this._renderer.placeImage(bitmap, image.id, undefined, undefined, width, height); - - if (this._debug) { - console.log(`[KittyApcHandler] Placed image ${image.id} at cursor, size: ${width || bitmap.width}x${height || bitmap.height}`); - } - } - - /** - * Decode base64 image data into an ImageBitmap. - */ - private async _decodeImage(image: IKittyImage): Promise { - const format = image.format; - const base64Data = image.data as string; - - // 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 which also - // supports chunked data ingestion. See addon-image/src/IIPHandler.ts for chunked usage. - 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); - if (this._debug) { - console.log(`[KittyApcHandler] Decompressed ${binaryString.length} -> ${bytes.length} bytes`); - } - } - - if (format === KittyFormat.PNG) { - // TODO: Older Safari versions cannot createImageBitmap from Blob and need a workaround - // with new Image() instead. See IIPHandler for the Safari fallback pattern. - // ---> Things seem to work for my safari, but still try to see how I can verify. - const blob = new Blob([bytes], { type: 'image/png' }); - return createImageBitmap(blob); - } - - 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: Get this checked by Daniel. - // TODO: Follow Jerch's feedback! - 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 { - 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 { - const ds = new DecompressionStream(format); - const writer = ds.writable.getWriter(); - writer.write(compressed); - 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; - } -} diff --git a/addons/addon-kitty-graphics/src/KittyGraphicsAddon.test.ts b/addons/addon-kitty-graphics/src/KittyGraphicsAddon.test.ts deleted file mode 100644 index eb721426..00000000 --- a/addons/addon-kitty-graphics/src/KittyGraphicsAddon.test.ts +++ /dev/null @@ -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 { - 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 ; 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); - }); - }); -}); diff --git a/addons/addon-kitty-graphics/src/KittyGraphicsAddon.ts b/addons/addon-kitty-graphics/src/KittyGraphicsAddon.ts deleted file mode 100644 index d46679ed..00000000 --- a/addons/addon-kitty-graphics/src/KittyGraphicsAddon.ts +++ /dev/null @@ -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 = new Map(); - private _decodedImages: Map = new Map(); - private _debug: boolean; - - constructor(options?: IKittyGraphicsOptions) { - this._debug = options?.debug ?? false; - } - - public get images(): ReadonlyMap { - 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 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; - } -} diff --git a/addons/addon-kitty-graphics/src/KittyImageRenderer.ts b/addons/addon-kitty-graphics/src/KittyImageRenderer.ts deleted file mode 100644 index 39f90de2..00000000 --- a/addons/addon-kitty-graphics/src/KittyImageRenderer.ts +++ /dev/null @@ -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 = 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(); - } - } -} diff --git a/addons/addon-kitty-graphics/src/Types.ts b/addons/addon-kitty-graphics/src/Types.ts deleted file mode 100644 index ac4011b3..00000000 --- a/addons/addon-kitty-graphics/src/Types.ts +++ /dev/null @@ -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 {} diff --git a/addons/addon-kitty-graphics/src/tsconfig.json b/addons/addon-kitty-graphics/src/tsconfig.json deleted file mode 100644 index 570d4af5..00000000 --- a/addons/addon-kitty-graphics/src/tsconfig.json +++ /dev/null @@ -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" - } - ] -} diff --git a/addons/addon-kitty-graphics/test/KittyGraphicsAddon.test.ts b/addons/addon-kitty-graphics/test/KittyGraphicsAddon.test.ts deleted file mode 100644 index 57b22f15..00000000 --- a/addons/addon-kitty-graphics/test/KittyGraphicsAddon.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -/** - * Copyright (c) 2025 The xterm.js authors. All rights reserved. - * @license MIT - * - * Playwright integration tests for Kitty Graphics Addon. - * These test the addon in a real browser with visual verification. - * - * Unit tests for parsing are in src/KittyGraphicsAddon.test.ts - */ - -import test from '@playwright/test'; -import { readFileSync } from 'fs'; -import { deepStrictEqual, strictEqual } from 'assert'; -import { ITestContext, createTestContext, openTerminal, timeout } from '../../../test/playwright/TestUtils'; - -// Load test images as base64 -const BLACK_1X1_BASE64 = readFileSync('./addons/addon-kitty-graphics/fixture/black-1x1.png').toString('base64'); -const RGB_3X1_BASE64 = readFileSync('./addons/addon-kitty-graphics/fixture/rgb-3x1.png').toString('base64'); - -let ctx: ITestContext; - -test.beforeAll(async ({ browser }) => { - ctx = await createTestContext(browser); -}); - -test.afterAll(async () => { - await ctx.page.close(); -}); - -test.describe('KittyGraphicsAddon', () => { - test.beforeEach(async () => { - await openTerminal(ctx, { cols: 80, rows: 24 }); - await ctx.page.evaluate(` - window.term.reset(); - window.kittyAddon?.dispose(); - window.kittyAddon = new KittyGraphicsAddon({ debug: true }); - window.term.loadAddon(window.kittyAddon); - `); - }); - - test('addon should be loaded and activated', async () => { - const hasAddon = await ctx.page.evaluate(`typeof window.KittyGraphicsAddon !== 'undefined'`); - strictEqual(hasAddon, true, 'KittyGraphicsAddon should be available'); - }); - - test.describe('image storage', () => { - test('stores PNG image with a=t (transmit only)', async () => { - const seq = `\x1b_Ga=t,f=100;${BLACK_1X1_BASE64}\x1b\\`; - await ctx.proxy.write(seq); - await timeout(50); - strictEqual(await ctx.page.evaluate('window.kittyAddon.images.size'), 1); - }); - - test('stores PNG image with a=T (transmit and display)', async () => { - const seq = `\x1b_Ga=T,f=100;${BLACK_1X1_BASE64}\x1b\\`; - await ctx.proxy.write(seq); - await timeout(50); - strictEqual(await ctx.page.evaluate('window.kittyAddon.images.size'), 1); - }); - - test('assigns auto-incrementing IDs when not specified', async () => { - const seq1 = `\x1b_Ga=t,f=100;${BLACK_1X1_BASE64}\x1b\\`; - const seq2 = `\x1b_Ga=t,f=100;${RGB_3X1_BASE64}\x1b\\`; - await ctx.proxy.write(seq1); - await ctx.proxy.write(seq2); - await timeout(50); - strictEqual(await ctx.page.evaluate('window.kittyAddon.images.size'), 2); - strictEqual(await ctx.page.evaluate('window.kittyAddon.images.has(1)'), true); - strictEqual(await ctx.page.evaluate('window.kittyAddon.images.has(2)'), true); - }); - - test('uses specified image ID', async () => { - const seq = `\x1b_Ga=t,f=100,i=42;${BLACK_1X1_BASE64}\x1b\\`; - await ctx.proxy.write(seq); - await timeout(50); - strictEqual(await ctx.page.evaluate('window.kittyAddon.images.has(42)'), true); - }); - - test('deletes specific image with a=d', async () => { - const seq1 = `\x1b_Ga=t,f=100,i=10;${BLACK_1X1_BASE64}\x1b\\`; - const seq2 = `\x1b_Ga=d,i=10;\x1b\\`; - await ctx.proxy.write(seq1); - await timeout(50); - strictEqual(await ctx.page.evaluate('window.kittyAddon.images.size'), 1); - await ctx.proxy.write(seq2); - await timeout(50); - strictEqual(await ctx.page.evaluate('window.kittyAddon.images.size'), 0); - }); - - test('deletes all images with a=d (no id)', async () => { - const seq1 = `\x1b_Ga=t,f=100,i=1;${BLACK_1X1_BASE64}\x1b\\`; - const seq2 = `\x1b_Ga=t,f=100,i=2;${RGB_3X1_BASE64}\x1b\\`; - const seqDelete = `\x1b_Ga=d;\x1b\\`; - await ctx.proxy.write(seq1); - await ctx.proxy.write(seq2); - await timeout(50); - strictEqual(await ctx.page.evaluate('window.kittyAddon.images.size'), 2); - await ctx.proxy.write(seqDelete); - await timeout(50); - strictEqual(await ctx.page.evaluate('window.kittyAddon.images.size'), 0); - }); - }); - - test.describe('chunked transmission (m=1)', () => { - test('assembles multi-chunk image', async () => { - // Split the base64 in half - const half = Math.floor(BLACK_1X1_BASE64.length / 2); - const part1 = BLACK_1X1_BASE64.substring(0, half); - const part2 = 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); - // Image should not be stored yet (still pending) - strictEqual(await ctx.page.evaluate('window.kittyAddon.images.has(99)'), false); - - await ctx.proxy.write(seq2); - await timeout(50); - // Now it should be stored - strictEqual(await ctx.page.evaluate('window.kittyAddon.images.has(99)'), true); - - // Verify the full data was assembled - const storedData = await ctx.page.evaluate('window.kittyAddon.images.get(99).data'); - strictEqual(storedData, BLACK_1X1_BASE64); - }); - }); - - test.describe('pixel verification', () => { - // TODO: Add more intense ones. - test('renders 1x1 black PNG at cursor position', async () => { - // Send image with a=T to transmit and display - const seq = `\x1b_Ga=T,f=100;${BLACK_1X1_BASE64}\x1b\\`; - await ctx.proxy.write(seq); - await timeout(100); - - // Read pixel from addon's canvas overlay - const pixel = await ctx.page.evaluate(() => { - const canvas = document.querySelector('.xterm-kitty-graphics-layer') as HTMLCanvasElement; - if (!canvas) return null; - const ctx = canvas.getContext('2d'); - if (!ctx) return null; - return Array.from(ctx.getImageData(0, 0, 1, 1).data); - }); - - // Black pixel: [0, 0, 0, 255] - deepStrictEqual(pixel, [0, 0, 0, 255]); - }); - - test('renders 3x1 RGB PNG (red, green, blue pixels)', async () => { - const seq = `\x1b_Ga=T,f=100;${RGB_3X1_BASE64}\x1b\\`; - await ctx.proxy.write(seq); - await timeout(100); - - const pixels = await ctx.page.evaluate(() => { - const canvas = document.querySelector('.xterm-kitty-graphics-layer') as HTMLCanvasElement; - 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)) - }; - }); - - // Verify RGB pixels - deepStrictEqual(pixels?.red, [255, 0, 0, 255]); - deepStrictEqual(pixels?.green, [0, 255, 0, 255]); - deepStrictEqual(pixels?.blue, [0, 0, 255, 255]); - }); - }); - - test.describe('query support (a=q)', () => { - test('responds with OK for capability query without payload', async () => { - let response = ''; - const disposable = ctx.proxy.onData(data => { response = data; }); - - await ctx.proxy.write('\x1b_Gi=31,a=q;\x1b\\'); - await timeout(100); - - disposable.dispose(); - - strictEqual(response, '\x1b_Gi=31;OK\x1b\\'); - }); - - test('responds with OK for valid PNG query', async () => { - let response = ''; - const disposable = ctx.proxy.onData(data => { response = data; }); - - // Send query with PNG data - await ctx.proxy.write(`\x1b_Gi=42,a=q,f=100;${BLACK_1X1_BASE64}\x1b\\`); - await timeout(100); - - disposable.dispose(); - - strictEqual(response, '\x1b_Gi=42;OK\x1b\\'); - }); - - test('query does NOT store the image (unlike transmit)', async () => { - const disposable = ctx.proxy.onData(() => { /* consume response */ }); - - await ctx.proxy.write(`\x1b_Gi=50,a=q,f=100;${BLACK_1X1_BASE64}\x1b\\`); - await timeout(100); - - disposable.dispose(); - - // Image should NOT be stored - strictEqual(await ctx.page.evaluate('window.kittyAddon.images.has(50)'), false); - }); - - test('responds with error for invalid base64', async () => { - let response = ''; - const disposable = ctx.proxy.onData(data => { response = data; }); - - // Send query with invalid base64 - await ctx.proxy.write('\x1b_Gi=60,a=q,f=100;!!!invalid!!!\x1b\\'); - await timeout(100); - - disposable.dispose(); - - // Should contain EINVAL error - strictEqual(response.startsWith('\x1b_Gi=60;EINVAL:'), true); - }); - - test('responds with error for RGB data without dimensions', async () => { - let response = ''; - const disposable = ctx.proxy.onData(data => { response = data; }); - - // RGB format (f=24) requires width (s) and height (v) - // Send RGB data without specifying s= and v= - await ctx.proxy.write('\x1b_Gi=70,a=q,f=24;AAAA\x1b\\'); - await timeout(100); - - disposable.dispose(); - - strictEqual(response, '\x1b_Gi=70;EINVAL:width and height required for raw pixel data\x1b\\'); - }); - - test('suppresses OK response when q=1', async () => { - // q=1 means suppress OK responses - let gotResponse = false; - const disposable = ctx.proxy.onData(() => { gotResponse = true; }); - - await ctx.proxy.write(`\x1b_Gi=80,a=q,q=1,f=100;${BLACK_1X1_BASE64}\x1b\\`); - await timeout(100); - - disposable.dispose(); - - strictEqual(gotResponse, false); - }); - - test('suppresses error response when q=2', async () => { - // q=2 means suppress error responses - let gotResponse = false; - const disposable = ctx.proxy.onData(() => { gotResponse = true; }); - - // Send invalid data with q=2 - await ctx.proxy.write('\x1b_Gi=90,a=q,q=2,f=100;!!!invalid!!!\x1b\\'); - await timeout(100); - - disposable.dispose(); - - strictEqual(gotResponse, false); - }); - }); -}); diff --git a/addons/addon-kitty-graphics/test/playwright.config.ts b/addons/addon-kitty-graphics/test/playwright.config.ts deleted file mode 100644 index 22834be1..00000000 --- a/addons/addon-kitty-graphics/test/playwright.config.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { PlaywrightTestConfig } from '@playwright/test'; - -const config: PlaywrightTestConfig = { - testDir: '.', - timeout: 10000, - projects: [ - { - name: 'ChromeStable', - use: { - browserName: 'chromium', - channel: 'chrome' - } - }, - { - name: 'FirefoxStable', - use: { - browserName: 'firefox' - } - }, - { - name: 'WebKit', - use: { - browserName: 'webkit' - } - } - ], - reporter: 'list', - webServer: { - command: 'npm run start', - port: 3000, - timeout: 120000, - reuseExistingServer: !process.env.CI - } -}; -export default config; diff --git a/addons/addon-kitty-graphics/test/tsconfig.json b/addons/addon-kitty-graphics/test/tsconfig.json deleted file mode 100644 index 8a742670..00000000 --- a/addons/addon-kitty-graphics/test/tsconfig.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "target": "ESNext", - "lib": ["es2021"], - "rootDir": ".", - "outDir": "../out-test", - "sourceMap": true, - "removeComments": true, - "baseUrl": ".", - "paths": { - "common/*": ["../../../src/common/*"], - "browser/*": ["../../../src/browser/*"] - }, - "strict": true, - "types": ["../../../node_modules/@types/node"] - }, - "include": ["./**/*", "../../../typings/xterm.d.ts"], - "references": [ - { - "path": "../../../src/common" - }, - { - "path": "../../../src/browser" - }, - { - "path": "../../../test/playwright" - } - ] -} diff --git a/addons/addon-kitty-graphics/tsconfig.json b/addons/addon-kitty-graphics/tsconfig.json deleted file mode 100644 index 6531dac8..00000000 --- a/addons/addon-kitty-graphics/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "files": [], - "include": [], - "references": [{ "path": "./src" }, { "path": "./test" }] -} diff --git a/addons/addon-kitty-graphics/typings/addon-kitty-graphics.d.ts b/addons/addon-kitty-graphics/typings/addon-kitty-graphics.d.ts deleted file mode 100644 index 32a137cb..00000000 --- a/addons/addon-kitty-graphics/typings/addon-kitty-graphics.d.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Copyright (c) 2025 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { Terminal, ITerminalAddon, IDisposable } from '@xterm/xterm'; - -declare module '@xterm/addon-kitty-graphics' { - /** - * An xterm.js addon that provides support for the Kitty graphics protocol. - * This allows applications to display images in the terminal using APC - * escape sequences. - */ - export class KittyGraphicsAddon implements ITerminalAddon, IDisposable { - /** - * Creates a new Kitty graphics addon. - * @param options Optional configuration for the addon. - */ - constructor(options?: IKittyGraphicsOptions); - - /** - * Activates the addon. - * @param terminal The terminal the addon is being loaded in. - */ - public activate(terminal: Terminal): void; - - /** - * Disposes the addon. - */ - public dispose(): void; - - /** - * Gets the current images stored in the addon. - * Returns a map of image IDs to their image data. - */ - public readonly images: ReadonlyMap; - } - - /** - * Options for the Kitty graphics addon. - */ - export interface IKittyGraphicsOptions { - /** - * Enable debug logging of received graphics commands. - * Default: false - */ - debug?: boolean; - } - - /** - * Represents a stored Kitty graphics image. - */ - export interface IKittyImage { - /** - * The image ID assigned by the terminal or the application. - */ - id: number; - - /** - * The image data as a base64 string or ImageData object. - */ - data: string | ImageData; - - /** - * Width of the image in pixels. - */ - width: number; - - /** - * Height of the image in pixels. - */ - height: number; - - /** - * Format of the image data. - * - 24: RGB (3 bytes per pixel) - * - 32: RGBA (4 bytes per pixel) - * - 100: PNG - */ - format: 24 | 32 | 100; - - /** - * Compression type. - * - '': no compression - * - 'z': zlib compression - */ - compression?: string; - } -} diff --git a/addons/addon-kitty-graphics/webpack.config.js b/addons/addon-kitty-graphics/webpack.config.js deleted file mode 100644 index 89ed51de..00000000 --- a/addons/addon-kitty-graphics/webpack.config.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright (c) 2025 The xterm.js authors. All rights reserved. - * @license MIT - */ - -const path = require('path'); - -const addonName = 'KittyGraphicsAddon'; -const mainFile = 'addon-kitty-graphics.js'; - -const addon = { - entry: `./out/${addonName}.js`, - devtool: 'source-map', - module: { - rules: [ - { - test: /\.js$/, - use: ["source-map-loader"], - enforce: "pre", - exclude: /node_modules/ - } - ] - }, - resolve: { - modules: ['./node_modules'], - extensions: [ '.js' ], - alias: { - common: path.resolve('../../out/common'), - browser: path.resolve('../../out/browser'), - vs: path.resolve('../../out/vs') - } - }, - output: { - filename: mainFile, - path: path.resolve('./lib'), - library: addonName, - libraryTarget: 'umd', - // Force usage of globalThis instead of global / self. (This is cross-env compatible) - globalObject: 'globalThis', - }, - mode: 'production' -}; - -module.exports = [addon]; diff --git a/bin/esbuild.mjs b/bin/esbuild.mjs index 5c7e5afe..5e0a370c 100644 --- a/bin/esbuild.mjs +++ b/bin/esbuild.mjs @@ -137,7 +137,6 @@ if (config.addon) { "@xterm/addon-clipboard": "./addons/addon-clipboard/lib/addon-clipboard.mjs", "@xterm/addon-fit": "./addons/addon-fit/lib/addon-fit.mjs", "@xterm/addon-image": "./addons/addon-image/lib/addon-image.mjs", - "@xterm/addon-kitty-graphics": "./addons/addon-kitty-graphics/lib/addon-kitty-graphics.mjs", "@xterm/addon-progress": "./addons/addon-progress/lib/addon-progress.mjs", "@xterm/addon-search": "./addons/addon-search/lib/addon-search.mjs", "@xterm/addon-serialize": "./addons/addon-serialize/lib/addon-serialize.mjs", diff --git a/demo/client/client.ts b/demo/client/client.ts index a2ccd40a..baa989c1 100644 --- a/demo/client/client.ts +++ b/demo/client/client.ts @@ -29,7 +29,6 @@ import { TestWindow } from './components/window/testWindow'; import { VtWindow } from './components/window/vtWindow'; import { ClipboardAddon } from '@xterm/addon-clipboard'; import { FitAddon } from '@xterm/addon-fit'; -import { KittyGraphicsAddon } from '@xterm/addon-kitty-graphics'; import { LigaturesAddon } from '@xterm/addon-ligatures'; import { ProgressAddon } from '@xterm/addon-progress'; import { SearchAddon, ISearchOptions } from '@xterm/addon-search'; @@ -48,7 +47,6 @@ export interface IWindowWithTerminal extends Window { ClipboardAddon?: typeof ClipboardAddon; FitAddon?: typeof FitAddon; ImageAddon?: typeof ImageAddon; - KittyGraphicsAddon?: typeof KittyGraphicsAddon; ProgressAddon?: typeof ProgressAddon; SearchAddon?: typeof SearchAddon; SerializeAddon?: typeof SerializeAddon; @@ -76,7 +74,6 @@ const addons: AddonCollection = { clipboard: { name: 'clipboard', ctor: ClipboardAddon, canChange: true }, fit: { name: 'fit', ctor: FitAddon, canChange: false }, image: { name: 'image', ctor: ImageAddon, canChange: true }, - kittyGraphics: { name: 'kittyGraphics', ctor: KittyGraphicsAddon, canChange: true }, progress: { name: 'progress', ctor: ProgressAddon, canChange: true }, search: { name: 'search', ctor: SearchAddon, canChange: true }, serialize: { name: 'serialize', ctor: SerializeAddon, canChange: true }, @@ -199,7 +196,6 @@ if (document.location.pathname === '/test') { window.ClipboardAddon = ClipboardAddon; window.FitAddon = FitAddon; window.ImageAddon = ImageAddon; - window.KittyGraphicsAddon = KittyGraphicsAddon; window.ProgressAddon = ProgressAddon; window.SearchAddon = SearchAddon; window.SerializeAddon = SerializeAddon; @@ -298,7 +294,6 @@ function createTerminal(): Terminal { addons.serialize.instance = new SerializeAddon(); addons.fit.instance = new FitAddon(); addons.image.instance = new ImageAddon(); - addons.kittyGraphics.instance = new KittyGraphicsAddon(); addons.progress.instance = new ProgressAddon(); addons.unicodeGraphemes.instance = new UnicodeGraphemesAddon(); addons.clipboard.instance = new ClipboardAddon(); @@ -311,7 +306,6 @@ function createTerminal(): Terminal { addons.webFonts.instance = new WebFontsAddon(); typedTerm.loadAddon(addons.fit.instance); typedTerm.loadAddon(addons.image.instance); - typedTerm.loadAddon(addons.kittyGraphics.instance); typedTerm.loadAddon(addons.progress.instance); typedTerm.loadAddon(addons.search.instance); typedTerm.loadAddon(addons.serialize.instance); diff --git a/demo/client/tsconfig.json b/demo/client/tsconfig.json index 554272de..1113cfdd 100644 --- a/demo/client/tsconfig.json +++ b/demo/client/tsconfig.json @@ -11,7 +11,6 @@ "@xterm/addon-clipboard": ["../../addons/addon-clipboard"], "@xterm/addon-fit": ["../../addons/addon-fit"], "@xterm/addon-image": ["../../addons/addon-image"], - "@xterm/addon-kitty-graphics": ["../../addons/addon-kitty-graphics"], "@xterm/addon-progress": ["../../addons/addon-progress"], "@xterm/addon-search": ["../../addons/addon-search"], "@xterm/addon-serialize": ["../../addons/addon-serialize"], @@ -19,12 +18,11 @@ "@xterm/addon-web-links": ["../../addons/addon-web-links"], "@xterm/addon-webgl": ["../../addons/addon-webgl"], "@xterm/addon-unicode11": ["../../addons/addon-unicode11"], - "@xterm/addon-unicode-graphemes": ["../../addons/addon-unicode-graphemes"], + "@xterm/addon-unicode-graphemes": [ + "../../addons/addon-unicode-graphemes" + ], "@xterm/addon-ligatures": ["../../addons/addon-ligatures"] } }, - "include": [ - "./**/*", - "../../typings/xterm.d.ts" - ] + "include": ["./**/*", "../../typings/xterm.d.ts"] } diff --git a/demo/client/types.ts b/demo/client/types.ts index 8cafdd50..c73f31e2 100644 --- a/demo/client/types.ts +++ b/demo/client/types.ts @@ -9,7 +9,6 @@ import type { ImageAddon } from '@xterm/addon-image'; import type { AttachAddon } from '@xterm/addon-attach'; import type { ClipboardAddon } from '@xterm/addon-clipboard'; import type { FitAddon } from '@xterm/addon-fit'; -import type { KittyGraphicsAddon } from '@xterm/addon-kitty-graphics'; import type { LigaturesAddon } from '@xterm/addon-ligatures'; import type { ProgressAddon } from '@xterm/addon-progress'; import type { SearchAddon } from '@xterm/addon-search'; @@ -20,7 +19,7 @@ import type { WebFontsAddon } from '@xterm/addon-web-fonts'; import type { WebLinksAddon } from '@xterm/addon-web-links'; import type { WebglAddon } from '@xterm/addon-webgl'; -export type AddonType = 'attach' | 'clipboard' | 'fit' | 'image' | 'kittyGraphics' | 'progress' | 'search' | 'serialize' | 'unicode11' | 'unicodeGraphemes' | 'webFonts' | 'webLinks' | 'webgl' | 'ligatures'; +export type AddonType = 'attach' | 'clipboard' | 'fit' | 'image' | 'ligatures' | 'progress' | 'search' | 'serialize' | 'unicode11' | 'unicodeGraphemes' | 'webFonts' | 'webLinks' | 'webgl'; export interface IDemoAddon { name: T; @@ -30,34 +29,32 @@ export interface IDemoAddon { T extends 'clipboard' ? typeof ClipboardAddon : T extends 'fit' ? typeof FitAddon : T extends 'image' ? typeof ImageAddon : - T extends 'kittyGraphics' ? typeof KittyGraphicsAddon : - T extends 'ligatures' ? typeof LigaturesAddon : - T extends 'progress' ? typeof ProgressAddon : - T extends 'search' ? typeof SearchAddon : - T extends 'serialize' ? typeof SerializeAddon : - T extends 'webFonts' ? typeof WebFontsAddon : - T extends 'webLinks' ? typeof WebLinksAddon : - T extends 'unicode11' ? typeof Unicode11Addon : - T extends 'unicodeGraphemes' ? typeof UnicodeGraphemesAddon : - T extends 'webgl' ? typeof WebglAddon : - never + T extends 'ligatures' ? typeof LigaturesAddon : + T extends 'progress' ? typeof ProgressAddon : + T extends 'search' ? typeof SearchAddon : + T extends 'serialize' ? typeof SerializeAddon : + T extends 'webFonts' ? typeof WebFontsAddon : + T extends 'webLinks' ? typeof WebLinksAddon : + T extends 'unicode11' ? typeof Unicode11Addon : + T extends 'unicodeGraphemes' ? typeof UnicodeGraphemesAddon : + T extends 'webgl' ? typeof WebglAddon : + never ); instance?: ( T extends 'attach' ? AttachAddon : T extends 'clipboard' ? ClipboardAddon : T extends 'fit' ? FitAddon : T extends 'image' ? ImageAddon : - T extends 'kittyGraphics' ? KittyGraphicsAddon : - T extends 'ligatures' ? LigaturesAddon : - T extends 'progress' ? ProgressAddon : - T extends 'search' ? SearchAddon : - T extends 'serialize' ? SerializeAddon : - T extends 'webFonts' ? WebFontsAddon : - T extends 'webLinks' ? WebLinksAddon : - T extends 'unicode11' ? Unicode11Addon : - T extends 'unicodeGraphemes' ? UnicodeGraphemesAddon : - T extends 'webgl' ? WebglAddon : - never + T extends 'ligatures' ? LigaturesAddon : + T extends 'progress' ? ProgressAddon : + T extends 'search' ? SearchAddon : + T extends 'serialize' ? SerializeAddon : + T extends 'webFonts' ? WebFontsAddon : + T extends 'webLinks' ? WebLinksAddon : + T extends 'unicode11' ? Unicode11Addon : + T extends 'unicodeGraphemes' ? UnicodeGraphemesAddon : + T extends 'webgl' ? WebglAddon : + never ); } diff --git a/tsconfig.all.json b/tsconfig.all.json index 2f682739..b0ed0279 100644 --- a/tsconfig.all.json +++ b/tsconfig.all.json @@ -11,7 +11,6 @@ { "path": "./addons/addon-clipboard" }, { "path": "./addons/addon-fit" }, { "path": "./addons/addon-image" }, - { "path": "./addons/addon-kitty-graphics" }, { "path": "./addons/addon-ligatures" }, { "path": "./addons/addon-progress" }, { "path": "./addons/addon-search" },