tmux-ide 2.6.1 → 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 (83) hide show
  1. package/README.md +14 -9
  2. package/bin/cli.js +1013 -516
  3. package/bin/cli.ts +62 -5
  4. package/package.json +9 -6
  5. package/packages/daemon/dist/agent-explain.d.ts +8 -1
  6. package/packages/daemon/dist/agent-explain.js +19 -3
  7. package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
  8. package/packages/daemon/dist/lib/tui-binary.js +130 -0
  9. package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
  10. package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
  11. package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
  12. package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
  13. package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
  14. package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
  15. package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
  16. package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
  17. package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
  18. package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
  19. package/packages/daemon/src/agent-explain.ts +34 -6
  20. package/packages/daemon/src/lib/app-config.ts +93 -2
  21. package/packages/daemon/src/lib/tui-binary.ts +165 -0
  22. package/packages/daemon/src/tui/chrome/notify.ts +221 -34
  23. package/packages/daemon/src/tui/chrome/updater.ts +89 -7
  24. package/packages/daemon/src/tui/compiled.ts +11 -3
  25. package/packages/daemon/src/tui/detect/classify.ts +15 -0
  26. package/packages/daemon/src/tui/detect/manifest-loader.ts +2 -1
  27. package/packages/daemon/src/tui/detect/manifest.ts +15 -0
  28. package/packages/daemon/src/tui/detect/manifests.ts +161 -33
  29. package/packages/daemon/src/tui/detect/process-tree.ts +20 -0
  30. package/packages/daemon/src/tui/main.ts +13 -1
  31. package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
  32. package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
  33. package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
  34. package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
  35. package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
  36. package/packages/daemon/src/tui/mirror/blit.ts +186 -0
  37. package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
  38. package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
  39. package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
  40. package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
  41. package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
  42. package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
  43. package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
  44. package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
  45. package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
  46. package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
  47. package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
  48. package/packages/daemon/src/tui/mirror/palette.ts +274 -0
  49. package/packages/daemon/src/tui/mirror/pane-mirror.ts +499 -19
  50. package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
  51. package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
  52. package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
  53. package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
  54. package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
  55. package/packages/daemon/src/tui/mirror/selection.ts +262 -0
  56. package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
  57. package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
  58. package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
  59. package/packages/daemon/src/tui/mirror/spans.ts +46 -0
  60. package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
  61. package/packages/daemon/src/tui/team/entry.ts +34 -7
  62. package/packages/daemon/src/tui/team/report.ts +11 -1
  63. package/packages/daemon/src/tui/team/sessions.ts +102 -13
  64. package/scripts/build-tui.mjs +11 -4
  65. package/scripts/perf-mirror.mjs +313 -0
  66. package/skill/SKILL.md +22 -0
  67. package/templates/AGENTS.md +14 -7
  68. package/templates/agent-team-monorepo.yml +8 -0
  69. package/templates/agent-team-nextjs.yml +8 -0
  70. package/templates/agent-team.yml +10 -0
  71. package/templates/convex.yml +2 -0
  72. package/templates/default.yml +11 -5
  73. package/templates/go.yml +4 -0
  74. package/templates/missions.yml +6 -0
  75. package/templates/nextjs.yml +4 -0
  76. package/templates/python.yml +4 -0
  77. package/templates/skills/backend.md +5 -12
  78. package/templates/skills/frontend.md +5 -12
  79. package/templates/skills/general-worker.md +5 -12
  80. package/templates/skills/researcher.md +7 -12
  81. package/templates/skills/reviewer.md +7 -16
  82. package/templates/vite.yml +4 -0
  83. package/packages/daemon/src/tui/mirror/viewer.tsx +0 -166
@@ -20,9 +20,9 @@
20
20
  * per-concern readers ({@link ../tui/chrome/notify.ts}, {@link ../restore.ts})
21
21
  * now delegate here.
22
22
  */
23
- import { existsSync, readFileSync } from "node:fs";
23
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
24
24
  import { homedir } from "node:os";
25
- import { join } from "node:path";
25
+ import { dirname, join } from "node:path";
26
26
  import type { AgentStatus } from "../tui/detect/classify.ts";
27
27
 
28
28
  // ---------------------------------------------------------------------------
