tmux-ide 2.9.7 → 2.9.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.
Files changed (34) hide show
  1. package/bin/cli.js +323 -55
  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 +49 -5
  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 -1
  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 +65 -4
  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 -1
  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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-ide",
3
- "version": "2.9.7",
3
+ "version": "2.9.9",
4
4
  "description": "The open-source workspace for coding agents: run Claude Code, Codex and other agents in tmux with live status",
5
5
  "type": "module",
6
6
  "bin": {
@@ -105,7 +105,7 @@
105
105
  "test:tui-gallery": "bun test --preload @opentui/solid/preload --preload ./packages/daemon/test-support/opentui-renderer-preload.ts ./scripts/tui-gallery/gallery.test.tsx",
106
106
  "typecheck:tui-gallery": "tsc --noEmit -p scripts/tsconfig.tui-gallery.json",
107
107
  "test:spark-qualification": "node --import tsx --test scripts/lib/spark-*.test.ts scripts/lib/spark-*.test.mjs",
108
- "test:installer": "node --test scripts/install.test.mjs",
108
+ "test:installer": "node --test scripts/install.test.mjs scripts/lib/installer-service-qualification.test.mjs",
109
109
  "qualify:styled-ssh": "node scripts/qualify-styled-ssh.mjs"
110
110
  },
111
111
  "keywords": [
@@ -360,6 +360,22 @@ export const PaneStreamTerminalGraphicsRequestFrameSchemaZ = z
360
360
  })
361
361
  .strict();
362
362
 
363
+ /** The renderer's terminal cell size in device pixels (see capability 103). */
364
+ export const PaneStreamTerminalCellPixelsFrameSchemaZ = z
365
+ .object({
366
+ type: z.literal("terminal-cell-pixels"),
367
+ width: z.number().int().min(1).max(1024),
368
+ height: z.number().int().min(1).max(1024),
369
+ precise: z
370
+ .object({
371
+ width: z.number().int().min(1000).max(1024000),
372
+ height: z.number().int().min(1000).max(1024000),
373
+ })
374
+ .strict()
375
+ .optional(),
376
+ })
377
+ .strict();
378
+
363
379
  export const PaneStreamClientFrameSchemaZ = z.union([
364
380
  PaneStreamInputFrameSchemaZ,
365
381
  PaneStreamConsumedFrameSchemaZ,
@@ -374,6 +390,7 @@ export const PaneStreamClientFrameSchemaZ = z.union([
374
390
  PaneStreamAuthorityReleaseFrameSchemaZ,
375
391
  PaneStreamClockProbeFrameSchemaZ,
376
392
  PaneStreamTerminalGraphicsRequestFrameSchemaZ,
393
+ PaneStreamTerminalCellPixelsFrameSchemaZ,
377
394
  ]);
378
395
  export type PaneStreamClientFrame = z.infer<typeof PaneStreamClientFrameSchemaZ>;
379
396
 
@@ -15,16 +15,39 @@ import { z } from "zod";
15
15
  * version; daemons that know it send graphics frames only to such renderers.
16
16
  */
17
17
  export const TERMINAL_GRAPHICS_CAPABILITY_VERSION = 101;
18
+ /** Renderers that inflate `rgba-zlib`/`rgb-zlib` themselves offer this too. */
19
+ export const TERMINAL_GRAPHICS_ZLIB_CAPABILITY_VERSION = 102;
20
+ /**
21
+ * Renderers that report their cell size in device pixels offer this; the
22
+ * daemon then marks graphics state with `cellPixels: true` and accepts
23
+ * `terminal-cell-pixels`, so programs size images 1:1 for the window.
24
+ */
25
+ export const TERMINAL_GRAPHICS_CELL_PIXELS_CAPABILITY_VERSION = 103;
26
+ /** Thousandths of a device pixel, avoiding accumulated per-cell rounding. */
27
+ export const TERMINAL_GRAPHICS_PRECISE_PIXELS_CAPABILITY_VERSION = 104;
18
28
  /** Raw image bytes per blob frame (base64 on the wire stays below 1 MiB). */
19
29
  export const TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES = 512 * 1024;
20
- /** Largest single image a daemon publishes. */
21
- export const TERMINAL_GRAPHICS_MAX_IMAGE_BYTES = 32 * 1024 * 1024;
30
+ /** Largest single image a daemon publishes (a 4K RGBA frame is ~33 MB). */
31
+ export const TERMINAL_GRAPHICS_MAX_IMAGE_BYTES = 64 * 1024 * 1024;
32
+ /** What renderers without the zlib capability accept. */
33
+ export const TERMINAL_GRAPHICS_LEGACY_MAX_IMAGE_BYTES = 32 * 1024 * 1024;
22
34
  export const TERMINAL_GRAPHICS_MAX_IMAGES = 256;
23
35
  export const TERMINAL_GRAPHICS_MAX_PLACEMENTS = 256;
24
36
  export const TERMINAL_GRAPHICS_MAX_REQUEST_DIGESTS = 64;
25
37
 
26
38
  export const TerminalGraphicsDigestSchemaZ = z.string().regex(/^[0-9a-f]{32}$/u);
27
- export const TerminalGraphicsFormatSchemaZ = z.enum(["png", "rgba", "rgb"]);
39
+ /**
40
+ * `*-zlib` carry raw pixels still deflated as the program sent them (`o=z`);
41
+ * only renderers that offered {@link TERMINAL_GRAPHICS_ZLIB_CAPABILITY_VERSION}
42
+ * receive them.
43
+ */
44
+ export const TerminalGraphicsFormatSchemaZ = z.enum([
45
+ "png",
46
+ "rgba",
47
+ "rgb",
48
+ "rgba-zlib",
49
+ "rgb-zlib",
50
+ ]);
28
51
  export type TerminalGraphicsFormat = z.infer<typeof TerminalGraphicsFormatSchemaZ>;
29
52
  const Dimension = z.number().int().min(1).max(10_000);
30
53
  const Cells = z.number().int().min(1).max(1_000);
@@ -66,6 +89,9 @@ export const TerminalGraphicsStateSchemaZ = z
66
89
  revision: z.number().int().nonnegative(),
67
90
  images: z.array(TerminalGraphicsImageSchemaZ).max(TERMINAL_GRAPHICS_MAX_IMAGES),
68
91
  placements: z.array(TerminalGraphicsPlacementSchemaZ).max(TERMINAL_GRAPHICS_MAX_PLACEMENTS),
92
+ /** The daemon accepts `terminal-cell-pixels` (only sent to renderers offering 103). */
93
+ cellPixels: z.literal(true).optional(),
94
+ preciseCellPixels: z.literal(true).optional(),
69
95
  })
