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,6 +1,8 @@
1
+ import { WorkspaceWindowLinkSelectResultSchemaZ } from "@tmux-ide/contracts";
2
+ import { windowLinkForPane, windowLinkTarget } from "./application-terminal-workspace-policy.ts";
1
3
  import { createHash, randomUUID } from "node:crypto";
2
4
  import type { Accessor, Setter } from "solid-js";
3
- import type { SessionRuntimeTerminalInput } from "@tmux-ide/contracts";
5
+ import type { WindowLinkTarget, SessionRuntimeTerminalInput } from "@tmux-ide/contracts";
4
6
  import {
5
7
  currentTuiPerformanceEventSink,
6
8
  type TuiTerminalInputOrigin,
@@ -79,6 +81,8 @@ export interface ApplicationTerminalInteractionController {
79
81
  adoptGeneration(snapshot: OpenTuiGenerationHostSnapshot | null): void;
80
82
  adoptLayout(snapshot: OpenTuiWorkspaceLayoutSnapshot): void;
81
83
  selectPane(paneId: string): void;
84
+ selectWindowLink(target: WindowLinkTarget): Promise<void>;
85
+ unlinkWindowLink(target: WindowLinkTarget): Promise<string>;
82
86
  sendInput(
83
87
  input: SessionRuntimeTerminalInput,
84
88
  parserOrigin?: Omit<
@@ -628,6 +632,41 @@ export function createApplicationTerminalInteractionController(
628
632
  }
629
633
  };
630
634
 
635
+ let linkSelection: {
636
+ target: WindowLinkTarget;
637
+ paneId: string;
638
+ layout: OpenTuiWorkspaceLayoutSnapshot;
639
+ received: boolean;
640
+ resolve: (selected: boolean) => void;
641
+ timer: ReturnType<typeof setTimeout>;
642
+ } | null = null;
643
+ const finishLinkSelection = (selected: boolean) => {
644
+ const pending = linkSelection;
645
+ if (!pending) return;
646
+ linkSelection = null;
647
+ clearTimeout(pending.timer);
648
+ pending.resolve(selected);
649
+ };
650
+ const reconcileLinkSelection = (snapshot: OpenTuiWorkspaceLayoutSnapshot) => {
651
+ const pending = linkSelection;
652
+ if (!pending) return;
653
+ pending.layout = snapshot;
654
+ const observed = windowLinkTarget(snapshot, pending.target.linkId);
655
+ if (
656
+ !observed ||
657
+ observed.linkRevision !== pending.target.linkRevision ||
658
+ observed.liveSessionId !== pending.target.liveSessionId ||
659
+ observed.expectedSemanticWindowId !== pending.target.expectedSemanticWindowId
660
+ ) {
661
+ finishLinkSelection(false);
662
+ return;
663
+ }
664
+ if (!pending.received || snapshot.windowLinks?.activeLinkId !== pending.target.linkId) return;
665
+ finishLinkSelection(
666
+ snapshot.current !== null && paneForWindow(snapshot.current) === pending.paneId,
667
+ );
668
+ };
669
+
631
670
  const paneInput = new TerminalPaneInputRouter<{
632
671
  readonly input: SessionRuntimeTerminalInput;
633
672
  readonly parserOrigin?: Omit<
@@ -636,6 +675,7 @@ export function createApplicationTerminalInteractionController(
636
675
  >;
637
676
  }>({
638
677
  select: async (paneId) => {
678
+ finishLinkSelection(false);
639
679
  const expected = liveSelectionTarget();
640
680
  const selecting = pendingWindowSwitch;
641
681
  const operationId = selecting?.traceId;
@@ -649,6 +689,7 @@ export function createApplicationTerminalInteractionController(
649
689
  (failure) => {
650
690
  selectionFailure.current = failure;
651
691
  },
692
+ windowLinkForPane(options.layout(), paneId) ?? undefined,
652
693
  )
653
694
  : null;
654
695
  if (selecting && pendingWindowSwitch === selecting) {
@@ -833,6 +874,7 @@ export function createApplicationTerminalInteractionController(
833
874
  next.fastLane !== inputAuthorityIdentity.fastLane ||
834
875
  next.adapter !== inputAuthorityIdentity.adapter);
835
876
  if (next === null || replaced) {
877
+ finishLinkSelection(false);
836
878
  paneInput.invalidateSelection();
837
879
  pendingWindowSwitch = null;
838
880
  pendingWindowRename = null;
@@ -846,6 +888,7 @@ export function createApplicationTerminalInteractionController(
846
888
  inputAuthorityIdentity = next;
847
889
  },
848
890
  adoptLayout(snapshot) {
891
+ reconcileLinkSelection(snapshot);
849
892
  paneInput.adoptCanonicalPane(snapshot.current ? paneForWindow(snapshot.current) : null);
850
893
  const currentWindow = snapshot.current?.semanticWindowId ?? snapshot.current?.windowName;
851
894
  const currentPane = snapshot.current ? paneForWindow(snapshot.current) : null;
@@ -933,7 +976,130 @@ export function createApplicationTerminalInteractionController(
933
976
  maybeRequestWindowSwitchFrame();
934
977
  maybeRequestPaneResizeFrame();
935
978
  },
936
- selectPane: (paneId) => paneInput.selectPane(paneId),
979
+ selectPane: (paneId) => {
980
+ finishLinkSelection(false);
981
+ paneInput.selectPane(paneId);
982
+ },
983
+ async unlinkWindowLink(target) {
984
+ const expected = liveSelectionTarget();
985
+ const observed = windowLinkTarget(options.layout(), target.linkId);
986
+ if (
987
+ !expected ||
988
+ !observed ||
989
+ observed.liveSessionId !== target.liveSessionId ||
990
+ observed.linkRevision !== target.linkRevision ||
991
+ observed.expectedSemanticWindowId !== target.expectedSemanticWindowId
992
+ )
993
+ return "Window link changed; reopen its menu";
994
+ try {
995
+ if (
996
+ !expected.client.ownsRuntimeAuthority?.("input") &&
997
+ !(await expected.client.requestAuthority("input"))
998
+ )
999
+ return "Window unlink requires input control";
1000
+ const current = liveSelectionTarget();
1001
+ if (
1002
+ current?.client !== expected.client ||
1003
+ current.daemonGeneration !== expected.daemonGeneration
1004
+ )
1005
+ return "Session changed; reopen the window menu";
1006
+ await expected.client.dispatch({
1007
+ kind: "semantic-intent",
1008
+ intent: {
1009
+ verb: "workspace.window.link.unlink",
1010
+ workspaceName: expected.workspaceName,
1011
+ target,
1012
+ },
1013
+ });
1014
+ return "Window link removed";
1015
+ } catch {
1016
+ return "Window link could not be removed; it may be the last link or have changed";
1017
+ }
1018
+ },
1019
+ async selectWindowLink(target) {
1020
+ const expected = liveSelectionTarget();
1021
+ const snapshot = options.layout();
1022
+ const observed = windowLinkTarget(snapshot, target.linkId);
1023
+ const backing = snapshot.windows.find(
1024
+ (window) => window.semanticWindowId === target.expectedSemanticWindowId,
1025
+ );
1026
+ const paneId = backing && paneForWindow(backing);
1027
+ if (
1028
+ !expected ||
1029
+ !paneId ||
1030
+ !observed ||
1031
+ observed.liveSessionId !== target.liveSessionId ||
1032
+ observed.linkRevision !== target.linkRevision ||
1033
+ observed.expectedSemanticWindowId !== target.expectedSemanticWindowId
1034
+ )
1035
+ return;
1036
+ finishLinkSelection(false);
1037
+ let resolve!: (selected: boolean) => void;
1038
+ const settled = new Promise<boolean>((done) => {
1039
+ resolve = done;
1040
+ });
1041
+ const pending = {
1042
+ target,
1043
+ paneId,
1044
+ layout: snapshot,
1045
+ received: false,
1046
+ resolve,
1047
+ timer: setTimeout(() => {
1048
+ if (linkSelection === pending) finishLinkSelection(false);
1049
+ }, 5_000),
1050
+ };
1051
+ pending.timer.unref?.();
1052
+ linkSelection = pending;
1053
+ // Register input ordering synchronously, before authority or command awaits.
1054
+ paneInput.selectPane(paneId, { presentOptimistically: false, selection: () => settled });
1055
+ try {
1056
+ if (
1057
+ !expected.client.ownsRuntimeAuthority?.("input") &&
1058
+ !(await expected.client.requestAuthority("input"))
1059
+ ) {
1060
+ if (linkSelection === pending) finishLinkSelection(false);
1061
+ return;
1062
+ }
1063
+ const current = liveSelectionTarget();
1064
+ if (
1065
+ linkSelection !== pending ||
1066
+ current?.client !== expected.client ||
1067
+ current.daemonGeneration !== expected.daemonGeneration
1068
+ ) {
1069
+ if (linkSelection === pending) finishLinkSelection(false);
1070
+ return;
1071
+ }
1072
+ const wrapper = (await expected.client.dispatch({
1073
+ kind: "semantic-intent",
1074
+ intent: {
1075
+ verb: "workspace.window.link.select",
1076
+ workspaceName: expected.workspaceName,
1077
+ target,
1078
+ },
1079
+ })) as { kind?: string; operationId?: string; result?: unknown };
1080
+ const result = WorkspaceWindowLinkSelectResultSchemaZ.safeParse(wrapper.result);
1081
+ if (linkSelection !== pending) return;
1082
+ if (
1083
+ wrapper.kind !== "semantic-intent" ||
1084
+ !result.success ||
1085
+ result.data.operationId !== wrapper.operationId ||
1086
+ result.data.daemonInstanceId !== expected.daemonGeneration ||
1087
+ result.data.workspaceName !== expected.workspaceName ||
1088
+ result.data.target.linkId !== target.linkId ||
1089
+ result.data.target.liveSessionId !== target.liveSessionId ||
1090
+ result.data.target.linkRevision !== target.linkRevision ||
1091
+ result.data.target.expectedSemanticWindowId !== target.expectedSemanticWindowId
1092
+ ) {
1093
+ finishLinkSelection(false);
1094
+ return;
1095
+ }
1096
+ pending.received = true;
1097
+ reconcileLinkSelection(pending.layout);
1098
+ await settled;
1099
+ } catch {
1100
+ if (linkSelection === pending) finishLinkSelection(false);
1101
+ }
1102
+ },
937
1103
  sendInput: async (input, parserOrigin) => {
938
1104
  await paneInput.sendInput({ input, parserOrigin });
939
1105
  },
@@ -1152,6 +1318,15 @@ export function createApplicationTerminalInteractionController(
1152
1318
  if (next) paneInput.selectPane(next);
1153
1319
  },
1154
1320
  cycleWindow() {
1321
+ const topology = options.layout().windowLinks;
1322
+ if (topology) {
1323
+ if (topology.links.length < 2) return;
1324
+ const current = topology.links.findIndex((link) => link.linkId === topology.activeLinkId);
1325
+ const next = topology.links[(current + 1) % topology.links.length]!;
1326
+ const target = windowLinkTarget(options.layout(), next.linkId);
1327
+ if (target) void this.selectWindowLink(target);
1328
+ return;
1329
+ }
1155
1330
  const windows = options.layout().windows;
1156
1331
  if (windows.length < 2) return;
1157
1332
  const current = windows.findIndex((window) => window.currentWindow);
@@ -1,3 +1,4 @@
1
+ import type { WindowLinkTarget } from "@tmux-ide/contracts";
1
2
  import type { AgentActivity } from "@tmux-ide/contracts";
2
3
 
3
4
  import type { OpenTuiWorkspaceLayoutSnapshot } from "../open-tui-workspace-runtime-port.ts";
@@ -309,3 +310,64 @@ export function terminalPaneResizePreview(
309
310
  }),
310
311
  });
311
312
  }
313
+
314
+ /** Resolve only live opaque observations; native indexes are display metadata. */
315
+ export function windowLinkTarget(
316
+ snapshot: OpenTuiWorkspaceLayoutSnapshot,
317
+ linkId: string,
318
+ ): WindowLinkTarget | null {
319
+ const topology = snapshot.windowLinks;
320
+ const link = topology?.links.find((candidate) => candidate.linkId === linkId);
321
+ if (!topology || !link) return null;
322
+ return {
323
+ liveSessionId: topology.liveSessionId,
324
+ linkRevision: topology.linkRevision,
325
+ linkId: link.linkId,
326
+ expectedSemanticWindowId: link.semanticWindowId,
327
+ };
328
+ }
329
+
330
+ /** Pane navigation within the active link must not silently choose a sibling link. */
331
+ export function windowLinkForPane(
332
+ snapshot: OpenTuiWorkspaceLayoutSnapshot,
333
+ paneId: string,
334
+ ): WindowLinkTarget | null {
335
+ const backing = snapshot.windows.find((window) =>
336
+ window.panes.some((pane) => pane.pane === paneId),
337
+ );
338
+ const topology = snapshot.windowLinks;
339
+ if (!backing || !topology) return null;
340
+ const links = topology.links.filter((link) => link.semanticWindowId === backing.semanticWindowId);
341
+ const link =
342
+ links.find((candidate) => candidate.linkId === topology.activeLinkId) ??
343
+ (links.length === 1 ? links[0] : undefined);
344
+ return link ? windowLinkTarget(snapshot, link.linkId) : null;
345
+ }
346
+
347
+ /** Root composition for backing zoom and link actions, all restoring host focus. */
348
+ export function terminalWindowActionCallbacks(
349
+ controller: Pick<
350
+ import("./application-terminal-interaction-controller.ts").ApplicationTerminalInteractionController,
351
+ "selectWindowLink" | "unlinkWindowLink" | "zoomPane" | "selectPane"
352
+ >,
353
+ notify: (message: string) => void,
354
+ recover: import("./application-host-focus-presentation.ts").ApplicationHostFocusRecovery,
355
+ cancelNavigation: () => void,
356
+ ) {
357
+ return {
358
+ onSelectPane: recover((paneId: string) => {
359
+ cancelNavigation();
360
+ controller.selectPane(paneId);
361
+ }),
362
+ onZoomPane: recover((paneId?: string) => {
363
+ void controller.zoomPane(paneId).then(notify);
364
+ }),
365
+ onSelectWindowLink: recover((target: WindowLinkTarget) => {
366
+ cancelNavigation();
367
+ void controller.selectWindowLink(target);
368
+ }),
369
+ onUnlinkWindowLink: recover((target: WindowLinkTarget) => {
370
+ void controller.unlinkWindowLink(target).then(notify);
371
+ }),
372
+ };
373
+ }
@@ -1,3 +1,6 @@
1
+ import { Menu } from "../ui/index.ts";
2
+ import type { WindowLinkTarget } from "@tmux-ide/contracts";
3
+ import { windowLinkTarget } from "./application-terminal-workspace-policy.ts";
1
4
  import type { PaneInteractionProjection } from "@tmux-ide/core";
2
5
  /* @jsxImportSource @opentui/solid */
3
6
  import {
@@ -61,7 +64,6 @@ import {
61
64
  type TerminalSelectionRange,
62
65
  } from "./terminal-selection.ts";
63
66
  import {
64
- retainedTerminalWindowKey,
65
67
  terminalAgentStatusLabel,
66
68
  terminalPaneDisplayTitle,
67
69
  terminalPaneResizePreview,
@@ -168,6 +170,8 @@ export interface ApplicationTerminalWorkspaceProps {
168
170
  /** Daemon-authored semantic agent state, keyed by durable pane identity. */
169
171
  readonly agentIndicators?: Accessor<ReadonlyMap<string, ApplicationTerminalAgentIndicator>>;
170
172
  readonly onSelectPane: (paneId: string) => void;
173
+ readonly onSelectWindowLink?: (target: WindowLinkTarget) => void;
174
+ readonly onUnlinkWindowLink?: (target: WindowLinkTarget) => void;
171
175
  readonly onCreateWindow?: () => void;
172
176
  readonly onPaneContextAction?: (
173
177
  paneId: string,
@@ -332,37 +336,56 @@ export function ApplicationTerminalWorkspace(props: ApplicationTerminalWorkspace
332
336
  ),
333
337
  );
334
338
  const visibleFrames = createMemo(() => projectedFrames().filter((frame) => frame.visible));
335
- const retainedWindowIds = createMemo(
336
- () =>
337
- Object.freeze(
338
- layout()
339
- .windows.map(retainedTerminalWindowKey)
340
- .filter((id): id is string => id !== null),
341
- ),
342
- undefined,
343
- {
344
- equals: (previous, next) =>
345
- previous.length === next.length && previous.every((id, index) => id === next[index]),
346
- },
347
- );
348
- const terminalWindowTabs = createMemo<readonly TerminalWindowTab[]>(() =>
349
- retainedWindowIds().map((windowId, index) => {
350
- const window = layout().windows.find(
351
- (candidate) => retainedTerminalWindowKey(candidate) === windowId,
352
- )!;
353
- const indicator = terminalWindowAgentIndicator(window, agentIndicators());
354
- return {
339
+ const [windowLinkMenu, setWindowLinkMenu] = createSignal<WindowLinkTarget | null>(null);
340
+ const unlinkWindowTab = () => {
341
+ const target = windowLinkMenu();
342
+ setWindowLinkMenu(null);
343
+ if (target) props.onUnlinkWindowLink?.(target);
344
+ };
345
+ createEffect(() => {
346
+ const target = windowLinkMenu();
347
+ const observed = target && windowLinkTarget(layout(), target.linkId);
348
+ if (
349
+ target &&
350
+ (!observed ||
351
+ observed.liveSessionId !== target.liveSessionId ||
352
+ observed.linkRevision !== target.linkRevision)
353
+ )
354
+ setWindowLinkMenu(null);
355
+ });
356
+ const terminalWindowTabs = createMemo<readonly TerminalWindowTab[]>(() => {
357
+ const snapshot = layout();
358
+ if (!snapshot.windowLinks)
359
+ return snapshot.windows.map((window, index) => ({
355
360
  index,
356
361
  name: terminalWindowTitle(window),
357
362
  active: window.currentWindow,
358
363
  sync: false,
359
364
  semanticWindowId: window.semanticWindowId,
360
365
  activePaneId: terminalWindowPane(window),
361
- status: indicator ? terminalAgentStatusLabel(indicator.activity) : undefined,
362
- attention: indicator?.attention,
363
- };
364
- }),
365
- );
366
+ disabled: true,
367
+ }));
368
+ return snapshot.windowLinks.links.flatMap((link) => {
369
+ const window = snapshot.windows.find(
370
+ (candidate) => candidate.semanticWindowId === link.semanticWindowId,
371
+ );
372
+ if (!window) return [];
373
+ const indicator = terminalWindowAgentIndicator(window, agentIndicators());
374
+ return [
375
+ {
376
+ linkId: link.linkId,
377
+ index: link.displayIndex,
378
+ name: terminalWindowTitle(window),
379
+ active: snapshot.windowLinks?.activeLinkId === link.linkId,
380
+ sync: false,
381
+ semanticWindowId: window.semanticWindowId,
382
+ activePaneId: terminalWindowPane(window),
383
+ status: indicator ? terminalAgentStatusLabel(indicator.activity) : undefined,
384
+ attention: indicator?.attention,
385
+ },
386
+ ];
387
+ });
388
+ });
366
389
  if (props.onWindowPresented)
367
390
  createRenderEffect(() => {
368
391
  const current = layout().current;
@@ -911,6 +934,12 @@ export function ApplicationTerminalWorkspace(props: ApplicationTerminalWorkspace
911
934
  props.onSelectionCopyOwner?.(copySelection);
912
935
  const handlePaneMenuKey: PaneMenuKeyHandler = (name, event) => {
913
936
  if (props.interactive === false) return false;
937
+ if (windowLinkMenu()) {
938
+ if (event?.eventType === "release") return true;
939
+ if (name === "escape") setWindowLinkMenu(null);
940
+ else if (name === "return" || name === "enter" || name === "u") unlinkWindowTab();
941
+ return true;
942
+ }
914
943
  if (paneMenu.handleKey(name, event)) return true;
915
944
  const focused = props.focusedPane;
916
945
  const keyboard = keyboardCopy();
@@ -1097,7 +1126,10 @@ export function ApplicationTerminalWorkspace(props: ApplicationTerminalWorkspace
1097
1126
  },
1098
1127
  () =>
1099
1128
  props.interactive !== false &&
1100
- (drag !== null || paneMenu.ownsInput() || keyboardCopy() !== null),
1129
+ (drag !== null ||
1130
+ windowLinkMenu() !== null ||
1131
+ paneMenu.ownsInput() ||
1132
+ keyboardCopy() !== null),
1101
1133
  () => {
1102
1134
  // Called only after global shortcuts, copy, and local navigation decline
1103
1135
  // the event, immediately before terminal key or paste delivery.
@@ -1116,7 +1148,7 @@ export function ApplicationTerminalWorkspace(props: ApplicationTerminalWorkspace
1116
1148
  // listener and the typed listener on a target, even after stopPropagation.
1117
1149
  const routePointer = (event: WorkspaceMouseEvent): void => {
1118
1150
  if (event.type === "down") wheelGesture.reset();
1119
- if (paneMenu.ownsInput()) {
1151
+ if (windowLinkMenu() || paneMenu.ownsInput()) {
1120
1152
  event.stopPropagation?.();
1121
1153
  return;
1122
1154
  }
@@ -1649,9 +1681,18 @@ export function ApplicationTerminalWorkspace(props: ApplicationTerminalWorkspace
1649
1681
  tabs={terminalWindowTabs}
1650
1682
  hoveredIndex={null}
1651
1683
  onActivate={(index) => {
1652
- const pane = terminalWindowTabs()[index]?.activePaneId;
1653
- if (pane) props.onSelectPane(pane);
1684
+ const tab = terminalWindowTabs().find((candidate) => candidate.index === index);
1685
+ const target = tab?.linkId ? windowLinkTarget(layout(), tab.linkId) : null;
1686
+ if (target) props.onSelectWindowLink?.(target);
1654
1687
  }}
1688
+ onWindowActions={
1689
+ props.onUnlinkWindowLink
1690
+ ? (index) => {
1691
+ const tab = terminalWindowTabs().find((candidate) => candidate.index === index);
1692
+ if (tab?.linkId) setWindowLinkMenu(windowLinkTarget(layout(), tab.linkId));
1693
+ }
1694
+ : undefined
1695
+ }
1655
1696
  onNewWindow={() => props.onCreateWindow?.()}
1656
1697
  />
1657
1698
  </Show>
@@ -1805,6 +1846,22 @@ export function ApplicationTerminalWorkspace(props: ApplicationTerminalWorkspace
1805
1846
  </box>
1806
1847
  )}
1807
1848
  </For>
1849
+ <Show when={windowLinkMenu()}>
1850
+ <Menu
1851
+ theme={props.theme}
1852
+ title="Window link"
1853
+ left={0}
1854
+ top={1}
1855
+ width={Math.min(36, props.width)}
1856
+ viewportWidth={props.width}
1857
+ viewportHeight={props.height}
1858
+ selectedId="unlink"
1859
+ items={[{ id: "unlink", label: "Unlink this tab", shortcut: "U" }]}
1860
+ footer="Other links keep their panes"
1861
+ onDismiss={() => setWindowLinkMenu(null)}
1862
+ onSelect={unlinkWindowTab}
1863
+ />
1864
+ </Show>
1808
1865
  <Show when={paneContextMenu()}>
1809
1866
  {(menu) => (
1810
1867
  <PaneActionMenu
@@ -1,3 +1,5 @@
1
+ import { createTmuxServerClient } from "@tmux-ide/daemon-client/tmux-server-client";
2
+ import type { TmuxServerScope } from "@tmux-ide/contracts";
1
3
  import type { ApplicationMachineAuthorityHandle } from "./application-machine-authority.ts";
2
4
  import type { ActionResult, WorkspaceSessionKillArguments } from "@tmux-ide/contracts";
3
5
  import {
@@ -52,6 +54,7 @@ async function dispatch<
52
54
 
53
55
  type FleetHandle = Pick<ApplicationMachineAuthorityHandle, "read" | "endpoint">;
54
56
  export interface FleetSessionCloseTarget {
57
+ readonly server?: TmuxServerScope;
55
58
  readonly daemonInstanceId: string;
56
59
  readonly liveSessionId: string;
57
60
  readonly sessionName: string;
@@ -91,10 +94,31 @@ async function dispatchOnHandle<Name extends "workspace.session.create" | "works
91
94
  export function createFleetSession(
92
95
  input: WorkspaceSessionCreateArguments | FleetHandle,
93
96
  name?: string,
97
+ server?: TmuxServerScope,
94
98
  ): Promise<WorkspaceSessionCreateResult | null> {
95
99
  if ("read" in input) {
96
100
  const daemon = input.read();
97
101
  if (!daemon || !name) return Promise.resolve(null);
102
+ if (server) {
103
+ if (!daemon.authToken || input.endpoint().state !== "ready") return Promise.resolve(null);
104
+ const epoch = input.endpoint().epoch;
105
+ const baseUrl = canonicalDaemonUrl("http", daemon.bindHostname, daemon.port);
106
+ const client = createTmuxServerClient(
107
+ { baseUrl, ownerToken: daemon.authToken, hostClientId: HOST_CLIENT_ID, origin: baseUrl },
108
+ server,
109
+ );
110
+ return client
111
+ .createSession(randomUUID(), {
112
+ displayName: name,
113
+ expectedDaemonInstanceId: server.generation,
114
+ })
115
+ .then((result) =>
116
+ input.endpoint().epoch === epoch && input.read()?.instanceId === daemon.instanceId
117
+ ? result
118
+ : null,
119
+ )
120
+ .finally(() => client.dispose());
121
+ }
98
122
  return dispatchOnHandle(input, "workspace.session.create", {
99
123
  displayName: name,
100
124
  expectedDaemonInstanceId: daemon.instanceId,
@@ -109,6 +133,34 @@ export function closeFleetSession(
109
133
  target: FleetSessionCloseTarget,
110
134
  ): Promise<ActionResult<"workspace.session.kill"> | null> {
111
135
  if (handle.read()?.instanceId !== target.daemonInstanceId) return Promise.resolve(null);
136
+ if (target.server) {
137
+ const daemon = handle.read();
138
+ if (!daemon?.authToken || handle.endpoint().state !== "ready") return Promise.resolve(null);
139
+ const epoch = handle.endpoint().epoch;
140
+ const baseUrl = canonicalDaemonUrl("http", daemon.bindHostname, daemon.port);
141
+ const client = createTmuxServerClient(
142
+ { baseUrl, ownerToken: daemon.authToken, hostClientId: HOST_CLIENT_ID, origin: baseUrl },
143
+ target.server,
144
+ );
145
+ return client
146
+ .mutate(randomUUID(), {
147
+ verb: "workspace.session.kill",
148
+ workspaceName: target.workspaceName ?? target.sessionName,
149
+ fleetTarget: {
150
+ daemonInstanceId: target.server.generation,
151
+ liveSessionId: target.liveSessionId,
152
+ sessionName: target.sessionName,
153
+ },
154
+ })
155
+ .then((result) =>
156
+ handle.endpoint().epoch === epoch &&
157
+ handle.read()?.instanceId === target.daemonInstanceId &&
158
+ result.verb === "workspace.session.kill"
159
+ ? result
160
+ : null,
161
+ )
162
+ .finally(() => client.dispose());
163
+ }
112
164
  return dispatchOnHandle(handle, "workspace.session.kill", {
113
165
  workspaceName: target.workspaceName ?? target.sessionName,
114
166
  fleetTarget: {
@@ -316,7 +316,7 @@ function buildProductionBundle(
316
316
  requestTerminalRuntimeInventoryRefresh: () => {
317
317
  connection.transport.refreshTerminalRuntimeInventory();
318
318
  },
319
- actions: productionOwnerActions(readDaemon),
319
+ actions: connection.ownerActions ?? productionOwnerActions(readDaemon),
320
320
  },
321
321
  });
322
322
  fastLane = createOpenTuiWorkspaceTerminalFastLane(
@@ -1,3 +1,4 @@
1
+ import type { WindowLinkTarget } from "@tmux-ide/contracts";
1
2
  import type { WorkspaceClientDispatch } from "@tmux-ide/daemon-client/workspace-client-types";
2
3
 
3
4
  interface PaneSelectionClient {
@@ -40,6 +41,7 @@ export async function selectTerminalPane(
40
41
  semanticPaneId: string,
41
42
  operationId?: string,
42
43
  onFailure?: (failure: PaneSelectionFailure) => void,
44
+ windowLink?: WindowLinkTarget,
43
45
  ): Promise<PaneSelectionReceipt | null> {
44
46
  const fail = (failure: PaneSelectionFailure): null => {
45
47
  try {
@@ -95,6 +97,7 @@ export async function selectTerminalPane(
95
97
  verb: "workspace.pane.select",
96
98
  workspaceName: expected.workspaceName,
97
99
  semanticPaneId,
100
+ ...(windowLink ? { windowLink } : {}),
98
101
  },
99
102
  });
100
103
  } catch (error) {
@@ -44,14 +44,25 @@ export class TerminalPaneInputRouter<Input> {
44
44
  if (this.#pending?.selected === true) this.#pending = null;
45
45
  }
46
46
 
47
- selectPane(paneId: string, options: { readonly presentOptimistically?: boolean } = {}): void {
48
- if (this.#pending?.paneId === paneId) return;
49
- if (this.#pending === null && this.#canonicalPane === paneId && this.#focusedPane === paneId)
47
+ selectPane(
48
+ paneId: string,
49
+ options: {
50
+ readonly presentOptimistically?: boolean;
51
+ readonly selection?: () => Promise<boolean>;
52
+ } = {},
53
+ ): void {
54
+ if (!options.selection && this.#pending?.paneId === paneId) return;
55
+ if (
56
+ !options.selection &&
57
+ this.#pending === null &&
58
+ this.#canonicalPane === paneId &&
59
+ this.#focusedPane === paneId
60
+ )
50
61
  return;
51
62
  const token = ++this.#selectionToken;
52
63
  const presentOptimistically = options.presentOptimistically !== false;
53
64
  if (presentOptimistically) this.#setFocusedPane(paneId);
54
- const settled = this.#options.select(paneId);
65
+ const settled = options.selection ? options.selection() : this.#options.select(paneId);
55
66
  this.#pending = { token, paneId, presentOptimistically, settled, selected: null };
56
67
  void settled.then((selected) => {
57
68
  if (this.#pending?.token !== token) return;
@@ -1,7 +1,9 @@
1
+ import type { TmuxServerScope } from "@tmux-ide/contracts";
1
2
  import { fuzzyTermsMatch as commandSearchMatch } from "../../team/fuzzy.ts";
2
3
  export { fuzzyTermsMatch as commandSearchMatch } from "../../team/fuzzy.ts";
3
4
  import { PANE_ACTION_MENU_ITEMS } from "./pane-action-menu-model.ts";
4
5
  export interface FleetPaletteTarget {
6
+ readonly server?: TmuxServerScope;
5
7
  readonly machineId: string;
6
8
  readonly liveSessionId: string;
7
9
  readonly hostLabel: string;
@@ -51,8 +53,14 @@ export type ApplicationPaletteCommand =
51
53
  export function applicationCommandDescription(command: ApplicationPaletteCommand) {
52
54
  if (typeof command === "object" && command.kind === "open-machine")
53
55
  return {
54
- id: JSON.stringify([command.kind, command.fleet.machineId]),
55
- label: `Machine · ${command.label}`,
56
+ id: JSON.stringify([
57
+ command.kind,
58
+ command.fleet.machineId,
59
+ ...(command.fleet.server
60
+ ? [command.fleet.server.serverId, command.fleet.server.generation]
61
+ : []),
62
+ ]),
63
+ label: `${command.fleet.server ? "Server" : "Machine"} · ${command.label}`,
56
64
  detail: command.fleet.disabled ? "Unavailable" : "Browse sessions or create on this host",
57
65
  };
58
66
  if (typeof command === "object") {
@@ -63,6 +71,9 @@ export function applicationCommandDescription(command: ApplicationPaletteCommand
63
71
  ? [
64
72
  command.kind,
65
73
  command.fleet.machineId,
74
+ ...(command.fleet.server
75
+ ? [command.fleet.server.serverId, command.fleet.server.generation]
76
+ : []),
66
77
  command.fleet.liveSessionId,
67
78
  session ? null : command.paneId,
68
79
  ]