tmux-ide 2.9.11 → 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 (56) hide show
  1. package/bin/cli.js +4158 -2545
  2. package/package.json +1 -1
  3. package/packages/contracts/src/index.ts +4 -0
  4. package/packages/contracts/src/pane-stream.ts +60 -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.ts +1 -1
  16. package/packages/daemon/dist/lib/daemon-embed.js +39 -3
  17. package/packages/daemon/dist/lib/embedded-tmux-server-owners.js +1 -0
  18. package/packages/daemon/dist/lib/tmux-server-owner.js +1 -0
  19. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  20. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  21. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  22. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  23. package/packages/daemon/dist/terminal/graphics/local-image-owner.js +330 -0
  24. package/packages/daemon/dist/terminal/graphics/local-image-store.js +255 -0
  25. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +176 -16
  26. package/packages/daemon/dist/terminal/pane-stream/runtime.js +1 -0
  27. package/packages/daemon/dist/terminal/session-runtime/registry.js +2 -0
  28. package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +146 -16
  29. package/packages/daemon/dist/terminal/session-runtime/terminal-projection-delivery.js +32 -0
  30. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +3 -4
  31. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +4 -0
  32. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +28 -3
  33. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +13 -1
  34. package/packages/daemon/dist/tui/mirror/runtime/renderer-probe.js +195 -0
  35. package/packages/daemon/dist/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.js +6 -0
  36. package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +4 -0
  37. package/packages/daemon/src/lib/daemon-embed.ts +38 -2
  38. package/packages/daemon/src/lib/embedded-tmux-server-owners.ts +3 -0
  39. package/packages/daemon/src/lib/tmux-server-owner.ts +3 -0
  40. package/packages/daemon/src/terminal/graphics/local-image-owner.ts +346 -0
  41. package/packages/daemon/src/terminal/graphics/local-image-store.ts +286 -0
  42. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +219 -15
  43. package/packages/daemon/src/terminal/pane-stream/runtime.ts +3 -0
  44. package/packages/daemon/src/terminal/session-runtime/registry.ts +2 -0
  45. package/packages/daemon/src/terminal/session-runtime/runtime-observability.ts +1 -1
  46. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +216 -22
  47. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-observation-identity.ts +7 -4
  48. package/packages/daemon/src/terminal/session-runtime/terminal-projection-delivery.ts +59 -0
  49. package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +3 -4
  50. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +4 -0
  51. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +26 -4
  52. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +20 -1
  53. package/packages/daemon/src/tui/mirror/runtime/renderer-probe.ts +257 -0
  54. package/packages/daemon/src/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.ts +15 -0
  55. package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +4 -0
  56. package/packages/daemon-client/src/pane-stream-client.ts +75 -1
@@ -1,7 +1,10 @@
1
+ import { prepareProjection } from "./terminal-projection-delivery.js";
2
+ import { TerminalProjectionEnvelopeSchemaZ, TerminalProjectionReceiptSchemaZ, TerminalProjectionMaterializeSchemaZ, } from "@tmux-ide/contracts";
3
+ import { encodeTerminalProjectionCooperatively } from "@tmux-ide/core";
1
4
  import { StockCaptureTabUnavailableError } from "./stock-capture-fidelity.js";
2
5
  import { MAX_RETAINED_NATIVE_BACKING_BYTES, takeNativeSeedBacking, } from "./native-seed-backing.js";
3
6
  import { WorkspaceIdSchemaZ, TERMINAL_GRAPHICS_CAPABILITY_VERSION, TERMINAL_DELIVERY_PATCH_TO_SEED_BYTES, TERMINAL_DELIVERY_MAX_REPRESENTATION_BYTES, TerminalDeliveryEnvelopeSchemaZ, TerminalDeliveryAckSchemaZ, TerminalDeliveryNackSchemaZ, TerminalDeliveryOfferSchemaZ, TerminalDeliveryVisibilitySchemaZ, } from "@tmux-ide/contracts";
4
- import { TerminalDeliveryStateTooLargeError, terminalReplicaRowsEqual, TERMINAL_COMPACT_COOPERATIVE_SEED_CELLS, applyTerminalReplicaUpdate, applyTerminalReplicaUpdateCooperatively, terminalReplicaUpdateNeedsCooperativeReduction, encodeCompactSemanticTerminalUpdate, encodeCompactSemanticTerminalUpdateCooperatively, terminalSemanticUpdateNeedsCooperativeEncoding, encodeAnsiTerminalRepresentation, encodeSemanticTerminalUpdate, hashTerminalDeliveryRepresentation, negotiateTerminalDelivery, preaccountSemanticTerminalUpdateBytes, } from "@tmux-ide/core";
7
+ import { TerminalDeliveryStateTooLargeError, terminalReplicaRowsEqual, TERMINAL_COMPACT_COOPERATIVE_SEED_CELLS, applyTerminalReplicaUpdate, applyTerminalReplicaUpdateCooperatively, terminalReplicaUpdateNeedsCooperativeReduction, encodeBinarySemanticTerminalUpdate, encodeBinarySemanticTerminalUpdateCooperatively, encodeCompactSemanticTerminalUpdate, encodeCompactSemanticTerminalUpdateCooperatively, terminalSemanticUpdateNeedsCooperativeEncoding, encodeAnsiTerminalRepresentation, encodeSemanticTerminalUpdate, hashTerminalDeliveryRepresentation, negotiateTerminalDelivery, preaccountSemanticTerminalUpdateBytes, } from "@tmux-ide/core";
5
8
  import { SYSTEM_SESSION_RUNTIME_SCHEDULER, } from "./runtime-scheduler.js";
