tmux-ide 2.9.7 → 2.9.8

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 (34) hide show
  1. package/bin/cli.js +322 -52
  2. package/package.json +2 -2
  3. package/packages/contracts/src/pane-stream.ts +17 -0
  4. package/packages/contracts/src/terminal-graphics.ts +29 -3
  5. package/packages/core/src/kitty-placeholders.ts +29 -11
  6. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  7. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  8. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  9. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  10. package/packages/daemon/dist/native/tmux/darwin-arm64/manifest.json +6 -2
  11. package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
  12. package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +6 -2
  13. package/packages/daemon/dist/native/tmux/linux-arm64/tmux +0 -0
  14. package/packages/daemon/dist/native/tmux/linux-x64/manifest.json +6 -2
  15. package/packages/daemon/dist/native/tmux/linux-x64/tmux +0 -0
  16. package/packages/daemon/dist/terminal/attachments/native-runtime.js +25 -12
  17. package/packages/daemon/dist/terminal/graphics/kitty-graphics.js +146 -25
  18. package/packages/daemon/dist/terminal/mirror/mirror-service.js +6 -0
  19. package/packages/daemon/dist/terminal/mirror/session-channel.js +45 -2
  20. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +109 -7
  21. package/packages/daemon/dist/terminal/session-runtime/registry.js +9 -0
  22. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +19 -2
  23. package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +4 -0
  24. package/packages/daemon/src/terminal/attachments/native-runtime.ts +28 -13
  25. package/packages/daemon/src/terminal/graphics/__fixtures__/full-frame-client-recorded.ctl +87 -0
  26. package/packages/daemon/src/terminal/graphics/__fixtures__/full-frame-client.ctl +10 -0
  27. package/packages/daemon/src/terminal/graphics/kitty-graphics.ts +143 -28
  28. package/packages/daemon/src/terminal/mirror/mirror-service.ts +11 -0
  29. package/packages/daemon/src/terminal/mirror/session-channel.ts +61 -2
  30. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +123 -6
  31. package/packages/daemon/src/terminal/session-runtime/registry.ts +18 -0
  32. package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +18 -2
  33. package/packages/daemon/src/terminal/session-runtime/transport-binding.ts +5 -0
  34. package/packages/daemon-client/src/pane-stream-client.ts +8 -0
@@ -18,7 +18,7 @@
18
18
  * marker OSC the scanner substitutes in place of the APC, so the VT parser
19
19
  * reports the cursor at the exact byte position. tmux never sees these
20
20
  * placements: they do not move the cursor, do not follow reflow, and are
21
- * dropped by a capture reseed.
21
+ * kept across capture reseeds only when the coordinate basis is unchanged.
22
22
  *
23
23
  * Limits: direct transmission (`t=d`) only — file and shared-memory media
24
24
  * cannot cross machines; `a=d` handles all and by-id; animation, relative
@@ -26,7 +26,7 @@
26
26
  * from the patched tmux, in stream order (native/tmux/kitty-graphics.patch).
27
27
  */
28
28
  import { createHash } from "node:crypto";
