tmux-ide 2.9.6 → 2.9.7

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 (80) hide show
  1. package/bin/cli.js +2597 -1322
  2. package/package.json +1 -1
  3. package/packages/contracts/src/index.ts +5 -0
  4. package/packages/contracts/src/pane-stream.ts +37 -0
  5. package/packages/contracts/src/terminal-graphics.ts +92 -0
  6. package/packages/contracts/src/terminal-history-anchor.ts +61 -0
  7. package/packages/contracts/src/tmux-server-diagnostics.ts +16 -0
  8. package/packages/core/src/index.ts +1 -0
  9. package/packages/core/src/kitty-placeholders.ts +190 -0
  10. package/packages/daemon/dist/command-center/tmux-server-diagnostics.js +24 -0
  11. package/packages/daemon/dist/command-center/tmux-server-history-anchor.js +48 -0
  12. package/packages/daemon/dist/command-center/tmux-servers.js +11 -0
  13. package/packages/daemon/dist/lib/canonical-daemon-bootstrap.js +23 -1
  14. package/packages/daemon/dist/lib/daemon-embed.js +21 -0
  15. package/packages/daemon/dist/lib/tmux-server-owner.js +10 -1
  16. package/packages/daemon/dist/lib/tmux-session-mutation-fence.js +2 -1
  17. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  18. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  19. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  20. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  21. package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libevent_core-2.1.7.dylib +0 -0
  22. package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libutf8proc.3.3.0.dylib +0 -0
  23. package/packages/daemon/dist/native/tmux/darwin-arm64/manifest.json +8 -4
  24. package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
  25. package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +5 -1
  26. package/packages/daemon/dist/native/tmux/linux-arm64/tmux +0 -0
  27. package/packages/daemon/dist/native/tmux/linux-x64/manifest.json +5 -1
  28. package/packages/daemon/dist/native/tmux/linux-x64/tmux +0 -0
  29. package/packages/daemon/dist/terminal/graphics/kitty-graphics.js +578 -0
  30. package/packages/daemon/dist/terminal/mirror/control-channel.js +17 -1
  31. package/packages/daemon/dist/terminal/mirror/mirror-service.js +1 -0
  32. package/packages/daemon/dist/terminal/mirror/native-grid-capture.js +8 -0
  33. package/packages/daemon/dist/terminal/mirror/native-grid-reader.js +6 -1
  34. package/packages/daemon/dist/terminal/mirror/session-channel.js +11 -2
  35. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +78 -1
  36. package/packages/daemon/dist/terminal/protocol/session-descriptor-discovery.js +3 -2
  37. package/packages/daemon/dist/terminal/session-runtime/history-anchor-owner.js +171 -0
  38. package/packages/daemon/dist/terminal/session-runtime/registry.js +20 -0
  39. package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +20 -1
  40. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +68 -1
  41. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +30 -0
  42. package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +49 -21
  43. package/packages/daemon/dist/terminal/session-runtime/xterm-terminal-interpreter-backend.js +11 -0
  44. package/packages/daemon/dist/tui/mirror/runtime/application-home-agents-owner.js +7 -1
  45. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-input-ingress.js +35 -5
  46. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-paste.js +2 -2
  47. package/packages/daemon/dist/tui/mirror/runtime/terminal-input-adapter.js +2 -2
  48. package/packages/daemon/src/command-center/tmux-server-diagnostics.ts +38 -0
  49. package/packages/daemon/src/command-center/tmux-server-history-anchor.ts +69 -0
  50. package/packages/daemon/src/command-center/tmux-servers.ts +11 -0
  51. package/packages/daemon/src/lib/canonical-daemon-bootstrap.ts +20 -2
  52. package/packages/daemon/src/lib/daemon-embed.ts +26 -0
  53. package/packages/daemon/src/lib/tmux-server-owner.ts +10 -0
  54. package/packages/daemon/src/lib/tmux-session-mutation-fence.ts +2 -1
  55. package/packages/daemon/src/terminal/graphics/kitty-graphics.ts +641 -0
  56. package/packages/daemon/src/terminal/mirror/control-channel.ts +18 -1
  57. package/packages/daemon/src/terminal/mirror/mirror-service.ts +3 -0
  58. package/packages/daemon/src/terminal/mirror/native-grid-capture.ts +11 -0
  59. package/packages/daemon/src/terminal/mirror/native-grid-reader.ts +10 -1
  60. package/packages/daemon/src/terminal/mirror/session-channel.ts +14 -1
  61. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +82 -0
  62. package/packages/daemon/src/terminal/protocol/session-descriptor-discovery.ts +3 -2
  63. package/packages/daemon/src/terminal/session-runtime/history-anchor-owner.ts +190 -0
  64. package/packages/daemon/src/terminal/session-runtime/registry.ts +20 -0
  65. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +38 -0
  66. package/packages/daemon/src/terminal/session-runtime/terminal-interpreter-backend.ts +4 -0
  67. package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +83 -1
  68. package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +39 -0
  69. package/packages/daemon/src/terminal/session-runtime/transport-binding.ts +53 -24
  70. package/packages/daemon/src/terminal/session-runtime/xterm-terminal-interpreter-backend.ts +13 -0
  71. package/packages/daemon/src/tui/mirror/runtime/application-home-agents-owner.ts +6 -1
  72. package/packages/daemon/src/tui/mirror/runtime/application-terminal-input-ingress.ts +44 -9
  73. package/packages/daemon/src/tui/mirror/runtime/application-terminal-paste.ts +2 -1
  74. package/packages/daemon/src/tui/mirror/runtime/terminal-input-adapter.ts +5 -2
  75. package/packages/daemon-client/src/fleet-connection-status.ts +1 -1
  76. package/packages/daemon-client/src/pane-creation-refusal.ts +48 -0
  77. package/packages/daemon-client/src/pane-stream-client.ts +29 -0
  78. package/packages/daemon-client/src/tmux-server-client.ts +133 -6
  79. package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libutf8proc.3.2.3.dylib +0 -0
  80. /package/packages/daemon/dist/native/tmux/darwin-arm64/licenses/{libutf8proc.3.2.3.dylib.txt → libutf8proc.3.3.0.dylib.txt} +0 -0
