tmux-ide 2.6.0 → 2.7.0

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 (271) hide show
  1. package/README.md +14 -9
  2. package/bin/cli.js +1024 -519
  3. package/bin/cli.ts +63 -6
  4. package/bunfig.toml +4 -0
  5. package/package.json +18 -7
  6. package/packages/contracts/package.json +22 -0
  7. package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
  8. package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
  9. package/packages/contracts/src/actions-contract.ts +310 -0
  10. package/packages/contracts/src/actions-errors.ts +41 -0
  11. package/packages/contracts/src/domain.ts +36 -0
  12. package/packages/contracts/src/ide-config.ts +170 -0
  13. package/packages/contracts/src/index.ts +24 -0
  14. package/packages/contracts/src/lib-internal/auth.ts +13 -0
  15. package/packages/contracts/src/lib-internal/hq.ts +38 -0
  16. package/packages/contracts/src/terminals.ts +116 -0
  17. package/packages/contracts/src/tmux.ts +60 -0
  18. package/packages/contracts/src/workspace.ts +67 -0
  19. package/packages/daemon/dist/agent-explain.d.ts +8 -1
  20. package/packages/daemon/dist/agent-explain.js +19 -3
  21. package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
  22. package/packages/daemon/dist/lib/tui-binary.js +130 -0
  23. package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
  24. package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
  25. package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
  26. package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
  27. package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
  28. package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
  29. package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
  30. package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
  31. package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
  32. package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
  33. package/packages/daemon/src/agent-explain.ts +298 -0
  34. package/packages/daemon/src/attach.ts +20 -0
  35. package/packages/daemon/src/bin.ts +4 -0
  36. package/packages/daemon/src/canonical.ts +7 -0
  37. package/packages/daemon/src/cli.ts +499 -0
  38. package/packages/daemon/src/command-center/actions/contract.ts +2 -0
  39. package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
  40. package/packages/daemon/src/command-center/actions/errors.ts +105 -0
  41. package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
  42. package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
  43. package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
  44. package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
  45. package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
  46. package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
  47. package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
  48. package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
  49. package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
  50. package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
  51. package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
  52. package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
  53. package/packages/daemon/src/command-center/actions/registry.ts +149 -0
  54. package/packages/daemon/src/command-center/discovery.ts +96 -0
  55. package/packages/daemon/src/command-center/index.ts +31 -0
  56. package/packages/daemon/src/command-center/schemas.ts +85 -0
  57. package/packages/daemon/src/command-center/server.ts +1260 -0
  58. package/packages/daemon/src/command-center/ws-events.ts +316 -0
  59. package/packages/daemon/src/config.ts +549 -0
  60. package/packages/daemon/src/detect.ts +248 -0
  61. package/packages/daemon/src/doctor.ts +242 -0
  62. package/packages/daemon/src/embed.ts +5 -0
  63. package/packages/daemon/src/index.ts +12 -0
  64. package/packages/daemon/src/init.ts +211 -0
  65. package/packages/daemon/src/inspect.ts +178 -0
  66. package/packages/daemon/src/js-yaml.d.ts +10 -0
  67. package/packages/daemon/src/launch.ts +349 -0
  68. package/packages/daemon/src/lib/active-projects.ts +49 -0
  69. package/packages/daemon/src/lib/agent-discovery.ts +121 -0
  70. package/packages/daemon/src/lib/app-config.ts +427 -0
  71. package/packages/daemon/src/lib/app-settings.ts +53 -0
  72. package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
  73. package/packages/daemon/src/lib/auth/middleware.ts +56 -0
  74. package/packages/daemon/src/lib/auth/types.ts +2 -0
  75. package/packages/daemon/src/lib/auth-token.ts +5 -0
  76. package/packages/daemon/src/lib/authorship.ts +280 -0
  77. package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
  78. package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
  79. package/packages/daemon/src/lib/daemon-embed.ts +782 -0
  80. package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
  81. package/packages/daemon/src/lib/daemon.ts +79 -0
  82. package/packages/daemon/src/lib/dot-path.ts +17 -0
  83. package/packages/daemon/src/lib/errors.ts +67 -0
  84. package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
  85. package/packages/daemon/src/lib/launch-plan.ts +90 -0
  86. package/packages/daemon/src/lib/log.ts +134 -0
  87. package/packages/daemon/src/lib/output.ts +76 -0
  88. package/packages/daemon/src/lib/project-init-runner.ts +150 -0
  89. package/packages/daemon/src/lib/project-inspect.ts +80 -0
  90. package/packages/daemon/src/lib/project-onboard.ts +149 -0
  91. package/packages/daemon/src/lib/project-probe.ts +92 -0
  92. package/packages/daemon/src/lib/project-registry.ts +296 -0
  93. package/packages/daemon/src/lib/session-monitor.ts +122 -0
  94. package/packages/daemon/src/lib/session-options.ts +100 -0
  95. package/packages/daemon/src/lib/shell.ts +8 -0
  96. package/packages/daemon/src/lib/sizes.ts +36 -0
  97. package/packages/daemon/src/lib/skill-sync.ts +155 -0
  98. package/packages/daemon/src/lib/slugify.ts +10 -0
  99. package/packages/daemon/src/lib/terminals-store.ts +125 -0
  100. package/packages/daemon/src/lib/tui-binary.ts +165 -0
  101. package/packages/daemon/src/lib/update-check.ts +298 -0
  102. package/packages/daemon/src/lib/update.ts +158 -0
  103. package/packages/daemon/src/lib/workspace-registry.ts +229 -0
  104. package/packages/daemon/src/lib/worktree.ts +289 -0
  105. package/packages/daemon/src/lib/yaml-io.ts +27 -0
  106. package/packages/daemon/src/ls.ts +40 -0
  107. package/packages/daemon/src/restart.ts +24 -0
  108. package/packages/daemon/src/restore.ts +514 -0
  109. package/packages/daemon/src/schemas/domain.ts +2 -0
  110. package/packages/daemon/src/schemas/filesystem.ts +34 -0
  111. package/packages/daemon/src/schemas/ide-config.ts +2 -0
  112. package/packages/daemon/src/schemas/index.ts +59 -0
  113. package/packages/daemon/src/schemas/inspect.ts +67 -0
  114. package/packages/daemon/src/schemas/registry.ts +55 -0
  115. package/packages/daemon/src/schemas/ws-events.ts +135 -0
  116. package/packages/daemon/src/send.ts +171 -0
  117. package/packages/daemon/src/server/README.md +15 -0
  118. package/packages/daemon/src/server/index.ts +74 -0
  119. package/packages/daemon/src/server/pty-bridge.ts +532 -0
  120. package/packages/daemon/src/server/standalone.ts +18 -0
  121. package/packages/daemon/src/server/ws-route.ts +483 -0
  122. package/packages/daemon/src/status.ts +59 -0
  123. package/packages/daemon/src/stop.ts +28 -0
  124. package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
  125. package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
  126. package/packages/daemon/src/terminal/README.md +92 -0
  127. package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
  128. package/packages/daemon/src/tui/chrome/chip.ts +30 -0
  129. package/packages/daemon/src/tui/chrome/events.ts +119 -0
  130. package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
  131. package/packages/daemon/src/tui/chrome/menu.ts +289 -0
  132. package/packages/daemon/src/tui/chrome/notify.ts +382 -0
  133. package/packages/daemon/src/tui/chrome/panels.ts +111 -0
  134. package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
  135. package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
  136. package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
  137. package/packages/daemon/src/tui/chrome/updater.ts +510 -0
  138. package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
  139. package/packages/daemon/src/tui/compiled.ts +121 -0
  140. package/packages/daemon/src/tui/detect/classify.ts +208 -0
  141. package/packages/daemon/src/tui/detect/manifest-loader.ts +193 -0
  142. package/packages/daemon/src/tui/detect/manifest.ts +199 -0
  143. package/packages/daemon/src/tui/detect/manifests.ts +354 -0
  144. package/packages/daemon/src/tui/detect/process-tree.ts +217 -0
  145. package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
  146. package/packages/daemon/src/tui/integrations/claude.ts +176 -0
  147. package/packages/daemon/src/tui/integrations/offer.ts +145 -0
  148. package/packages/daemon/src/tui/main.ts +82 -0
  149. package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
  150. package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
  151. package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
  152. package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
  153. package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
  154. package/packages/daemon/src/tui/mirror/blit.ts +186 -0
  155. package/packages/daemon/src/tui/mirror/control-client.ts +214 -0
  156. package/packages/daemon/src/tui/mirror/control.ts +97 -0
  157. package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
  158. package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
  159. package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
  160. package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
  161. package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
  162. package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
  163. package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
  164. package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
  165. package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
  166. package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
  167. package/packages/daemon/src/tui/mirror/palette.ts +274 -0
  168. package/packages/daemon/src/tui/mirror/pane-mirror.ts +561 -0
  169. package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
  170. package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
  171. package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
  172. package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
  173. package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
  174. package/packages/daemon/src/tui/mirror/selection.ts +262 -0
  175. package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
  176. package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
  177. package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
  178. package/packages/daemon/src/tui/mirror/spans.ts +46 -0
  179. package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
  180. package/packages/daemon/src/tui/team/CONTROL.md +50 -0
  181. package/packages/daemon/src/tui/team/entry.ts +38 -0
  182. package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
  183. package/packages/daemon/src/tui/team/home.ts +170 -0
  184. package/packages/daemon/src/tui/team/index.tsx +1521 -0
  185. package/packages/daemon/src/tui/team/input.ts +34 -0
  186. package/packages/daemon/src/tui/team/keymap.ts +127 -0
  187. package/packages/daemon/src/tui/team/mouse.ts +29 -0
  188. package/packages/daemon/src/tui/team/nav.ts +31 -0
  189. package/packages/daemon/src/tui/team/preview.ts +34 -0
  190. package/packages/daemon/src/tui/team/projects.ts +191 -0
  191. package/packages/daemon/src/tui/team/report.ts +83 -0
  192. package/packages/daemon/src/tui/team/sessions.ts +433 -0
  193. package/packages/daemon/src/tui/team/tree.ts +62 -0
  194. package/packages/daemon/src/types.ts +13 -0
  195. package/packages/daemon/src/ui/index.ts +32 -0
  196. package/packages/daemon/src/ui/terminal/index.ts +9 -0
  197. package/packages/daemon/src/ui/types.ts +91 -0
  198. package/packages/daemon/src/ui/web/base.css +80 -0
  199. package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
  200. package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
  201. package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
  202. package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
  203. package/packages/daemon/src/ui/web/hooks.ts +106 -0
  204. package/packages/daemon/src/ui/web/index.ts +27 -0
  205. package/packages/daemon/src/ui/web/render.ts +77 -0
  206. package/packages/daemon/src/ui/web/utils/color.ts +27 -0
  207. package/packages/daemon/src/validate.ts +217 -0
  208. package/packages/daemon/src/widgets/changes/README.md +3 -0
  209. package/packages/daemon/src/widgets/changes/index.tsx +691 -0
  210. package/packages/daemon/src/widgets/config/README.md +3 -0
  211. package/packages/daemon/src/widgets/config/index.tsx +481 -0
  212. package/packages/daemon/src/widgets/explorer/README.md +3 -0
  213. package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
  214. package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
  215. package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
  216. package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
  217. package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
  218. package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
  219. package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
  220. package/packages/daemon/src/widgets/lib/files.ts +88 -0
  221. package/packages/daemon/src/widgets/lib/git.ts +88 -0
  222. package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
  223. package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
  224. package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
  225. package/packages/daemon/src/widgets/lib/theme.ts +194 -0
  226. package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
  227. package/packages/daemon/src/widgets/preview/README.md +3 -0
  228. package/packages/daemon/src/widgets/preview/index.tsx +416 -0
  229. package/packages/daemon/src/widgets/resolve.ts +121 -0
  230. package/packages/daemon/src/widgets/setup/README.md +3 -0
  231. package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
  232. package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
  233. package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
  234. package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
  235. package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
  236. package/packages/daemon/src/widgets/setup/index.tsx +341 -0
  237. package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
  238. package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
  239. package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
  240. package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
  241. package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
  242. package/packages/tmux-bridge/package.json +22 -0
  243. package/packages/tmux-bridge/src/errors.ts +28 -0
  244. package/packages/tmux-bridge/src/index.ts +31 -0
  245. package/packages/tmux-bridge/src/monitor.ts +77 -0
  246. package/packages/tmux-bridge/src/panes.ts +136 -0
  247. package/packages/tmux-bridge/src/runner.test.ts +501 -0
  248. package/packages/tmux-bridge/src/runner.ts +91 -0
  249. package/packages/tmux-bridge/src/sessions.ts +126 -0
  250. package/packages/tmux-bridge/src/targeting.test.ts +107 -0
  251. package/packages/tmux-bridge/src/targeting.ts +90 -0
  252. package/scripts/build-tui.mjs +11 -4
  253. package/scripts/perf-mirror.mjs +313 -0
  254. package/scripts/postinstall.js +26 -2
  255. package/skill/SKILL.md +22 -0
  256. package/templates/AGENTS.md +14 -7
  257. package/templates/agent-team-monorepo.yml +8 -0
  258. package/templates/agent-team-nextjs.yml +8 -0
  259. package/templates/agent-team.yml +10 -0
  260. package/templates/convex.yml +2 -0
  261. package/templates/default.yml +11 -5
  262. package/templates/go.yml +4 -0
  263. package/templates/missions.yml +6 -0
  264. package/templates/nextjs.yml +4 -0
  265. package/templates/python.yml +4 -0
  266. package/templates/skills/backend.md +5 -12
  267. package/templates/skills/frontend.md +5 -12
  268. package/templates/skills/general-worker.md +5 -12
  269. package/templates/skills/researcher.md +7 -12
  270. package/templates/skills/reviewer.md +7 -16
  271. package/templates/vite.yml +4 -0
@@ -0,0 +1,510 @@
1
+ /**
2
+ * The chrome status-bar updater — ONE background loop that keeps every adopted
3
+ * session's status var fresh.
4
+ *
5
+ * WHY a single updater (vs. the old per-session `#()`): the first chrome
6
+ * pointed each adopted session's `status-format[1]` at
7
+ * `#(tmux-ide statusline …)`, which tmux re-ran every `status-interval` — a
8
+ * full node boot + fleet scan PER adopted session PER tick (~0.35s each). And
9
+ * because each `#()` invocation was stateless it could never produce the
10
+ * cross-tick `done` status (working→idle needs history).
11
+ *
12
+ * This module computes the fleet ONCE per tick behind a PERSISTENT
13
+ * {@link createStatusTracker} (so working→idle surfaces as `done`), then writes
14
+ * a per-session `@tmux_ide_status` user option for each adopted session
15
+ * (per-session so each keeps its own active-highlight). `adoptSession` points
16
+ * `status-format[1]` at a bare `#{@tmux_ide_status}` read — near-free, no spawn.
17
+ *
18
+ * The loop is HOSTED IN TMUX: `adoptSession` spins up a hidden `_tmux-ide-chrome`
19
+ * session running `tmux-ide chrome-updater`, and unadopting the last session
20
+ * kills it. `runUpdaterTick` is factored to take injected io so it's unit-tested
21
+ * without a live tmux; `adoptedSessionsFrom` is a pure parser.
22
+ */
23
+ import { hasSession, isProcessAlive, runTmux } from "@tmux-ide/tmux-bridge";
24
+ import { DEFAULT_THEME, getAppConfig, type AppTheme } from "../../lib/app-config.ts";
25
+ import {
26
+ maybeCheckForUpdate,
27
+ markUpdateNotified,
28
+ type UpdateStatus,
29
+ } from "../../lib/update-check.ts";
30
+ import { createStatusTracker, type AgentStatus } from "../detect/classify.ts";
31
+ import { listTeamProjects, type TeamProject } from "../team/projects.ts";
32
+ import type { PaneDetail } from "../team/sessions.ts";
33
+ import { paneChip } from "./chip.ts";
34
+ import { appendEvents, diffFleet, type AgentEventInit } from "./events.ts";
35
+ import {
36
+ decideNotifications,
37
+ enabledStates,
38
+ inQuietHours,
39
+ listAttachedClients,
40
+ readNotificationPrefs,
41
+ sendSystemNotification,
42
+ sendToasts,
43
+ type AttachedClient,
44
+ type NotificationPrefs,
45
+ type NotifyEvent,
46
+ type SystemNotification,
47
+ type ToastTarget,
48
+ } from "./notify.ts";
49
+ import {
50
+ collectFleetSnapshot,
51
+ createSnapshotter,
52
+ readSnapshot,
53
+ writeSnapshot,
54
+ } from "./snapshot.ts";
55
+ import { buildStatusline } from "./statusline.ts";
56
+
57
+ /** Per-session user option holding the pre-rendered status-bar string. */
58
+ export const STATUS_OPTION = "@tmux_ide_status";
59
+ /** Per-PANE user option holding the pre-rendered agent chip (read by pane-border-format). */
60
+ export const CHIP_OPTION = "@tmux_ide_chip";
61
+ /** Per-session marker option set on adopt so the updater can enumerate adopted sessions. */
62
+ export const ADOPTED_OPTION = "@tmux_ide_adopted";
63
+ /** The hidden internal session that hosts the updater loop. */
64
+ export const UPDATER_SESSION = "_tmux-ide-chrome";
65
+ /** Server option holding the running updater's pid (a lightweight single-owner guard). */
66
+ export const UPDATER_PID_OPTION = "@tmux_ide_updater_pid";
67
+ /** Default tick cadence — overridable via `updater.tickMs` in the app config. */
68
+ export const TICK_MS = 2000;
69
+
70
+ /**
71
+ * PURE — parse `list-sessions -F '#{session_name}\t#{@tmux_ide_adopted}'` output
72
+ * into the list of adopted session names. A session is adopted when its marker
73
+ * field is exactly `"1"` (sessions without the option render an empty field —
74
+ * verified on tmux 3.6, where user options ARE readable in list-sessions
75
+ * formats).
76
+ */
77
+ export function adoptedSessionsFrom(lines: string[]): string[] {
78
+ const out: string[] = [];
79
+ for (const line of lines) {
80
+ const [name = "", flag = ""] = line.split("\t");
81
+ if (name && flag === "1") out.push(name);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /** Enumerate adopted sessions from the live tmux server. Never throws. */
87
+ export function listAdoptedSessions(): string[] {
88
+ try {
89
+ const raw = runTmux(["list-sessions", "-F", `#{session_name}\t#{${ADOPTED_OPTION}}`])
90
+ .toString()
91
+ .trim();
92
+ return raw ? adoptedSessionsFrom(raw.split("\n")) : [];
93
+ } catch {
94
+ return [];
95
+ }
96
+ }
97
+
98
+ /** Write a session's pre-rendered status var. */
99
+ function writeSessionStatus(session: string, value: string): void {
100
+ runTmux(["set-option", "-t", session, STATUS_OPTION, value]);
101
+ }
102
+
103
+ /** Write a pane's pre-rendered chip var (empty string clears it → title fallback). */
104
+ function writePaneChip(paneId: string, value: string): void {
105
+ runTmux(["set-option", "-p", "-t", paneId, CHIP_OPTION, value]);
106
+ }
107
+
108
+ /** The io a single tick needs — injectable so the orchestration is unit-tested. */
109
+ export interface UpdaterTickDeps {
110
+ listAdopted: () => string[];
111
+ /**
112
+ * Compute the fleet. The tick passes an `onPane` collector so per-pane detail
113
+ * (agent + status) is recovered during the SAME scan the bars are built from
114
+ * — the loop wires this to `listTeamProjects(tracker, { onPane })`. Callers
115
+ * that don't need chips (tests) may ignore the argument.
116
+ */
117
+ computeProjects: (onPane: (pane: PaneDetail) => void) => TeamProject[];
118
+ writeStatus: (session: string, value: string) => void;
119
+ /**
120
+ * The shared palette threaded into {@link buildStatusline} / {@link paneChip}
121
+ * (default {@link DEFAULT_THEME}). The loop resolves it once from the app
122
+ * config so a re-theme applies on the next updater start.
123
+ */
124
+ theme?: AppTheme;
125
+ /**
126
+ * Per-pane chip write (optional). When wired, the tick writes each ADOPTED
127
+ * session's panes a `@tmux_ide_chip` pane option (`agent · status`, or empty
128
+ * for a non-agent pane). Only CHANGED chips are written — `chipCache` holds
129
+ * the last value per pane and is mutated in place across ticks so the steady
130
+ * state costs zero set-options.
131
+ */
132
+ writeChip?: (paneId: string, value: string) => void;
133
+ chipCache?: Map<string, string>;
134
+ /**
135
+ * Transition tracking (optional). When both are supplied, the tick diffs the
136
+ * WHOLE fleet against `prevState` and appends any transitions via
137
+ * `appendEvents`, mutating `prevState` in place to the fresh state. Omitted by
138
+ * callers/tests that only care about the status bars.
139
+ */
140
+ prevState?: Map<string, AgentStatus>;
141
+ appendEvents?: (events: AgentEventInit[]) => void;
142
+ /**
143
+ * Notification dispatch (optional). When wired alongside `prevState`, the tick
144
+ * turns THIS tick's transitions into user pings — toasts on attached clients
145
+ * and/or a macOS notification — via {@link decideNotifications}, gated on
146
+ * `prefs`. `lastNotified` is the persistent debounce map, mutated in place.
147
+ * All deps-injected so the routing is unit-tested without a live tmux.
148
+ */
149
+ listClients?: () => AttachedClient[];
150
+ lastNotified?: Map<string, number>;
151
+ now?: () => number;
152
+ prefs?: NotificationPrefs;
153
+ sendToasts?: (toasts: ToastTarget[]) => void;
154
+ sendSystem?: (n: SystemNotification) => void;
155
+ /**
156
+ * Resolve a pane id to its human `session:window.pane` location for the ping
157
+ * text (optional). Wired to the live tmux {@link paneLocation}; tests inject a
158
+ * pure stub. Only ever called for the pane behind a blocked/done transition.
159
+ */
160
+ locatePane?: (paneId: string) => string;
161
+ /**
162
+ * Update-flow surfacing (optional). When wired, the tick calls this cheap,
163
+ * cache-backed check each tick (throttled internally to 24h; it kicks off a
164
+ * background registry refresh). When it reports an available update the tick
165
+ * threads the `⬆ v<latest>` dock segment into every adopted session's bar and,
166
+ * via {@link markUpdateNotified}, fires a ONE-time toast for that version. Both
167
+ * deps-injected so the surfacing is unit-tested without a live tmux/network.
168
+ */
169
+ maybeCheckForUpdate?: () => UpdateStatus;
170
+ markUpdateNotified?: (version: string) => boolean;
171
+ }
172
+
173
+ /**
174
+ * PURE — the reserved dock segment for an available update: the clickable
175
+ * `⬆ v<latest>` chip (accent-colored, wrapped in a `user|update` mouse range so a
176
+ * click floats the update popup — see {@link ./statusline.ts statusClickBindCommand}).
177
+ * Empty string when there's nothing to offer, so it takes no space on the bar.
178
+ */
179
+ export function updateSegment(status: UpdateStatus, theme: AppTheme): string {
180
+ if (!status.updateAvailable || !status.latest) return "";
181
+ return `#[range=user|update]#[fg=${theme.accent}]⬆ v${status.latest}#[default]#[norange]`;
182
+ }
183
+
184
+ /** Flatten the project view to a flat per-session status list for {@link diffFleet}. */
185
+ function fleetStatuses(projects: TeamProject[]): Array<{ name: string; status: AgentStatus }> {
186
+ return projects.flatMap((p) => p.sessions.map((s) => ({ name: s.name, status: s.status })));
187
+ }
188
+
189
+ /**
190
+ * One tick: if any session is adopted, compute the fleet ONCE and write each
191
+ * adopted session its own {@link buildStatusline} (its name flagged active so
192
+ * the per-session highlight is correct). PURE given its deps — no tmux, no
193
+ * fleet scan when nothing is adopted.
194
+ *
195
+ * When `prevState`/`appendEvents` are wired, it also detects state TRANSITIONS
196
+ * across the whole fleet (not just adopted sessions) and appends them to the
197
+ * event log — the updater is the one process that sees every tick, so it's the
198
+ * natural place to emit history.
199
+ */
200
+ export function runUpdaterTick(deps: UpdaterTickDeps): void {
201
+ const adopted = deps.listAdopted();
202
+ if (adopted.length === 0) return;
203
+ const theme = deps.theme ?? DEFAULT_THEME;
204
+ // Collect per-pane detail during the fleet scan so chips need no second pass.
205
+ const panes: PaneDetail[] = [];
206
+ const projects = deps.computeProjects((pane) => panes.push(pane));
207
+ // The dock-first update surface: a cheap cache read that also kicks off the
208
+ // throttled background registry refresh. Threads the "⬆ v<latest>" chip into
209
+ // every bar this tick when an update is pending.
210
+ const update = deps.maybeCheckForUpdate?.();
211
+ const extra = update ? updateSegment(update, theme) : "";
212
+ for (const session of adopted) {
213
+ deps.writeStatus(session, buildStatusline(projects, session, 12, theme, extra));
214
+ }
215
+ writeChips(deps, adopted, panes, theme);
216
+ if (update?.updateAvailable && update.latest) dispatchUpdateToast(deps, update.latest);
217
+ if (deps.prevState && deps.appendEvents) {
218
+ const { events, state } = diffFleet(deps.prevState, fleetStatuses(projects));
219
+ deps.prevState.clear();
220
+ for (const [name, status] of state) deps.prevState.set(name, status);
221
+ if (events.length > 0) {
222
+ deps.appendEvents(events);
223
+ dispatchNotifications(deps, enrichEvents(events, panes, deps.locatePane));
224
+ }
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Write each adopted session's panes their agent chip — but only when the chip
230
+ * CHANGED since last tick (per-pane cache), so a steady fleet issues zero
231
+ * set-options. No-op unless both `writeChip` and `chipCache` are wired. Panes of
232
+ * non-adopted (user, un-adopted) sessions are skipped: we only paint borders on
233
+ * sessions we've adopted.
234
+ */
235
+ function writeChips(
236
+ deps: UpdaterTickDeps,
237
+ adopted: string[],
238
+ panes: PaneDetail[],
239
+ theme: AppTheme,
240
+ ): void {
241
+ const { writeChip, chipCache } = deps;
242
+ if (!writeChip || !chipCache) return;
243
+ const adoptedSet = new Set(adopted);
244
+ for (const pane of panes) {
245
+ if (!adoptedSet.has(pane.sessionName)) continue;
246
+ const chip = paneChip(pane.agent, pane.status, theme);
247
+ if (chipCache.get(pane.paneId) === chip) continue;
248
+ chipCache.set(pane.paneId, chip);
249
+ writeChip(pane.paneId, chip);
250
+ }
251
+ }
252
+
253
+ /**
254
+ * PURE — pick the pane to name in a session-level ping. A session rolls up many
255
+ * panes; the ping should point at ONE. We take a pane whose status matches the
256
+ * transition (`to`), preferring one that resolved to a real agent (so the ping
257
+ * reads `claude blocked …`, not a bare shell). Null when no pane matches — the
258
+ * updater then falls back to the session name / a generic label.
259
+ */
260
+ export function pickRepresentativePane(
261
+ session: string,
262
+ to: AgentStatus,
263
+ panes: PaneDetail[],
264
+ ): PaneDetail | null {
265
+ const matching = panes.filter((p) => p.sessionName === session && p.status === to);
266
+ if (matching.length === 0) return null;
267
+ return matching.find((p) => p.agent !== null) ?? matching[0]!;
268
+ }
269
+
270
+ /**
271
+ * PURE — enrich session-level transitions with the pane's `agent` id and human
272
+ * `location` so {@link notifyMessage} can name who needs the user. Only the
273
+ * notifiable states (blocked/done) get resolved — everything else keeps the bare
274
+ * session as its location and is filtered out downstream anyway, so `locate`
275
+ * (a live tmux call) only fires for a real ping.
276
+ */
277
+ export function enrichEvents(
278
+ events: AgentEventInit[],
279
+ panes: PaneDetail[],
280
+ locate?: (paneId: string) => string,
281
+ ): NotifyEvent[] {
282
+ return events.map((ev) => {
283
+ const notifiable = ev.to === "blocked" || ev.to === "done";
284
+ const rep = notifiable ? pickRepresentativePane(ev.session, ev.to, panes) : null;
285
+ return {
286
+ ...ev,
287
+ agent: rep?.agent ?? null,
288
+ location: rep && locate ? locate(rep.paneId) : ev.session,
289
+ };
290
+ });
291
+ }
292
+
293
+ /**
294
+ * Ping the user about who needs them from this tick's transitions. Only runs
295
+ * when the notification deps are wired, notifications are `enabled`, AND at
296
+ * least one channel is on. `lastNotified` is mutated in place so the debounce
297
+ * (the flap guard) persists across ticks. The macOS banner is additionally
298
+ * gated on QUIET HOURS — inside the window the banner is skipped, but the event
299
+ * has already been recorded to the log by the caller, so history stays honest.
300
+ */
301
+ function dispatchNotifications(deps: UpdaterTickDeps, events: NotifyEvent[]): void {
302
+ const { listClients, lastNotified, now, prefs, sendToasts: toast, sendSystem } = deps;
303
+ if (!listClients || !lastNotified || !now || !prefs) return;
304
+ if (!prefs.enabled) return;
305
+ if (!prefs.toast && !prefs.macos) return;
306
+ const nowMs = now();
307
+ const decision = decideNotifications(
308
+ events,
309
+ listClients(),
310
+ lastNotified,
311
+ nowMs,
312
+ enabledStates(prefs),
313
+ );
314
+ lastNotified.clear();
315
+ for (const [key, ts] of decision.nextLastNotified) lastNotified.set(key, ts);
316
+ if (prefs.toast && toast) toast(decision.toasts);
317
+ if (prefs.macos && sendSystem && !inQuietHours(new Date(nowMs), prefs.quietHours)) {
318
+ for (const n of decision.system) sendSystem(n);
319
+ }
320
+ }
321
+
322
+ /**
323
+ * io — resolve a pane id to `session:window.pane` (e.g. `myproj:1.2`) for the
324
+ * ping text. Best-effort: a gone pane / failed call degrades to the raw pane id.
325
+ */
326
+ export function paneLocation(paneId: string): string {
327
+ try {
328
+ const raw = runTmux([
329
+ "display-message",
330
+ "-p",
331
+ "-t",
332
+ paneId,
333
+ "#{session_name}:#{window_index}.#{pane_index}",
334
+ ])
335
+ .toString()
336
+ .trim();
337
+ return raw || paneId;
338
+ } catch {
339
+ return paneId;
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Toast every attached client ONCE that an update is out — "run: tmux-ide
345
+ * update". The one-time guarantee lives in {@link markUpdateNotified} (persisted
346
+ * in the update cache, so it survives updater restarts, unlike the fleet
347
+ * notification's in-memory debounce). Honors the `toast` prefs kill-switch and
348
+ * no-ops unless the toast deps are wired.
349
+ */
350
+ function dispatchUpdateToast(deps: UpdaterTickDeps, version: string): void {
351
+ const { markUpdateNotified: mark, listClients, sendToasts: toast, prefs } = deps;
352
+ if (!mark || !listClients || !toast) return;
353
+ if (prefs && !prefs.toast) return;
354
+ if (!mark(version)) return; // already toasted this version
355
+ const message = `⬆ tmux-ide v${version} available — run: tmux-ide update`;
356
+ toast(listClients().map((c) => ({ client: c.client, message })));
357
+ }
358
+
359
+ /**
360
+ * Seed a single session's status var NOW (a one-off fleet scan). Called by
361
+ * `adoptSession` so a freshly-adopted bar is never blank while it waits for the
362
+ * background loop's next tick. Best-effort — a failure just defers to the loop.
363
+ */
364
+ export function seedSessionStatus(session: string): void {
365
+ try {
366
+ const projects = listTeamProjects(createStatusTracker());
367
+ writeSessionStatus(session, buildStatusline(projects, session, 12, getAppConfig().theme));
368
+ } catch {
369
+ // leave it to the updater's next tick
370
+ }
371
+ }
372
+
373
+ /** Whether the updater session is already up. */
374
+ export function updaterRunning(): boolean {
375
+ try {
376
+ return hasSession(UPDATER_SESSION);
377
+ } catch {
378
+ return false;
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Ensure the background updater is running: if the `_tmux-ide-chrome` session
384
+ * isn't up, start it detached running `tmux-ide chrome-updater`. `exec` replaces
385
+ * the shell so the pane IS the loop; killing the session stops it. `_`-internal
386
+ * so it's hidden from the bar/switcher. Best-effort — a chrome failure must
387
+ * never break adopt/launch.
388
+ */
389
+ export function startUpdaterIfNeeded(): void {
390
+ try {
391
+ if (updaterRunning()) return;
392
+ runTmux(["new-session", "-d", "-s", UPDATER_SESSION, "exec tmux-ide chrome-updater"]);
393
+ } catch {
394
+ // best-effort — the bar still works via the last-written var
395
+ }
396
+ }
397
+
398
+ /** Kill the updater session (called when the last adopted session is unadopted). */
399
+ export function stopUpdater(): void {
400
+ try {
401
+ if (updaterRunning()) runTmux(["kill-session", "-t", UPDATER_SESSION]);
402
+ } catch {
403
+ // already gone — nothing to stop
404
+ }
405
+ }
406
+
407
+ /** Read the pid the current updater owner recorded, or null when unset/garbage. */
408
+ function readUpdaterPid(): number | null {
409
+ try {
410
+ const raw = runTmux(["show-option", "-s", "-v", UPDATER_PID_OPTION]).toString().trim();
411
+ const pid = Number(raw);
412
+ return raw && Number.isInteger(pid) ? pid : null;
413
+ } catch {
414
+ // option never set (unset server user-options error out) — no owner
415
+ return null;
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Claim single-ownership of the loop. Returns false when another LIVE updater
421
+ * already holds the pid option (so a stray manual `chrome-updater` exits
422
+ * cleanly instead of double-writing). A dead/stale pid is reclaimed.
423
+ */
424
+ function claimUpdater(): boolean {
425
+ const existing = readUpdaterPid();
426
+ if (existing !== null && existing !== process.pid && isProcessAlive(existing)) return false;
427
+ try {
428
+ runTmux(["set-option", "-s", UPDATER_PID_OPTION, String(process.pid)]);
429
+ } catch {
430
+ // if we can't record the pid, still run — the session-level guard suffices
431
+ }
432
+ return true;
433
+ }
434
+
435
+ /** Release ownership on shutdown (only if we still hold it). */
436
+ function releaseUpdater(): void {
437
+ try {
438
+ if (readUpdaterPid() === process.pid) runTmux(["set-option", "-s", "-u", UPDATER_PID_OPTION]);
439
+ } catch {
440
+ // best-effort
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Run the updater loop forever (the body of `tmux-ide chrome-updater`). Claims
446
+ * single-ownership, then rewrites every adopted session's bar immediately and
447
+ * every {@link TICK_MS} thereafter behind ONE persistent tracker (so `done`
448
+ * transitions surface). Blocks — the interval keeps the event loop alive.
449
+ */
450
+ export function runUpdaterLoop(): void {
451
+ if (!claimUpdater()) return;
452
+ // Resolve the config once for the loop's lifetime — cadence + palette. A
453
+ // config change (theme/keys/cadence) takes effect on the next updater start
454
+ // (which a re-adopt triggers).
455
+ const config = getAppConfig();
456
+ const tracker = createStatusTracker();
457
+ // Persistent across ticks so `diffFleet` can spot working→done etc.
458
+ const prevState = new Map<string, AgentStatus>();
459
+ // Persistent so the notification debounce survives across ticks.
460
+ const lastNotified = new Map<string, number>();
461
+ // Persistent per-pane chip cache so we only rewrite a chip when it changed.
462
+ const chipCache = new Map<string, string>();
463
+ // The fleet snapshotter — pulsed each tick, self-throttled, writes only on a
464
+ // structural change so the fleet can be rebuilt after a tmux-server death.
465
+ const snapshotter = createSnapshotter({
466
+ collect: () => collectFleetSnapshot(),
467
+ read: readSnapshot,
468
+ write: writeSnapshot,
469
+ every: config.updater.snapshotEvery,
470
+ });
471
+ const tick = () => {
472
+ try {
473
+ runUpdaterTick({
474
+ listAdopted: listAdoptedSessions,
475
+ computeProjects: (onPane) => listTeamProjects(tracker, { onPane }),
476
+ writeStatus: writeSessionStatus,
477
+ theme: config.theme,
478
+ writeChip: writePaneChip,
479
+ chipCache,
480
+ prevState,
481
+ appendEvents,
482
+ listClients: listAttachedClients,
483
+ lastNotified,
484
+ now: () => Date.now(),
485
+ prefs: readNotificationPrefs(),
486
+ sendToasts,
487
+ sendSystem: sendSystemNotification,
488
+ locatePane: paneLocation,
489
+ maybeCheckForUpdate: () => maybeCheckForUpdate({ enabled: config.updates.check }),
490
+ markUpdateNotified,
491
+ });
492
+ } catch {
493
+ // never let one bad tick kill the loop
494
+ }
495
+ try {
496
+ snapshotter.onTick();
497
+ } catch {
498
+ // a failed snapshot just means staler disaster-recovery state
499
+ }
500
+ };
501
+ tick();
502
+ const timer = setInterval(tick, config.updater.tickMs);
503
+ const shutdown = () => {
504
+ clearInterval(timer);
505
+ releaseUpdater();
506
+ process.exit(0);
507
+ };
508
+ process.on("SIGTERM", shutdown);
509
+ process.on("SIGINT", shutdown);
510
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The first-run WELCOME card — shown ONCE, the moment tmux-ide first adopts a
3
+ * session.
4
+ *
5
+ * The discovery problem: once a session is adopted, the whole TUI is one
6
+ * keystroke away (the home cockpit, the switcher, the actions menu, the cheat
7
+ * sheet), but a brand-new user has no way to know those keys exist. The welcome
8
+ * card is the pointer: a tiny hero that names the four "unlock" keys and then
9
+ * gets out of the way forever.
10
+ *
11
+ * "Once" is enforced by a marker file (`~/.tmux-ide/welcomed`, overridable via
12
+ * `TMUX_IDE_HOME` so tests — and the dev box — never see it unexpectedly). The
13
+ * card is ALSO gated by config (`welcome.show`), so it can be suppressed without
14
+ * touching the marker.
15
+ *
16
+ * {@link buildWelcomeText} is PURE (tested); {@link maybeShowWelcomePopup} and
17
+ * the marker helpers wire the io. The CLI `welcome` command prints the card and
18
+ * waits for any key (see bin/cli.ts).
19
+ */
20
+ import { spawn } from "node:child_process";
21
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { dirname, join } from "node:path";
24
+ import { DEFAULT_KEYS, getAppConfig, type AppKeys } from "../../lib/app-config.ts";
25
+
26
+ // --- ANSI styling — the CLI's bold/dim/cyan pattern (matches ./cheatsheet.ts). ---
27
+ const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
28
+ const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
29
+ const head = (s: string) => `\x1b[1;36m${s}\x1b[0m`;
30
+
31
+ /**
32
+ * Render a tmux key name for humans: `M-` → `⌥`, `C-` → `^`, `S-` → `⇧`. Keeps
33
+ * the card's key hints sourced from the real `M-…` config values (same helper as
34
+ * the cheat sheet).
35
+ */
36
+ function renderKey(tmuxKey: string): string {
37
+ return tmuxKey.replace(/M-/g, "⌥").replace(/C-/g, "^").replace(/S-/g, "⇧");
38
+ }
39
+
40
+ /**
41
+ * Absolute path to the "already welcomed" marker: `<home>/welcomed`, where
42
+ * `<home>` is `TMUX_IDE_HOME` when set (tests / per-run overrides), else
43
+ * `~/.tmux-ide`. The env override lets a live test point the marker at a scratch
44
+ * dir so it never touches — or is confused by — the real user's marker.
45
+ */
46
+ export function welcomeMarkerPath(): string {
47
+ const home = process.env.TMUX_IDE_HOME ?? join(homedir(), ".tmux-ide");
48
+ return join(home, "welcomed");
49
+ }
50
+
51
+ /**
52
+ * Whether the first-run welcome should show: the marker file is ABSENT and the
53
+ * config hasn't disabled it (`welcome.show !== false`). A missing/garbage config
54
+ * defaults `welcome.show` to true (see app-config), so a fresh install shows it.
55
+ */
56
+ export function shouldShowWelcome(): boolean {
57
+ return !existsSync(welcomeMarkerPath()) && getAppConfig().welcome.show;
58
+ }
59
+
60
+ /**
61
+ * Create the marker file so the welcome shows only once. Best-effort — a marker
62
+ * we can't write means the card may show again, but it must never crash adopt.
63
+ */
64
+ export function markWelcomed(): void {
65
+ const path = welcomeMarkerPath();
66
+ try {
67
+ mkdirSync(dirname(path), { recursive: true });
68
+ writeFileSync(path, new Date().toISOString());
69
+ } catch {
70
+ // can't write the marker — degrade to "may show again", never throw
71
+ }
72
+ }
73
+
74
+ /**
75
+ * PURE — the welcome card text (ANSI-styled), sized for a small ~60×12 popup. A
76
+ * tiny hero naming the FOUR keys that unlock the whole TUI, sourced from the live
77
+ * key config so a rebind relabels the card. Ends with the "shows once" note so
78
+ * the user knows it won't nag.
79
+ */
80
+ export function buildWelcomeText(keys: AppKeys = DEFAULT_KEYS): string {
81
+ const lines = [
82
+ head(" You're in tmux-ide"),
83
+ dim(" your terminal, now a fleet you can see and steer."),
84
+ "",
85
+ " Four keys unlock everything:",
86
+ ` ${bold("right-click")} the actions menu — anywhere`,
87
+ ` ${bold(renderKey(keys.home).padEnd(11))} the home cockpit`,
88
+ ` ${bold(renderKey(keys.popup).padEnd(11))} switch session`,
89
+ ` ${bold(renderKey(keys.cheatsheet).padEnd(11))} all keys (the cheat sheet)`,
90
+ "",
91
+ dim(" This card shows once — press any key to close."),
92
+ ];
93
+ return lines.join("\n");
94
+ }
95
+
96
+ /**
97
+ * io — float the one-time welcome card on the CURRENT tmux client, best-effort.
98
+ *
99
+ * Called at the end of {@link ../chrome/statusline.ts adoptSession}. Gated by
100
+ * {@link shouldShowWelcome} (marker + config) AND by being inside a tmux client
101
+ * — outside tmux there's nowhere to float the popup, so we neither show it nor
102
+ * burn the one-shot (the marker stays, so the next in-tmux adopt still shows it).
103
+ *
104
+ * The popup is spawned DETACHED and unref'd so it never blocks the adopt: a
105
+ * `display-popup -E` would otherwise keep the tmux CLI alive until the user
106
+ * pressed a key. `spawn` inherits `$TMUX`, so the popup lands on the invoking
107
+ * client. The marker is written immediately after the spawn attempt so a rapid
108
+ * `adopt --all` shows the card exactly once, not once per session.
109
+ */
110
+ export function maybeShowWelcomePopup(): void {
111
+ if (!shouldShowWelcome()) return;
112
+ if (!process.env.TMUX) return;
113
+ try {
114
+ const child = spawn(
115
+ "tmux",
116
+ ["display-popup", "-E", "-w", "60", "-h", "12", "tmux-ide welcome"],
117
+ { stdio: "ignore", detached: true },
118
+ );
119
+ child.unref();
120
+ } catch {
121
+ // tmux missing / no client — best-effort, still mark so we don't retry forever
122
+ }
123
+ markWelcomed();
124
+ }