6
9
  import { DISABLED_SESSION_RUNTIME_OBSERVABILITY, } from "./runtime-observability.js";
7
10
  import { registerTerminalDeliveryObservationOrdinal } from "./terminal-delivery-observation-identity.js";
@@ -15,6 +18,13 @@ const MAX_PANES = 32;
15
18
  const MAX_PENDING_CANONICAL_CELLS = 1_000_000;
16
19
  const MAX_CONNECTIONS = MAX_CLIENTS * MAX_PANES;
17
20
  const BACKGROUND_CADENCE_MS = 100;
21
+ class DeferredProjectionEncoding extends Error {
22
+ payload;
23
+ constructor(payload) {
24
+ super("Deferred projection encoding");
25
+ this.payload = payload;
26
+ }
27
+ }
18
28
  class DeferredSeedEncoding extends Error {
19
29
  payload;
20
30
  constructor(payload) {
@@ -135,6 +145,10 @@ export class SessionRuntimeTerminalDeliveryHub {
135
145
  visibility: "visible",
136
146
  baselineRevision: -1,
137
147
  baselineHash: null,
148
+ projectionBaseline: null,
149
+ materializeRequested: false,
150
+ lastProjectionReceipt: null,
151
+ lastMaterialize: null,
138
152
  reseedRequired: false,
139
153
  inFlight: null,
140
154
  latestRevision: pane.latest?.update.revision ?? null,
@@ -159,6 +173,8 @@ export class SessionRuntimeTerminalDeliveryHub {
159
173
  negotiation,
160
174
  closed,
161
175
  ack: (ack) => this.#ack(client, ack),
176
+ projectionReceipt: (receipt) => this.#projectionReceipt(client, receipt),
177
+ materialize: (request) => this.#materialize(client, request),
162
178
  nack: (nack) => this.#nack(client, nack),
163
179
  setVisibility: (visibilityInput) => {
164
180
  if (client.closed)
@@ -587,7 +603,10 @@ export class SessionRuntimeTerminalDeliveryHub {
587
603
  if (!client.lifecycleOpenRecorded)
588
604
  client.lifecycleOpenRecorded = this.#recordDeliveryLifecycle(client, pane, "open");
589
605
  if (update.type === "terminal.tombstone" &&
590
- (client.inFlight !== null || client.encoding !== null || client.visibility !== "visible")) {
606
+ (client.inFlight !== null ||
607
+ client.encoding !== null ||
608
+ client.visibility !== "visible" ||
609
+ client.projectionBaseline !== null)) {
591
610
  this.#fault(client, "source-closed", "Terminal source closed before final state delivery");
592
611
  continue;
593
612
  }
@@ -705,8 +724,13 @@ export class SessionRuntimeTerminalDeliveryHub {
705
724
  encodedRepresentation = representation;
706
725
  const transactionId = this.#scheduler.createId();
707
726
  const chunkCount = Math.max(1, Math.ceil(representation.bytes.byteLength / (256 * 1024)));
708
- const envelope = TerminalDeliveryEnvelopeSchemaZ.parse({
709
- type: "terminal.delivery",
727
+ const envelope = (representation.projection
728
+ ? TerminalProjectionEnvelopeSchemaZ
729
+ : TerminalDeliveryEnvelopeSchemaZ).parse({
730
+ type: representation.projection ? "terminal.projection" : "terminal.delivery",
731
+ ...(representation.projection
732
+ ? { projectionStateHash: representation.projection.hash }
733
+ : {}),
710
734
  workspaceName: client.deliveryWorkspaceName,
711
735
  semanticPaneId: target.update.semanticPaneId,
712
736
  generation: target.update.generation,
@@ -717,7 +741,11 @@ export class SessionRuntimeTerminalDeliveryHub {
717
741
  protocolVersion: 1,
718
742
  encoding: representation.encoding ?? client.negotiated.encoding,
719
743
  frame: representation.frame,
720
- baseRevision: representation.frame === "seed" ? null : Math.max(0, client.baselineRevision),
744
+ baseRevision: representation.projection
745
+ ? representation.projectionBaseRevision
746
+ : representation.frame === "seed"
747
+ ? null
748
+ : Math.max(0, client.baselineRevision),
721
749
  canonicalRevision: target.update.revision,
722
750
  canonicalStateHash: target.update.stateHash,
723
751
  representationHash: representation.representationHash ??
@@ -733,6 +761,7 @@ export class SessionRuntimeTerminalDeliveryHub {
733
761
  registerTerminalDeliveryObservationOrdinal(envelope, deliveryOrdinal);
734
762
  client.inFlight = {
735
763
  envelope,
764
+ projection: representation.projection ?? null,
736
765
  deliveryOrdinal,
737
766
  bytes: representation.bytes,
738
767
  nextChunk: 0,
@@ -744,9 +773,9 @@ export class SessionRuntimeTerminalDeliveryHub {
744
773
  pane.pendingDeliveryTrace = null;
745
774
  }
746
775
  catch (error) {
747
- if (error instanceof DeferredSeedEncoding) {
776
+ if (error instanceof DeferredSeedEncoding || error instanceof DeferredProjectionEncoding) {
748
777
  deferred = true;
749
- this.#startEncoding(client, pane, target, error.payload, traceStarted);
778
+ this.#startEncoding(client, pane, target, error.payload, traceStarted, error instanceof DeferredProjectionEncoding);
750
779
  return;
751
780
  }
752
781
  failedSelectionObservation = semanticSelectionObservationFromError(error);
@@ -804,7 +833,7 @@ export class SessionRuntimeTerminalDeliveryHub {
804
833
  }
805
834
  }
806
835
  }
807
- #startEncoding(client, pane, target, payload, traceStarted) {
836
+ #startEncoding(client, pane, target, payload, traceStarted, projection = false) {
808
837
  const key = cacheKey([
809
838
  target.update.workspaceName,
810
839
  client.paneId,
@@ -814,6 +843,9 @@ export class SessionRuntimeTerminalDeliveryHub {
814
843
  target.update.stateHash,
815
844
  client.negotiated.encoding,
816
845
  client.negotiated.richPlacements ? "rich" : "plain",
846
+ projection
847
+ ? `projection:${client.projectionBaseline?.revision ?? -1}:${client.projectionBaseline?.hash ?? "none"}`
848
+ : "complete",
817
849
  ]);
818
850
  let job = this.#pendingEncodings.get(key);
819
851
  let created = false;
@@ -844,10 +876,13 @@ export class SessionRuntimeTerminalDeliveryHub {
844
876
  owned.clients.add(client);
845
877
  if (!created)
846
878
  return;
847
- void encodeCompactSemanticTerminalUpdateCooperatively(payload, {
848
- signal: owned.abort.signal,
849
- yieldControl: () => this.#yieldEncoding(owned),
850
- }).then((bytes) => this.#completeEncoding(owned, { bytes }), (error) => this.#completeEncoding(owned, { error }));
879
+ const options = { signal: owned.abort.signal, yieldControl: () => this.#yieldEncoding(owned) };
880
+ const result = projection
881
+ ? encodeTerminalProjectionCooperatively(payload, options)
882
+ : client.negotiated.encoding === "semantic-binary-v1"
883
+ ? encodeBinarySemanticTerminalUpdateCooperatively(payload, options)
884
+ : encodeCompactSemanticTerminalUpdateCooperatively(payload, options);
885
+ void result.then((bytes) => this.#completeEncoding(owned, { bytes }), (error) => this.#completeEncoding(owned, { error }));
851
886
  }
852
887
  #yieldEncoding(job) {
853
888
  if (this.#scheduler.nowMs() - job.yieldStartedAt < 2)
@@ -914,6 +949,27 @@ export class SessionRuntimeTerminalDeliveryHub {
914
949
  this.#pruneCanonicalRevisions(client.paneId);
915
950
  }
916
951
  #representation(client, pane, target, seedOutcome) {
952
+ const snapshotForProjection = target.state.snapshot;
953
+ if (client.negotiated.alternateScreenProjection &&
954
+ !client.materializeRequested &&
955
+ (client.baselineRevision < 0 || client.projectionBaseline !== null) &&
956
+ snapshotForProjection?.modes.alternateScreen &&
957
+ snapshotForProjection.history.length > 0) {
958
+ const { payload, retained } = prepareProjection(snapshotForProjection, target.update.revision, target.update.incarnation, client.reseedRequired ? null : client.projectionBaseline);
959
+ if (!seedOutcome)
960
+ throw new DeferredProjectionEncoding(payload);
961
+ if ("error" in seedOutcome)
962
+ throw seedOutcome.error;
963
+ return {
964
+ bytes: seedOutcome.bytes,
965
+ encoding: "alternate-projection-binary-v1",
966
+ frame: payload.frame,
967
+ history: "deferred",
968
+ canonicalEquivalent: false,
969
+ projection: retained,
970
+ projectionBaseRevision: payload.frame === "seed" ? null : payload.baseRevision,
971
+ };
972
+ }
917
973
  const key = cacheKey([
918
974
  target.update.workspaceName,
919
975
  target.update.semanticPaneId,
@@ -937,18 +993,24 @@ export class SessionRuntimeTerminalDeliveryHub {
937
993
  const snapshot = target.state.snapshot;
938
994
  let result = null;
939
995
  if (client.negotiated.encoding === "semantic-v1" ||
940
- client.negotiated.encoding === "semantic-compact-v1") {
996
+ client.negotiated.encoding === "semantic-compact-v1" ||
997
+ client.negotiated.encoding === "semantic-binary-v1") {
941
998
  const encodeSemantic = (payload) => {
942
- if (client.negotiated.encoding !== "semantic-compact-v1")
999
+ if (client.negotiated.encoding !== "semantic-compact-v1" &&
1000
+ client.negotiated.encoding !== "semantic-binary-v1")
943
1001
  return encodeSemanticTerminalUpdate(payload);
944
- if (terminalSemanticUpdateNeedsCooperativeEncoding(payload)) {
1002
+ if (payload.frame === "seed" &&
1003
+ (client.negotiated.encoding === "semantic-binary-v1" ||
1004
+ terminalSemanticUpdateNeedsCooperativeEncoding(payload))) {
945
1005
  if (!seedOutcome)
946
1006
  throw new DeferredSeedEncoding(payload);
947
1007
  if ("error" in seedOutcome)
948
1008
  throw seedOutcome.error;
949
1009
  return seedOutcome.bytes;
950
1010
  }
951
- return encodeCompactSemanticTerminalUpdate(payload);
1011
+ return client.negotiated.encoding === "semantic-binary-v1"
1012
+ ? encodeBinarySemanticTerminalUpdate(payload)
1013
+ : encodeCompactSemanticTerminalUpdate(payload);
952
1014
  };
953
1015
  if (client.reseedRequired) {
954
1016
  const payload = semanticSeed(target);
@@ -1213,6 +1275,10 @@ export class SessionRuntimeTerminalDeliveryHub {
1213
1275
  return;
1214
1276
  }
1215
1277
  const envelope = flight.envelope;
1278
+ if (envelope.type !== "terminal.delivery") {
1279
+ this.#fault(client, "protocol-violation", "Canonical ACK cannot acknowledge a projection");
1280
+ return;
1281
+ }
1216
1282
  if (flight.nextChunk !== envelope.chunkCount ||
1217
1283
  ack.workspaceName !== envelope.workspaceName ||
1218
1284
  ack.semanticPaneId !== envelope.semanticPaneId ||
@@ -1227,6 +1293,8 @@ export class SessionRuntimeTerminalDeliveryHub {
1227
1293
  return;
1228
1294
  }
1229
1295
  this.#maxSlowClientMs = Math.max(this.#maxSlowClientMs, this.#scheduler.nowMs() - flight.sentAt);
1296
+ client.projectionBaseline = null;
1297
+ client.lastProjectionReceipt = null;
1230
1298
  client.baselineRevision = ack.canonicalRevision;
1231
1299
  client.baselineHash = ack.canonicalStateHash;
1232
1300
  client.reseedRequired = false;
@@ -1277,6 +1345,68 @@ export class SessionRuntimeTerminalDeliveryHub {
1277
1345
  this.#schedule(client);
1278
1346
  this.#recordDeliveryStatus(client, this.#panes.get(client.paneId));
1279
1347
  }
1348
+ #projectionReceipt(client, input) {
1349
+ const receipt = TerminalProjectionReceiptSchemaZ.parse(input);
1350
+ if (client.lastProjectionReceipt &&
1351
+ JSON.stringify(client.lastProjectionReceipt) === JSON.stringify(receipt))
1352
+ return;
1353
+ const flight = client.inFlight;
1354
+ const envelope = flight?.envelope;
1355
+ if (!flight ||
1356
+ !flight.projection ||
1357
+ envelope?.type !== "terminal.projection" ||
1358
+ flight.nextChunk !== envelope.chunkCount ||
1359
+ receipt.workspaceName !== envelope.workspaceName ||
1360
+ receipt.semanticPaneId !== envelope.semanticPaneId ||
1361
+ receipt.generation !== envelope.generation ||
1362
+ receipt.incarnation !== envelope.incarnation ||
1363
+ receipt.deliveryNonce !== envelope.deliveryNonce ||
1364
+ receipt.transactionId !== envelope.transactionId ||
1365
+ receipt.canonicalRevision !== envelope.canonicalRevision ||
1366
+ receipt.canonicalStateHash !== envelope.canonicalStateHash ||
1367
+ receipt.projectionStateHash !== envelope.projectionStateHash ||
1368
+ receipt.representationHash !== envelope.representationHash) {
1369
+ this.#fault(client, "protocol-violation", "Projection receipt does not identify the in-flight projection");
1370
+ return;
1371
+ }
1372
+ client.projectionBaseline = flight.projection;
1373
+ client.lastProjectionReceipt = receipt;
1374
+ client.reseedRequired = false;
1375
+ client.inFlight = null;
1376
+ if (client.latestRevision === receipt.canonicalRevision)
1377
+ client.latestRevision = null;
1378
+ if (client.materializeRequested)
1379
+ client.latestRevision = this.#panes.get(client.paneId)?.latest?.update.revision ?? null;
1380
+ this.#pruneCanonicalRevisions(client.paneId);
1381
+ this.#schedule(client);
1382
+ this.#recordDeliveryStatus(client, this.#panes.get(client.paneId));
1383
+ }
1384
+ #materialize(client, input) {
1385
+ const request = TerminalProjectionMaterializeSchemaZ.parse(input);
1386
+ if (client.lastMaterialize &&
1387
+ JSON.stringify(client.lastMaterialize) === JSON.stringify(request))
1388
+ return;
1389
+ const baseline = client.projectionBaseline;
1390
+ if (!client.negotiated.alternateScreenProjection ||
1391
+ !baseline ||
1392
+ request.workspaceName !== client.deliveryWorkspaceName ||
1393
+ request.semanticPaneId !== client.paneId ||
1394
+ request.generation !== client.negotiated.generation ||
1395
+ request.incarnation !== baseline.incarnation ||
1396
+ request.deliveryNonce !== client.negotiated.deliveryNonce ||
1397
+ request.canonicalRevision !== baseline.revision ||
1398
+ request.projectionStateHash !== baseline.hash) {
1399
+ this.#fault(client, "protocol-violation", "History request does not identify the retained projection");
1400
+ return;
1401
+ }
1402
+ client.lastMaterialize = request;
1403
+ client.materializeRequested = true;
1404
+ this.#cancelEncoding(client);
1405
+ client.latestRevision = this.#panes.get(client.paneId)?.latest?.update.revision ?? null;
1406
+ // A queued projection still owns its transaction until its receipt arrives.
1407
+ // The following complete seed then supersedes it, including at the same revision.
1408
+ this.#schedule(client);
1409
+ }
1280
1410
  #nack(client, input) {
1281
1411
  const nack = TerminalDeliveryNackSchemaZ.parse(input);
1282
1412
  const closed = client.sourceClosedFlight;
@@ -0,0 +1,32 @@
1
+ import { hashTerminalProjection, projectAlternateSnapshot, terminalReplicaRowsEqual, } from "@tmux-ide/core";
2
+ export function prepareProjection(snapshot, revision, incarnation, baseline) {
3
+ const projected = projectAlternateSnapshot(snapshot);
4
+ const retained = {
5
+ revision,
6
+ incarnation,
7
+ hash: hashTerminalProjection(projected),
8
+ snapshot: projected,
9
+ };
10
+ if (!baseline ||
11
+ baseline.incarnation !== incarnation ||
12
+ baseline.snapshot.cols !== projected.cols ||
13
+ baseline.snapshot.rows !== projected.rows)
14
+ return { payload: { frame: "seed", revision, projection: projected }, retained };
15
+ const rows = projected.grid.flatMap((row, index) => terminalReplicaRowsEqual(row, baseline.snapshot.grid[index]) ? [] : [{ index, row }]);
16
+ return {
17
+ retained,
18
+ payload: {
19
+ frame: "patch",
20
+ baseRevision: baseline.revision,
21
+ revision,
22
+ patch: {
23
+ rows,
24
+ deferredHistory: projected.deferredHistory,
25
+ cursor: projected.cursor,
26
+ modes: projected.modes,
27
+ placements: projected.placements,
28
+ bootstrap: projected.bootstrap,
29
+ },
30
+ },
31
+ };
32
+ }
@@ -155,11 +155,10 @@ export class SessionRuntimeTerminalReplicaOwner {
155
155
  inputDispatchIncarnation() {
156
156
  return this.#disposed ? null : this.#publishedIncarnation;
157
157
  }
158
- /** Scalar admission fence; unavailable while canonical replacement is pending. */
158
+ /** Screen recapture does not replace the terminal lifetime. Keep its input fence
159
+ * while resizing; disposal or a new owner still invalidates held input. */
159
160
  inputAdmissionIncarnation() {
160
- return this.#disposed || this.#reseed || this.#waitingForGeometryCapture
161
- ? null
162
- : this.#publishedIncarnation;
161
+ return this.#disposed ? null : this.#publishedIncarnation;
163
162
  }
164
163
  qualificationSnapshot() {
165
164
  const seed = this.#interpreter.currentSeed();
@@ -335,6 +335,10 @@ class WireTerminalEndpoint {
335
335
  }
336
336
  if (this.#unavailable)
337
337
  return;
338
+ if (message.type === "terminal.projection") {
339
+ this.#failConnection(new Error("Unsolicited alternate-screen projection"));
340
+ return;
341
+ }
338
342
  if (message.type === "terminal.delivery") {
339
343
  this.#acceptEnvelope(message);
340
344
  return;
@@ -1,3 +1,4 @@
1
+ import { rendererProbe } from "./renderer-probe.js";
1
2
  import { createTerminalSelectionWaiter } from "./terminal-selection-waiter.js";
2
3
  import { WorkspaceWindowLinkSelectResultSchemaZ } from "@tmux-ide/contracts";
3
4
  import { windowLinkForPane, windowLinkTarget } from "./application-terminal-workspace-policy.js";
@@ -738,10 +739,14 @@ export function createApplicationTerminalInteractionController(options) {
738
739
  }
739
740
  return outcome.status === "sent";
740
741
  },
741
- onFocusedPane: options.setFocusedPane,
742
+ onFocusedPane: (pane) => {
743
+ rendererProbe()?.cancel("selection-changed", nowMicros());
744
+ options.setFocusedPane(pane);
745
+ },
742
746
  });
743
747
  return {
744
748
  cancelPendingInput() {
749
+ rendererProbe()?.cancel("input-cancelled", nowMicros());
745
750
  selectionWaiter.cancel();
746
751
  finishLinkSelection(false);
747
752
  paneInput.invalidateSelection();
@@ -752,6 +757,7 @@ export function createApplicationTerminalInteractionController(options) {
752
757
  selectionGeneration = snapshot;
753
758
  const control = controlIdentity(snapshot);
754
759
  if (!sameControl(selectedControl, control)) {
760
+ rendererProbe()?.cancel("control-changed", nowMicros());
755
761
  finishLinkSelection(false);
756
762
  paneInput.invalidateSelection();
757
763
  }
@@ -807,6 +813,7 @@ export function createApplicationTerminalInteractionController(options) {
807
813
  next.fastLane !== inputAuthorityIdentity.fastLane ||
808
814
  next.adapter !== inputAuthorityIdentity.adapter);
809
815
  if (next === null || replaced) {
816
+ rendererProbe()?.cancel("generation-changed", nowMicros());
810
817
  finishLinkSelection(false);
811
818
  pendingWindowSwitch = null;
812
819
  pendingWindowRename = null;
@@ -1039,9 +1046,27 @@ export function createApplicationTerminalInteractionController(options) {
1039
1046
  }
1040
1047
  },
1041
1048
  sendInput: async (input, parserOrigin) => {
1042
- await paneInput.sendInput({ input, parserOrigin });
1049
+ const probe = rendererProbe();
1050
+ const state = probe
1051
+ ? (options.generation()?.fastLane?.lane.paneState(paneInput.focusedPane ?? "") ?? null)
1052
+ : null;
1053
+ const measured = probe?.admit(input, state, nowMicros());
1054
+ const sent = await paneInput.sendInput({ input: measured ?? input, parserOrigin });
1055
+ if (measured && !sent)
1056
+ probe?.cancel("dispatch-rejected", nowMicros());
1057
+ },
1058
+ sendInputToPane: async (paneId, input, parserOrigin) => {
1059
+ const probe = rendererProbe();
1060
+ const state = probe ? (options.generation()?.fastLane?.lane.paneState(paneId) ?? null) : null;
1061
+ const measured = probe?.admit(input, state, nowMicros());
1062
+ const sent = await paneInput.sendInputToPane(paneId, {
1063
+ input: measured ?? input,
1064
+ parserOrigin,
1065
+ });
1066
+ if (measured && !sent)
1067
+ probe?.cancel("dispatch-rejected", nowMicros());
1068
+ return sent;
1043
1069
  },
1044
- sendInputToPane: (paneId, input, parserOrigin) => paneInput.sendInputToPane(paneId, { input, parserOrigin }),
1045
1070
  beginResizePointerIngress(input) {
1046
1071
  if (!options.diagnosticsEnabled)
1047
1072
  return null;
@@ -1,3 +1,4 @@
1
+ import { rendererProbe } from "./renderer-probe.js";
1
2
  import { paneInteractionDisplayDestination } from "../ui/pane-interaction-presentation.js";
2
3
  import { interactionPaneEndpointKey } from "@tmux-ide/core";
3
4
  import { interactionForCurrentPane } from "../ui/pane-interaction-presentation.js";
@@ -935,6 +936,7 @@ export function ApplicationTerminalWorkspace(props) {
935
936
  return;
936
937
  }
937
938
  if (event.type === "scroll") {
939
+ const probeAt = rendererProbe() ? Math.floor(performance.now() * 1000) : 0;
938
940
  // A held selection owns the gesture even when the pointer crosses a sibling.
939
941
  const activeSelection = selecting;
940
942
  const selectionCell = activeSelection &&
@@ -942,7 +944,9 @@ export function ApplicationTerminalWorkspace(props) {
942
944
  const hit = activeSelection && selectionCell
943
945
  ? { frame: activeSelection.frame, ...selectionCell }
944
946
  : paneContentAt(point);
945
- const before = props.onWheelObservation && hit ? scrollback.offset(hit.frame.paneId) : null;
947
+ const before = (props.onWheelObservation || rendererProbe()) && hit
948
+ ? scrollback.offset(hit.frame.paneId)
949
+ : null;
946
950
  const observe = (route) => {
947
951
  props.onWheelObservation?.({
948
952
  paneId: hit?.frame.paneId ?? null,
@@ -1021,6 +1025,14 @@ export function ApplicationTerminalWorkspace(props) {
1021
1025
  }
1022
1026
  else
1023
1027
  scrollback.move(hit.frame.paneId, motion.lines);
1028
+ if (rendererProbe() && identity && before !== null)
1029
+ rendererProbe()?.scroll({
1030
+ pane: hit.frame.paneId,
1031
+ generation: identity.generation,
1032
+ incarnation: identity.incarnation,
1033
+ cols: snapshot.cols,
1034
+ rows: snapshot.rows,
1035
+ }, snapshot, before, scrollback.offset(hit.frame.paneId), probeAt);
1024
1036
  observe("local-history");
1025
1037
  if (selecting?.paneId === hit.frame.paneId)
1026
1038
  updatePointerSelection(selecting.pointer);
@@ -0,0 +1,195 @@
1
+ import { visibleTerminalRowAt } from "../semantic-pane-render-source.js";
2
+ import { appendFile } from "node:fs/promises";
3
+ import { isAbsolute } from "node:path";
4
+ function scope(state) {
5
+ return state.snapshot
6
+ ? {
7
+ pane: state.semanticPaneId,
8
+ generation: state.generation,
9
+ incarnation: state.incarnation,
10
+ cols: state.snapshot.cols,
11
+ rows: state.snapshot.rows,
12
+ }
13
+ : null;
14
+ }
15
+ const key = (s) => JSON.stringify([s.pane, s.generation, s.incarnation, s.cols, s.rows]);
16
+ export class RendererProbe {
17
+ trials;
18
+ emit;
19
+ #next = 0;
20
+ #pending = null;
21
+ constructor(trials, emit) {
22
+ this.trials = trials;
23
+ this.emit = emit;
24
+ if (trials.length > 64 ||
25
+ new Set(trials.map((t) => t.nonce)).size !== trials.length ||
26
+ trials.some((t) => !t.id ||
27
+ t.id.length > 128 ||
28
+ !/^[a-f0-9]{32}$/.test(t.nonce) ||
29
+ (t.kind !== undefined && !["input", "scroll", "output"].includes(t.kind))))
30
+ throw new Error("Invalid renderer probe trials");
31
+ }
32
+ get pending() {
33
+ return this.#pending !== null;
34
+ }
35
+ admit(input, state, at) {
36
+ if (input.kind !== "text" ||
37
+ input.data !== "!" ||
38
+ !state?.snapshot ||
39
+ this.#pending ||
40
+ !this.trials[this.#next])
41
+ return null;
42
+ if (this.trials[this.#next]?.kind === "scroll")
43
+ return { kind: "text", data: `PROBE-PREPARE-SCROLL:${this.trials[this.#next].nonce}\n` };
44
+ const trial = this.trials[this.#next++], marker = `PROBE-OUTPUT:${trial.nonce}`, identity = scope(state);
45
+ this.#pending = { trial, scope: identity, at, marker, offset: 0, sourceOffset: 0 };
46
+ if (state.snapshot.cols < marker.length ||
47
+ state.snapshot.grid.some((r) => r.cells
48
+ .map((c) => c.grapheme)
49
+ .join("")
50
+ .includes(marker))) {
51
+ this.cancel("negative-control", at);
52
+ return null;
53
+ }
54
+ return {
55
+ kind: "text",
56
+ data: `${trial.kind === "output" ? "PROBE-BURST" : "PROBE-IN"}:${trial.nonce}\n`,
57
+ };
58
+ }
59
+ scroll(identity, snapshot, before, after, at) {
60
+ const trial = this.trials[this.#next];
61
+ if (this.#pending || trial?.kind !== "scroll" || before === after)
62
+ return;
63
+ const row = visibleTerminalRowAt(snapshot, after, 0);
64
+ const text = row?.cells
65
+ .map((c) => c.grapheme)
66
+ .join("")
67
+ .trimEnd() ?? "";
68
+ if (!new RegExp(`^PROBE-ROW:${trial.nonce}:[0-9]{4}$`).test(text))
69
+ return;
70
+ for (let y = 0; y < snapshot.rows; y++)
71
+ if (visibleTerminalRowAt(snapshot, before, y)
72
+ ?.cells.map((c) => c.grapheme)
73
+ .join("")
74
+ .trimEnd() === text)
75
+ return;
76
+ this.#next++;
77
+ this.#pending = {
78
+ trial,
79
+ scope: identity,
80
+ at,
81
+ marker: text,
82
+ offset: after,
83
+ sourceOffset: before,
84
+ };
85
+ }
86
+ tick(at) {
87
+ if (this.#pending && at - this.#pending.at > 5_000_000)
88
+ this.cancel("timeout", at);
89
+ }
90
+ cancel(reason, at) {
91
+ const p = this.#pending;
92
+ if (!p)
93
+ return;
94
+ this.#pending = null;
95
+ this.emit({
96
+ ...p.trial,
97
+ side: "tui",
98
+ kind: p.trial.kind ?? "input",
99
+ sourceScope: p.scope,
100
+ expectedScope: p.scope,
101
+ sourceOffset: p.sourceOffset,
102
+ expectedOffset: p.offset,
103
+ admittedAtMicros: p.at,
104
+ completedAtMicros: at,
105
+ endpoint: "input-admission-to-renderer-work-complete",
106
+ status: "failed",
107
+ reason,
108
+ });
109
+ }
110
+ observe(state, at) {
111
+ if (!this.#pending)
112
+ return;
113
+ if (state && state.semanticPaneId !== this.#pending.scope.pane)
114
+ return;
115
+ if (!state || !scope(state) || key(scope(state)) !== key(this.#pending.scope))
116
+ this.cancel("scope-changed", at);
117
+ else if (at - this.#pending.at > 5_000_000)
118
+ this.cancel("timeout", at);
119
+ }
120
+ row(state, row, viewportRow, width, height, left, scrollOffset, at) {
121
+ this.observe(state, at);
122
+ const p = this.#pending;
123
+ if (!p || state.semanticPaneId !== p.scope.pane)
124
+ return;
125
+ if (scrollOffset !== p.offset ||
126
+ left !== 0 ||
127
+ viewportRow !== 0 ||
128
+ height < 1 ||
129
+ width < p.marker.length ||
130
+ !row)
131
+ return;
132
+ const cells = row.cells.slice(0, p.marker.length);
133
+ if (cells.some((c) => c.width !== 1) || cells.map((c) => c.grapheme).join("") !== p.marker)
134
+ return;
135
+ if (!Number.isSafeInteger(at) || at < p.at) {
136
+ this.cancel("clock-invalid", at);
137
+ return;
138
+ }
139
+ this.#pending = null;
140
+ this.emit({
141
+ ...p.trial,
142
+ side: "tui",
143
+ kind: p.trial.kind ?? "input",
144
+ sourceScope: p.scope,
145
+ expectedScope: p.scope,
146
+ sourceOffset: p.sourceOffset,
147
+ expectedOffset: p.offset,
148
+ admittedAtMicros: p.at,
149
+ completedAtMicros: at,
150
+ endpoint: "input-admission-to-renderer-work-complete",
151
+ status: "completed",
152
+ durationMicros: at - p.at,
153
+ witness: {
154
+ row: viewportRow,
155
+ column: 0,
156
+ marker: p.marker,
157
+ revision: state.revision,
158
+ stateHash: state.hash,
159
+ },
160
+ });
161
+ }
162
+ }
163
+ let singleton;
164
+ /** All filesystem work is asynchronous and deferred; at most64 short receipts. */
165
+ export function rendererProbe() {
166
+ if (singleton !== undefined)
167
+ return singleton;
168
+ singleton = null;
169
+ if (process.env.TMUX_IDE_RENDERER_FIXTURE !== "1")
170
+ return null;
171
+ const raw = process.env.TMUX_IDE_RENDERER_PROBE_TRIALS, path = process.env.TMUX_IDE_RENDERER_PROBE_LOG;
172
+ if (!raw || raw.length > 16384 || !path || !isAbsolute(path))
173
+ return null;
174
+ try {
175
+ const trials = JSON.parse(raw);
176
+ if (!Array.isArray(trials))
177
+ return null;
178
+ let writes = Promise.resolve(), failed = false;
179
+ singleton = new RendererProbe(trials, (record) => {
180
+ if (failed)
181
+ return;
182
+ writes = writes
183
+ .then(() => appendFile(path, JSON.stringify(record) + "\n", { mode: 0o600 }))
184
+ .catch(() => {
185
+ failed = true;
186
+ });
187
+ });
188
+ const timer = setInterval(() => singleton?.tick(Math.floor(performance.now() * 1000)), 250);
189
+ timer.unref();
190
+ }
191
+ catch {
192
+ singleton = null;
193
+ }
194
+ return singleton;
195
+ }
@@ -1,3 +1,4 @@
1
+ import { rendererProbe } from "./renderer-probe.js";
1
2
  import { terminalReplicaRowsEqual } from "@tmux-ide/core";
2
3
  import { reflowTerminalPosition, reflowRetainedTerminalSnapshot, retainNativeTerminalBacking, } from "../terminal-viewport.js";
3
4
  import { blitSemanticRow, changedTerminalRows, visibleTerminalRowAt, } from "../semantic-pane-render-source.js";
@@ -481,6 +482,8 @@ export class TerminalFastLaneRendererAdapter {
481
482
  if (interest.retainedView && next)
482
483
  this.#resizeRetainedView(interest, next);
483
484
  interest.state = publication.state;
485
+ if (rendererProbe()?.pending)
486
+ rendererProbe()?.observe(publication.state, Math.floor(performance.now() * 1000));
484
487
  // Preserve coordinates for an unchanged prefix or an exact retained suffix.
485
488
  // The suffix offset is fixed by the length difference; never search repeated
486
489
  // log lines for a guessed overlap. Changed grids/reflow need a separate map.
@@ -810,6 +813,9 @@ export class TerminalFastLaneRendererAdapter {
810
813
  if (interest?.dirtyRows.has(canonicalRow))
811
814
  paintedCanonicalChange = true;
812
815
  blit(sourceRow, buffers, row, width, defaultFg, defaultBg, options.graphemes, options.palette, options.viewportOrigin?.x ?? 0);
816
+ if (interest?.state && rendererProbe()?.pending) {
817
+ rendererProbe()?.row(interest.retainedView?.state ?? interest.state, sourceRow, row, width, height, options.viewportOrigin?.x ?? 0, options.viewportOrigin ? Math.max(0, -options.viewportOrigin.y) : scrollOffset, Math.floor(performance.now() * 1_000));
818
+ }
813
819
  options.dirtyRows.push(row);
814
820
  paintedRows[row] = sourceRow;
815
821
  }