Support deletion

This commit is contained in:
Anthony Kim
2026-02-12 14:53:37 -08:00
parent 36118d9d4c
commit 457542b7b3
4 changed files with 210 additions and 33 deletions
@@ -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;
@@ -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);
});
});
});
@@ -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;
}
}
+112 -5
View File
@@ -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);
});