Skip to content

Commit 457542b

Browse files
committed
Support deletion
1 parent 36118d9 commit 457542b

4 files changed

Lines changed: 210 additions & 33 deletions

File tree

addons/addon-image/src/kitty/KittyGraphicsHandler.ts

Lines changed: 47 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -428,36 +428,56 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
428428
}
429429

430430
private _handleDelete(cmd: IKittyCommand): boolean {
431-
const id = cmd.id;
432-
433-
if (id !== undefined) {
434-
// Abort in-flight chunked upload for this specific image
435-
const pending = this._pendingTransmissions.get(id);
436-
if (pending) {
437-
pending.decoder.release();
438-
this._pendingTransmissions.delete(id);
439-
}
431+
// Per spec: default delete selector is 'a' (delete all visible placements)
432+
const selector = cmd.deleteSelector ?? 'a';
433+
434+
// TODO: Distinguish lowercase (delete placements only) from uppercase
435+
// (delete placements + free stored image data). Currently both variants
436+
// free everything since we don't separate stored data from placements.
437+
switch (selector) {
438+
case 'a':
439+
case 'A':
440+
// Delete all — also abort all in-flight uploads
441+
for (const pending of this._pendingTransmissions.values()) {
442+
pending.decoder.release();
443+
}
444+
this._pendingTransmissions.clear();
445+
this._deleteAll();
446+
break;
447+
case 'i':
448+
case 'I':
449+
// Delete by image ID — only abort the targeted upload
450+
if (cmd.id !== undefined) {
451+
const pending = this._pendingTransmissions.get(cmd.id);
452+
if (pending) {
453+
pending.decoder.release();
454+
this._pendingTransmissions.delete(cmd.id);
455+
}
456+
this._deleteById(cmd.id);
457+
}
458+
break;
459+
default:
460+
// Unsupported selectors (c, n, p, q, r, x, y, z, f) — ignore for now
461+
break;
462+
}
463+
return true;
464+
}
440465

441-
this._images.delete(id);
442-
const storageId = this._kittyIdToStorageId.get(id);
443-
if (storageId !== undefined) {
444-
this._storage.deleteImage(storageId);
445-
this._kittyIdToStorageId.delete(id);
446-
}
447-
} else {
448-
// Abort all in-flight chunked uploads
449-
for (const pending of this._pendingTransmissions.values()) {
450-
pending.decoder.release();
451-
}
452-
this._pendingTransmissions.clear();
466+
private _deleteById(id: number): void {
467+
this._images.delete(id);
468+
const storageId = this._kittyIdToStorageId.get(id);
469+
if (storageId !== undefined) {
470+
this._storage.deleteImage(storageId);
471+
this._kittyIdToStorageId.delete(id);
472+
}
473+
}
453474

454-
this._images.clear();
455-
for (const storageId of this._kittyIdToStorageId.values()) {
456-
this._storage.deleteImage(storageId);
457-
}
458-
this._kittyIdToStorageId.clear();
475+
private _deleteAll(): void {
476+
this._images.clear();
477+
for (const storageId of this._kittyIdToStorageId.values()) {
478+
this._storage.deleteImage(storageId);
459479
}
460-
return true;
480+
this._kittyIdToStorageId.clear();
461481
}
462482

463483
private _sendResponse(id: number, message: string, quiet: number): void {

addons/addon-image/src/kitty/KittyGraphicsTypes.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,5 +106,44 @@ describe('KittyGraphicsTypes', () => {
106106
const cmd = parseKittyCommand('a=T,f=100');
107107
assert.strictEqual(cmd.zIndex, undefined);
108108
});
109+
110+
it('should parse delete selector key', () => {
111+
const cmd = parseKittyCommand('a=d,d=i,i=5');
112+
assert.strictEqual(cmd.action, 'd');
113+
assert.strictEqual(cmd.deleteSelector, 'i');
114+
assert.strictEqual(cmd.id, 5);
115+
});
116+
117+
it('should parse uppercase delete selector', () => {
118+
const cmd = parseKittyCommand('a=d,d=A');
119+
assert.strictEqual(cmd.deleteSelector, 'A');
120+
});
121+
122+
it('should parse delete selector d=a (all)', () => {
123+
const cmd = parseKittyCommand('a=d,d=a');
124+
assert.strictEqual(cmd.deleteSelector, 'a');
125+
});
126+
127+
it('should leave deleteSelector undefined when not specified', () => {
128+
const cmd = parseKittyCommand('a=d,i=5');
129+
assert.strictEqual(cmd.deleteSelector, undefined);
130+
});
131+
132+
it('should parse placement id key', () => {
133+
const cmd = parseKittyCommand('a=d,d=i,i=5,p=3');
134+
assert.strictEqual(cmd.placementId, 3);
135+
assert.strictEqual(cmd.deleteSelector, 'i');
136+
assert.strictEqual(cmd.id, 5);
137+
});
138+
139+
it('should leave placementId undefined when not specified', () => {
140+
const cmd = parseKittyCommand('a=d,d=i,i=5');
141+
assert.strictEqual(cmd.placementId, undefined);
142+
});
143+
144+
it('should parse image number key', () => {
145+
const cmd = parseKittyCommand('a=t,f=100,I=42');
146+
assert.strictEqual(cmd.imageNumber, 42);
147+
});
109148
});
110149
});