70
96
  .strict();
71
97
  export type TerminalGraphicsState = z.infer<typeof TerminalGraphicsStateSchemaZ>;
@@ -158,7 +158,19 @@ export function resolveKittyPlacements(
158
158
  grid: readonly TerminalReplicaRow[],
159
159
  state: TerminalGraphicsState,
160
160
  ): TerminalGraphicsPlacement[] {
161
- const placements: TerminalGraphicsPlacement[] = [];
161
+ return resolveKittyImagePlacements(grid, state).map(({ placement }) => placement);
162
+ }
163
+
164
+ /**
165
+ * As {@link resolveKittyPlacements}, with the kitty image id each placement
166
+ * shows: exact for placeholder cells (they encode it); for cursor placements
167
+ * the first image with that content.
168
+ */
169
+ export function resolveKittyImagePlacements(
170
+ grid: readonly TerminalReplicaRow[],
171
+ state: TerminalGraphicsState,
172
+ ): { placement: TerminalGraphicsPlacement; imageId: number }[] {
173
+ const placements: { placement: TerminalGraphicsPlacement; imageId: number }[] = [];
162
174
  const byId = new Map(state.images.map((image) => [image.imageId, image]));
163
175
  let scan = false;
164
176
  for (const image of state.images) if (image.placeholder) scan = true;
@@ -173,18 +185,24 @@ export function resolveKittyPlacements(
173
185
  )
174
186
  continue;
175
187
  placements.push({
176
- digest: image.digest,
177
- row: rect.row,
178
- column: rect.column,
179
- rows: rect.rows,
180
- columns: rect.columns,
181
- sourceRow: rect.srcRow,
182
- sourceColumn: rect.srcCol,
183
- totalRows: virtualGrid.rows,
184
- totalColumns: virtualGrid.columns,
188
+ imageId: image.imageId,
189
+ placement: {
190
+ digest: image.digest,
191
+ row: rect.row,
192
+ column: rect.column,
193
+ rows: rect.rows,
194
+ columns: rect.columns,
195
+ sourceRow: rect.srcRow,
196
+ sourceColumn: rect.srcCol,
197
+ totalRows: virtualGrid.rows,
198
+ totalColumns: virtualGrid.columns,
199
+ },
185
200
  });
186
201
  }
