tmux-ide 2.9.10 → 2.9.12

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 (81) hide show
  1. package/bin/cli.js +4321 -2596
  2. package/package.json +1 -1
  3. package/packages/contracts/src/index.ts +4 -0
  4. package/packages/contracts/src/pane-stream.ts +72 -0
  5. package/packages/contracts/src/terminal-delivery-binary-chunk.ts +55 -0
  6. package/packages/contracts/src/terminal-delivery.ts +17 -2
  7. package/packages/contracts/src/terminal-graphics.ts +68 -0
  8. package/packages/contracts/src/terminal-projection.ts +153 -0
  9. package/packages/core/src/fixtures/terminal-projection-vectors.json +585 -0
  10. package/packages/core/src/index.ts +2 -0
  11. package/packages/core/src/terminal-delivery-binary.ts +160 -0
  12. package/packages/core/src/terminal-delivery.ts +371 -16
  13. package/packages/core/src/terminal-projection-vectors.mjs +82 -0
  14. package/packages/core/src/terminal-projection.ts +476 -0
  15. package/packages/core/src/terminal-replica-hash-cache.ts +8 -1
  16. package/packages/core/src/terminal-replica.ts +1 -1
  17. package/packages/daemon/dist/lib/daemon-embed.js +39 -3
  18. package/packages/daemon/dist/lib/embedded-tmux-server-owners.js +1 -0
  19. package/packages/daemon/dist/lib/tmux-server-owner.js +1 -0
  20. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  21. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  22. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  23. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  24. package/packages/daemon/dist/terminal/graphics/local-image-owner.js +330 -0
  25. package/packages/daemon/dist/terminal/graphics/local-image-store.js +255 -0
  26. package/packages/daemon/dist/terminal/mirror/mirror-service.js +12 -2
  27. package/packages/daemon/dist/terminal/mirror/session-channel.js +46 -23
  28. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +190 -19
  29. package/packages/daemon/dist/terminal/pane-stream/runtime.js +1 -0
  30. package/packages/daemon/dist/terminal/session-runtime/registry.js +19 -6
  31. package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +146 -16
  32. package/packages/daemon/dist/terminal/session-runtime/terminal-projection-delivery.js +32 -0
  33. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +95 -36
  34. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +5 -0
  35. package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +2 -2
  36. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +4 -0
  37. package/packages/daemon/dist/tui/mirror/runtime/application-generation-starter.js +16 -1
  38. package/packages/daemon/dist/tui/mirror/runtime/application-machine-agent-navigation.js +13 -0
  39. package/packages/daemon/dist/tui/mirror/runtime/application-machine-navigation.js +12 -4
  40. package/packages/daemon/dist/tui/mirror/runtime/application-retained-agent.js +18 -0
  41. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +90 -5
  42. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +13 -1
  43. package/packages/daemon/dist/tui/mirror/runtime/open-tui-generation-host.js +2 -2
  44. package/packages/daemon/dist/tui/mirror/runtime/renderer-probe.js +195 -0
  45. package/packages/daemon/dist/tui/mirror/runtime/runtime-layout-presentation.js +6 -3
  46. package/packages/daemon/dist/tui/mirror/runtime/select-terminal-pane.js +7 -2
  47. package/packages/daemon/dist/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.js +6 -0
  48. package/packages/daemon/dist/tui/mirror/runtime/terminal-selection-waiter.js +36 -0
  49. package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +4 -0
  50. package/packages/daemon/src/lib/daemon-embed.ts +38 -2
  51. package/packages/daemon/src/lib/embedded-tmux-server-owners.ts +3 -0
  52. package/packages/daemon/src/lib/tmux-server-owner.ts +3 -0
  53. package/packages/daemon/src/terminal/graphics/local-image-owner.ts +346 -0
  54. package/packages/daemon/src/terminal/graphics/local-image-store.ts +286 -0
  55. package/packages/daemon/src/terminal/mirror/mirror-service.ts +19 -2
  56. package/packages/daemon/src/terminal/mirror/session-channel.ts +57 -9
  57. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +248 -16
  58. package/packages/daemon/src/terminal/pane-stream/runtime.ts +3 -0
  59. package/packages/daemon/src/terminal/session-runtime/registry.ts +25 -3
  60. package/packages/daemon/src/terminal/session-runtime/runtime-observability.ts +1 -1
  61. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +216 -22
  62. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-observation-identity.ts +7 -4
  63. package/packages/daemon/src/terminal/session-runtime/terminal-projection-delivery.ts +59 -0
  64. package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +102 -44
  65. package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +6 -0
  66. package/packages/daemon/src/terminal/session-runtime/transport-binding.ts +3 -0
  67. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +6 -0
  68. package/packages/daemon/src/tui/mirror/runtime/application-generation-starter.ts +17 -1
  69. package/packages/daemon/src/tui/mirror/runtime/application-machine-agent-navigation.ts +15 -0
  70. package/packages/daemon/src/tui/mirror/runtime/application-machine-navigation.ts +18 -4
  71. package/packages/daemon/src/tui/mirror/runtime/application-retained-agent.ts +29 -0
  72. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +89 -6
  73. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +20 -1
  74. package/packages/daemon/src/tui/mirror/runtime/open-tui-generation-host.ts +2 -2
  75. package/packages/daemon/src/tui/mirror/runtime/renderer-probe.ts +257 -0
  76. package/packages/daemon/src/tui/mirror/runtime/runtime-layout-presentation.ts +7 -4
  77. package/packages/daemon/src/tui/mirror/runtime/select-terminal-pane.ts +9 -1
  78. package/packages/daemon/src/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.ts +15 -0
  79. package/packages/daemon/src/tui/mirror/runtime/terminal-selection-waiter.ts +36 -0
  80. package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +4 -0
  81. package/packages/daemon-client/src/pane-stream-client.ts +92 -1
