From 457542b7b391ceee277d358438eb25b873afcd3b Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 12 Feb 2026 14:53:37 -0800 Subject: [PATCH] Support deletion --- .../src/kitty/KittyGraphicsHandler.ts | 74 +++++++---- .../src/kitty/KittyGraphicsTypes.test.ts | 39 ++++++ .../src/kitty/KittyGraphicsTypes.ts | 13 +- addons/addon-image/test/KittyGraphics.test.ts | 117 +++++++++++++++++- 4 files changed, 210 insertions(+), 33 deletions(-) diff --git a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts index 12caf59d..674b92ef 100644 --- a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts +++ b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts @@ -428,38 +428,58 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { } private _handleDelete(cmd: IKittyCommand): boolean { - const id = cmd.id; + // Per spec: default delete selector is 'a' (delete all visible placements) + const selector = cmd.deleteSelector ?? 'a'; - if (id !== undefined) { - // Abort in-flight chunked upload for this specific image - const pending = this._pendingTransmissions.get(id); - if (pending) { - pending.decoder.release(); - this._pendingTransmissions.delete(id); - } - - this._images.delete(id); - const storageId = this._kittyIdToStorageId.get(id); - if (storageId !== undefined) { - this._storage.deleteImage(storageId); - this._kittyIdToStorageId.delete(id); - } - } else { - // Abort all in-flight chunked uploads - for (const pending of this._pendingTransmissions.values()) { - pending.decoder.release(); - } - this._pendingTransmissions.clear(); - - this._images.clear(); - for (const storageId of this._kittyIdToStorageId.values()) { - this._storage.deleteImage(storageId); - } - this._kittyIdToStorageId.clear(); + // TODO: Distinguish lowercase (delete placements only) from uppercase + // (delete placements + free stored image data). Currently both variants + // free everything since we don't separate stored data from placements. + switch (selector) { + case 'a': + case 'A': + // Delete all — also abort all in-flight uploads + for (const pending of this._pendingTransmissions.values()) { + pending.decoder.release(); + } + this._pendingTransmissions.clear(); + this._deleteAll(); + break; + case 'i': + case 'I': + // Delete by image ID — only abort the targeted upload + if (cmd.id !== undefined) { + const pending = this._pendingTransmissions.get(cmd.id); + if (pending) { + pending.decoder.release(); + this._pendingTransmissions.delete(cmd.id); + } + this._deleteById(cmd.id); + } + break; + default: + // Unsupported selectors (c, n, p, q, r, x, y, z, f) — ignore for now + break; } return true; } + private _deleteById(id: number): void { + this._images.delete(id); + const storageId = this._kittyIdToStorageId.get(id); + if (storageId !== undefined) { + this._storage.deleteImage(storageId); + this._kittyIdToStorageId.delete(id); + } + } + + private _deleteAll(): void { + this._images.clear(); + for (const storageId of this._kittyIdToStorageId.values()) { + this._storage.deleteImage(storageId); + } + this._kittyIdToStorageId.clear(); + } + private _sendResponse(id: number, message: string, quiet: number): void { const isOk = message === 'OK'; if (isOk && quiet === 1) return; diff --git a/addons/addon-image/src/kitty/KittyGraphicsTypes.test.ts b/addons/addon-image/src/kitty/KittyGraphicsTypes.test.ts index 04d95dd3..31d963f7 100644 --- a/addons/addon-image/src/kitty/KittyGraphicsTypes.test.ts +++ b/addons/addon-image/src/kitty/KittyGraphicsTypes.test.ts @@ -106,5 +106,44 @@ describe('KittyGraphicsTypes', () => { const cmd = parseKittyCommand('a=T,f=100'); assert.strictEqual(cmd.zIndex, undefined); }); + + it('should parse delete selector key', () => { + const cmd = parseKittyCommand('a=d,d=i,i=5'); + assert.strictEqual(cmd.action, 'd'); + assert.strictEqual(cmd.deleteSelector, 'i'); + assert.strictEqual(cmd.id, 5); + }); + + it('should parse uppercase delete selector', () => { + const cmd = parseKittyCommand('a=d,d=A'); + assert.strictEqual(cmd.deleteSelector, 'A'); + }); + + it('should parse delete selector d=a (all)', () => { + const cmd = parseKittyCommand('a=d,d=a'); + assert.strictEqual(cmd.deleteSelector, 'a'); + }); + + it('should leave deleteSelector undefined when not specified', () => { + const cmd = parseKittyCommand('a=d,i=5'); + assert.strictEqual(cmd.deleteSelector, undefined); + }); + + it('should parse placement id key', () => { + const cmd = parseKittyCommand('a=d,d=i,i=5,p=3'); + assert.strictEqual(cmd.placementId, 3); + assert.strictEqual(cmd.deleteSelector, 'i'); + assert.strictEqual(cmd.id, 5); + }); + + it('should leave placementId undefined when not specified', () => { + const cmd = parseKittyCommand('a=d,d=i,i=5'); + assert.strictEqual(cmd.placementId, undefined); + }); + + it('should parse image number key', () => { + const cmd = parseKittyCommand('a=t,f=100,I=42'); + assert.strictEqual(cmd.imageNumber, 42); + }); }); }); diff --git a/addons/addon-image/src/kitty/KittyGraphicsTypes.ts b/addons/addon-image/src/kitty/KittyGraphicsTypes.ts index b3f871fd..df4021f8 100644 --- a/addons/addon-image/src/kitty/KittyGraphicsTypes.ts +++ b/addons/addon-image/src/kitty/KittyGraphicsTypes.ts @@ -72,7 +72,11 @@ export const enum KittyKey { // Cursor movement policy (0=move cursor after image, 1=don't move cursor) CURSOR_MOVEMENT = 'C', // Z-index for image layering (negative = behind text, 0+ = on top) - Z_INDEX = 'z' + Z_INDEX = 'z', + // 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 + PLACEMENT_ID = 'p' } // Pixel format constants @@ -98,6 +102,8 @@ export interface IKittyCommand { quiet?: number; cursorMovement?: number; zIndex?: number; + deleteSelector?: string; + placementId?: number; compression?: string; payload?: string; } @@ -153,6 +159,10 @@ export function parseKittyCommand(data: string): IKittyCommand { cmd.compression = value; continue; } + if (key === KittyKey.DELETE_SELECTOR) { + cmd.deleteSelector = value; + continue; + } const numValue = parseInt(value); switch (key) { case KittyKey.FORMAT: cmd.format = numValue; break; @@ -168,6 +178,7 @@ export function parseKittyCommand(data: string): IKittyCommand { case KittyKey.QUIET: cmd.quiet = numValue; break; case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break; case KittyKey.Z_INDEX: cmd.zIndex = numValue; break; + case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break; } } diff --git a/addons/addon-image/test/KittyGraphics.test.ts b/addons/addon-image/test/KittyGraphics.test.ts index 5655660b..676eda0f 100644 --- a/addons/addon-image/test/KittyGraphics.test.ts +++ b/addons/addon-image/test/KittyGraphics.test.ts @@ -86,6 +86,7 @@ test.describe('Kitty Graphics Protocol', () => { // TODO: Add tests for animation frames // TODO: Add performance tests for streaming large images // TODO: Implement cursor movement per Kitty spec - cursor should move by cols/rows after placement (unless C=1) + // TODO: Distinguish lowercase delete selectors (placement only) from uppercase (placement + free data) test.beforeEach(async ({}, testInfo) => { // DEBT: This test never worked on webkit @@ -227,12 +228,12 @@ test.describe('Kitty Graphics Protocol', () => { }); test.describe('Delete commands', () => { - test('delete command (a=d) removes specific image by id', async () => { + test('delete command (a=d,d=i) removes specific image by id', async () => { await ctx.proxy.write(`\x1b_Ga=t,f=100,i=10;${KITTY_BLACK_1X1_BASE64}\x1b\\`); await timeout(50); strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1); - await ctx.proxy.write(`\x1b_Ga=d,i=10\x1b\\`); + await ctx.proxy.write(`\x1b_Ga=d,d=i,i=10\x1b\\`); await timeout(50); strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0); }); @@ -256,7 +257,7 @@ test.describe('Kitty Graphics Protocol', () => { await timeout(50); strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 1); - await ctx.proxy.write(`\x1b_Ga=d,i=50\x1b\\`); + await ctx.proxy.write(`\x1b_Ga=d,d=i,i=50\x1b\\`); await timeout(50); strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 0); }); @@ -270,7 +271,7 @@ test.describe('Kitty Graphics Protocol', () => { await timeout(50); strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 2); - await ctx.proxy.write(`\x1b_Ga=d,i=55\x1b\\`); + await ctx.proxy.write(`\x1b_Ga=d,d=i,i=55\x1b\\`); await timeout(50); strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 1); strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.has(56)`), true); @@ -290,6 +291,112 @@ test.describe('Kitty Graphics Protocol', () => { strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 0); }); + test('d=i selector deletes specific image by id', async () => { + await ctx.proxy.write(`\x1b_Ga=t,f=100,i=80;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await ctx.proxy.write(`\x1b_Ga=t,f=100,i=81;${KITTY_RGB_3X1_BASE64}\x1b\\`); + await timeout(50); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 2); + + await ctx.proxy.write(`\x1b_Ga=d,d=i,i=80\x1b\\`); + await timeout(50); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(81)`), true); + }); + + test('d=I selector deletes specific image by id (uppercase)', async () => { + await ctx.proxy.write(`\x1b_Ga=t,f=100,i=82;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await ctx.proxy.write(`\x1b_Ga=t,f=100,i=83;${KITTY_RGB_3X1_BASE64}\x1b\\`); + await timeout(50); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 2); + + await ctx.proxy.write(`\x1b_Ga=d,d=I,i=82\x1b\\`); + await timeout(50); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(83)`), true); + }); + + test('d=a selector deletes all images', async () => { + await ctx.proxy.write(`\x1b_Ga=t,f=100,i=84;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await ctx.proxy.write(`\x1b_Ga=t,f=100,i=85;${KITTY_RGB_3X1_BASE64}\x1b\\`); + await timeout(50); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 2); + + await ctx.proxy.write(`\x1b_Ga=d,d=a\x1b\\`); + await timeout(50); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0); + }); + + test('d=A selector deletes all images (uppercase)', async () => { + await ctx.proxy.write(`\x1b_Ga=t,f=100,i=86;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await ctx.proxy.write(`\x1b_Ga=t,f=100,i=87;${KITTY_RGB_3X1_BASE64}\x1b\\`); + await timeout(50); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 2); + + await ctx.proxy.write(`\x1b_Ga=d,d=A\x1b\\`); + await timeout(50); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0); + }); + + test('d=a selector also removes displayed images from storage', async () => { + await ctx.proxy.write(`\x1b_Ga=T,f=100,i=88;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await timeout(100); + strictEqual(await getImageStorageLength(), 1); + + await ctx.proxy.write(`\x1b_Ga=d,d=a\x1b\\`); + await timeout(50); + strictEqual(await getImageStorageLength(), 0); + }); + + test('d=i selector also removes displayed image from storage', async () => { + await ctx.proxy.write(`\x1b_Ga=T,f=100,i=89;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await timeout(100); + strictEqual(await getImageStorageLength(), 1); + + await ctx.proxy.write(`\x1b_Ga=d,d=i,i=89\x1b\\`); + await timeout(50); + strictEqual(await getImageStorageLength(), 0); + }); + + test('d=i without id does nothing', async () => { + await ctx.proxy.write(`\x1b_Ga=t,f=100,i=90;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await timeout(50); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1); + + await ctx.proxy.write(`\x1b_Ga=d,d=i\x1b\\`); + await timeout(50); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1); + }); + + test('d=i selector clears pixels from canvas', async () => { + await ctx.proxy.write(`\x1b_Ga=T,f=100,i=92,q=1;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await timeout(100); + deepStrictEqual(await getPixel(0, 0, 0, 0), [0, 0, 0, 255]); + + await ctx.proxy.write(`\x1b_Ga=d,d=i,i=92\x1b\\`); + await timeout(100); + strictEqual(await getPixel(0, 0, 0, 0), null); + }); + + test('d=a selector clears all pixels from canvas', async () => { + await ctx.proxy.write(`\x1b_Ga=T,f=100,i=93,q=1;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await timeout(100); + deepStrictEqual(await getPixel(0, 0, 0, 0), [0, 0, 0, 255]); + + await ctx.proxy.write(`\x1b_Ga=d,d=a\x1b\\`); + await timeout(100); + strictEqual(await getPixel(0, 0, 0, 0), null); + }); + + test('unsupported delete selector is ignored', async () => { + await ctx.proxy.write(`\x1b_Ga=t,f=100,i=91;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await timeout(50); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1); + + await ctx.proxy.write(`\x1b_Ga=d,d=c\x1b\\`); + await timeout(50); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1); + }); + test('chunks sent after delete are not assembled with previous data', async () => { const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2); const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half); @@ -865,7 +972,7 @@ test.describe('Kitty Graphics Protocol', () => { await timeout(200); strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(700)`), true); - await ctx.proxy.write(`\x1b_Ga=d,i=700\x1b\\`); + await ctx.proxy.write(`\x1b_Ga=d,d=i,i=700\x1b\\`); await timeout(50); strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(700)`), false); });