@sayknow-cli/tui 0.3.7 → 0.3.9

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.
@@ -222,6 +222,40 @@ export interface ImageRenderOptions {
222
222
  maxWidthCells?: number;
223
223
  maxHeightCells?: number;
224
224
  preserveAspectRatio?: boolean;
225
+ /**
226
+ * Kitty-only: stable placement id (`p=`). Re-emitting the same image id +
227
+ * placement id *replaces* the existing placement instead of stacking a new
228
+ * copy, which makes diff-renderer repaints idempotent. Callers that render
229
+ * a persistent component should allocate one id per component instance.
230
+ */
231
+ placementId?: number;
232
+ /**
233
+ * Kitty-only: stable image id (`i=`). Defaults to a content hash of the
234
+ * base64 payload ({@link kittyImageId}). Pass a precomputed id to avoid
235
+ * re-hashing large payloads on every render.
236
+ */
237
+ imageId?: number;
238
+ /**
239
+ * Kitty-only: sink for the out-of-band data transmission (`a=t`) emitted
240
+ * the first time an image id is rendered. Defaults to the process-wide
241
+ * writer configured via {@link setKittyTransmitWriter} (stdout).
242
+ */
243
+ onTransmit?: (sequence: string) => void;
244
+ }
245
+
246
+ /**
247
+ * Derive a stable 32-bit non-zero kitty image id (`i=`) from image content
248
+ * (FNV-1a over the base64 payload). Identical content maps to the same id, so
249
+ * retransmission replaces the stored image instead of accumulating copies.
250
+ */
251
+ export function kittyImageId(base64Data: string): number {
252
+ let hash = 0x811c9dc5;
253
+ for (let i = 0; i < base64Data.length; i++) {
254
+ hash ^= base64Data.charCodeAt(i);
255
+ hash = Math.imul(hash, 0x01000193);
256
+ }
257
+ hash >>>= 0;
258
+ return hash === 0 ? 1 : hash;
225
259
  }
226
260
 
227
261
  // Default cell dimensions - updated by TUI when terminal responds to query
@@ -241,6 +275,7 @@ export function encodeKitty(
241
275
  columns?: number;
242
276
  rows?: number;
243
277
  imageId?: number;
278
+ placementId?: number;
244
279
  } = {},
