@archastro/astroshot-review 0.2.1

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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -0
  3. package/bin/astroshot-review.mjs +17 -0
  4. package/dist/cli.d.ts +12 -0
  5. package/dist/cli.js +240 -0
  6. package/dist/data/friction.d.ts +22 -0
  7. package/dist/data/friction.js +278 -0
  8. package/dist/data/hash-cache.d.ts +17 -0
  9. package/dist/data/hash-cache.js +51 -0
  10. package/dist/data/index-cache.d.ts +22 -0
  11. package/dist/data/index-cache.js +64 -0
  12. package/dist/data/manifest.d.ts +41 -0
  13. package/dist/data/manifest.js +105 -0
  14. package/dist/data/model.d.ts +96 -0
  15. package/dist/data/model.js +1 -0
  16. package/dist/data/paths.d.ts +27 -0
  17. package/dist/data/paths.js +98 -0
  18. package/dist/data/review-store.d.ts +67 -0
  19. package/dist/data/review-store.js +237 -0
  20. package/dist/data/scan.d.ts +32 -0
  21. package/dist/data/scan.js +227 -0
  22. package/dist/data/store.d.ts +92 -0
  23. package/dist/data/store.js +408 -0
  24. package/dist/data/watcher.d.ts +37 -0
  25. package/dist/data/watcher.js +126 -0
  26. package/dist/images/halfblocks.d.ts +10 -0
  27. package/dist/images/halfblocks.js +21 -0
  28. package/dist/images/png.d.ts +14 -0
  29. package/dist/images/png.js +45 -0
  30. package/dist/images/scale.d.ts +31 -0
  31. package/dist/images/scale.js +102 -0
  32. package/dist/images/service.d.ts +54 -0
  33. package/dist/images/service.js +163 -0
  34. package/dist/images/worker.d.ts +21 -0
  35. package/dist/images/worker.js +30 -0
  36. package/dist/index.d.ts +12 -0
  37. package/dist/index.js +9 -0
  38. package/dist/terminal/graphics-stdout.d.ts +9 -0
  39. package/dist/terminal/graphics-stdout.js +36 -0
  40. package/dist/terminal/herdr.d.ts +61 -0
  41. package/dist/terminal/herdr.js +327 -0
  42. package/dist/terminal/image-layer.d.ts +122 -0
  43. package/dist/terminal/image-layer.js +471 -0
  44. package/dist/terminal/kitty.d.ts +74 -0
  45. package/dist/terminal/kitty.js +112 -0
  46. package/dist/terminal/probe.d.ts +49 -0
  47. package/dist/terminal/probe.js +206 -0
  48. package/dist/ui/app.d.ts +6 -0
  49. package/dist/ui/app.js +695 -0
  50. package/dist/ui/chrome.d.ts +53 -0
  51. package/dist/ui/chrome.js +69 -0
  52. package/dist/ui/context.d.ts +16 -0
  53. package/dist/ui/context.js +8 -0
  54. package/dist/ui/detail.d.ts +33 -0
  55. package/dist/ui/detail.js +39 -0
  56. package/dist/ui/friction.d.ts +42 -0
  57. package/dist/ui/friction.js +84 -0
  58. package/dist/ui/help.d.ts +4 -0
  59. package/dist/ui/help.js +61 -0
  60. package/dist/ui/hooks.d.ts +9 -0
  61. package/dist/ui/hooks.js +32 -0
  62. package/dist/ui/movie-player.d.ts +29 -0
  63. package/dist/ui/movie-player.js +119 -0
  64. package/dist/ui/picture.d.ts +19 -0
  65. package/dist/ui/picture.js +100 -0
  66. package/dist/ui/selectors.d.ts +26 -0
  67. package/dist/ui/selectors.js +69 -0
  68. package/dist/ui/settings.d.ts +5 -0
  69. package/dist/ui/settings.js +17 -0
  70. package/dist/ui/stream.d.ts +38 -0
  71. package/dist/ui/stream.js +118 -0
  72. package/dist/ui/system.d.ts +4 -0
  73. package/dist/ui/system.js +34 -0
  74. package/dist/ui/takeover.d.ts +26 -0
  75. package/dist/ui/takeover.js +29 -0
  76. package/dist/ui/text-input.d.ts +8 -0
  77. package/dist/ui/text-input.js +74 -0
  78. package/dist/ui/theme.d.ts +21 -0
  79. package/dist/ui/theme.js +63 -0
  80. package/dist/video/ffmpeg.d.ts +54 -0
  81. package/dist/video/ffmpeg.js +206 -0
  82. package/package.json +71 -0
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Bridges Ink's layout tree to kitty graphics placements.
3
+ *
4
+ * Components register a source path and the DOM element that reserves cells
5
+ * for the picture. After every Ink frame the layer measures each element,
6
+ * fits the picture into that box, transmits bytes the terminal has not seen
7
+ * yet, and (re)places every visible image. Placements share the frame's
8
+ * synchronized-update block so text and pictures land together.
9
+ */
10
+ import { measureElement } from "ink";
11
+ import { fitInside } from "../images/png.js";
12
+ import { RESTORE_CURSOR, SAVE_CURSOR, cursorTo, encodeDelete, encodePlace, encodeTransmit, } from "./kitty.js";
13
+ export class ImageLayer {
14
+ entries = new Map();
15
+ transmitted = new Map();
16
+ lastPlacements = new Map();
17
+ nextEntryId = 1;
18
+ nextImageId = 1000 + Math.floor(Math.random() * 100_000);
19
+ tick = 0;
20
+ resync = true;
21
+ flushScheduled = false;
22
+ capabilities;
23
+ service;
24
+ write;
25
+ onReady;
26
+ onError;
27
+ onDebug;
28
+ herdr;
29
+ herdrLayers = new Map();
30
+ herdrGeneration = -1;
31
+ maxTransmitted;
32
+ lastSummary = "";
33
+ /** Row offset of the live region's first line, 1-based screen row minus 1. */
34
+ originRow = 0;
35
+ originCol = 0;
36
+ constructor(options) {
37
+ this.capabilities = options.capabilities;
38
+ this.service = options.service;
39
+ this.write = options.write;
40
+ this.onReady = options.onReady;
41
+ this.onError = options.onError;
42
+ this.onDebug = options.onDebug;
43
+ this.herdr = options.herdr;
44
+ this.maxTransmitted = options.maxTransmitted ?? 48;
45
+ }
46
+ get enabled() {
47
+ return this.capabilities.graphics === "kitty" || Boolean(this.herdr);
48
+ }
49
+ herdrLayerId(entryId) {
50
+ return `astro-${entryId}`;
51
+ }
52
+ register(options) {
53
+ const id = this.nextEntryId++;
54
+ const entry = {
55
+ id,
56
+ src: options.src,
57
+ version: options.version ?? 0,
58
+ z: options.z ?? 0,
59
+ maxUpscale: options.maxUpscale ?? 1,
60
+ zoom: options.zoom ?? 1,
61
+ panX: 0.5,
62
+ panY: 0.5,
63
+ node: null,
64
+ ready: null,
65
+ requestedKey: null,
66
+ failed: null,
67
+ frame: null,
68
+ frameData: null,
69
+ kind: "file",
70
+ };
71
+ this.entries.set(id, entry);
72
+ return {
73
+ id,
74
+ setNode: (node) => {
75
+ entry.node = node;
76
+ },
77
+ setSource: (src, version = 0) => {
78
+ if (entry.src === src && entry.version === version)
79
+ return;
80
+ entry.src = src;
81
+ entry.version = version;
82
+ entry.ready = null;
83
+ entry.requestedKey = null;
84
+ entry.failed = null;
85
+ this.scheduleFlush();
86
+ },
87
+ setView: (view) => {
88
+ let changed = false;
89
+ if (view.zoom !== undefined && view.zoom !== entry.zoom) {
90
+ entry.zoom = view.zoom;
91
+ changed = true;
92
+ }
93
+ if (view.panX !== undefined && view.panX !== entry.panX) {
94
+ entry.panX = view.panX;
95
+ changed = true;
96
+ }
97
+ if (view.panY !== undefined && view.panY !== entry.panY) {
98
+ entry.panY = view.panY;
99
+ changed = true;
100
+ }
101
+ if (view.maxUpscale !== undefined && view.maxUpscale !== entry.maxUpscale) {
102
+ entry.maxUpscale = view.maxUpscale;
103
+ changed = true;
104
+ }
105
+ if (changed)
106
+ this.scheduleFlush();
107
+ },
108
+ unregister: () => {
109
+ this.entries.delete(id);
110
+ if (this.herdr) {
111
+ this.herdr.clear(this.herdrLayerId(id));
112
+ this.herdrLayers.delete(id);
113
+ }
114
+ this.scheduleFlush();
115
+ },
116
+ };
117
+ }
118
+ registerFrames(options = {}) {
119
+ const id = this.nextEntryId++;
120
+ const entry = {
121
+ id,
122
+ src: null,
123
+ version: 0,
124
+ z: options.z ?? 1,
125
+ maxUpscale: 1,
126
+ zoom: 1,
127
+ panX: 0.5,
128
+ panY: 0.5,
129
+ node: null,
130
+ ready: null,
131
+ requestedKey: null,
132
+ failed: null,
133
+ frame: null,
134
+ frameData: null,
135
+ kind: "frames",
136
+ };
137
+ this.entries.set(id, entry);
138
+ const dropFrame = () => {
139
+ if (!entry.frame)
140
+ return "";
141
+ const output = encodeDelete({ kind: "image", id: entry.frame.imageId });
142
+ entry.frame = null;
143
+ return output;
144
+ };
145
+ return {
146
+ id,
147
+ setNode: (node) => {
148
+ entry.node = node;
149
+ },
150
+ pushFrame: (frame) => {
151
+ if (!this.enabled)
152
+ return;
153
+ const imageId = this.nextImageId++;
154
+ entry.frame = { imageId, width: frame.width, height: frame.height };
155
+ entry.frameData = frame.png;
156
+ if (this.herdr) {
157
+ const placement = this.placementFor(entry);
158
+ if (placement) {
159
+ this.herdr.set(this.herdrLayerId(entry.id), frame.png, frame.width, frame.height, placement);
160
+ this.herdrLayers.set(entry.id, `frame-${imageId}`);
161
+ }
162
+ return;
163
+ }
164
+ const previous = { imageId: entry.frame.imageId };
165
+ let output = encodeTransmit({ id: imageId, format: 100, data: frame.png });
166
+ const placement = this.placementFor(entry);
167
+ if (placement) {
168
+ output += SAVE_CURSOR + this.placeCommand(placement) + RESTORE_CURSOR;
169
+ this.lastPlacements.set(entry.id, placement);
170
+ }
171
+ void previous;
172
+ this.write(`\x1b[?2026h${output}\x1b[?2026l`);
173
+ },
174
+ clearFrame: () => {
175
+ entry.frame = null;
176
+ entry.frameData = null;
177
+ if (this.herdr) {
178
+ this.herdr.clear(this.herdrLayerId(entry.id));
179
+ this.herdrLayers.delete(entry.id);
180
+ return;
181
+ }
182
+ const output = dropFrame();
183
+ this.lastPlacements.delete(entry.id);
184
+ if (output)
185
+ this.write(output);
186
+ },
187
+ unregister: () => {
188
+ entry.frame = null;
189
+ entry.frameData = null;
190
+ if (this.herdr) {
191
+ this.herdr.clear(this.herdrLayerId(entry.id));
192
+ this.herdrLayers.delete(entry.id);
193
+ this.entries.delete(id);
194
+ return;
195
+ }
196
+ const output = dropFrame();
197
+ this.entries.delete(id);
198
+ this.lastPlacements.delete(entry.id);
199
+ if (output)
200
+ this.write(output);
201
+ },
202
+ };
203
+ }
204
+ /** Where a frame entry's current picture lands, given the live layout. */
205
+ placementFor(entry) {
206
+ if (!entry.node || !entry.frame)
207
+ return null;
208
+ const box = measureElement(entry.node);
209
+ if (box.width <= 0 || box.height <= 0)
210
+ return null;
211
+ const cellWidth = this.capabilities.cellWidth;
212
+ const cellHeight = this.capabilities.cellHeight;
213
+ const targetPx = { width: box.width * cellWidth, height: box.height * cellHeight };
214
+ const fitted = fitInside({ width: entry.frame.width, height: entry.frame.height }, targetPx);
215
+ const cols = Math.min(box.width, Math.max(1, Math.round(fitted.width / cellWidth)));
216
+ const rows = Math.min(box.height, Math.max(1, Math.round(fitted.height / cellHeight)));
217
+ return {
218
+ placementId: entry.id,
219
+ imageId: entry.frame.imageId,
220
+ col: box.x + Math.floor((box.width - cols) / 2),
221
+ row: box.y + Math.floor((box.height - rows) / 2),
222
+ cols,
223
+ rows,
224
+ z: entry.z,
225
+ };
226
+ }
227
+ placeCommand(placement) {
228
+ return (cursorTo(placement.row + 1 + this.originRow, placement.col + 1 + this.originCol) +
229
+ encodePlace({
230
+ id: placement.imageId,
231
+ placementId: placement.placementId,
232
+ cols: placement.cols,
233
+ rows: placement.rows,
234
+ z: placement.z,
235
+ }));
236
+ }
237
+ /** Prepared image for an entry, if any (lets components show dimensions). */
238
+ prepared(id) {
239
+ return this.entries.get(id)?.ready ?? null;
240
+ }
241
+ failure(id) {
242
+ return this.entries.get(id)?.failed ?? null;
243
+ }
244
+ /** After a resize or screen clear, every placement must be re-sent. */
245
+ invalidate() {
246
+ this.resync = true;
247
+ // Force herdr layers to be re-set at their new positions.
248
+ this.herdrLayers.clear();
249
+ }
250
+ /**
251
+ * Reconcile the herdr layers with the current placement set: (re)place any
252
+ * image whose bytes or box changed, and clear layers no longer shown.
253
+ */
254
+ syncHerdr(placements, readyByEntry) {
255
+ const sink = this.herdr;
256
+ if (!sink)
257
+ return;
258
+ // A dropped-and-restored connection loses server-side layers; re-send all.
259
+ if (sink.generation !== this.herdrGeneration) {
260
+ this.herdrGeneration = sink.generation;
261
+ this.herdrLayers.clear();
262
+ }
263
+ for (const [entryId, placement] of placements) {
264
+ const ready = readyByEntry.get(entryId);
265
+ if (!ready)
266
+ continue; // frame entries place themselves in pushFrame
267
+ const signature = `${ready.key}|${placement.col},${placement.row},${placement.cols},${placement.rows},${placement.z}`;
268
+ if (this.herdrLayers.get(entryId) === signature)
269
+ continue;
270
+ sink.set(this.herdrLayerId(entryId), ready.data, ready.width, ready.height, placement);
271
+ this.herdrLayers.set(entryId, signature);
272
+ }
273
+ for (const entryId of [...this.herdrLayers.keys()]) {
274
+ const entry = this.entries.get(entryId);
275
+ // Keep active file placements and live frame entries; clear the rest.
276
+ if (placements.has(entryId))
277
+ continue;
278
+ if (entry?.kind === "frames" && entry.frame)
279
+ continue;
280
+ sink.clear(this.herdrLayerId(entryId));
281
+ this.herdrLayers.delete(entryId);
282
+ }
283
+ }
284
+ /** Escape sequences that bring the terminal in line with the current layout. */
285
+ render() {
286
+ if (!this.enabled)
287
+ return "";
288
+ this.tick += 1;
289
+ const placements = new Map();
290
+ const readyByEntry = new Map();
291
+ let output = "";
292
+ const cellWidth = this.capabilities.cellWidth;
293
+ const cellHeight = this.capabilities.cellHeight;
294
+ for (const entry of this.entries.values()) {
295
+ if (entry.kind === "frames") {
296
+ // Frame entries place themselves in pushFrame; on herdr they re-place
297
+ // there too. Only the kitty escape path needs them re-emitted here.
298
+ if (!this.herdr) {
299
+ const placement = this.placementFor(entry);
300
+ if (placement)
301
+ placements.set(entry.id, placement);
302
+ }
303
+ continue;
304
+ }
305
+ if (!entry.src || !entry.node)
306
+ continue;
307
+ const box = measureElement(entry.node);
308
+ if (box.width <= 0 || box.height <= 0)
309
+ continue;
310
+ const boxPx = { width: box.width * cellWidth, height: box.height * cellHeight };
311
+ // Prepare above the cell box so the compositor only ever downscales
312
+ // (downscaling stays sharp; upscaling blurs). herdr may render the box at
313
+ // more physical pixels than its reported cell size implies, so oversample.
314
+ const supersample = this.herdr ? 2 : 1;
315
+ const targetPx = { width: boxPx.width * supersample, height: boxPx.height * supersample };
316
+ // Zoom > 1 shows a centered sub-rectangle of the source (a real crop, so
317
+ // it magnifies instead of squishing); pan slides that rectangle. The
318
+ // on-screen footprint stays fixed — only the visible region changes.
319
+ let crop;
320
+ if (entry.zoom > 1 && entry.ready) {
321
+ const sourceW = entry.ready.sourceWidth;
322
+ const sourceH = entry.ready.sourceHeight;
323
+ const cropW = sourceW / entry.zoom;
324
+ const cropH = sourceH / entry.zoom;
325
+ const centerX = Math.max(cropW / 2, Math.min(sourceW - cropW / 2, entry.panX * sourceW));
326
+ const centerY = Math.max(cropH / 2, Math.min(sourceH - cropH / 2, entry.panY * sourceH));
327
+ crop = { x: centerX - cropW / 2, y: centerY - cropH / 2, width: cropW, height: cropH };
328
+ }
329
+ const cropKey = crop
330
+ ? `|z${entry.zoom.toFixed(2)}|${Math.round(crop.x)},${Math.round(crop.y)},${Math.round(crop.width)},${Math.round(crop.height)}`
331
+ : "";
332
+ const requestKey = `${entry.src}|${entry.version}|${targetPx.width}x${targetPx.height}${cropKey}`;
333
+ if (entry.requestedKey !== requestKey) {
334
+ entry.requestedKey = requestKey;
335
+ entry.failed = null;
336
+ void this.service.prepare(entry.src, targetPx, "png", crop).then((prepared) => {
337
+ if (entry.requestedKey !== requestKey)
338
+ return;
339
+ entry.ready = prepared;
340
+ this.scheduleFlush();
341
+ this.onReady?.();
342
+ }, (error) => {
343
+ if (entry.requestedKey !== requestKey)
344
+ return;
345
+ entry.failed = error instanceof Error ? error.message : String(error);
346
+ this.onError?.(entry.src ?? "", error instanceof Error ? error : new Error(String(error)));
347
+ this.onReady?.();
348
+ });
349
+ }
350
+ const ready = entry.ready;
351
+ if (!ready)
352
+ continue;
353
+ readyByEntry.set(entry.id, ready);
354
+ // On-screen footprint from the FULL image aspect (constant across zoom, so
355
+ // zooming magnifies in place without moving the picture). Fits the box,
356
+ // upscaling small sources up to `maxUpscale`× to fill.
357
+ const fullWidth = ready.sourceWidth;
358
+ const fullHeight = ready.sourceHeight;
359
+ const containScale = Math.min(boxPx.width / fullWidth, boxPx.height / fullHeight);
360
+ const scale = Math.min(containScale, Math.max(1, entry.maxUpscale));
361
+ const cols = Math.min(box.width, Math.max(1, Math.round((fullWidth * scale) / cellWidth)));
362
+ const rows = Math.min(box.height, Math.max(1, Math.round((fullHeight * scale) / cellHeight)));
363
+ const col = box.x + Math.floor((box.width - cols) / 2);
364
+ const row = box.y + Math.floor((box.height - rows) / 2);
365
+ let imageId = 0;
366
+ if (!this.herdr) {
367
+ let image = this.transmitted.get(ready.key);
368
+ if (!image) {
369
+ image = { id: this.nextImageId++, key: ready.key, lastUsed: this.tick };
370
+ this.transmitted.set(ready.key, image);
371
+ output += encodeTransmit({
372
+ id: image.id,
373
+ format: 100,
374
+ data: ready.data,
375
+ filePath: this.capabilities.fileMedium && ready.isOriginal ? ready.path : undefined,
376
+ });
377
+ }
378
+ image.lastUsed = this.tick;
379
+ imageId = image.id;
380
+ }
381
+ placements.set(entry.id, { placementId: entry.id, imageId, col, row, cols, rows, z: entry.z });
382
+ }
383
+ // herdr composits images on named layers over the pane's text; drive its
384
+ // socket API instead of writing Kitty escapes the multiplexer would drop.
385
+ if (this.herdr) {
386
+ this.syncHerdr(placements, readyByEntry);
387
+ this.lastPlacements = placements;
388
+ if (this.onDebug) {
389
+ const summary = `herdr layers=${this.herdrLayers.size} placed=${placements.size}`;
390
+ if (summary !== this.lastSummary) {
391
+ this.lastSummary = summary;
392
+ this.onDebug(summary);
393
+ }
394
+ }
395
+ return "";
396
+ }
397
+ if (this.resync) {
398
+ output += encodeDelete({ kind: "all-placements" });
399
+ }
400
+ else {
401
+ for (const [placementId, previous] of this.lastPlacements) {
402
+ if (!placements.has(placementId)) {
403
+ output += encodeDelete({ kind: "placement", id: previous.imageId, placementId });
404
+ }
405
+ }
406
+ }
407
+ if (placements.size > 0) {
408
+ output += SAVE_CURSOR;
409
+ for (const placement of placements.values()) {
410
+ output += this.placeCommand(placement);
411
+ }
412
+ output += RESTORE_CURSOR;
413
+ }
414
+ output += this.evictTransmitted(placements);
415
+ this.lastPlacements = placements;
416
+ this.resync = false;
417
+ if (this.onDebug) {
418
+ const summary = `entries=${this.entries.size} placed=${placements.size} transmitted=${this.transmitted.size} bytes=${output.length}`;
419
+ if (summary !== this.lastSummary) {
420
+ this.lastSummary = summary;
421
+ this.onDebug(summary);
422
+ }
423
+ }
424
+ return output;
425
+ }
426
+ evictTransmitted(active) {
427
+ if (this.transmitted.size <= this.maxTransmitted)
428
+ return "";
429
+ const inUse = new Set([...active.values()].map((placement) => placement.imageId));
430
+ const candidates = [...this.transmitted.values()]
431
+ .filter((image) => !inUse.has(image.id))
432
+ .sort((a, b) => a.lastUsed - b.lastUsed);
433
+ let output = "";
434
+ while (this.transmitted.size > this.maxTransmitted && candidates.length > 0) {
435
+ const image = candidates.shift();
436
+ this.transmitted.delete(image.key);
437
+ output += encodeDelete({ kind: "image", id: image.id });
438
+ }
439
+ return output;
440
+ }
441
+ /** Write placements now, outside an Ink frame. Safe for the cursor. */
442
+ flushNow() {
443
+ this.flushScheduled = false;
444
+ if (!this.enabled)
445
+ return;
446
+ const output = this.render();
447
+ if (output)
448
+ this.write(`\x1b[?2026h${output}\x1b[?2026l`);
449
+ }
450
+ scheduleFlush() {
451
+ if (this.flushScheduled || !this.enabled)
452
+ return;
453
+ this.flushScheduled = true;
454
+ setImmediate(() => {
455
+ if (this.flushScheduled)
456
+ this.flushNow();
457
+ });
458
+ }
459
+ /** Remove every placement and free image data in the terminal. */
460
+ clear() {
461
+ this.lastPlacements = new Map();
462
+ this.transmitted.clear();
463
+ this.resync = true;
464
+ if (this.herdr) {
465
+ this.herdr.clearAll();
466
+ this.herdrLayers.clear();
467
+ return "";
468
+ }
469
+ return this.enabled ? encodeDelete({ kind: "all" }) : "";
470
+ }
471
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Kitty terminal graphics protocol encoder.
3
+ *
4
+ * Only the subset the review tray needs: transmit PNG or raw RGB data by id,
5
+ * place a transmitted image over a cell box, and delete placements or image
6
+ * data. Every command is emitted quietly (q=2) so the terminal never answers
7
+ * on stdin while Ink owns it.
8
+ *
9
+ * Spec: https://sw.kovidgoyal.net/kitty/graphics-protocol/
10
+ */
11
+ export declare const APC = "\u001B_G";
12
+ export declare const ST = "\u001B\\";
13
+ /** Payload chunk size; the protocol caps chunks at 4096 bytes. */
14
+ export declare const CHUNK_SIZE = 4096;
15
+ export type ImageFormat = 100 | 24 | 32;
16
+ export interface TransmitRequest {
17
+ id: number;
18
+ /** 100 = PNG bytes, 24 = RGB, 32 = RGBA. */
19
+ format: ImageFormat;
20
+ data: Buffer;
21
+ /** Required for raw formats. */
22
+ width?: number;
23
+ height?: number;
24
+ /** Raw payload is zlib-compressed (o=z). */
25
+ compressed?: boolean;
26
+ /**
27
+ * When set the payload is a base64 file path the terminal reads itself
28
+ * (t=f). Only valid when the terminal runs on this machine.
29
+ */
30
+ filePath?: string;
31
+ }
32
+ export interface PlaceRequest {
33
+ id: number;
34
+ placementId: number;
35
+ cols: number;
36
+ rows: number;
37
+ /** Stacking order; images with z >= 0 draw above text. */
38
+ z?: number;
39
+ }
40
+ export type DeleteRequest = {
41
+ kind: "placement";
42
+ id: number;
43
+ placementId: number;
44
+ } | {
45
+ kind: "image";
46
+ id: number;
47
+ } | {
48
+ kind: "all-placements";
49
+ } | {
50
+ kind: "all";
51
+ };
52
+ export declare function chunkPayload(base64: string, size?: number): string[];
53
+ /** Transmit image data without displaying it (a=t). */
54
+ export declare function encodeTransmit(request: TransmitRequest): string;
55
+ /**
56
+ * Display a transmitted image over a c×r cell box at the cursor (a=p). C=1
57
+ * keeps the cursor where it was so Ink's own cursor bookkeeping stays valid.
58
+ * A placement with the same (i, p) pair replaces the previous one.
59
+ */
60
+ export declare function encodePlace(request: PlaceRequest): string;
61
+ export declare function encodeDelete(request: DeleteRequest): string;
62
+ /** The 1×1 RGB query kitty documents for capability detection. */
63
+ export declare function encodeQuery(id: number): string;
64
+ export declare function encodeFileQuery(id: number, filePath: string): string;
65
+ /** Move the cursor to a 1-based row/column (CUP). */
66
+ export declare function cursorTo(row: number, col: number): string;
67
+ export declare const SAVE_CURSOR = "\u001B7";
68
+ export declare const RESTORE_CURSOR = "\u001B8";
69
+ export interface ParsedGraphicsCommand {
70
+ keys: Record<string, string>;
71
+ payload: string;
72
+ }
73
+ /** Parse one `ESC _ G <keys> ; <payload> ESC \` command body. */
74
+ export declare function parseGraphicsCommand(body: string): ParsedGraphicsCommand;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Kitty terminal graphics protocol encoder.
3
+ *
4
+ * Only the subset the review tray needs: transmit PNG or raw RGB data by id,
5
+ * place a transmitted image over a cell box, and delete placements or image
6
+ * data. Every command is emitted quietly (q=2) so the terminal never answers
7
+ * on stdin while Ink owns it.
8
+ *
9
+ * Spec: https://sw.kovidgoyal.net/kitty/graphics-protocol/
10
+ */
11
+ export const APC = "\x1b_G";
12
+ export const ST = "\x1b\\";
13
+ /** Payload chunk size; the protocol caps chunks at 4096 bytes. */
14
+ export const CHUNK_SIZE = 4096;
15
+ function control(pairs) {
16
+ return Object.entries(pairs)
17
+ .filter(([, value]) => value !== undefined)
18
+ .map(([key, value]) => `${key}=${value}`)
19
+ .join(",");
20
+ }
21
+ export function chunkPayload(base64, size = CHUNK_SIZE) {
22
+ if (base64.length === 0)
23
+ return [""];
24
+ const chunks = [];
25
+ for (let offset = 0; offset < base64.length; offset += size) {
26
+ chunks.push(base64.slice(offset, offset + size));
27
+ }
28
+ return chunks;
29
+ }
30
+ /** Transmit image data without displaying it (a=t). */
31
+ export function encodeTransmit(request) {
32
+ const base = {
33
+ a: "t",
34
+ i: request.id,
35
+ f: request.format,
36
+ q: 2,
37
+ s: request.format === 100 ? undefined : request.width,
38
+ v: request.format === 100 ? undefined : request.height,
39
+ o: request.compressed ? "z" : undefined,
40
+ };
41
+ if (request.filePath) {
42
+ const payload = Buffer.from(request.filePath, "utf8").toString("base64");
43
+ return `${APC}${control({ ...base, t: "f" })};${payload}${ST}`;
44
+ }
45
+ const chunks = chunkPayload(request.data.toString("base64"));
46
+ return chunks
47
+ .map((chunk, index) => {
48
+ const more = index < chunks.length - 1 ? 1 : 0;
49
+ const keys = index === 0 ? control({ ...base, t: "d", m: more }) : `m=${more}`;
50
+ return `${APC}${keys};${chunk}${ST}`;
51
+ })
52
+ .join("");
53
+ }
54
+ /**
55
+ * Display a transmitted image over a c×r cell box at the cursor (a=p). C=1
56
+ * keeps the cursor where it was so Ink's own cursor bookkeeping stays valid.
57
+ * A placement with the same (i, p) pair replaces the previous one.
58
+ */
59
+ export function encodePlace(request) {
60
+ return `${APC}${control({
61
+ a: "p",
62
+ i: request.id,
63
+ p: request.placementId,
64
+ c: request.cols,
65
+ r: request.rows,
66
+ z: request.z ?? 0,
67
+ C: 1,
68
+ q: 2,
69
+ })}${ST}`;
70
+ }
71
+ export function encodeDelete(request) {
72
+ switch (request.kind) {
73
+ case "placement":
74
+ return `${APC}${control({ a: "d", d: "i", i: request.id, p: request.placementId, q: 2 })}${ST}`;
75
+ case "image":
76
+ return `${APC}${control({ a: "d", d: "I", i: request.id, q: 2 })}${ST}`;
77
+ case "all-placements":
78
+ return `${APC}${control({ a: "d", d: "a", q: 2 })}${ST}`;
79
+ case "all":
80
+ return `${APC}${control({ a: "d", d: "A", q: 2 })}${ST}`;
81
+ }
82
+ }
83
+ /** The 1×1 RGB query kitty documents for capability detection. */
84
+ export function encodeQuery(id) {
85
+ return `${APC}${control({ i: id, s: 1, v: 1, a: "q", t: "d", f: 24 })};AAAA${ST}`;
86
+ }
87
+ export function encodeFileQuery(id, filePath) {
88
+ const payload = Buffer.from(filePath, "utf8").toString("base64");
89
+ return `${APC}${control({ i: id, a: "q", t: "f", f: 100 })};${payload}${ST}`;
90
+ }
91
+ /** Move the cursor to a 1-based row/column (CUP). */
92
+ export function cursorTo(row, col) {
93
+ return `\x1b[${row};${col}H`;
94
+ }
95
+ export const SAVE_CURSOR = "\x1b7";
96
+ export const RESTORE_CURSOR = "\x1b8";
97
+ /** Parse one `ESC _ G <keys> ; <payload> ESC \` command body. */
98
+ export function parseGraphicsCommand(body) {
99
+ const separator = body.indexOf(";");
100
+ const keyText = separator === -1 ? body : body.slice(0, separator);
101
+ const payload = separator === -1 ? "" : body.slice(separator + 1);
102
+ const keys = {};
103
+ for (const pair of keyText.split(",")) {
104
+ if (!pair)
105
+ continue;
106
+ const equals = pair.indexOf("=");
107
+ if (equals === -1)
108
+ continue;
109
+ keys[pair.slice(0, equals)] = pair.slice(equals + 1);
110
+ }
111
+ return { keys, payload };
112
+ }