tmux-ide 2.9.6 → 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 (84) hide show
  1. package/bin/cli.js +2881 -1336
  2. package/package.json +2 -2
  3. package/packages/contracts/src/index.ts +5 -0
  4. package/packages/contracts/src/pane-stream.ts +54 -0
  5. package/packages/contracts/src/terminal-graphics.ts +118 -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 +208 -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 +12 -4
  24. package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
  25. package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +9 -1
  26. package/packages/daemon/dist/native/tmux/linux-arm64/tmux +0 -0
  27. package/packages/daemon/dist/native/tmux/linux-x64/manifest.json +9 -1
  28. package/packages/daemon/dist/native/tmux/linux-x64/tmux +0 -0
  29. package/packages/daemon/dist/terminal/attachments/native-runtime.js +25 -12
  30. package/packages/daemon/dist/terminal/graphics/kitty-graphics.js +699 -0
  31. package/packages/daemon/dist/terminal/mirror/control-channel.js +17 -1
  32. package/packages/daemon/dist/terminal/mirror/mirror-service.js +7 -0
  33. package/packages/daemon/dist/terminal/mirror/native-grid-capture.js +8 -0
  34. package/packages/daemon/dist/terminal/mirror/native-grid-reader.js +6 -1
  35. package/packages/daemon/dist/terminal/mirror/session-channel.js +56 -4
  36. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +180 -1
  37. package/packages/daemon/dist/terminal/protocol/session-descriptor-discovery.js +3 -2
  38. package/packages/daemon/dist/terminal/session-runtime/history-anchor-owner.js +171 -0
  39. package/packages/daemon/dist/terminal/session-runtime/registry.js +29 -0
  40. package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +20 -1
  41. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +85 -1
  42. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +30 -0
  43. package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +53 -21
  44. package/packages/daemon/dist/terminal/session-runtime/xterm-terminal-interpreter-backend.js +11 -0
  45. package/packages/daemon/dist/tui/mirror/runtime/application-home-agents-owner.js +7 -1
  46. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-input-ingress.js +35 -5
  47. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-paste.js +2 -2
  48. package/packages/daemon/dist/tui/mirror/runtime/terminal-input-adapter.js +2 -2
  49. package/packages/daemon/src/command-center/tmux-server-diagnostics.ts +38 -0
  50. package/packages/daemon/src/command-center/tmux-server-history-anchor.ts +69 -0
  51. package/packages/daemon/src/command-center/tmux-servers.ts +11 -0
  52. package/packages/daemon/src/lib/canonical-daemon-bootstrap.ts +20 -2
  53. package/packages/daemon/src/lib/daemon-embed.ts +26 -0
  54. package/packages/daemon/src/lib/tmux-server-owner.ts +10 -0
  55. package/packages/daemon/src/lib/tmux-session-mutation-fence.ts +2 -1
  56. package/packages/daemon/src/terminal/attachments/native-runtime.ts +28 -13
  57. package/packages/daemon/src/terminal/graphics/__fixtures__/full-frame-client-recorded.ctl +87 -0
  58. package/packages/daemon/src/terminal/graphics/__fixtures__/full-frame-client.ctl +10 -0
  59. package/packages/daemon/src/terminal/graphics/kitty-graphics.ts +756 -0
  60. package/packages/daemon/src/terminal/mirror/control-channel.ts +18 -1
  61. package/packages/daemon/src/terminal/mirror/mirror-service.ts +14 -0
  62. package/packages/daemon/src/terminal/mirror/native-grid-capture.ts +11 -0
  63. package/packages/daemon/src/terminal/mirror/native-grid-reader.ts +10 -1
  64. package/packages/daemon/src/terminal/mirror/session-channel.ts +75 -3
  65. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +199 -0
  66. package/packages/daemon/src/terminal/protocol/session-descriptor-discovery.ts +3 -2
  67. package/packages/daemon/src/terminal/session-runtime/history-anchor-owner.ts +190 -0
  68. package/packages/daemon/src/terminal/session-runtime/registry.ts +38 -0
  69. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +38 -0
  70. package/packages/daemon/src/terminal/session-runtime/terminal-interpreter-backend.ts +4 -0
  71. package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +99 -1
  72. package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +39 -0
  73. package/packages/daemon/src/terminal/session-runtime/transport-binding.ts +58 -24
  74. package/packages/daemon/src/terminal/session-runtime/xterm-terminal-interpreter-backend.ts +13 -0
  75. package/packages/daemon/src/tui/mirror/runtime/application-home-agents-owner.ts +6 -1
  76. package/packages/daemon/src/tui/mirror/runtime/application-terminal-input-ingress.ts +44 -9
  77. package/packages/daemon/src/tui/mirror/runtime/application-terminal-paste.ts +2 -1
  78. package/packages/daemon/src/tui/mirror/runtime/terminal-input-adapter.ts +5 -2
  79. package/packages/daemon-client/src/fleet-connection-status.ts +1 -1
  80. package/packages/daemon-client/src/pane-creation-refusal.ts +48 -0
  81. package/packages/daemon-client/src/pane-stream-client.ts +37 -0
  82. package/packages/daemon-client/src/tmux-server-client.ts +133 -6
  83. package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libutf8proc.3.2.3.dylib +0 -0
  84. /package/packages/daemon/dist/native/tmux/darwin-arm64/licenses/{libutf8proc.3.2.3.dylib.txt → libutf8proc.3.3.0.dylib.txt} +0 -0