@@ -0,0 +1,578 @@
1
+ /**
2
+ * Kitty graphics protocol extraction for the daemon pane feed.
3
+ *
4
+ * tmux parses and drops APC sequences, so its grid never holds images. Its
5
+ * control-mode `%output` stream, however, carries the pane's raw bytes before
6
+ * that parse (octal-escaped, then decoded by `decodeControlBytes`), so the
7
+ * daemon sees every `ESC _ G … ESC \` intact — split across arbitrary
8
+ * `%output` lines. This module is the pure half: a streaming scanner, a
9
+ * control-key parser, and a budgeted per-pane image store.
10
+ *
11
+ * Placement models:
12
+ *
13
+ * - Unicode placeholders (`U=1` virtual placement + U+10EEEE cells): the
14
+ * position is ordinary grid text, so tmux keeps it through scroll, resize,
15
+ * copy-mode and capture-pane. The store only records the image and its
16
+ * virtual cell grid; renderers resolve the cells (`@tmux-ide/core`).
17
+ * - cursor-anchored (`a=T`/`a=p` without `U=1`), best effort: positioned by a
18
+ * marker OSC the scanner substitutes in place of the APC, so the VT parser
19
+ * reports the cursor at the exact byte position. tmux never sees these
20
+ * placements: they do not move the cursor, do not follow reflow, and are
21
+ * dropped by a capture reseed.
22
+ *
23
+ * Limits: direct transmission (`t=d`) only — file and shared-memory media
24
+ * cannot cross machines; `a=d` handles all and by-id; animation, relative
25
+ * placements and z-ordering are not modelled. Acknowledgements (`a=q`) come
26
+ * from the patched tmux, in stream order (native/tmux/kitty-graphics.patch).
27
+ */
28
+ import { createHash } from "node:crypto";
29
+ import { inflateSync } from "node:zlib";
30
+ import { TERMINAL_GRAPHICS_MAX_IMAGE_BYTES, TERMINAL_GRAPHICS_MAX_IMAGES, TERMINAL_GRAPHICS_MAX_PLACEMENTS, } from "@tmux-ide/contracts";
31
+ /** Private OSC used to mark an APC's byte position for the VT parser. */
32
+ export const KITTY_PLACEMENT_OSC = 6974;
33
+ const ESC = 0x1b;
34
+ const MAX_COMMAND_BYTES = 32 * 1024 * 1024;
35
+ const PASSTHROUGH_PREFIX = [0x74, 0x6d, 0x75, 0x78, 0x3b]; // "tmux;"
36
+ /** PURE — parse `G<control>;<payload>` (the APC body after `ESC _`). */
37
+ export function parseKittyCommand(body) {
38
+ if (!body.startsWith("G"))
39
+ return null;
40
+ const separator = body.indexOf(";");
41
+ const controlText = separator < 0 ? body.slice(1) : body.slice(1, separator);
42
+ const payload = separator < 0 ? "" : body.slice(separator + 1);
43
+ const control = new Map();
44
+ for (const pair of controlText.split(",")) {
45
+ if (!pair)
46
+ continue;
47
+ const eq = pair.indexOf("=");
48
+ if (eq !== 1)
49
+ return null;
50
+ control.set(pair[0], pair.slice(2));
51
+ }
52
+ return { control, payload };
53
+ }
54
+ /**
55
+ * Streaming extractor. One instance per pane feed; chunks may split any
56
+ * sequence at any byte. Also unwraps tmux DCS passthrough
57
+ * (`ESC P tmux; … ESC \` with doubled ESCs), which reaches control mode
58
+ * verbatim regardless of `allow-passthrough`.
59
+ */
60
+ export class KittyApcScanner {
61
+ #state = "ground";
62
+ #held = [];
63
+ #body = [];
64
+ #bodyBytes = 0;
65
+ #prefixMatched = 0;
66
+ #inner = [];
67
+ #nextMarker = 1;
68
+ #overflowed = false;
69
+ scan(chunk) {
70
+ // Verbatim runs stay views of the chunk; ordinary output is never copied.
71
+ const parts = [];
72
+ const emit = (...bytes) => void parts.push(Uint8Array.from(bytes));
73
+ const commands = [];
74
+ let ground = -1; // start of a pending verbatim run
75
+ let escAt = -1; // index of a held ESC inside this chunk (-1: held from an earlier chunk)
76
+ const flushGround = (end) => {
77
+ if (ground >= 0) {
78
+ if (end > ground)
79
+ parts.push(chunk.subarray(ground, end));
80
+ ground = -1;
81
+ }
82
+ };
83
+ const complete = (bodyBytes) => {
84
+ const command = parseKittyCommand(Buffer.from(bodyBytes).toString("latin1"));
85
+ if (!command)
86
+ return;
87
+ const marker = this.#nextMarker++;
88
+ commands.push({ ...command, marker });
89
+ parts.push(Buffer.from(`\x1b]${KITTY_PLACEMENT_OSC};${marker}\x07`, "latin1"));
90
+ };
91
+ for (let i = 0; i < chunk.length; i += 1) {
92
+ const byte = chunk[i];
93
+ switch (this.#state) {
94
+ case "ground":
95
+ // The ESC stays in the verbatim run until it turns out to start an APC/DCS.
96
+ if (ground < 0)
97
+ ground = i;
98
+ if (byte === ESC) {
99
+ escAt = i;
100
+ this.#held = [byte];
101
+ this.#state = "esc";
102
+ }
103
+ break;
104
+ case "esc":
105
+ if (byte === 0x5f || byte === 0x50) {
106
+ if (escAt >= 0)
107
+ flushGround(escAt);
108
+ this.#held.push(byte);
109
+ if (byte === 0x50)
110
+ this.#prefixMatched = 0;
111
+ this.#state = byte === 0x5f ? "apc-start" : "dcs-start";
112
+ }
113
+ else {
114
+ if (escAt < 0) {
115
+ emit(...this.#held);
116
+ ground = i;
117
+ }
118
+ if (byte === ESC) {
119
+ escAt = i;
120
+ this.#held = [byte];
121
+ }
122
+ else {
123
+ this.#held = [];
124
+ this.#state = "ground";
125
+ }
126
+ }
127
+ break;
128
+ case "apc-start":
129
+ if (byte === 0x47) {
130
+ this.#body = [Uint8Array.of(byte)];
131
+ this.#bodyBytes = 1;
132
+ this.#overflowed = false;
133
+ this.#held = [];
134
+ this.#state = "apc-body";
135
+ }
136
+ else {
137
+ // Another APC: the VT parser ignores it; pass it through untouched.
138
+ emit(...this.#held, byte);
139
+ this.#held = [];
140
+ this.#state = "ground";
141
+ }
142
+ break;
143
+ case "apc-body": {
144
+ const end = chunk.indexOf(ESC, i);
145
+ const stop = end < 0 ? chunk.length : end;
146
+ if (!this.#overflowed && this.#bodyBytes + (stop - i) <= MAX_COMMAND_BYTES) {
147
+ this.#body.push(chunk.subarray(i, stop).slice());
148
+ this.#bodyBytes += stop - i;
149
+ }
150
+ else
151
+ this.#overflowed = true;
152
+ i = stop - 1;
153
+ if (end >= 0) {
154
+ i = end;
155
+ this.#state = "apc-esc";
156
+ }
157
+ break;
158
+ }
159
+ case "apc-esc": {
160
+ const body = this.#overflowed ? null : Buffer.concat(this.#body);
161
+ this.#body = [];
162
+ this.#bodyBytes = 0;
163
+ if (body)
164
+ complete(body);
165
+ this.#state = "ground";
166
+ // ESC \ is the terminator; anything else aborts the string and is reparsed.
167
+ if (byte !== 0x5c) {
168
+ this.#state = "esc";
169
+ this.#held = [ESC];
170
+ escAt = -1;
171
+ i -= 1;
172
+ }
173
+ break;
174
+ }
175
+ case "dcs-start":
176
+ if (byte === PASSTHROUGH_PREFIX[this.#prefixMatched]) {
177
+ this.#held.push(byte);
178
+ this.#prefixMatched += 1;
179
+ if (this.#prefixMatched === PASSTHROUGH_PREFIX.length) {
180
+ this.#held = [];
181
+ this.#inner = [];
182
+ this.#state = "pt-body";
183
+ }
184
+ }
185
+ else {
186
+ emit(...this.#held, byte);
187
+ this.#held = [];
188
+ this.#state = "ground";
189
+ }
190
+ break;
191
+ case "pt-body":
192
+ if (byte === ESC)
193
+ this.#state = "pt-esc";
194
+ else if (this.#inner.length < MAX_COMMAND_BYTES)
195
+ this.#inner.push(byte);
196
+ break;
197
+ case "pt-esc":
198
+ if (byte === ESC) {
199
+ this.#inner.push(ESC);
200
+ this.#state = "pt-body";
201
+ }
202
+ else if (byte === 0x5c) {
203
+ // Passthrough content targets the outer terminal — which is us.
204
+ // Only its kitty commands matter; other wrapped output is dropped.
205
+ const nested = new KittyApcScanner();
206
+ nested.#nextMarker = this.#nextMarker;
207
+ const result = nested.scan(Uint8Array.from(this.#inner));
208
+ this.#nextMarker = nested.#nextMarker;
209
+ for (const command of result.commands) {
210
+ commands.push(command);
211
+ parts.push(Buffer.from(`\x1b]${KITTY_PLACEMENT_OSC};${command.marker}\x07`, "latin1"));
212
+ }
213
+ this.#inner = [];
214
+ this.#state = "ground";
215
+ }
216
+ else {
217
+ this.#inner.push(ESC, byte);
218
+ this.#state = "pt-body";
219
+ }
220
+ break;
221
+ }
222
+ }
223
+ // A trailing ESC is held for the next chunk, not forwarded twice.
224
+ flushGround(this.#state === "esc" && escAt >= 0 ? escAt : chunk.length);
225
+ const forward = parts.length === 1 && parts[0].length === chunk.length && parts[0].buffer === chunk.buffer
226
+ ? chunk
227
+ : Uint8Array.from(Buffer.concat(parts));
228
+ return { forward, commands };
229
+ }
230
+ }
231
+ function int(control, key, fallback = 0) {
232
+ const value = control.get(key);
233
+ if (value === undefined || !/^-?[0-9]+$/u.test(value))
234
+ return fallback;
235
+ return Number(value);
236
+ }
237
+ /** PURE — width/height from a PNG IHDR, or null. */
238
+ export function pngDimensions(data) {
239
+ if (data.length < 24)
240
+ return null;
241
+ const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
242
+ if (signature.some((b, i) => data[i] !== b))
243
+ return null;
244
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
245
+ return { width: view.getUint32(16), height: view.getUint32(20) };
246
+ }
247
+ /**
248
+ * Byte quotas shared by every pane store in the daemon. Each pane keeps at
249
+ * most `perPaneBytes`; across panes the least recently used image goes first.
250
+ * An evicted image disappears from its pane's graphics state, so renderers
251
+ * stop placing it (placeholder cells then render as blank).
252
+ */
253
+ export class KittyImageBudget {
254
+ perPaneBytes;
255
+ globalBytes;
256
+ #stores = new Set();
257
+ #tick = 0;
258
+ constructor(options) {
259
+ this.perPaneBytes = options.perPaneBytes;
260
+ this.globalBytes = options.globalBytes;
261
+ }
262
+ get totalBytes() {
263
+ let total = 0;
264
+ for (const store of this.#stores)
265
+ total += store.bytes;
266
+ return total;
267
+ }
268
+ register(store) {
269
+ this.#stores.add(store);
270
+ }
271
+ unregister(store) {
272
+ this.#stores.delete(store);
273
+ }
274
+ nextTick() {
275
+ return ++this.#tick;
276
+ }
277
+ enforce() {
278
+ let total = this.totalBytes;
279
+ while (total > this.globalBytes) {
280
+ let victim = null;
281
+ let oldest = Infinity;
282
+ for (const store of this.#stores) {
283
+ const used = store.oldestUse();
284
+ if (used < oldest) {
285
+ oldest = used;
286
+ victim = store;
287
+ }
288
+ }
289
+ if (!victim)
290
+ return;
291
+ total -= victim.evictOldest();
292
+ }
293
+ }
294
+ }
295
+ export const DEFAULT_KITTY_IMAGE_BUDGET = new KittyImageBudget({
296
+ perPaneBytes: 64 * 1024 * 1024,
297
+ globalBytes: 256 * 1024 * 1024,
298
+ });
299
+ /** Per-pane image store. Survives reseeds; cursor placements do not. */
300
+ export class KittyImageStore {
301
+ #images = new Map();
302
+ #used = new Map();
303
+ #cursorPlacements = [];
304
+ #virtual = new Map();
305
+ #pending = null;
306
+ #nextAnonymous = 0x7f000000;
307
+ #bytes = 0;
308
+ #cellWidth;
309
+ #cellHeight;
310
+ #budget;
311
+ #revision = 0;
312
+ constructor(options = {}) {
313
+ this.#cellWidth = options.cellWidthPx ?? 10;
314
+ this.#cellHeight = options.cellHeightPx ?? 20;
315
+ this.#budget = options.budget ?? DEFAULT_KITTY_IMAGE_BUDGET;
316
+ this.#budget.register(this);
317
+ }
318
+ /** Bumps whenever images or placements change. */
319
+ get revision() {
320
+ return this.#revision;
321
+ }
322
+ get bytes() {
323
+ return this.#bytes;
324
+ }
325
+ images() {
326
+ return this.#images;
327
+ }
328
+ imageByDigest(digest) {
329
+ for (const image of this.#images.values())
330
+ if (image.digest === digest)
331
+ return image;
332
+ return undefined;
333
+ }
334
+ cursorPlacements() {
335
+ return this.#cursorPlacements;
336
+ }
337
+ dispose() {
338
+ this.#budget.unregister(this);
339
+ this.#images.clear();
340
+ this.#used.clear();
341
+ this.#bytes = 0;
342
+ }
343
+ /** A capture reseed replaced the grid: tmux holds no record of cursor placements. */
344
+ dropCursorPlacements() {
345
+ if (this.#cursorPlacements.length === 0)
346
+ return;
347
+ this.#cursorPlacements.length = 0;
348
+ this.#revision += 1;
349
+ }
350
+ oldestUse() {
351
+ let oldest = Infinity;
352
+ for (const used of this.#used.values())
353
+ oldest = Math.min(oldest, used);
354
+ return oldest;
355
+ }
356
+ /** Evict the least recently used image; returns the bytes freed. */
357
+ evictOldest() {
358
+ let victim = null;
359
+ let oldest = Infinity;
360
+ for (const [id, used] of this.#used)
361
+ if (used < oldest) {
362
+ oldest = used;
363
+ victim = id;
364
+ }
365
+ if (victim === null)
366
+ return 0;
367
+ return this.#remove(victim);
368
+ }
369
+ #remove(imageId) {
370
+ const image = this.#images.get(imageId);
371
+ if (!image)
372
+ return 0;
373
+ this.#images.delete(imageId);
374
+ this.#used.delete(imageId);
375
+ this.#virtual.delete(imageId);
376
+ this.#bytes -= image.data.byteLength;
377
+ const kept = this.#cursorPlacements.filter((p) => p.imageId !== imageId);
378
+ this.#cursorPlacements.length = 0;
379
+ this.#cursorPlacements.push(...kept);
380
+ this.#revision += 1;
381
+ return image.data.byteLength;
382
+ }
383
+ /**
384
+ * Apply one command. `cursor` is the parser's position at the command's byte
385
+ * offset (from the marker OSC); null when unavailable.
386
+ */
387
+ apply(command, cursor) {
388
+ let { control } = command;
389
+ const more = control.get("m") === "1";
390
+ if (this.#pending) {
391
+ // Continuation chunks carry only m (and q); the first chunk's keys rule.
392
+ this.#pending.payload.push(command.payload);
393
+ this.#pending.bytes += command.payload.length;
394
+ if (this.#pending.bytes > MAX_COMMAND_BYTES) {
395
+ this.#pending = null;
396
+ return;
397
+ }
398
+ if (more)
399
+ return;
400
+ control = this.#pending.control;
401
+ const payload = this.#pending.payload.join("");
402
+ this.#pending = null;
403
+ this.#finish(control, payload, cursor);
404
+ return;
405
+ }
406
+ const action = control.get("a") ?? "t";
407
+ if ((action === "t" || action === "T") && more) {
408
+ this.#pending = { control, payload: [command.payload], bytes: command.payload.length };
409
+ return;
410
+ }
411
+ this.#finish(control, command.payload, cursor);
412
+ }
413
+ #finish(control, payload, cursor) {
414
+ const action = control.get("a") ?? "t";
415
+ if (action === "d")
416
+ return this.#delete(control);
417
+ if (action === "q")
418
+ return; // Answered by the patched tmux; nothing to store.
419
+ let imageId = int(control, "i");
420
+ if (action === "t" || action === "T") {
421
+ if ((control.get("t") ?? "d") !== "d")
422
+ return; // file/shm cannot cross machines.
423
+ if (imageId === 0)
424
+ imageId = this.#nextAnonymous++;
425
+ const image = this.#decode(imageId, control, payload);
426
+ if (!image)
427
+ return;
428
+ this.#remove(imageId);
429
+ this.#images.set(imageId, image);
430
+ this.#bytes += image.data.byteLength;
431
+ this.#used.set(imageId, this.#budget.nextTick());
432
+ 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();
437
+ if (action === "t" || !this.#images.has(imageId))
438
+ return;
439
+ }
440
+ if (action !== "T" && action !== "p")
441
+ return;
442
+ const image = this.#images.get(imageId);
443
+ if (!image)
444
+ return;
445
+ this.#used.set(imageId, this.#budget.nextTick());
446
+ const columns = clampCells(int(control, "c") || Math.ceil(image.width / this.#cellWidth));
447
+ const rows = clampCells(int(control, "r") || Math.ceil(image.height / this.#cellHeight));
448
+ const placementId = int(control, "p");
449
+ if (control.get("U") === "1") {
450
+ this.#virtual.set(imageId, { columns, rows });
451
+ }
452
+ else if (cursor) {
453
+ const existing = this.#cursorPlacements.findIndex((p) => p.imageId === imageId && placementId !== 0 && p.placementId === placementId);
454
+ if (existing >= 0)
455
+ this.#cursorPlacements.splice(existing, 1);
456
+ if (this.#cursorPlacements.length >= TERMINAL_GRAPHICS_MAX_PLACEMENTS)
457
+ this.#cursorPlacements.shift();
458
+ this.#cursorPlacements.push({
459
+ imageId,
460
+ placementId,
461
+ line: cursor.line,
462
+ column: cursor.x,
463
+ columns,
464
+ rows,
465
+ alternate: cursor.alternate,
466
+ });
467
+ }
468
+ this.#revision += 1;
469
+ }
470
+ #decode(imageId, control, payload) {
471
+ let data = Buffer.from(payload, "base64");
472
+ if (control.get("o") === "z") {
473
+ try {
474
+ data = inflateSync(data, { maxOutputLength: TERMINAL_GRAPHICS_MAX_IMAGE_BYTES });
475
+ }
476
+ catch {
477
+ return null;
478
+ }
479
+ }
480
+ if (data.byteLength === 0 || data.byteLength > TERMINAL_GRAPHICS_MAX_IMAGE_BYTES)
481
+ return null;
482
+ const f = int(control, "f", 32);
483
+ let format;
484
+ let width;
485
+ let height;
486
+ if (f === 100) {
487
+ const size = pngDimensions(data);
488
+ if (!size)
489
+ return null;
490
+ ({ width, height } = size);
491
+ format = "png";
492
+ }
493
+ else if (f === 32 || f === 24) {
494
+ width = int(control, "s");
495
+ height = int(control, "v");
496
+ format = f === 32 ? "rgba" : "rgb";
497
+ if (!width || !height || data.length !== width * height * (f === 32 ? 4 : 3))
498
+ return null;
499
+ }
500
+ else
501
+ return null;
502
+ if (width < 1 || height < 1 || width > MAX_IMAGE_SIDE || height > MAX_IMAGE_SIDE)
503
+ return null;
504
+ const digest = createHash("sha256").update(data).digest("hex").slice(0, 32);
505
+ return { imageId, format, width, height, data, digest };
506
+ }
507
+ #delete(control) {
508
+ const what = control.get("d") ?? "a";
509
+ const id = int(control, "i");
510
+ const all = what === "a" || what === "A";
511
+ const byId = (what === "i" || what === "I") && id !== 0;
512
+ if (!all && !byId)
513
+ return;
514
+ const keep = (imageId) => !all && imageId !== id;
515
+ const kept = this.#cursorPlacements.filter((p) => keep(p.imageId));
516
+ if (kept.length !== this.#cursorPlacements.length) {
517
+ this.#cursorPlacements.length = 0;
518
+ this.#cursorPlacements.push(...kept);
519
+ this.#revision += 1;
520
+ }
521
+ for (const key of [...this.#virtual.keys()])
522
+ if (!keep(key)) {
523
+ this.#virtual.delete(key);
524
+ this.#revision += 1;
525
+ }
526
+ // Upper-case variants also free the image data.
527
+ if (what === "A")
528
+ for (const key of [...this.#images.keys()])
529
+ this.#remove(key);
530
+ if (what === "I")
531
+ this.#remove(id);
532
+ }
533
+ /**
534
+ * The pane's graphics state for renderers: the image registry (with each
535
+ * image's placeholder grid) and cursor placements clipped to the viewport.
536
+ * `viewportTop` is the absolute line of grid row 0.
537
+ */
538
+ state(viewportTop, gridRows, cols, alternate) {
539
+ const images = [...this.#images.values()].slice(-TERMINAL_GRAPHICS_MAX_IMAGES).map((image) => ({
540
+ imageId: image.imageId,
541
+ digest: image.digest,
542
+ format: image.format,
543
+ width: image.width,
544
+ height: image.height,
545
+ bytes: image.data.byteLength,
546
+ placeholder: this.#virtual.get(image.imageId) ?? null,
547
+ }));
548
+ const placements = [];
549
+ for (const placement of this.#cursorPlacements) {
550
+ if (placement.alternate !== alternate)
551
+ continue;
552
+ const image = this.#images.get(placement.imageId);
553
+ if (!image)
554
+ continue;
555
+ const top = placement.line - viewportTop;
556
+ const first = Math.max(0, top);
557
+ const last = Math.min(gridRows, top + placement.rows);
558
+ const right = Math.min(cols, placement.column + placement.columns);
559
+ if (first >= last || placement.column >= right)
560
+ continue;
561
+ placements.push({
562
+ digest: image.digest,
563
+ row: first,
564
+ column: placement.column,
565
+ rows: last - first,
566
+ columns: right - placement.column,
567
+ sourceRow: first - top,
568
+ sourceColumn: 0,
569
+ totalRows: placement.rows,
570
+ totalColumns: placement.columns,
571
+ });
572
+ }
573
+ return { revision: this.#revision, images, placements };
574
+ }
575
+ }
576
+ const MAX_IMAGES_PER_PANE = TERMINAL_GRAPHICS_MAX_IMAGES;
577
+ const MAX_IMAGE_SIDE = 10_000;
578
+ const clampCells = (cells) => Math.max(1, Math.min(1_000, cells));
@@ -868,6 +868,16 @@ export class ControlChannelCore {
868
868
  }));
869
869
  }
870
870
  }
871
+ /**
872
+ * Server-wide, idempotent: `-q` keeps a stock tmux (no such option) quiet.
873
+ * Images reach this control client either way; the option only lets programs
874
+ * detect support, and the environment tells new panes' programs that this
875
+ * server's renderer shows placeholder images.
876
+ */
877
+ export const KITTY_GRAPHICS_SETUP = [
878
+ "set-option -gq kitty-graphics on",
879
+ "set-environment -g TMUX_IDE_GRAPHICS kitty",
880
+ ];
871
881
  export function mirrorControlAttachArgs(options, pauseAfterSeconds = DEFAULT_PAUSE_AFTER_SECONDS) {
872
882
  const attach = [
873
883
  "attach",
@@ -1009,7 +1019,13 @@ export class MirrorControlChannel {
1009
1019
  this.core.fail(String(err));
1010
1020
  reject(err);
1011
1021
  });
1012
- }).then(() => this.initializeNativeViewer());
1022
+ }).then(() => {
1023
+ // One command per send: tmux answers a `;` list with one block per command.
1024
+ if (this.opts.kittyGraphics)
1025
+ for (const command of KITTY_GRAPHICS_SETUP)
1026
+ this.send(command);
1027
+ return this.initializeNativeViewer();
1028
+ });
1013
1029
  }
1014
1030
  get nativeViewerIdentity() {
1015
1031
  return this.viewerIdentity ? { ...this.viewerIdentity } : null;
@@ -532,6 +532,7 @@ export class MirrorService {
532
532
  nativeServerIdentity: this.opts.nativeServerIdentity,
533
533
  configFile: this.opts.configFile,
534
534
  pauseAfterSeconds: this.opts.pauseAfterSeconds,
535
+ kittyGraphics: this.opts.kittyGraphics ?? process.env.TMUX_IDE_KITTY_GRAPHICS !== "0",
535
536
  nowMicros: this.opts.nowMicros,
536
537
  nativeViewer: ownedViewer?.controlOptions(),
537
538
  nativeViewerReady: ownedViewer
@@ -1,3 +1,11 @@
1
+ /** Upstream tmux 3.8 reuses -R for a textual grid dump, not our JSON extension.
2
+ * Require the generated grid and first-row headers; arbitrary malformed native
3
+ * replies must remain errors rather than silently downgrading capture fidelity.
4
+ */
5
+ export function isStockRawGridCapture(lines) {
6
+ return (/^G [1-9][0-9]*x[1-9][0-9]* \([0-9]+\/[0-9]+\)$/.test(lines[0] ?? "") &&
7
+ /^\tL 0 \((?:-|0)\) flags=/.test(lines[1] ?? ""));
8
+ }
1
9
  const MAX_BYTES = 16 * 1024 * 1024;
2
10
  const MAX_ROWS = MAX_BYTES / 64;
3
11
  const MAX_CELLS = 1_000_000;
@@ -1,4 +1,4 @@
1
- import { decodeNativeGridCapture } from "./native-grid-capture.js";
1
+ import { decodeNativeGridCapture, isStockRawGridCapture, } from "./native-grid-capture.js";
2
2
  /** One experimental capability owner per control connection. */
3
3
  export class NativeGridCaptureReader {
4
4
  io;
@@ -57,6 +57,11 @@ export class NativeGridCaptureReader {
57
57
  finish({ status: "changed" });
58
58
  return;
59
59
  }
60
+ if (isStockRawGridCapture(reply.lines)) {
61
+ this.unsupported = true;
62
+ finish({ status: "unsupported" });
63
+ return;
64
+ }
60
65
  const snapshot = decodeNativeGridCapture(reply.lines.join("\n"));
61
66
  finish(snapshot
62
67
  ? {