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

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 (209) hide show
  1. package/bin/cli.js +46791 -43589
  2. package/package.json +2 -2
  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/fleet-lifecycle.ts +5 -0
  15. package/packages/contracts/src/index.ts +2 -0
  16. package/packages/contracts/src/interaction-receipts.ts +39 -0
  17. package/packages/contracts/src/issue-error.ts +1 -1
  18. package/packages/contracts/src/multiplexer-verbs.ts +25 -1
  19. package/packages/contracts/src/pane-stream.ts +51 -8
  20. package/packages/contracts/src/tmux-server-scope.ts +172 -0
  21. package/packages/contracts/src/window-links.ts +76 -0
  22. package/packages/contracts/src/workspace-multiplexer.ts +54 -0
  23. package/packages/core/src/interaction-receipts.ts +4 -0
  24. package/packages/daemon/dist/command-center/actions/command-definitions.js +6 -0
  25. package/packages/daemon/dist/command-center/actions/handlers/workspace-multiplexer.js +2 -0
  26. package/packages/daemon/dist/command-center/actions/registry.js +13 -1
  27. package/packages/daemon/dist/command-center/discovery.js +2 -9
  28. package/packages/daemon/dist/command-center/server.js +21 -1
  29. package/packages/daemon/dist/command-center/tmux-server-native-backing.js +37 -0
  30. package/packages/daemon/dist/command-center/tmux-servers.js +351 -0
  31. package/packages/daemon/dist/lib/canonical-daemon-bootstrap.js +64 -8
  32. package/packages/daemon/dist/lib/canonical-daemon.js +5 -2
  33. package/packages/daemon/dist/lib/daemon-embed.js +198 -30
  34. package/packages/daemon/dist/lib/embedded-tmux-server-owners.js +147 -0
  35. package/packages/daemon/dist/lib/fleet-lifecycle-authority.js +31 -11
  36. package/packages/daemon/dist/lib/headless-daemon.js +10 -1
  37. package/packages/daemon/dist/lib/runtime-namespace.js +17 -3
  38. package/packages/daemon/dist/lib/tmux-client-execution.js +59 -0
  39. package/packages/daemon/dist/lib/tmux-named-socket-fence.js +4 -0
  40. package/packages/daemon/dist/lib/tmux-server-generation-runner.js +62 -0
  41. package/packages/daemon/dist/lib/tmux-server-owner.js +285 -0
  42. package/packages/daemon/dist/lib/tmux-server-owners.js +192 -0
  43. package/packages/daemon/dist/lib/tmux-server-proof.js +82 -0
  44. package/packages/daemon/dist/lib/tmux-server-registration.js +85 -0
  45. package/packages/daemon/dist/lib/tmux-server-session-create.js +43 -0
  46. package/packages/daemon/dist/lib/tmux-server-session-open.js +101 -0
  47. package/packages/daemon/dist/lib/tmux-servers-cli.js +134 -0
  48. package/packages/daemon/dist/lib/tmux-session-mutation-fence.js +55 -0
  49. package/packages/daemon/dist/lib/tmux-window-link-guard.js +88 -0
  50. package/packages/daemon/dist/lib/workspace-config-loader.js +3 -0
  51. package/packages/daemon/dist/lib/workspace-multiplexer-verbs.js +82 -1
  52. package/packages/daemon/dist/lib/workspace-pane-creation.js +13 -57
  53. package/packages/daemon/dist/lib/workspace-promotion.js +91 -12
  54. package/packages/daemon/dist/lib/workspace-registry.js +4 -0
  55. package/packages/daemon/dist/server/pane-stream-upgrade.js +20 -6
  56. package/packages/daemon/dist/terminal/attachments/native-runtime.js +61 -17
  57. package/packages/daemon/dist/terminal/mirror/__tests__/scripted-channel.js +9 -1
  58. package/packages/daemon/dist/terminal/mirror/__tests__/simulated-channel.js +11 -2
  59. package/packages/daemon/dist/terminal/mirror/control-channel.js +65 -6
  60. package/packages/daemon/dist/terminal/mirror/mirror-service.js +44 -0
  61. package/packages/daemon/dist/terminal/mirror/session-channel.js +210 -50
  62. package/packages/daemon/dist/terminal/mirror/window-link-authority.js +160 -0
  63. package/packages/daemon/dist/terminal/pane-stream/lease-manager.js +3 -3
  64. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +40 -6
  65. package/packages/daemon/dist/terminal/protocol/live-session-identity.js +9 -0
  66. package/packages/daemon/dist/terminal/protocol/native-backing-client.js +2 -1
  67. package/packages/daemon/dist/terminal/session-runtime/interaction-receipt-facts.js +9 -0
  68. package/packages/daemon/dist/terminal/session-runtime/registry.js +6 -0
  69. package/packages/daemon/dist/terminal/session-runtime/semantic-mutation-executor.js +1 -1
  70. package/packages/daemon/dist/terminal/session-runtime/semantic-mutation-resource-changes.js +1 -0
  71. package/packages/daemon/dist/tui/mirror/application-shell-daemon-connection.js +7 -2
  72. package/packages/daemon/dist/tui/mirror/application-shell-server-connection.js +114 -0
  73. package/packages/daemon/dist/tui/mirror/menu-model.js +1 -1
  74. package/packages/daemon/dist/tui/mirror/open-tui-verified-routing.js +34 -4
  75. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +29 -8
  76. package/packages/daemon/dist/tui/mirror/runtime/application-guided-tour-integration.jsx +119 -0
  77. package/packages/daemon/dist/tui/mirror/runtime/application-guided-tour-owner.js +132 -0
  78. package/packages/daemon/dist/tui/mirror/runtime/application-home-agent-roster.jsx +21 -9
  79. package/packages/daemon/dist/tui/mirror/runtime/application-home-agent-transport.js +63 -1
  80. package/packages/daemon/dist/tui/mirror/runtime/application-home-agents-owner.js +8 -2
  81. package/packages/daemon/dist/tui/mirror/runtime/application-home-agents.js +3 -1
  82. package/packages/daemon/dist/tui/mirror/runtime/application-home-catalog-owner.js +11 -5
  83. package/packages/daemon/dist/tui/mirror/runtime/application-home-catalog.js +140 -75
  84. package/packages/daemon/dist/tui/mirror/runtime/application-home-experience.js +40 -0
  85. package/packages/daemon/dist/tui/mirror/runtime/application-home-fleet.js +132 -0
  86. package/packages/daemon/dist/tui/mirror/runtime/application-machine-agents.js +9 -0
  87. package/packages/daemon/dist/tui/mirror/runtime/application-machine-catalog.js +12 -2
  88. package/packages/daemon/dist/tui/mirror/runtime/application-machine-navigation.js +211 -31
  89. package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +91 -37
  90. package/packages/daemon/dist/tui/mirror/runtime/application-performance-log.js +20 -7
  91. package/packages/daemon/dist/tui/mirror/runtime/application-root-configuration.js +9 -3
  92. package/packages/daemon/dist/tui/mirror/runtime/application-route-connection.js +95 -2
  93. package/packages/daemon/dist/tui/mirror/runtime/application-shell-home.jsx +6 -2
  94. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +158 -2
  95. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace-policy.js +43 -0
  96. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +78 -25
  97. package/packages/daemon/dist/tui/mirror/runtime/application-theme-repaint.js +9 -0
  98. package/packages/daemon/dist/tui/mirror/runtime/fleet-lifecycle-client.js +44 -1
  99. package/packages/daemon/dist/tui/mirror/runtime/guided-tour-coach.jsx +54 -0
  100. package/packages/daemon/dist/tui/mirror/runtime/guided-tour-storage.js +67 -0
  101. package/packages/daemon/dist/tui/mirror/runtime/guided-tour.js +113 -0
  102. package/packages/daemon/dist/tui/mirror/runtime/open-tui-generation-host.js +1 -1
  103. package/packages/daemon/dist/tui/mirror/runtime/resize-terminal-pane.js +4 -0
  104. package/packages/daemon/dist/tui/mirror/runtime/select-terminal-pane.js +2 -1
  105. package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +12 -4
  106. package/packages/daemon/dist/tui/mirror/runtime/terminal-pane-input-router.js +6 -3
  107. package/packages/daemon/dist/tui/mirror/workspace/application-command-description.js +11 -2
  108. package/packages/daemon/dist/tui/mirror/workspace/terminal-window-strip.jsx +2 -1
  109. package/packages/daemon/src/command-center/actions/command-definitions.ts +6 -0
  110. package/packages/daemon/src/command-center/actions/errors.ts +4 -0
  111. package/packages/daemon/src/command-center/actions/handlers/workspace-multiplexer.ts +2 -0
  112. package/packages/daemon/src/command-center/actions/registry.ts +14 -0
  113. package/packages/daemon/src/command-center/discovery.ts +2 -10
  114. package/packages/daemon/src/command-center/server.ts +29 -1
  115. package/packages/daemon/src/command-center/tmux-server-native-backing.ts +49 -0
  116. package/packages/daemon/src/command-center/tmux-servers.ts +479 -0
  117. package/packages/daemon/src/lib/canonical-daemon-bootstrap.ts +111 -4
  118. package/packages/daemon/src/lib/canonical-daemon.ts +15 -3
  119. package/packages/daemon/src/lib/daemon-embed.ts +241 -28
  120. package/packages/daemon/src/lib/embedded-tmux-server-owners.ts +190 -0
  121. package/packages/daemon/src/lib/fleet-lifecycle-authority.ts +33 -9
  122. package/packages/daemon/src/lib/headless-daemon.ts +18 -1
  123. package/packages/daemon/src/lib/runtime-namespace.ts +24 -3
  124. package/packages/daemon/src/lib/tmux-client-execution.ts +56 -0
  125. package/packages/daemon/src/lib/tmux-named-socket-fence.ts +5 -0
  126. package/packages/daemon/src/lib/tmux-server-generation-runner.ts +81 -0
  127. package/packages/daemon/src/lib/tmux-server-owner.ts +336 -0
  128. package/packages/daemon/src/lib/tmux-server-owners.ts +228 -0
  129. package/packages/daemon/src/lib/tmux-server-proof.ts +95 -0
  130. package/packages/daemon/src/lib/tmux-server-registration.ts +105 -0
  131. package/packages/daemon/src/lib/tmux-server-session-create.ts +52 -0
  132. package/packages/daemon/src/lib/tmux-server-session-open.ts +106 -0
  133. package/packages/daemon/src/lib/tmux-servers-cli.ts +186 -0
  134. package/packages/daemon/src/lib/tmux-session-mutation-fence.ts +62 -0
  135. package/packages/daemon/src/lib/tmux-window-link-guard.ts +112 -0
  136. package/packages/daemon/src/lib/workspace-config-loader.ts +3 -0
  137. package/packages/daemon/src/lib/workspace-multiplexer-verbs.ts +121 -1
  138. package/packages/daemon/src/lib/workspace-pane-creation.ts +16 -55
  139. package/packages/daemon/src/lib/workspace-promotion.ts +98 -12
  140. package/packages/daemon/src/lib/workspace-registry.ts +5 -0
  141. package/packages/daemon/src/server/pane-stream-upgrade.ts +25 -5
  142. package/packages/daemon/src/terminal/attachments/native-runtime.ts +70 -16
  143. package/packages/daemon/src/terminal/mirror/control-channel.ts +64 -7
  144. package/packages/daemon/src/terminal/mirror/events.ts +2 -0
  145. package/packages/daemon/src/terminal/mirror/mirror-service.ts +56 -0
  146. package/packages/daemon/src/terminal/mirror/session-channel.ts +245 -50
  147. package/packages/daemon/src/terminal/mirror/window-link-authority.ts +203 -0
  148. package/packages/daemon/src/terminal/pane-stream/lease-manager.ts +3 -3
  149. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +50 -6
  150. package/packages/daemon/src/terminal/protocol/live-session-identity.ts +14 -0
  151. package/packages/daemon/src/terminal/protocol/native-backing-client.ts +4 -1
  152. package/packages/daemon/src/terminal/session-runtime/interaction-receipt-facts.ts +9 -0
  153. package/packages/daemon/src/terminal/session-runtime/registry.ts +9 -0
  154. package/packages/daemon/src/terminal/session-runtime/semantic-mutation-executor.ts +2 -2
  155. package/packages/daemon/src/terminal/session-runtime/semantic-mutation-resource-changes.ts +1 -0
  156. package/packages/daemon/src/tui/mirror/application-shell-daemon-connection.ts +28 -2
  157. package/packages/daemon/src/tui/mirror/application-shell-server-connection.ts +154 -0
  158. package/packages/daemon/src/tui/mirror/menu-model.ts +1 -1
  159. package/packages/daemon/src/tui/mirror/open-tui-verified-routing.ts +41 -3
  160. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +47 -6
  161. package/packages/daemon/src/tui/mirror/runtime/application-entry.ts +2 -1
  162. package/packages/daemon/src/tui/mirror/runtime/application-fleet-session-actions.tsx +10 -4
  163. package/packages/daemon/src/tui/mirror/runtime/application-fleet-tabs.ts +2 -0
  164. package/packages/daemon/src/tui/mirror/runtime/application-guided-tour-integration.tsx +199 -0
  165. package/packages/daemon/src/tui/mirror/runtime/application-guided-tour-owner.ts +162 -0
  166. package/packages/daemon/src/tui/mirror/runtime/application-home-agent-roster.tsx +36 -10
  167. package/packages/daemon/src/tui/mirror/runtime/application-home-agent-transport.ts +69 -0
  168. package/packages/daemon/src/tui/mirror/runtime/application-home-agents-owner.ts +12 -2
  169. package/packages/daemon/src/tui/mirror/runtime/application-home-agents.ts +13 -2
  170. package/packages/daemon/src/tui/mirror/runtime/application-home-catalog-owner.ts +13 -3
  171. package/packages/daemon/src/tui/mirror/runtime/application-home-catalog.ts +172 -100
  172. package/packages/daemon/src/tui/mirror/runtime/application-home-experience.ts +59 -0
  173. package/packages/daemon/src/tui/mirror/runtime/application-home-fleet.ts +175 -0
  174. package/packages/daemon/src/tui/mirror/runtime/application-machine-agents.ts +9 -1
  175. package/packages/daemon/src/tui/mirror/runtime/application-machine-catalog.ts +19 -7
  176. package/packages/daemon/src/tui/mirror/runtime/application-machine-navigation.ts +285 -36
  177. package/packages/daemon/src/tui/mirror/runtime/application-machine-overlays.tsx +1 -1
  178. package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +124 -40
  179. package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +3 -0
  180. package/packages/daemon/src/tui/mirror/runtime/application-performance-log.ts +18 -6
  181. package/packages/daemon/src/tui/mirror/runtime/application-root-configuration.ts +12 -3
  182. package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +40 -40
  183. package/packages/daemon/src/tui/mirror/runtime/application-route-connection.ts +93 -1
  184. package/packages/daemon/src/tui/mirror/runtime/application-shell-catalog.tsx +4 -0
  185. package/packages/daemon/src/tui/mirror/runtime/application-shell-home.tsx +20 -1
  186. package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +10 -0
  187. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +177 -2
  188. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace-policy.ts +62 -0
  189. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +87 -30
  190. package/packages/daemon/src/tui/mirror/runtime/application-theme-repaint.ts +19 -0
  191. package/packages/daemon/src/tui/mirror/runtime/fleet-lifecycle-client.ts +55 -0
  192. package/packages/daemon/src/tui/mirror/runtime/guided-tour-coach.tsx +118 -0
  193. package/packages/daemon/src/tui/mirror/runtime/guided-tour-storage.ts +72 -0
  194. package/packages/daemon/src/tui/mirror/runtime/guided-tour.ts +147 -0
  195. package/packages/daemon/src/tui/mirror/runtime/open-tui-generation-host.ts +1 -1
  196. package/packages/daemon/src/tui/mirror/runtime/resize-terminal-pane.ts +3 -0
  197. package/packages/daemon/src/tui/mirror/runtime/select-terminal-pane.ts +3 -0
  198. package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +11 -4
  199. package/packages/daemon/src/tui/mirror/runtime/terminal-pane-input-router.ts +15 -4
  200. package/packages/daemon/src/tui/mirror/workspace/application-command-description.ts +13 -2
  201. package/packages/daemon/src/tui/mirror/workspace/terminal-window-strip.tsx +5 -1
  202. package/packages/daemon-client/package.json +3 -1
  203. package/packages/daemon-client/src/index.ts +2 -0
  204. package/packages/daemon-client/src/pane-stream-client.test.ts +108 -38
  205. package/packages/daemon-client/src/pane-stream-client.ts +26 -0
  206. package/packages/daemon-client/src/scoped-tmux-server-transport.test.ts +114 -0
  207. package/packages/daemon-client/src/scoped-tmux-server-transport.ts +385 -0
  208. package/packages/daemon-client/src/tmux-server-client.test.ts +279 -0
  209. package/packages/daemon-client/src/tmux-server-client.ts +287 -0