29
- import { inflateSync } from "node:zlib";
29
+ import { deflateSync, inflateSync } from "node:zlib";
30
30
  import {
31
31
  TERMINAL_GRAPHICS_MAX_IMAGE_BYTES,
32
32
  TERMINAL_GRAPHICS_MAX_IMAGES,
@@ -356,12 +356,13 @@ export interface KittyImageStoreOptions {
356
356
  readonly budget?: KittyImageBudget;
357
357
  }
358
358
 
359
- /** Per-pane image store. Survives reseeds; cursor placements do not. */
359
+ /** Per-pane image store. Survives reseeds; the interpreter validates cursor placement coordinates. */
360
360
  export class KittyImageStore {
361
361
  readonly #images = new Map<number, KittyImage>();
362
362
  readonly #used = new Map<number, number>();
363
363
  readonly #cursorPlacements: KittyCursorPlacement[] = [];
364
- readonly #virtual = new Map<number, { columns: number; rows: number }>();
364
+ /** `U=1` virtual placements per image, by placement id (0 when unnamed). */
365
+ readonly #virtual = new Map<number, Map<number, { columns: number; rows: number }>>();
365
366
  #pending: { control: ReadonlyMap<string, string>; payload: string[]; bytes: number } | null =
366
367
  null;
367
368
  #nextAnonymous = 0x7f000000;
@@ -407,7 +408,7 @@ export class KittyImageStore {
407
408
  this.#bytes = 0;
408
409
  }
409
410
 
410
- /** A capture reseed replaced the grid: tmux holds no record of cursor placements. */
411
+ /** The coordinate basis changed: tmux holds no record to restore cursor anchors. */
411
412
  dropCursorPlacements(): void {
412
413
  if (this.#cursorPlacements.length === 0) return;
413
414
  this.#cursorPlacements.length = 0;
@@ -433,11 +434,16 @@ export class KittyImageStore {
433
434
  return this.#remove(victim);
434
435
  }
435
436
 
436
- #remove(imageId: number): number {
437
+ #remove(imageId: number, keepPlacements = false): number {
437
438
  const image = this.#images.get(imageId);
438
439
  if (!image) return 0;
439
440
  this.#images.delete(imageId);
440
441
  this.#used.delete(imageId);
442
+ if (keepPlacements) {
443
+ this.#bytes -= image.data.byteLength;
444
+ this.#revision += 1;
445
+ return image.data.byteLength;
446
+ }
441
447
  this.#virtual.delete(imageId);
442
448
  this.#bytes -= image.data.byteLength;
443
449
  const kept = this.#cursorPlacements.filter((p) => p.imageId !== imageId);
@@ -470,7 +476,7 @@ export class KittyImageStore {
470
476
  return;
471
477
  }
472
478
  const action = control.get("a") ?? "t";
473
- if ((action === "t" || action === "T") && more) {
479
+ if ((action === "t" || action === "T" || action === "f") && more) {
474
480
  this.#pending = { control, payload: [command.payload], bytes: command.payload.length };
475
481
  return;
476
482
  }
@@ -491,19 +497,16 @@ export class KittyImageStore {
491
497
  if (imageId === 0) imageId = this.#nextAnonymous++;
492
498
  const image = this.#decode(imageId, control, payload);
493
499
  if (!image) return;
494
- this.#remove(imageId);
500
+ // Re-transmitting an id replaces its pixels; its placements keep showing it.
501
+ this.#remove(imageId, true);
495
502
  this.#images.set(imageId, image);
496
503
  this.#bytes += image.data.byteLength;
497
504
  this.#used.set(imageId, this.#budget.nextTick());
498
505
  this.#revision += 1;
499
- while (
500
- (this.#bytes > this.#budget.perPaneBytes || this.#images.size > MAX_IMAGES_PER_PANE) &&
501
- this.#images.size > 1
502
- )
503
- this.evictOldest();
504
- this.#budget.enforce();
506
+ this.#enforceBudget();
505
507
  if (action === "t" || !this.#images.has(imageId)) return;
506
508
  }
509
+ if (action === "f") return this.#editFrame(imageId, control, payload);
507
510
  if (action !== "T" && action !== "p") return;
508
511
  const image = this.#images.get(imageId);
509
512
  if (!image) return;
@@ -512,7 +515,20 @@ export class KittyImageStore {
512
515
  const rows = clampCells(int(control, "r") || Math.ceil(image.height / this.#cellHeight));
513
516
  const placementId = int(control, "p");
514
517
  if (control.get("U") === "1") {
515
- this.#virtual.set(imageId, { columns, rows });
518
+ const placements = this.#virtual.get(imageId) ?? new Map();
519
+ placements.set(placementId, { columns, rows });
520
+ this.#virtual.set(imageId, placements);
521
+ let count = 0;
522
+ for (const entries of this.#virtual.values()) count += entries.size;
523
+ for (const [id, entries] of this.#virtual) {
524
+ for (const placement of entries.keys()) {
525
+ if (count <= TERMINAL_GRAPHICS_MAX_PLACEMENTS) break;
526
+ entries.delete(placement);
527
+ count -= 1;
528
+ }
529
+ if (entries.size === 0) this.#virtual.delete(id);
530
+ if (count <= TERMINAL_GRAPHICS_MAX_PLACEMENTS) break;
531
+ }
516
532
  } else if (cursor) {
517
533
  const existing = this.#cursorPlacements.findIndex(
518
534
  (p) => p.imageId === imageId && placementId !== 0 && p.placementId === placementId,
@@ -539,7 +555,13 @@ export class KittyImageStore {
539
555
  payload: string,
540
556
  ): KittyImage | null {
541
557
  let data: Uint8Array = Buffer.from(payload, "base64");
542
- if (control.get("o") === "z") {
558
+ const f = int(control, "f", 32);
559
+ const zlib = control.get("o") === "z";
560
+ // Raw pixels stay compressed end to end: renderers inflate off their UI
561
+ // thread, and the stream carries a fraction of the bytes. PNG and other
562
+ // compressed payloads are inflated here.
563
+ const keepCompressed = zlib && (f === 32 || f === 24);
564
+ if (zlib && !keepCompressed) {
543
565
  try {
544
566
  data = inflateSync(data, { maxOutputLength: TERMINAL_GRAPHICS_MAX_IMAGE_BYTES });
545
567
  } catch {
@@ -547,7 +569,6 @@ export class KittyImageStore {
547
569
  }
548
570
  }
549
571
  if (data.byteLength === 0 || data.byteLength > TERMINAL_GRAPHICS_MAX_IMAGE_BYTES) return null;
550
- const f = int(control, "f", 32);
551
572
  let format: KittyImageFormat;
552
573
  let width: number;
553
574
  let height: number;
@@ -559,35 +580,115 @@ export class KittyImageStore {
559
580
  } else if (f === 32 || f === 24) {
560
581
  width = int(control, "s");
561
582
  height = int(control, "v");
562
- format = f === 32 ? "rgba" : "rgb";
563
- if (!width || !height || data.length !== width * height * (f === 32 ? 4 : 3)) return null;
583
+ const bytes = width * height * (f === 32 ? 4 : 3);
584
+ if (!width || !height || bytes > MAX_RAW_IMAGE_BYTES) return null;
585
+ if (keepCompressed) format = f === 32 ? "rgba-zlib" : "rgb-zlib";
586
+ else {
587
+ format = f === 32 ? "rgba" : "rgb";
588
+ if (data.length !== bytes) return null;
589
+ }
564
590
  } else return null;
565
591
  if (width < 1 || height < 1 || width > MAX_IMAGE_SIDE || height > MAX_IMAGE_SIDE) return null;
566
592
  const digest = createHash("sha256").update(data).digest("hex").slice(0, 32);
567
593
  return { imageId, format, width, height, data, digest };
568
594
  }
569
595
 
596
+ /**
597
+ * `a=f` edits of the root frame (`r=1`): RGBA/RGB rectangles written at
598
+ * `x,y`, replacing (`X=1`) or alpha-blending the existing pixels. New
599
+ * animation frames, `c` base frames and PNG data are not modelled.
600
+ */
601
+ #editFrame(imageId: number, control: ReadonlyMap<string, string>, payload: string): void {
602
+ const image = this.#images.get(imageId);
603
+ if (!image || int(control, "r") !== 1 || (control.get("t") ?? "d") !== "d") return;
604
+ const source = image.format === "rgba" || image.format === "rgba-zlib" ? 4 : 3;
605
+ if (image.format === "png") return;
606
+ let pixels: Uint8Array;
607
+ let patch: Uint8Array = Buffer.from(payload, "base64");
608
+ try {
609
+ pixels =
610
+ image.format === "rgba-zlib" || image.format === "rgb-zlib"
611
+ ? inflateSync(image.data, { maxOutputLength: MAX_RAW_IMAGE_BYTES })
612
+ : Uint8Array.from(image.data);
613
+ if (control.get("o") === "z")
614
+ patch = inflateSync(patch, { maxOutputLength: MAX_RAW_IMAGE_BYTES });
615
+ } catch {
616
+ return;
617
+ }
618
+ const f = int(control, "f", 32);
619
+ const depth = f === 24 ? 3 : 4;
620
+ const width = int(control, "s") || image.width;
621
+ const height = int(control, "v") || image.height;
622
+ const x0 = int(control, "x");
623
+ const y0 = int(control, "y");
624
+ if ((f !== 32 && f !== 24) || patch.length !== width * height * depth) return;
625
+ if (pixels.length !== image.width * image.height * source) return;
626
+ const replace = control.get("X") === "1";
627
+ for (let row = 0; row < height && y0 + row < image.height; row += 1)
628
+ for (let col = 0; col < width && x0 + col < image.width; col += 1) {
629
+ const from = (row * width + col) * depth;
630
+ const to = ((y0 + row) * image.width + x0 + col) * source;
631
+ const alpha = depth === 4 && !replace ? patch[from + 3]! / 255 : 1;
632
+ for (let channel = 0; channel < 3; channel += 1)
633
+ pixels[to + channel] = Math.round(
634
+ patch[from + channel]! * alpha + pixels[to + channel]! * (1 - alpha),
635
+ );
636
+ if (source === 4)
637
+ pixels[to + 3] =
638
+ depth === 4 && replace
639
+ ? patch[from + 3]!
640
+ : Math.round(255 * alpha + pixels[to + 3]! * (1 - alpha));
641
+ }
642
+ const data = deflateSync(pixels, { level: 1 });
643
+ const format = source === 4 ? "rgba-zlib" : "rgb-zlib";
644
+ this.#remove(imageId, true);
645
+ this.#images.set(imageId, {
646
+ ...image,
647
+ format,
648
+ data,
649
+ digest: createHash("sha256").update(data).digest("hex").slice(0, 32),
650
+ });
651
+ this.#bytes += data.byteLength;
652
+ this.#used.set(imageId, this.#budget.nextTick());
653
+ this.#revision += 1;
654
+ this.#enforceBudget();
655
+ }
656
+
657
+ #enforceBudget(): void {
658
+ while (
659
+ (this.#bytes > this.#budget.perPaneBytes || this.#images.size > MAX_IMAGES_PER_PANE) &&
660
+ this.#images.size > 0
661
+ )
662
+ this.evictOldest();
663
+ this.#budget.enforce();
664
+ }
665
+
570
666
  #delete(control: ReadonlyMap<string, string>): void {
571
667
  const what = control.get("d") ?? "a";
572
668
  const id = int(control, "i");
573
669
  const all = what === "a" || what === "A";
574
670
  const byId = (what === "i" || what === "I") && id !== 0;
575
671
  if (!all && !byId) return;
576
- const keep = (imageId: number) => !all && imageId !== id;
577
- const kept = this.#cursorPlacements.filter((p) => keep(p.imageId));
672
+ // With `p`, only that placement of the image goes (spec: d=i/I with p).
673
+ const placementId = control.has("p") ? int(control, "p") : null;
674
+ const doomed = (imageId: number, placement: number) =>
675
+ all || (imageId === id && (placementId === null || placement === placementId));
676
+ const kept = this.#cursorPlacements.filter((p) => !doomed(p.imageId, p.placementId));
578
677
  if (kept.length !== this.#cursorPlacements.length) {
579
678
  this.#cursorPlacements.length = 0;
580
679
  this.#cursorPlacements.push(...kept);
581
680
  this.#revision += 1;
582
681
  }
583
- for (const key of [...this.#virtual.keys()])
584
- if (!keep(key)) {
585
- this.#virtual.delete(key);
586
- this.#revision += 1;
587
- }
682
+ for (const [imageId, placements] of [...this.#virtual])
683
+ for (const placement of [...placements.keys()])
684
+ if (doomed(imageId, placement)) {
685
+ placements.delete(placement);
686
+ if (placements.size === 0) this.#virtual.delete(imageId);
687
+ this.#revision += 1;
688
+ }
588
689
  // Upper-case variants also free the image data.
589
690
  if (what === "A") for (const key of [...this.#images.keys()]) this.#remove(key);
590
- if (what === "I") this.#remove(id);
691
+ if (what === "I" && placementId === null) this.#remove(id);
591
692
  }
592
693
 
593
694
  /**
@@ -608,7 +709,7 @@ export class KittyImageStore {
608
709
  width: image.width,
609
710
  height: image.height,
610
711
  bytes: image.data.byteLength,
611
- placeholder: this.#virtual.get(image.imageId) ?? null,
712
+ placeholder: virtualGrid(this.#virtual.get(image.imageId)),
612
713
  }));
613
714
  const placements: TerminalGraphicsPlacement[] = [];
614
715
  for (const placement of this.#cursorPlacements) {
@@ -637,5 +738,19 @@ export class KittyImageStore {
637
738
  }
638
739
 
639
740
  const MAX_IMAGES_PER_PANE = TERMINAL_GRAPHICS_MAX_IMAGES;
741
+ /** Largest decoded raw image (a 4K RGBA frame is ~33 MB). */
742
+ const MAX_RAW_IMAGE_BYTES = 64 * 1024 * 1024;
743
+
744
+ /**
745
+ * Placeholder cells carry the image id; with several virtual placements the
746
+ * protocol picks one by the placement id in the underline colour. Renderers
747
+ * resolve cells by image id, so publish the most recently placed grid.
748
+ */
749
+ function virtualGrid(
750
+ placements: ReadonlyMap<number, { columns: number; rows: number }> | undefined,
751
+ ): { columns: number; rows: number } | null {
752
+ if (!placements || placements.size === 0) return null;
753
+ return [...placements.values()].at(-1)!;
754
+ }
640
755
  const MAX_IMAGE_SIDE = 10_000;
641
756
  const clampCells = (cells: number) => Math.max(1, Math.min(1_000, cells));
@@ -565,6 +565,17 @@ export class MirrorService {
565
565
  entry.channel.fitWindowViewport(semanticWindowId, cols, rows);
566
566
  }
567
567
 
568
+ setCellPixels(
569
+ session: string,
570
+ width: number,
571
+ height: number,
572
+ precise?: { width: number; height: number },
573
+ ): void {
574
+ const entry = this.channels.get(session);
575
+ if (!entry || entry.retired) return;
576
+ entry.channel.setCellPixels(width, height, precise);
577
+ }
578
+
568
579
  clearWindowViewports(session: string): void {
569
580
  const entry = this.channels.get(session);
570
581
  if (entry && !entry.retired) entry.channel.clearWindowViewports();
@@ -1309,7 +1309,66 @@ export class SessionChannel {
1309
1309
  this.input.flush();
1310
1310
  this.clearWindowViewports();
1311
1311
  this.restoreWindowSizing(this.attachedIdentity?.runtimeSessionId ?? `=${this.opts.session}`);
1312
- this.io.send(`refresh-client -C ${cols}x${rows}`);
1312
+ this.io.send(`refresh-client -C ${cols}x${rows}${this.cellPixelSuffix()}`);
1313
+ this.clientViewport = { cols, rows };
1314
+ }
1315
+
1316
+ private clientViewport: { cols: number; rows: number } | null = null;
1317
+ private cellPixels: {
1318
+ width: number;
1319
+ height: number;
1320
+ precise?: { width: number; height: number };
1321
+ } | null = null;
1322
+
1323
+ /** "/WxH" for tmux's refresh-client -C (patched tmux; stock ignores it). */
1324
+ private cellPixelSuffix(): string {
1325
+ if (!this.cellPixels) return "";
1326
+ const { width, height, precise } = this.cellPixels;
1327
+ return `/${width}x${height}` + (precise ? `/${precise.width}x${precise.height}` : "");
1328
+ }
1329
+
1330
+ /**
1331
+ * The renderer's cell size in device pixels. tmux answers CSI 14t/16t and
1332
+ * sets TIOCGWINSZ pixels from it, so image programs render 1:1 for the
1333
+ * window. Re-applies the current viewport when the size changes.
1334
+ */
1335
+ setCellPixels(width: number, height: number, precise?: { width: number; height: number }): void {
1336
+ if (
1337
+ !Number.isSafeInteger(width) ||
1338
+ !Number.isSafeInteger(height) ||
1339
+ width < 1 ||
1340
+ height < 1 ||
1341
+ width > 1024 ||
1342
+ height > 1024
1343
+ )
1344
+ throw new RangeError("cell pixel size out of range");
1345
+ if (
1346
+ precise &&
1347
+ [precise.width, precise.height].some(
1348
+ (value) => !Number.isSafeInteger(value) || value < 1000 || value > 1024000,
1349
+ )
1350
+ )
1351
+ throw new RangeError("precise cell pixel size out of range");
1352
+ if (
1353
+ this.cellPixels?.width === width &&
1354
+ this.cellPixels.height === height &&
1355
+ this.cellPixels.precise?.width === precise?.width &&
1356
+ this.cellPixels.precise?.height === precise?.height
1357
+ )
1358
+ return;
1359
+ this.cellPixels = { width, height, ...(precise ? { precise } : {}) };
1360
+ if (!this.geometryParticipating) return;
1361
+ this.input.flush();
1362
+ if (this.fittedWindows.size > 0) {
1363
+ for (const [runtimeId, size] of this.fittedWindows)
1364
+ if (this.windowsByRuntime.has(runtimeId))
1365
+ this.io.send(
1366
+ `refresh-client -C ${runtimeId}:${size.cols}x${size.rows}${this.cellPixelSuffix()}`,
1367
+ );
1368
+ } else if (this.clientViewport)
1369
+ this.io.send(
1370
+ `refresh-client -C ${this.clientViewport.cols}x${this.clientViewport.rows}${this.cellPixelSuffix()}`,
1371
+ );
1313
1372
  }
1314
1373
 
1315
1374
  private restoreWindowSizing(target: string): void {
@@ -1359,7 +1418,7 @@ export class SessionChannel {
1359
1418
  if (previous?.cols === cols && previous.rows === rows) return;
1360
1419
  this.input.flush();
1361
1420
  this.restoreWindowSizing(window.runtimeId);
1362
- this.io.send(`refresh-client -C ${window.runtimeId}:${cols}x${rows}`);
1421
+ this.io.send(`refresh-client -C ${window.runtimeId}:${cols}x${rows}${this.cellPixelSuffix()}`);
1363
1422
  this.fittedWindows.set(window.runtimeId, { cols, rows });
1364
1423
  }
1365
1424
 
@@ -1,3 +1,4 @@
1
+ import { inflateSync } from "node:zlib";
1
2
  import { PaneStreamLayoutSnapshotFrameSchemaZ, type WindowLinkTopology } from "@tmux-ide/contracts";
2
3
  import type { TerminalDeliveryGraphics } from "../session-runtime/terminal-delivery-hub.ts";
3
4
  import { z } from "zod";
@@ -10,6 +11,11 @@ import {
10
11
  PANE_STREAM_REDEEM_PATH,
11
12
  PANE_STREAM_WEBSOCKET_SUBPROTOCOL,
12
13
  TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES,
14
+ TERMINAL_GRAPHICS_CELL_PIXELS_CAPABILITY_VERSION,
15
+ TERMINAL_GRAPHICS_PRECISE_PIXELS_CAPABILITY_VERSION,
16
+ TERMINAL_GRAPHICS_LEGACY_MAX_IMAGE_BYTES,
17
+ TERMINAL_GRAPHICS_ZLIB_CAPABILITY_VERSION,
18
+ type TerminalGraphicsState,
13
19
  PaneStreamClientFrameSchemaZ,
14
20
  PaneStreamLoopbackWebSocketUrlSchemaZ,
15
21
  PaneStreamRedeemFrameSchemaZ,
@@ -305,6 +311,7 @@ export interface SessionRuntimePaneStreamTransportBinding {
305
311
  rows: number,
306
312
  semanticWindowId?: string,
307
313
  ): void;
314
+ setCellPixels?(width: number, height: number, precise?: { width: number; height: number }): void;
308
315
  close(): Promise<void>;
309
316
  }
310
317
 
@@ -1046,6 +1053,10 @@ interface PaneChannel {
1046
1053
  graphicsAnnounced: boolean;
1047
1054
  graphicsQueue: string[];
1048
1055
  graphicsSending: boolean;
1056
+ /** Image id → digest last served; a replacement for the same id is pushed. */
1057
+ graphicsServed: Map<number, string>;
1058
+ graphicsState: TerminalGraphicsState | null;
1059
+ graphicsZlib?: boolean;
1049
1060
  }
1050
1061
 
1051
1062
  interface QueuedSend {
@@ -1054,6 +1065,26 @@ interface QueuedSend {
1054
1065
  }
1055
1066
 
1056
1067
  const GRAPHICS_MAX_QUEUED_DIGESTS = 256;
1068
+
1069
+ /** Graphics state as renderers without zlib support must see it. */
1070
+ export function rawGraphicsState(state: TerminalGraphicsState): TerminalGraphicsState {
1071
+ const legacy = state.images.map((image): TerminalGraphicsState["images"][number] =>
1072
+ image.format === "rgba-zlib" || image.format === "rgb-zlib"
1073
+ ? {
1074
+ ...image,
1075
+ format: image.format === "rgba-zlib" ? "rgba" : "rgb",
1076
+ bytes: image.width * image.height * (image.format === "rgba-zlib" ? 4 : 3),
1077
+ }
1078
+ : image,
1079
+ );
1080
+ const images = legacy.filter((image) => image.bytes <= TERMINAL_GRAPHICS_LEGACY_MAX_IMAGE_BYTES);
1081
+ const kept = new Set(images.map((image) => image.digest));
1082
+ return {
1083
+ ...state,
1084
+ images,
1085
+ placements: state.placements.filter((placement) => kept.has(placement.digest)),
1086
+ };
1087
+ }
1057
1088
  const GRAPHICS_SEND_WINDOW_BYTES = 2 * 1024 * 1024;
1058
1089
 
1059
1090
  export class PaneStreamLiveConnection {
@@ -1227,6 +1258,8 @@ export class PaneStreamLiveConnection {
1227
1258
  graphicsAnnounced: false,
1228
1259
  graphicsQueue: [],
1229
1260
  graphicsSending: false,
1261
+ graphicsServed: new Map(),
1262
+ graphicsState: null,
1230
1263
  });
1231
1264
  }
1232
1265
  }
@@ -1748,17 +1781,72 @@ export class PaneStreamLiveConnection {
1748
1781
  #startGraphics(channel: PaneChannel): void {
1749
1782
  const graphics = channel.delivery?.graphics;
1750
1783
  if (!graphics) return;
1784
+ // Renderers that cannot inflate see raw formats; their bytes are inflated
1785
+ // when served.
1786
+ const zlib =
1787
+ this.#descriptor.terminalDelivery?.protocolVersions.includes(
1788
+ TERMINAL_GRAPHICS_ZLIB_CAPABILITY_VERSION,
1789
+ ) === true;
1790
+ channel.graphicsZlib = zlib;
1791
+ // Renderers that report cell pixels learn up front that this daemon
1792
+ // accepts them, before any image exists.
1793
+ const cellPixels =
1794
+ this.#descriptor.terminalDelivery?.protocolVersions.includes(
1795
+ TERMINAL_GRAPHICS_CELL_PIXELS_CAPABILITY_VERSION,
1796
+ ) === true;
1797
+ const preciseCellPixels =
1798
+ this.#descriptor.terminalDelivery?.protocolVersions.includes(
1799
+ TERMINAL_GRAPHICS_PRECISE_PIXELS_CAPABILITY_VERSION,
1800
+ ) === true;
1801
+ let announced = false;
1751
1802
  graphics.subscribe((state) => {
1803
+ announced = true;
1752
1804
  if (this.#closed || channel.closed) return;
1753
1805
  channel.graphicsAnnounced = true;
1754
- this.#sendFrame(null, { type: "terminal-graphics", pane: channel.semanticPaneId, state });
1806
+ channel.graphicsState = state;
1807
+ // A program redrawing one image id (every frame of an animation or a
1808
+ // page) needs the new bytes at once: push them instead of waiting for
1809
+ // the renderer's round trip.
1810
+ const pushed: string[] = [];
1811
+ for (const image of state.images) {
1812
+ const served = channel.graphicsServed.get(image.imageId);
1813
+ if (served !== undefined && served !== image.digest) pushed.push(image.digest);
1814
+ }
1815
+ this.#sendFrame(null, {
1816
+ type: "terminal-graphics",
1817
+ pane: channel.semanticPaneId,
1818
+ state: {
1819
+ ...(zlib ? state : rawGraphicsState(state)),
1820
+ ...(cellPixels ? { cellPixels: true as const } : {}),
1821
+ ...(preciseCellPixels ? { preciseCellPixels: true as const } : {}),
1822
+ },
1823
+ });
1824
+ if (pushed.length > 0) this.#queueGraphics(channel, pushed);
1755
1825
  });
1826
+ if (cellPixels && !announced && !this.#closed && !channel.closed) {
1827
+ channel.graphicsAnnounced = true;
1828
+ this.#sendFrame(null, {
1829
+ type: "terminal-graphics",
1830
+ pane: channel.semanticPaneId,
1831
+ state: {
1832
+ revision: 0,
1833
+ images: [],
1834
+ placements: [],
1835
+ cellPixels: true,
1836
+ ...(preciseCellPixels ? { preciseCellPixels: true as const } : {}),
1837
+ },
1838
+ });
1839
+ }
1756
1840
  }
1757
1841
 
1758
1842
  #requestGraphics(pane: string, digests: readonly string[]): void {
1759
1843
  const channel = this.#panes.get(pane);
1760
1844
  // Requests are only meaningful after an announcement; ignore stray ones.
1761
1845
  if (!channel || channel.closed || !channel.graphicsAnnounced) return;
1846
+ this.#queueGraphics(channel, digests);
1847
+ }
1848
+
1849
+ #queueGraphics(channel: PaneChannel, digests: readonly string[]): void {
1762
1850
  for (const digest of digests)
1763
1851
  if (
1764
1852
  !channel.graphicsQueue.includes(digest) &&
@@ -1778,28 +1866,49 @@ export class PaneStreamLiveConnection {
1778
1866
  try {
1779
1867
  while (!this.#closed && !channel.closed && channel.graphicsQueue.length > 0) {
1780
1868
  const digest = channel.graphicsQueue.shift()!;
1781
- const image = channel.delivery?.graphics?.image(digest);
1782
- if (!image) continue;
1783
- const total = image.data.byteLength;
1869
+ const graphics = channel.delivery?.graphics;
1870
+ const image = graphics?.image(digest);
1871
+ if (!graphics || !image) continue;
1872
+ for (const entry of channel.graphicsState?.images ?? [])
1873
+ if (entry.digest === digest) channel.graphicsServed.set(entry.imageId, digest);
1874
+ const raw = !channel.graphicsZlib && image.format.endsWith("-zlib");
1875
+ let data: Uint8Array = image.data;
1876
+ if (raw) {
1877
+ const expected = image.width * image.height * (image.format === "rgba-zlib" ? 4 : 3);
1878
+ if (expected < 1 || expected > TERMINAL_GRAPHICS_LEGACY_MAX_IMAGE_BYTES) continue;
1879
+ try {
1880
+ data = inflateSync(image.data, { maxOutputLength: expected });
1881
+ } catch {
1882
+ continue;
1883
+ }
1884
+ if (data.byteLength !== expected) continue;
1885
+ }
1886
+ if (!channel.graphicsZlib && data.byteLength > TERMINAL_GRAPHICS_LEGACY_MAX_IMAGE_BYTES)
1887
+ continue;
1888
+ const format = raw ? (image.format === "rgba-zlib" ? "rgba" : "rgb") : image.format;
1889
+ const total = data.byteLength;
1784
1890
  for (let offset = 0; offset < total; offset += TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES) {
1785
1891
  while ((this.#socket.bufferedAmount ?? 0) > GRAPHICS_SEND_WINDOW_BYTES) {
1786
1892
  if (this.#closed || channel.closed) return;
1787
1893
  await new Promise((resolve) => setTimeout(resolve, 5));
1788
1894
  }
1789
1895
  if (this.#closed || channel.closed) return;
1896
+ // A superseded frame (the id was redrawn) is abandoned mid-transfer;
1897
+ // the renderer drops the partial image and shows the newer one.
1898
+ if (offset > 0 && graphics.image(digest) !== image) break;
1790
1899
  const end = Math.min(total, offset + TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES);
1791
1900
  this.#sendFrame(null, {
1792
1901
  type: "terminal-graphics-blob",
1793
1902
  pane: channel.semanticPaneId,
1794
1903
  blob: {
1795
1904
  digest,
1796
- format: image.format,
1905
+ format,
1797
1906
  width: image.width,
1798
1907
  height: image.height,
1799
1908
  total,
1800
1909
  offset,
1801
1910
  },
1802
- data: Buffer.from(image.data.subarray(offset, end)).toString("base64"),
1911
+ data: Buffer.from(data.subarray(offset, end)).toString("base64"),
1803
1912
  });
1804
1913
  }
1805
1914
  }
@@ -2383,6 +2492,14 @@ export class PaneStreamLiveConnection {
2383
2492
  this.#requestGraphics(frame.pane, frame.digests);
2384
2493
  return;
2385
2494
  }
2495
+ if (frame.type === "terminal-cell-pixels") {
2496
+ try {
2497
+ this.#sessionRuntimeBinding?.setCellPixels?.(frame.width, frame.height, frame.precise);
2498
+ } catch {
2499
+ // A retired runtime simply keeps tmux's default cell size.
2500
+ }
2501
+ return;
2502
+ }
2386
2503
  if (frame.type === "viewport") {
2387
2504
  if (
2388
2505
  !this.#descriptor.terminalDelivery ||
@@ -256,6 +256,8 @@ export interface SessionRuntimeConsumer {
256
256
  rows: number,
257
257
  semanticWindowId?: string,
258
258
  ): void;
259
+ /** Renderer cell size in device pixels, forwarded to tmux. */
260
+ setCellPixels?(width: number, height: number, precise?: { width: number; height: number }): void;
259
261
  fitViewportWithAuthority(
260
262
  lease: SessionRuntimeAuthorityLease,
261
263
  cols: number,
@@ -1767,6 +1769,17 @@ class SessionRuntime {
1767
1769
  }
1768
1770
  }
1769
1771
 
1772
+ /** Renderer cell size in device pixels; benign, so any connected client may set it. */
1773
+ setCellPixels(
1774
+ clientId: string,
1775
+ width: number,
1776
+ height: number,
1777
+ precise?: { width: number; height: number },
1778
+ ): void {
1779
+ this.#assertConnected(clientId);
1780
+ this.#mirror.setCellPixels?.(this.session, width, height, precise);
1781
+ }
1782
+
1770
1783
  fitViewportWithAuthority(
1771
1784
  clientId: string,
1772
1785
  lease: SessionRuntimeAuthorityLease,
@@ -2342,6 +2355,11 @@ class SessionRuntimeConsumerImpl implements SessionRuntimeConsumer {
2342
2355
  this.#runtime.fitViewport(this.clientId, lease, cols, rows, semanticWindowId);
2343
2356
  }
2344
2357
 
2358
+ setCellPixels(width: number, height: number, precise?: { width: number; height: number }): void {
2359
+ this.#assertOpen();
2360
+ this.#runtime.setCellPixels(this.clientId, width, height, precise);
2361
+ }
2362
+
2345
2363
  fitViewportWithAuthority(
2346
2364
  lease: SessionRuntimeAuthorityLease,
2347
2365
  cols: number,
@@ -422,8 +422,17 @@ export class TerminalReplicaInterpreter {
422
422
  const previous = this.#backend;
423
423
  this.#backend = replacement;
424
424
  this.#attachKitty(replacement);
425
- // tmux never saw cursor-anchored placements; a capture cannot restore them.
426
- this.#kitty.dropCursorPlacements();
425
+ // A capture replay does not erase graphics already observed by this owner.
426
+ // Keep anchors only when both backends agree on their coordinate basis;
427
+ // reflow, history replacement and authoritative stream resets are ambiguous.
428
+ const sameGraphicsBasis =
429
+ operation.bootstrap === "painted-capture" &&
430
+ previous.cols === replacement.cols &&
431
+ previous.rows === replacement.rows &&
432
+ previous.graphicsViewportTop?.() !== undefined &&
433
+ previous.graphicsViewportTop?.() === replacement.graphicsViewportTop?.() &&
434
+ previous.modes().alternateScreen === replacement.modes().alternateScreen;
435
+ if (!sameGraphicsBasis) this.#kitty.dropCursorPlacements();
427
436
  this.#nativeReseedRequested = false;
428
437
  this.#scrollback = scrollback;
429
438
  this.#bootstrap = {
@@ -459,6 +468,8 @@ export class TerminalReplicaInterpreter {
459
468
  return;
460
469
  }
461
470
  if (operation.type === "resize") {
471
+ if (operation.cols !== this.#backend.cols || operation.rows !== this.#backend.rows)
472
+ this.#kitty.dropCursorPlacements();
462
473
  this.#causalCell?.fail("geometry-drift");
463
474
  if (this.#backend.modes().synchronizedOutput) {
464
475
  // Geometry is part of the admitted FIFO even while publication is
@@ -512,6 +523,11 @@ export class TerminalReplicaInterpreter {
512
523
  // Always forward the scanner's output: tmux splits %output (~2 KiB) inside
513
524
  // kitty APCs (~4 KiB chunks), so a chunk with no completed command can still
514
525
  // be all image payload that must never reach the VT parser as text.
526
+ // A chunk entirely inside image payload changes nothing the parser can
527
+ // see. Skipping it matters: every parser write settles on a later timer
528
+ // turn, so a frame split into dozens of %output chunks would otherwise
529
+ // serialize dozens of turns and fall behind a 30 fps image stream.
530
+ if (scanned.forward.byteLength === 0) return;
515
531
  await this.#writeToBackend(this.#backend, scanned.forward);
516
532
  if (this.#observability.enabled)
517
533
  this.#observability.recordSpan(