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
@@ -53,6 +53,23 @@ import {
53
53
  } from "../graphics/kitty-graphics.ts";
54
54
  import type { TerminalGraphicsState } from "@tmux-ide/contracts";
55
55
 
56
+ // Bound parser work per turn without changing byte-stream semantics. This is a
57
+ // batch bound, not a producer queue cap: upstream pressure must preserve bytes.
58
+ const MAX_INGEST_BATCH_BYTES = 256 * 1024;
59
+ type PendingWrite = {
60
+ data: Uint8Array;
61
+ offset: number;
62
+ resolve: () => void;
63
+ reject: (error: unknown) => void;
64
+ };
65
+ type WriteGroup = {
66
+ entries: PendingWrite[];
67
+ bytes: number;
68
+ scheduled: boolean;
69
+ flushQueued: boolean;
70
+ trace: SessionRuntimeTraceContext | null;
71
+ };
72
+
56
73
  type CloseReason = "pane-closed" | "session-restarted" | "runtime-disposed";
57
74
  export type TerminalReplicaInterpreterOperation =
58
75
  | {
@@ -167,13 +184,7 @@ export class TerminalReplicaInterpreter {
167
184
  #needsSeed = true;
168
185
  #closed = false;
169
186
  #walkCount = 0;
170
- #pendingWrites: Array<{
171
- data: Uint8Array;
172
- trace: SessionRuntimeTraceContext | null;
173
- resolve: () => void;
174
- reject: (error: unknown) => void;
175
- }> = [];
176
- #writeFlushScheduled = false;
187
+ #pendingWrites: WriteGroup | null = null;
177
188
  #widgetGate = false;
178
189
  // kitty graphics: images outlive reseeds; the scanner owns split APCs.
179
190
  readonly #kittyScanner = new KittyApcScanner();
@@ -252,18 +263,27 @@ export class TerminalReplicaInterpreter {
252
263
  if (operation.type === "write") {
253
264
  const data = operation.data.slice();
254
265
  const trace = operation.trace ?? null;
255
- const pendingTrace = this.#pendingWrites[0]?.trace ?? null;
256
266
  if (
257
- this.#pendingWrites.length > 0 &&
258
- (pendingTrace?.traceId ?? null) !== (trace?.traceId ?? null)
267
+ this.#pendingWrites &&
268
+ (this.#pendingWrites.trace?.traceId ?? null) !== (trace?.traceId ?? null)
259
269
  )
260
270
  this.#flushWrites();
271
+ const group = (this.#pendingWrites ??= {
272
+ entries: [],
273
+ bytes: 0,
274
+ scheduled: false,
275
+ flushQueued: false,
276
+ trace,
277
+ });
261
278
  const promise = new Promise<void>((resolve, reject) => {
262
- this.#pendingWrites.push({ data, trace, resolve, reject });
279
+ group.entries.push({ data, offset: 0, resolve, reject });
280
+ group.bytes += data.length;
263
281
  });
264
- if (!this.#writeFlushScheduled) {
265
- this.#writeFlushScheduled = true;
266
- this.#scheduler.microtask(() => this.#flushWrites());
282
+ // Leave this group open while the parser awaits its callback. Scheduling
283
+ // it once on the FIFO lets later arrivals join the next bounded batch.
284
+ if (!group.scheduled && !group.flushQueued) {
285
+ group.flushQueued = true;
286
+ this.#scheduler.microtask(() => this.#scheduleWriteGroup(group));
267
287
  }
268
288
  return promise;
269
289
  }
@@ -528,7 +548,27 @@ export class TerminalReplicaInterpreter {
528
548
  // turn, so a frame split into dozens of %output chunks would otherwise
529
549
  // serialize dozens of turns and fall behind a 30 fps image stream.
530
550
  if (scanned.forward.byteLength === 0) return;
531
- await this.#writeToBackend(this.#backend, scanned.forward);
551
+ // Scanning can prepend an escape retained from the previous chunk. Bound
552
+ // the actual parser calls too, and union damage across every fragment.
553
+ for (let offset = 0; offset < scanned.forward.length; offset += MAX_INGEST_BATCH_BYTES) {
554
+ await this.#writeToBackend(
555
+ this.#backend,
556
+ scanned.forward.subarray(offset, offset + MAX_INGEST_BATCH_BYTES),
557
+ );
558
+ // The parser resets its dirty tracker per write. Synchronized output and
559
+ // bounded fragments can both span writes before canonical publication.
560
+ const dirty = this.#backend.dirtyRange();
561
+ const pending = this.#pendingWriteDirty;
562
+ this.#pendingWriteDirty =
563
+ pending === null
564
+ ? dirty
565
+ : pending === undefined || dirty === undefined
566
+ ? undefined
567
+ : {
568
+ start: Math.min(pending.start, dirty.start),
569
+ end: Math.max(pending.end, dirty.end),
570
+ };
571
+ }
532
572
  if (this.#observability.enabled)
533
573
  this.#observability.recordSpan(
534
574
  "parse",
@@ -537,19 +577,6 @@ export class TerminalReplicaInterpreter {
537
577
  this.#observability.nowMicros(),
538
578
  trace,
539
579
  );
540
- // The parser resets its dirty tracker per write. Synchronized output may
541
- // span many writes before publication, so retain their union until commit.
542
- const dirty = this.#backend.dirtyRange();
543
- const pending = this.#pendingWriteDirty;
544
- this.#pendingWriteDirty =
545
- pending === null
546
- ? dirty
547
- : pending === undefined || dirty === undefined
548
- ? undefined
549
- : {
550
- start: Math.min(pending.start, dirty.start),
551
- end: Math.max(pending.end, dirty.end),
552
- };
553
580
  // DEC synchronized-output is atomic: no intermediate frame leaks.
554
581
  if (this.#backend.requiresNativeReseed()) {
555
582
  this.#commit(false);
@@ -574,6 +601,7 @@ export class TerminalReplicaInterpreter {
574
601
  if (this.#syncRecovery || this.#closed) return;
575
602
  this.#syncRecovery = this.#scheduler.timer(() => {
576
603
  this.#syncRecovery = null;
604
+ this.#flushWrites();
577
605
  const run = this.#tail.then(async () => {
578
606
  if (this.#closed || !this.#backend.modes().synchronizedOutput) return;
579
607
  // Recovery is synthetic parser maintenance, not pane output caused by
@@ -859,24 +887,54 @@ export class TerminalReplicaInterpreter {
859
887
  }
860
888
  }
861
889
 
890
+ /** Seal the current group at every trace, control and idle barrier. */
862
891
  #flushWrites(): void {
863
- this.#writeFlushScheduled = false;
864
- if (this.#pendingWrites.length === 0) return;
865
- const pending = this.#pendingWrites.splice(0);
866
- const length = pending.reduce((total, entry) => total + entry.data.length, 0);
867
- const data = new Uint8Array(length);
868
- let offset = 0;
869
- for (const entry of pending) {
870
- data.set(entry.data, offset);
871
- offset += entry.data.length;
872
- }
873
- const trace = pending.find(({ trace }) => trace !== null)?.trace ?? null;
874
- const run = this.#tail.then(() => this.#write(data, trace));
892
+ const group = this.#pendingWrites;
893
+ this.#pendingWrites = null;
894
+ if (group) this.#scheduleWriteGroup(group);
895
+ }
896
+
897
+ #scheduleWriteGroup(group: WriteGroup): void {
898
+ if (group.scheduled) return;
899
+ group.scheduled = true;
900
+ const run = this.#tail.then(async () => {
901
+ try {
902
+ while (group.entries.length > 0) {
903
+ const length = Math.min(group.bytes, MAX_INGEST_BATCH_BYTES);
904
+ const data = new Uint8Array(length);
905
+ const completed: PendingWrite[] = [];
906
+ let offset = 0;
907
+ for (const entry of group.entries) {
908
+ const take = Math.min(entry.data.length - entry.offset, length - offset);
909
+ data.set(entry.data.subarray(entry.offset, entry.offset + take), offset);
910
+ offset += take;
911
+ entry.offset += take;
912
+ if (entry.offset === entry.data.length) completed.push(entry);
913
+ else break;
914
+ if (offset === length && length > 0) break;
915
+ }
916
+ group.entries.splice(0, completed.length);
917
+ group.bytes -= length;
918
+ try {
919
+ await this.#write(data, group.trace);
920
+ } catch (error) {
921
+ for (const entry of completed) entry.reject(error);
922
+ throw error;
923
+ }
924
+ for (const entry of completed) entry.resolve();
925
+ }
926
+ } catch (error) {
927
+ // Reject every remaining fragment in the failed group; later FIFO
928
+ // controls (including recovery reseeds and close) still execute.
929
+ for (const entry of group.entries) entry.reject(error);
930
+ group.entries.length = 0;
931
+ group.bytes = 0;
932
+ throw error;
933
+ } finally {
934
+ if (this.#pendingWrites === group) this.#pendingWrites = null;
935
+ }
936
+ });
875
937
  this.#tail = run.catch(() => undefined);
876
- void run.then(
877
- () => pending.forEach((entry) => entry.resolve()),
878
- (error) => pending.forEach((entry) => entry.reject(error)),
879
- );
880
938
  }
881
939
 
882
940
  #observeMarkerBytes(data: Uint8Array): void {
@@ -259,6 +259,12 @@ export class SessionRuntimeTerminalReplicaOwner {
259
259
  return this.#disposed ? null : this.#publishedIncarnation;
260
260
  }
261
261
 
262
+ /** Screen recapture does not replace the terminal lifetime. Keep its input fence
263
+ * while resizing; disposal or a new owner still invalidates held input. */
264
+ inputAdmissionIncarnation(): string | null {
265
+ return this.#disposed ? null : this.#publishedIncarnation;
266
+ }
267
+
262
268
  qualificationSnapshot(): TerminalReplicaQualificationSnapshot {
263
269
  const seed = this.#interpreter.currentSeed();
264
270
  return Object.freeze({
@@ -13,6 +13,7 @@ import {
13
13
  type SessionRuntimeSemanticIntent,
14
14
  type SessionRuntimeTerminalInput,
15
15
  type CausalCellProbeV1,
16
+ type PaneStreamInputLifetime,
16
17
  type TerminalDeliveryOffer,
17
18
  type TerminalDeliveryServerMessage,
18
19
  } from "@tmux-ide/contracts";
@@ -428,6 +429,7 @@ export class SessionRuntimeTransportBinding {
428
429
  performanceTraceId?: string,
429
430
  causalProbe?: CausalCellProbeV1,
430
431
  onCausalResult?: (result: CausalCellLedgerResult) => void,
432
+ expectedLifetime?: PaneStreamInputLifetime,
431
433
  ): void {
432
434
  this.assertController(semanticPaneId);
433
435
  const lease = this.#shared.lease;
@@ -451,6 +453,7 @@ export class SessionRuntimeTransportBinding {
451
453
  onCausalResult?.(result);
452
454
  }
453
455
  : onCausalResult,
456
+ expectedLifetime,
454
457
  );
455
458
  } catch (error) {
456
459
  if (causalProbe) this.#causalCellProbes.delete(causalProbe.traceId);
@@ -144,6 +144,8 @@ export const OPEN_TUI_HOST_CLIENT_ID = `opentui:${process.pid}`;
144
144
  export type OpenTuiWorkspaceLayout = Extract<PaneStreamServerFrame, { type: "layout" }>;
145
145
 
146
146
  export interface OpenTuiWorkspaceLayoutSnapshot {
147
+ /** Renderer-local provenance, stamped only by the host presentation adapter. */
148
+ readonly rendererEpoch?: number;
147
149
  /** Live link authority; absent until an atomic stream snapshot arrives. */
148
150
  readonly windowLinks?: WindowLinkTopology | null;
149
151
  readonly current: OpenTuiWorkspaceLayout | null;
@@ -530,6 +532,10 @@ class WireTerminalEndpoint {
530
532
  return;
531
533
  }
532
534
  if (this.#unavailable) return;
535
+ if (message.type === "terminal.projection") {
536
+ this.#failConnection(new Error("Unsolicited alternate-screen projection"));
537
+ return;
538
+ }
533
539
  if (message.type === "terminal.delivery") {
534
540
  this.#acceptEnvelope(message);
535
541
  return;
@@ -104,9 +104,25 @@ export function createApplicationAgentNavigator(options: {
104
104
  return async (sessionName: string, paneId: string, source: Source = "mouse") => {
105
105
  const token = ++navigationToken;
106
106
  const currentOwner = options.sessionOwner();
107
+ const snapshot = currentOwner.snapshot();
108
+ // A renderer rebind keeps its semantic control connection. Reserve this
109
+ // exact target synchronously; the controller fences and bounds first send.
110
+ const retainedControl = (() => {
111
+ try {
112
+ return (
113
+ snapshot?.status === "rebinding" &&
114
+ snapshot.connection?.workspaceName === sessionName &&
115
+ snapshot.daemonGeneration &&
116
+ snapshot.client &&
117
+ Number.isSafeInteger(snapshot.client.getSnapshot().generation)
118
+ );
119
+ } catch {
120
+ return false;
121
+ }
122
+ })();
107
123
  if (
108
124
  currentOwner.sessionName() === sessionName &&
109
- applicationGenerationNavigationKey(currentOwner.snapshot()) !== null
125
+ (applicationGenerationNavigationKey(snapshot) !== null || retainedControl)
110
126
  ) {
111
127
  // Sidebar agents belong to the already-live workspace. Select their pane
112
128
  // before returning control to OpenTUI so the very next key cannot leak to
@@ -1,3 +1,4 @@
1
+ import { retainsAgentControl } from "./application-retained-agent.ts";
1
2
  import {
2
3
  createApplicationHomeAgentNavigator,
3
4
  type HomeAgentNavigationSource,
@@ -39,6 +40,20 @@ export function createApplicationMachineAgentNavigator(options: {
39
40
  const selected = () => options.selectedMachineId() === machineId;
40
41
  const generation = () =>
41
42
  selected() && options.generationMachineId() === machineId ? options.generation() : null;
43
+ if (
44
+ selected() &&
45
+ options.isCurrentTarget(machineId, target) &&
46
+ options.sessionName() === target.sessionName &&
47
+ retainsAgentControl(generation(), target)
48
+ ) {
49
+ // Reserve before the next key; the terminal controller waits for a fresh
50
+ // coherent runtime and remains the only owner of physical input.
51
+ options.selectPane(target.paneId!, source);
52
+ options.showTerminals(source);
53
+ options.setNote(null);
54
+ opening = false;
55
+ return Promise.resolve({ opened: true });
56
+ }
42
57
  current = createApplicationHomeAgentNavigator({
43
58
  isCurrentTarget: (row) => selected() && options.isCurrentTarget(machineId, row),
44
59
  currentGeneration() {
@@ -1,3 +1,4 @@
1
+ import { retainsAgentControl } from "./application-retained-agent.ts";
1
2
  import type { OpenTuiGenerationHostSnapshot } from "./open-tui-generation-host.ts";
2
3
  import type { TmuxServerScope } from "@tmux-ide/contracts";
3
4
  import { fleetHostColor } from "./fleet-presentation.ts";
@@ -90,7 +91,12 @@ export function createApplicationMachineNavigation(options: {
90
91
  const [tabRevision, setTabRevision] = createSignal(0);
91
92
  const history: string[] = [...saved().recent].reverse();
92
93
  let historyIndex = history.length - 1;
93
- const select = (id: string, expectedLiveSessionId?: string, server?: TmuxServerScope) => {
94
+ const select = (
95
+ id: string,
96
+ expectedLiveSessionId?: string,
97
+ server?: TmuxServerScope,
98
+ retainedAgent?: ApplicationMachineAgent,
99
+ ) => {
94
100
  if (!manager.getMachine(id)) return false;
95
101
  if (manager.snapshot().selectedMachineId !== id) {
96
102
  navigation++;
@@ -100,9 +106,17 @@ export function createApplicationMachineNavigation(options: {
100
106
  else options.resetWorkspace(id, expectedLiveSessionId);
101
107
  manager.select(id);
102
108
  } else if (expectedLiveSessionId) {
109
+ const active = options.attachedGeneration?.() ?? null;
110
+ const retained =
111
+ retainedAgent && retainsAgentControl(active, retainedAgent)
112
+ ? {
113
+ liveSessionId: active!.connection!.liveSessionId,
114
+ daemonGeneration: active!.daemonGeneration,
115
+ ...(active!.connection!.server ? { server: active!.connection!.server } : {}),
116
+ }
117
+ : null;
103
118
  const attached =
104
- options.attachedTarget?.() ??
105
- attachedMachineNavigationTarget(options.attachedGeneration?.() ?? null);
119
+ options.attachedTarget?.() ?? attachedMachineNavigationTarget(active) ?? retained;
106
120
  const sameAuthority =
107
121
  attached?.liveSessionId === expectedLiveSessionId &&
108
122
  (server
@@ -357,7 +371,7 @@ export function createApplicationMachineNavigation(options: {
357
371
  return;
358
372
  }
359
373
  options.cancelOpen();
360
- if (!select(id, selectedSession?.liveSessionId, selectedSession?.server)) return;
374
+ if (!select(id, selectedSession?.liveSessionId, selectedSession?.server, row)) return;
361
375
  if (selectedSession) setActiveSessionKey(selectedSession.id);
362
376
  navigation++;
363
377
  setFocused(false);
@@ -0,0 +1,29 @@
1
+ import type { OpenTuiGenerationHostSnapshot } from "./open-tui-generation-host.ts";
2
+ import type { HomeAgentNavigationTarget } from "./application-home-agent-navigation.ts";
3
+
4
+ /** Selection intent may wait on this exact retained control; input cannot use it yet. */
5
+ export function retainsAgentControl(
6
+ active: OpenTuiGenerationHostSnapshot | null,
7
+ target: HomeAgentNavigationTarget,
8
+ ): boolean {
9
+ if (
10
+ active?.status !== "rebinding" ||
11
+ !active.client ||
12
+ !target.paneId ||
13
+ active.daemonGeneration !== target.daemonInstanceId ||
14
+ active.connection?.workspaceName !== target.sessionName ||
15
+ active.connection.liveSessionId !== target.liveSessionId
16
+ )
17
+ return false;
18
+ try {
19
+ const state = active.client.getSnapshot();
20
+ return (
21
+ Number.isSafeInteger(state.generation) &&
22
+ state.semantic?.sidebar.agents.some(
23
+ (agent) => agent.id === target.agentId && agent.paneId === target.paneId,
24
+ ) === true
25
+ );
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
@@ -1,3 +1,5 @@
1
+ import { rendererProbe } from "./renderer-probe.ts";
2
+ import { createTerminalSelectionWaiter } from "./terminal-selection-waiter.ts";
1
3
  import { WorkspaceWindowLinkSelectResultSchemaZ } from "@tmux-ide/contracts";
2
4
  import { windowLinkForPane, windowLinkTarget } from "./application-terminal-workspace-policy.ts";
3
5
  import { createHash, randomUUID } from "node:crypto";
@@ -350,6 +352,10 @@ export function createApplicationTerminalInteractionController(
350
352
  left.generation === right.generation &&
351
353
  left.workspace === right.workspace;
352
354
  let selectedControl: ControlIdentity | null = null;
355
+ const selectionWaiter = createTerminalSelectionWaiter();
356
+ let observedLayoutEpoch: number | null = null;
357
+ let selectionGeneration: OpenTuiGenerationHostSnapshot | null = null;
358
+ let selectionLayout = options.layout();
353
359
  const waitingInput = new Set<{
354
360
  control: ControlIdentity;
355
361
  weight: number;
@@ -823,10 +829,30 @@ export function createApplicationTerminalInteractionController(
823
829
  "semanticPaneId" | "generation" | "incarnation" | "revision" | "stateHash"
824
830
  >;
825
831
  }>({
826
- select: async (paneId) => {
832
+ select: async (paneId): Promise<boolean> => {
827
833
  finishLinkSelection(false);
834
+ const initial = options.generation();
835
+ const selectionControl = controlIdentity(initial);
836
+ const selectionVersion = paneInput.selectionVersion;
837
+ const selectionCurrent = (): boolean =>
838
+ paneInput.selectionVersion === selectionVersion &&
839
+ sameControl(selectionControl, controlIdentity(selectionGeneration));
840
+ const panePresent = () =>
841
+ selectionLayout.windows.some((window) => window.panes.some((pane) => pane.pane === paneId));
842
+ if (initial?.status === "rebinding") {
843
+ // Reserve the optimistic target now, but send its FIRST mutation only
844
+ // after this same control connection has a coherent terminal runtime.
845
+ const ready = await selectionWaiter.wait(() => {
846
+ if (!selectionCurrent()) return false;
847
+ const active = selectionGeneration;
848
+ if (active?.status !== "live" || observedLayoutEpoch !== active.rendererEpoch)
849
+ return "pending";
850
+ return panePresent();
851
+ });
852
+ if (!ready || !selectionCurrent()) return false;
853
+ }
828
854
  const expected = liveSelectionTarget();
829
- const selectionControl = controlIdentity(options.generation());
855
+ let dispatchedRendererEpoch: number | undefined;
830
856
  const selecting = pendingWindowSwitch;
831
857
  const operationId = selecting?.traceId;
832
858
  const selectionFailure: { current: PaneSelectionFailure | null } = { current: null };
@@ -845,8 +871,37 @@ export function createApplicationTerminalInteractionController(
845
871
  selectionFailure.current = failure;
846
872
  },
847
873
  windowLinkForPane(options.layout(), paneId) ?? undefined,
874
+ () => {
875
+ dispatchedRendererEpoch = options.generation()?.rendererEpoch;
876
+ },
848
877
  )
849
878
  : null;
879
+ if (
880
+ receipt === null &&
881
+ selectionFailure.current?.reason === "topology-changed" &&
882
+ selectionCurrent()
883
+ ) {
884
+ // The daemon may have selected the pane before retiring its stream.
885
+ // Observe the replacement runtime; never resubmit an uncertain command
886
+ // or manufacture an operation receipt. Input still waits on this promise.
887
+ const confirmed = await selectionWaiter.wait(() => {
888
+ if (!selectionCurrent()) return false;
889
+ const active = selectionGeneration;
890
+ if (
891
+ active?.status !== "live" ||
892
+ active.rendererEpoch === dispatchedRendererEpoch ||
893
+ observedLayoutEpoch !== active.rendererEpoch
894
+ )
895
+ return "pending";
896
+ if (!panePresent()) return false;
897
+ const current = selectionLayout.current;
898
+ const activePanes = current?.panes.filter((pane) => pane.active) ?? [];
899
+ if (!current?.currentWindow || activePanes.length !== 1) return "pending";
900
+ return activePanes[0]?.pane === paneId ? true : "pending";
901
+ });
902
+ if (selecting && pendingWindowSwitch === selecting) pendingWindowSwitch = null;
903
+ return confirmed && selectionCurrent();
904
+ }
850
905
  if (selecting && pendingWindowSwitch === selecting) {
851
906
  const receiptAtMicros = options.detailedWindowSwitchTiming ? diagnosticNowMicros() : null;
852
907
  if (selecting.timing) selecting.timing.receiptAtMicros = receiptAtMicros;
@@ -996,22 +1051,30 @@ export function createApplicationTerminalInteractionController(
996
1051
  }
997
1052
  return outcome.status === "sent";
998
1053
  },
999
- onFocusedPane: options.setFocusedPane,
1054
+ onFocusedPane: (pane) => {
1055
+ rendererProbe()?.cancel("selection-changed", nowMicros());
1056
+ options.setFocusedPane(pane);
1057
+ },
1000
1058
  });
1001
1059
 
1002
1060
  return {
1003
1061
  cancelPendingInput() {
1062
+ rendererProbe()?.cancel("input-cancelled", nowMicros());
1063
+ selectionWaiter.cancel();
1004
1064
  finishLinkSelection(false);
1005
1065
  paneInput.invalidateSelection();
1006
1066
  for (const entry of waitingInput) entry.settle(false);
1007
1067
  },
1008
1068
  adoptGeneration(snapshot) {
1069
+ selectionGeneration = snapshot;
1009
1070
  const control = controlIdentity(snapshot);
1010
1071
  if (!sameControl(selectedControl, control)) {
1072
+ rendererProbe()?.cancel("control-changed", nowMicros());
1011
1073
  finishLinkSelection(false);
1012
1074
  paneInput.invalidateSelection();
1013
1075
  }
1014
1076
  selectedControl = control;
1077
+ selectionWaiter.reconcile();
1015
1078
  if (waitingInput.size > 0 && snapshot?.status === "live" && snapshot.fastLane) {
1016
1079
  // Old waiters resume first; input admitted synchronously by the next
1017
1080
  // layout publication must not overtake them in this microtask turn.
@@ -1061,6 +1124,7 @@ export function createApplicationTerminalInteractionController(
1061
1124
  next.fastLane !== inputAuthorityIdentity.fastLane ||
1062
1125
  next.adapter !== inputAuthorityIdentity.adapter);
1063
1126
  if (next === null || replaced) {
1127
+ rendererProbe()?.cancel("generation-changed", nowMicros());
1064
1128
  finishLinkSelection(false);
1065
1129
  pendingWindowSwitch = null;
1066
1130
  pendingWindowRename = null;
@@ -1074,6 +1138,9 @@ export function createApplicationTerminalInteractionController(
1074
1138
  inputAuthorityIdentity = next;
1075
1139
  },
1076
1140
  adoptLayout(snapshot) {
1141
+ selectionLayout = snapshot;
1142
+ observedLayoutEpoch = snapshot.rendererEpoch ?? null;
1143
+ selectionWaiter.reconcile();
1077
1144
  reconcileLinkSelection(snapshot);
1078
1145
  paneInput.adoptCanonicalPane(snapshot.current ? paneForWindow(snapshot.current) : null);
1079
1146
  const currentWindow = snapshot.current?.semanticWindowId ?? snapshot.current?.windowName;
@@ -1165,6 +1232,7 @@ export function createApplicationTerminalInteractionController(
1165
1232
  selectPane: (paneId) => {
1166
1233
  finishLinkSelection(false);
1167
1234
  paneInput.selectPane(paneId);
1235
+ selectionWaiter.reconcile();
1168
1236
  },
1169
1237
  async unlinkWindowLink(target) {
1170
1238
  const expected = liveSelectionTarget();
@@ -1303,10 +1371,25 @@ export function createApplicationTerminalInteractionController(
1303
1371
  }
1304
1372
  },
1305
1373
  sendInput: async (input, parserOrigin) => {
1306
- await paneInput.sendInput({ input, parserOrigin });
1374
+ const probe = rendererProbe();
1375
+ const state = probe
1376
+ ? (options.generation()?.fastLane?.lane.paneState(paneInput.focusedPane ?? "") ?? null)
1377
+ : null;
1378
+ const measured = probe?.admit(input, state, nowMicros());
1379
+ const sent = await paneInput.sendInput({ input: measured ?? input, parserOrigin });
1380
+ if (measured && !sent) probe?.cancel("dispatch-rejected", nowMicros());
1381
+ },
1382
+ sendInputToPane: async (paneId, input, parserOrigin) => {
1383
+ const probe = rendererProbe();
1384
+ const state = probe ? (options.generation()?.fastLane?.lane.paneState(paneId) ?? null) : null;
1385
+ const measured = probe?.admit(input, state, nowMicros());
1386
+ const sent = await paneInput.sendInputToPane(paneId, {
1387
+ input: measured ?? input,
1388
+ parserOrigin,
1389
+ });
1390
+ if (measured && !sent) probe?.cancel("dispatch-rejected", nowMicros());
1391
+ return sent;
1307
1392
  },
1308
- sendInputToPane: (paneId, input, parserOrigin) =>
1309
- paneInput.sendInputToPane(paneId, { input, parserOrigin }),
1310
1393
  beginResizePointerIngress(input) {
1311
1394
  if (!options.diagnosticsEnabled) return null;
1312
1395
  const atMicros = diagnosticNowMicros();
@@ -1,3 +1,4 @@
1
+ import { rendererProbe } from "./renderer-probe.ts";
1
2
  import { paneInteractionDisplayDestination } from "../ui/pane-interaction-presentation.ts";
2
3
  import type { InteractionObservationStatus, NativePaneIdentity } from "@tmux-ide/contracts";
3
4
  import { interactionPaneEndpointKey } from "@tmux-ide/core";
@@ -1350,6 +1351,7 @@ export function ApplicationTerminalWorkspace(props: ApplicationTerminalWorkspace
1350
1351
  return;
1351
1352
  }
1352
1353
  if (event.type === "scroll") {
1354
+ const probeAt = rendererProbe() ? Math.floor(performance.now() * 1000) : 0;
1353
1355
  // A held selection owns the gesture even when the pointer crosses a sibling.
1354
1356
  const activeSelection = selecting;
1355
1357
  const selectionCell =
@@ -1359,7 +1361,10 @@ export function ApplicationTerminalWorkspace(props: ApplicationTerminalWorkspace
1359
1361
  activeSelection && selectionCell
1360
1362
  ? { frame: activeSelection.frame, ...selectionCell }
1361
1363
  : paneContentAt(point);
1362
- const before = props.onWheelObservation && hit ? scrollback.offset(hit.frame.paneId) : null;
1364
+ const before =
1365
+ (props.onWheelObservation || rendererProbe()) && hit
1366
+ ? scrollback.offset(hit.frame.paneId)
1367
+ : null;
1363
1368
  const observe = (route: string): void => {
1364
1369
  props.onWheelObservation?.({
1365
1370
  paneId: hit?.frame.paneId ?? null,
@@ -1457,6 +1462,20 @@ export function ApplicationTerminalWorkspace(props: ApplicationTerminalWorkspace
1457
1462
  scrollback.seek(hit.frame.paneId, { ...origin, y: next.originY });
1458
1463
  setKeyboardCopy({ ...keyboard, cursor: next.cursor });
1459
1464
  } else scrollback.move(hit.frame.paneId, motion.lines);
1465
+ if (rendererProbe() && identity && before !== null)
1466
+ rendererProbe()?.scroll(
1467
+ {
1468
+ pane: hit.frame.paneId,
1469
+ generation: identity.generation,
1470
+ incarnation: identity.incarnation,
1471
+ cols: snapshot.cols,
1472
+ rows: snapshot.rows,
1473
+ },
1474
+ snapshot,
1475
+ before,
1476
+ scrollback.offset(hit.frame.paneId),
1477
+ probeAt,
1478
+ );
1460
1479
  observe("local-history");
1461
1480
  if (selecting?.paneId === hit.frame.paneId) updatePointerSelection(selecting.pointer);
1462
1481
  else {
@@ -560,7 +560,7 @@ export function createOpenTuiGenerationHost(
560
560
  // desired state against the fresh socket on the following live publish.
561
561
  publish({ ...snapshot, status: "rebinding" });
562
562
  owner.stopPresentation?.();
563
- owner.stopPresentation = runtime ? presentation.adopt(runtime) : null;
563
+ owner.stopPresentation = runtime ? presentation.adopt(runtime, rendererEpoch + 1) : null;
564
564
  owner.runtime = runtime;
565
565
  owner.authorityClient = runtime ? runtimeAuthorityClient(owner.bundle, runtime) : null;
566
566
  if (!runtime) presentation.clear();
@@ -580,7 +580,7 @@ export function createOpenTuiGenerationHost(
580
580
  }
581
581
  if (candidate !== owner) return;
582
582
  if (runtime) {
583
- owner.stopPresentation = presentation.adopt(runtime);
583
+ owner.stopPresentation = presentation.adopt(runtime, rendererEpoch + 1);
584
584
  diagnose?.("host-internal-snapshot-publication", {
585
585
  publicationPhase: "presentation-adopted",
586
586
  daemonGeneration: owner.bundle.connection.target.daemon.instanceId,