tmux-ide 2.9.0-beta.21 → 2.9.0-beta.22

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 (173) hide show
  1. package/bin/cli.js +52253 -49070
  2. package/package.json +1 -1
  3. package/packages/contracts/src/__tests__/daemon-wire.test.ts +2 -2
  4. package/packages/contracts/src/__tests__/fixtures/daemon-wire-v3.json +35 -0
  5. package/packages/contracts/src/__tests__/issue-error.test.ts +1 -1
  6. package/packages/contracts/src/__tests__/multiplexer-verbs.test.ts +3 -1
  7. package/packages/contracts/src/__tests__/pane-stream.test.ts +17 -5
  8. package/packages/contracts/src/__tests__/tmux-server-scope.test.ts +166 -0
  9. package/packages/contracts/src/__tests__/window-links.test.ts +243 -0
  10. package/packages/contracts/src/actions-contract.ts +12 -0
  11. package/packages/contracts/src/daemon-resource-request.test.ts +1 -1
  12. package/packages/contracts/src/daemon-wire.ts +10 -5
  13. package/packages/contracts/src/fleet-client-state.ts +3 -0
  14. package/packages/contracts/src/index.ts +2 -0
  15. package/packages/contracts/src/interaction-receipts.ts +39 -0
  16. package/packages/contracts/src/issue-error.ts +1 -1
  17. package/packages/contracts/src/multiplexer-verbs.ts +25 -1
  18. package/packages/contracts/src/pane-stream.ts +51 -8
  19. package/packages/contracts/src/tmux-server-scope.ts +172 -0
  20. package/packages/contracts/src/window-links.ts +76 -0
  21. package/packages/contracts/src/workspace-multiplexer.ts +54 -0
  22. package/packages/core/src/interaction-receipts.ts +4 -0
  23. package/packages/daemon/dist/command-center/actions/command-definitions.js +6 -0
  24. package/packages/daemon/dist/command-center/actions/handlers/workspace-multiplexer.js +2 -0
  25. package/packages/daemon/dist/command-center/actions/registry.js +13 -1
  26. package/packages/daemon/dist/command-center/discovery.js +2 -9
  27. package/packages/daemon/dist/command-center/server.js +21 -1
  28. package/packages/daemon/dist/command-center/tmux-server-native-backing.js +37 -0
  29. package/packages/daemon/dist/command-center/tmux-servers.js +345 -0
  30. package/packages/daemon/dist/lib/canonical-daemon-bootstrap.js +64 -8
  31. package/packages/daemon/dist/lib/canonical-daemon.js +5 -2
  32. package/packages/daemon/dist/lib/daemon-embed.js +198 -30
  33. package/packages/daemon/dist/lib/embedded-tmux-server-owners.js +147 -0
  34. package/packages/daemon/dist/lib/fleet-lifecycle-authority.js +16 -10
  35. package/packages/daemon/dist/lib/headless-daemon.js +10 -1
  36. package/packages/daemon/dist/lib/runtime-namespace.js +17 -3
  37. package/packages/daemon/dist/lib/tmux-client-execution.js +59 -0
  38. package/packages/daemon/dist/lib/tmux-named-socket-fence.js +4 -0
  39. package/packages/daemon/dist/lib/tmux-server-generation-runner.js +62 -0
  40. package/packages/daemon/dist/lib/tmux-server-owner.js +285 -0
  41. package/packages/daemon/dist/lib/tmux-server-owners.js +192 -0
  42. package/packages/daemon/dist/lib/tmux-server-proof.js +82 -0
  43. package/packages/daemon/dist/lib/tmux-server-registration.js +85 -0
  44. package/packages/daemon/dist/lib/tmux-server-session-create.js +43 -0
  45. package/packages/daemon/dist/lib/tmux-server-session-open.js +101 -0
  46. package/packages/daemon/dist/lib/tmux-servers-cli.js +134 -0
  47. package/packages/daemon/dist/lib/tmux-session-mutation-fence.js +55 -0
  48. package/packages/daemon/dist/lib/tmux-window-link-guard.js +88 -0
  49. package/packages/daemon/dist/lib/workspace-config-loader.js +3 -0
  50. package/packages/daemon/dist/lib/workspace-multiplexer-verbs.js +82 -1
  51. package/packages/daemon/dist/lib/workspace-pane-creation.js +13 -57
  52. package/packages/daemon/dist/lib/workspace-promotion.js +91 -12
  53. package/packages/daemon/dist/lib/workspace-registry.js +4 -0
  54. package/packages/daemon/dist/server/pane-stream-upgrade.js +20 -6
  55. package/packages/daemon/dist/terminal/attachments/native-runtime.js +61 -17
  56. package/packages/daemon/dist/terminal/mirror/__tests__/scripted-channel.js +9 -1
  57. package/packages/daemon/dist/terminal/mirror/__tests__/simulated-channel.js +11 -2
  58. package/packages/daemon/dist/terminal/mirror/control-channel.js +65 -6
  59. package/packages/daemon/dist/terminal/mirror/mirror-service.js +44 -0
  60. package/packages/daemon/dist/terminal/mirror/session-channel.js +210 -50
  61. package/packages/daemon/dist/terminal/mirror/window-link-authority.js +160 -0
  62. package/packages/daemon/dist/terminal/pane-stream/lease-manager.js +3 -3
  63. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +40 -6
  64. package/packages/daemon/dist/terminal/protocol/live-session-identity.js +9 -0
  65. package/packages/daemon/dist/terminal/protocol/native-backing-client.js +2 -1
  66. package/packages/daemon/dist/terminal/session-runtime/interaction-receipt-facts.js +9 -0
  67. package/packages/daemon/dist/terminal/session-runtime/registry.js +6 -0
  68. package/packages/daemon/dist/terminal/session-runtime/semantic-mutation-executor.js +1 -1
  69. package/packages/daemon/dist/terminal/session-runtime/semantic-mutation-resource-changes.js +1 -0
  70. package/packages/daemon/dist/tui/mirror/application-shell-daemon-connection.js +7 -2
  71. package/packages/daemon/dist/tui/mirror/application-shell-server-connection.js +114 -0
  72. package/packages/daemon/dist/tui/mirror/menu-model.js +1 -1
  73. package/packages/daemon/dist/tui/mirror/open-tui-verified-routing.js +34 -4
  74. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +29 -8
  75. package/packages/daemon/dist/tui/mirror/runtime/application-home-catalog-owner.js +11 -5
  76. package/packages/daemon/dist/tui/mirror/runtime/application-home-catalog.js +140 -75
  77. package/packages/daemon/dist/tui/mirror/runtime/application-machine-catalog.js +12 -2
  78. package/packages/daemon/dist/tui/mirror/runtime/application-machine-navigation.js +190 -31
  79. package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +91 -37
  80. package/packages/daemon/dist/tui/mirror/runtime/application-root-configuration.js +9 -3
  81. package/packages/daemon/dist/tui/mirror/runtime/application-route-connection.js +95 -2
  82. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +158 -2
  83. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace-policy.js +43 -0
  84. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +78 -25
  85. package/packages/daemon/dist/tui/mirror/runtime/fleet-lifecycle-client.js +42 -1
  86. package/packages/daemon/dist/tui/mirror/runtime/open-tui-generation-host.js +1 -1
  87. package/packages/daemon/dist/tui/mirror/runtime/select-terminal-pane.js +2 -1
  88. package/packages/daemon/dist/tui/mirror/runtime/terminal-pane-input-router.js +6 -3
  89. package/packages/daemon/dist/tui/mirror/workspace/application-command-description.js +11 -2
  90. package/packages/daemon/dist/tui/mirror/workspace/terminal-window-strip.jsx +2 -1
  91. package/packages/daemon/src/command-center/actions/command-definitions.ts +6 -0
  92. package/packages/daemon/src/command-center/actions/errors.ts +4 -0
  93. package/packages/daemon/src/command-center/actions/handlers/workspace-multiplexer.ts +2 -0
  94. package/packages/daemon/src/command-center/actions/registry.ts +14 -0
  95. package/packages/daemon/src/command-center/discovery.ts +2 -10
  96. package/packages/daemon/src/command-center/server.ts +29 -1
  97. package/packages/daemon/src/command-center/tmux-server-native-backing.ts +49 -0
  98. package/packages/daemon/src/command-center/tmux-servers.ts +468 -0
  99. package/packages/daemon/src/lib/canonical-daemon-bootstrap.ts +111 -4
  100. package/packages/daemon/src/lib/canonical-daemon.ts +15 -3
  101. package/packages/daemon/src/lib/daemon-embed.ts +241 -28
  102. package/packages/daemon/src/lib/embedded-tmux-server-owners.ts +190 -0
  103. package/packages/daemon/src/lib/fleet-lifecycle-authority.ts +16 -8
  104. package/packages/daemon/src/lib/headless-daemon.ts +18 -1
  105. package/packages/daemon/src/lib/runtime-namespace.ts +24 -3
  106. package/packages/daemon/src/lib/tmux-client-execution.ts +56 -0
  107. package/packages/daemon/src/lib/tmux-named-socket-fence.ts +5 -0
  108. package/packages/daemon/src/lib/tmux-server-generation-runner.ts +81 -0
  109. package/packages/daemon/src/lib/tmux-server-owner.ts +336 -0
  110. package/packages/daemon/src/lib/tmux-server-owners.ts +228 -0
  111. package/packages/daemon/src/lib/tmux-server-proof.ts +95 -0
  112. package/packages/daemon/src/lib/tmux-server-registration.ts +105 -0
  113. package/packages/daemon/src/lib/tmux-server-session-create.ts +52 -0
  114. package/packages/daemon/src/lib/tmux-server-session-open.ts +106 -0
  115. package/packages/daemon/src/lib/tmux-servers-cli.ts +186 -0
  116. package/packages/daemon/src/lib/tmux-session-mutation-fence.ts +62 -0
  117. package/packages/daemon/src/lib/tmux-window-link-guard.ts +112 -0
  118. package/packages/daemon/src/lib/workspace-config-loader.ts +3 -0
  119. package/packages/daemon/src/lib/workspace-multiplexer-verbs.ts +121 -1
  120. package/packages/daemon/src/lib/workspace-pane-creation.ts +16 -55
  121. package/packages/daemon/src/lib/workspace-promotion.ts +98 -12
  122. package/packages/daemon/src/lib/workspace-registry.ts +5 -0
  123. package/packages/daemon/src/server/pane-stream-upgrade.ts +25 -5
  124. package/packages/daemon/src/terminal/attachments/native-runtime.ts +70 -16
  125. package/packages/daemon/src/terminal/mirror/control-channel.ts +64 -7
  126. package/packages/daemon/src/terminal/mirror/events.ts +2 -0
  127. package/packages/daemon/src/terminal/mirror/mirror-service.ts +56 -0
  128. package/packages/daemon/src/terminal/mirror/session-channel.ts +245 -50
  129. package/packages/daemon/src/terminal/mirror/window-link-authority.ts +203 -0
  130. package/packages/daemon/src/terminal/pane-stream/lease-manager.ts +3 -3
  131. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +50 -6
  132. package/packages/daemon/src/terminal/protocol/live-session-identity.ts +14 -0
  133. package/packages/daemon/src/terminal/protocol/native-backing-client.ts +4 -1
  134. package/packages/daemon/src/terminal/session-runtime/interaction-receipt-facts.ts +9 -0
  135. package/packages/daemon/src/terminal/session-runtime/registry.ts +9 -0
  136. package/packages/daemon/src/terminal/session-runtime/semantic-mutation-executor.ts +2 -2
  137. package/packages/daemon/src/terminal/session-runtime/semantic-mutation-resource-changes.ts +1 -0
  138. package/packages/daemon/src/tui/mirror/application-shell-daemon-connection.ts +28 -2
  139. package/packages/daemon/src/tui/mirror/application-shell-server-connection.ts +154 -0
  140. package/packages/daemon/src/tui/mirror/menu-model.ts +1 -1
  141. package/packages/daemon/src/tui/mirror/open-tui-verified-routing.ts +41 -3
  142. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +47 -6
  143. package/packages/daemon/src/tui/mirror/runtime/application-entry.ts +2 -1
  144. package/packages/daemon/src/tui/mirror/runtime/application-fleet-session-actions.tsx +10 -4
  145. package/packages/daemon/src/tui/mirror/runtime/application-fleet-tabs.ts +2 -0
  146. package/packages/daemon/src/tui/mirror/runtime/application-home-catalog-owner.ts +13 -3
  147. package/packages/daemon/src/tui/mirror/runtime/application-home-catalog.ts +172 -100
  148. package/packages/daemon/src/tui/mirror/runtime/application-machine-catalog.ts +19 -7
  149. package/packages/daemon/src/tui/mirror/runtime/application-machine-navigation.ts +263 -36
  150. package/packages/daemon/src/tui/mirror/runtime/application-machine-overlays.tsx +1 -1
  151. package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +124 -40
  152. package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +3 -0
  153. package/packages/daemon/src/tui/mirror/runtime/application-root-configuration.ts +12 -3
  154. package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +24 -24
  155. package/packages/daemon/src/tui/mirror/runtime/application-route-connection.ts +93 -1
  156. package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +4 -0
  157. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +177 -2
  158. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace-policy.ts +62 -0
  159. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +87 -30
  160. package/packages/daemon/src/tui/mirror/runtime/fleet-lifecycle-client.ts +52 -0
  161. package/packages/daemon/src/tui/mirror/runtime/open-tui-generation-host.ts +1 -1
  162. package/packages/daemon/src/tui/mirror/runtime/select-terminal-pane.ts +3 -0
  163. package/packages/daemon/src/tui/mirror/runtime/terminal-pane-input-router.ts +15 -4
  164. package/packages/daemon/src/tui/mirror/workspace/application-command-description.ts +13 -2
  165. package/packages/daemon/src/tui/mirror/workspace/terminal-window-strip.tsx +5 -1
  166. package/packages/daemon-client/package.json +3 -1
  167. package/packages/daemon-client/src/index.ts +2 -0
  168. package/packages/daemon-client/src/pane-stream-client.test.ts +108 -38
  169. package/packages/daemon-client/src/pane-stream-client.ts +26 -0
  170. package/packages/daemon-client/src/scoped-tmux-server-transport.test.ts +114 -0
  171. package/packages/daemon-client/src/scoped-tmux-server-transport.ts +385 -0
  172. package/packages/daemon-client/src/tmux-server-client.test.ts +279 -0
  173. package/packages/daemon-client/src/tmux-server-client.ts +287 -0