addons/addon-image/src/kitty/KittyGraphicsTypes.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,11 @@ export const enum KittyKey {
7272
// Cursor movement policy (0=move cursor after image, 1=don't move cursor)
7373
CURSOR_MOVEMENT = 'C',
7474
// Z-index for image layering (negative = behind text, 0+ = on top)
75-
Z_INDEX = 'z'
75+
Z_INDEX = 'z',
76+
// Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) — only used when a=d
77+
DELETE_SELECTOR = 'd',
78+
// Placement ID for targeting specific placements
79+
PLACEMENT_ID = 'p'
7680
}
7781

7882
// Pixel format constants
@@ -98,6 +102,8 @@ export interface IKittyCommand {
98102
quiet?: number;
99103
cursorMovement?: number;
100104
zIndex?: number;
105+
deleteSelector?: string;
106+
placementId?: number;
101107
compression?: string;
102108
payload?: string;
103109
}
@@ -153,6 +159,10 @@ export function parseKittyCommand(data: string): IKittyCommand {
153159
cmd.compression = value;
154160
continue;
155161
}
162+
if (key === KittyKey.DELETE_SELECTOR) {
163+
cmd.deleteSelector = value;
164+
continue;
165+
}
156166
const numValue = parseInt(value);
157167
switch (key) {
158168
case KittyKey.FORMAT: cmd.format = numValue; break;
@@ -168,6 +178,7 @@ export function parseKittyCommand(data: string): IKittyCommand {
168178
case KittyKey.QUIET: cmd.quiet = numValue; break;
169179
case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;
170180
case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;
181+
case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;
171182
}
172183
}
173184

