diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c40e86ff2c..03f289b9b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,9 @@ 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-*/* \ @@ -216,6 +219,8 @@ 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/LICENSE b/addons/addon-kitty-graphics/LICENSE new file mode 100644 index 0000000000..a6db9edcfc --- /dev/null +++ b/addons/addon-kitty-graphics/LICENSE @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000000..650f84e034 --- /dev/null +++ b/addons/addon-kitty-graphics/README.md @@ -0,0 +1,47 @@ +# @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/fixture/black-1x1.png b/addons/addon-kitty-graphics/fixture/black-1x1.png new file mode 100644 index 0000000000..9456317423 Binary files /dev/null and b/addons/addon-kitty-graphics/fixture/black-1x1.png differ diff --git a/addons/addon-kitty-graphics/fixture/rgb-3x1.png b/addons/addon-kitty-graphics/fixture/rgb-3x1.png new file mode 100644 index 0000000000..3f071895d5 Binary files /dev/null and b/addons/addon-kitty-graphics/fixture/rgb-3x1.png differ diff --git a/addons/addon-kitty-graphics/package.json b/addons/addon-kitty-graphics/package.json new file mode 100644 index 0000000000..5bb560e7e0 --- /dev/null +++ b/addons/addon-kitty-graphics/package.json @@ -0,0 +1,27 @@ +{ + "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 new file mode 100644 index 0000000000..fc521acec4 Binary files /dev/null and b/addons/addon-kitty-graphics/src/19616.jpg differ diff --git a/addons/addon-kitty-graphics/src/KittyApcHandler.ts b/addons/addon-kitty-graphics/src/KittyApcHandler.ts new file mode 100644 index 0000000000..b622556c18 --- /dev/null +++ b/addons/addon-kitty-graphics/src/KittyApcHandler.ts @@ -0,0 +1,618 @@ +/** + * 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 new file mode 100644 index 0000000000..eb72142613 --- /dev/null +++ b/addons/addon-kitty-graphics/src/KittyGraphicsAddon.test.ts @@ -0,0 +1,188 @@ +/** + * 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 new file mode 100644 index 0000000000..d46679ed9f --- /dev/null +++ b/addons/addon-kitty-graphics/src/KittyGraphicsAddon.ts @@ -0,0 +1,71 @@ +/** + * 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 new file mode 100644 index 0000000000..39f90de2ff --- /dev/null +++ b/addons/addon-kitty-graphics/src/KittyImageRenderer.ts @@ -0,0 +1,237 @@ +/** + * 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 new file mode 100644 index 0000000000..ac4011b35a --- /dev/null +++ b/addons/addon-kitty-graphics/src/Types.ts @@ -0,0 +1,11 @@ +/** + * 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 new file mode 100644 index 0000000000..570d4af57d --- /dev/null +++ b/addons/addon-kitty-graphics/src/tsconfig.json @@ -0,0 +1,27 @@ +{ + "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 new file mode 100644 index 0000000000..57b22f150c --- /dev/null +++ b/addons/addon-kitty-graphics/test/KittyGraphicsAddon.test.ts @@ -0,0 +1,269 @@ +/** + * 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 new file mode 100644 index 0000000000..22834be116 --- /dev/null +++ b/addons/addon-kitty-graphics/test/playwright.config.ts @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000000..8a7426703f --- /dev/null +++ b/addons/addon-kitty-graphics/test/tsconfig.json @@ -0,0 +1,30 @@ +{ + "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 new file mode 100644 index 0000000000..6531dac8b6 --- /dev/null +++ b/addons/addon-kitty-graphics/tsconfig.json @@ -0,0 +1,5 @@ +{ + "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 new file mode 100644 index 0000000000..32a137cb04 --- /dev/null +++ b/addons/addon-kitty-graphics/typings/addon-kitty-graphics.d.ts @@ -0,0 +1,89 @@ +/** + * 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 new file mode 100644 index 0000000000..89ed51dec0 --- /dev/null +++ b/addons/addon-kitty-graphics/webpack.config.js @@ -0,0 +1,44 @@ +/** + * 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 5e0a370cad..5c7e5afea9 100644 --- a/bin/esbuild.mjs +++ b/bin/esbuild.mjs @@ -137,6 +137,7 @@ 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/bin/test_integration.js b/bin/test_integration.js index 37e6aca7c5..caceaf9bec 100644 --- a/bin/test_integration.js +++ b/bin/test_integration.js @@ -25,6 +25,7 @@ const addons = [ 'clipboard', 'fit', 'image', + 'kitty-graphics', 'progress', 'search', 'serialize', diff --git a/demo/client/client.ts b/demo/client/client.ts index 1ed8366961..a2ccd40afa 100644 --- a/demo/client/client.ts +++ b/demo/client/client.ts @@ -29,6 +29,7 @@ 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'; @@ -47,6 +48,7 @@ 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; @@ -74,6 +76,7 @@ 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 }, @@ -196,6 +199,7 @@ 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; @@ -294,6 +298,7 @@ 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(); @@ -306,6 +311,7 @@ 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); @@ -321,7 +327,9 @@ function createTerminal(): Terminal { } const cols = size.cols; const rows = size.rows; - const url = '/terminals/' + pid + '/size?cols=' + cols + '&rows=' + rows; + const pixelWidth = Math.round(term.dimensions?.css?.canvas?.width ?? 0); + const pixelHeight = Math.round(term.dimensions?.css?.canvas?.height ?? 0); + const url = '/terminals/' + pid + '/size?cols=' + cols + '&rows=' + rows + '&pixelWidth=' + pixelWidth + '&pixelHeight=' + pixelHeight; fetch(url, { method: 'POST' }); }); @@ -372,7 +380,9 @@ function createTerminal(): Terminal { if (useRealTerminal instanceof HTMLInputElement && !useRealTerminal.checked) { runFakeTerminal(); } else { - const res = await fetch('/terminals?cols=' + term.cols + '&rows=' + term.rows, { method: 'POST' }); + const pixelWidth = Math.round(term.dimensions?.css?.canvas?.width ?? 0); + const pixelHeight = Math.round(term.dimensions?.css?.canvas?.height ?? 0); + const res = await fetch('/terminals?cols=' + term.cols + '&rows=' + term.rows + '&pixelWidth=' + pixelWidth + '&pixelHeight=' + pixelHeight, { method: 'POST' }); const processId = await res.text(); pid = processId; socketURL += processId; @@ -607,7 +617,7 @@ function updateTerminalSize(): void { function getBox(width: number, height: number): any { return { string: '+', - style: 'font-size: 1px; padding: ' + Math.floor(height/2) + 'px ' + Math.floor(width/2) + 'px; line-height: ' + height + 'px;' + style: 'font-size: 1px; padding: ' + Math.floor(height / 2) + 'px ' + Math.floor(width / 2) + 'px; line-height: ' + height + 'px;' }; } if (source instanceof HTMLCanvasElement) { diff --git a/demo/client/tsconfig.json b/demo/client/tsconfig.json index 17aae876b3..554272dec9 100644 --- a/demo/client/tsconfig.json +++ b/demo/client/tsconfig.json @@ -11,6 +11,7 @@ "@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"], diff --git a/demo/client/types.ts b/demo/client/types.ts index 4790fd4f9f..8cafdd5066 100644 --- a/demo/client/types.ts +++ b/demo/client/types.ts @@ -9,6 +9,7 @@ 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'; @@ -19,7 +20,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' | 'progress' | 'search' | 'serialize' | 'unicode11' | 'unicodeGraphemes' | 'webFonts' | 'webLinks' | 'webgl' | 'ligatures'; +export type AddonType = 'attach' | 'clipboard' | 'fit' | 'image' | 'kittyGraphics' | 'progress' | 'search' | 'serialize' | 'unicode11' | 'unicodeGraphemes' | 'webFonts' | 'webLinks' | 'webgl' | 'ligatures'; export interface IDemoAddon { name: T; @@ -29,32 +30,34 @@ export interface IDemoAddon { T extends 'clipboard' ? typeof ClipboardAddon : T extends 'fit' ? typeof FitAddon : T extends 'image' ? typeof ImageAddon : - 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 '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 ); instance?: ( T extends 'attach' ? AttachAddon : T extends 'clipboard' ? ClipboardAddon : T extends 'fit' ? FitAddon : T extends 'image' ? ImageAddon : - 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 '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 ); } diff --git a/demo/server/server.ts b/demo/server/server.ts index 0f4a79b3d1..ef98eb84c2 100644 --- a/demo/server/server.ts +++ b/demo/server/server.ts @@ -65,6 +65,8 @@ function startServer(): void { } const cols = parseInt(req.query.cols); const rows = parseInt(req.query.rows); + const pixelWidth = typeof req.query.pixelWidth === 'string' ? parseInt(req.query.pixelWidth) : 0; + const pixelHeight = typeof req.query.pixelHeight === 'string' ? parseInt(req.query.pixelHeight) : 0; const isWindows = process.platform === 'win32'; const term = pty.spawn(isWindows ? 'powershell.exe' : 'bash', [], { name: 'xterm-256color', @@ -77,7 +79,13 @@ function startServer(): void { useConptyDll: isWindows, }); - console.log('Created terminal with PID: ' + term.pid); + // Set pixel dimensions immediately after spawn (pty.spawn doesn't support them) + if (pixelWidth > 0 && pixelHeight > 0) { + term.resize(cols, rows, { width: pixelWidth, height: pixelHeight }); + console.log('Created terminal with PID: ' + term.pid + ' (' + cols + 'x' + rows + ', ' + pixelWidth + 'px x ' + pixelHeight + 'px)'); + } else { + console.log('Created terminal with PID: ' + term.pid); + } terminals[term.pid] = term; unsentOutput[term.pid] = ''; temporaryDisposable[term.pid] = term.onData(function(data) { @@ -95,10 +103,17 @@ function startServer(): void { const pid = parseInt(req.params.pid); const cols = parseInt(req.query.cols); const rows = parseInt(req.query.rows); + const pixelWidth = typeof req.query.pixelWidth === 'string' ? parseInt(req.query.pixelWidth) : 0; + const pixelHeight = typeof req.query.pixelHeight === 'string' ? parseInt(req.query.pixelHeight) : 0; const term = terminals[pid]; - term.resize(cols, rows); - console.log('Resized terminal ' + pid + ' to ' + cols + ' cols and ' + rows + ' rows.'); + if (pixelWidth > 0 && pixelHeight > 0) { + term.resize(cols, rows, { width: pixelWidth, height: pixelHeight }); + console.log('Resized terminal ' + pid + ' to ' + cols + ' cols, ' + rows + ' rows, ' + pixelWidth + 'px x ' + pixelHeight + 'px'); + } else { + term.resize(cols, rows); + console.log('Resized terminal ' + pid + ' to ' + cols + ' cols and ' + rows + ' rows.'); + } res.end(); }); diff --git a/package-lock.json b/package-lock.json index 4c218b7097..89fda195df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,6 +82,11 @@ "xterm-wasm-parts": "^0.1.0" } }, + "addons/addon-kitty-graphics": { + "name": "@xterm/addon-kitty-graphics", + "version": "0.1.0", + "license": "MIT" + }, "addons/addon-ligatures": { "name": "@xterm/addon-ligatures", "version": "0.10.0", @@ -2274,6 +2279,10 @@ "resolved": "addons/addon-image", "link": true }, + "node_modules/@xterm/addon-kitty-graphics": { + "resolved": "addons/addon-kitty-graphics", + "link": true + }, "node_modules/@xterm/addon-ligatures": { "resolved": "addons/addon-ligatures", "link": true diff --git a/tsconfig.all.json b/tsconfig.all.json index b0ed02799c..2f68273930 100644 --- a/tsconfig.all.json +++ b/tsconfig.all.json @@ -11,6 +11,7 @@ { "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" },