diff --git a/addons/addon-kitty-graphics/src/KittyGraphicsAddon.ts b/addons/addon-kitty-graphics/src/KittyGraphicsAddon.ts index 69d0c95d..3cf76f24 100644 --- a/addons/addon-kitty-graphics/src/KittyGraphicsAddon.ts +++ b/addons/addon-kitty-graphics/src/KittyGraphicsAddon.ts @@ -6,6 +6,7 @@ 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'; /** * Kitty graphics protocol action types. @@ -80,7 +81,7 @@ export function parseKittyCommand(data: string): IKittyCommand { } export class KittyGraphicsAddon implements ITerminalAddon, IKittyGraphicsApi { - private _terminal: Terminal | undefined; + private _terminal: ITerminalExt | undefined; private _apcHandler: IDisposable | undefined; private _renderer: KittyImageRenderer | undefined; private _images: Map = new Map(); @@ -98,11 +99,11 @@ export class KittyGraphicsAddon implements ITerminalAddon, IKittyGraphicsApi { } public activate(terminal: Terminal): void { - this._terminal = terminal; + this._terminal = terminal as ITerminalExt; this._renderer = new KittyImageRenderer(terminal); if (this._debug) { - console.log('[KittyGraphicsAddon] Activating, registering APC handler for G (0x47)'); + console.log('[KittyGraphicsAddon] Registering APC handler for G (0x47)'); } // Register APC handler for 'G' (0x47) - Kitty graphics protocol @@ -314,12 +315,119 @@ export class KittyGraphicsAddon implements ITerminalAddon, IKittyGraphicsApi { } } - private _handleQuery(cmd: IKittyCommand): boolean { - // TODO: Respond with APC sequence indicating graphics support - // Protocol: terminal should reply with ESC _ G i=;OK ESC \ - if (this._debug) { - console.log('[KittyGraphicsAddon] Query received'); + /** + * Send a response back to the client via the terminal's data event. + * Per the Kitty protocol, responses are sent when an image id (i=) is specified. + * Format: ESC _ G i=;message ESC \ + * + * The quiet flag (q=) can suppress responses: + * - q=1: suppress OK responses + * - q=2: suppress error responses + * + * @param id - The image ID to include in the response + * @param message - The message (e.g., 'OK' or 'EINVAL:error description') + * @param quiet - The quiet flag value (0, 1, or 2) + */ + private _sendResponse(id: number, message: string, quiet: number): void { + // Check quiet flag: q=1 suppresses OK, q=2 suppresses errors + 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._core.coreService.triggerDataEvent(response); + + if (this._debug) { + console.log(`[KittyGraphicsAddon] Sent response: i=${id};${message}`); + } + } + + /** + * 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." + * + * The query is used by clients to check if the terminal supports the graphics + * protocol. A typical query looks like: + * ESC _ G i=31,s=1,v=1,a=q,t=d,f=24;AAAA ESC \ ESC [c + * + * If the terminal supports graphics, it responds with: + * ESC _ G i=31;OK ESC \ + * + * @param cmd - The parsed Kitty command + */ + private _handleQuery(cmd: IKittyCommand): boolean { + const id = cmd.id || 0; + const quiet = cmd.quiet || 0; + + if (this._debug) { + console.log(`[KittyGraphicsAddon] Query received, id=${id}, quiet=${quiet}`); + } + + // Try to validate the image data without storing it + const payload = cmd.payload || ''; + + if (!payload) { + // No payload - just checking if graphics protocol is supported + // Respond with OK to indicate support + this._sendResponse(id, 'OK', quiet); + return true; + } + + // Validate the image data can be decoded + try { + // Decode base64 to verify it's valid + 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 || 32; + + if (format === KittyFormat.PNG) { + // For PNG, we just verify base64 decoded successfully + // Full PNG validation would require async createImageBitmap + // which we can't do synchronously, so we trust the data is valid PNG + this._sendResponse(id, 'OK', quiet); + } else { + // RGB (24) or RGBA (32): verify we have enough bytes + 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 ? 4 : 3; + 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) { + // Base64 decode failed or other error + const errorMsg = e instanceof Error ? e.message : 'unknown error'; + this._sendResponse(id, `EINVAL:${errorMsg}`, quiet); + } + return true; } diff --git a/addons/addon-kitty-graphics/src/Types.ts b/addons/addon-kitty-graphics/src/Types.ts new file mode 100644 index 00000000..a2c502ec --- /dev/null +++ b/addons/addon-kitty-graphics/src/Types.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import type { Terminal } from '@xterm/xterm'; + +/** + * Core service interface for triggering data events. + * This is used to send responses back to the terminal client. + */ +export interface ICoreService { + triggerDataEvent(data: string, wasUserInput?: boolean): void; +} + +/** + * Core terminal interface exposing internal services. + */ +export interface ICoreTerminal { + coreService: ICoreService; +} + +/** + * Extended terminal interface that exposes the internal _core property. + * This is needed to send responses back to the client. + */ +export interface ITerminalExt extends Terminal { + _core: ICoreTerminal; +} diff --git a/addons/addon-kitty-graphics/test/KittyGraphicsAddon.test.ts b/addons/addon-kitty-graphics/test/KittyGraphicsAddon.test.ts index 307604f2..57b22f15 100644 --- a/addons/addon-kitty-graphics/test/KittyGraphicsAddon.test.ts +++ b/addons/addon-kitty-graphics/test/KittyGraphicsAddon.test.ts @@ -172,4 +172,98 @@ test.describe('KittyGraphicsAddon', () => { 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); + }); + }); });