187
202
  }
188
- placements.push(...state.placements);
203
+ for (const placement of state.placements) {
204
+ const image = state.images.find((candidate) => candidate.digest === placement.digest);
205
+ if (image) placements.push({ placement, imageId: image.imageId });
206
+ }
189
207
  return placements;
190
208
  }
@@ -21,9 +21,9 @@
21
21
  <key>CFBundlePackageType</key>
22
22
  <string>APPL</string>
23
23
  <key>CFBundleShortVersionString</key>
24
- <string>2.9.7</string>
24
+ <string>2.9.9</string>
25
25
  <key>CFBundleVersion</key>
26
- <string>2.9.7</string>
26
+ <string>2.9.9</string>
27
27
  <key>LSMinimumSystemVersion</key>
28
28
  <string>11.0</string>
29
29
  <key>LSUIElement</key>
@@ -10,7 +10,7 @@
10
10
  </data>
11
11
  <key>Resources/Assets.car</key>
12
12
  <data>
13
- Qm5AzGjCw77rGEStuJKTDz8K1aw=
13
+ SmelhC8h5DucT+rzFXGF+vwuM4M=
14
14
  </data>
15
15
  </dict>
16
16
  <key>files2</key>
@@ -26,7 +26,7 @@
26
26
  <dict>
27
27
  <key>hash2</key>
28
28
  <data>
29
- ZcHZD0tqsRi0ialJfr0XsFGJNpXz+YpfH7HT9lYpqVI=
29
+ jOmbXxbTijoy+G+1Ruyi6wQKiSnUSw3V8mkHnJwyMlg=
30
30
  </data>
31
31
  </dict>
32
32
  </dict>
@@ -25,7 +25,11 @@
25
25
  },
26
26
  {
27
27
  "patch": "kitty-graphics.patch",
28
- "patchSha256": "4a9a34701f0e31e48fe5ccc27bc31a53cb50b7bf24a871ba99cb1c72a411ea43"
28
+ "patchSha256": "1ff7b3022aa2a3d7014b7339c0f2ae868292d5d6df3c668cefa403509ecbc1f6"
29
+ },
30
+ {
31
+ "patch": "precise-cell-pixels.patch",
32
+ "patchSha256": "11b2f09f347d0369a744be5aef14e7be3407f4bfa6cae2047b9477234a01ba24"
29
33
  }
30
34
  ],
31
35
  "experimentalExtensions": [
@@ -44,7 +48,7 @@
44
48
  "platform": "darwin",
45
49
  "arch": "arm64",
46
50
  "files": {
47
- "tmux": "e06c32b2173353c53e2b3e3e6cfb3db4aba06164e1d923d3463e3b72edc4f976",
51
+ "tmux": "78b5efc7cb7308e892487a0ea046c531a271680c812c8b8851796dbdf57b0fe8",
48
52
  "lib/libutf8proc.3.3.0.dylib": "da17133f84767a9873ff61402ac1a9d6a19ddfc1efb90cffc7c894df68532ce2",
49
53
  "lib/libncursesw.6.dylib": "c9b3c77b5874b0c694590774f7b7b1568b2bb13e82707bdf29cb7296446fe596",
50
54
  "lib/libevent_core-2.1.7.dylib": "65a205eee103b07356c1d6b1102b6b6ca08b0abaad44d960a0dad809722d0a9d",
@@ -25,7 +25,11 @@
25
25
  },
26
26
  {
27
27
  "patch": "kitty-graphics.patch",
28
- "patchSha256": "4a9a34701f0e31e48fe5ccc27bc31a53cb50b7bf24a871ba99cb1c72a411ea43"
28
+ "patchSha256": "1ff7b3022aa2a3d7014b7339c0f2ae868292d5d6df3c668cefa403509ecbc1f6"
29
+ },
30
+ {
31
+ "patch": "precise-cell-pixels.patch",
32
+ "patchSha256": "11b2f09f347d0369a744be5aef14e7be3407f4bfa6cae2047b9477234a01ba24"
29
33
  }
30
34
  ],
