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
@@ -0,0 +1,190 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { CanonicalTerminalReplicaUpdate } from "@tmux-ide/contracts";
3
+
4
+ export interface HistoryAnchorProof {
5
+ readonly workspaceName: string;
6
+ readonly semanticPaneId: string;
7
+ readonly generation: string;
8
+ readonly incarnation: string;
9
+ readonly revision: number;
10
+ readonly stateHash: string;
11
+ }
12
+ export type HistoryAnchorIdentity = Pick<
13
+ HistoryAnchorProof,
14
+ "workspaceName" | "semanticPaneId" | "generation" | "incarnation"
15
+ >;
16
+ type Failure = { readonly status: "unavailable" | "stale" | "lost" };
17
+ const MAX_TOKENS = 32;
18
+ const PROOF_TTL_MS = 5 * 60_000;
19
+
20
+ /** Owner-private row ordinals. Never changes terminal v1 payloads or hashes. */
21
+ export class HistoryAnchorOwner {
22
+ #current: (HistoryAnchorProof & { cols: number; rows: number; alternate: boolean }) | null = null;
23
+ #first = 0;
24
+ #length = 0;
25
+ #proofs = new Map<string, { first: number; length: number; expires: number }>();
26
+ #tokens = new Map<string, { ordinal: number }>();
27
+ constructor(private readonly now: () => number = () => performance.now()) {}
28
+
29
+ invalidate(): void {
30
+ this.#current = null;
31
+ this.#first = 0;
32
+ this.#length = 0;
33
+ this.#tokens.clear();
34
+ this.#proofs.clear();
35
+ }
36
+
37
+ observe(update: CanonicalTerminalReplicaUpdate): void {
38
+ if (update.type === "terminal.tombstone") {
39
+ this.invalidate();
40
+ return;
41
+ }
42
+ if (update.type === "terminal.seed") {
43
+ this.invalidate();
44
+ this.#length = update.snapshot.history.length;
45
+ this.#current = {
46
+ workspaceName: update.workspaceName,
47
+ semanticPaneId: update.semanticPaneId,
48
+ generation: update.generation,
49
+ incarnation: update.incarnation,
50
+ revision: update.revision,
51
+ stateHash: update.stateHash,
52
+ cols: update.cols,
53
+ rows: update.rows,
54
+ alternate: update.snapshot.modes.alternateScreen,
55
+ };
56
+ this.#remember();
57
+ return;
58
+ }
59
+ const old = this.#current;
60
+ if (
61
+ !old ||
62
+ old.workspaceName !== update.workspaceName ||
63
+ old.semanticPaneId !== update.semanticPaneId ||
64
+ old.generation !== update.generation ||
65
+ old.incarnation !== update.incarnation ||
66
+ old.revision !== update.baseRevision
67
+ ) {
68
+ this.invalidate();
69
+ return;
70
+ }
71
+ const delta = update.patch.historyDelta;
72
+ const alternate = update.patch.modes?.alternateScreen ?? old.alternate;
73
+ if (
74
+ delta &&
75
+ (!Number.isSafeInteger(delta.trim) || delta.trim < 0 || delta.trim > this.#length)
76
+ ) {
77
+ this.invalidate();
78
+ return;
79
+ }
80
+ const length =
81
+ update.patch.history?.length ??
82
+ this.#length - (delta?.trim ?? 0) + (delta?.append.length ?? 0);
83
+ const reset =
84
+ update.patch.history !== undefined ||
85
+ update.cols !== old.cols ||
86
+ update.rows !== old.rows ||
87
+ alternate !== old.alternate;
88
+ if (reset) {
89
+ this.#tokens.clear();
90
+ this.#proofs.clear();
91
+ this.#first = 0;
92
+ } else this.#first += delta?.trim ?? 0;
93
+ if (!Number.isSafeInteger(this.#first + length)) {
94
+ this.invalidate();
95
+ return;
96
+ }
97
+ this.#length = length;
98
+ this.#current = {
99
+ workspaceName: update.workspaceName,
100
+ semanticPaneId: update.semanticPaneId,
101
+ generation: update.generation,
102
+ incarnation: update.incarnation,
103
+ revision: update.revision,
104
+ stateHash: update.stateHash,
105
+ cols: update.cols,
106
+ rows: update.rows,
107
+ alternate,
108
+ };
109
+ this.#prune();
110
+ this.#remember();
111
+ }
112
+
113
+ capture(
114
+ proof: HistoryAnchorProof,
115
+ historyIndex: number,
116
+ ): Failure | { status: "captured"; token: string } {
117
+ const failure = this.#admitIdentity(proof);
118
+ if (failure) return failure;
119
+ this.#prune();
120
+ const known =
121
+ this.#current && this.#key(proof) === this.#key(this.#current)
122
+ ? { first: this.#first, length: this.#length }
123
+ : this.#proofs.get(this.#key(proof));
124
+ if (!known) return { status: "stale" };
125
+ if (!Number.isSafeInteger(historyIndex) || historyIndex < 0 || historyIndex >= known.length)
126
+ return { status: "lost" };
127
+ const ordinal = known.first + historyIndex;
128
+ if (ordinal < this.#first || ordinal >= this.#first + this.#length) return { status: "lost" };
129
+ while (this.#tokens.size >= MAX_TOKENS) this.#tokens.delete(this.#tokens.keys().next().value!);
130
+ const token = randomUUID();
131
+ // Saved tabs retain identity until capacity eviction or actual history loss.
132
+ this.#tokens.set(token, { ordinal });
133
+ return { status: "captured", token };
134
+ }
135
+
136
+ resolve(
137
+ proof: HistoryAnchorIdentity,
138
+ token: string,
139
+ ): Failure | { status: "resolved"; proof: HistoryAnchorProof; historyIndex: number } {
140
+ const failure = this.#admitIdentity(proof);
141
+ if (failure) return failure;
142
+ this.#prune();
143
+ const entry = this.#tokens.get(token);
144
+ if (!entry) return { status: "lost" };
145
+ const { workspaceName, semanticPaneId, generation, incarnation, revision, stateHash } =
146
+ this.#current!;
147
+ return {
148
+ status: "resolved",
149
+ proof: { workspaceName, semanticPaneId, generation, incarnation, revision, stateHash },
150
+ historyIndex: entry.ordinal - this.#first,
151
+ };
152
+ }
153
+
154
+ #admitIdentity(proof: HistoryAnchorIdentity): Failure | null {
155
+ const current = this.#current;
156
+ if (!current || current.alternate) return { status: "unavailable" };
157
+ return current.workspaceName === proof.workspaceName &&
158
+ current.semanticPaneId === proof.semanticPaneId &&
159
+ current.generation === proof.generation &&
160
+ current.incarnation === proof.incarnation
161
+ ? null
162
+ : { status: "stale" };
163
+ }
164
+ #key(proof: HistoryAnchorProof): string {
165
+ return JSON.stringify([
166
+ proof.workspaceName,
167
+ proof.semanticPaneId,
168
+ proof.generation,
169
+ proof.incarnation,
170
+ proof.revision,
171
+ proof.stateHash,
172
+ ]);
173
+ }
174
+ #remember(): void {
175
+ if (!this.#current || this.#current.alternate) return;
176
+ while (this.#proofs.size >= 64) this.#proofs.delete(this.#proofs.keys().next().value!);
177
+ this.#proofs.set(this.#key(this.#current), {
178
+ first: this.#first,
179
+ length: this.#length,
180
+ expires: this.now() + PROOF_TTL_MS,
181
+ });
182
+ }
183
+ #prune(): void {
184
+ const now = this.now();
185
+ for (const [key, value] of this.#proofs) if (value.expires <= now) this.#proofs.delete(key);
186
+ for (const [token, entry] of this.#tokens)
187
+ if (entry.ordinal < this.#first || entry.ordinal >= this.#first + this.#length)
188
+ this.#tokens.delete(token);
189
+ }
190
+ }
@@ -1,3 +1,4 @@
1
+ import type { HistoryAnchorRequest, HistoryAnchorResult } from "@tmux-ide/contracts";
1
2
  import { StockCaptureTabUnavailableError } from "./stock-capture-fidelity.ts";
2
3
  import type { OwnedNativePlanCompletion } from "../../lib/owned-native-interaction-bindings.ts";
3
4
  import type {
@@ -255,6 +256,8 @@ export interface SessionRuntimeConsumer {
255
256
  rows: number,
256
257
  semanticWindowId?: string,
257
258
  ): void;
259
+ /** Renderer cell size in device pixels, forwarded to tmux. */
260
+ setCellPixels?(width: number, height: number, precise?: { width: number; height: number }): void;
258
261
  fitViewportWithAuthority(
259
262
  lease: SessionRuntimeAuthorityLease,
260
263
  cols: number,
@@ -875,6 +878,12 @@ export class SessionRuntimeRegistry implements PaneStreamMirror {
875
878
  return await this.#runtime(session).describe();
876
879
  }
877
880
 
881
+ historyAnchor(session: string, request: HistoryAnchorRequest): HistoryAnchorResult {
882
+ const runtime = this.#sessions.get(session);
883
+ if (this.#disposed || !runtime) return { status: "unavailable" };
884
+ return runtime.historyAnchor(request);
885
+ }
886
+
878
887
  async captureNativeBacking(
879
888
  session: string,
880
889
  semanticPaneId: string,
@@ -1760,6 +1769,17 @@ class SessionRuntime {
1760
1769
  }
1761
1770
  }
1762
1771
 
1772
+ /** Renderer cell size in device pixels; benign, so any connected client may set it. */
1773
+ setCellPixels(
1774
+ clientId: string,
1775
+ width: number,
1776
+ height: number,
1777
+ precise?: { width: number; height: number },
1778
+ ): void {
1779
+ this.#assertConnected(clientId);
1780
+ this.#mirror.setCellPixels?.(this.session, width, height, precise);
1781
+ }
1782
+
1763
1783
  fitViewportWithAuthority(
1764
1784
  clientId: string,
1765
1785
  lease: SessionRuntimeAuthorityLease,
@@ -1880,6 +1900,18 @@ class SessionRuntime {
1880
1900
  }
1881
1901
  }
1882
1902
 
1903
+ historyAnchor(request: HistoryAnchorRequest): HistoryAnchorResult {
1904
+ if (this.#disposed) return { status: "unavailable" };
1905
+ const owner = this.#terminalReplicas.get(request.proof.semanticPaneId);
1906
+ if (!owner) return { status: "unavailable" };
1907
+ if (request.operation.kind === "capture") {
1908
+ if (!("revision" in request.proof) || !("stateHash" in request.proof))
1909
+ return { status: "stale" };
1910
+ return owner.captureHistoryAnchor(request.proof, request.operation.historyIndex);
1911
+ }
1912
+ return owner.resolveHistoryAnchor(request.proof, request.operation.token);
1913
+ }
1914
+
1883
1915
  async captureNativeBacking(
1884
1916
  semanticPaneId: string,
1885
1917
  expected?: NativeBackingIdentity,
@@ -2323,6 +2355,11 @@ class SessionRuntimeConsumerImpl implements SessionRuntimeConsumer {
2323
2355
  this.#runtime.fitViewport(this.clientId, lease, cols, rows, semanticWindowId);
2324
2356
  }
2325
2357
 
2358
+ setCellPixels(width: number, height: number, precise?: { width: number; height: number }): void {
2359
+ this.#assertOpen();
2360
+ this.#runtime.setCellPixels(this.clientId, width, height, precise);
2361
+ }
2362
+
2326
2363
  fitViewportWithAuthority(
2327
2364
  lease: SessionRuntimeAuthorityLease,
2328
2365
  cols: number,
@@ -2424,6 +2461,7 @@ class SessionRuntimeConsumerImpl implements SessionRuntimeConsumer {
2424
2461
  this.#deliveryConnections.delete(connection);
2425
2462
  await upstream.close();
2426
2463
  },
2464
+ ...(upstream.graphics ? { graphics: upstream.graphics } : {}),
2427
2465
  };
2428
2466
  this.#deliveryConnections.add(connection);
2429
2467
  return connection;
@@ -1,3 +1,4 @@
1
+ import type { KittyImage } from "../graphics/kitty-graphics.ts";
1
2
  import { StockCaptureTabUnavailableError } from "./stock-capture-fidelity.ts";
2
3
  import {
3
4
  MAX_RETAINED_NATIVE_BACKING_BYTES,
@@ -8,6 +9,8 @@ import {
8
9
  } from "./native-seed-backing.ts";
9
10
  import {
10
11
  WorkspaceIdSchemaZ,
12
+ TERMINAL_GRAPHICS_CAPABILITY_VERSION,
13
+ type TerminalGraphicsState,
11
14
  TERMINAL_DELIVERY_PATCH_TO_SEED_BYTES,
12
15
  TERMINAL_DELIVERY_MAX_REPRESENTATION_BYTES,
13
16
  TerminalDeliveryEnvelopeSchemaZ,
@@ -124,6 +127,17 @@ export interface TerminalDeliveryConnection {
124
127
  nack(nack: TerminalDeliveryNack): void;
125
128
  setVisibility(visibility: TerminalDeliveryVisibility): void;
126
129
  close(): Promise<void>;
130
+ /**
131
+ * Kitty graphics side channel, present only when the client offered
132
+ * TERMINAL_GRAPHICS_CAPABILITY_VERSION. Delivery is independent of the
133
+ * canonical FIFO: renderers resolve state against cells they already hold.
134
+ */
135
+ readonly graphics?: TerminalDeliveryGraphics;
136
+ }
137
+
138
+ export interface TerminalDeliveryGraphics {
139
+ subscribe(listener: (state: TerminalGraphicsState) => void): () => void;
140
+ image(digest: string): KittyImage | null;
127
141
  }
128
142
 
129
143
  interface NativeBackingLease {
@@ -173,6 +187,8 @@ export interface TerminalDeliverySourceOwner {
173
187
  ) => void,
174
188
  onRaw: (record: TerminalReplicaCommittedRaw) => void,
175
189
  ): Promise<TerminalReplicaSourceSubscription>;
190
+ subscribeGraphics?(listener: (state: TerminalGraphicsState) => void): () => void;
191
+ graphicsImage?(digest: string): KittyImage | null;
176
192
  }
177
193
 
178
194
  interface ClientState {
@@ -451,6 +467,9 @@ export class SessionRuntimeTerminalDeliveryHub {
451
467
  this.#recordDeliveryStatus(client, this.#panes.get(client.paneId));
452
468
  },
453
469
  close: async () => this.#closeClient(client),
470
+ ...(offer.protocolVersions.includes(TERMINAL_GRAPHICS_CAPABILITY_VERSION)
471
+ ? { graphics: this.#graphicsFor(semanticPaneId, client, closed) }
472
+ : {}),
454
473
  };
455
474
  } catch (error) {
456
475
  if (!(error instanceof StockCaptureTabUnavailableError)) throw error;
@@ -474,6 +493,25 @@ export class SessionRuntimeTerminalDeliveryHub {
474
493
  }
475
494
  }
476
495
 
496
+ #graphicsFor(
497
+ semanticPaneId: string,
498
+ client: ClientState,
499
+ closed: Promise<unknown>,
500
+ ): TerminalDeliveryGraphics {
501
+ const owner = this.#ownerForPane(semanticPaneId);
502
+ return {
503
+ subscribe: (listener) => {
504
+ if (client.closed || !owner.subscribeGraphics) return () => {};
505
+ const unsubscribe = owner.subscribeGraphics((state) => {
506
+ if (!client.closed) listener(state);
507
+ });
508
+ void closed.then(unsubscribe);
509
+ return unsubscribe;
510
+ },
511
+ image: (digest) => (client.closed ? null : (owner.graphicsImage?.(digest) ?? null)),
512
+ };
513
+ }
514
+
477
515
  /** Retire only this pane's delivery owners before its canonical owner is disposed. */
478
516
  failPaneCapture(semanticPaneId: string, error: StockCaptureTabUnavailableError): void {
479
517
  for (const client of this.#clients.values())
@@ -25,6 +25,10 @@ export interface TerminalInterpreterBackend {
25
25
  /** Public-parser diagnostic OSC seam; installed only for an active probe. */
26
26
  registerOscHandler(identifier: number, handler: (data: string) => boolean): () => void;
27
27
  write(data: Uint8Array | string): Promise<void>;
28
+ /** SPIKE (kitty graphics): cursor as an absolute buffer line, read mid-parse. */
29
+ graphicsCursor?(): { readonly x: number; readonly line: number; readonly alternate: boolean };
30
+ /** SPIKE (kitty graphics): absolute buffer line of viewport row 0. */
31
+ graphicsViewportTop?(): number;
28
32
  /** Exact native painted state; false means this parser cannot import it. */
29
33
  canImportNativeGrid?(): boolean;
30
34
  importNativeGrid?(snapshot: NativeGridCapture): boolean;
@@ -44,6 +44,14 @@ import {
44
44
  cellsEqual,
45
45
  type CausalCellLedgerResult,
46
46
  } from "./causal-cell-ledger.ts";
47
+ import {
48
+ KITTY_PLACEMENT_OSC,
49
+ KittyApcScanner,
50
+ KittyImageStore,
51
+ type KittyCommand,
52
+ type KittyImageBudget,
53
+ } from "../graphics/kitty-graphics.ts";
54
+ import type { TerminalGraphicsState } from "@tmux-ide/contracts";
47
55
 
48
56
  type CloseReason = "pane-closed" | "session-restarted" | "runtime-disposed";
49
57
  export type TerminalReplicaInterpreterOperation =
@@ -115,6 +123,8 @@ export interface TerminalReplicaInterpreterOptions {
115
123
  readonly observability?: SessionRuntimeObservability;
116
124
  /** Parser implementation only; SessionRuntime remains the sole state authority. */
117
125
  readonly backendFactory?: TerminalInterpreterBackendFactory;
126
+ /** Shared kitty image byte quotas; defaults to the daemon-wide budget. */
127
+ readonly kittyImageBudget?: KittyImageBudget;
118
128
  }
119
129
 
120
130
  export interface TerminalReplicaInterpreterStats {
@@ -165,6 +175,13 @@ export class TerminalReplicaInterpreter {
165
175
  }> = [];
166
176
  #writeFlushScheduled = false;
167
177
  #widgetGate = false;
178
+ // kitty graphics: images outlive reseeds; the scanner owns split APCs.
179
+ readonly #kittyScanner = new KittyApcScanner();
180
+ readonly #kitty: KittyImageStore;
181
+ readonly #kittyPending = new Map<number, KittyCommand>();
182
+ readonly #graphicsListeners = new Set<(state: TerminalGraphicsState) => void>();
183
+ #graphics: TerminalGraphicsState | null = null;
184
+ #graphicsKey = "";
168
185
  #markerTail = "";
169
186
  #pendingResize: { cols: number; rows: number } | null = null;
170
187
  // null: no unpublished writes; undefined: a write requires full projection.
@@ -208,6 +225,10 @@ export class TerminalReplicaInterpreter {
208
225
  rows: options.rows,
209
226
  scrollback: this.#scrollback,
210
227
  });
228
+ this.#kitty = new KittyImageStore(
229
+ options.kittyImageBudget ? { budget: options.kittyImageBudget } : {},
230
+ );
231
+ this.#attachKitty(this.#backend);
211
232
  this.#snapshot = blankTerminalReplicaSnapshot(options.cols, options.rows);
212
233
  this.#seedReady = new Promise((resolve, reject) => {
213
234
  this.#resolveSeedReady = resolve;
@@ -400,6 +421,18 @@ export class TerminalReplicaInterpreter {
400
421
  }
401
422
  const previous = this.#backend;
402
423
  this.#backend = replacement;
424
+ this.#attachKitty(replacement);
425
+ // A capture replay does not erase graphics already observed by this owner.
426
+ // Keep anchors only when both backends agree on their coordinate basis;
427
+ // reflow, history replacement and authoritative stream resets are ambiguous.
428
+ const sameGraphicsBasis =
429
+ operation.bootstrap === "painted-capture" &&
430
+ previous.cols === replacement.cols &&
431
+ previous.rows === replacement.rows &&
432
+ previous.graphicsViewportTop?.() !== undefined &&
433
+ previous.graphicsViewportTop?.() === replacement.graphicsViewportTop?.() &&
434
+ previous.modes().alternateScreen === replacement.modes().alternateScreen;
435
+ if (!sameGraphicsBasis) this.#kitty.dropCursorPlacements();
403
436
  this.#nativeReseedRequested = false;
404
437
  this.#scrollback = scrollback;
405
438
  this.#bootstrap = {
@@ -421,6 +454,7 @@ export class TerminalReplicaInterpreter {
421
454
  this.#nativeSeedBackingCandidate = undefined;
422
455
  }
423
456
  previous.dispose();
457
+ this.#publishGraphics();
424
458
  return;
425
459
  }
426
460
  if (operation.type === "cursor") {
@@ -434,6 +468,8 @@ export class TerminalReplicaInterpreter {
434
468
  return;
435
469
  }
436
470
  if (operation.type === "resize") {
471
+ if (operation.cols !== this.#backend.cols || operation.rows !== this.#backend.rows)
472
+ this.#kitty.dropCursorPlacements();
437
473
  this.#causalCell?.fail("geometry-drift");
438
474
  if (this.#backend.modes().synchronizedOutput) {
439
475
  // Geometry is part of the admitted FIFO even while publication is
@@ -445,6 +481,7 @@ export class TerminalReplicaInterpreter {
445
481
  }
446
482
  this.#backend.resize(operation.cols, operation.rows);
447
483
  this.#commit(false);
484
+ this.#publishGraphics();
448
485
  return;
449
486
  }
450
487
  const baseRevision = this.#revision;
@@ -462,6 +499,7 @@ export class TerminalReplicaInterpreter {
462
499
  };
463
500
  this.#revision = revision;
464
501
  this.#closed = true;
502
+ this.#kitty.dispose();
465
503
  this.#causalCell?.fail("transport-closed");
466
504
  this.#clearSyncRecovery();
467
505
  if (this.#needsSeed)
@@ -479,8 +517,18 @@ export class TerminalReplicaInterpreter {
479
517
  this.#admitRaw(data);
480
518
  this.#stats.parseBatches += 1;
481
519
  this.#observeMarkerBytes(data);
520
+ const scanned = this.#kittyScanner.scan(data);
521
+ for (const command of scanned.commands) this.#kittyPending.set(command.marker, command);
482
522
  const parseStarted = this.#observability.enabled ? this.#observability.nowMicros() : 0;
483
- await this.#writeToBackend(this.#backend, data);
523
+ // Always forward the scanner's output: tmux splits %output (~2 KiB) inside
524
+ // kitty APCs (~4 KiB chunks), so a chunk with no completed command can still
525
+ // be all image payload that must never reach the VT parser as text.
526
+ // A chunk entirely inside image payload changes nothing the parser can
527
+ // see. Skipping it matters: every parser write settles on a later timer
528
+ // turn, so a frame split into dozens of %output chunks would otherwise
529
+ // serialize dozens of turns and fall behind a 30 fps image stream.
530
+ if (scanned.forward.byteLength === 0) return;
531
+ await this.#writeToBackend(this.#backend, scanned.forward);
484
532
  if (this.#observability.enabled)
485
533
  this.#observability.recordSpan(
486
534
  "parse",
@@ -519,6 +567,7 @@ export class TerminalReplicaInterpreter {
519
567
  } else {
520
568
  this.#commit(false, this.#backend.dirtyRange(), trace);
521
569
  }
570
+ this.#publishGraphics();
522
571
  }
523
572
 
524
573
  #scheduleSyncRecovery(): void {
@@ -719,6 +768,55 @@ export class TerminalReplicaInterpreter {
719
768
  });
720
769
  }
721
770
 
771
+ /** The pane's kitty image store (bytes for the graphics side channel). */
772
+ kittyImages(): KittyImageStore {
773
+ return this.#kitty;
774
+ }
775
+
776
+ /** Latest graphics state, or null while the pane has never held an image. */
777
+ currentGraphics(): TerminalGraphicsState | null {
778
+ return this.#graphics;
779
+ }
780
+
781
+ onGraphics(listener: (state: TerminalGraphicsState) => void): () => void {
782
+ this.#graphicsListeners.add(listener);
783
+ return () => this.#graphicsListeners.delete(listener);
784
+ }
785
+
786
+ /** Publish graphics state when the store or the viewport-relative placements moved. */
787
+ #publishGraphics(): void {
788
+ if (this.#graphics === null && this.#kitty.revision === 0) return;
789
+ const state = this.#kitty.state(
790
+ this.#backend.graphicsViewportTop?.() ?? 0,
791
+ this.#backend.rows,
792
+ this.#backend.cols,
793
+ this.#backend.modes().alternateScreen,
794
+ );
795
+ const key = JSON.stringify([state.revision, state.placements]);
796
+ if (key === this.#graphicsKey) return;
797
+ this.#graphicsKey = key;
798
+ this.#graphics = state;
799
+ for (const listener of this.#graphicsListeners) {
800
+ try {
801
+ listener(state);
802
+ } catch {
803
+ // Renderer callbacks never poison the authoritative parser FIFO.
804
+ }
805
+ }
806
+ }
807
+
808
+ #attachKitty(backend: TerminalInterpreterBackend): void {
809
+ // Minimal test backends omit the OSC seam; they simply never place images.
810
+ if (typeof backend.registerOscHandler !== "function") return;
811
+ backend.registerOscHandler(KITTY_PLACEMENT_OSC, (data) => {
812
+ const command = this.#kittyPending.get(Number(data));
813
+ if (!command) return true;
814
+ this.#kittyPending.delete(Number(data));
815
+ this.#kitty.apply(command, backend.graphicsCursor?.() ?? null);
816
+ return true;
817
+ });
818
+ }
819
+
722
820
  #currentSnapshotHash(): string {
723
821
  return (this.#snapshotHash ??= hashTerminalReplicaSnapshot(this.#snapshot));
724
822
  }
@@ -1,3 +1,8 @@
1
+ import {
2
+ HistoryAnchorOwner,
3
+ type HistoryAnchorIdentity,
4
+ type HistoryAnchorProof,
5
+ } from "./history-anchor-owner.ts";
1
6
  import { layoutContentRows } from "../mirror/layout-content-rows.ts";
2
7
  import type { NativeGridCapture } from "../mirror/native-grid-capture.ts";
3
8
  import type {
@@ -27,6 +32,8 @@ import type {
27
32
  } from "./runtime-observability.ts";
28
33
  import { DISABLED_SESSION_RUNTIME_OBSERVABILITY } from "./runtime-observability.ts";
29
34
  import type { CausalCellLedgerResult } from "./causal-cell-ledger.ts";
35
+ import type { KittyImage } from "../graphics/kitty-graphics.ts";
36
+ import type { TerminalGraphicsState } from "@tmux-ide/contracts";
30
37
 
31
38
  export interface TerminalReplicaSubscription {
32
39
  readonly generation: SessionRuntimeGeneration;
@@ -96,6 +103,7 @@ export class SessionRuntimeTerminalReplicaOwner {
96
103
  readonly #start: Promise<void>;
97
104
  #upstream: MirrorSubscription | null = null;
98
105
  #disposed = false;
106
+ readonly #historyAnchors = new HistoryAnchorOwner();
99
107
  #cols = 80;
100
108
  #rows = 24;
101
109
  #layoutEpoch = 0;
@@ -144,6 +152,7 @@ export class SessionRuntimeTerminalReplicaOwner {
144
152
  onNativeReseedRequired: () => {
145
153
  if (this.#disposed || this.#reseed || this.#waitingForGeometryCapture) return;
146
154
  this.#waitingForGeometryCapture = true;
155
+ this.#historyAnchors.invalidate();
147
156
  this.#supervise(
148
157
  this.#start.then(() => {
149
158
  if (!this.#disposed && this.#waitingForGeometryCapture) this.#upstream?.reseed();
@@ -151,6 +160,7 @@ export class SessionRuntimeTerminalReplicaOwner {
151
160
  );
152
161
  },
153
162
  onUpdate: (update, trace) => {
163
+ this.#historyAnchors.observe(update);
154
164
  this.#publishedIncarnation = update.incarnation;
155
165
  if (update.type === "terminal.seed") {
156
166
  this.#bootstrapped = true;
@@ -232,6 +242,18 @@ export class SessionRuntimeTerminalReplicaOwner {
232
242
  this.#interpreter.failCausalCell(reason, traceId);
233
243
  }
234
244
 
245
+ captureHistoryAnchor(proof: HistoryAnchorProof, historyIndex: number) {
246
+ if (this.#disposed || this.#reseed || this.#waitingForGeometryCapture)
247
+ return { status: "unavailable" as const };
248
+ return this.#historyAnchors.capture(proof, historyIndex);
249
+ }
250
+
251
+ resolveHistoryAnchor(proof: HistoryAnchorIdentity, token: string) {
252
+ if (this.#disposed || this.#reseed || this.#waitingForGeometryCapture)
253
+ return { status: "unavailable" as const };
254
+ return this.#historyAnchors.resolve(proof, token);
255
+ }
256
+
235
257
  /** Scalar diagnostic only: no seed construction, hashing, or owner creation. */
236
258
  inputDispatchIncarnation(): string | null {
237
259
  return this.#disposed ? null : this.#publishedIncarnation;
@@ -350,6 +372,18 @@ export class SessionRuntimeTerminalReplicaOwner {
350
372
  };
351
373
  }
352
374
 
375
+ /** Kitty graphics side channel: current state now (if any), then every change. */
376
+ subscribeGraphics(listener: (state: TerminalGraphicsState) => void): () => void {
377
+ const current = this.#interpreter.currentGraphics();
378
+ if (current) listener(current);
379
+ return this.#interpreter.onGraphics(listener);
380
+ }
381
+
382
+ /** Image bytes by content digest, while the pane's store still holds them. */
383
+ graphicsImage(digest: string): KittyImage | null {
384
+ return this.#interpreter.kittyImages().imageByDigest(digest) ?? null;
385
+ }
386
+
353
387
  async subscribeSource(
354
388
  listener: (
355
389
  update: CanonicalTerminalReplicaUpdate,
@@ -377,6 +411,7 @@ export class SessionRuntimeTerminalReplicaOwner {
377
411
  if (this.#disposed) return;
378
412
  this.#noteLifecycle(`dispose-${reason}`);
379
413
  this.#disposed = true;
414
+ this.#historyAnchors.invalidate();
380
415
  this.#historyTimer?.cancel();
381
416
  this.#historyTimer = null;
382
417
  this.#subscriptionEpoch += 1;
@@ -437,6 +472,7 @@ export class SessionRuntimeTerminalReplicaOwner {
437
472
  // Deterministic capture point: reset opens the atomic capture. A probe
438
473
  // armed after reset belongs to the next post-capture delta, never to this
439
474
  // already-started reseed.
475
+ this.#historyAnchors.invalidate();
440
476
  this.#reseed = {
441
477
  nativeCols: event.cols,
442
478
  nativeRows: event.rows,
@@ -561,6 +597,7 @@ export class SessionRuntimeTerminalReplicaOwner {
561
597
  this.#reseedRetryCount = 0;
562
598
  if (!this.#bootstrapped || this.#reseed || this.#waitingForGeometryCapture) return;
563
599
  this.#waitingForGeometryCapture = true;
600
+ this.#historyAnchors.invalidate();
564
601
  void this.#start.then(() => {
565
602
  if (!this.#disposed && this.#waitingForGeometryCapture) this.#upstream?.reseed();
566
603
  });
@@ -690,6 +727,7 @@ export class SessionRuntimeTerminalReplicaOwner {
690
727
  this.#noteLifecycle("reseed-retry", diagnostic);
691
728
  this.#reseedRetryCount += 1;
692
729
  this.#waitingForGeometryCapture = true;
730
+ this.#historyAnchors.invalidate();
693
731
  void this.#start.then(() => {
694
732
  if (!this.#disposed) this.#upstream?.reseed();
695
733
  });
@@ -697,6 +735,7 @@ export class SessionRuntimeTerminalReplicaOwner {
697
735
 
698
736
  #supervise(operation: Promise<void>): void {
699
737
  void operation.catch((error) => {
738
+ this.#historyAnchors.invalidate();
700
739
  this.#noteLifecycle("interpreter-fault");
701
740
  this.#interpreter.abort(error);
702
741
  this.#onFault?.(error);