@@ -1189,8 +1189,21 @@ export interface MirrorControlChannelOptions {
1189
1189
  pauseAfterSeconds?: number;
1190
1190
  /** Qualification-only clock. Omitted in production's zero-observer path. */
1191
1191
  nowMicros?: () => number;
1192
+ /** Ask tmux to acknowledge kitty graphics commands (patched tmux; stock ignores it). */
1193
+ kittyGraphics?: boolean;
1192
1194
  }
1193
1195
 
1196
+ /**
1197
+ * Server-wide, idempotent: `-q` keeps a stock tmux (no such option) quiet.
1198
+ * Images reach this control client either way; the option only lets programs
1199
+ * detect support, and the environment tells new panes' programs that this
1200
+ * server's renderer shows placeholder images.
1201
+ */
1202
+ export const KITTY_GRAPHICS_SETUP = [
1203
+ "set-option -gq kitty-graphics on",
1204
+ "set-environment -g TMUX_IDE_GRAPHICS kitty",
1205
+ ] as const;
1206
+
1194
1207
  export function mirrorControlAttachArgs(
1195
1208
  options: Pick<
1196
1209
  MirrorControlChannelOptions,
@@ -1348,7 +1361,11 @@ export class MirrorControlChannel implements MirrorChannelIo {
1348
1361
  this.core.fail(String(err));
1349
1362
  reject(err);
1350
1363
  });
1351
- }).then(() => this.initializeNativeViewer());
1364
+ }).then(() => {
1365
+ // One command per send: tmux answers a `;` list with one block per command.
1366
+ if (this.opts.kittyGraphics) for (const command of KITTY_GRAPHICS_SETUP) this.send(command);
1367
+ return this.initializeNativeViewer();
1368
+ });
1352
1369
  }
1353
1370
 