@@ -50,8 +50,11 @@ const LIFECYCLE_RUNTIME_PHASES = new Set([
50
50
  "stream-open-resolved",
51
51
  ]);
52
52
  const lifecycleRecords = new Map();
53
+ let closing = false;
54
+ let closePromise = null;
53
55
  function serializeMark(phase, details) {
54
56
  if (!stream ||
57
+ closing ||
55
58
  (!tuiPerfStream &&
56
59
  !LIFECYCLE_PHASES.has(phase) &&
57
60
  !(phase === "generation-runtime-progress" &&
@@ -120,11 +123,21 @@ async function flushTuiPerfMarks() {
120
123
  }
121
124
  });
122
125
  }
123
- export async function closeTuiPerfMarks() {
124
- if (!stream)
125
- return;
126
- await flushTuiPerfMarks();
127
- await new Promise((resolveClose) => stream.end(resolveClose));
128
- stream.off("error", fail);
129
- stream.off("drain", drain);
126
+ export function closeTuiPerfMarks() {
127
+ if (closePromise)
128
+ return closePromise;
129
+ // Renderer/runtime callbacks can still emit marks during asynchronous teardown.
130
+ // Stop accepting them before yielding, and let every caller await one close.
131
+ closing = true;
132
+ closePromise = (async () => {
133
+ if (!stream)
134
+ return;
135
+ await flushTuiPerfMarks();
136
+ // No drain callback may enqueue another critical record after end().
137
+ stream.off("drain", drain);
138
+ await new Promise((resolveClose) => stream.end(resolveClose));
139
+ // Keep the error listener through the underlying file descriptor teardown;
140
+ // an asynchronous close error must remain a logging failure, not a crash.
141
+ })();
142
+ return closePromise;
130
143
  }
@@ -1,3 +1,4 @@
1
+ import { TmuxServerIdSchemaZ } from "@tmux-ide/contracts";
1
2
  import { parseArgs } from "node:util";
2
3
  import { loadAppConfig } from "../../../lib/app-config.js";
3
4
  import { tuiPerfMark } from "./application-performance-log.js";
@@ -6,16 +7,21 @@ export const parseApplicationArgs = (argv) => {
6
7
  args: [...argv],
7
8
  allowPositionals: true,
8
9
  strict: false,
9
- options: { target: { type: "string" } },
10
+ options: { target: { type: "string" }, server: { type: "string" } },
10
11
  });
11
12
  const positional = parsed.positionals.find((value) => value !== "app") ?? null;
12
13
  const target = typeof parsed.values.target === "string" ? parsed.values.target : positional;
13
- return Object.freeze({ target: target && target !== "home" ? target : null });
14
+ return Object.freeze({
15
+ target: target && target !== "home" ? target : null,
16
+ serverId: typeof parsed.values.server === "string"
17
+ ? TmuxServerIdSchemaZ.parse(parsed.values.server)
18
+ : null,
19
+ });
14
20
  };