@@ -133,6 +133,17 @@ export interface AppIntegrations {
133
133
  offer: boolean;
134
134
  }
135
135
 
136
+ /** The unified-app front-door decision (M22.6). */
137
+ export interface AppApp {
138
+ /**
139
+ * Whether bare `tmux-ide` (no ide.yml here, no `--team`) launches the unified
140
+ * app (`tmux-ide app`) instead of the classic team cockpit. Default false —
141
+ * the flip is opt-in until the default-entry decision is made. `tmux-ide team`
142
+ * (the explicit cockpit) and a project's `ide.yml` auto-launch are unaffected.
143
+ */
144
+ frontDoor: boolean;
145
+ }
146
+
136
147
  /** Worktree flow config (`tmux-ide worktree`). */
137
148
  export interface AppWorktrees {
138
149
  /**
@@ -154,6 +165,7 @@ export interface AppConfig {
154
165
  welcome: AppWelcome;
155
166
  integrations: AppIntegrations;
156
167
  worktrees: AppWorktrees;
168
+ app: AppApp;
157
169
  }
158
170
 
159
171
  // ---------------------------------------------------------------------------
@@ -190,6 +202,7 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
190
202
  welcome: { show: true },
191
203
  integrations: { offer: true },
192
204
  worktrees: { dir: "" },
205
+ app: { frontDoor: false },
193
206
  };
194
207
 
195
208
  /** The default theme tokens — the fallback threaded into the pure builders. */
@@ -245,6 +258,7 @@ export function parseAppConfig(input: unknown): AppConfig {
245
258
  const welcome = asObject(root.welcome);
246
259
  const integrations = asObject(root.integrations);
247
260
  const worktrees = asObject(root.worktrees);
261
+ const app = asObject(root.app);
248
262
  return {
249
263
  keys: {
250
264
  popup: pickString(keys.popup, D.keys.popup),
@@ -287,6 +301,7 @@ export function parseAppConfig(input: unknown): AppConfig {
287
301
  welcome: { show: pickBool(welcome.show, D.welcome.show) },
288
302
  integrations: { offer: pickBool(integrations.offer, D.integrations.offer) },
289
303
  worktrees: { dir: pickString(worktrees.dir, D.worktrees.dir) },
304
+ app: { frontDoor: pickBool(app.frontDoor, D.app.frontDoor) },
290
305
  };
291
306
  }
292
307
 
@@ -334,3 +349,79 @@ export function getAppConfig(): AppConfig {
334
349
  export function _resetForTests(): void {
335
350
  cached = null;
336
351
  }
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // Writes (M22.4 — the settings surface persists through here)
355
+ // ---------------------------------------------------------------------------
356
+
357
+ /**
358
+ * io — the RAW parsed config file as the user wrote it (`{}` when missing or
359
+ * malformed). Writers merge over THIS, not over {@link parseAppConfig}'s output:
360
+ * round-tripping through the parser would materialize every default into the
361
+ * hand-editable file and silently DROP fields the typed shape doesn't model yet
362
+ * (e.g. the notification polish fields `notifications.enabled` /
363
+ * `notifications.quietHours` that {@link ../tui/chrome/notify.ts} reads raw).
364
+ */
365
+ export function loadRawAppConfig(): Record<string, unknown> {
366
+ const path = appConfigPath();
367
+ if (!existsSync(path)) return {};
368
+ try {
369
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
370
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
371
+ ? (parsed as Record<string, unknown>)
372
+ : {};
373
+ } catch {
374
+ return {};
375
+ }
376
+ }
377
+
378
+ /** A partial config write: plain objects merge recursively, scalars/arrays
379
+ * replace, and an explicit `undefined` DELETES the key (how "reset to
380
+ * defaults" removes a block so the parser's defaults take over). */
381
+ export type AppConfigPatch = { [key: string]: unknown };
382
+
383
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
384
+ return !!value && typeof value === "object" && !Array.isArray(value);
385
+ }
386
+
387
+ /**
388
+ * PURE — merge `patch` into `raw` without touching either input. Objects merge
389
+ * key-by-key (recursively), anything else replaces, and a key whose patch value
390
+ * is `undefined` is REMOVED from the result. Unknown user fields survive — the
391
+ * merge only visits keys the patch names.
392
+ */
393
+ export function mergeConfigPatch(
394
+ raw: Record<string, unknown>,
395
+ patch: AppConfigPatch,
396
+ ): Record<string, unknown> {
397
+ const out: Record<string, unknown> = { ...raw };
398
+ for (const [key, value] of Object.entries(patch)) {
399
+ if (value === undefined) {
400
+ delete out[key];
401
+ } else if (isPlainObject(value) && isPlainObject(out[key])) {
402
+ out[key] = mergeConfigPatch(out[key] as Record<string, unknown>, value as AppConfigPatch);
403
+ } else if (isPlainObject(value)) {
404
+ out[key] = mergeConfigPatch({}, value as AppConfigPatch);
405
+ } else {
406
+ out[key] = value;
407
+ }
408
+ }
409
+ return out;
410
+ }
411
+
412
+ /**
413
+ * io — apply a patch to the config file ATOMICALLY (temp + rename, the same
414
+ * discipline as the editor save): read raw → {@link mergeConfigPatch} → write.
415
+ * Busts the {@link getAppConfig} process cache and returns the new parsed
416
+ * config. Honors `TMUX_IDE_CONFIG`, so tests never touch the real file.
417
+ */
418
+ export function updateAppConfig(patch: AppConfigPatch): AppConfig {
419
+ const path = appConfigPath();
420
+ const merged = mergeConfigPatch(loadRawAppConfig(), patch);
421
+ mkdirSync(dirname(path), { recursive: true });
422
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
423
+ writeFileSync(tmp, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
424
+ renameSync(tmp, path);
425
+ cached = null;
426
+ return parseAppConfig(merged);
427
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Per-platform TUI binary: the runtime-download fallback that lets a clean
3
+ * `npm i -g tmux-ide` run the full OpenTUI/Solid cockpit WITHOUT `bun`.
4
+ *
5
+ * The dev checkout runs the `.tsx` surfaces via bun; an npm install with no bun
6
+ * needs a self-contained binary. We do NOT bundle that ~70MB blob in the npm
7
+ * tarball (a surprise on every install) — instead the release workflow
8
+ * (`.github/workflows/release-binaries.yml`) cross-compiles one per platform and
9
+ * uploads them as GitHub release assets, and this module downloads the right one
10
+ * on demand (explicit `tmux-ide update --tui-binary`, or a consented first run).
11
+ *
12
+ * The mapping/URL/path helpers are PURE (unit-tested); {@link downloadTuiBinary}
13
+ * and {@link findDownloadedTui} are the thin io that fetch and probe.
14
+ */
15
+ import { chmodSync, existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+ import { gunzipSync } from "node:zlib";
19
+ import { getCurrentVersion } from "./update-check.ts";
20
+
21
+ /** The `<os>-<arch>` tags we publish a prebuilt TUI binary for. */
22
+ export type TuiPlatformTag = "darwin-arm64" | "darwin-x64" | "linux-x64" | "linux-arm64";
23
+
24
+ /** The GitHub repo the release assets live under. */
25
+ export const RELEASE_REPO = "wavyrai/tmux-ide";
26
+
27
+ /** A downloaded binary smaller than this is treated as corrupt/truncated. */
28
+ export const MIN_TUI_BINARY_BYTES = 10 * 1024 * 1024;
29
+
30
+ const SUPPORTED: Record<string, TuiPlatformTag> = {
31
+ "darwin-arm64": "darwin-arm64",
32
+ "darwin-x64": "darwin-x64",
33
+ "linux-x64": "linux-x64",
34
+ "linux-arm64": "linux-arm64",
35
+ };
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Pure
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * PURE — map a Node `process.platform`/`process.arch` pair to the release tag,
43
+ * or null when we don't publish a binary for it (e.g. windows, freebsd) so the
44
+ * caller can fall back to the "install bun" message.
45
+ */
46
+ export function tuiPlatformTag(
47
+ platform: NodeJS.Platform = process.platform,
48
+ arch: string = process.arch,
49
+ ): TuiPlatformTag | null {
50
+ return SUPPORTED[`${platform}-${arch}`] ?? null;
51
+ }
52
+
53
+ /** PURE — the `bun build --compile --target` flag for a tag (`bun-<tag>`). */
54
+ export function bunTargetForTag(tag: TuiPlatformTag): string {
55
+ return `bun-${tag}`;
56
+ }
57
+
58
+ /** PURE — the release asset filename for a tag (gzip-compressed binary). */
59
+ export function releaseAssetName(tag: TuiPlatformTag): string {
60
+ return `tmux-ide-tui-${tag}.gz`;
61
+ }
62
+
63
+ /** PURE — strip a leading `v` from a version string; `2.6.1` and `v2.6.1` both → `2.6.1`. */
64
+ export function normalizeVersion(version: string): string {
65
+ return version.startsWith("v") ? version.slice(1) : version;
66
+ }
67
+
68
+ /**
69
+ * PURE — the GitHub download URL for a platform's asset at a given version:
70
+ * `https://github.com/<repo>/releases/download/v<version>/tmux-ide-tui-<tag>.gz`.
71
+ */
72
+ export function releaseAssetUrl(version: string, tag: TuiPlatformTag): string {
73
+ return `https://github.com/${RELEASE_REPO}/releases/download/v${normalizeVersion(version)}/${releaseAssetName(tag)}`;
74
+ }
75
+
76
+ /**
77
+ * PURE — where a downloaded binary lives: `<home>/bin/tmux-ide-tui-<tag>-<version>`.
78
+ * The version is stamped INTO the name so a `tmux-ide update` to a new version
79
+ * misses the old download and re-fetches (rather than launching a stale binary).
80
+ */
81
+ export function downloadedTuiPath(home: string, tag: TuiPlatformTag, version: string): string {
82
+ return join(home, "bin", `tmux-ide-tui-${tag}-${normalizeVersion(version)}`);
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // io
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * io — the tmux-ide state home (`TMUX_IDE_HOME` override, else `~/.tmux-ide`),
91
+ * the same resolution the update-check cache and welcome marker use.
92
+ */
93
+ export function tuiStateHome(): string {
94
+ return process.env.TMUX_IDE_HOME ?? join(homedir(), ".tmux-ide");
95
+ }
96
+
97
+ /**
98
+ * io — locate a previously downloaded per-platform binary for THIS version and
99
+ * platform, or null. Feeds the resolution order in `tui/compiled.ts` after the
100
+ * shipped/local compiled binary and before the honest "unavailable" error.
101
+ */
102
+ export function findDownloadedTui(version: string = getCurrentVersion()): string | null {
103
+ const tag = tuiPlatformTag();
104
+ if (!tag) return null;
105
+ const path = downloadedTuiPath(tuiStateHome(), tag, version);
106
+ return existsSync(path) ? path : null;
107
+ }
108
+
109
+ /**
110
+ * io — download, verify, and install the per-platform TUI binary for the running
111
+ * version. Fetches the gzip asset, inflates it, rejects anything under
112
+ * {@link MIN_TUI_BINARY_BYTES} (a truncated download or an HTML error page),
113
+ * writes it `0o755`, and atomically renames it into place (temp-in-same-dir →
114
+ * rename, so a crashed download never leaves a half-written executable).
115
+ *
116
+ * Throws with an actionable message on an unsupported platform or a failed
117
+ * fetch. Returns the installed path and byte size.
118
+ */
119
+ export async function downloadTuiBinary(
120
+ opts: {
121
+ version?: string;
122
+ log?: (msg: string) => void;
123
+ } = {},
124
+ ): Promise<{ path: string; bytes: number }> {
125
+ const log = opts.log ?? (() => {});
126
+ const version = normalizeVersion(opts.version ?? getCurrentVersion());
127
+ const tag = tuiPlatformTag();
128
+ if (!tag) {
129
+ throw new Error(
130
+ `no prebuilt TUI binary is published for ${process.platform}-${process.arch} — ` +
131
+ `install bun (https://bun.sh) to run the TUI surfaces from source instead`,
132
+ );
133
+ }
134
+
135
+ const url = releaseAssetUrl(version, tag);
136
+ const dest = downloadedTuiPath(tuiStateHome(), tag, version);
137
+ mkdirSync(dirname(dest), { recursive: true });
138
+
139
+ log(`downloading ${url}`);
140
+ const res = await fetch(url);
141
+ if (!res.ok) {
142
+ throw new Error(
143
+ `could not download the TUI binary (${url} → HTTP ${res.status} ${res.statusText}). ` +
144
+ `Check that release v${version} exists and published its assets.`,
145
+ );
146
+ }
147
+
148
+ const gz = Buffer.from(await res.arrayBuffer());
149
+ const bin = gunzipSync(gz);
150
+ if (bin.byteLength < MIN_TUI_BINARY_BYTES) {
151
+ throw new Error(
152
+ `the downloaded TUI binary is only ${bin.byteLength} bytes (expected >10MB) — ` +
153
+ `treating it as corrupt and leaving the previous binary (if any) in place`,
154
+ );
155
+ }
156
+
157
+ const tmp = `${dest}.${process.pid}.tmp`;
158
+ writeFileSync(tmp, bin, { mode: 0o755 });
159
+ chmodSync(tmp, 0o755);
160
+ renameSync(tmp, dest);
161
+
162
+ const mb = (bin.byteLength / 1024 / 1024).toFixed(1);
163
+ log(`installed ${dest} (${mb} MB)`);
164
+ return { path: dest, bytes: bin.byteLength };
165
+ }
@@ -7,23 +7,36 @@
7
7
  * signals out to the human: a tmux toast on each attached client
8
8
  * (`display-message -c`), and optionally a macOS notification.
9
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},
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},
13
15
  * {@link listAttachedClients} and {@link readNotificationPrefs} are the thin io
14
16
  * wrappers. Every io path is best-effort — a failed ping must never break the
15
17
  * updater loop.
16
18
  */
17
19
  import { execFileSync } from "node:child_process";
20
+ import { existsSync, readFileSync } from "node:fs";
18
21
  import { runTmux } from "@tmux-ide/tmux-bridge";
19
- import { appConfigPath, loadAppConfig, parseAppConfig } from "../../lib/app-config.ts";
22
+ import { appConfigPath, parseAppConfig } from "../../lib/app-config.ts";
20
23
  import type { AgentStatus } from "../detect/classify.ts";
21
24
 
22
- /** A fleet transition this tick — the shape {@link ./events.ts diffFleet} emits. */
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
+ */
23
32
  export interface NotifyEvent {
24
33
  session: string;
25
34
  from: AgentStatus | null;
26
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;
27
40
  }
28
41
 
29
42
  /** An attached tmux client and the session it's currently viewing. */
@@ -38,9 +51,13 @@ export interface ToastTarget {
38
51
  message: string;
39
52
  }
40
53
 
41
- /** A macOS-notification payload (title is fixed by {@link sendSystemNotification}). */
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
+ */
42
58
  export interface SystemNotification {
43
59
  message: string;
60
+ session: string;
44
61
  }
45
62
 
46
63
  /** The verdict of {@link decideNotifications}. */
@@ -53,25 +70,58 @@ export interface NotifyDecision {
53
70
 
54
71
  /** The only two states worth pinging the user over. */
55
72
  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. */
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
+ */
57
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
+ }
58
100
 
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)`;
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;
62
110
  }
63
111
 
64
112
  /**
65
113
  * PURE — decide who to ping from this tick's transitions.
66
114
  *
67
115
  * 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};
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});
70
120
  * - SUPPRESS the toast for any client already viewing that session (they can
71
121
  * see the bar flip themselves) — other clients still get toasted;
72
122
  * - a `system` entry is produced per qualifying, non-debounced event regardless
73
123
  * of clients (so the macOS path fires even with nothing attached — the
74
- * caller gates it on prefs).
124
+ * caller gates it on prefs / quiet hours).
75
125
  *
76
126
  * Returns the toasts/system to dispatch plus `nextLastNotified` (a copy of the
77
127
  * input with fresh timestamps for the events we acted on) to thread onward.
@@ -81,22 +131,23 @@ export function decideNotifications(
81
131
  clients: AttachedClient[],
82
132
  lastNotified: Map<string, number>,
83
133
  nowMs: number,
134
+ states: ReadonlySet<AgentStatus> = NOTIFY_STATES,
84
135
  ): NotifyDecision {
85
136
  const nextLastNotified = new Map(lastNotified);
86
137
  const toasts: ToastTarget[] = [];
87
138
  const system: SystemNotification[] = [];
88
139
  for (const ev of events) {
89
- if (!NOTIFY_STATES.has(ev.to)) continue;
140
+ if (!states.has(ev.to)) continue;
90
141
  const key = `${ev.session}:${ev.to}`;
91
142
  const last = nextLastNotified.get(key);
92
143
  if (last !== undefined && nowMs - last < NOTIFY_DEBOUNCE_MS) continue;
93
144
  nextLastNotified.set(key, nowMs);
94
- const message = notifyMessage(ev.session, ev.to);
145
+ const message = notifyMessage(ev);
95
146
  for (const c of clients) {
96
147
  if (c.session === ev.session) continue; // they're already looking at it
97
148
  toasts.push({ client: c.client, message });
98
149
  }
99
- system.push({ message });
150
+ system.push({ message, session: ev.session });
100
151
  }
101
152
  return { toasts, system, nextLastNotified };
102
153
  }
@@ -137,47 +188,172 @@ export function sendToasts(toasts: ToastTarget[]): void {
137
188
  }
138
189
  }
139
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
+
140
206
  /**
141
- * io — fire a macOS notification via `osascript`. macOS-only (guarded), and
142
- * quotes/backslashes are escaped for the AppleScript string literal. Best-effort.
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.)
143
212
  */
144
- export function sendSystemNotification(message: string): void {
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 {
145
232
  if (process.platform !== "darwin") return;
146
- const escaped = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
147
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, '\\"');
148
239
  execFileSync("osascript", ["-e", `display notification "${escaped}" with title "tmux-ide"`], {
149
240
  stdio: "ignore",
150
241
  });
151
242
  } catch {
152
- // osascript missing / notification blocked — never fatal
243
+ // osascript / terminal-notifier missing or notification blocked — never fatal
153
244
  }
154
245
  }
155
246
 
156
- /** User notification preferences (minimal, pre-M14). */
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. */
157
256
  export interface NotificationPrefs {
257
+ /** Master switch — false silences every channel. */
258
+ enabled: boolean;
259
+ /** In-terminal status-line toasts. */
158
260
  toast: boolean;
261
+ /** macOS system banners. */
159
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;
160
291
  }
161
292
 
162
- /** Defaults: tmux toasts on, macOS notifications off. */
163
- export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { toast: true, macos: false };
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
+ }
164
303
 
165
304
  /**
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.
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.
170
308
  */
171
- export function notificationPrefs(parsedConfig: unknown): NotificationPrefs {
172
- return parseAppConfig(parsedConfig).notifications;
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;
173
316
  }
174
317
 
175
- /** PURE — the `TMUX_IDE_NOTIFY=0` kill-switch: disables both channels. */
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. */
176
352
  export function applyKillSwitch(
177
353
  prefs: NotificationPrefs,
178
354
  envValue: string | undefined,
179
355
  ): NotificationPrefs {
180
- return envValue === "0" ? { toast: false, macos: false } : prefs;
356
+ return envValue === "0" ? { ...prefs, enabled: false, toast: false, macos: false } : prefs;
181
357
  }
182
358
 
183
359
  /** Absolute path to the shared config (honors `TMUX_IDE_CONFIG`). */
@@ -185,11 +361,22 @@ export function notifyConfigPath(): string {
185
361
  return appConfigPath();
186
362
  }
187
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
+
188
375
  /**
189
376
  * io — resolve effective prefs from the shared app config (defaults for a
190
377
  * missing/invalid file), then apply the `TMUX_IDE_NOTIFY=0` kill-switch. Reads
191
- * fresh so the env kill-switch is honored each call.
378
+ * fresh so the env kill-switch and config edits are honored each call.
192
379
  */
193
380
  export function readNotificationPrefs(): NotificationPrefs {
194
- return applyKillSwitch(loadAppConfig().notifications, process.env.TMUX_IDE_NOTIFY);
381
+ return applyKillSwitch(parseNotificationPrefs(readRawConfig()), process.env.TMUX_IDE_NOTIFY);
195
382
  }