1354
1371
  get nativeViewerIdentity(): NativeJournalIdentity | null {
@@ -79,6 +79,8 @@ export interface MirrorServiceOptions {
79
79
  /** `tmux -f <file>` — tests pass /dev/null. */
80
80
  configFile?: string;
81
81
  pauseAfterSeconds?: number;
82
+ /** Enable kitty graphics acknowledgements on attach (default: on unless TMUX_IDE_KITTY_GRAPHICS=0). */
83
+ kittyGraphics?: boolean;
82
84
  /** Explicit capture tail override. Omitted preserves native retained history. */
83
85
  historyLines?: number;
84
86
  internalReadHookEmission?: SessionChannelOptions["internalReadHookEmission"];
@@ -563,6 +565,17 @@ export class MirrorService {
563
565
  entry.channel.fitWindowViewport(semanticWindowId, cols, rows);
564
566
  }
565
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
+
566
579
  clearWindowViewports(session: string): void {
567
580
  const entry = this.channels.get(session);
568
581
  if (entry && !entry.retired) entry.channel.clearWindowViewports();
@@ -737,6 +750,7 @@ export class MirrorService {
737
750
  nativeServerIdentity: this.opts.nativeServerIdentity,
738
751
  configFile: this.opts.configFile,
739
752
  pauseAfterSeconds: this.opts.pauseAfterSeconds,
753
+ kittyGraphics: this.opts.kittyGraphics ?? process.env.TMUX_IDE_KITTY_GRAPHICS !== "0",
740
754
  nowMicros: this.opts.nowMicros,
741
755
  nativeViewer: ownedViewer?.controlOptions(),
742
756
  nativeViewerReady: ownedViewer
@@ -1,3 +1,14 @@
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: readonly string[]): boolean {
6
+ return (
7
+ /^G [1-9][0-9]*x[1-9][0-9]* \([0-9]+\/[0-9]+\)$/.test(lines[0] ?? "") &&
8
+ /^\tL 0 \((?:-|0)\) flags=/.test(lines[1] ?? "")
9
+ );
10
+ }
11
+
1
12
  /** Private native capture experiment; this is not the canonical renderer schema. */
2
13
  export interface NativeGridCaptureCell {
3
14
  readonly flags: number;
@@ -1,5 +1,9 @@
1
1
  import type { MirrorChannelIo } from "./control-channel.ts";
2
- import { decodeNativeGridCapture, type NativeGridCapture } from "./native-grid-capture.ts";
2
+ import {
3
+ decodeNativeGridCapture,
4
+ isStockRawGridCapture,
5
+ type NativeGridCapture,
6
+ } from "./native-grid-capture.ts";
3
7
 
4
8
  export type NativeGridReadResult =
5
9
  | {
@@ -83,6 +87,11 @@ export class NativeGridCaptureReader {
83
87
  finish({ status: "changed" });
84
88
  return;
85
89
  }
90
+ if (isStockRawGridCapture(reply.lines)) {
91
+ this.unsupported = true;
92
+ finish({ status: "unsupported" });
93
+ return;
94
+ }
86
95
  const snapshot = decodeNativeGridCapture(reply.lines.join("\n"));
87
96
  finish(
88
97
  snapshot
@@ -10,6 +10,7 @@ import {
10
10
  import { boundedTmuxInteractionAppendCommand } from "../../lib/tmux-interaction-retention.ts";
11
11
  import {
12
12
  decodeNativeGridCapture,
13
+ isStockRawGridCapture,
13
14
  isNativeBootstrapCapture,
14
15
  type NativeGridCapture,
15
16
  } from "./native-grid-capture.ts";
@@ -212,7 +213,10 @@ function nativeBootstrapUnsupported(
212
213
  native: NativeGridCapture | null,
213
214
  ): boolean {
214
215
  if (ok)
215
- return native !== null && (native.version !== 2 || native.currentAttributes === undefined);
216
+ return (
217
+ isStockRawGridCapture(lines) ||
218
+ (native !== null && (native.version !== 2 || native.currentAttributes === undefined))
219
+ );
216
220
  return lines.some((line) =>
217
221
  /^(?:parse error: )?(?:command capture-pane: )?unknown flag -R$/.test(line.trim()),
218
222
  );
@@ -345,6 +349,7 @@ interface SnapshotLease {
345
349
  cancelFence: (() => void) | null;
346
350
  cleanup?: () => void;
347
351
  ownsPause?: boolean;
352
+ attemptCharged?: boolean;
348
353
  }
349
354
 
350
355
  interface RecoveryRecord {
@@ -1304,7 +1309,66 @@ export class SessionChannel {
1304
1309
  this.input.flush();
1305
1310
  this.clearWindowViewports();
1306
1311
  this.restoreWindowSizing(this.attachedIdentity?.runtimeSessionId ?? `=${this.opts.session}`);
1307
- 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
+ );
1308
1372
  }
1309
1373
 
1310
1374
  private restoreWindowSizing(target: string): void {
@@ -1354,7 +1418,7 @@ export class SessionChannel {
1354
1418
  if (previous?.cols === cols && previous.rows === rows) return;
1355
1419
  this.input.flush();
1356
1420
  this.restoreWindowSizing(window.runtimeId);
1357
- 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()}`);
1358
1422
  this.fittedWindows.set(window.runtimeId, { cols, rows });
1359
1423
  }
1360
1424
 
@@ -1959,6 +2023,7 @@ export class SessionChannel {
1959
2023
  return;
1960
2024
  }
1961
2025
  recovery.attempts += 1;
2026
+ lease.attemptCharged = true;
1962
2027
  recovery.stage = "stock-pause";
1963
2028
  recovery.stock = new StockPaneSnapshot(this.snapshotContext(pane));
1964
2029
  // These rows were received before this pause and belong to the new seed,
@@ -2135,6 +2200,7 @@ export class SessionChannel {
2135
2200
  return;
2136
2201
  }
2137
2202
  recovery.attempts += 1;
2203
+ lease.attemptCharged = true;
2138
2204
  const ordinal = ++recovery.reseedOrdinal;
2139
2205
  const participants = [...pane.subs]
2140
2206
  .filter((sub) => !sub.closed && !sub.frozen)
@@ -2924,6 +2990,12 @@ export class SessionChannel {
2924
2990
  if (pane.windowRuntimeId !== change.windowId) continue;
2925
2991
  const recovery = this.recoveries.get(pane.runtimeId);
2926
2992
  if (recovery?.lease && !recovery.lease.retired) {
2993
+ // A newer layout cancels this capture; it is not a failed snapshot.
2994
+ // Refund only this lease, retaining earlier failures and the original deadline.
2995
+ if (recovery.lease.attemptCharged) {
2996
+ recovery.lease.attemptCharged = false;
2997
+ recovery.attempts -= 1;
2998
+ }
2927
2999
  this.awaitSnapshotLayout(recovery);
2928
3000
  this.retrySnapshot(recovery);
2929
3001
  }
@@ -1,4 +1,6 @@
1
+ import { inflateSync } from "node:zlib";
1
2
  import { PaneStreamLayoutSnapshotFrameSchemaZ, type WindowLinkTopology } from "@tmux-ide/contracts";
3
+ import type { TerminalDeliveryGraphics } from "../session-runtime/terminal-delivery-hub.ts";
2
4
  import { z } from "zod";
3
5
  import {
4
6
  PANE_STREAM_MAX_HELD_DELTAS,
@@ -8,6 +10,12 @@ import {
8
10
  PANE_STREAM_PROTOCOL_VERSION,
9
11
  PANE_STREAM_REDEEM_PATH,
10
12
  PANE_STREAM_WEBSOCKET_SUBPROTOCOL,
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,
11
19
  PaneStreamClientFrameSchemaZ,
12
20
  PaneStreamLoopbackWebSocketUrlSchemaZ,
13
21
  PaneStreamRedeemFrameSchemaZ,
@@ -303,6 +311,7 @@ export interface SessionRuntimePaneStreamTransportBinding {
303
311
  rows: number,
304
312
  semanticWindowId?: string,
305
313
  ): void;
314
+ setCellPixels?(width: number, height: number, precise?: { width: number; height: number }): void;
306
315
  close(): Promise<void>;
307
316
  }
308
317
 
@@ -314,6 +323,7 @@ export interface SessionRuntimeTerminalDeliveryConnection {
314
323
  nack(nack: TerminalDeliveryNack): void;
315
324
  setVisibility(visibility: TerminalDeliveryVisibility): void;
316
325
  close(): Promise<void>;
326
+ readonly graphics?: TerminalDeliveryGraphics;
317
327
  }
318
328
 
319
329
  interface PendingTicket {
@@ -1039,6 +1049,14 @@ interface PaneChannel {
1039
1049
  frozenByWire: boolean;
1040
1050
  closed: boolean;
1041
1051
  batch: SeedBatch | null;
1052
+ /** Kitty graphics: state announced to this client, and blob digests queued. */
1053
+ graphicsAnnounced: boolean;
1054
+ graphicsQueue: string[];
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;
1042
1060
  }
1043
1061
 
1044
1062
  interface QueuedSend {
@@ -1046,6 +1064,29 @@ interface QueuedSend {
1046
1064
  remaining: number;
1047
1065
  }
1048
1066
 
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
+ }
1088
+ const GRAPHICS_SEND_WINDOW_BYTES = 2 * 1024 * 1024;
1089
+
1049
1090
  export class PaneStreamLiveConnection {
1050
1091
  readonly #clientId: string;
1051
1092
  readonly #socket: DirectTerminalSocket;
@@ -1214,6 +1255,11 @@ export class PaneStreamLiveConnection {
1214
1255
  frozenByWire: false,
1215
1256
  closed: false,
1216
1257
  batch: null,
1258
+ graphicsAnnounced: false,
1259
+ graphicsQueue: [],
1260
+ graphicsSending: false,
1261
+ graphicsServed: new Map(),
1262
+ graphicsState: null,
1217
1263
  });
1218
1264
  }
1219
1265
  }
@@ -1619,6 +1665,7 @@ export class PaneStreamLiveConnection {
1619
1665
  await this.#sendTerminalDelivery(channel.semanticPaneId, message);
1620
1666
  pending.length = 0;
1621
1667
  markReady();
1668
+ this.#startGraphics(channel);
1622
1669
  }
1623
1670
  this.#closeIfAllPanesGone();
1624
1671
  }
@@ -1730,6 +1777,146 @@ export class PaneStreamLiveConnection {
1730
1777
  await this.#awaitSemanticCredit(pane);
1731
1778
  }
1732
1779
 
1780
+ /** Announce the pane's kitty graphics state; the client pulls bytes it lacks. */
1781
+ #startGraphics(channel: PaneChannel): void {
1782
+ const graphics = channel.delivery?.graphics;
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;
1802
+ graphics.subscribe((state) => {
1803
+ announced = true;
1804
+ if (this.#closed || channel.closed) return;
1805
+ channel.graphicsAnnounced = true;
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);
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
+ }
1840
+ }
1841
+
1842
+ #requestGraphics(pane: string, digests: readonly string[]): void {
1843
+ const channel = this.#panes.get(pane);
1844
+ // Requests are only meaningful after an announcement; ignore stray ones.
1845
+ if (!channel || channel.closed || !channel.graphicsAnnounced) return;
1846
+ this.#queueGraphics(channel, digests);
1847
+ }
1848
+
1849
+ #queueGraphics(channel: PaneChannel, digests: readonly string[]): void {
1850
+ for (const digest of digests)
1851
+ if (
1852
+ !channel.graphicsQueue.includes(digest) &&
1853
+ channel.graphicsQueue.length < GRAPHICS_MAX_QUEUED_DIGESTS
1854
+ )
1855
+ channel.graphicsQueue.push(digest);
1856
+ if (!channel.graphicsSending) void this.#drainGraphics(channel);
1857
+ }
1858
+
1859
+ /**
1860
+ * Blob bytes yield to terminal frames: each chunk waits until the socket's
1861
+ * buffered bytes fall below a small window, so images never trip the
1862
+ * socket-wide backpressure ceiling or delay a pane's next seed for long.
1863
+ */
1864
+ async #drainGraphics(channel: PaneChannel): Promise<void> {
1865
+ channel.graphicsSending = true;
1866
+ try {
1867
+ while (!this.#closed && !channel.closed && channel.graphicsQueue.length > 0) {
1868
+ const digest = channel.graphicsQueue.shift()!;
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;
1890
+ for (let offset = 0; offset < total; offset += TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES) {
1891
+ while ((this.#socket.bufferedAmount ?? 0) > GRAPHICS_SEND_WINDOW_BYTES) {
1892
+ if (this.#closed || channel.closed) return;
1893
+ await new Promise((resolve) => setTimeout(resolve, 5));
1894
+ }
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;
1899
+ const end = Math.min(total, offset + TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES);
1900
+ this.#sendFrame(null, {
1901
+ type: "terminal-graphics-blob",
1902
+ pane: channel.semanticPaneId,
1903
+ blob: {
1904
+ digest,
1905
+ format,
1906
+ width: image.width,
1907
+ height: image.height,
1908
+ total,
1909
+ offset,
1910
+ },
1911
+ data: Buffer.from(data.subarray(offset, end)).toString("base64"),
1912
+ });
1913
+ }
1914
+ }
1915
+ } finally {
1916
+ channel.graphicsSending = false;
1917
+ }
1918
+ }
1919
+
1733
1920
  #awaitSemanticCredit(pane: string): Promise<void> {
1734
1921
  // Backpressure is pane-local. Aggregate pressure is guarded by the hard
1735
1922
  // socket ceiling in #drainNow, but must not let a noisy sibling prevent a
@@ -2301,6 +2488,18 @@ export class PaneStreamLiveConnection {
2301
2488
  this.#acceptSemanticIntent(frame.operationId, frame.intent);
2302
2489
  return;
2303
2490
  }
2491
+ if (frame.type === "terminal-graphics-request") {
2492
+ this.#requestGraphics(frame.pane, frame.digests);
2493
+ return;
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
+ }
2304
2503
  if (frame.type === "viewport") {
2305
2504
  if (
2306
2505
  !this.#descriptor.terminalDelivery ||
@@ -183,7 +183,6 @@ export function parseSessionPaneDescriptorReply(
183
183
  !bounded(type, 256) ||
184
184
  !bounded(currentCommand, 512) ||
185
185
  !bounded(cwd, 4_096) ||
186
- !bounded(title, 1_024) ||
187
186
  !bounded(windowName, 1_024) ||
188
187
  !bounded(name, 256) ||
189
188
  !bounded(missionStamp, 256) ||
@@ -203,7 +202,9 @@ export function parseSessionPaneDescriptorReply(
203
202
  type: nonempty(type),
204
203
  currentCommand: nonempty(currentCommand),
205
204
  cwd: nonempty(cwd),
206
- title: nonempty(title),
205
+ // Stock tmux turns any APC, including kitty graphics payloads, into the
206
+ // pane title; an overlong title names nothing and must not drop the pane.
207
+ title: bounded(title, 1_024) ? nonempty(title) : null,
207
208
  windowIndex,
208
209
  windowName: nonempty(windowName),
209
210
  windowId: /^@(?:0|[1-9][0-9]*)$/u.test(windowId) ? windowId : null,