From 3c7f313c3480e0d7a3b9d6110ecd85bc75dfc367 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 12 Feb 2026 16:00:48 -0800 Subject: [PATCH] Dont send false query --- .../src/kitty/KittyGraphicsHandler.ts | 59 ++++- .../src/kitty/KittyGraphicsTypes.ts | 7 + addons/addon-image/test/KittyGraphics.test.ts | 233 ++++++++++++++++++ 3 files changed, 288 insertions(+), 11 deletions(-) diff --git a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts index 674b92ef..052898ed 100644 --- a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts +++ b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts @@ -309,6 +309,14 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0); return true; default: + // TODO: Implement remaining actions when needed: + // - a=p (placement): place a previously transmitted image + // - a=f (frame): animation frame operations + // - a=a (animation): animation control + // - a=c (compose): compose images + if (cmd.id !== undefined) { + this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0); + } return true; } } @@ -319,8 +327,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { switch (action) { case KittyAction.TRANSMIT: { const result = this._handleTransmit(cmd, bytes, decodeError); - if (cmd.id !== undefined && !decodeError && bytes.length > 0) { - this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0); + if (cmd.id !== undefined) { + if (decodeError) { + this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0); + } else if (bytes.length > 0) { + this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0); + } } return result; } @@ -329,6 +341,14 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { case KittyAction.QUERY: return this._handleQuery(cmd, bytes, decodeError); default: + // TODO: Implement remaining actions when needed: + // - a=p (placement): place a previously transmitted image + // - a=f (frame): animation frame operations + // - a=a (animation): animation control + // - a=c (compose): compose images + if (cmd.id !== undefined) { + this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0); + } return true; } } @@ -338,11 +358,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { // Currently only supports direct transmission (t=d, the default). // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path. // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes. - // - t=s Payload is base64-encoded POSIX shm name. Terminal reads from shared memory. + // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory. // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron: // 1. Check cmd.transmission (t key) before treating bytes as image data // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents // 3. For t=d: treat bytes as image data (current behavior) + // When implementing, also update _handleQuery to accept these transmission mediums. if (decodeError || bytes.length === 0) return true; @@ -360,7 +381,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { } private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise { - if (decodeError) return true; + if (decodeError) { + if (cmd.id !== undefined) { + this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0); + } + return true; + } const pendingKey = cmd.id ?? 0; @@ -375,12 +401,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { if (image) { const result = this._displayImage(image, cmd); if (cmd.id !== undefined) { - return (result as Promise).then(r => { - this._sendResponse(id, 'OK', cmd.quiet ?? 0); - return r; + return result.then(success => { + this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0); + return true; }); } - return result; + return result.then(() => true); } return true; } @@ -389,6 +415,15 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { const id = cmd.id ?? 0; const quiet = cmd.quiet ?? 0; + // Per spec: reject unsupported transmission mediums (only t=d is supported atm) + // TODO: When filesystem support is added (Node.js/Electron), update this to accept + // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries. + const transmission = cmd.transmission ?? 'd'; + if (transmission !== 'd') { + this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet); + return true; + } + // Check decode error first (invalid base64) if (decodeError) { this._sendResponse(id, 'EINVAL:invalid base64 data', quiet); @@ -491,10 +526,10 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { // Image display - private _displayImage(image: IKittyImageData, cmd: IKittyCommand): boolean | Promise { + private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise { return this._decodeAndDisplay(image, cmd) .then(() => true) - .catch(() => true); + .catch(() => false); } private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise { @@ -516,7 +551,9 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { h = Math.round(imgRows * ch); } - if (w * h > this._opts.pixelLimit) return; + if (w * h > this._opts.pixelLimit) { + throw new Error('image exceeds pixel limit'); + } // Save cursor position before addImage modifies it const buffer = this._coreTerminal._core.buffer; diff --git a/addons/addon-image/src/kitty/KittyGraphicsTypes.ts b/addons/addon-image/src/kitty/KittyGraphicsTypes.ts index df4021f8..cbba1a81 100644 --- a/addons/addon-image/src/kitty/KittyGraphicsTypes.ts +++ b/addons/addon-image/src/kitty/KittyGraphicsTypes.ts @@ -73,6 +73,8 @@ export const enum KittyKey { CURSOR_MOVEMENT = 'C', // Z-index for image layering (negative = behind text, 0+ = on top) Z_INDEX = 'z', + // Transmission medium (d=direct, f=file, t=temp file, s=shared memory) + TRANSMISSION = 't', // Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) — only used when a=d DELETE_SELECTOR = 'd', // Placement ID for targeting specific placements @@ -102,6 +104,7 @@ export interface IKittyCommand { quiet?: number; cursorMovement?: number; zIndex?: number; + transmission?: string; deleteSelector?: string; placementId?: number; compression?: string; @@ -159,6 +162,10 @@ export function parseKittyCommand(data: string): IKittyCommand { cmd.compression = value; continue; } + if (key === KittyKey.TRANSMISSION) { + cmd.transmission = value; + continue; + } if (key === KittyKey.DELETE_SELECTOR) { cmd.deleteSelector = value; continue; diff --git a/addons/addon-image/test/KittyGraphics.test.ts b/addons/addon-image/test/KittyGraphics.test.ts index 676eda0f..7df3d503 100644 --- a/addons/addon-image/test/KittyGraphics.test.ts +++ b/addons/addon-image/test/KittyGraphics.test.ts @@ -544,6 +544,239 @@ test.describe('Kitty Graphics Protocol', () => { }); }); + test.describe('Error responses for transmit and display', () => { + test('a=t sends EINVAL on decode error when id is specified', async () => { + 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=110,a=t,f=100;!!!invalid!!!\x1b\\'); + await timeout(100); + + const response = await ctx.page.evaluate('window.kittyResponse'); + strictEqual(response, '\x1b_Gi=110;EINVAL:invalid base64 data\x1b\\'); + }); + + test('a=t sends no response on decode error without id', 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_Ga=t,f=100;!!!invalid!!!\x1b\\'); + await timeout(100); + + strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false); + }); + + test('a=T sends EINVAL on decode error when id is specified', async () => { + 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=120,a=T,f=100;!!!invalid!!!\x1b\\'); + await timeout(100); + + const response = await ctx.page.evaluate('window.kittyResponse'); + strictEqual(response, '\x1b_Gi=120;EINVAL:invalid base64 data\x1b\\'); + }); + + test('a=T sends no response on decode error without id', 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_Ga=T,f=100;!!!invalid!!!\x1b\\'); + await timeout(100); + + strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false); + }); + + test('a=T sends EINVAL when raw pixel render fails (missing dimensions)', async () => { + 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=130,a=T,f=24;${RAW_RGB_1X1_BLACK}\x1b\\`); + await timeout(100); + + const response: string = await ctx.page.evaluate('window.kittyResponse'); + strictEqual(response.startsWith('\x1b_Gi=130;EINVAL:'), true); + }); + + test('a=T sends OK on successful render with id', async () => { + 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=140,a=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await timeout(100); + + const response = await ctx.page.evaluate('window.kittyResponse'); + strictEqual(response, '\x1b_Gi=140;OK\x1b\\'); + }); + + test('a=t sends OK on successful transmit with id', async () => { + 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=150,a=t,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await timeout(100); + + const response = await ctx.page.evaluate('window.kittyResponse'); + strictEqual(response, '\x1b_Gi=150;OK\x1b\\'); + }); + + test('a=t EINVAL suppressed by 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=160,a=t,q=2,f=100;!!!invalid!!!\x1b\\'); + await timeout(100); + + strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false); + }); + + test('a=T EINVAL suppressed by 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=170,a=T,q=2,f=100;!!!invalid!!!\x1b\\'); + await timeout(100); + + strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false); + }); + + test('a=t OK suppressed by 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=180,a=t,q=1,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await timeout(100); + + strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false); + }); + + test('a=T OK suppressed by 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=190,a=T,q=1,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await timeout(100); + + strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false); + }); + }); + + test.describe('Transmission medium rejection', () => { + test('query rejects t=f (file transmission)', async () => { + 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=200,a=q,t=f,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await timeout(100); + + const response: string = await ctx.page.evaluate('window.kittyResponse'); + strictEqual(response.startsWith('\x1b_Gi=200;EINVAL:'), true); + }); + + test('query rejects t=s (shared memory)', async () => { + 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=201,a=q,t=s,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await timeout(100); + + const response: string = await ctx.page.evaluate('window.kittyResponse'); + strictEqual(response.startsWith('\x1b_Gi=201;EINVAL:'), true); + }); + + test('query rejects t=t (temp file)', async () => { + 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=202,a=q,t=t,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await timeout(100); + + const response: string = await ctx.page.evaluate('window.kittyResponse'); + strictEqual(response.startsWith('\x1b_Gi=202;EINVAL:'), true); + }); + + test('query accepts t=d (direct transmission)', async () => { + 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=203,a=q,t=d,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await timeout(100); + + const response = await ctx.page.evaluate('window.kittyResponse'); + strictEqual(response, '\x1b_Gi=203;OK\x1b\\'); + }); + + test('query without t key defaults to direct (OK)', async () => { + 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=204,a=q,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await timeout(100); + + const response = await ctx.page.evaluate('window.kittyResponse'); + strictEqual(response, '\x1b_Gi=204;OK\x1b\\'); + }); + }); + + test.describe('Unimplemented action responses', () => { + test('a=p with id responds EINVAL', async () => { + 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=210,a=p\x1b\\`); + await timeout(100); + + const response: string = await ctx.page.evaluate('window.kittyResponse'); + strictEqual(response.startsWith('\x1b_Gi=210;EINVAL:'), true); + }); + + test('a=p without id sends no response', 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_Ga=p\x1b\\`); + await timeout(100); + + strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false); + }); + }); + test.describe('Cursor positioning', () => { // NOTE: Current tests document ACTUAL behavior (MVP - cursor doesn't move) // Per Kitty spec: cursor placed at first column after last image column,