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,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,195 @@
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 notificationPrefs} +
11
+ * {@link applyKillSwitch} + {@link parseClients} are PURE (unit-tested without a
12
+ * live tmux / filesystem); {@link sendToasts}, {@link sendSystemNotification},
13
+ * {@link listAttachedClients} and {@link readNotificationPrefs} are the thin io
14
+ * wrappers. Every io path is best-effort — a failed ping must never break the
15
+ * updater loop.
16
+ */
17
+ import { execFileSync } from "node:child_process";
18
+ import { runTmux } from "@tmux-ide/tmux-bridge";
19
+ import { appConfigPath, loadAppConfig, parseAppConfig } from "../../lib/app-config.ts";
20
+ import type { AgentStatus } from "../detect/classify.ts";
21
+
22
+ /** A fleet transition this tick — the shape {@link ./events.ts diffFleet} emits. */
23
+ export interface NotifyEvent {
24
+ session: string;
25
+ from: AgentStatus | null;
26
+ to: AgentStatus;
27
+ }
28
+
29
+ /** An attached tmux client and the session it's currently viewing. */
30
+ export interface AttachedClient {
31
+ client: string;
32
+ session: string;
33
+ }
34
+
35
+ /** A toast destined for one client's status line. */
36
+ export interface ToastTarget {
37
+ client: string;
38
+ message: string;
39
+ }
40
+
41
+ /** A macOS-notification payload (title is fixed by {@link sendSystemNotification}). */
42
+ export interface SystemNotification {
43
+ message: string;
44
+ }
45
+
46
+ /** The verdict of {@link decideNotifications}. */
47
+ export interface NotifyDecision {
48
+ toasts: ToastTarget[];
49
+ system: SystemNotification[];
50
+ /** The debounce map to thread into the next tick (a copy of the input). */
51
+ nextLastNotified: Map<string, number>;
52
+ }
53
+
54
+ /** The only two states worth pinging the user over. */
55
+ const NOTIFY_STATES: ReadonlySet<AgentStatus> = new Set<AgentStatus>(["blocked", "done"]);
56
+ /** Don't re-ping the same session+state more than once inside this window. */
57
+ export const NOTIFY_DEBOUNCE_MS = 30_000;
58
+
59
+ /** PURE — the human-readable ping text for a notifiable state. */
60
+ function notifyMessage(session: string, to: AgentStatus): string {
61
+ return to === "blocked" ? `⚠ ${session} needs you (blocked)` : `✓ ${session} finished (done)`;
62
+ }
63
+
64
+ /**
65
+ * PURE — decide who to ping from this tick's transitions.
66
+ *
67
+ * Rules:
68
+ * - only `blocked` / `done` transitions qualify (working/idle etc. are noise);
69
+ * - DEBOUNCE: skip a session+state that fired within {@link NOTIFY_DEBOUNCE_MS};
70
+ * - SUPPRESS the toast for any client already viewing that session (they can
71
+ * see the bar flip themselves) — other clients still get toasted;
72
+ * - a `system` entry is produced per qualifying, non-debounced event regardless
73
+ * of clients (so the macOS path fires even with nothing attached — the
74
+ * caller gates it on prefs).
75
+ *
76
+ * Returns the toasts/system to dispatch plus `nextLastNotified` (a copy of the
77
+ * input with fresh timestamps for the events we acted on) to thread onward.
78
+ */
79
+ export function decideNotifications(
80
+ events: NotifyEvent[],
81
+ clients: AttachedClient[],
82
+ lastNotified: Map<string, number>,
83
+ nowMs: number,
84
+ ): NotifyDecision {
85
+ const nextLastNotified = new Map(lastNotified);
86
+ const toasts: ToastTarget[] = [];
87
+ const system: SystemNotification[] = [];
88
+ for (const ev of events) {
89
+ if (!NOTIFY_STATES.has(ev.to)) continue;
90
+ const key = `${ev.session}:${ev.to}`;
91
+ const last = nextLastNotified.get(key);
92
+ if (last !== undefined && nowMs - last < NOTIFY_DEBOUNCE_MS) continue;
93
+ nextLastNotified.set(key, nowMs);
94
+ const message = notifyMessage(ev.session, ev.to);
95
+ for (const c of clients) {
96
+ if (c.session === ev.session) continue; // they're already looking at it
97
+ toasts.push({ client: c.client, message });
98
+ }
99
+ system.push({ message });
100
+ }
101
+ return { toasts, system, nextLastNotified };
102
+ }
103
+
104
+ /** PURE — parse `list-clients -F '#{client_name}\t#{session_name}'` output. */
105
+ export function parseClients(lines: string[]): AttachedClient[] {
106
+ const out: AttachedClient[] = [];
107
+ for (const line of lines) {
108
+ const [client = "", session = ""] = line.split("\t");
109
+ if (client && session) out.push({ client, session });
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /** io — enumerate attached clients from the live tmux server. Never throws. */
115
+ export function listAttachedClients(): AttachedClient[] {
116
+ try {
117
+ const raw = runTmux(["list-clients", "-F", "#{client_name}\t#{session_name}"])
118
+ .toString()
119
+ .trim();
120
+ return raw ? parseClients(raw.split("\n")) : [];
121
+ } catch {
122
+ return [];
123
+ }
124
+ }
125
+
126
+ /**
127
+ * io — flash each toast on its client's status line (`-d 3000` = 3s; needs tmux
128
+ * ≥3.2). Best-effort per toast: a gone client / failed call just drops that one.
129
+ */
130
+ export function sendToasts(toasts: ToastTarget[]): void {
131
+ for (const { client, message } of toasts) {
132
+ try {
133
+ runTmux(["display-message", "-c", client, "-d", "3000", message]);
134
+ } catch {
135
+ // client vanished or display failed — never fatal
136
+ }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * io — fire a macOS notification via `osascript`. macOS-only (guarded), and
142
+ * quotes/backslashes are escaped for the AppleScript string literal. Best-effort.
143
+ */
144
+ export function sendSystemNotification(message: string): void {
145
+ if (process.platform !== "darwin") return;
146
+ const escaped = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
147
+ try {
148
+ execFileSync("osascript", ["-e", `display notification "${escaped}" with title "tmux-ide"`], {
149
+ stdio: "ignore",
150
+ });
151
+ } catch {
152
+ // osascript missing / notification blocked — never fatal
153
+ }
154
+ }
155
+
156
+ /** User notification preferences (minimal, pre-M14). */
157
+ export interface NotificationPrefs {
158
+ toast: boolean;
159
+ macos: boolean;
160
+ }
161
+
162
+ /** Defaults: tmux toasts on, macOS notifications off. */
163
+ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { toast: true, macos: false };
164
+
165
+ /**
166
+ * PURE — read `{ notifications: { toast, macos } }` out of a parsed config,
167
+ * falling back to {@link DEFAULT_NOTIFICATION_PREFS} for anything missing or of
168
+ * the wrong type. Delegates to the shared {@link parseAppConfig} so notification
169
+ * parsing can't drift from the rest of the config.
170
+ */
171
+ export function notificationPrefs(parsedConfig: unknown): NotificationPrefs {
172
+ return parseAppConfig(parsedConfig).notifications;
173
+ }
174
+
175
+ /** PURE — the `TMUX_IDE_NOTIFY=0` kill-switch: disables both channels. */
176
+ export function applyKillSwitch(
177
+ prefs: NotificationPrefs,
178
+ envValue: string | undefined,
179
+ ): NotificationPrefs {
180
+ return envValue === "0" ? { toast: false, macos: false } : prefs;
181
+ }
182
+
183
+ /** Absolute path to the shared config (honors `TMUX_IDE_CONFIG`). */
184
+ export function notifyConfigPath(): string {
185
+ return appConfigPath();
186
+ }
187
+
188
+ /**
189
+ * io — resolve effective prefs from the shared app config (defaults for a
190
+ * missing/invalid file), then apply the `TMUX_IDE_NOTIFY=0` kill-switch. Reads
191
+ * fresh so the env kill-switch is honored each call.
192
+ */
193
+ export function readNotificationPrefs(): NotificationPrefs {
194
+ return applyKillSwitch(loadAppConfig().notifications, process.env.TMUX_IDE_NOTIFY);
195
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The tmux-ide widget PANELS — every widget is a floating app dialog.
3
+ *
4
+ * The one-app milestone: instead of living as dedicated tmux panes, each widget
5
+ * opens as a `display-popup -E` over your work, one root-table keystroke away,
6
+ * esc/q to close (the popup exits when the widget process does). This mirrors the
7
+ * chrome popups (switcher `M-p` / cheat sheet `M-k`) but points each key at
8
+ * `tmux-ide popup <widget>`, which execs the bun widget synchronously inside the
9
+ * popup's PTY (see the `popup` case in bin/cli.ts).
10
+ *
11
+ * {@link PANEL_POPUPS} is the single source of truth — the widget name (the
12
+ * `tmux-ide popup <widget>` arg AND the `keys.panels.<widget>` config key), a
13
+ * menu label, and per-widget popup sizing. The status bar's adopt binds one key
14
+ * per entry, the cheat sheet lists them, and the actions menu opens them — all
15
+ * derived from this list so they never drift.
16
+ *
17
+ * Only widgets that EXIST and stand alone are here: `preview` renders whatever
18
+ * the explorer selected (via a shared `@preview_file` tmux option — nothing to
19
+ * show on its own) and `setup` is the onboarding wizard, so neither is a panel.
20
+ * `tasks`/`warroom`/`costs` were removed in the v2.6.0 front-end trim.
21
+ *
22
+ * Every builder here is PURE (tested); the io (running the popup) is the CLI's.
23
+ */
24
+ import type { AppPanelKeys } from "../../lib/app-config.ts";
25
+
26
+ /** A widget that can float as a panel popup. */
27
+ export interface PanelPopup {
28
+ /** Widget name — the `tmux-ide popup <widget>` arg + `keys.panels` field. */
29
+ widget: "explorer" | "changes" | "config";
30
+ /** Human label for the actions menu row. */
31
+ label: string;
32
+ /**
33
+ * `display-popup` size tokens (tmux `-w`/`-h`). Sized per widget: the explorer
34
+ * is a tall, narrower file column; changes is a wide diff surface; config is a
35
+ * roomy tree editor. All large — a panel floats OVER your work, so it should
36
+ * dominate while open, then vanish on esc.
37
+ */
38
+ width: string;
39
+ height: string;
40
+ }
41
+
42
+ /**
43
+ * The panel registry — the widgets bound as floating popups, in menu order.
44
+ * Keys are configurable via `~/.tmux-ide/config.json` (`keys.panels.*`); the
45
+ * defaults live in {@link ../../lib/app-config.ts}. Sizing is fixed here (tuned,
46
+ * not configurable — a deliberate scope line).
47
+ */
48
+ export const PANEL_POPUPS: PanelPopup[] = [
49
+ { widget: "explorer", label: "⊞ Files", width: "60%", height: "85%" },
50
+ { widget: "changes", label: "± Changes", width: "85%", height: "90%" },
51
+ { widget: "config", label: "⚙ Config", width: "80%", height: "85%" },
52
+ ];
53
+
54
+ /** The widget names that `tmux-ide popup <widget>` accepts (the panel set). */
55
+ export const POPUP_WIDGETS: string[] = PANEL_POPUPS.map((p) => p.widget);
56
+
57
+ /** The CLI invocation a panel popup runs (`tmux-ide popup <widget>`). */
58
+ export function panelPopupCli(widget: string): string {
59
+ return `tmux-ide popup ${widget}`;
60
+ }
61
+
62
+ /**
63
+ * The configured root-table key for a panel. `AppPanelKeys` fields are named
64
+ * for the widgets, so this is a typed lookup — the panel registry and the config
65
+ * shape stay in lockstep.
66
+ */
67
+ export function panelKey(panel: PanelPopup, keys: AppPanelKeys): string {
68
+ return keys[panel.widget];
69
+ }
70
+
71
+ /**
72
+ * PURE — the `display-popup` command STRING that floats a panel (shared by the
73
+ * actions menu's Panels rows, so a menu pick opens an identical popup to the
74
+ * key bind). `-d '#{pane_current_path}'` opens the widget on the pane's cwd, so
75
+ * the explorer/changes/config land on the project you're looking at.
76
+ */
77
+ export function panelPopupCommand(panel: PanelPopup, cli = panelPopupCli(panel.widget)): string {
78
+ return `display-popup -E -d '#{pane_current_path}' -w ${panel.width} -h ${panel.height} "${cli}"`;
79
+ }
80
+
81
+ /**
82
+ * PURE — the tmux argv that binds a panel's root-table key to its
83
+ * `display-popup`. Server-wide root-table bind (tmux has no per-session
84
+ * bind-key), mirroring the switcher's popup key — see the note on
85
+ * `unadoptSession`. Idempotent: re-binding a key overwrites it.
86
+ */
87
+ export function panelPopupBindCommand(
88
+ panel: PanelPopup,
89
+ key: string,
90
+ cli = panelPopupCli(panel.widget),
91
+ ): string[] {
92
+ return [
93
+ "bind-key",
94
+ "-n",
95
+ key,
96
+ "display-popup",
97
+ "-E",
98
+ "-d",
99
+ "#{pane_current_path}",
100
+ "-w",
101
+ panel.width,
102
+ "-h",
103
+ panel.height,
104
+ cli,
105
+ ];
106
+ }
107
+
108
+ /** PURE — the tmux argv that removes a panel's key binding. */
109
+ export function panelPopupUnbindCommand(key: string): string[] {
110
+ return ["unbind-key", "-n", key];
111
+ }