@@ -1,15 +1,108 @@
1
+ import { listTmuxServers, createTmuxServerClient, } from "@tmux-ide/daemon-client/tmux-server-client";
2
+ import { canonicalDaemonUrl } from "../../../lib/canonical-daemon.js";
1
3
  import { fetchCanonicalLiveWorkspaceRouting } from "../canonical-workspace-routing.js";
2
4
  import { prepareOpenTuiApplicationShellConnection } from "../application-shell-daemon-connection.js";
3
5
  import { ensureOpenTuiSessionWorkspaceResult } from "../configless-session-bootstrap.js";
4
6
  /** Every retained tab gets this fixed route, including promotion and reconnect paths. */
5
- export function applicationRouteConnection(handle, expectedLiveSessionId, onDiagnostic) {
7
+ export function applicationRouteConnection(handle, expectedLiveSessionId, onDiagnostic, server) {
8
+ let rootInstanceId = handle.read()?.instanceId;
9
+ let selectedServer = server;
10
+ let refresh = null;
11
+ const refreshAfterDaemonReplacement = async (nextRoot) => {
12
+ if (!selectedServer)
13
+ throw new Error("Select the server session again");
14
+ if (rootInstanceId === nextRoot)
15
+ return selectedServer;
16
+ if (!expectedLiveSessionId)
17
+ throw new Error("Select the server session again");
18
+ if (refresh?.root === nextRoot)
19
+ return refresh.promise;
20
+ const stableServerId = selectedServer.serverId;
21
+ const promise = (async () => {
22
+ const daemon = handle.read();
23
+ const epoch = handle.endpoint()?.epoch;
24
+ if (!daemon?.authToken || daemon.instanceId !== nextRoot)
25
+ throw new Error("Daemon changed during reconnect");
26
+ const options = {
27
+ baseUrl: canonicalDaemonUrl("http", daemon.bindHostname, daemon.port),
28
+ ownerToken: daemon.authToken,
29
+ hostClientId: `opentui-reconnect:${process.pid}`,
30
+ origin: "tmux-ide://opentui",
31
+ };
32
+ const catalog = await listTmuxServers(options);
33
+ const descriptor = catalog.servers.find((candidate) => candidate.serverId === stableServerId);
34
+ if (!descriptor || descriptor.state !== "online")
35
+ throw new Error("Selected tmux server is unavailable");
36
+ const scope = { serverId: descriptor.serverId, generation: descriptor.generation };
37
+ const client = createTmuxServerClient(options, scope);
38
+ try {
39
+ const sessions = await client.sessions();
40
+ if (!sessions.sessions.some((session) => session.liveSessionId === expectedLiveSessionId))
41
+ throw new Error("Selected tmux session was replaced");
42
+ }
43
+ finally {
44
+ client.dispose();
45
+ }
46
+ if (handle.read()?.instanceId !== nextRoot || handle.endpoint()?.epoch !== epoch)
47
+ throw new Error("Daemon changed during reconnect");
48
+ rootInstanceId = nextRoot;
49
+ selectedServer = scope;
50
+ return scope;
51
+ })();
52
+ refresh = { root: nextRoot, promise };
53
+ try {
54
+ return await promise;
55
+ }
56
+ finally {
57
+ if (refresh?.promise === promise)
58
+ refresh = null;
59
+ }
60
+ };
6
61
  return {
7
62
  readDaemon: handle.read,
8
- observeCanonicalGeneration: handle.observe,
63
+ observeCanonicalGeneration: server
64
+ ? async (listener) => {
65
+ let stopped = false;
66
+ let revision = 0;
67
+ const stop = await handle.observe((generation) => {
68
+ const token = ++revision;
69
+ if (!generation) {
70
+ listener(null);
71
+ return;
72
+ }
73
+ if (generation === rootInstanceId) {
74
+ listener(selectedServer.generation);
75
+ return;
76
+ }
77
+ // Retire old input before reading the replacement daemon's registration.
78
+ listener(null);
79
+ void refreshAfterDaemonReplacement(generation).then((scope) => {
80
+ if (!stopped && token === revision && handle.read()?.instanceId === generation)
81
+ listener(scope.generation);
82
+ }, () => {
83
+ if (!stopped && token === revision)
84
+ listener(null);
85
+ });
86
+ });
87
+ return () => {
88
+ stopped = true;
89
+ revision++;
90
+ stop();
91
+ };
92
+ }
93
+ : handle.observe,
9
94
  resolveConnection: async (sessionName) => {
95
+ if (selectedServer) {
96
+ const currentRoot = handle.read()?.instanceId;
97
+ if (!currentRoot)
98
+ return null;
99
+ await refreshAfterDaemonReplacement(currentRoot);
100
+ }
10
101
  const connection = await prepareOpenTuiApplicationShellConnection(sessionName, {
11
102
  ...(onDiagnostic ? { onDiagnostic } : {}),
103
+ ...(selectedServer ? { server: selectedServer, expectedLiveSessionId } : {}),
12
104
  readCanonicalDaemonInfo: handle.read,
105
+ readDaemonEndpoint: handle.endpoint,
13
106
  isCanonicalDaemonAlive: handle.isAlive,
14
107
  ensureSessionWorkspace: (name) => ensureOpenTuiSessionWorkspaceResult(name, {
15
108
  readDaemon: handle.read,
@@ -1,3 +1,5 @@
1
+ import { WorkspaceWindowLinkSelectResultSchemaZ } from "@tmux-ide/contracts";
2
+ import { windowLinkForPane, windowLinkTarget } from "./application-terminal-workspace-policy.js";
1
3
  import { createHash, randomUUID } from "node:crypto";
2
4
  import { currentTuiPerformanceEventSink, } from "../performance-events.js";
3
5
  import { prepareCausalCellFixtureV1 } from "./causal-cell-input-fixture.js";
@@ -354,8 +356,35 @@ export function createApplicationTerminalInteractionController(options) {
354
356
  pendingWindowSwitch = null;
355
357
  }
356
358
  };
359
+ let linkSelection = null;
360
+ const finishLinkSelection = (selected) => {
361
+ const pending = linkSelection;
362
+ if (!pending)
363
+ return;
364
+ linkSelection = null;
365
+ clearTimeout(pending.timer);
366
+ pending.resolve(selected);
367
+ };
368
+ const reconcileLinkSelection = (snapshot) => {
369
+ const pending = linkSelection;
370
+ if (!pending)
371
+ return;
372
+ pending.layout = snapshot;
373
+ const observed = windowLinkTarget(snapshot, pending.target.linkId);
374
+ if (!observed ||
375
+ observed.linkRevision !== pending.target.linkRevision ||
376
+ observed.liveSessionId !== pending.target.liveSessionId ||
377
+ observed.expectedSemanticWindowId !== pending.target.expectedSemanticWindowId) {
378
+ finishLinkSelection(false);
379
+ return;
380
+ }
381
+ if (!pending.received || snapshot.windowLinks?.activeLinkId !== pending.target.linkId)
382
+ return;
383
+ finishLinkSelection(snapshot.current !== null && paneForWindow(snapshot.current) === pending.paneId);
384
+ };
357
385
  const paneInput = new TerminalPaneInputRouter({
358
386
  select: async (paneId) => {
387
+ finishLinkSelection(false);
359
388
  const expected = liveSelectionTarget();
360
389
  const selecting = pendingWindowSwitch;
361
390
  const operationId = selecting?.traceId;
@@ -363,7 +392,7 @@ export function createApplicationTerminalInteractionController(options) {
363
392
  const receipt = expected
364
393
  ? await selectTerminalPane(expected, liveSelectionTarget, paneId, operationId, (failure) => {
365
394
  selectionFailure.current = failure;
366
- })
395
+ }, windowLinkForPane(options.layout(), paneId) ?? undefined)
367
396
  : null;
368
397
  if (selecting && pendingWindowSwitch === selecting) {
369
398
  const receiptAtMicros = options.detailedWindowSwitchTiming ? diagnosticNowMicros() : null;
@@ -540,6 +569,7 @@ export function createApplicationTerminalInteractionController(options) {
540
569
  next.fastLane !== inputAuthorityIdentity.fastLane ||
541
570
  next.adapter !== inputAuthorityIdentity.adapter);
542
571
  if (next === null || replaced) {
572
+ finishLinkSelection(false);
543
573
  paneInput.invalidateSelection();
544
574
  pendingWindowSwitch = null;
545
575
  pendingWindowRename = null;
@@ -553,6 +583,7 @@ export function createApplicationTerminalInteractionController(options) {
553
583
  inputAuthorityIdentity = next;
554
584
  },
555
585
  adoptLayout(snapshot) {
586
+ reconcileLinkSelection(snapshot);
556
587
  paneInput.adoptCanonicalPane(snapshot.current ? paneForWindow(snapshot.current) : null);
557
588
  const currentWindow = snapshot.current?.semanticWindowId ?? snapshot.current?.windowName;
558
589
  const currentPane = snapshot.current ? paneForWindow(snapshot.current) : null;
@@ -635,7 +666,121 @@ export function createApplicationTerminalInteractionController(options) {
635
666
  maybeRequestWindowSwitchFrame();
636
667
  maybeRequestPaneResizeFrame();
637
668
  },
638
- selectPane: (paneId) => paneInput.selectPane(paneId),
669
+ selectPane: (paneId) => {
670
+ finishLinkSelection(false);
671
+ paneInput.selectPane(paneId);
672
+ },
673
+ async unlinkWindowLink(target) {
674
+ const expected = liveSelectionTarget();
675
+ const observed = windowLinkTarget(options.layout(), target.linkId);
676
+ if (!expected ||
677
+ !observed ||
678
+ observed.liveSessionId !== target.liveSessionId ||
679
+ observed.linkRevision !== target.linkRevision ||
680
+ observed.expectedSemanticWindowId !== target.expectedSemanticWindowId)
681
+ return "Window link changed; reopen its menu";
682
+ try {
683
+ if (!expected.client.ownsRuntimeAuthority?.("input") &&
684
+ !(await expected.client.requestAuthority("input")))
685
+ return "Window unlink requires input control";
686
+ const current = liveSelectionTarget();
687
+ if (current?.client !== expected.client ||
688
+ current.daemonGeneration !== expected.daemonGeneration)
689
+ return "Session changed; reopen the window menu";
690
+ await expected.client.dispatch({
691
+ kind: "semantic-intent",
692
+ intent: {
693
+ verb: "workspace.window.link.unlink",
694
+ workspaceName: expected.workspaceName,
695
+ target,
696
+ },
697
+ });
698
+ return "Window link removed";
699
+ }
700
+ catch {
701
+ return "Window link could not be removed; it may be the last link or have changed";
702
+ }
703
+ },
704
+ async selectWindowLink(target) {
705
+ const expected = liveSelectionTarget();
706
+ const snapshot = options.layout();
707
+ const observed = windowLinkTarget(snapshot, target.linkId);
708
+ const backing = snapshot.windows.find((window) => window.semanticWindowId === target.expectedSemanticWindowId);
709
+ const paneId = backing && paneForWindow(backing);
710
+ if (!expected ||
711
+ !paneId ||
712
+ !observed ||
713
+ observed.liveSessionId !== target.liveSessionId ||
714
+ observed.linkRevision !== target.linkRevision ||
715
+ observed.expectedSemanticWindowId !== target.expectedSemanticWindowId)
716
+ return;
717
+ finishLinkSelection(false);
718
+ let resolve;
719
+ const settled = new Promise((done) => {
720
+ resolve = done;
721
+ });
722
+ const pending = {
723
+ target,
724
+ paneId,
725
+ layout: snapshot,
726
+ received: false,
727
+ resolve,
728
+ timer: setTimeout(() => {
729
+ if (linkSelection === pending)
730
+ finishLinkSelection(false);
731
+ }, 5_000),
732
+ };
733
+ pending.timer.unref?.();
734
+ linkSelection = pending;
735
+ // Register input ordering synchronously, before authority or command awaits.
736
+ paneInput.selectPane(paneId, { presentOptimistically: false, selection: () => settled });
737
+ try {
738
+ if (!expected.client.ownsRuntimeAuthority?.("input") &&
739
+ !(await expected.client.requestAuthority("input"))) {
740
+ if (linkSelection === pending)
741
+ finishLinkSelection(false);
742
+ return;
743
+ }
744
+ const current = liveSelectionTarget();
745
+ if (linkSelection !== pending ||
746
+ current?.client !== expected.client ||
747
+ current.daemonGeneration !== expected.daemonGeneration) {
748
+ if (linkSelection === pending)
749
+ finishLinkSelection(false);
750
+ return;
751
+ }
752
+ const wrapper = (await expected.client.dispatch({
753
+ kind: "semantic-intent",
754
+ intent: {
755
+ verb: "workspace.window.link.select",
756
+ workspaceName: expected.workspaceName,
757
+ target,
758
+ },
759
+ }));
760
+ const result = WorkspaceWindowLinkSelectResultSchemaZ.safeParse(wrapper.result);
761
+ if (linkSelection !== pending)
762
+ return;
763
+ if (wrapper.kind !== "semantic-intent" ||
764
+ !result.success ||
765
+ result.data.operationId !== wrapper.operationId ||
766
+ result.data.daemonInstanceId !== expected.daemonGeneration ||
767
+ result.data.workspaceName !== expected.workspaceName ||
768
+ result.data.target.linkId !== target.linkId ||
769
+ result.data.target.liveSessionId !== target.liveSessionId ||
770
+ result.data.target.linkRevision !== target.linkRevision ||
771
+ result.data.target.expectedSemanticWindowId !== target.expectedSemanticWindowId) {
772
+ finishLinkSelection(false);
773
+ return;
774
+ }
775
+ pending.received = true;
776
+ reconcileLinkSelection(pending.layout);
777
+ await settled;
778
+ }
779
+ catch {
780
+ if (linkSelection === pending)
781
+ finishLinkSelection(false);
782
+ }
783
+ },
639
784
  sendInput: async (input, parserOrigin) => {
640
785
  await paneInput.sendInput({ input, parserOrigin });
641
786
  },
@@ -857,6 +1002,17 @@ export function createApplicationTerminalInteractionController(options) {
857
1002
  paneInput.selectPane(next);
858
1003
  },
859
1004
  cycleWindow() {
1005
+ const topology = options.layout().windowLinks;
1006
+ if (topology) {
1007
+ if (topology.links.length < 2)
1008
+ return;
1009
+ const current = topology.links.findIndex((link) => link.linkId === topology.activeLinkId);
1010
+ const next = topology.links[(current + 1) % topology.links.length];
1011
+ const target = windowLinkTarget(options.layout(), next.linkId);
1012
+ if (target)
1013
+ void this.selectWindowLink(target);
1014
+ return;
1015
+ }
860
1016
  const windows = options.layout().windows;
861
1017
  if (windows.length < 2)
862
1018
  return;
@@ -191,3 +191,46 @@ export function terminalPaneResizePreview(separator, pointer, origin) {
191
191
  }),
192
192
  });
193
193
  }
194
+ /** Resolve only live opaque observations; native indexes are display metadata. */
195
+ export function windowLinkTarget(snapshot, linkId) {
196
+ const topology = snapshot.windowLinks;
197
+ const link = topology?.links.find((candidate) => candidate.linkId === linkId);
198
+ if (!topology || !link)
199
+ return null;
200
+ return {
201
+ liveSessionId: topology.liveSessionId,
202
+ linkRevision: topology.linkRevision,
203
+ linkId: link.linkId,
204
+ expectedSemanticWindowId: link.semanticWindowId,
205
+ };
206
+ }
207
+ /** Pane navigation within the active link must not silently choose a sibling link. */
208
+ export function windowLinkForPane(snapshot, paneId) {
209
+ const backing = snapshot.windows.find((window) => window.panes.some((pane) => pane.pane === paneId));
210
+ const topology = snapshot.windowLinks;
211
+ if (!backing || !topology)
212
+ return null;
213
+ const links = topology.links.filter((link) => link.semanticWindowId === backing.semanticWindowId);
214
+ const link = links.find((candidate) => candidate.linkId === topology.activeLinkId) ??
215
+ (links.length === 1 ? links[0] : undefined);
216
+ return link ? windowLinkTarget(snapshot, link.linkId) : null;
217
+ }
218
+ /** Root composition for backing zoom and link actions, all restoring host focus. */
219
+ export function terminalWindowActionCallbacks(controller, notify, recover, cancelNavigation) {
220
+ return {
221
+ onSelectPane: recover((paneId) => {
222
+ cancelNavigation();
223
+ controller.selectPane(paneId);
224
+ }),
225
+ onZoomPane: recover((paneId) => {
226
+ void controller.zoomPane(paneId).then(notify);
227
+ }),
228
+ onSelectWindowLink: recover((target) => {
229
+ cancelNavigation();
230
+ void controller.selectWindowLink(target);
231
+ }),
232
+ onUnlinkWindowLink: recover((target) => {
233
+ void controller.unlinkWindowLink(target).then(notify);
234
+ }),
235
+ };
236
+ }
@@ -1,3 +1,5 @@
1
+ import { Menu } from "../ui/index.js";
2
+ import { windowLinkTarget } from "./application-terminal-workspace-policy.js";
1
3
  /* @jsxImportSource @opentui/solid */
2
4
  import { clampTerminalViewportOrigin, terminalLiveViewportOrigin, reflowTerminalPosition, } from "../terminal-viewport.js";
3
5
  import { For, Show, createMemo, createEffect, createRenderEffect, createSignal, onCleanup, untrack, } from "solid-js";
@@ -14,7 +16,7 @@ import { PaneTitleBar } from "../workspace/terminal-pane-header.jsx";
14
16
  import { PANE_ACTION_MENU_ITEMS, PaneActionMenu } from "../workspace/pane-action-menu.jsx";
15
17
  import { createApplicationPaneMenuOwner } from "./application-pane-menu-owner.js";
16
18
  import { extractTerminalSelection, terminalMouseActionSupported, terminalGestureLeaseMatches, terminalSelectionCell, terminalMouseInput, } from "./terminal-selection.js";
17
- import { retainedTerminalWindowKey, terminalAgentStatusLabel, terminalPaneDisplayTitle, terminalPaneResizePreview, terminalPaneSeparatorAt, terminalPaneSeparators, terminalWindowAgentIndicator, terminalWindowPane, terminalWindowTitle, } from "./application-terminal-workspace-policy.js";
19
+ import { terminalAgentStatusLabel, terminalPaneDisplayTitle, terminalPaneResizePreview, terminalPaneSeparatorAt, terminalPaneSeparators, terminalWindowAgentIndicator, terminalWindowPane, terminalWindowTitle, } from "./application-terminal-workspace-policy.js";
18
20
  export { terminalAgentStatusLabel, terminalPaneChromeLabel, terminalPaneDisplayTitle, } from "./application-terminal-workspace-policy.js";
19
21
  export function safeApplicationMouseIngressMicros(now = () => performance.now()) {
20
22
  try {
@@ -99,25 +101,54 @@ export function ApplicationTerminalWorkspace(props) {
99
101
  onCleanup(scrollback.dispose);
100
102
  createRenderEffect(() => scrollback.retain(new Set(layout().windows.flatMap((window) => window.panes.flatMap((pane) => (pane.pane ? [pane.pane] : []))))));
101
103
  const visibleFrames = createMemo(() => projectedFrames().filter((frame) => frame.visible));
102
- const retainedWindowIds = createMemo(() => Object.freeze(layout()
103
- .windows.map(retainedTerminalWindowKey)
104
- .filter((id) => id !== null)), undefined, {
105
- equals: (previous, next) => previous.length === next.length && previous.every((id, index) => id === next[index]),
104
+ const [windowLinkMenu, setWindowLinkMenu] = createSignal(null);
105
+ const unlinkWindowTab = () => {
106
+ const target = windowLinkMenu();
107
+ setWindowLinkMenu(null);
108
+ if (target)
109
+ props.onUnlinkWindowLink?.(target);
110
+ };
111
+ createEffect(() => {
112
+ const target = windowLinkMenu();
113
+ const observed = target && windowLinkTarget(layout(), target.linkId);
114
+ if (target &&
115
+ (!observed ||
116
+ observed.liveSessionId !== target.liveSessionId ||
117
+ observed.linkRevision !== target.linkRevision))
118
+ setWindowLinkMenu(null);
119
+ });
120
+ const terminalWindowTabs = createMemo(() => {
121
+ const snapshot = layout();
122
+ if (!snapshot.windowLinks)
123
+ return snapshot.windows.map((window, index) => ({
124
+ index,
125
+ name: terminalWindowTitle(window),
126
+ active: window.currentWindow,
127
+ sync: false,
128
+ semanticWindowId: window.semanticWindowId,
129
+ activePaneId: terminalWindowPane(window),
130
+ disabled: true,
131
+ }));
132
+ return snapshot.windowLinks.links.flatMap((link) => {
133
+ const window = snapshot.windows.find((candidate) => candidate.semanticWindowId === link.semanticWindowId);
134
+ if (!window)
135
+ return [];
136
+ const indicator = terminalWindowAgentIndicator(window, agentIndicators());
137
+ return [
138
+ {
139
+ linkId: link.linkId,
140
+ index: link.displayIndex,
141
+ name: terminalWindowTitle(window),
142
+ active: snapshot.windowLinks?.activeLinkId === link.linkId,
143
+ sync: false,
144
+ semanticWindowId: window.semanticWindowId,
145
+ activePaneId: terminalWindowPane(window),
146
+ status: indicator ? terminalAgentStatusLabel(indicator.activity) : undefined,
147
+ attention: indicator?.attention,
148
+ },
149
+ ];
150
+ });
106
151
  });
107
- const terminalWindowTabs = createMemo(() => retainedWindowIds().map((windowId, index) => {
108
- const window = layout().windows.find((candidate) => retainedTerminalWindowKey(candidate) === windowId);
109
- const indicator = terminalWindowAgentIndicator(window, agentIndicators());
110
- return {
111
- index,
112
- name: terminalWindowTitle(window),
113
- active: window.currentWindow,
114
- sync: false,
115
- semanticWindowId: window.semanticWindowId,
116
- activePaneId: terminalWindowPane(window),
117
- status: indicator ? terminalAgentStatusLabel(indicator.activity) : undefined,
118
- attention: indicator?.attention,
119
- };
120
- }));
121
152
  if (props.onWindowPresented)
122
153
  createRenderEffect(() => {
123
154
  const current = layout().current;
@@ -564,6 +595,15 @@ export function ApplicationTerminalWorkspace(props) {
564
595
  const handlePaneMenuKey = (name, event) => {
565
596
  if (props.interactive === false)
566
597
  return false;
598
+ if (windowLinkMenu()) {
599
+ if (event?.eventType === "release")
600
+ return true;
601
+ if (name === "escape")
602
+ setWindowLinkMenu(null);
603
+ else if (name === "return" || name === "enter" || name === "u")
604
+ unlinkWindowTab();
605
+ return true;
606
+ }
567
607
  if (paneMenu.handleKey(name, event))
568
608
  return true;
569
609
  const focused = props.focusedPane;
@@ -717,7 +757,10 @@ export function ApplicationTerminalWorkspace(props) {
717
757
  }
718
758
  return handlePaneMenuKey(name, event);
719
759
  }, () => props.interactive !== false &&
720
- (drag !== null || paneMenu.ownsInput() || keyboardCopy() !== null), () => {
760
+ (drag !== null ||
761
+ windowLinkMenu() !== null ||
762
+ paneMenu.ownsInput() ||
763
+ keyboardCopy() !== null), () => {
721
764
  // Called only after global shortcuts, copy, and local navigation decline
722
765
  // the event, immediately before terminal key or paste delivery.
723
766
  const paneId = retainedSelectionPane() ?? props.focusedPane;
@@ -736,7 +779,7 @@ export function ApplicationTerminalWorkspace(props) {
736
779
  const routePointer = (event) => {
737
780
  if (event.type === "down")
738
781
  wheelGesture.reset();
739
- if (paneMenu.ownsInput()) {
782
+ if (windowLinkMenu() || paneMenu.ownsInput()) {
740
783
  event.stopPropagation?.();
741
784
  return;
742
785
  }
@@ -1178,10 +1221,17 @@ export function ApplicationTerminalWorkspace(props) {
1178
1221
  <box position="absolute" left={0} top={topOffset() - 1} width={props.width} height={1} backgroundColor={props.theme.roles.surfaces.panel} flexDirection="row" onMouse={routePointer}>
1179
1222
  <Show when={layout().windows.length > 0} fallback={<text fg={props.theme.roles.text.muted}> no terminal windows </text>}>
1180
1223
  <TerminalWindowStrip theme={props.theme} width={props.width} tabs={terminalWindowTabs} hoveredIndex={null} onActivate={(index) => {
1181
- const pane = terminalWindowTabs()[index]?.activePaneId;
1182
- if (pane)
1183
- props.onSelectPane(pane);
1184
- }} onNewWindow={() => props.onCreateWindow?.()}/>
1224
+ const tab = terminalWindowTabs().find((candidate) => candidate.index === index);
1225
+ const target = tab?.linkId ? windowLinkTarget(layout(), tab.linkId) : null;
1226
+ if (target)
1227
+ props.onSelectWindowLink?.(target);
1228
+ }} onWindowActions={props.onUnlinkWindowLink
1229
+ ? (index) => {
1230
+ const tab = terminalWindowTabs().find((candidate) => candidate.index === index);
1231
+ if (tab?.linkId)
1232
+ setWindowLinkMenu(windowLinkTarget(layout(), tab.linkId));
1233
+ }
1234
+ : undefined} onNewWindow={() => props.onCreateWindow?.()}/>
1185
1235
  </Show>
1186
1236
  </box>
1187
1237
  <For each={retainedPaneIds()}>
@@ -1244,6 +1294,9 @@ export function ApplicationTerminalWorkspace(props) {
1244
1294
  </Show>
1245
1295
  </box>)}
1246
1296
  </For>
1297
+ <Show when={windowLinkMenu()}>
1298
+ <Menu theme={props.theme} title="Window link" left={0} top={1} width={Math.min(36, props.width)} viewportWidth={props.width} viewportHeight={props.height} selectedId="unlink" items={[{ id: "unlink", label: "Unlink this tab", shortcut: "U" }]} footer="Other links keep their panes" onDismiss={() => setWindowLinkMenu(null)} onSelect={unlinkWindowTab}/>
1299
+ </Show>
1247
1300
  <Show when={paneContextMenu()}>
1248
1301
  {(menu) => (<PaneActionMenu theme={props.theme} left={menu().left} top={menu().top} width={paneContextMenuWidth()} viewportWidth={props.width} viewportHeight={props.height + topOffset()} paneTitle={menu().displayName} active onDismiss={paneMenu.dismiss} selectedId={menu().selectedId} closeArmed={menu().closeArmed} onHighlight={paneMenu.highlight} onActionIntent={paneMenu.activate}/>)}
1249
1302
  </Show>
@@ -1,3 +1,4 @@
1
+ import { createTmuxServerClient } from "@tmux-ide/daemon-client/tmux-server-client";
1
2
  import { applicationDaemonEndpoint, readApplicationDaemonInfo as readCanonicalDaemonInfo, } from "./application-daemon-authority.js";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { dispatchOwnerAction } from "@tmux-ide/daemon-client/owner-action-client";
@@ -43,11 +44,27 @@ async function dispatchOnHandle(handle, name, input) {
43
44
  ? result
44
45
  : null;
45
46
  }
46
- export function createFleetSession(input, name) {
47
+ export function createFleetSession(input, name, server) {
47
48
  if ("read" in input) {
48
49
  const daemon = input.read();
49
50
  if (!daemon || !name)
50
51
  return Promise.resolve(null);
52
+ if (server) {
53
+ if (!daemon.authToken || input.endpoint().state !== "ready")
54
+ return Promise.resolve(null);
55
+ const epoch = input.endpoint().epoch;
56
+ const baseUrl = canonicalDaemonUrl("http", daemon.bindHostname, daemon.port);
57
+ const client = createTmuxServerClient({ baseUrl, ownerToken: daemon.authToken, hostClientId: HOST_CLIENT_ID, origin: baseUrl }, server);
58
+ return client
59
+ .createSession(randomUUID(), {
60
+ displayName: name,
61
+ expectedDaemonInstanceId: server.generation,
62
+ })
63
+ .then((result) => input.endpoint().epoch === epoch && input.read()?.instanceId === daemon.instanceId
64
+ ? result
65
+ : null)
66
+ .finally(() => client.dispose());
67
+ }
51
68
  return dispatchOnHandle(input, "workspace.session.create", {
52
69
  displayName: name,
53
70
  expectedDaemonInstanceId: daemon.instanceId,
@@ -59,6 +76,30 @@ export function createFleetSession(input, name) {
59
76
  export function closeFleetSession(handle, target) {
60
77
  if (handle.read()?.instanceId !== target.daemonInstanceId)
61
78
  return Promise.resolve(null);
79
+ if (target.server) {
80
+ const daemon = handle.read();
81
+ if (!daemon?.authToken || handle.endpoint().state !== "ready")
82
+ return Promise.resolve(null);
83
+ const epoch = handle.endpoint().epoch;
84
+ const baseUrl = canonicalDaemonUrl("http", daemon.bindHostname, daemon.port);
85
+ const client = createTmuxServerClient({ baseUrl, ownerToken: daemon.authToken, hostClientId: HOST_CLIENT_ID, origin: baseUrl }, target.server);
86
+ return client
87
+ .mutate(randomUUID(), {
88
+ verb: "workspace.session.kill",
89
+ workspaceName: target.workspaceName ?? target.sessionName,
90
+ fleetTarget: {
91
+ daemonInstanceId: target.server.generation,
92
+ liveSessionId: target.liveSessionId,
93
+ sessionName: target.sessionName,
94
+ },
95
+ })
96
+ .then((result) => handle.endpoint().epoch === epoch &&
97
+ handle.read()?.instanceId === target.daemonInstanceId &&
98
+ result.verb === "workspace.session.kill"
99
+ ? result
100
+ : null)
101
+ .finally(() => client.dispose());
102
+ }
62
103
  return dispatchOnHandle(handle, "workspace.session.kill", {
63
104
  workspaceName: target.workspaceName ?? target.sessionName,
64
105
  fleetTarget: {
@@ -162,7 +162,7 @@ function buildProductionBundle(connection, callbacks, readDaemon = readCanonical
162
162
  requestTerminalRuntimeInventoryRefresh: () => {
163
163
  connection.transport.refreshTerminalRuntimeInventory();
164
164
  },
165
- actions: productionOwnerActions(readDaemon),
165
+ actions: connection.ownerActions ?? productionOwnerActions(readDaemon),
166
166
  },
167
167
  });
168
168
  fastLane = createOpenTuiWorkspaceTerminalFastLane(client, OPEN_TUI_HOST_CLIENT_ID, causalCellLedger);
@@ -1,5 +1,5 @@
1
1
  /** Acquire controller authority before issuing the canonical pane-select intent. */
2
- export async function selectTerminalPane(expected, current, semanticPaneId, operationId, onFailure) {
2
+ export async function selectTerminalPane(expected, current, semanticPaneId, operationId, onFailure, windowLink) {
3
3
  const fail = (failure) => {
4
4
  try {
5
5
  onFailure?.(failure);
@@ -57,6 +57,7 @@ export async function selectTerminalPane(expected, current, semanticPaneId, oper
57
57
  verb: "workspace.pane.select",
58
58
  workspaceName: expected.workspaceName,
59
59
  semanticPaneId,
60
+ ...(windowLink ? { windowLink } : {}),
60
61
  },
61
62
  });
62
63
  }
@@ -30,15 +30,18 @@ export class TerminalPaneInputRouter {
30
30
  this.#pending = null;
31
31
  }
32
32
  selectPane(paneId, options = {}) {
33
- if (this.#pending?.paneId === paneId)
33
+ if (!options.selection && this.#pending?.paneId === paneId)
34
34
  return;
35
- if (this.#pending === null && this.#canonicalPane === paneId && this.#focusedPane === paneId)
35
+ if (!options.selection &&
36
+ this.#pending === null &&
37
+ this.#canonicalPane === paneId &&
38
+ this.#focusedPane === paneId)
36
39
  return;
37
40
  const token = ++this.#selectionToken;
38
41
  const presentOptimistically = options.presentOptimistically !== false;
39
42
  if (presentOptimistically)
40
43
  this.#setFocusedPane(paneId);
41
- const settled = this.#options.select(paneId);
44
+ const settled = options.selection ? options.selection() : this.#options.select(paneId);
42
45
  this.#pending = { token, paneId, presentOptimistically, settled, selected: null };
43
46
  void settled.then((selected) => {
44
47
  if (this.#pending?.token !== token)
@@ -5,8 +5,14 @@ import { PANE_ACTION_MENU_ITEMS } from "./pane-action-menu-model.js";
5
5
  export function applicationCommandDescription(command) {
6
6
  if (typeof command === "object" && command.kind === "open-machine")
7
7
  return {
8
- id: JSON.stringify([command.kind, command.fleet.machineId]),
9
- label: `Machine · ${command.label}`,
8
+ id: JSON.stringify([
9
+ command.kind,
10
+ command.fleet.machineId,
11
+ ...(command.fleet.server
12
+ ? [command.fleet.server.serverId, command.fleet.server.generation]
13
+ : []),
14
+ ]),
15
+ label: `${command.fleet.server ? "Server" : "Machine"} · ${command.label}`,
10
16
  detail: command.fleet.disabled ? "Unavailable" : "Browse sessions or create on this host",
11
17
  };
12
18
  if (typeof command === "object") {
@@ -16,6 +22,9 @@ export function applicationCommandDescription(command) {
16
22
  ? [
17
23
  command.kind,
18
24
  command.fleet.machineId,
25
+ ...(command.fleet.server
26
+ ? [command.fleet.server.serverId, command.fleet.server.generation]
27
+ : []),
19
28
  command.fleet.liveSessionId,
20
29
  session ? null : command.paneId,
21
30
  ]