245
280
  ): string {
246
281
  const CHUNK_SIZE = 4096;
@@ -249,7 +284,77 @@ export function encodeKitty(
249
284
 
250
285
  if (options.columns) params.push(`c=${options.columns}`);
251
286
  if (options.rows) params.push(`r=${options.rows}`);
252
- if (options.imageId) params.push(`i=${options.imageId}`);
287
+ if (options.imageId) {
288
+ params.push(`i=${options.imageId}`);
289
+ // A placement id is only meaningful together with an image id. Same
290
+ // i= + p= replaces the previous placement (kitty graphics spec), so
291
+ // re-emitting this sequence never duplicates the image on screen.
292
+ if (options.placementId) params.push(`p=${options.placementId}`);
293
+ }
294
+
295
+ if (base64Data.length <= CHUNK_SIZE) {
296
+ return `\x1b_G${params.join(",")};${base64Data}\x1b\\`;
297
+ }
298
+
299
+ const chunks: string[] = [];
300
+ let offset = 0;
301
+ let isFirst = true;
302
+
303
+ while (offset < base64Data.length) {
304
+ const chunk = base64Data.slice(offset, offset + CHUNK_SIZE);
305
+ const isLast = offset + CHUNK_SIZE >= base64Data.length;
306
+
307
+ if (isFirst) {
308
+ chunks.push(`\x1b_G${params.join(",")},m=1;${chunk}\x1b\\`);
309
+ isFirst = false;
310
+ } else if (isLast) {
311
+ chunks.push(`\x1b_Gm=0;${chunk}\x1b\\`);
312
+ } else {
313
+ chunks.push(`\x1b_Gm=1;${chunk}\x1b\\`);
314
+ }
315
+
316
+ offset += CHUNK_SIZE;
317
+ }
318
+
319
+ return chunks.join("");
320
+ }
321
+
322
+ /** Kitty image ids already uploaded to the terminal in this process. */
323
+ const transmittedKittyImageIds = new Set<number>();
324
+
325
+ /** Test hook: forget which kitty image ids were transmitted. */
326
+ export function resetKittyTransmissions(): void {
327
+ transmittedKittyImageIds.clear();
328
+ }
329
+
330
+ let kittyTransmitWriter: (sequence: string) => void = sequence => {
331
+ process.stdout.write(sequence);
332
+ };
333
+
334
+ /**
335
+ * Override where out-of-band kitty data transmissions (`a=t`) are written.
336
+ * The default writes directly to stdout: a transmit-only escape is
337
+ * cursor-neutral (it uploads pixel data without drawing anything), so the
338
+ * only ordering requirement is that it reaches the terminal before the
339
+ * placement escape that references it — which the synchronous write during
340
+ * render guarantees. Tests use this to capture transmissions.
341
+ */
342
+ export function setKittyTransmitWriter(writer: (sequence: string) => void): void {
343
+ kittyTransmitWriter = writer;
344
+ }
345
+
346
+ /**
347
+ * Encode a kitty transmit-only (`a=t`) escape: uploads image data under a
348
+ * stable id without creating a placement. Chunked at 4096 bytes per spec.
349
+ *
350
+ * This is deliberately separate from placement: re-sending data (`a=t`/`a=T`)
351
+ * for an existing image id deletes the image and ALL of its placements, so
352
+ * data must be uploaded exactly once per id and repaints must go through
353
+ * {@link encodeKittyPlacement} only.
354
+ */
355
+ export function encodeKittyTransmit(base64Data: string, imageId: number): string {
356
+ const CHUNK_SIZE = 4096;
357
+ const params = ["a=t", "f=100", "q=2", `i=${imageId}`];
253
358
 
254
359
  if (base64Data.length <= CHUNK_SIZE) {
255
360
  return `\x1b_G${params.join(",")};${base64Data}\x1b\\`;
@@ -278,6 +383,22 @@ export function encodeKitty(
278
383
  return chunks.join("");
279
384
  }
280
385
 
386
+ /**
387
+ * Encode a kitty placement-only (`a=p`) escape referencing previously
388
+ * transmitted data. Re-emitting the same i=/p= pair replaces that one
389
+ * placement (never stacks, never touches sibling placements), and C=1
390
+ * keeps the cursor where it is so the escape can be emitted from the
391
+ * component's first row without cursor-up tricks.
392
+ */
393
+ export function encodeKittyPlacement(options: {
394
+ imageId: number;
395
+ placementId: number;
396
+ columns: number;
397
+ rows: number;
398
+ }): string {
399
+ return `\x1b_Ga=p,i=${options.imageId},p=${options.placementId},c=${options.columns},r=${options.rows},C=1,q=2\x1b\\`;
400
+ }
401
+
281
402
  export function encodeITerm2(
282
403
  base64Data: string,
283
404
  options: {
@@ -485,11 +606,23 @@ export function getImageDimensions(base64Data: string, mimeType: string): ImageD
485
606
  return null;
486
607
  }
487
608
 
609
+ export interface RenderedImage {
610
+ sequence: string;
611
+ rows: number;
612
+ /**
613
+ * True when the escape neither moves the cursor nor carries pixel data
614
+ * (kitty `a=p,C=1` placements). Cursor-neutral sequences can be emitted
615
+ * from the component's first row; cursor-advancing protocols
616
+ * (iTerm2/SIXEL) must draw from the last reserved row instead.
617
+ */
618
+ cursorNeutral?: boolean;
619
+ }
620
+
488
621
  export function renderImage(
489
622
  base64Data: string,
490
623
  imageDimensions: ImageDimensions,
491
624
  options: ImageRenderOptions = {},
492
- ): { sequence: string; rows: number } | null {
625
+ ): RenderedImage | null {
493
626
  if (!TERMINAL.imageProtocol) {
494
627
  return null;
495
628
  }
@@ -498,11 +631,20 @@ export function renderImage(
498
631
  const fit = calculateImageFit(imageDimensions, options, cellDims);
499
632
 
500
633
  if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
501
- const sequence = encodeKitty(base64Data, {
502
- columns: fit.columns,
503
- rows: fit.rows,
504
- });
505
- return { sequence, rows: fit.rows };
634
+ const imageId = options.imageId ?? kittyImageId(base64Data);
635
+ const placementId = options.placementId ?? 1;
636
+ // Upload data once per image id (out-of-band; the transmit escape is
637
+ // cursor-neutral), then return only a tiny placement escape. Repaints
638
+ // re-emit just the placement, which replaces/moves that placement —
639
+ // re-sending data (a=T/a=t) for an existing id would delete the image
640
+ // and ALL of its placements (breaking sibling components showing the
641
+ // same content) and would re-send multi-MB payloads on every repaint.
642
+ if (!transmittedKittyImageIds.has(imageId)) {
643
+ transmittedKittyImageIds.add(imageId);
644
+ (options.onTransmit ?? kittyTransmitWriter)(encodeKittyTransmit(base64Data, imageId));
645
+ }
646
+ const sequence = encodeKittyPlacement({ imageId, placementId, columns: fit.columns, rows: fit.rows });
647
+ return { sequence, rows: fit.rows, cursorNeutral: true };
506
648
  }
507
649
 
508
650
  if (TERMINAL.imageProtocol === ImageProtocol.Sixel) {
package/src/terminal.ts CHANGED
@@ -128,6 +128,9 @@ export interface Terminal {
128
128
  // Whether terminal output is still writable
129
129
  get available(): boolean;
130
130
 
131
+ // True for the real process stdin/stdout terminal (not virtual test terminals).
132
+ readonly isProcessTerminal?: boolean;
133
+
131
134
  // Get terminal dimensions
132
135
  get columns(): number;
133
136
  get rows(): number;
@@ -238,6 +241,10 @@ export class ProcessTerminal implements Terminal {
238
241
  #mode2031DebounceTimer?: Timer;
239
242
  #progressTimer?: ReturnType<typeof setInterval>;
240
243
 
244
+ get isProcessTerminal(): boolean {
245
+ return true;
246
+ }
247
+
241
248
  get kittyProtocolActive(): boolean {
242
249
  return this.#kittyProtocolActive;
243
250
  }