15
21
  export async function loadApplicationConfig(args) {
16
22
  tuiPerfMark("config-load-start", { requestedTarget: args.target });
17
23
  // Home discovery is daemon-authoritative and remains live after mount. The
18
24
  // immutable bootstrap config only carries an explicit user target.
19
25
  tuiPerfMark("config-load-end", { target: args.target });
20
- return Object.freeze({ app: loadAppConfig(), target: args.target });
26
+ return Object.freeze({ app: loadAppConfig(), target: args.target, serverId: args.serverId });
21
27
  }
@@ -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,
@@ -30,9 +30,10 @@ export function ApplicationHomeSurface(props) {
30
30
  naturalButtonWidth("Open terminals", "F2") +
31
31
  naturalButtonWidth("Commands", "F5") +
32
32
  (props.onCycleTheme ? naturalButtonWidth(themeLabel()) + 2 : 0) +
33
+ (props.onOpenTutorial ? naturalButtonWidth(props.tutorialLabel ?? "Learn tmux-ide") + 2 : 0) +
33
34
  2;
34
35
  const reservedRows = () => (spacious() ? 4 : 2) +
35
- (actionsInRow() ? 1 : props.onCycleTheme ? 3 : 2) +
36
+ (actionsInRow() ? 1 : 2 + (props.onCycleTheme ? 1 : 0) + (props.onOpenTutorial ? 1 : 0)) +
36
37
  (spacious() ? 1 : 0) +
37
38
  (props.note ? (spacious() ? 2 : 1) : 0);
38
39
  const recentActivity = () => (props.recentPaneActivity ?? []).slice(0, spacious()
@@ -78,7 +79,7 @@ export function ApplicationHomeSurface(props) {
78
79
  {clipTerminal(summary(), bodyWidth())}
79
80
  </text>
80
81
  </>}>
81
- {(snapshot) => (<HomeAgentRoster theme={props.theme} width={bodyWidth()} height={rosterHeight()} snapshot={snapshot()} selection={props.agentSelection ?? { selectedKey: null, scrollOffset: 0 }} inputActive={props.agentInputActive ?? false} onSelect={(key) => props.onSelectAgent?.(key)} onMove={(delta) => props.onMoveAgent?.(delta)} onViewport={(rows) => props.onAgentViewport?.(rows)} onOpen={(row, source) => props.onOpenAgent?.(row, source)} onRetry={props.onRetryAgents} onLoadMore={props.onLoadMoreAgents}/>)}
82
+ {(snapshot) => (<HomeAgentRoster filterLabel={props.agentFilterLabel} onCycleMachine={props.onCycleAgentMachine} onToggleAttention={props.onToggleAgentAttention} theme={props.theme} width={bodyWidth()} height={rosterHeight()} snapshot={snapshot()} selection={props.agentSelection ?? { selectedKey: null, scrollOffset: 0 }} inputActive={props.agentInputActive ?? false} onSelect={(key) => props.onSelectAgent?.(key)} onMove={(delta) => props.onMoveAgent?.(delta)} onViewport={(rows) => props.onAgentViewport?.(rows)} onOpen={(row, source) => props.onOpenAgent?.(row, source)} onRetry={props.onRetryAgents} onLoadMore={props.onLoadMoreAgents}/>)}
82
83
  </Show>
83
84
  <Show when={activityHeight() > 0}>
84
85
  <box width={bodyWidth()} height={activityHeight()} flexShrink={0} flexDirection="column" overflow="hidden">
@@ -99,6 +100,9 @@ export function ApplicationHomeSurface(props) {
99
100
  <box width={bodyWidth()} flexShrink={0} flexDirection={actionsInRow() ? "row" : "column"} alignItems="flex-start" gap={actionsInRow() ? 2 : 0}>
100
101
  <TuiButton theme={props.theme} label="Open terminals" shortcut="F2" width={buttonWidth("Open terminals", "F2")} variant="primary" onPress={props.onOpenTerminals}/>
101
102
  <TuiButton theme={props.theme} label="Commands" shortcut="F5" width={buttonWidth("Commands", "F5")} onPress={props.onOpenCommands}/>
103
+ <Show when={props.onOpenTutorial}>
104
+ {(open) => (<TuiButton theme={props.theme} label={props.tutorialLabel ?? "Learn tmux-ide"} width={buttonWidth(props.tutorialLabel ?? "Learn tmux-ide")} onPress={open()}/>)}
105
+ </Show>
102
106
  <Show when={props.onCycleTheme}>
103
107
  {(onCycleTheme) => (<TuiButton theme={props.theme} label={themeLabel()} width={buttonWidth(themeLabel())} variant="ghost" background={props.theme.roles.surfaces.canvas} onPress={onCycleTheme()}/>)}
104
108
  </Show>
@@ -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 { createEffect } from "solid-js";
1
2
  /**
2
3
  * OpenTUI 0.5 keeps a private full-frame damage flag. A semantic theme switch
3
4
  * changes backgrounds without necessarily changing glyphs, so its ordinary
@@ -32,3 +33,11 @@ export function applyApplicationAppearanceToRenderer(renderer, theme, generation
32
33
  requestApplicationThemeRepaint(renderer);
33
34
  return generation;
34
35
  }
36
+ /** Bind the complete appearance publication to one renderer transaction. */
37
+ export function createApplicationAppearanceRendererBinding(renderer, appearance) {
38
+ let paintedGeneration = null;
39
+ createEffect(() => {
40
+ const next = appearance.appearance();
41
+ paintedGeneration = applyApplicationAppearanceToRenderer(renderer, next.theme, next.generation, paintedGeneration);
42
+ });
43
+ }