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,289 @@
1
+ /**
2
+ * The tmux-ide right-click actions menu — native chrome.
3
+ *
4
+ * Right-clicking the chrome row (or pressing `⌥m` on an adopted session) floats
5
+ * tmux's own `display-menu`: a small popup of tmux-ide actions (switch session,
6
+ * cheat sheet, jump to any live session, new session, kill session). tmux draws
7
+ * the whole thing — there is no OpenTUI/bun boot and no per-tick cost — so this
8
+ * module only produces the argument vector.
9
+ *
10
+ * The menu content is LIVE (the session list changes), so it can't be baked into
11
+ * a static bind. Instead the bind runs our CLI via `run-shell`, and the CLI
12
+ * rebuilds the menu args fresh and hands them to `tmux display-menu -c <client>`
13
+ * (see the `menu` case in bin/cli.ts). This mirrors how the switcher/cheat-sheet
14
+ * popups defer their content to a fresh CLI invocation.
15
+ *
16
+ * `buildMenu` and the bind/unbind command builders are PURE (tested); the CLI
17
+ * `menu` command wires the io (resolve the client, run display-menu).
18
+ */
19
+ import { DEFAULT_THEME, type AppTheme } from "../../lib/app-config.ts";
20
+ import type { AgentStatus } from "../detect/classify.ts";
21
+ import {
22
+ switcherPopupCommand,
23
+ homePopupCommand,
24
+ updatePopupCommand,
25
+ MENU_KEY,
26
+ MENU_PANE_KEY,
27
+ MENU_STATUS_KEY,
28
+ } from "./statusline.ts";
29
+ import type { UpdateStatus } from "../../lib/update-check.ts";
30
+ import { cheatsheetPopupCommand } from "./cheatsheet.ts";
31
+ import { PANEL_POPUPS, panelPopupCommand } from "./panels.ts";
32
+
33
+ /** Single-char menu mnemonics for the panel rows (parallel to PANEL_POPUPS). */
34
+ const PANEL_MENU_KEYS = ["e", "g", ","];
35
+
36
+ /**
37
+ * PURE — glyph + tmux colour per status for the menu's session rows, from the
38
+ * shared theme tokens. Colours are `theme.status.*` (working amber, blocked red,
39
+ * done blue, idle green). `idle` uses the hollow `inactive` glyph so a quiet
40
+ * session reads as "nothing running" at a glance; `unknown` uses the `·` "no
41
+ * signal" marker; everything else the filled `active` glyph.
42
+ */
43
+ function menuGlyph(status: AgentStatus, theme: AppTheme): { glyph: string; colour: string } {
44
+ const glyph =
45
+ status === "idle" ? theme.glyphs.inactive : status === "unknown" ? "·" : theme.glyphs.active;
46
+ return { glyph, colour: theme.status[status] };
47
+ }
48
+
49
+ /** Max session rows the menu lists (keys 1..8). Extra sessions are dropped. */
50
+ const MAX_SESSION_ITEMS = 8;
51
+
52
+ /**
53
+ * Quote a session name for embedding in a tmux command string (the target of a
54
+ * menu item's `switch-client -t <name>`). Single-quote it and escape any embedded
55
+ * single quote the shell way (`'\''`) — tmux's lexer builds words like the shell,
56
+ * so this keeps a weird name (spaces, `;`, `#`, `$`) inert instead of leaking as
57
+ * extra command tokens. Verified live on tmux 3.6.
58
+ */
59
+ export function menuQuoteName(name: string): string {
60
+ return `'${name.replace(/'/g, `'\\''`)}'`;
61
+ }
62
+
63
+ /** The styled `#[fg=…]glyph#[default] name` label for a session row. */
64
+ function sessionLabel(session: { name: string; status: AgentStatus }, theme: AppTheme): string {
65
+ const g = menuGlyph(session.status, theme);
66
+ return `#[fg=${g.colour}]${g.glyph}#[default] ${session.name}`;
67
+ }
68
+
69
+ /**
70
+ * PURE — the `display-menu` argument vector (everything AFTER `display-menu`,
71
+ * minus the runtime `-c <client>`/placement flags the CLI prepends): a
72
+ * `tmux-ide` title, then four groups joined by separator lines —
73
+ *
74
+ * 1. `⌂ Home cockpit` (h) → the same home popup M-h / the bar trigger opens;
75
+ * `⧉ Switch session…` (s) → the same switcher popup M-p / the bar trigger
76
+ * opens; `? Cheat sheet` (k) → the same cheat-sheet popup M-k opens. All
77
+ * reuse the exact popup command strings so a menu pick behaves identically.
78
+ * 2. the widget PANELS (Files e / Changes g / Config ,) → the same floating
79
+ * `display-popup` each panel's root-table key opens (see ./panels.ts).
80
+ * 3. up to 8 live sessions, each `«glyph» <name>` keyed 1..8 →
81
+ * `switch-client -t <name>` (the name quoted so odd names stay inert). The
82
+ * menu commands run in the client that opened the menu, so the switch lands
83
+ * on the right client.
84
+ * 4. `+ New session…` (n) → a `command-prompt` that creates + switches to a
85
+ * named session; `✕ Kill this session` (x) → a confirmed `kill-session`.
86
+ *
87
+ * A separator is a single empty-string argument (tmux's display-menu convention).
88
+ * Empty groups (no sessions) collapse so two separators never stack.
89
+ */
90
+ export function buildMenu(
91
+ sessions: Array<{ name: string; status: AgentStatus }>,
92
+ theme: AppTheme = DEFAULT_THEME,
93
+ update?: UpdateStatus,
94
+ ): string[] {
95
+ // When an update is pending, lead with a highlighted row that opens the same
96
+ // update popup the dock's `⬆ v<latest>` chip does. Its own group so it reads
97
+ // as a callout above the navigation actions.
98
+ const updateItems: string[] =
99
+ update?.updateAvailable && update.latest
100
+ ? [`#[fg=${theme.accent}]⬆ Update available (v${update.latest})`, "u", updatePopupCommand()]
101
+ : [];
102
+
103
+ const header: string[] = [
104
+ "⌂ Home cockpit",
105
+ "h",
106
+ homePopupCommand(),
107
+ "⧉ Switch session…",
108
+ "s",
109
+ switcherPopupCommand(),
110
+ "? Cheat sheet",
111
+ "k",
112
+ cheatsheetPopupCommand(),
113
+ "▏ Toggle sidebar",
114
+ "b",
115
+ // run-shell format-expands #{session_name}, so the toggle targets whatever
116
+ // session the opening client is viewing (bind args don't expand; run-shell
117
+ // does — the same trick the menu bind itself uses).
118
+ `run-shell "tmux-ide sidebar-toggle --session '#{session_name}'"`,
119
+ ];
120
+
121
+ // The widget panels — each row opens the same floating popup its root-table
122
+ // key does (esc/q closes). Menu commands run in the opening client, so the
123
+ // popup lands on its pane's cwd.
124
+ const panelItems: string[] = [];
125
+ PANEL_POPUPS.forEach((panel, i) => {
126
+ panelItems.push(panel.label, PANEL_MENU_KEYS[i] ?? "", panelPopupCommand(panel));
127
+ });
128
+
129
+ const sessionItems: string[] = [];
130
+ sessions.slice(0, MAX_SESSION_ITEMS).forEach((session, i) => {
131
+ sessionItems.push(
132
+ sessionLabel(session, theme),
133
+ String(i + 1),
134
+ `switch-client -t ${menuQuoteName(session.name)}`,
135
+ );
136
+ });
137
+
138
+ const footer: string[] = [
139
+ "+ New session…",
140
+ "n",
141
+ `command-prompt -p "new session name:" "new-session -d -s '%%' ; switch-client -t '%%'"`,
142
+ "⎇ New worktree…",
143
+ "w",
144
+ // Prompt for a branch, then create a git worktree + session for it. The
145
+ // command-prompt template is SINGLE-quoted so the inner run-shell arg can be
146
+ // DOUBLE-quoted — run-shell only format-expands #{session_name} inside double
147
+ // quotes (single quotes suppress it). `%%` is command-prompt's branch
148
+ // substitution; --session carries the current session so the CLI resolves the
149
+ // repo from its cwd (run-shell's own cwd is the tmux server's, not the pane's).
150
+ // Quoting verified live on tmux 3.6 with branch `feat/x-1`.
151
+ `command-prompt -p "worktree branch:" 'run-shell "tmux-ide worktree create %% --session #{session_name}"'`,
152
+ "✕ Kill this session",
153
+ "x",
154
+ `confirm-before -p "kill session #S? (y/n)" kill-session`,
155
+ ];
156
+
157
+ // Join non-empty groups with a single separator line between them.
158
+ const items: string[] = [];
159
+ for (const group of [updateItems, header, panelItems, sessionItems, footer]) {
160
+ if (group.length === 0) continue;
161
+ if (items.length > 0) items.push(""); // separator
162
+ items.push(...group);
163
+ }
164
+ return ["-T", "tmux-ide", ...items];
165
+ }
166
+
167
+ // Re-export the key constants (defined in statusline.ts alongside POPUP_KEY so
168
+ // the cheat sheet can list them without importing this module) for callers that
169
+ // reach for them via the menu module.
170
+ export { MENU_KEY, MENU_PANE_KEY, MENU_STATUS_KEY };
171
+
172
+ /**
173
+ * The tmux command run by every menu bind: invoke our CLI to (re)build and show
174
+ * the menu. `run-shell` format-expands `#{client_name}` into `--client` (bind
175
+ * args themselves do NOT expand; run-shell DOES — verified live on tmux 3.6), and
176
+ * `-b` detaches so tmux's key/mouse dispatch isn't blocked while the menu is open.
177
+ * The CLI resolves the client from `--client`, falling back to the
178
+ * most-recently-active attached client, and runs display-menu (all tmux calls
179
+ * time-capped so the bind can never hang).
180
+ */
181
+ function menuRunShellArgs(menuCmd: string): string[] {
182
+ return ["run-shell", "-b", `${menuCmd} --client '#{client_name}'`];
183
+ }
184
+
185
+ /**
186
+ * Like {@link menuRunShellArgs} but for a PANE mouse bind: forwards the click
187
+ * position, only knowable at fire time inside the mouse binding.
188
+ *
189
+ * Coordinate model (measured live on tmux 3.6):
190
+ * - `#{mouse_x}`/`#{mouse_y}` are PANE-RELATIVE for pane mouse events — the
191
+ * screen position is `pane_left + mouse_x` / `pane_top + mouse_y`, computed
192
+ * in the bind string itself with tmux format arithmetic (`#{e|+:…}`).
193
+ * - `display-menu -y` is the menu's BOTTOM edge, so passing the pointer row
194
+ * opens the menu upward from the click (tmux clamps/flips near edges).
195
+ * Keyboard binds (M-m) deliberately stay coordless — centered is right when
196
+ * there's no pointer.
197
+ */
198
+ function menuPaneMouseRunShellArgs(menuCmd: string): string[] {
199
+ return [
200
+ "run-shell",
201
+ "-b",
202
+ `${menuCmd} --client '#{client_name}' --x '#{e|+:#{pane_left},#{mouse_x}}' --y '#{e|+:#{pane_top},#{mouse_y}}'`,
203
+ ];
204
+ }
205
+
206
+ /**
207
+ * Status-line (dock) variant: status mouse events carry no pane and their
208
+ * `#{mouse_x}` is already the screen column. The dock sits at the bottom, so
209
+ * `#{client_height}` as the bottom edge opens the menu directly ABOVE the dock
210
+ * at the clicked column.
211
+ */
212
+ function menuStatusMouseRunShellArgs(menuCmd: string): string[] {
213
+ return [
214
+ "run-shell",
215
+ "-b",
216
+ `${menuCmd} --client '#{client_name}' --x '#{mouse_x}' --y '#{client_height}'`,
217
+ ];
218
+ }
219
+
220
+ /**
221
+ * PURE — the `display-menu` position flags (`-x <n> -y <n>`) for a click at
222
+ * `(x, y)`, or `[]` when either coord is absent or non-numeric (the keyboard
223
+ * path, or an unexpanded `#{mouse_*}` literal) so tmux falls back to centering.
224
+ * `-x` is the menu's LEFT edge; `-y` is the menu's BOTTOM edge on tmux 3.6, so
225
+ * the pointer coords put the menu just above-right of the click.
226
+ */
227
+ export function menuPositionArgs(x: string | undefined, y: string | undefined): string[] {
228
+ const nx = parseCoord(x);
229
+ const ny = parseCoord(y);
230
+ if (nx === null || ny === null) return [];
231
+ // One row ABOVE the pointer: -y is the menu's bottom edge, and if the pointer
232
+ // cell were ON the menu, the user's trailing button-release would land on the
233
+ // border and dismiss it instantly (even with -O — measured live). A -1 offset
234
+ // puts the release just BELOW the menu, which -O survives.
235
+ return ["-x", String(nx), "-y", String(Math.max(0, ny - 1))];
236
+ }
237
+
238
+ /** Parse a non-negative integer coord, or null for anything else. */
239
+ function parseCoord(value: string | undefined): number | null {
240
+ if (typeof value !== "string" || !/^\d+$/.test(value)) return null;
241
+ const n = Number(value);
242
+ return Number.isInteger(n) ? n : null;
243
+ }
244
+
245
+ /**
246
+ * PURE — the tmux argv that binds {@link MENU_KEY} (`M-m`, root table) to open
247
+ * the actions menu. Server-wide bind, mirroring the switcher's M-p — see the note
248
+ * on `unadoptSession`.
249
+ */
250
+ export function menuBindCommand(menuCmd = "tmux-ide menu", key = MENU_KEY): string[] {
251
+ return ["bind-key", "-n", key, ...menuRunShellArgs(menuCmd)];
252
+ }
253
+
254
+ /**
255
+ * PURE — the tmux argv that binds a RIGHT-click on the chrome row
256
+ * ({@link MENU_STATUS_KEY}) to open the same actions menu. Unlike the left-click
257
+ * router this is range-independent: a right-click anywhere on the bar opens the
258
+ * menu, so the session-list content is what matters, not the click target.
259
+ */
260
+ export function menuStatusBindCommand(menuCmd = "tmux-ide menu"): string[] {
261
+ return ["bind-key", "-n", MENU_STATUS_KEY, ...menuStatusMouseRunShellArgs(menuCmd)];
262
+ }
263
+
264
+ /**
265
+ * PURE — the tmux argv that binds a RIGHT-click on ANY pane body
266
+ * ({@link MENU_PANE_KEY}) to open the same actions menu, so the menu is reachable
267
+ * everywhere, not only from the dock row. Forwards the click position (see
268
+ * {@link menuPaneMouseRunShellArgs}) so the menu opens at the pointer. Panes whose app
269
+ * captures the mouse (vim, agent TUIs) eat the event — graceful degradation,
270
+ * `M-m` still works there.
271
+ */
272
+ export function menuPaneBindCommand(menuCmd = "tmux-ide menu"): string[] {
273
+ return ["bind-key", "-n", MENU_PANE_KEY, ...menuPaneMouseRunShellArgs(menuCmd)];
274
+ }
275
+
276
+ /** PURE — the tmux argv that removes the {@link MENU_KEY} binding. */
277
+ export function menuUnbindCommand(key = MENU_KEY): string[] {
278
+ return ["unbind-key", "-n", key];
279
+ }
280
+
281
+ /** PURE — the tmux argv that removes the {@link MENU_STATUS_KEY} binding. */
282
+ export function menuStatusUnbindCommand(): string[] {
283
+ return ["unbind-key", "-n", MENU_STATUS_KEY];
284
+ }
285
+
286
+ /** PURE — the tmux argv that removes the {@link MENU_PANE_KEY} binding. */
287
+ export function menuPaneUnbindCommand(): string[] {
288
+ return ["unbind-key", "-n", MENU_PANE_KEY];
289
+ }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * The "who needs me" loop — turn fleet transitions into user-facing pings.
3
+ *
4
+ * The chrome {@link ./updater.ts updater} already sees every session's state
5
+ * transition each tick (via {@link ./events.ts diffFleet}). Watching the status
6
+ * bar for a `blocked`/`done` flip is a chore, so this module fans those two
7
+ * signals out to the human: a tmux toast on each attached client
8
+ * (`display-message -c`), and optionally a macOS notification.
9
+ *
10
+ * Split as usual: {@link decideNotifications} + {@link notifyMessage} +
11
+ * {@link enabledStates} + {@link inQuietHours} + {@link parseNotificationPrefs}
12
+ * + {@link applyKillSwitch} + {@link parseClients} + {@link terminalNotifierArgs}
13
+ * are PURE (unit-tested without a live tmux / filesystem); {@link sendToasts},
14
+ * {@link sendSystemNotification}, {@link hasTerminalNotifier},
15
+ * {@link listAttachedClients} and {@link readNotificationPrefs} are the thin io
16
+ * wrappers. Every io path is best-effort — a failed ping must never break the
17
+ * updater loop.
18
+ */
19
+ import { execFileSync } from "node:child_process";
20
+ import { existsSync, readFileSync } from "node:fs";
21
+ import { runTmux } from "@tmux-ide/tmux-bridge";
22
+ import { appConfigPath, parseAppConfig } from "../../lib/app-config.ts";
23
+ import type { AgentStatus } from "../detect/classify.ts";
24
+
25
+ /**
26
+ * A fleet transition this tick — the shape {@link ./events.ts diffFleet} emits,
27
+ * ENRICHED by the updater with the pane's resolved `agent` id and human
28
+ * `location` (`session:window.pane`) so the ping can name who needs the user.
29
+ * Both are optional: a caller that can't resolve them falls back to a generic
30
+ * `agent` label and the bare session name (see {@link notifyMessage}).
31
+ */
32
+ export interface NotifyEvent {
33
+ session: string;
34
+ from: AgentStatus | null;
35
+ to: AgentStatus;
36
+ /** Resolved agent id (e.g. `claude`), or null/absent when unknown. */
37
+ agent?: string | null;
38
+ /** Human location `session:window.pane` (e.g. `myproj:1.2`); falls back to `session`. */
39
+ location?: string;
40
+ }
41
+
42
+ /** An attached tmux client and the session it's currently viewing. */
43
+ export interface AttachedClient {
44
+ client: string;
45
+ session: string;
46
+ }
47
+
48
+ /** A toast destined for one client's status line. */
49
+ export interface ToastTarget {
50
+ client: string;
51
+ message: string;
52
+ }
53
+
54
+ /**
55
+ * A macOS-notification payload. `session` rides along so the click-through path
56
+ * ({@link terminalNotifierArgs}) can focus the right session on click.
57
+ */
58
+ export interface SystemNotification {
59
+ message: string;
60
+ session: string;
61
+ }
62
+
63
+ /** The verdict of {@link decideNotifications}. */
64
+ export interface NotifyDecision {
65
+ toasts: ToastTarget[];
66
+ system: SystemNotification[];
67
+ /** The debounce map to thread into the next tick (a copy of the input). */
68
+ nextLastNotified: Map<string, number>;
69
+ }
70
+
71
+ /** The only two states worth pinging the user over. */
72
+ const NOTIFY_STATES: ReadonlySet<AgentStatus> = new Set<AgentStatus>(["blocked", "done"]);
73
+ /**
74
+ * Don't re-ping the same session+state more than once inside this window. This
75
+ * is what tames a FLAPPING agent (working↔blocked every few seconds): the first
76
+ * `blocked` fires, and every subsequent `blocked` inside the window is dropped,
77
+ * so a flap notifies at most once per 30s instead of on every bounce.
78
+ */
79
+ export const NOTIFY_DEBOUNCE_MS = 30_000;
80
+ /** Cap the ping text so a macOS banner never truncates mid-word (banners clip ~200+). */
81
+ export const NOTIFY_MAX_LEN = 120;
82
+
83
+ /** PURE — the trailing clause for a notifiable state. */
84
+ function statusPhrase(to: AgentStatus): string {
85
+ return to === "blocked" ? "needs input" : "finished";
86
+ }
87
+
88
+ /**
89
+ * PURE — the human-readable ping for an (enriched) transition, e.g.
90
+ * `claude blocked · myproj:1.2 — needs input`. Falls back to a generic `agent`
91
+ * label and the bare session name when the updater couldn't resolve the pane's
92
+ * agent/location. Clamped to {@link NOTIFY_MAX_LEN} so it fits a macOS banner.
93
+ */
94
+ export function notifyMessage(ev: NotifyEvent): string {
95
+ const agent = ev.agent && ev.agent.length > 0 ? ev.agent : "agent";
96
+ const where = ev.location && ev.location.length > 0 ? ev.location : ev.session;
97
+ const text = `${agent} ${ev.to} · ${where} — ${statusPhrase(ev.to)}`;
98
+ return text.length > NOTIFY_MAX_LEN ? `${text.slice(0, NOTIFY_MAX_LEN - 1)}…` : text;
99
+ }
100
+
101
+ /**
102
+ * PURE — which states this user has opted into pinging on, from the
103
+ * `onBlocked`/`onDone` prefs. Empty when both are off (→ no notifications).
104
+ */
105
+ export function enabledStates(prefs: NotificationPrefs): ReadonlySet<AgentStatus> {
106
+ const states = new Set<AgentStatus>();
107
+ if (prefs.onBlocked) states.add("blocked");
108
+ if (prefs.onDone) states.add("done");
109
+ return states;
110
+ }
111
+
112
+ /**
113
+ * PURE — decide who to ping from this tick's transitions.
114
+ *
115
+ * Rules:
116
+ * - only states in `states` qualify (default {@link NOTIFY_STATES}; the caller
117
+ * narrows it via {@link enabledStates} to honor `onBlocked`/`onDone`);
118
+ * - DEBOUNCE: skip a session+state that fired within {@link NOTIFY_DEBOUNCE_MS}
119
+ * — this is the flap guard (see {@link NOTIFY_DEBOUNCE_MS});
120
+ * - SUPPRESS the toast for any client already viewing that session (they can
121
+ * see the bar flip themselves) — other clients still get toasted;
122
+ * - a `system` entry is produced per qualifying, non-debounced event regardless
123
+ * of clients (so the macOS path fires even with nothing attached — the
124
+ * caller gates it on prefs / quiet hours).
125
+ *
126
+ * Returns the toasts/system to dispatch plus `nextLastNotified` (a copy of the
127
+ * input with fresh timestamps for the events we acted on) to thread onward.
128
+ */
129
+ export function decideNotifications(
130
+ events: NotifyEvent[],
131
+ clients: AttachedClient[],
132
+ lastNotified: Map<string, number>,
133
+ nowMs: number,
134
+ states: ReadonlySet<AgentStatus> = NOTIFY_STATES,
135
+ ): NotifyDecision {
136
+ const nextLastNotified = new Map(lastNotified);
137
+ const toasts: ToastTarget[] = [];
138
+ const system: SystemNotification[] = [];
139
+ for (const ev of events) {
140
+ if (!states.has(ev.to)) continue;
141
+ const key = `${ev.session}:${ev.to}`;
142
+ const last = nextLastNotified.get(key);
143
+ if (last !== undefined && nowMs - last < NOTIFY_DEBOUNCE_MS) continue;
144
+ nextLastNotified.set(key, nowMs);
145
+ const message = notifyMessage(ev);
146
+ for (const c of clients) {
147
+ if (c.session === ev.session) continue; // they're already looking at it
148
+ toasts.push({ client: c.client, message });
149
+ }
150
+ system.push({ message, session: ev.session });
151
+ }
152
+ return { toasts, system, nextLastNotified };
153
+ }
154
+
155
+ /** PURE — parse `list-clients -F '#{client_name}\t#{session_name}'` output. */
156
+ export function parseClients(lines: string[]): AttachedClient[] {
157
+ const out: AttachedClient[] = [];
158
+ for (const line of lines) {
159
+ const [client = "", session = ""] = line.split("\t");
160
+ if (client && session) out.push({ client, session });
161
+ }
162
+ return out;
163
+ }
164
+
165
+ /** io — enumerate attached clients from the live tmux server. Never throws. */
166
+ export function listAttachedClients(): AttachedClient[] {
167
+ try {
168
+ const raw = runTmux(["list-clients", "-F", "#{client_name}\t#{session_name}"])
169
+ .toString()
170
+ .trim();
171
+ return raw ? parseClients(raw.split("\n")) : [];
172
+ } catch {
173
+ return [];
174
+ }
175
+ }
176
+
177
+ /**
178
+ * io — flash each toast on its client's status line (`-d 3000` = 3s; needs tmux
179
+ * ≥3.2). Best-effort per toast: a gone client / failed call just drops that one.
180
+ */
181
+ export function sendToasts(toasts: ToastTarget[]): void {
182
+ for (const { client, message } of toasts) {
183
+ try {
184
+ runTmux(["display-message", "-c", client, "-d", "3000", message]);
185
+ } catch {
186
+ // client vanished or display failed — never fatal
187
+ }
188
+ }
189
+ }
190
+
191
+ /** io — whether `terminal-notifier` is on PATH (enables click-through banners). */
192
+ export function hasTerminalNotifier(): boolean {
193
+ try {
194
+ execFileSync("which", ["terminal-notifier"], { stdio: "ignore" });
195
+ return true;
196
+ } catch {
197
+ return false;
198
+ }
199
+ }
200
+
201
+ /** PURE — single-quote a string for safe interpolation into a `/bin/sh -c` command. */
202
+ function shellSingleQuote(value: string): string {
203
+ return `'${value.replace(/'/g, `'\\''`)}'`;
204
+ }
205
+
206
+ /**
207
+ * PURE — the `terminal-notifier` argv for a click-through banner: clicking it
208
+ * runs `tmux switch-client -t <session>`, jumping the user's most-recent client
209
+ * straight to the session that needs them. (`switch-client` without `-c` targets
210
+ * the last-active client — the best we can do without knowing which terminal the
211
+ * click came from.)
212
+ */
213
+ export function terminalNotifierArgs(n: SystemNotification): string[] {
214
+ return [
215
+ "-title",
216
+ "tmux-ide",
217
+ "-message",
218
+ n.message,
219
+ "-execute",
220
+ `tmux switch-client -t ${shellSingleQuote(n.session)}`,
221
+ ];
222
+ }
223
+
224
+ /**
225
+ * io — fire a macOS notification. macOS-only (guarded), best-effort. When
226
+ * `terminal-notifier` is available we use it for a CLICK-THROUGH banner
227
+ * ({@link terminalNotifierArgs}) that focuses the session on click; otherwise we
228
+ * fall back to `osascript`, whose `display notification` has NO click action —
229
+ * so on a stock machine the banner informs but can't be clicked to jump.
230
+ */
231
+ export function sendSystemNotification(n: SystemNotification): void {
232
+ if (process.platform !== "darwin") return;
233
+ try {
234
+ if (hasTerminalNotifier()) {
235
+ execFileSync("terminal-notifier", terminalNotifierArgs(n), { stdio: "ignore" });
236
+ return;
237
+ }
238
+ const escaped = n.message.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
239
+ execFileSync("osascript", ["-e", `display notification "${escaped}" with title "tmux-ide"`], {
240
+ stdio: "ignore",
241
+ });
242
+ } catch {
243
+ // osascript / terminal-notifier missing or notification blocked — never fatal
244
+ }
245
+ }
246
+
247
+ /** A "quiet hours" window — banners are suppressed while the wall clock is inside it. */
248
+ export interface QuietHours {
249
+ /** Local `HH:MM` the window opens (e.g. `22:00`). */
250
+ start: string;
251
+ /** Local `HH:MM` the window closes (e.g. `08:00`). */
252
+ end: string;
253
+ }
254
+
255
+ /** User notification preferences. */
256
+ export interface NotificationPrefs {
257
+ /** Master switch — false silences every channel. */
258
+ enabled: boolean;
259
+ /** In-terminal status-line toasts. */
260
+ toast: boolean;
261
+ /** macOS system banners. */
262
+ macos: boolean;
263
+ /** Ping when an agent goes `blocked`. */
264
+ onBlocked: boolean;
265
+ /** Ping when an agent goes `done`. */
266
+ onDone: boolean;
267
+ /** Optional local-time window that suppresses macOS banners (events still record). */
268
+ quietHours: QuietHours | null;
269
+ }
270
+
271
+ /** Defaults: enabled, tmux toasts on, macOS banners off, both states pinged, no quiet window. */
272
+ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
273
+ enabled: true,
274
+ toast: true,
275
+ macos: false,
276
+ onBlocked: true,
277
+ onDone: true,
278
+ quietHours: null,
279
+ };
280
+
281
+ /** A plain object, or `{}` for anything that isn't one (arrays included). */
282
+ function asObject(value: unknown): Record<string, unknown> {
283
+ return value && typeof value === "object" && !Array.isArray(value)
284
+ ? (value as Record<string, unknown>)
285
+ : {};
286
+ }
287
+
288
+ /** A boolean, else the default. */
289
+ function pickBool(value: unknown, fallback: boolean): boolean {
290
+ return typeof value === "boolean" ? value : fallback;
291
+ }
292
+
293
+ /** PURE — parse `HH:MM` to minutes-since-midnight, or null when malformed / out of range. */
294
+ export function parseHHMM(value: unknown): number | null {
295
+ if (typeof value !== "string") return null;
296
+ const m = /^(\d{2}):(\d{2})$/.exec(value.trim());
297
+ if (!m) return null;
298
+ const hours = Number(m[1]);
299
+ const minutes = Number(m[2]);
300
+ if (hours > 23 || minutes > 59) return null;
301
+ return hours * 60 + minutes;
302
+ }
303
+
304
+ /**
305
+ * PURE — is `now` (local time) inside the quiet window? Handles a window that
306
+ * WRAPS midnight (`22:00`–`08:00`). A null window, or a malformed / zero-width
307
+ * (`start === end`) one, is never quiet.
308
+ */
309
+ export function inQuietHours(now: Date, quiet: QuietHours | null): boolean {
310
+ if (!quiet) return false;
311
+ const start = parseHHMM(quiet.start);
312
+ const end = parseHHMM(quiet.end);
313
+ if (start === null || end === null || start === end) return false;
314
+ const nowMin = now.getHours() * 60 + now.getMinutes();
315
+ return start < end ? nowMin >= start && nowMin < end : nowMin >= start || nowMin < end;
316
+ }
317
+
318
+ /** PURE — parse a `{ start, end }` quiet-hours block, or null when absent / malformed. */
319
+ function parseQuietHours(value: unknown): QuietHours | null {
320
+ const o = asObject(value);
321
+ const start = typeof o.start === "string" ? o.start : null;
322
+ const end = typeof o.end === "string" ? o.end : null;
323
+ if (start === null || end === null) return null;
324
+ if (parseHHMM(start) === null || parseHHMM(end) === null) return null;
325
+ return { start, end };
326
+ }
327
+
328
+ /**
329
+ * PURE — resolve full {@link NotificationPrefs} from a parsed config object.
330
+ *
331
+ * `toast`/`macos` delegate to the shared {@link parseAppConfig} (so those two
332
+ * can't drift from the rest of the config); the polish-era fields
333
+ * (`enabled`/`onBlocked`/`onDone`/`quietHours`) are read straight off the raw
334
+ * `notifications` block here — `parseAppConfig` doesn't model them, so reading
335
+ * them locally keeps this change inside the chrome layer. Anything missing or
336
+ * mistyped falls back to {@link DEFAULT_NOTIFICATION_PREFS}.
337
+ */
338
+ export function parseNotificationPrefs(rawConfig: unknown): NotificationPrefs {
339
+ const base = parseAppConfig(rawConfig).notifications;
340
+ const n = asObject(asObject(rawConfig).notifications);
341
+ return {
342
+ enabled: pickBool(n.enabled, DEFAULT_NOTIFICATION_PREFS.enabled),
343
+ toast: base.toast,
344
+ macos: base.macos,
345
+ onBlocked: pickBool(n.onBlocked, DEFAULT_NOTIFICATION_PREFS.onBlocked),
346
+ onDone: pickBool(n.onDone, DEFAULT_NOTIFICATION_PREFS.onDone),
347
+ quietHours: parseQuietHours(n.quietHours),
348
+ };
349
+ }
350
+
351
+ /** PURE — the `TMUX_IDE_NOTIFY=0` kill-switch: disables everything. */
352
+ export function applyKillSwitch(
353
+ prefs: NotificationPrefs,
354
+ envValue: string | undefined,
355
+ ): NotificationPrefs {
356
+ return envValue === "0" ? { ...prefs, enabled: false, toast: false, macos: false } : prefs;
357
+ }
358
+
359
+ /** Absolute path to the shared config (honors `TMUX_IDE_CONFIG`). */
360
+ export function notifyConfigPath(): string {
361
+ return appConfigPath();
362
+ }
363
+
364
+ /** io — read + JSON-parse the raw config file; undefined when missing / malformed. */
365
+ function readRawConfig(): unknown {
366
+ const path = appConfigPath();
367
+ if (!existsSync(path)) return undefined;
368
+ try {
369
+ return JSON.parse(readFileSync(path, "utf-8"));
370
+ } catch {
371
+ return undefined;
372
+ }
373
+ }
374
+
375
+ /**
376
+ * io — resolve effective prefs from the shared app config (defaults for a
377
+ * missing/invalid file), then apply the `TMUX_IDE_NOTIFY=0` kill-switch. Reads
378
+ * fresh so the env kill-switch and config edits are honored each call.
379
+ */
380
+ export function readNotificationPrefs(): NotificationPrefs {
381
+ return applyKillSwitch(parseNotificationPrefs(readRawConfig()), process.env.TMUX_IDE_NOTIFY);
382
+ }