31
35
  "experimentalExtensions": [
@@ -72,7 +76,7 @@
72
76
  "platform": "linux",
73
77
  "arch": "arm64",
74
78
  "files": {
75
- "tmux": "d6e98ac909428ddd6e0038437cf78a4e8e4b21ecd12c1d800cfe7c8864307d8f",
79
+ "tmux": "23ce24278d4c778f6ce974c492e46fcc09e20de577125a841f1d5f91b21a79ce",
76
80
  "lib/libutf8proc.so.3": "83cd7bce65037a46494b154b0f7bfd2479bc1659f56321b9ec896f4b64fb490c",
77
81
  "lib/libtinfo.so.6": "22952a4f434fd0f356fad23b92de42084e6666d8a594eadd11511b059cc5bb66",
78
82
  "lib/libevent_core-2.1.so.7": "80ad9f14cdea14f65c7a778fef4d831d51d23edc55efd263d7f265587eab73d4",
@@ -25,7 +25,11 @@
25
25
  },
26
26
  {
27
27
  "patch": "kitty-graphics.patch",
28
- "patchSha256": "4a9a34701f0e31e48fe5ccc27bc31a53cb50b7bf24a871ba99cb1c72a411ea43"
28
+ "patchSha256": "1ff7b3022aa2a3d7014b7339c0f2ae868292d5d6df3c668cefa403509ecbc1f6"
29
+ },
30
+ {
31
+ "patch": "precise-cell-pixels.patch",
32
+ "patchSha256": "11b2f09f347d0369a744be5aef14e7be3407f4bfa6cae2047b9477234a01ba24"
29
33
  }
30
34
  ],