addons/addon-image/test/KittyGraphics.test.ts

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ test.describe('Kitty Graphics Protocol', () => {
8686
// TODO: Add tests for animation frames
8787
// TODO: Add performance tests for streaming large images
8888
// TODO: Implement cursor movement per Kitty spec - cursor should move by cols/rows after placement (unless C=1)
89+
// TODO: Distinguish lowercase delete selectors (placement only) from uppercase (placement + free data)
8990

9091
test.beforeEach(async ({}, testInfo) => {
9192
// DEBT: This test never worked on webkit
@@ -227,12 +228,12 @@ test.describe('Kitty Graphics Protocol', () => {
227228
});
228229

229230
test.describe('Delete commands', () => {
230-
test('delete command (a=d) removes specific image by id', async () => {
231+
test('delete command (a=d,d=i) removes specific image by id', async () => {
231232
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=10;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
232233
await timeout(50);
233234
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
234235

235-
await ctx.proxy.write(`\x1b_Ga=d,i=10\x1b\\`);
236+
await ctx.proxy.write(`\x1b_Ga=d,d=i,i=10\x1b\\`);
236237
await timeout(50);
237238
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0);
238239
});
@@ -256,7 +257,7 @@ test.describe('Kitty Graphics Protocol', () => {
256257
await timeout(50);
257258
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 1);
258259

259-
await ctx.proxy.write(`\x1b_Ga=d,i=50\x1b\\`);
260+
await ctx.proxy.write(`\x1b_Ga=d,d=i,i=50\x1b\\`);
260261
await timeout(50);
261262
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 0);
262263
});
@@ -270,7 +271,7 @@ test.describe('Kitty Graphics Protocol', () => {
270271
await timeout(50);
271272
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 2);
272273

273-
await ctx.proxy.write(`\x1b_Ga=d,i=55\x1b\\`);
274+
await ctx.proxy.write(`\x1b_Ga=d,d=i,i=55\x1b\\`);
274275
await timeout(50);
275276
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 1);
276277
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.has(56)`), true);
@@ -290,6 +291,112 @@ test.describe('Kitty Graphics Protocol', () => {
290291
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 0);
291292
});
292293

294+
test('d=i selector deletes specific image by id', async () => {
295+
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=80;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
296+
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=81;${KITTY_RGB_3X1_BASE64}\x1b\\`);
297+
await timeout(50);
298+
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 2);
299+
300+
await ctx.proxy.write(`\x1b_Ga=d,d=i,i=80\x1b\\`);
301+
await timeout(50);
302+
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
303+
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(81)`), true);
304+
});
305+
306+
test('d=I selector deletes specific image by id (uppercase)', async () => {
307+
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=82;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
308+
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=83;${KITTY_RGB_3X1_BASE64}\x1b\\`);
309+
await timeout(50);
310+
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 2);
311+
312+
await ctx.proxy.write(`\x1b_Ga=d,d=I,i=82\x1b\\`);
313+
await timeout(50);
314+
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
315+
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(83)`), true);
316+
});
317+
318+
test('d=a selector deletes all images', async () => {
319+
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=84;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
320+
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=85;${KITTY_RGB_3X1_BASE64}\x1b\\`);
321+
await timeout(50);
322+
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 2);
323+
324+
await ctx.proxy.write(`\x1b_Ga=d,d=a\x1b\\`);
325+
await timeout(50);
326+
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0);
327+
});
328+
329+
test('d=A selector deletes all images (uppercase)', async () => {
330+
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=86;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
331+
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=87;${KITTY_RGB_3X1_BASE64}\x1b\\`);
332+
await timeout(50);
333+
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 2);
334+
335+
await ctx.proxy.write(`\x1b_Ga=d,d=A\x1b\\`);
336+
await timeout(50);
337+
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0);
338+
});
339+
340+
test('d=a selector also removes displayed images from storage', async () => {
341+
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=88;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
342+
await timeout(100);
343+
strictEqual(await getImageStorageLength(), 1);
344+
345+
await ctx.proxy.write(`\x1b_Ga=d,d=a\x1b\\`);
346+
await timeout(50);
347+
strictEqual(await getImageStorageLength(), 0);
348+
});
349+
350+
test('d=i selector also removes displayed image from storage', async () => {
351+
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=89;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
352+
await timeout(100);
353+
strictEqual(await getImageStorageLength(), 1);
354+
355+
await ctx.proxy.write(`\x1b_Ga=d,d=i,i=89\x1b\\`);
356+
await timeout(50);
357+
strictEqual(await getImageStorageLength(), 0);
358+
});
359+
360+
test('d=i without id does nothing', async () => {
361+
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=90;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
362+
await timeout(50);
363+
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
364+
365+
await ctx.proxy.write(`\x1b_Ga=d,d=i\x1b\\`);
366+
await timeout(50);
367+
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
368+
});
369+
370+
test('d=i selector clears pixels from canvas', async () => {
371+
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=92,q=1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
372+
await timeout(100);
373+
deepStrictEqual(await getPixel(0, 0, 0, 0), [0, 0, 0, 255]);
374+
375+
await ctx.proxy.write(`\x1b_Ga=d,d=i,i=92\x1b\\`);
376+
await timeout(100);
377+
strictEqual(await getPixel(0, 0, 0, 0), null);
378+
});
379+
380+
test('d=a selector clears all pixels from canvas', async () => {
381+
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=93,q=1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
382+
await timeout(100);
383+
deepStrictEqual(await getPixel(0, 0, 0, 0), [0, 0, 0, 255]);
384+
385+
await ctx.proxy.write(`\x1b_Ga=d,d=a\x1b\\`);
386+
await timeout(100);
387+
strictEqual(await getPixel(0, 0, 0, 0), null);
388+
});
389+
390+
test('unsupported delete selector is ignored', async () => {
391+
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=91;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
392+
await timeout(50);
393+
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
394+
395+
await ctx.proxy.write(`\x1b_Ga=d,d=c\x1b\\`);
396+
await timeout(50);
397+
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
398+
});
399+
293400
test('chunks sent after delete are not assembled with previous data', async () => {
294401
const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
295402
const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
@@ -865,7 +972,7 @@ test.describe('Kitty Graphics Protocol', () => {
865972
await timeout(200);
866973
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(700)`), true);
867974

868-
await ctx.proxy.write(`\x1b_Ga=d,i=700\x1b\\`);
975+
await ctx.proxy.write(`\x1b_Ga=d,d=i,i=700\x1b\\`);
869976
await timeout(50);
870977
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(700)`), false);
871978
});

0 commit comments

Comments
 (0)