tmux-ide 2.6.0 → 2.6.1

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 +13 -5
  2. package/bin/cli.ts +1 -1
  3. package/bunfig.toml +4 -0
  4. package/package.json +11 -3
  5. package/packages/contracts/package.json +22 -0
  6. package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
  7. package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
  8. package/packages/contracts/src/actions-contract.ts +310 -0
  9. package/packages/contracts/src/actions-errors.ts +41 -0
  10. package/packages/contracts/src/domain.ts +36 -0
  11. package/packages/contracts/src/ide-config.ts +170 -0
  12. package/packages/contracts/src/index.ts +24 -0
  13. package/packages/contracts/src/lib-internal/auth.ts +13 -0
  14. package/packages/contracts/src/lib-internal/hq.ts +38 -0
  15. package/packages/contracts/src/terminals.ts +116 -0
  16. package/packages/contracts/src/tmux.ts +60 -0
  17. package/packages/contracts/src/workspace.ts +67 -0
  18. package/packages/daemon/src/agent-explain.ts +270 -0
  19. package/packages/daemon/src/attach.ts +20 -0
  20. package/packages/daemon/src/bin.ts +4 -0
  21. package/packages/daemon/src/canonical.ts +7 -0
  22. package/packages/daemon/src/cli.ts +499 -0
  23. package/packages/daemon/src/command-center/actions/contract.ts +2 -0
  24. package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
  25. package/packages/daemon/src/command-center/actions/errors.ts +105 -0
  26. package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
  27. package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
  28. package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
  29. package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
  30. package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
  31. package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
  32. package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
  33. package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
  34. package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
  35. package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
  36. package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
  37. package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
  38. package/packages/daemon/src/command-center/actions/registry.ts +149 -0
  39. package/packages/daemon/src/command-center/discovery.ts +96 -0
  40. package/packages/daemon/src/command-center/index.ts +31 -0
  41. package/packages/daemon/src/command-center/schemas.ts +85 -0
  42. package/packages/daemon/src/command-center/server.ts +1260 -0
  43. package/packages/daemon/src/command-center/ws-events.ts +316 -0
  44. package/packages/daemon/src/config.ts +549 -0
  45. package/packages/daemon/src/detect.ts +248 -0
  46. package/packages/daemon/src/doctor.ts +242 -0
  47. package/packages/daemon/src/embed.ts +5 -0
  48. package/packages/daemon/src/index.ts +12 -0
  49. package/packages/daemon/src/init.ts +211 -0
  50. package/packages/daemon/src/inspect.ts +178 -0
  51. package/packages/daemon/src/js-yaml.d.ts +10 -0
  52. package/packages/daemon/src/launch.ts +349 -0
  53. package/packages/daemon/src/lib/active-projects.ts +49 -0
  54. package/packages/daemon/src/lib/agent-discovery.ts +121 -0
  55. package/packages/daemon/src/lib/app-config.ts +336 -0
  56. package/packages/daemon/src/lib/app-settings.ts +53 -0
  57. package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
  58. package/packages/daemon/src/lib/auth/middleware.ts +56 -0
  59. package/packages/daemon/src/lib/auth/types.ts +2 -0
  60. package/packages/daemon/src/lib/auth-token.ts +5 -0
  61. package/packages/daemon/src/lib/authorship.ts +280 -0
  62. package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
  63. package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
  64. package/packages/daemon/src/lib/daemon-embed.ts +782 -0
  65. package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
  66. package/packages/daemon/src/lib/daemon.ts +79 -0
  67. package/packages/daemon/src/lib/dot-path.ts +17 -0
  68. package/packages/daemon/src/lib/errors.ts +67 -0
  69. package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
  70. package/packages/daemon/src/lib/launch-plan.ts +90 -0
  71. package/packages/daemon/src/lib/log.ts +134 -0
  72. package/packages/daemon/src/lib/output.ts +76 -0
  73. package/packages/daemon/src/lib/project-init-runner.ts +150 -0
  74. package/packages/daemon/src/lib/project-inspect.ts +80 -0
  75. package/packages/daemon/src/lib/project-onboard.ts +149 -0
  76. package/packages/daemon/src/lib/project-probe.ts +92 -0
  77. package/packages/daemon/src/lib/project-registry.ts +296 -0
  78. package/packages/daemon/src/lib/session-monitor.ts +122 -0
  79. package/packages/daemon/src/lib/session-options.ts +100 -0
  80. package/packages/daemon/src/lib/shell.ts +8 -0
  81. package/packages/daemon/src/lib/sizes.ts +36 -0
  82. package/packages/daemon/src/lib/skill-sync.ts +155 -0
  83. package/packages/daemon/src/lib/slugify.ts +10 -0
  84. package/packages/daemon/src/lib/terminals-store.ts +125 -0
  85. package/packages/daemon/src/lib/update-check.ts +298 -0
  86. package/packages/daemon/src/lib/update.ts +158 -0
  87. package/packages/daemon/src/lib/workspace-registry.ts +229 -0
  88. package/packages/daemon/src/lib/worktree.ts +289 -0
  89. package/packages/daemon/src/lib/yaml-io.ts +27 -0
  90. package/packages/daemon/src/ls.ts +40 -0
  91. package/packages/daemon/src/restart.ts +24 -0
  92. package/packages/daemon/src/restore.ts +514 -0
  93. package/packages/daemon/src/schemas/domain.ts +2 -0
  94. package/packages/daemon/src/schemas/filesystem.ts +34 -0
  95. package/packages/daemon/src/schemas/ide-config.ts +2 -0
  96. package/packages/daemon/src/schemas/index.ts +59 -0
  97. package/packages/daemon/src/schemas/inspect.ts +67 -0
  98. package/packages/daemon/src/schemas/registry.ts +55 -0
  99. package/packages/daemon/src/schemas/ws-events.ts +135 -0
  100. package/packages/daemon/src/send.ts +171 -0
  101. package/packages/daemon/src/server/README.md +15 -0
  102. package/packages/daemon/src/server/index.ts +74 -0
  103. package/packages/daemon/src/server/pty-bridge.ts +532 -0
  104. package/packages/daemon/src/server/standalone.ts +18 -0
  105. package/packages/daemon/src/server/ws-route.ts +483 -0
  106. package/packages/daemon/src/status.ts +59 -0
  107. package/packages/daemon/src/stop.ts +28 -0
  108. package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
  109. package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
  110. package/packages/daemon/src/terminal/README.md +92 -0
  111. package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
  112. package/packages/daemon/src/tui/chrome/chip.ts +30 -0
  113. package/packages/daemon/src/tui/chrome/events.ts +119 -0
  114. package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
  115. package/packages/daemon/src/tui/chrome/menu.ts +289 -0
  116. package/packages/daemon/src/tui/chrome/notify.ts +195 -0
  117. package/packages/daemon/src/tui/chrome/panels.ts +111 -0
  118. package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
  119. package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
  120. package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
  121. package/packages/daemon/src/tui/chrome/updater.ts +428 -0
  122. package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
  123. package/packages/daemon/src/tui/compiled.ts +113 -0
  124. package/packages/daemon/src/tui/detect/classify.ts +193 -0
  125. package/packages/daemon/src/tui/detect/manifest-loader.ts +192 -0
  126. package/packages/daemon/src/tui/detect/manifest.ts +184 -0
  127. package/packages/daemon/src/tui/detect/manifests.ts +226 -0
  128. package/packages/daemon/src/tui/detect/process-tree.ts +197 -0
  129. package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
  130. package/packages/daemon/src/tui/integrations/claude.ts +176 -0
  131. package/packages/daemon/src/tui/integrations/offer.ts +145 -0
  132. package/packages/daemon/src/tui/main.ts +70 -0
  133. package/packages/daemon/src/tui/mirror/control-client.ts +143 -0
  134. package/packages/daemon/src/tui/mirror/control.ts +97 -0
  135. package/packages/daemon/src/tui/mirror/pane-mirror.ts +81 -0
  136. package/packages/daemon/src/tui/mirror/viewer.tsx +166 -0
  137. package/packages/daemon/src/tui/team/CONTROL.md +50 -0
  138. package/packages/daemon/src/tui/team/entry.ts +11 -0
  139. package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
  140. package/packages/daemon/src/tui/team/home.ts +170 -0
  141. package/packages/daemon/src/tui/team/index.tsx +1521 -0
  142. package/packages/daemon/src/tui/team/input.ts +34 -0
  143. package/packages/daemon/src/tui/team/keymap.ts +127 -0
  144. package/packages/daemon/src/tui/team/mouse.ts +29 -0
  145. package/packages/daemon/src/tui/team/nav.ts +31 -0
  146. package/packages/daemon/src/tui/team/preview.ts +34 -0
  147. package/packages/daemon/src/tui/team/projects.ts +191 -0
  148. package/packages/daemon/src/tui/team/report.ts +73 -0
  149. package/packages/daemon/src/tui/team/sessions.ts +344 -0
  150. package/packages/daemon/src/tui/team/tree.ts +62 -0
  151. package/packages/daemon/src/types.ts +13 -0
  152. package/packages/daemon/src/ui/index.ts +32 -0
  153. package/packages/daemon/src/ui/terminal/index.ts +9 -0
  154. package/packages/daemon/src/ui/types.ts +91 -0
  155. package/packages/daemon/src/ui/web/base.css +80 -0
  156. package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
  157. package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
  158. package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
  159. package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
  160. package/packages/daemon/src/ui/web/hooks.ts +106 -0
  161. package/packages/daemon/src/ui/web/index.ts +27 -0
  162. package/packages/daemon/src/ui/web/render.ts +77 -0
  163. package/packages/daemon/src/ui/web/utils/color.ts +27 -0
  164. package/packages/daemon/src/validate.ts +217 -0
  165. package/packages/daemon/src/widgets/changes/README.md +3 -0
  166. package/packages/daemon/src/widgets/changes/index.tsx +691 -0
  167. package/packages/daemon/src/widgets/config/README.md +3 -0
  168. package/packages/daemon/src/widgets/config/index.tsx +481 -0
  169. package/packages/daemon/src/widgets/explorer/README.md +3 -0
  170. package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
  171. package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
  172. package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
  173. package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
  174. package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
  175. package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
  176. package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
  177. package/packages/daemon/src/widgets/lib/files.ts +88 -0
  178. package/packages/daemon/src/widgets/lib/git.ts +88 -0
  179. package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
  180. package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
  181. package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
  182. package/packages/daemon/src/widgets/lib/theme.ts +194 -0
  183. package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
  184. package/packages/daemon/src/widgets/preview/README.md +3 -0
  185. package/packages/daemon/src/widgets/preview/index.tsx +416 -0
  186. package/packages/daemon/src/widgets/resolve.ts +121 -0
  187. package/packages/daemon/src/widgets/setup/README.md +3 -0
  188. package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
  189. package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
  190. package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
  191. package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
  192. package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
  193. package/packages/daemon/src/widgets/setup/index.tsx +341 -0
  194. package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
  195. package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
  196. package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
  197. package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
  198. package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
  199. package/packages/tmux-bridge/package.json +22 -0
  200. package/packages/tmux-bridge/src/errors.ts +28 -0
  201. package/packages/tmux-bridge/src/index.ts +31 -0
  202. package/packages/tmux-bridge/src/monitor.ts +77 -0
  203. package/packages/tmux-bridge/src/panes.ts +136 -0
  204. package/packages/tmux-bridge/src/runner.test.ts +501 -0
  205. package/packages/tmux-bridge/src/runner.ts +91 -0
  206. package/packages/tmux-bridge/src/sessions.ts +126 -0
  207. package/packages/tmux-bridge/src/targeting.test.ts +107 -0
  208. package/packages/tmux-bridge/src/targeting.ts +90 -0
  209. package/scripts/postinstall.js +26 -2