@@ -0,0 +1,257 @@
1
+ /** Opt-in controlled nonce workload; renderer-buffer completion, never visible pixels. */
2
+ import type { TerminalReplicaState } from "@tmux-ide/core";
3
+ import { visibleTerminalRowAt } from "../semantic-pane-render-source.ts";
4
+ import type { TerminalReplicaRow, TerminalReplicaSnapshot } from "@tmux-ide/contracts";
5
+ import type { SessionRuntimeTerminalInput } from "@tmux-ide/contracts";
6
+ import { appendFile } from "node:fs/promises";
7
+ import { isAbsolute } from "node:path";
8
+ export interface ProbeTrial {
9
+ id: string;
10
+ nonce: string;
11
+ kind?: "input" | "scroll" | "output";
12
+ }
13
+ export interface ProbeRecord extends ProbeTrial {
14
+ side: "tui";
15
+ kind: "input" | "scroll" | "output";
16
+ sourceScope: Scope;
17
+ expectedOffset: number;
18
+ sourceOffset: number;
19
+ expectedScope: Scope;
20
+ admittedAtMicros: number;
21
+ completedAtMicros: number;
22
+ witness?: { row: number; column: number; marker: string; revision: number; stateHash: string };
23
+ endpoint: "input-admission-to-renderer-work-complete";
24
+ status: "completed" | "failed";
25
+ durationMicros?: number;
26
+ reason?: string;
27
+ }
28
+ interface Scope {
29
+ pane: string;
30
+ generation: string;
31
+ incarnation: string;
32
+ cols: number;
33
+ rows: number;
34
+ }
35
+ function scope(state: TerminalReplicaState): Scope | null {
36
+ return state.snapshot
37
+ ? {
38
+ pane: state.semanticPaneId,
39
+ generation: state.generation,
40
+ incarnation: state.incarnation,
41
+ cols: state.snapshot.cols,
42
+ rows: state.snapshot.rows,
43
+ }
44
+ : null;
45
+ }
46
+ const key = (s: Scope) => JSON.stringify([s.pane, s.generation, s.incarnation, s.cols, s.rows]);
47
+ export class RendererProbe {
48
+ #next = 0;
49
+ #pending: {
50
+ trial: ProbeTrial;
51
+ scope: Scope;
52
+ at: number;
53
+ marker: string;
54
+ offset: number;
55
+ sourceOffset: number;
56
+ } | null = null;
57
+ constructor(
58
+ private readonly trials: readonly ProbeTrial[],
59
+ private readonly emit: (r: ProbeRecord) => void,
60
+ ) {
61
+ if (
62
+ trials.length > 64 ||
63
+ new Set(trials.map((t) => t.nonce)).size !== trials.length ||
64
+ trials.some(
65
+ (t) =>
66
+ !t.id ||
67
+ t.id.length > 128 ||
68
+ !/^[a-f0-9]{32}$/.test(t.nonce) ||
69
+ (t.kind !== undefined && !["input", "scroll", "output"].includes(t.kind)),
70
+ )
71
+ )
72
+ throw new Error("Invalid renderer probe trials");
73
+ }
74
+ get pending(): boolean {
75
+ return this.#pending !== null;
76
+ }
77
+ admit(
78
+ input: SessionRuntimeTerminalInput,
79
+ state: TerminalReplicaState | null,
80
+ at: number,
81
+ ): SessionRuntimeTerminalInput | null {
82
+ if (
83
+ input.kind !== "text" ||
84
+ input.data !== "!" ||
85
+ !state?.snapshot ||
86
+ this.#pending ||
87
+ !this.trials[this.#next]
88
+ )
89
+ return null;
90
+ if (this.trials[this.#next]?.kind === "scroll")
91
+ return { kind: "text", data: `PROBE-PREPARE-SCROLL:${this.trials[this.#next]!.nonce}\n` };
92
+ const trial = this.trials[this.#next++]!,
93
+ marker = `PROBE-OUTPUT:${trial.nonce}`,
94
+ identity = scope(state)!;
95
+ this.#pending = { trial, scope: identity, at, marker, offset: 0, sourceOffset: 0 };
96
+ if (
97
+ state.snapshot.cols < marker.length ||
98
+ state.snapshot.grid.some((r) =>
99
+ r.cells
100
+ .map((c) => c.grapheme)
101
+ .join("")
102
+ .includes(marker),
103
+ )
104
+ ) {
105
+ this.cancel("negative-control", at);
106
+ return null;
107
+ }
108
+ return {
109
+ kind: "text",
110
+ data: `${trial.kind === "output" ? "PROBE-BURST" : "PROBE-IN"}:${trial.nonce}\n`,
111
+ };
112
+ }
113
+ scroll(
114
+ identity: Scope,
115
+ snapshot: TerminalReplicaSnapshot,
116
+ before: number,
117
+ after: number,
118
+ at: number,
119
+ ): void {
120
+ const trial = this.trials[this.#next];
121
+ if (this.#pending || trial?.kind !== "scroll" || before === after) return;
122
+ const row = visibleTerminalRowAt(snapshot, after, 0);
123
+ const text =
124
+ row?.cells
125
+ .map((c) => c.grapheme)
126
+ .join("")
127
+ .trimEnd() ?? "";
128
+ if (!new RegExp(`^PROBE-ROW:${trial.nonce}:[0-9]{4}$`).test(text)) return;
129
+ for (let y = 0; y < snapshot.rows; y++)
130
+ if (
131
+ visibleTerminalRowAt(snapshot, before, y)
132
+ ?.cells.map((c) => c.grapheme)
133
+ .join("")
134
+ .trimEnd() === text
135
+ )
136
+ return;
137
+ this.#next++;
138
+ this.#pending = {
139
+ trial,
140
+ scope: identity,
141
+ at,
142
+ marker: text,
143
+ offset: after,
144
+ sourceOffset: before,
145
+ };
146
+ }
147
+ tick(at: number): void {
148
+ if (this.#pending && at - this.#pending.at > 5_000_000) this.cancel("timeout", at);
149
+ }
150
+ cancel(reason: string, at: number): void {
151
+ const p = this.#pending;
152
+ if (!p) return;
153
+ this.#pending = null;
154
+ this.emit({
155
+ ...p.trial,
156
+ side: "tui",
157
+ kind: p.trial.kind ?? "input",
158
+ sourceScope: p.scope,
159
+ expectedScope: p.scope,
160
+ sourceOffset: p.sourceOffset,
161
+ expectedOffset: p.offset,
162
+ admittedAtMicros: p.at,
163
+ completedAtMicros: at,
164
+ endpoint: "input-admission-to-renderer-work-complete",
165
+ status: "failed",
166
+ reason,
167
+ });
168
+ }
169
+ observe(state: TerminalReplicaState | null, at: number): void {
170
+ if (!this.#pending) return;
171
+ if (state && state.semanticPaneId !== this.#pending.scope.pane) return;
172
+ if (!state || !scope(state) || key(scope(state)!) !== key(this.#pending.scope))
173
+ this.cancel("scope-changed", at);
174
+ else if (at - this.#pending.at > 5_000_000) this.cancel("timeout", at);
175
+ }
176
+ row(
177
+ state: TerminalReplicaState,
178
+ row: TerminalReplicaRow | undefined,
179
+ viewportRow: number,
180
+ width: number,
181
+ height: number,
182
+ left: number,
183
+ scrollOffset: number,
184
+ at: number,
185
+ ): void {
186
+ this.observe(state, at);
187
+ const p = this.#pending;
188
+ if (!p || state.semanticPaneId !== p.scope.pane) return;
189
+ if (
190
+ scrollOffset !== p.offset ||
191
+ left !== 0 ||
192
+ viewportRow !== 0 ||
193
+ height < 1 ||
194
+ width < p.marker.length ||
195
+ !row
196
+ )
197
+ return;
198
+ const cells = row.cells.slice(0, p.marker.length);
199
+ if (cells.some((c) => c.width !== 1) || cells.map((c) => c.grapheme).join("") !== p.marker)
200
+ return;
201
+ if (!Number.isSafeInteger(at) || at < p.at) {
202
+ this.cancel("clock-invalid", at);
203
+ return;
204
+ }
205
+ this.#pending = null;
206
+ this.emit({
207
+ ...p.trial,
208
+ side: "tui",
209
+ kind: p.trial.kind ?? "input",
210
+ sourceScope: p.scope,
211
+ expectedScope: p.scope,
212
+ sourceOffset: p.sourceOffset,
213
+ expectedOffset: p.offset,
214
+ admittedAtMicros: p.at,
215
+ completedAtMicros: at,
216
+ endpoint: "input-admission-to-renderer-work-complete",
217
+ status: "completed",
218
+ durationMicros: at - p.at,
219
+ witness: {
220
+ row: viewportRow,
221
+ column: 0,
222
+ marker: p.marker,
223
+ revision: state.revision,
224
+ stateHash: state.hash,
225
+ },
226
+ });
227
+ }
228
+ }
229
+ let singleton: RendererProbe | null | undefined;
230
+ /** All filesystem work is asynchronous and deferred; at most64 short receipts. */
231
+ export function rendererProbe(): RendererProbe | null {
232
+ if (singleton !== undefined) return singleton;
233
+ singleton = null;
234
+ if (process.env.TMUX_IDE_RENDERER_FIXTURE !== "1") return null;
235
+ const raw = process.env.TMUX_IDE_RENDERER_PROBE_TRIALS,
236
+ path = process.env.TMUX_IDE_RENDERER_PROBE_LOG;
237
+ if (!raw || raw.length > 16384 || !path || !isAbsolute(path)) return null;
238
+ try {
239
+ const trials: unknown = JSON.parse(raw);
240
+ if (!Array.isArray(trials)) return null;
241
+ let writes = Promise.resolve(),
242
+ failed = false;
243
+ singleton = new RendererProbe(trials, (record) => {
244
+ if (failed) return;
245
+ writes = writes
246
+ .then(() => appendFile(path, JSON.stringify(record) + "\n", { mode: 0o600 }))
247
+ .catch(() => {
248
+ failed = true;
249
+ });
250
+ });
251
+ const timer = setInterval(() => singleton?.tick(Math.floor(performance.now() * 1000)), 250);
252
+ timer.unref();
253
+ } catch {
254
+ singleton = null;
255
+ }
256
+ return singleton;
257
+ }
@@ -11,7 +11,7 @@ export interface OpenTuiRuntimeLayoutPresentation {
11
11
  getWindowSnapshot(): OpenTuiWorkspaceLayoutSnapshot;
12
12
  subscribe(listener: (layout: OpenTuiTerminalLayout | null) => void): () => void;
13
13
  subscribeWindows(listener: (layout: OpenTuiWorkspaceLayoutSnapshot) => void): () => void;
14
- adopt(port: OpenTuiLayoutPort): () => void;
14
+ adopt(port: OpenTuiLayoutPort, rendererEpoch: number): () => void;
15
15
  clear(): void;
16
16
  dispose(): void;
17
17
  }
@@ -66,21 +66,24 @@ export function createOpenTuiRuntimeLayoutPresentation(): OpenTuiRuntimeLayoutPr
66
66
  listener(windows);
67
67
  return () => windowListeners.delete(listener);
68
68
  },
69
- adopt(port) {
69
+ adopt(port, rendererEpoch) {
70
70
  if (disposed) return () => undefined;
71
71
  let candidate = true;
72
72
  let candidateStop: (() => void) | null = null;
73
73
  candidateStop = port.onLayout((layout) => {
74
74
  if (disposed || !candidate || !layout.current) return;
75
+ // A port may reuse its snapshot object. Own the provenance wrapper so
76
+ // a preparing runtime cannot relabel the retained coherent frame.
77
+ const presented = Object.freeze({ ...layout, rendererEpoch });
75
78
  if (currentPort !== port) {
76
79
  const previousStop = stopLayout;
77
80
  currentPort = port;
78
81
  stopLayout = candidateStop;
79
- publish(layout);
82
+ publish(presented);
80
83
  previousStop?.();
81
84
  return;
82
85
  }
83
- publish(layout);
86
+ publish(presented);
84
87
  });
85
88
  // `onLayout` intentionally seeds synchronously. Capture the disposer
86
89
  // after that first callback has promoted this candidate.
@@ -20,6 +20,7 @@ export type PaneSelectionFailure = Readonly<{
20
20
  | "generation-replaced"
21
21
  | "operation-timeout"
22
22
  | "transport-rejected"
23
+ | "topology-changed"
23
24
  | "receipt-invalid";
24
25
  backendReason?:
25
26
  | "pane_inventory_not_ready"
@@ -42,6 +43,7 @@ export async function selectTerminalPane(
42
43
  operationId?: string,
43
44
  onFailure?: (failure: PaneSelectionFailure) => void,
44
45
  windowLink?: WindowLinkTarget,
46
+ onDispatch?: () => void,
45
47
  ): Promise<PaneSelectionReceipt | null> {
46
48
  const fail = (failure: PaneSelectionFailure): null => {
47
49
  try {
@@ -62,7 +64,12 @@ export async function selectTerminalPane(
62
64
  : undefined;
63
65
  return fail({
64
66
  stage,
65
- reason: code === "operation-timeout" ? "operation-timeout" : "transport-rejected",
67
+ reason:
68
+ code === "operation-timeout"
69
+ ? "operation-timeout"
70
+ : code === "topology-changed" && stage === "dispatch"
71
+ ? "topology-changed"
72
+ : "transport-rejected",
66
73
  ...(backendReason ? { backendReason } : {}),
67
74
  });
68
75
  };
@@ -90,6 +97,7 @@ export async function selectTerminalPane(
90
97
  if (!isCurrent()) return fail({ stage: "pre-dispatch", reason: "generation-replaced" });
91
98
  let result: unknown;
92
99
  try {
100
+ onDispatch?.();
93
101
  result = await expected.client.dispatch({
94
102
  kind: "semantic-intent",
95
103
  ...(operationId ? { operationId } : {}),
@@ -1,3 +1,4 @@
1
+ import { rendererProbe } from "./renderer-probe.ts";
1
2
  import type { ReadNativeBacking } from "../../../terminal/protocol/native-backing-client.ts";
2
3
  import type { TerminalReplicaSnapshot } from "@tmux-ide/contracts";
3
4
  import type {
@@ -637,6 +638,8 @@ export class TerminalFastLaneRendererAdapter implements PaneScopedTerminalAdapte
637
638
  // live parsing and must keep following the native pane's actual size.
638
639
  if (interest.retainedView && next) this.#resizeRetainedView(interest, next);
639
640
  interest.state = publication.state;
641
+ if (rendererProbe()?.pending)
642
+ rendererProbe()?.observe(publication.state, Math.floor(performance.now() * 1000));
640
643
  // Preserve coordinates for an unchanged prefix or an exact retained suffix.
641
644
  // The suffix offset is fixed by the length difference; never search repeated
642
645
  // log lines for a guessed overlap. Changed grids/reflow need a separate map.
@@ -992,6 +995,18 @@ export class TerminalFastLaneRendererAdapter implements PaneScopedTerminalAdapte
992
995
  options.palette,
993
996
  options.viewportOrigin?.x ?? 0,
994
997
  );
998
+ if (interest?.state && rendererProbe()?.pending) {
999
+ rendererProbe()?.row(
1000
+ interest.retainedView?.state ?? interest.state,
1001
+ sourceRow,
1002
+ row,
1003
+ width,
1004
+ height,
1005
+ options.viewportOrigin?.x ?? 0,
1006
+ options.viewportOrigin ? Math.max(0, -options.viewportOrigin.y) : scrollOffset,
1007
+ Math.floor(performance.now() * 1_000),
1008
+ );
1009
+ }
995
1010
  options.dirtyRows.push(row);
996
1011
  paintedRows[row] = sourceRow;
997
1012
  }
@@ -0,0 +1,36 @@
1
+ /** One bounded selection observation; never submits or repeats a mutation. */
2
+ export function createTerminalSelectionWaiter() {
3
+ let pending: {
4
+ check: () => boolean | "pending";
5
+ finish: (selected: boolean) => void;
6
+ } | null = null;
7
+ const cancel = () => pending?.finish(false);
8
+ const reconcile = () => {
9
+ const waiting = pending;
10
+ if (!waiting) return;
11
+ const result = waiting.check();
12
+ if (result !== "pending") waiting.finish(result);
13
+ };
14
+ return {
15
+ cancel,
16
+ reconcile,
17
+ wait(check: () => boolean | "pending"): Promise<boolean> {
18
+ cancel();
19
+ return new Promise((resolve) => {
20
+ const timer = setTimeout(() => waiting.finish(false), 5000);
21
+ timer.unref?.();
22
+ const waiting = {
23
+ check,
24
+ finish(selected: boolean) {
25
+ if (pending !== waiting) return;
26
+ pending = null;
27
+ clearTimeout(timer);
28
+ resolve(selected);
29
+ },
30
+ };
31
+ pending = waiting;
32
+ reconcile();
33
+ });
34
+ },
35
+ };
36
+ }
@@ -202,6 +202,10 @@ export class SemanticPaneReplica {
202
202
  }
203
203
 
204
204
  accept(message: TerminalDeliveryServerMessage): void {
205
+ if (message.type === "terminal.projection") {
206
+ this.#fail("protocol-violation", message.transactionId);
207
+ return;
208
+ }
205
209
  if (message.type === "terminal.delivery.fault") {
206
210
  this.#assembler = null;
207
211
  this.#snapshot = null;
@@ -1,5 +1,12 @@
1
1
  import {
2
2
  PANE_STREAM_ISSUE_PATH,
3
+ decodeTerminalDeliveryBinaryChunk,
4
+ decodeTerminalGraphicsBinary,
5
+ TERMINAL_DELIVERY_BINARY_CAPABILITY_VERSION,
6
+ TERMINAL_DELIVERY_BINARY_LOCAL_CAPABILITY_VERSION,
7
+ PANE_STREAM_INPUT_BYTES_CAPABILITY_VERSION,
8
+ PaneStreamInputLifetimeSchemaZ,
9
+ type PaneStreamInputLifetime,
3
10
  PANE_STREAM_PROTOCOL_VERSION,
4
11
  PaneStreamClientFrameSchemaZ,
5
12
  PaneStreamIssueMutationRequestSchemaZ,
@@ -116,6 +123,7 @@ function createRuntimeTimer(callback: () => void, delayMs: number): RuntimeTimer
116
123
 
117
124
  export interface PaneStreamClientSocket {
118
125
  readonly readyState: number;
126
+ binaryType?: string;
119
127
  /** Optional native/socket queue watermark in bytes. */
120
128
  readonly bufferedAmount?: number;
121
129
  addEventListener(type: SocketEventType, listener: SocketListener): void;
@@ -343,6 +351,8 @@ export interface PaneStreamRuntimeClient {
343
351
  readonly effectiveViewerMode: PaneStreamIssueDescriptor["effectiveViewerMode"];
344
352
  readonly authoritySnapshot: SessionRuntimeAuthoritySnapshot | null;
345
353
  readonly connectionClientId: string | null;
354
+ readonly supportsInputLifetime?: boolean;
355
+ readonly supportsInputBytes?: boolean;
346
356
  ownsConnectionAuthority(authority: SessionRuntimeAuthorityKind): boolean;
347
357
  connectionAuthorityClientId(authority: SessionRuntimeAuthorityKind): string | null;
348
358
  setPresence(state: SessionRuntimePresenceState): void;
@@ -358,6 +368,7 @@ export interface PaneStreamRuntimeClient {
358
368
  input: SessionRuntimeTerminalInput,
359
369
  performanceTraceId?: string,
360
370
  causalProbe?: CausalCellProbeRequestV1,
371
+ expectedLifetime?: PaneStreamInputLifetime,
361
372
  ): Promise<SessionRuntimeTerminalInputResult>;
362
373
  sendText(pane: string, text: string, performanceTraceId?: string): void;
363
374
  sendKey(pane: string, key: string, performanceTraceId?: string): void;
@@ -452,6 +463,7 @@ export async function connectIssuedPaneStreamRuntimeClient(
452
463
  "X-Tmux-Ide-Host-Client-Id": options.hostClientId,
453
464
  "X-Tmux-Ide-Request-Id": descriptor.requestId,
454
465
  });
466
+ socket.binaryType = "arraybuffer";
455
467
  options.onConnectionDiagnostic?.("socket-created", { requestId: descriptor.requestId });
456
468
  const releaseSocketResource = acquireRuntimeResource("pane-stream-socket");
457
469
  const releaseSocketListenerResources = acquireRuntimeResource("socket-listener", 4);
@@ -470,6 +482,13 @@ export async function connectIssuedPaneStreamRuntimeClient(
470
482
  };
471
483
  let closed = false;
472
484
  let verified = false;
485
+ const binaryPanes = new Set<string>();
486
+ const binaryGraphicsPanes = new Set<string>();
487
+ const offeredBinary = options.stream.terminalDelivery.protocolVersions.some(
488
+ (version) =>
489
+ version === TERMINAL_DELIVERY_BINARY_CAPABILITY_VERSION ||
490
+ version === TERMINAL_DELIVERY_BINARY_LOCAL_CAPABILITY_VERSION,
491
+ );
473
492
  let layoutTopologyEpoch = -1;
474
493
  let windowLinks: WindowLinkTopology | null = null;
475
494
  let resolveReady!: (client: PaneStreamRuntimeClient) => void;
@@ -984,13 +1003,24 @@ export async function connectIssuedPaneStreamRuntimeClient(
984
1003
  connectionAuthorityReleases.set(authority, release);
985
1004
  return release;
986
1005
  };
1006
+ let supportsInputLifetime = false;
1007
+ let supportsInputBytes = false;
987
1008
  const sendTerminalInput = (
988
1009
  target: TerminalReplicaAddress,
989
1010
  rawInput: SessionRuntimeTerminalInput,
990
1011
  performanceTraceId?: string,
991
1012
  causalProbeRequest?: CausalCellProbeRequestV1,
1013
+ expectedLifetime?: PaneStreamInputLifetime,
992
1014
  ): Promise<SessionRuntimeTerminalInputResult> => {
1015
+ if (expectedLifetime && !supportsInputLifetime)
1016
+ return Promise.reject(new Error("Input lifetime capability unavailable"));
1017
+ const lifetime =
1018
+ expectedLifetime === undefined
1019
+ ? undefined
1020
+ : PaneStreamInputLifetimeSchemaZ.parse(expectedLifetime);
993
1021
  const input = SessionRuntimeTerminalInputSchemaZ.parse(rawInput);
1022
+ if (input.kind === "bytes" && !supportsInputBytes)
1023
+ return Promise.reject(new Error("Byte input capability unavailable"));
994
1024
  const pane = target.semanticPaneId;
995
1025
  if (!descriptor.panes.includes(pane)) {
996
1026
  return Promise.reject(
@@ -1087,6 +1117,7 @@ export async function connectIssuedPaneStreamRuntimeClient(
1087
1117
  seq: sequence,
1088
1118
  ...(performanceTraceId ? { performanceTraceId } : {}),
1089
1119
  ...(causalProbe ? { causalProbe } : {}),
1120
+ ...(lifetime ? { expectedLifetime: lifetime } : {}),
1090
1121
  },
1091
1122
  Boolean(transportStage),
1092
1123
  );
@@ -1234,7 +1265,41 @@ export async function connectIssuedPaneStreamRuntimeClient(
1234
1265
  );
1235
1266
  };
1236
1267
  const onMessage: SocketListener = (event) => {
1237
- if (closed || typeof event.data !== "string") return fail("Pane-stream frame was not text");
1268
+ if (closed) return;
1269
+ if (typeof event.data !== "string") {
1270
+ if (!verified) return fail("Binary pane-stream frame before readiness");
1271
+ const data =
1272
+ event.data instanceof ArrayBuffer
1273
+ ? new Uint8Array(event.data)
1274
+ : event.data instanceof Uint8Array
1275
+ ? event.data
1276
+ : null;
1277
+ if (!data) return fail("Unsupported binary pane-stream data");
1278
+ try {
1279
+ if (data[0] === 84 && data[1] === 68 && data[2] === 67 && data[3] === 49) {
1280
+ const { header, bytes } = decodeTerminalDeliveryBinaryChunk(data);
1281
+ if (!descriptor.panes.includes(header.pane) || !binaryPanes.has(header.pane))
1282
+ return fail("Unnegotiated binary terminal delivery");
1283
+ options.onTerminalDelivery(header.pane, {
1284
+ type: "terminal.delivery.chunk",
1285
+ transactionId: header.transactionId,
1286
+ index: header.index,
1287
+ bytes:
1288
+ bytes.buffer instanceof ArrayBuffer
1289
+ ? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength)
1290
+ : bytes.slice(),
1291
+ });
1292
+ } else {
1293
+ const { header, bytes } = decodeTerminalGraphicsBinary(data);
1294
+ if (!descriptor.panes.includes(header.pane) || !binaryGraphicsPanes.has(header.pane))
1295
+ return fail("Unnegotiated binary graphics delivery");
1296
+ options.onTerminalGraphicsBlob?.(header.pane, header.blob, bytes);
1297
+ }
1298
+ } catch {
1299
+ return fail("Invalid binary pane-stream frame");
1300
+ }
1301
+ return;
1302
+ }
1238
1303
  const arrivedAtMicros = options.onTerminalFrameArrival
1239
1304
  ? Math.floor(performance.now() * 1_000)
1240
1305
  : 0;
@@ -1285,6 +1350,12 @@ export async function connectIssuedPaneStreamRuntimeClient(
1285
1350
  )
1286
1351
  return fail("Pane-stream peer client identity did not match the redeemed connection");
1287
1352
  connectionClientId = frame.connectionClientId ?? null;
1353
+ supportsInputLifetime = frame.inputLifetime === true;
1354
+ supportsInputBytes =
1355
+ frame.inputBytes === true &&
1356
+ options.stream.terminalDelivery.protocolVersions.includes(
1357
+ PANE_STREAM_INPUT_BYTES_CAPABILITY_VERSION,
1358
+ );
1288
1359
  verified = true;
1289
1360
  options.onConnectionDiagnostic?.("ready-frame", {
1290
1361
  requestId: descriptor.requestId,
@@ -1370,7 +1441,21 @@ export async function connectIssuedPaneStreamRuntimeClient(
1370
1441
  options.onCausalCellFailure?.(frame.failure);
1371
1442
  return;
1372
1443
  }
1444
+ if (frame.type === "terminal-graphics") {
1445
+ if (!descriptor.panes.includes(frame.pane)) return fail("Unissued graphics pane");
1446
+ if (frame.state.binaryBlobs === true) binaryGraphicsPanes.add(frame.pane);
1447
+ else binaryGraphicsPanes.delete(frame.pane);
1448
+ }
1373
1449
  if (frame.type === "terminal-delivery-ready") {
1450
+ if (!descriptor.panes.includes(frame.pane)) return fail("Unissued terminal pane");
1451
+ binaryPanes.delete(frame.pane);
1452
+ if (
1453
+ frame.negotiation.accepted &&
1454
+ frame.negotiation.negotiated.encoding === "semantic-binary-v1"
1455
+ ) {
1456
+ if (!offeredBinary) return fail("Unsolicited binary terminal negotiation");
1457
+ binaryPanes.add(frame.pane);
1458
+ }
1374
1459
  if (frame.negotiation.accepted) {
1375
1460
  negotiatedDeliveries.set(frame.pane, {
1376
1461
  generation: frame.negotiation.negotiated.generation,
@@ -1553,6 +1638,12 @@ export async function connectIssuedPaneStreamRuntimeClient(
1553
1638
  send({ type: "activity", generation: descriptor.daemonInstanceId, activity }),
1554
1639
  requestAuthority,
1555
1640
  releaseAuthority,
1641
+ get supportsInputBytes() {
1642
+ return supportsInputBytes;
1643
+ },
1644
+ get supportsInputLifetime() {
1645
+ return supportsInputLifetime;
1646
+ },
1556
1647
  sendTerminalInput,
1557
1648
  sendText: (pane, text, performanceTraceId) => {
1558
1649
  void sendTerminalInput(