31
35
  "experimentalExtensions": [
@@ -72,7 +76,7 @@
72
76
  "platform": "linux",
73
77
  "arch": "x64",
74
78
  "files": {
75
- "tmux": "5e1c6631e090ce04ce3712b9f0143a492d74f9ff45624f4d8e838ad07596c6d3",
79
+ "tmux": "ef3549656f724cb37e5e94f718d9997570fb3cf55bdafa2585fa1f827e66ba3c",
76
80
  "lib/libutf8proc.so.3": "a5aa10c99ac2e9a30c4e01fa4baa85a03dadf71eb73119c76fb3f9f54798962d",
77
81
  "lib/libtinfo.so.6": "170db4dd0c324ee73d8d5440a38708726066e1288aba942d57191c6f002240d9",
78
82
  "lib/libevent_core-2.1.so.7": "adf343350b739d3dba2b8a6cccbe0729cd9e9ee32b38ba69c08bd14f24a8431e",
@@ -583,6 +583,9 @@ function parsePaneSnapshot(stdout, expected) {
583
583
  * application-shell inventory. Names are resolved exactly to `$session_id`
584
584
  * before any pane query, and a second identity/topology snapshot closes races.
585
585
  */
586
+ const INVENTORY_FENCE_ATTEMPTS = 4;
587
+ /** 25, 50, 100 ms: enough for a mutation's identity stamps, bounded for every reader. */
588
+ const settleDelay = (attempt) => new Promise((resolve) => setTimeout(resolve, 25 * 2 ** (attempt - 1)));
586
589
  export async function discoverWorkspaceRegistryTerminalInventory(registry, runner, signal) {
587
590
  const workspaces = registry.list();
588
591
  if (workspaces.length > MAX_DISCOVERED_WORKSPACES) {
@@ -604,13 +607,6 @@ export async function discoverWorkspaceRegistryTerminalInventory(registry, runne
604
607
  const before = await requiredTmuxResult(runner, argv, signal);
605
608
  if (before === null)
606
609
  continue;
607
- const first = parsePaneSnapshot(before, identity);
608
- const panes = first.panes;
609
- const after = await requiredTmuxResult(runner, argv, signal);
610
- if (after === null)
611
- throw new NativeTerminalAttachmentRuntimeError("discovery-failed");
612
- const second = parsePaneSnapshot(after, identity);
613
- const latest = second.panes;
614
610
  // Foreground commands, titles, focus and paths can change while the same
615
611
  // pane remains valid. Fence binding/topology only, then publish one complete
616
612
  // latest snapshot rather than mixing presentation values across reads.
@@ -625,12 +621,29 @@ export async function discoverWorkspaceRegistryTerminalInventory(registry, runne
625
621
  "windowStamp",
626
622
  "index",
627
623
  ];
628
- if (first.linksProof !== second.linksProof ||
629
- panes.length !== latest.length ||
630
- panes.some((pane, index) => proofKeys.some((key) => pane[key] !== latest[index][key]))) {
631
- throw new NativeTerminalAttachmentRuntimeError("discovery-failed");
624
+ const matches = (first, second) => first.linksProof === second.linksProof &&
625
+ first.panes.length === second.panes.length &&
626
+ first.panes.every((pane, index) => proofKeys.every((key) => pane[key] === second.panes[index][key]));
627
+ // A just-created window or split is still being stamped, so topology can
628
+ // move between two reads. Let it settle for a bounded number of re-reads;
629
+ // only two consecutive matching reads are ever published.
630
+ let previous = parsePaneSnapshot(before, identity);
631
+ let stable = null;
632
+ for (let attempt = 0; attempt < INVENTORY_FENCE_ATTEMPTS && !stable; attempt += 1) {
633
+ if (attempt > 0)
634
+ await settleDelay(attempt);
635
+ const after = await requiredTmuxResult(runner, argv, signal);
636
+ if (after === null)
637
+ throw new NativeTerminalAttachmentRuntimeError("discovery-failed");
638
+ const next = parsePaneSnapshot(after, identity);
639
+ if (matches(previous, next))
640
+ stable = next;
641
+ else
642
+ previous = next;
632
643
  }
633
- bySessionName.set(sessionName, latest);
644
+ if (!stable)
645
+ throw new NativeTerminalAttachmentRuntimeError("discovery-failed");
646
+ bySessionName.set(sessionName, stable.panes);
634
647
  }
635
648
  const panes = [];
636
649
  for (const workspace of workspaces) {
@@ -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 { TERMINAL_GRAPHICS_MAX_IMAGE_BYTES, TERMINAL_GRAPHICS_MAX_IMAGES, TERMINAL_GRAPHICS_MAX_PLACEMENTS, } from "@tmux-ide/contracts";
31
31
  /** Private OSC used to mark an APC's byte position for the VT parser. */
32
32
  export const KITTY_PLACEMENT_OSC = 6974;
@@ -296,11 +296,12 @@ export const DEFAULT_KITTY_IMAGE_BUDGET = new KittyImageBudget({
296
296
  perPaneBytes: 64 * 1024 * 1024,
297
297
  globalBytes: 256 * 1024 * 1024,
298
298
  });
299
- /** Per-pane image store. Survives reseeds; cursor placements do not. */
299
+ /** Per-pane image store. Survives reseeds; the interpreter validates cursor placement coordinates. */
300
300
  export class KittyImageStore {
301
301
  #images = new Map();
302
302
  #used = new Map();
303
303
  #cursorPlacements = [];
304
+ /** `U=1` virtual placements per image, by placement id (0 when unnamed). */
304
305
  #virtual = new Map();
305
306
  #pending = null;
306
307
  #nextAnonymous = 0x7f000000;
@@ -340,7 +341,7 @@ export class KittyImageStore {
340
341
  this.#used.clear();
341
342
  this.#bytes = 0;
342
343
  }
343
- /** A capture reseed replaced the grid: tmux holds no record of cursor placements. */
344
+ /** The coordinate basis changed: tmux holds no record to restore cursor anchors. */
344
345
  dropCursorPlacements() {
345
346
  if (this.#cursorPlacements.length === 0)
346
347
  return;
@@ -366,12 +367,17 @@ export class KittyImageStore {
366
367
  return 0;
367
368
  return this.#remove(victim);
368
369
  }
369
- #remove(imageId) {
370
+ #remove(imageId, keepPlacements = false) {
370
371
  const image = this.#images.get(imageId);
371
372
  if (!image)
372
373
  return 0;
373
374
  this.#images.delete(imageId);
374
375
  this.#used.delete(imageId);
376
+ if (keepPlacements) {
377
+ this.#bytes -= image.data.byteLength;
378
+ this.#revision += 1;
379
+ return image.data.byteLength;
380
+ }
375
381
  this.#virtual.delete(imageId);
376
382
  this.#bytes -= image.data.byteLength;
377
383
  const kept = this.#cursorPlacements.filter((p) => p.imageId !== imageId);
@@ -404,7 +410,7 @@ export class KittyImageStore {
404
410
  return;
405
411
  }
406
412
  const action = control.get("a") ?? "t";
407
- if ((action === "t" || action === "T") && more) {
413
+ if ((action === "t" || action === "T" || action === "f") && more) {
408
414
  this.#pending = { control, payload: [command.payload], bytes: command.payload.length };
409
415
  return;
410
416
  }
@@ -425,18 +431,18 @@ export class KittyImageStore {
425
431
  const image = this.#decode(imageId, control, payload);
426
432
  if (!image)
427
433
  return;
428
- this.#remove(imageId);
434
+ // Re-transmitting an id replaces its pixels; its placements keep showing it.
435
+ this.#remove(imageId, true);
429
436
  this.#images.set(imageId, image);
430
437
  this.#bytes += image.data.byteLength;
431
438
  this.#used.set(imageId, this.#budget.nextTick());
432
439
  this.#revision += 1;
433
- while ((this.#bytes > this.#budget.perPaneBytes || this.#images.size > MAX_IMAGES_PER_PANE) &&
434
- this.#images.size > 1)
435
- this.evictOldest();
436
- this.#budget.enforce();
440
+ this.#enforceBudget();
437
441
  if (action === "t" || !this.#images.has(imageId))
438
442
  return;
439
443
  }
444
+ if (action === "f")
445
+ return this.#editFrame(imageId, control, payload);
440
446
  if (action !== "T" && action !== "p")
441
447
  return;
442
448
  const image = this.#images.get(imageId);
@@ -447,7 +453,24 @@ export class KittyImageStore {
447
453
  const rows = clampCells(int(control, "r") || Math.ceil(image.height / this.#cellHeight));
448
454
  const placementId = int(control, "p");
449
455
  if (control.get("U") === "1") {
450
- this.#virtual.set(imageId, { columns, rows });
456
+ const placements = this.#virtual.get(imageId) ?? new Map();
457
+ placements.set(placementId, { columns, rows });
458
+ this.#virtual.set(imageId, placements);
459
+ let count = 0;
460
+ for (const entries of this.#virtual.values())
461
+ count += entries.size;
462
+ for (const [id, entries] of this.#virtual) {
463
+ for (const placement of entries.keys()) {
464
+ if (count <= TERMINAL_GRAPHICS_MAX_PLACEMENTS)
465
+ break;
466
+ entries.delete(placement);
467
+ count -= 1;
468
+ }
469
+ if (entries.size === 0)
470
+ this.#virtual.delete(id);
471
+ if (count <= TERMINAL_GRAPHICS_MAX_PLACEMENTS)
472
+ break;
473
+ }
451
474
  }
452
475
  else if (cursor) {
453
476
  const existing = this.#cursorPlacements.findIndex((p) => p.imageId === imageId && placementId !== 0 && p.placementId === placementId);
@@ -469,7 +492,13 @@ export class KittyImageStore {
469
492
  }
470
493
  #decode(imageId, control, payload) {
471
494
  let data = Buffer.from(payload, "base64");
472
- if (control.get("o") === "z") {
495
+ const f = int(control, "f", 32);
496
+ const zlib = control.get("o") === "z";
497
+ // Raw pixels stay compressed end to end: renderers inflate off their UI
498
+ // thread, and the stream carries a fraction of the bytes. PNG and other
499
+ // compressed payloads are inflated here.
500
+ const keepCompressed = zlib && (f === 32 || f === 24);
501
+ if (zlib && !keepCompressed) {
473
502
  try {
474
503
  data = inflateSync(data, { maxOutputLength: TERMINAL_GRAPHICS_MAX_IMAGE_BYTES });
475
504
  }
@@ -479,7 +508,6 @@ export class KittyImageStore {
479
508
  }
480
509
  if (data.byteLength === 0 || data.byteLength > TERMINAL_GRAPHICS_MAX_IMAGE_BYTES)
481
510
  return null;
482
- const f = int(control, "f", 32);
483
511
  let format;
484
512
  let width;
485
513
  let height;
@@ -493,9 +521,16 @@ export class KittyImageStore {
493
521
  else if (f === 32 || f === 24) {
494
522
  width = int(control, "s");
495
523
  height = int(control, "v");
496
- format = f === 32 ? "rgba" : "rgb";
497
- if (!width || !height || data.length !== width * height * (f === 32 ? 4 : 3))
524
+ const bytes = width * height * (f === 32 ? 4 : 3);
525
+ if (!width || !height || bytes > MAX_RAW_IMAGE_BYTES)
498
526
  return null;
527
+ if (keepCompressed)
528
+ format = f === 32 ? "rgba-zlib" : "rgb-zlib";
529
+ else {
530
+ format = f === 32 ? "rgba" : "rgb";
531
+ if (data.length !== bytes)
532
+ return null;
533
+ }
499
534
  }
500
535
  else
501
536
  return null;
@@ -504,6 +539,75 @@ export class KittyImageStore {
504
539
  const digest = createHash("sha256").update(data).digest("hex").slice(0, 32);
505
540
  return { imageId, format, width, height, data, digest };
506
541
  }
542
+ /**
543
+ * `a=f` edits of the root frame (`r=1`): RGBA/RGB rectangles written at
544
+ * `x,y`, replacing (`X=1`) or alpha-blending the existing pixels. New
545
+ * animation frames, `c` base frames and PNG data are not modelled.
546
+ */
547
+ #editFrame(imageId, control, payload) {
548
+ const image = this.#images.get(imageId);
549
+ if (!image || int(control, "r") !== 1 || (control.get("t") ?? "d") !== "d")
550
+ return;
551
+ const source = image.format === "rgba" || image.format === "rgba-zlib" ? 4 : 3;
552
+ if (image.format === "png")
553
+ return;
554
+ let pixels;
555
+ let patch = Buffer.from(payload, "base64");
556
+ try {
557
+ pixels =
558
+ image.format === "rgba-zlib" || image.format === "rgb-zlib"
559
+ ? inflateSync(image.data, { maxOutputLength: MAX_RAW_IMAGE_BYTES })
560
+ : Uint8Array.from(image.data);
561
+ if (control.get("o") === "z")
562
+ patch = inflateSync(patch, { maxOutputLength: MAX_RAW_IMAGE_BYTES });
563
+ }
564
+ catch {
565
+ return;
566
+ }
567
+ const f = int(control, "f", 32);
568
+ const depth = f === 24 ? 3 : 4;
569
+ const width = int(control, "s") || image.width;
570
+ const height = int(control, "v") || image.height;
571
+ const x0 = int(control, "x");
572
+ const y0 = int(control, "y");
573
+ if ((f !== 32 && f !== 24) || patch.length !== width * height * depth)
574
+ return;
575
+ if (pixels.length !== image.width * image.height * source)
576
+ return;
577
+ const replace = control.get("X") === "1";
578
+ for (let row = 0; row < height && y0 + row < image.height; row += 1)
579
+ for (let col = 0; col < width && x0 + col < image.width; col += 1) {
580
+ const from = (row * width + col) * depth;
581
+ const to = ((y0 + row) * image.width + x0 + col) * source;
582
+ const alpha = depth === 4 && !replace ? patch[from + 3] / 255 : 1;
583
+ for (let channel = 0; channel < 3; channel += 1)
584
+ pixels[to + channel] = Math.round(patch[from + channel] * alpha + pixels[to + channel] * (1 - alpha));
585
+ if (source === 4)
586
+ pixels[to + 3] =
587
+ depth === 4 && replace
588
+ ? patch[from + 3]
589
+ : Math.round(255 * alpha + pixels[to + 3] * (1 - alpha));
590
+ }
591
+ const data = deflateSync(pixels, { level: 1 });
592
+ const format = source === 4 ? "rgba-zlib" : "rgb-zlib";
593
+ this.#remove(imageId, true);
594
+ this.#images.set(imageId, {
595
+ ...image,
596
+ format,
597
+ data,
598
+ digest: createHash("sha256").update(data).digest("hex").slice(0, 32),
599
+ });
600
+ this.#bytes += data.byteLength;
601
+ this.#used.set(imageId, this.#budget.nextTick());
602
+ this.#revision += 1;
603
+ this.#enforceBudget();
604
+ }
605
+ #enforceBudget() {
606
+ while ((this.#bytes > this.#budget.perPaneBytes || this.#images.size > MAX_IMAGES_PER_PANE) &&
607
+ this.#images.size > 0)
608
+ this.evictOldest();
609
+ this.#budget.enforce();
610
+ }
507
611
  #delete(control) {
508
612
  const what = control.get("d") ?? "a";
509
613
  const id = int(control, "i");
@@ -511,23 +615,28 @@ export class KittyImageStore {
511
615
  const byId = (what === "i" || what === "I") && id !== 0;
512
616
  if (!all && !byId)
513
617
  return;
514
- const keep = (imageId) => !all && imageId !== id;
515
- const kept = this.#cursorPlacements.filter((p) => keep(p.imageId));
618
+ // With `p`, only that placement of the image goes (spec: d=i/I with p).
619
+ const placementId = control.has("p") ? int(control, "p") : null;
620
+ const doomed = (imageId, placement) => all || (imageId === id && (placementId === null || placement === placementId));
621
+ const kept = this.#cursorPlacements.filter((p) => !doomed(p.imageId, p.placementId));
516
622
  if (kept.length !== this.#cursorPlacements.length) {
517
623
  this.#cursorPlacements.length = 0;
518
624
  this.#cursorPlacements.push(...kept);
519
625
  this.#revision += 1;
520
626
  }
521
- for (const key of [...this.#virtual.keys()])
522
- if (!keep(key)) {
523
- this.#virtual.delete(key);
524
- this.#revision += 1;
525
- }
627
+ for (const [imageId, placements] of [...this.#virtual])
628
+ for (const placement of [...placements.keys()])
629
+ if (doomed(imageId, placement)) {
630
+ placements.delete(placement);
631
+ if (placements.size === 0)
632
+ this.#virtual.delete(imageId);
633
+ this.#revision += 1;
634
+ }
526
635
  // Upper-case variants also free the image data.
527
636
  if (what === "A")
528
637
  for (const key of [...this.#images.keys()])
529
638
  this.#remove(key);
530
- if (what === "I")
639
+ if (what === "I" && placementId === null)
531
640
  this.#remove(id);
532
641
  }
533
642
  /**
@@ -543,7 +652,7 @@ export class KittyImageStore {
543
652
  width: image.width,
544
653
  height: image.height,
545
654
  bytes: image.data.byteLength,
546
- placeholder: this.#virtual.get(image.imageId) ?? null,
655
+ placeholder: virtualGrid(this.#virtual.get(image.imageId)),
547
656
  }));
548
657
  const placements = [];
549
658
  for (const placement of this.#cursorPlacements) {
@@ -574,5 +683,17 @@ export class KittyImageStore {
574
683
  }
575
684
  }
576
685
  const MAX_IMAGES_PER_PANE = TERMINAL_GRAPHICS_MAX_IMAGES;
686
+ /** Largest decoded raw image (a 4K RGBA frame is ~33 MB). */
687
+ const MAX_RAW_IMAGE_BYTES = 64 * 1024 * 1024;
688
+ /**
689
+ * Placeholder cells carry the image id; with several virtual placements the
690
+ * protocol picks one by the placement id in the underline colour. Renderers
691
+ * resolve cells by image id, so publish the most recently placed grid.
692
+ */
693
+ function virtualGrid(placements) {
694
+ if (!placements || placements.size === 0)
695
+ return null;
696
+ return [...placements.values()].at(-1);
697
+ }
577
698
  const MAX_IMAGE_SIDE = 10_000;
578
699
  const clampCells = (cells) => Math.max(1, Math.min(1_000, cells));
@@ -369,6 +369,12 @@ export class MirrorService {
369
369
  this.assertWindowOwnership(session);
370
370
  entry.channel.fitWindowViewport(semanticWindowId, cols, rows);
371
371
  }
372
+ setCellPixels(session, width, height, precise) {
373
+ const entry = this.channels.get(session);
374
+ if (!entry || entry.retired)
375
+ return;
376
+ entry.channel.setCellPixels(width, height, precise);
377
+ }
372
378
  clearWindowViewports(session) {
373
379
  const entry = this.channels.get(session);
374
380
  if (entry && !entry.retired)