@@ -0,0 +1,428 @@
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
+ listAttachedClients,
38
+ readNotificationPrefs,
39
+ sendSystemNotification,
40
+ sendToasts,
41
+ type AttachedClient,
42
+ type NotificationPrefs,
43
+ type NotifyEvent,
44
+ type ToastTarget,
45
+ } from "./notify.ts";
46
+ import {
47
+ collectFleetSnapshot,
48
+ createSnapshotter,
49
+ readSnapshot,
50
+ writeSnapshot,
51
+ } from "./snapshot.ts";
52
+ import { buildStatusline } from "./statusline.ts";
53
+
54
+ /** Per-session user option holding the pre-rendered status-bar string. */
55
+ export const STATUS_OPTION = "@tmux_ide_status";
56
+ /** Per-PANE user option holding the pre-rendered agent chip (read by pane-border-format). */
57
+ export const CHIP_OPTION = "@tmux_ide_chip";
58
+ /** Per-session marker option set on adopt so the updater can enumerate adopted sessions. */
59
+ export const ADOPTED_OPTION = "@tmux_ide_adopted";
60
+ /** The hidden internal session that hosts the updater loop. */
61
+ export const UPDATER_SESSION = "_tmux-ide-chrome";
62
+ /** Server option holding the running updater's pid (a lightweight single-owner guard). */
63
+ export const UPDATER_PID_OPTION = "@tmux_ide_updater_pid";
64
+ /** Default tick cadence — overridable via `updater.tickMs` in the app config. */
65
+ export const TICK_MS = 2000;
66
+
67
+ /**
68
+ * PURE — parse `list-sessions -F '#{session_name}\t#{@tmux_ide_adopted}'` output
69
+ * into the list of adopted session names. A session is adopted when its marker
70
+ * field is exactly `"1"` (sessions without the option render an empty field —
71
+ * verified on tmux 3.6, where user options ARE readable in list-sessions
72
+ * formats).
73
+ */
74
+ export function adoptedSessionsFrom(lines: string[]): string[] {
75
+ const out: string[] = [];
76
+ for (const line of lines) {
77
+ const [name = "", flag = ""] = line.split("\t");
78
+ if (name && flag === "1") out.push(name);
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /** Enumerate adopted sessions from the live tmux server. Never throws. */
84
+ export function listAdoptedSessions(): string[] {
85
+ try {
86
+ const raw = runTmux(["list-sessions", "-F", `#{session_name}\t#{${ADOPTED_OPTION}}`])
87
+ .toString()
88
+ .trim();
89
+ return raw ? adoptedSessionsFrom(raw.split("\n")) : [];
90
+ } catch {
91
+ return [];
92
+ }
93
+ }
94
+
95
+ /** Write a session's pre-rendered status var. */
96
+ function writeSessionStatus(session: string, value: string): void {
97
+ runTmux(["set-option", "-t", session, STATUS_OPTION, value]);
98
+ }
99
+
100
+ /** Write a pane's pre-rendered chip var (empty string clears it → title fallback). */
101
+ function writePaneChip(paneId: string, value: string): void {
102
+ runTmux(["set-option", "-p", "-t", paneId, CHIP_OPTION, value]);
103
+ }
104
+
105
+ /** The io a single tick needs — injectable so the orchestration is unit-tested. */
106
+ export interface UpdaterTickDeps {
107
+ listAdopted: () => string[];
108
+ /**
109
+ * Compute the fleet. The tick passes an `onPane` collector so per-pane detail
110
+ * (agent + status) is recovered during the SAME scan the bars are built from
111
+ * — the loop wires this to `listTeamProjects(tracker, { onPane })`. Callers
112
+ * that don't need chips (tests) may ignore the argument.
113
+ */
114
+ computeProjects: (onPane: (pane: PaneDetail) => void) => TeamProject[];
115
+ writeStatus: (session: string, value: string) => void;
116
+ /**
117
+ * The shared palette threaded into {@link buildStatusline} / {@link paneChip}
118
+ * (default {@link DEFAULT_THEME}). The loop resolves it once from the app
119
+ * config so a re-theme applies on the next updater start.
120
+ */
121
+ theme?: AppTheme;
122
+ /**
123
+ * Per-pane chip write (optional). When wired, the tick writes each ADOPTED
124
+ * session's panes a `@tmux_ide_chip` pane option (`agent · status`, or empty
125
+ * for a non-agent pane). Only CHANGED chips are written — `chipCache` holds
126
+ * the last value per pane and is mutated in place across ticks so the steady
127
+ * state costs zero set-options.
128
+ */
129
+ writeChip?: (paneId: string, value: string) => void;
130
+ chipCache?: Map<string, string>;
131
+ /**
132
+ * Transition tracking (optional). When both are supplied, the tick diffs the
133
+ * WHOLE fleet against `prevState` and appends any transitions via
134
+ * `appendEvents`, mutating `prevState` in place to the fresh state. Omitted by
135
+ * callers/tests that only care about the status bars.
136
+ */
137
+ prevState?: Map<string, AgentStatus>;
138
+ appendEvents?: (events: AgentEventInit[]) => void;
139
+ /**
140
+ * Notification dispatch (optional). When wired alongside `prevState`, the tick
141
+ * turns THIS tick's transitions into user pings — toasts on attached clients
142
+ * and/or a macOS notification — via {@link decideNotifications}, gated on
143
+ * `prefs`. `lastNotified` is the persistent debounce map, mutated in place.
144
+ * All deps-injected so the routing is unit-tested without a live tmux.
145
+ */
146
+ listClients?: () => AttachedClient[];
147
+ lastNotified?: Map<string, number>;
148
+ now?: () => number;
149
+ prefs?: NotificationPrefs;
150
+ sendToasts?: (toasts: ToastTarget[]) => void;
151
+ sendSystem?: (message: string) => void;
152
+ /**
153
+ * Update-flow surfacing (optional). When wired, the tick calls this cheap,
154
+ * cache-backed check each tick (throttled internally to 24h; it kicks off a
155
+ * background registry refresh). When it reports an available update the tick
156
+ * threads the `⬆ v<latest>` dock segment into every adopted session's bar and,
157
+ * via {@link markUpdateNotified}, fires a ONE-time toast for that version. Both
158
+ * deps-injected so the surfacing is unit-tested without a live tmux/network.
159
+ */
160
+ maybeCheckForUpdate?: () => UpdateStatus;
161
+ markUpdateNotified?: (version: string) => boolean;
162
+ }
163
+
164
+ /**
165
+ * PURE — the reserved dock segment for an available update: the clickable
166
+ * `⬆ v<latest>` chip (accent-colored, wrapped in a `user|update` mouse range so a
167
+ * click floats the update popup — see {@link ./statusline.ts statusClickBindCommand}).
168
+ * Empty string when there's nothing to offer, so it takes no space on the bar.
169
+ */
170
+ export function updateSegment(status: UpdateStatus, theme: AppTheme): string {
171
+ if (!status.updateAvailable || !status.latest) return "";
172
+ return `#[range=user|update]#[fg=${theme.accent}]⬆ v${status.latest}#[default]#[norange]`;
173
+ }
174
+
175
+ /** Flatten the project view to a flat per-session status list for {@link diffFleet}. */
176
+ function fleetStatuses(projects: TeamProject[]): Array<{ name: string; status: AgentStatus }> {
177
+ return projects.flatMap((p) => p.sessions.map((s) => ({ name: s.name, status: s.status })));
178
+ }
179
+
180
+ /**
181
+ * One tick: if any session is adopted, compute the fleet ONCE and write each
182
+ * adopted session its own {@link buildStatusline} (its name flagged active so
183
+ * the per-session highlight is correct). PURE given its deps — no tmux, no
184
+ * fleet scan when nothing is adopted.
185
+ *
186
+ * When `prevState`/`appendEvents` are wired, it also detects state TRANSITIONS
187
+ * across the whole fleet (not just adopted sessions) and appends them to the
188
+ * event log — the updater is the one process that sees every tick, so it's the
189
+ * natural place to emit history.
190
+ */
191
+ export function runUpdaterTick(deps: UpdaterTickDeps): void {
192
+ const adopted = deps.listAdopted();
193
+ if (adopted.length === 0) return;
194
+ const theme = deps.theme ?? DEFAULT_THEME;
195
+ // Collect per-pane detail during the fleet scan so chips need no second pass.
196
+ const panes: PaneDetail[] = [];
197
+ const projects = deps.computeProjects((pane) => panes.push(pane));
198
+ // The dock-first update surface: a cheap cache read that also kicks off the
199
+ // throttled background registry refresh. Threads the "⬆ v<latest>" chip into
200
+ // every bar this tick when an update is pending.
201
+ const update = deps.maybeCheckForUpdate?.();
202
+ const extra = update ? updateSegment(update, theme) : "";
203
+ for (const session of adopted) {
204
+ deps.writeStatus(session, buildStatusline(projects, session, 12, theme, extra));
205
+ }
206
+ writeChips(deps, adopted, panes, theme);
207
+ if (update?.updateAvailable && update.latest) dispatchUpdateToast(deps, update.latest);
208
+ if (deps.prevState && deps.appendEvents) {
209
+ const { events, state } = diffFleet(deps.prevState, fleetStatuses(projects));
210
+ deps.prevState.clear();
211
+ for (const [name, status] of state) deps.prevState.set(name, status);
212
+ if (events.length > 0) {
213
+ deps.appendEvents(events);
214
+ dispatchNotifications(deps, events);
215
+ }
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Write each adopted session's panes their agent chip — but only when the chip
221
+ * CHANGED since last tick (per-pane cache), so a steady fleet issues zero
222
+ * set-options. No-op unless both `writeChip` and `chipCache` are wired. Panes of
223
+ * non-adopted (user, un-adopted) sessions are skipped: we only paint borders on
224
+ * sessions we've adopted.
225
+ */
226
+ function writeChips(
227
+ deps: UpdaterTickDeps,
228
+ adopted: string[],
229
+ panes: PaneDetail[],
230
+ theme: AppTheme,
231
+ ): void {
232
+ const { writeChip, chipCache } = deps;
233
+ if (!writeChip || !chipCache) return;
234
+ const adoptedSet = new Set(adopted);
235
+ for (const pane of panes) {
236
+ if (!adoptedSet.has(pane.sessionName)) continue;
237
+ const chip = paneChip(pane.agent, pane.status, theme);
238
+ if (chipCache.get(pane.paneId) === chip) continue;
239
+ chipCache.set(pane.paneId, chip);
240
+ writeChip(pane.paneId, chip);
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Ping the user about who needs them from this tick's transitions. Only runs
246
+ * when the notification deps are wired AND at least one channel is enabled;
247
+ * `lastNotified` is mutated in place so the debounce persists across ticks.
248
+ */
249
+ function dispatchNotifications(deps: UpdaterTickDeps, events: NotifyEvent[]): void {
250
+ const { listClients, lastNotified, now, prefs, sendToasts: toast, sendSystem } = deps;
251
+ if (!listClients || !lastNotified || !now || !prefs) return;
252
+ if (!prefs.toast && !prefs.macos) return;
253
+ const decision = decideNotifications(events, listClients(), lastNotified, now());
254
+ lastNotified.clear();
255
+ for (const [key, ts] of decision.nextLastNotified) lastNotified.set(key, ts);
256
+ if (prefs.toast && toast) toast(decision.toasts);
257
+ if (prefs.macos && sendSystem) {
258
+ for (const { message } of decision.system) sendSystem(message);
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Toast every attached client ONCE that an update is out — "run: tmux-ide
264
+ * update". The one-time guarantee lives in {@link markUpdateNotified} (persisted
265
+ * in the update cache, so it survives updater restarts, unlike the fleet
266
+ * notification's in-memory debounce). Honors the `toast` prefs kill-switch and
267
+ * no-ops unless the toast deps are wired.
268
+ */
269
+ function dispatchUpdateToast(deps: UpdaterTickDeps, version: string): void {
270
+ const { markUpdateNotified: mark, listClients, sendToasts: toast, prefs } = deps;
271
+ if (!mark || !listClients || !toast) return;
272
+ if (prefs && !prefs.toast) return;
273
+ if (!mark(version)) return; // already toasted this version
274
+ const message = `⬆ tmux-ide v${version} available — run: tmux-ide update`;
275
+ toast(listClients().map((c) => ({ client: c.client, message })));
276
+ }
277
+
278
+ /**
279
+ * Seed a single session's status var NOW (a one-off fleet scan). Called by
280
+ * `adoptSession` so a freshly-adopted bar is never blank while it waits for the
281
+ * background loop's next tick. Best-effort — a failure just defers to the loop.
282
+ */
283
+ export function seedSessionStatus(session: string): void {
284
+ try {
285
+ const projects = listTeamProjects(createStatusTracker());
286
+ writeSessionStatus(session, buildStatusline(projects, session, 12, getAppConfig().theme));
287
+ } catch {
288
+ // leave it to the updater's next tick
289
+ }
290
+ }
291
+
292
+ /** Whether the updater session is already up. */
293
+ export function updaterRunning(): boolean {
294
+ try {
295
+ return hasSession(UPDATER_SESSION);
296
+ } catch {
297
+ return false;
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Ensure the background updater is running: if the `_tmux-ide-chrome` session
303
+ * isn't up, start it detached running `tmux-ide chrome-updater`. `exec` replaces
304
+ * the shell so the pane IS the loop; killing the session stops it. `_`-internal
305
+ * so it's hidden from the bar/switcher. Best-effort — a chrome failure must
306
+ * never break adopt/launch.
307
+ */
308
+ export function startUpdaterIfNeeded(): void {
309
+ try {
310
+ if (updaterRunning()) return;
311
+ runTmux(["new-session", "-d", "-s", UPDATER_SESSION, "exec tmux-ide chrome-updater"]);
312
+ } catch {
313
+ // best-effort — the bar still works via the last-written var
314
+ }
315
+ }
316
+
317
+ /** Kill the updater session (called when the last adopted session is unadopted). */
318
+ export function stopUpdater(): void {
319
+ try {
320
+ if (updaterRunning()) runTmux(["kill-session", "-t", UPDATER_SESSION]);
321
+ } catch {
322
+ // already gone — nothing to stop
323
+ }
324
+ }
325
+
326
+ /** Read the pid the current updater owner recorded, or null when unset/garbage. */
327
+ function readUpdaterPid(): number | null {
328
+ try {
329
+ const raw = runTmux(["show-option", "-s", "-v", UPDATER_PID_OPTION]).toString().trim();
330
+ const pid = Number(raw);
331
+ return raw && Number.isInteger(pid) ? pid : null;
332
+ } catch {
333
+ // option never set (unset server user-options error out) — no owner
334
+ return null;
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Claim single-ownership of the loop. Returns false when another LIVE updater
340
+ * already holds the pid option (so a stray manual `chrome-updater` exits
341
+ * cleanly instead of double-writing). A dead/stale pid is reclaimed.
342
+ */
343
+ function claimUpdater(): boolean {
344
+ const existing = readUpdaterPid();
345
+ if (existing !== null && existing !== process.pid && isProcessAlive(existing)) return false;
346
+ try {
347
+ runTmux(["set-option", "-s", UPDATER_PID_OPTION, String(process.pid)]);
348
+ } catch {
349
+ // if we can't record the pid, still run — the session-level guard suffices
350
+ }
351
+ return true;
352
+ }
353
+
354
+ /** Release ownership on shutdown (only if we still hold it). */
355
+ function releaseUpdater(): void {
356
+ try {
357
+ if (readUpdaterPid() === process.pid) runTmux(["set-option", "-s", "-u", UPDATER_PID_OPTION]);
358
+ } catch {
359
+ // best-effort
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Run the updater loop forever (the body of `tmux-ide chrome-updater`). Claims
365
+ * single-ownership, then rewrites every adopted session's bar immediately and
366
+ * every {@link TICK_MS} thereafter behind ONE persistent tracker (so `done`
367
+ * transitions surface). Blocks — the interval keeps the event loop alive.
368
+ */
369
+ export function runUpdaterLoop(): void {
370
+ if (!claimUpdater()) return;
371
+ // Resolve the config once for the loop's lifetime — cadence + palette. A
372
+ // config change (theme/keys/cadence) takes effect on the next updater start
373
+ // (which a re-adopt triggers).
374
+ const config = getAppConfig();
375
+ const tracker = createStatusTracker();
376
+ // Persistent across ticks so `diffFleet` can spot working→done etc.
377
+ const prevState = new Map<string, AgentStatus>();
378
+ // Persistent so the notification debounce survives across ticks.
379
+ const lastNotified = new Map<string, number>();
380
+ // Persistent per-pane chip cache so we only rewrite a chip when it changed.
381
+ const chipCache = new Map<string, string>();
382
+ // The fleet snapshotter — pulsed each tick, self-throttled, writes only on a
383
+ // structural change so the fleet can be rebuilt after a tmux-server death.
384
+ const snapshotter = createSnapshotter({
385
+ collect: () => collectFleetSnapshot(),
386
+ read: readSnapshot,
387
+ write: writeSnapshot,
388
+ every: config.updater.snapshotEvery,
389
+ });
390
+ const tick = () => {
391
+ try {
392
+ runUpdaterTick({
393
+ listAdopted: listAdoptedSessions,
394
+ computeProjects: (onPane) => listTeamProjects(tracker, { onPane }),
395
+ writeStatus: writeSessionStatus,
396
+ theme: config.theme,
397
+ writeChip: writePaneChip,
398
+ chipCache,
399
+ prevState,
400
+ appendEvents,
401
+ listClients: listAttachedClients,
402
+ lastNotified,
403
+ now: () => Date.now(),
404
+ prefs: readNotificationPrefs(),
405
+ sendToasts,
406
+ sendSystem: sendSystemNotification,
407
+ maybeCheckForUpdate: () => maybeCheckForUpdate({ enabled: config.updates.check }),
408
+ markUpdateNotified,
409
+ });
410
+ } catch {
411
+ // never let one bad tick kill the loop
412
+ }
413
+ try {
414
+ snapshotter.onTick();
415
+ } catch {
416
+ // a failed snapshot just means staler disaster-recovery state
417
+ }
418
+ };
419
+ tick();
420
+ const timer = setInterval(tick, config.updater.tickMs);
421
+ const shutdown = () => {
422
+ clearInterval(timer);
423
+ releaseUpdater();
424
+ process.exit(0);
425
+ };
426
+ process.on("SIGTERM", shutdown);
427
+ process.on("SIGINT", shutdown);
428
+ }
@@ -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
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Resolution for the TUI surfaces across BOTH distribution modes.
3
+ *
4
+ * Dev checkout: the surfaces are OpenTUI/Solid `.tsx` spawned by `bun` (the
5
+ * bunfig preload supplies the JSX transform). Installed via npm/pnpm/bun: there
6
+ * is no checkout and no `bun`, so we fall back to the compiled `tmux-ide-tui`
7
+ * binary (see scripts/build-tui.mjs) which bundles every surface behind a
8
+ * `<surface> [flags]` argv dispatcher and needs no runtime.
9
+ *
10
+ * Order is "checkout first, binary second": a dev machine that happens to have
11
+ * built the binary still uses its live `.tsx` sources. The binary is consulted
12
+ * only when the checkout sources or `bun` are missing.
13
+ *
14
+ * {@link resolveTuiLaunch} is a PURE decision (unit-tested); {@link findCompiledTui}
15
+ * and {@link isBunAvailable} are the thin io probes that feed it.
16
+ */
17
+ import { existsSync } from "node:fs";
18
+ import { dirname, resolve } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { execFileSync } from "node:child_process";
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+
24
+ export type TuiLaunch =
25
+ | { mode: "bun"; bin: "bun"; argv: string[] }
26
+ | { mode: "binary"; bin: string; argv: string[] }
27
+ | { mode: "unavailable"; reasons: string[] };
28
+
29
+ export interface TuiResolveInput {
30
+ /** Dispatcher surface token: team | explorer | changes | preview | config | setup | sidebar. */
31
+ surface: string;
32
+ /** The checkout `.tsx` entry for this surface. */
33
+ scriptPath: string;
34
+ /** Surface flags (`--session=…`, `--dir=…`, `--theme=…`, …). */
35
+ args: string[];
36
+ /** Whether {@link scriptPath} exists (checkout present). */
37
+ checkoutExists: boolean;
38
+ /** Whether the `bun` runtime is on PATH. */
39
+ bunAvailable: boolean;
40
+ /** Absolute path to the compiled `tmux-ide-tui`, or null if not found. */
41
+ compiledBinary: string | null;
42
+ }
43
+
44
+ /**
45
+ * PURE — decide how to launch a surface. Bun-from-checkout wins when both are
46
+ * present (dev); the compiled binary is the installed fallback; otherwise the
47
+ * caller surfaces an actionable message built from `reasons`.
48
+ */
49
+ export function resolveTuiLaunch(input: TuiResolveInput): TuiLaunch {
50
+ if (input.checkoutExists && input.bunAvailable) {
51
+ return { mode: "bun", bin: "bun", argv: [input.scriptPath, ...input.args] };
52
+ }
53
+ if (input.compiledBinary) {
54
+ return { mode: "binary", bin: input.compiledBinary, argv: [input.surface, ...input.args] };
55
+ }
56
+ const reasons: string[] = [];
57
+ if (!input.checkoutExists) {
58
+ reasons.push(
59
+ "the TUI widget sources are absent (reinstall tmux-ide — releases since v2.6.1 ship them)",
60
+ );
61
+ }
62
+ if (!input.bunAvailable) {
63
+ reasons.push("the `bun` runtime is not installed (https://bun.sh)");
64
+ }
65
+ reasons.push(
66
+ "no compiled `tmux-ide-tui` binary was found (build one with `pnpm build:tui`, or reinstall a release that ships it)",
67
+ );
68
+ return { mode: "unavailable", reasons };
69
+ }
70
+
71
+ // Candidate locations for the compiled binary, relative to an anchor dir. The
72
+ // installed tarball ships it at packages/daemon/dist/tui/tmux-ide-tui and the
73
+ // bin is bin/cli.js, so a bin-anchored `../packages/daemon/dist/...` hits it;
74
+ // the other rels cover unbundled daemon layouts and a co-located binary.
75
+ const BINARY_RELS = [
76
+ "../packages/daemon/dist/tui/tmux-ide-tui",
77
+ "../../dist/tui/tmux-ide-tui",
78
+ "../dist/tui/tmux-ide-tui",
79
+ "dist/tui/tmux-ide-tui",
80
+ "tmux-ide-tui",
81
+ ];
82
+
83
+ /**
84
+ * io — locate the compiled `tmux-ide-tui`. Honors `TMUX_IDE_TUI_BIN` (absolute
85
+ * override, e.g. for tests / custom installs), then probes bin- and
86
+ * module-relative candidates. Returns null when none exists.
87
+ */
88
+ export function findCompiledTui(): string | null {
89
+ const override = process.env.TMUX_IDE_TUI_BIN;
90
+ if (override) return existsSync(override) ? override : null;
91
+
92
+ const anchors: string[] = [];
93
+ if (process.argv[1]) anchors.push(dirname(process.argv[1]));
94
+ anchors.push(__dirname);
95
+
96
+ for (const anchor of anchors) {
97
+ for (const rel of BINARY_RELS) {
98
+ const candidate = resolve(anchor, rel);
99
+ if (existsSync(candidate)) return candidate;
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+
105
+ /** io — is the `bun` runtime callable? */
106
+ export function isBunAvailable(): boolean {
107
+ try {
108
+ execFileSync("bun", ["--version"], { stdio: "ignore" });
109
+ return true;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }