@xterm/addon-image 0.10.0-beta.16 → 0.10.0-beta.161
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/lib/addon-image.js +1 -1
- package/lib/addon-image.js.map +1 -1
- package/lib/addon-image.mjs +1 -22
- package/lib/addon-image.mjs.map +4 -4
- package/package.json +5 -5
- package/src/IIPHandler.ts +32 -21
- package/src/IIPHeaderParser.ts +3 -2
- package/src/IIPImageStorage.ts +26 -0
- package/src/ImageAddon.ts +25 -5
- package/src/ImageRenderer.ts +88 -47
- package/src/ImageStorage.ts +126 -64
- package/src/SixelHandler.ts +3 -3
- package/src/SixelImageStorage.ts +50 -0
- package/src/Types.ts +8 -2
- package/src/kitty/KittyGraphicsHandler.ts +721 -0
- package/src/kitty/KittyGraphicsTypes.ts +177 -0
- package/src/kitty/KittyImageStorage.ts +134 -0
- package/typings/addon-image.d.ts +9 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
|
3
|
+
* @license MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { IDisposable } from '@xterm/xterm';
|
|
7
|
+
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';
|
|
8
|
+
import { ImageRenderer } from '../ImageRenderer';
|
|
9
|
+
import { CELL_SIZE_DEFAULT } from '../ImageStorage';
|
|
10
|
+
import { KittyImageStorage } from './KittyImageStorage';
|
|
11
|
+
import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
|
12
|
+
import {
|
|
13
|
+
KittyAction,
|
|
14
|
+
KittyFormat,
|
|
15
|
+
KittyCompression,
|
|
16
|
+
IKittyCommand,
|
|
17
|
+
IPendingTransmission,
|
|
18
|
+
IKittyImageData,
|
|
19
|
+
BYTES_PER_PIXEL_RGB,
|
|
20
|
+
BYTES_PER_PIXEL_RGBA,
|
|
21
|
+
ALPHA_OPAQUE,
|
|
22
|
+
parseKittyCommand
|
|
23
|
+
} from './KittyGraphicsTypes';
|
|
24
|
+
|
|
25
|
+
// Memory limit for base64 decoder (4MB, same as IIPHandler)
|
|
26
|
+
const DECODER_KEEP_DATA = 4194304;
|
|
27
|
+
const DECODER_INITIAL_DATA = 4194304; // 4MB
|
|
28
|
+
|
|
29
|
+
// Local mirror of const enum (esbuild can't inline const enums from external packages)
|
|
30
|
+
const DECODER_OK: DecodeStatus.OK = 0;
|
|
31
|
+
|
|
32
|
+
// Maximum control data size
|
|
33
|
+
const MAX_CONTROL_DATA_SIZE = 512;
|
|
34
|
+
|
|
35
|
+
// Semicolon codepoint
|
|
36
|
+
const SEMICOLON = 0x3B;
|
|
37
|
+
|
|
38
|
+
// Kitty graphics protocol handler with streaming base64 decoding.
|
|
39
|
+
export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {
|
|
40
|
+
private _aborted = false;
|
|
41
|
+
private _decodeError = false;
|
|
42
|
+
|
|
43
|
+
private _activeDecoder: Base64Decoder | null = null;
|
|
44
|
+
private readonly _maxEncodedBytes: number;
|
|
45
|
+
private readonly _initialEncodedBytes: number;
|
|
46
|
+
|
|
47
|
+
// Streaming related states
|
|
48
|
+
|
|
49
|
+
// True while receiving control data (before semicolon).
|
|
50
|
+
private _inControlData = true;
|
|
51
|
+
|
|
52
|
+
// Buffer for control data.
|
|
53
|
+
private _controlData = new Uint32Array(MAX_CONTROL_DATA_SIZE);
|
|
54
|
+
private _controlLength = 0;
|
|
55
|
+
|
|
56
|
+
// Pre-calculated encoded size limit
|
|
57
|
+
private _encodedSizeLimit = 0;
|
|
58
|
+
private _totalEncodedSize = 0;
|
|
59
|
+
|
|
60
|
+
// Parsed command. These are the control data before semicolon.
|
|
61
|
+
private _parsedCommand: IKittyCommand | null = null;
|
|
62
|
+
|
|
63
|
+
// Storage related states
|
|
64
|
+
|
|
65
|
+
private _pendingTransmissions: Map<number, IPendingTransmission> = new Map();
|
|
66
|
+
// Tracks the pending key of the most recently started chunked upload.
|
|
67
|
+
// Per spec, subsequent chunks only need m= (and optionally q=), without i=.
|
|
68
|
+
// When a chunk arrives with no i=, this key is used to find the pending upload.
|
|
69
|
+
private _lastPendingKey: number | undefined;
|
|
70
|
+
|
|
71
|
+
constructor(
|
|
72
|
+
private readonly _opts: IImageAddonOptions,
|
|
73
|
+
private readonly _renderer: ImageRenderer,
|
|
74
|
+
private readonly _kittyStorage: KittyImageStorage,
|
|
75
|
+
private readonly _coreTerminal: ITerminalExt
|
|
76
|
+
) {
|
|
77
|
+
// Convert decoded size limit -> max encoded bytes.
|
|
78
|
+
this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);
|
|
79
|
+
// ensure we preallocate more than configured limit while using 4mb initial size.
|
|
80
|
+
this._initialEncodedBytes = Math.min(DECODER_INITIAL_DATA, this._maxEncodedBytes);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public reset(): void {
|
|
84
|
+
this._cleanupAllPending();
|
|
85
|
+
if (this._activeDecoder) {
|
|
86
|
+
this._activeDecoder.release();
|
|
87
|
+
this._activeDecoder = null;
|
|
88
|
+
}
|
|
89
|
+
this._kittyStorage.reset();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
public dispose(): void {
|
|
93
|
+
this.reset();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private _removePendingEntry(key: number): void {
|
|
97
|
+
this._pendingTransmissions.delete(key);
|
|
98
|
+
if (this._lastPendingKey === key) {
|
|
99
|
+
this._lastPendingKey = undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private _cleanupAllPending(): void {
|
|
104
|
+
for (const pending of this._pendingTransmissions.values()) {
|
|
105
|
+
pending.decoder.release();
|
|
106
|
+
}
|
|
107
|
+
this._pendingTransmissions.clear();
|
|
108
|
+
this._lastPendingKey = undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
public start(): void {
|
|
112
|
+
this._aborted = false;
|
|
113
|
+
this._decodeError = false;
|
|
114
|
+
this._inControlData = true;
|
|
115
|
+
this._controlLength = 0;
|
|
116
|
+
this._parsedCommand = null;
|
|
117
|
+
// Pre-calculate encoded limit once: base64 is 4 bytes encoded → 3 bytes decoded
|
|
118
|
+
this._encodedSizeLimit = this._maxEncodedBytes;
|
|
119
|
+
this._totalEncodedSize = 0;
|
|
120
|
+
this._activeDecoder = null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
public put(data: Uint32Array, start: number, end: number): void {
|
|
124
|
+
if (this._aborted) return;
|
|
125
|
+
|
|
126
|
+
if (!this._inControlData) {
|
|
127
|
+
this._streamPayload(data, start, end);
|
|
128
|
+
} else {
|
|
129
|
+
// Scan for semicolon
|
|
130
|
+
let controlEnd = end;
|
|
131
|
+
for (let i = start; i < end; i++) {
|
|
132
|
+
if (data[i] === SEMICOLON) {
|
|
133
|
+
this._inControlData = false;
|
|
134
|
+
controlEnd = i;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Copy control data
|
|
140
|
+
const copyLength = controlEnd - start;
|
|
141
|
+
if (this._controlLength + copyLength > MAX_CONTROL_DATA_SIZE) {
|
|
142
|
+
this._aborted = true;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
this._controlData.set(data.subarray(start, controlEnd), this._controlLength);
|
|
146
|
+
this._controlLength += copyLength;
|
|
147
|
+
|
|
148
|
+
if (!this._inControlData) {
|
|
149
|
+
// Found semicolon - parse control data early for validation
|
|
150
|
+
this._parsedCommand = parseKittyCommand(this._parseControlDataString());
|
|
151
|
+
|
|
152
|
+
// Early validation: i+I conflict
|
|
153
|
+
if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {
|
|
154
|
+
this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);
|
|
155
|
+
this._aborted = true;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Delete action doesn't need payload - skip streaming
|
|
160
|
+
if (this._parsedCommand.action === KittyAction.DELETE) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Stream remaining as payload
|
|
165
|
+
const payloadStart = controlEnd + 1;
|
|
166
|
+
if (payloadStart < end) {
|
|
167
|
+
this._streamPayload(data, payloadStart, end);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Stream payload bytes into the base64 decoder.
|
|
174
|
+
private _streamPayload(data: Uint32Array, start: number, end: number): void {
|
|
175
|
+
if (this._aborted) return;
|
|
176
|
+
|
|
177
|
+
// Check size limit (compare encoded bytes against pre-calculated limit)
|
|
178
|
+
// Include cumulative size from pending transmission for multi-chunk images.
|
|
179
|
+
// Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.
|
|
180
|
+
const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;
|
|
181
|
+
const pending = this._pendingTransmissions.get(pendingKey);
|
|
182
|
+
const previousEncodedSize = pending?.totalEncodedSize ?? 0;
|
|
183
|
+
this._totalEncodedSize += end - start;
|
|
184
|
+
const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;
|
|
185
|
+
if (cumulativeEncodedSize > this._encodedSizeLimit) {
|
|
186
|
+
const decoderToRelease = this._activeDecoder ?? pending?.decoder;
|
|
187
|
+
if (decoderToRelease) {
|
|
188
|
+
decoderToRelease.release();
|
|
189
|
+
}
|
|
190
|
+
this._activeDecoder = null;
|
|
191
|
+
if (pending) {
|
|
192
|
+
this._removePendingEntry(pendingKey);
|
|
193
|
+
}
|
|
194
|
+
this._aborted = true;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (this._decodeError) return;
|
|
199
|
+
|
|
200
|
+
if (pending?.decoder && !this._activeDecoder) {
|
|
201
|
+
this._activeDecoder = pending.decoder;
|
|
202
|
+
}
|
|
203
|
+
if (!this._activeDecoder) {
|
|
204
|
+
this._activeDecoder = new Base64Decoder(DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
|
|
205
|
+
this._activeDecoder.init();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {
|
|
209
|
+
this._activeDecoder.release();
|
|
210
|
+
this._activeDecoder = null;
|
|
211
|
+
this._decodeError = true;
|
|
212
|
+
if (pending) {
|
|
213
|
+
this._removePendingEntry(pendingKey);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
public end(success: boolean): boolean | Promise<boolean> {
|
|
219
|
+
if (this._aborted || !success) {
|
|
220
|
+
if (this._activeDecoder) {
|
|
221
|
+
this._activeDecoder.release();
|
|
222
|
+
this._activeDecoder = null;
|
|
223
|
+
}
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// No semicolon = no payload (delete, capability query)
|
|
228
|
+
if (this._inControlData) {
|
|
229
|
+
return this._handleNoPayloadCommand();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Use command parsed early in put() - i+I already validated there
|
|
233
|
+
const cmd = this._parsedCommand!;
|
|
234
|
+
|
|
235
|
+
// Delete action was handled by skipping payload - just execute
|
|
236
|
+
if (cmd.action === KittyAction.DELETE) {
|
|
237
|
+
return this._handleDelete(cmd);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.
|
|
241
|
+
const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;
|
|
242
|
+
const isMoreComing = cmd.more === 1;
|
|
243
|
+
const pending = this._pendingTransmissions.get(pendingKey);
|
|
244
|
+
|
|
245
|
+
if (isMoreComing) {
|
|
246
|
+
if (this._activeDecoder) {
|
|
247
|
+
if (pending) {
|
|
248
|
+
pending.totalEncodedSize += this._totalEncodedSize;
|
|
249
|
+
pending.decodeError = pending.decodeError || this._decodeError;
|
|
250
|
+
} else {
|
|
251
|
+
this._pendingTransmissions.set(pendingKey, {
|
|
252
|
+
cmd: { ...cmd },
|
|
253
|
+
decoder: this._activeDecoder,
|
|
254
|
+
totalEncodedSize: this._totalEncodedSize,
|
|
255
|
+
decodeError: this._decodeError
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
this._lastPendingKey = pendingKey;
|
|
259
|
+
this._activeDecoder = null;
|
|
260
|
+
}
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Final chunk received — clear the last pending key
|
|
265
|
+
if (pending) {
|
|
266
|
+
this._lastPendingKey = undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let decodeError = this._decodeError;
|
|
270
|
+
let finalCmd = cmd;
|
|
271
|
+
let decoder = this._activeDecoder;
|
|
272
|
+
|
|
273
|
+
if (pending) {
|
|
274
|
+
finalCmd = pending.cmd;
|
|
275
|
+
decoder = pending.decoder;
|
|
276
|
+
decodeError = decodeError || pending.decodeError;
|
|
277
|
+
this._pendingTransmissions.delete(pendingKey);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let imageBytes = new Uint8Array(0);
|
|
281
|
+
if (decoder) {
|
|
282
|
+
if (decoder.end() !== DECODER_OK) {
|
|
283
|
+
decodeError = true;
|
|
284
|
+
}
|
|
285
|
+
imageBytes = decoder.data8;
|
|
286
|
+
}
|
|
287
|
+
this._activeDecoder = null;
|
|
288
|
+
|
|
289
|
+
// Handle command first — handlers create Blob/ImageData from imageBytes,
|
|
290
|
+
// which copies the data. Only then is it safe to release the decoder's
|
|
291
|
+
// wasm memory that imageBytes points into.
|
|
292
|
+
const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);
|
|
293
|
+
if (decoder) {
|
|
294
|
+
decoder.release();
|
|
295
|
+
}
|
|
296
|
+
return result;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Command handling
|
|
300
|
+
|
|
301
|
+
private _parseControlDataString(): string {
|
|
302
|
+
let str = '';
|
|
303
|
+
for (let i = 0; i < this._controlLength; i++) {
|
|
304
|
+
str += String.fromCodePoint(this._controlData[i]);
|
|
305
|
+
}
|
|
306
|
+
return str;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private _handleNoPayloadCommand(): boolean | Promise<boolean> {
|
|
310
|
+
const cmd = parseKittyCommand(this._parseControlDataString());
|
|
311
|
+
|
|
312
|
+
// Per spec: specifying both i and I is an error
|
|
313
|
+
if (cmd.id !== undefined && cmd.imageNumber !== undefined) {
|
|
314
|
+
this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const action = cmd.action ?? 't';
|
|
319
|
+
|
|
320
|
+
switch (action) {
|
|
321
|
+
case KittyAction.DELETE:
|
|
322
|
+
return this._handleDelete(cmd);
|
|
323
|
+
case KittyAction.QUERY:
|
|
324
|
+
this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);
|
|
325
|
+
return true;
|
|
326
|
+
default:
|
|
327
|
+
// TODO: Implement remaining actions when needed:
|
|
328
|
+
// - a=p (placement): place a previously transmitted image
|
|
329
|
+
// - a=f (frame): animation frame operations
|
|
330
|
+
// - a=a (animation): animation control
|
|
331
|
+
// - a=c (compose): compose images
|
|
332
|
+
if (cmd.id !== undefined) {
|
|
333
|
+
this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);
|
|
334
|
+
}
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
|
|
340
|
+
const action = cmd.action ?? 't';
|
|
341
|
+
|
|
342
|
+
switch (action) {
|
|
343
|
+
case KittyAction.TRANSMIT: {
|
|
344
|
+
const result = this._handleTransmit(cmd, bytes, decodeError);
|
|
345
|
+
// Only send response when _handleTransmit didn't already respond
|
|
346
|
+
// (it handles unsupported transmission medium responses internally)
|
|
347
|
+
if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {
|
|
348
|
+
if (decodeError) {
|
|
349
|
+
this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);
|
|
350
|
+
} else if (bytes.length > 0) {
|
|
351
|
+
this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return result;
|
|
355
|
+
}
|
|
356
|
+
case KittyAction.TRANSMIT_DISPLAY:
|
|
357
|
+
return this._handleTransmitDisplay(cmd, bytes, decodeError);
|
|
358
|
+
case KittyAction.QUERY:
|
|
359
|
+
return this._handleQuery(cmd, bytes, decodeError);
|
|
360
|
+
default:
|
|
361
|
+
// TODO: Implement remaining actions when needed:
|
|
362
|
+
// - a=p (placement): place a previously transmitted image
|
|
363
|
+
// - a=f (frame): animation frame operations
|
|
364
|
+
// - a=a (animation): animation control
|
|
365
|
+
// - a=c (compose): compose images
|
|
366
|
+
if (cmd.id !== undefined) {
|
|
367
|
+
this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);
|
|
368
|
+
}
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
|
|
374
|
+
// TODO: Support file-based transmission modes (t=f, t=t, t=s)
|
|
375
|
+
// Currently only supports direct transmission (t=d, the default).
|
|
376
|
+
// - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.
|
|
377
|
+
// - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.
|
|
378
|
+
// - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.
|
|
379
|
+
// These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:
|
|
380
|
+
// 1. Check cmd.transmission (t key) before treating bytes as image data
|
|
381
|
+
// 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents
|
|
382
|
+
// 3. For t=d: treat bytes as image data (current behavior)
|
|
383
|
+
// When implementing, also update _handleQuery to accept these transmission mediums.
|
|
384
|
+
const transmission = cmd.transmission ?? 'd';
|
|
385
|
+
if (transmission !== 'd') {
|
|
386
|
+
if (cmd.id !== undefined) {
|
|
387
|
+
this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);
|
|
388
|
+
}
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (decodeError || bytes.length === 0) return true;
|
|
393
|
+
|
|
394
|
+
this._kittyStorage.storeImage(cmd.id, {
|
|
395
|
+
data: new Blob([bytes as BlobPart]),
|
|
396
|
+
width: cmd.width ?? 0,
|
|
397
|
+
height: cmd.height ?? 0,
|
|
398
|
+
format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,
|
|
399
|
+
compression: cmd.compression ?? ''
|
|
400
|
+
});
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
|
|
405
|
+
if (decodeError) {
|
|
406
|
+
if (cmd.id !== undefined) {
|
|
407
|
+
this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);
|
|
408
|
+
}
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
this._handleTransmit(cmd, bytes, decodeError);
|
|
413
|
+
|
|
414
|
+
const id = cmd.id ?? this._kittyStorage.lastImageId;
|
|
415
|
+
const image = this._kittyStorage.getImage(id);
|
|
416
|
+
if (image) {
|
|
417
|
+
const result = this._displayImage(image, cmd);
|
|
418
|
+
if (cmd.id !== undefined) {
|
|
419
|
+
return result.then(success => {
|
|
420
|
+
this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);
|
|
421
|
+
return true;
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
return result.then(() => true);
|
|
425
|
+
}
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
|
|
430
|
+
const id = cmd.id ?? 0;
|
|
431
|
+
const quiet = cmd.quiet ?? 0;
|
|
432
|
+
|
|
433
|
+
// Per spec: reject unsupported transmission mediums (only t=d is supported atm)
|
|
434
|
+
// TODO: When filesystem support is added (Node.js/Electron), update this to accept
|
|
435
|
+
// t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.
|
|
436
|
+
const transmission = cmd.transmission ?? 'd';
|
|
437
|
+
if (transmission !== 'd') {
|
|
438
|
+
this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Check decode error first (invalid base64)
|
|
443
|
+
if (decodeError) {
|
|
444
|
+
this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Capability query (no payload) - just respond OK
|
|
449
|
+
if (bytes.length === 0) {
|
|
450
|
+
this._sendResponse(id, 'OK', quiet);
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const format = cmd.format ?? KittyFormat.RGBA;
|
|
455
|
+
|
|
456
|
+
if (format === KittyFormat.PNG) {
|
|
457
|
+
this._sendResponse(id, 'OK', quiet);
|
|
458
|
+
} else {
|
|
459
|
+
const width = cmd.width ?? 0;
|
|
460
|
+
const height = cmd.height ?? 0;
|
|
461
|
+
|
|
462
|
+
if (!width || !height) {
|
|
463
|
+
this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const bytesPerPixel = format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
|
|
468
|
+
const expectedBytes = width * height * bytesPerPixel;
|
|
469
|
+
|
|
470
|
+
if (bytes.length < expectedBytes) {
|
|
471
|
+
this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
this._sendResponse(id, 'OK', quiet);
|
|
476
|
+
}
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private _handleDelete(cmd: IKittyCommand): boolean {
|
|
481
|
+
// Per spec: default delete selector is 'a' (delete all visible placements)
|
|
482
|
+
const selector = cmd.deleteSelector ?? 'a';
|
|
483
|
+
|
|
484
|
+
// TODO: Distinguish lowercase (delete placements only) from uppercase
|
|
485
|
+
// (delete placements + free stored image data). Currently both variants
|
|
486
|
+
// free everything since we don't separate stored data from placements.
|
|
487
|
+
switch (selector) {
|
|
488
|
+
case 'a':
|
|
489
|
+
case 'A':
|
|
490
|
+
this._cleanupAllPending();
|
|
491
|
+
this._kittyStorage.deleteAll();
|
|
492
|
+
break;
|
|
493
|
+
case 'i':
|
|
494
|
+
case 'I':
|
|
495
|
+
if (cmd.id !== undefined) {
|
|
496
|
+
const pending = this._pendingTransmissions.get(cmd.id);
|
|
497
|
+
if (pending) {
|
|
498
|
+
pending.decoder.release();
|
|
499
|
+
}
|
|
500
|
+
this._removePendingEntry(cmd.id);
|
|
501
|
+
this._kittyStorage.deleteById(cmd.id);
|
|
502
|
+
}
|
|
503
|
+
break;
|
|
504
|
+
default:
|
|
505
|
+
// Unsupported selectors (c, n, p, q, r, x, y, z, f) — ignore for now
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private _sendResponse(id: number, message: string, quiet: number): void {
|
|
512
|
+
const isOk = message === 'OK';
|
|
513
|
+
if (isOk && quiet === 1) return;
|
|
514
|
+
if (!isOk && quiet === 2) return;
|
|
515
|
+
|
|
516
|
+
const response = `\x1b_Gi=${id};${message}\x1b\\`;
|
|
517
|
+
this._coreTerminal._core.coreService.triggerDataEvent(response);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Image display
|
|
521
|
+
|
|
522
|
+
private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise<boolean> {
|
|
523
|
+
return this._decodeAndDisplay(image, cmd)
|
|
524
|
+
.then(() => true)
|
|
525
|
+
.catch(() => false);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
|
|
529
|
+
const bitmap = await this._createBitmap(image);
|
|
530
|
+
|
|
531
|
+
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
|
|
532
|
+
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
|
|
533
|
+
|
|
534
|
+
// Per spec: c/r default to image's natural cell dimensions
|
|
535
|
+
const imgCols = cmd.columns ?? Math.ceil(bitmap.width / cw);
|
|
536
|
+
const imgRows = cmd.rows ?? Math.ceil(bitmap.height / ch);
|
|
537
|
+
|
|
538
|
+
let w = bitmap.width;
|
|
539
|
+
let h = bitmap.height;
|
|
540
|
+
|
|
541
|
+
// Scale bitmap to fit placement rectangle when c/r are specified
|
|
542
|
+
if (cmd.columns !== undefined || cmd.rows !== undefined) {
|
|
543
|
+
w = Math.round(imgCols * cw);
|
|
544
|
+
h = Math.round(imgRows * ch);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (w * h > this._opts.pixelLimit) {
|
|
548
|
+
throw new Error('image exceeds pixel limit');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Save cursor position before addImage modifies it
|
|
552
|
+
const buffer = this._coreTerminal._core.buffer;
|
|
553
|
+
const savedX = buffer.x;
|
|
554
|
+
const savedY = buffer.y;
|
|
555
|
+
const savedYbase = buffer.ybase;
|
|
556
|
+
|
|
557
|
+
// Determine layer based on z-index: negative = behind text, 0+ = on top.
|
|
558
|
+
// When z<0 we always use the bottom layer even without allowTransparency —
|
|
559
|
+
// the image will simply be hidden behind the opaque text background, which
|
|
560
|
+
// is the correct behavior (client asked for "behind text").
|
|
561
|
+
const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;
|
|
562
|
+
const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';
|
|
563
|
+
|
|
564
|
+
const zIndex = cmd.zIndex ?? 0;
|
|
565
|
+
if (w !== bitmap.width || h !== bitmap.height) {
|
|
566
|
+
const resized = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
|
|
567
|
+
bitmap.close();
|
|
568
|
+
this._kittyStorage.addImage(image.id, resized, true, layer, zIndex);
|
|
569
|
+
} else {
|
|
570
|
+
this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Kitty cursor movement
|
|
574
|
+
// Per spec: cursor placed at first column after last image column,
|
|
575
|
+
// on the last row of the image. C=1 means don't move cursor.
|
|
576
|
+
if (cmd.cursorMovement === 1) {
|
|
577
|
+
// C=1: restore cursor to position before image was placed
|
|
578
|
+
const scrolled = buffer.ybase - savedYbase;
|
|
579
|
+
buffer.x = savedX;
|
|
580
|
+
// Can't restore cursor to scrollback?
|
|
581
|
+
buffer.y = Math.max(savedY - scrolled, 0);
|
|
582
|
+
} else {
|
|
583
|
+
// Default (C=0): advance cursor horizontally past the image
|
|
584
|
+
// addImage already positioned cursor on the last row via lineFeeds
|
|
585
|
+
buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Create ImageBitmap from already-decoded image data.
|
|
590
|
+
private async _createBitmap(image: IKittyImageData): Promise<ImageBitmap> {
|
|
591
|
+
let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());
|
|
592
|
+
|
|
593
|
+
if (image.compression === KittyCompression.ZLIB) {
|
|
594
|
+
bytes = await this._decompressZlib(bytes);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (image.format === KittyFormat.PNG) {
|
|
598
|
+
const blob = new Blob([bytes as BlobPart], { type: 'image/png' });
|
|
599
|
+
if (!window.createImageBitmap) {
|
|
600
|
+
const url = URL.createObjectURL(blob);
|
|
601
|
+
const img = new Image();
|
|
602
|
+
return new Promise<ImageBitmap>((resolve, reject) => {
|
|
603
|
+
img.addEventListener('load', () => {
|
|
604
|
+
URL.revokeObjectURL(url);
|
|
605
|
+
const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);
|
|
606
|
+
canvas.getContext('2d')?.drawImage(img, 0, 0);
|
|
607
|
+
createImageBitmap(canvas).then(resolve).catch(reject);
|
|
608
|
+
});
|
|
609
|
+
img.addEventListener('error', () => {
|
|
610
|
+
URL.revokeObjectURL(url);
|
|
611
|
+
reject(new Error('Failed to load image'));
|
|
612
|
+
});
|
|
613
|
+
img.src = url;
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
return createImageBitmap(blob);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Raw pixel data
|
|
620
|
+
const width = image.width;
|
|
621
|
+
const height = image.height;
|
|
622
|
+
|
|
623
|
+
if (!width || !height) {
|
|
624
|
+
throw new Error('Width and height required for raw pixel data');
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const bytesPerPixel = image.format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
|
|
628
|
+
const expectedBytes = width * height * bytesPerPixel;
|
|
629
|
+
|
|
630
|
+
if (bytes.length < expectedBytes) {
|
|
631
|
+
throw new Error('Insufficient pixel data');
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const pixelCount = width * height;
|
|
635
|
+
|
|
636
|
+
if (image.format === KittyFormat.RGBA) {
|
|
637
|
+
// RGBA: use bytes directly — no copy needed
|
|
638
|
+
return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * BYTES_PER_PIXEL_RGBA), width, height));
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).
|
|
642
|
+
// 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.
|
|
643
|
+
// Assumes little-endian (all modern browsers/Node.js).
|
|
644
|
+
const data = new Uint8ClampedArray(pixelCount * BYTES_PER_PIXEL_RGBA);
|
|
645
|
+
const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
|
|
646
|
+
const dst32 = new Uint32Array(data.buffer);
|
|
647
|
+
const alignedPixels = pixelCount & ~3; // round down to multiple of 4
|
|
648
|
+
|
|
649
|
+
let srcOffset = 0;
|
|
650
|
+
let dstOffset = 0;
|
|
651
|
+
for (let i = 0; i < alignedPixels; i += 4) {
|
|
652
|
+
const b0 = src32[srcOffset++];
|
|
653
|
+
const b1 = src32[srcOffset++];
|
|
654
|
+
const b2 = src32[srcOffset++];
|
|
655
|
+
// Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout
|
|
656
|
+
dst32[dstOffset++] = (b0 & 0x00FFFFFF) | 0xFF000000;
|
|
657
|
+
dst32[dstOffset++] = ((b0 >>> 24) | (b1 << 8)) & 0x00FFFFFF | 0xFF000000;
|
|
658
|
+
dst32[dstOffset++] = ((b1 >>> 16) | (b2 << 16)) & 0x00FFFFFF | 0xFF000000;
|
|
659
|
+
dst32[dstOffset++] = (b2 >>> 8) | 0xFF000000;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Handle remaining 1–3 pixels
|
|
663
|
+
let srcByte = alignedPixels * BYTES_PER_PIXEL_RGB;
|
|
664
|
+
let dstByte = alignedPixels * BYTES_PER_PIXEL_RGBA;
|
|
665
|
+
for (let i = alignedPixels; i < pixelCount; i++) {
|
|
666
|
+
data[dstByte] = bytes[srcByte];
|
|
667
|
+
data[dstByte + 1] = bytes[srcByte + 1];
|
|
668
|
+
data[dstByte + 2] = bytes[srcByte + 2];
|
|
669
|
+
data[dstByte + 3] = ALPHA_OPAQUE;
|
|
670
|
+
srcByte += BYTES_PER_PIXEL_RGB;
|
|
671
|
+
dstByte += BYTES_PER_PIXEL_RGBA;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return createImageBitmap(new ImageData(data, width, height));
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private async _decompressZlib(compressed: Uint8Array): Promise<Uint8Array> {
|
|
678
|
+
try {
|
|
679
|
+
return await this._decompress(compressed, 'deflate');
|
|
680
|
+
} catch {
|
|
681
|
+
return await this._decompress(compressed, 'deflate-raw');
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise<Uint8Array> {
|
|
686
|
+
const ds = new DecompressionStream(format);
|
|
687
|
+
const writer = ds.writable.getWriter();
|
|
688
|
+
writer.write(compressed as BufferSource);
|
|
689
|
+
writer.close();
|
|
690
|
+
|
|
691
|
+
const chunks: Uint8Array[] = [];
|
|
692
|
+
const reader = ds.readable.getReader();
|
|
693
|
+
|
|
694
|
+
while (true) {
|
|
695
|
+
const { done, value } = await reader.read();
|
|
696
|
+
if (done) break;
|
|
697
|
+
chunks.push(value);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
701
|
+
const result = new Uint8Array(totalLength);
|
|
702
|
+
let offset = 0;
|
|
703
|
+
for (const chunk of chunks) {
|
|
704
|
+
result.set(chunk, offset);
|
|
705
|
+
offset += chunk.length;
|
|
706
|
+
}
|
|
707
|
+
return result;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
public get images(): ReadonlyMap<number, IKittyImageData> {
|
|
711
|
+
return this._kittyStorage.images;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
public get _kittyIdToStorageId(): ReadonlyMap<number, number> {
|
|
715
|
+
return this._kittyStorage.kittyIdToStorageId;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
public get pendingTransmissions(): ReadonlyMap<number, IPendingTransmission> {
|
|
719
|
+
return this._pendingTransmissions;
|
|
720
|
+
}
|
|
721
|
+
}
|