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.
- package/README.md +14 -9
- package/bin/cli.js +1024 -519
- package/bin/cli.ts +63 -6
- package/bunfig.toml +4 -0
- package/package.json +18 -7
- package/packages/contracts/package.json +22 -0
- package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
- package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
- package/packages/contracts/src/actions-contract.ts +310 -0
- package/packages/contracts/src/actions-errors.ts +41 -0
- package/packages/contracts/src/domain.ts +36 -0
- package/packages/contracts/src/ide-config.ts +170 -0
- package/packages/contracts/src/index.ts +24 -0
- package/packages/contracts/src/lib-internal/auth.ts +13 -0
- package/packages/contracts/src/lib-internal/hq.ts +38 -0
- package/packages/contracts/src/terminals.ts +116 -0
- package/packages/contracts/src/tmux.ts +60 -0
- package/packages/contracts/src/workspace.ts +67 -0
- package/packages/daemon/dist/agent-explain.d.ts +8 -1
- package/packages/daemon/dist/agent-explain.js +19 -3
- package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
- package/packages/daemon/dist/lib/tui-binary.js +130 -0
- package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
- package/packages/daemon/src/agent-explain.ts +298 -0
- package/packages/daemon/src/attach.ts +20 -0
- package/packages/daemon/src/bin.ts +4 -0
- package/packages/daemon/src/canonical.ts +7 -0
- package/packages/daemon/src/cli.ts +499 -0
- package/packages/daemon/src/command-center/actions/contract.ts +2 -0
- package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
- package/packages/daemon/src/command-center/actions/errors.ts +105 -0
- package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
- package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
- package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
- package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
- package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
- package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
- package/packages/daemon/src/command-center/actions/registry.ts +149 -0
- package/packages/daemon/src/command-center/discovery.ts +96 -0
- package/packages/daemon/src/command-center/index.ts +31 -0
- package/packages/daemon/src/command-center/schemas.ts +85 -0
- package/packages/daemon/src/command-center/server.ts +1260 -0
- package/packages/daemon/src/command-center/ws-events.ts +316 -0
- package/packages/daemon/src/config.ts +549 -0
- package/packages/daemon/src/detect.ts +248 -0
- package/packages/daemon/src/doctor.ts +242 -0
- package/packages/daemon/src/embed.ts +5 -0
- package/packages/daemon/src/index.ts +12 -0
- package/packages/daemon/src/init.ts +211 -0
- package/packages/daemon/src/inspect.ts +178 -0
- package/packages/daemon/src/js-yaml.d.ts +10 -0
- package/packages/daemon/src/launch.ts +349 -0
- package/packages/daemon/src/lib/active-projects.ts +49 -0
- package/packages/daemon/src/lib/agent-discovery.ts +121 -0
- package/packages/daemon/src/lib/app-config.ts +427 -0
- package/packages/daemon/src/lib/app-settings.ts +53 -0
- package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
- package/packages/daemon/src/lib/auth/middleware.ts +56 -0
- package/packages/daemon/src/lib/auth/types.ts +2 -0
- package/packages/daemon/src/lib/auth-token.ts +5 -0
- package/packages/daemon/src/lib/authorship.ts +280 -0
- package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
- package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
- package/packages/daemon/src/lib/daemon-embed.ts +782 -0
- package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
- package/packages/daemon/src/lib/daemon.ts +79 -0
- package/packages/daemon/src/lib/dot-path.ts +17 -0
- package/packages/daemon/src/lib/errors.ts +67 -0
- package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
- package/packages/daemon/src/lib/launch-plan.ts +90 -0
- package/packages/daemon/src/lib/log.ts +134 -0
- package/packages/daemon/src/lib/output.ts +76 -0
- package/packages/daemon/src/lib/project-init-runner.ts +150 -0
- package/packages/daemon/src/lib/project-inspect.ts +80 -0
- package/packages/daemon/src/lib/project-onboard.ts +149 -0
- package/packages/daemon/src/lib/project-probe.ts +92 -0
- package/packages/daemon/src/lib/project-registry.ts +296 -0
- package/packages/daemon/src/lib/session-monitor.ts +122 -0
- package/packages/daemon/src/lib/session-options.ts +100 -0
- package/packages/daemon/src/lib/shell.ts +8 -0
- package/packages/daemon/src/lib/sizes.ts +36 -0
- package/packages/daemon/src/lib/skill-sync.ts +155 -0
- package/packages/daemon/src/lib/slugify.ts +10 -0
- package/packages/daemon/src/lib/terminals-store.ts +125 -0
- package/packages/daemon/src/lib/tui-binary.ts +165 -0
- package/packages/daemon/src/lib/update-check.ts +298 -0
- package/packages/daemon/src/lib/update.ts +158 -0
- package/packages/daemon/src/lib/workspace-registry.ts +229 -0
- package/packages/daemon/src/lib/worktree.ts +289 -0
- package/packages/daemon/src/lib/yaml-io.ts +27 -0
- package/packages/daemon/src/ls.ts +40 -0
- package/packages/daemon/src/restart.ts +24 -0
- package/packages/daemon/src/restore.ts +514 -0
- package/packages/daemon/src/schemas/domain.ts +2 -0
- package/packages/daemon/src/schemas/filesystem.ts +34 -0
- package/packages/daemon/src/schemas/ide-config.ts +2 -0
- package/packages/daemon/src/schemas/index.ts +59 -0
- package/packages/daemon/src/schemas/inspect.ts +67 -0
- package/packages/daemon/src/schemas/registry.ts +55 -0
- package/packages/daemon/src/schemas/ws-events.ts +135 -0
- package/packages/daemon/src/send.ts +171 -0
- package/packages/daemon/src/server/README.md +15 -0
- package/packages/daemon/src/server/index.ts +74 -0
- package/packages/daemon/src/server/pty-bridge.ts +532 -0
- package/packages/daemon/src/server/standalone.ts +18 -0
- package/packages/daemon/src/server/ws-route.ts +483 -0
- package/packages/daemon/src/status.ts +59 -0
- package/packages/daemon/src/stop.ts +28 -0
- package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
- package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
- package/packages/daemon/src/terminal/README.md +92 -0
- package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
- package/packages/daemon/src/tui/chrome/chip.ts +30 -0
- package/packages/daemon/src/tui/chrome/events.ts +119 -0
- package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
- package/packages/daemon/src/tui/chrome/menu.ts +289 -0
- package/packages/daemon/src/tui/chrome/notify.ts +382 -0
- package/packages/daemon/src/tui/chrome/panels.ts +111 -0
- package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
- package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
- package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
- package/packages/daemon/src/tui/chrome/updater.ts +510 -0
- package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
- package/packages/daemon/src/tui/compiled.ts +121 -0
- package/packages/daemon/src/tui/detect/classify.ts +208 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +193 -0
- package/packages/daemon/src/tui/detect/manifest.ts +199 -0
- package/packages/daemon/src/tui/detect/manifests.ts +354 -0
- package/packages/daemon/src/tui/detect/process-tree.ts +217 -0
- package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
- package/packages/daemon/src/tui/integrations/claude.ts +176 -0
- package/packages/daemon/src/tui/integrations/offer.ts +145 -0
- package/packages/daemon/src/tui/main.ts +82 -0
- package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
- package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
- package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
- package/packages/daemon/src/tui/mirror/blit.ts +186 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +214 -0
- package/packages/daemon/src/tui/mirror/control.ts +97 -0
- package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
- package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
- package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
- package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
- package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
- package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
- package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
- package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
- package/packages/daemon/src/tui/mirror/palette.ts +274 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +561 -0
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
- package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
- package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
- package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
- package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
- package/packages/daemon/src/tui/mirror/selection.ts +262 -0
- package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
- package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
- package/packages/daemon/src/tui/mirror/spans.ts +46 -0
- package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
- package/packages/daemon/src/tui/team/CONTROL.md +50 -0
- package/packages/daemon/src/tui/team/entry.ts +38 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
- package/packages/daemon/src/tui/team/home.ts +170 -0
- package/packages/daemon/src/tui/team/index.tsx +1521 -0
- package/packages/daemon/src/tui/team/input.ts +34 -0
- package/packages/daemon/src/tui/team/keymap.ts +127 -0
- package/packages/daemon/src/tui/team/mouse.ts +29 -0
- package/packages/daemon/src/tui/team/nav.ts +31 -0
- package/packages/daemon/src/tui/team/preview.ts +34 -0
- package/packages/daemon/src/tui/team/projects.ts +191 -0
- package/packages/daemon/src/tui/team/report.ts +83 -0
- package/packages/daemon/src/tui/team/sessions.ts +433 -0
- package/packages/daemon/src/tui/team/tree.ts +62 -0
- package/packages/daemon/src/types.ts +13 -0
- package/packages/daemon/src/ui/index.ts +32 -0
- package/packages/daemon/src/ui/terminal/index.ts +9 -0
- package/packages/daemon/src/ui/types.ts +91 -0
- package/packages/daemon/src/ui/web/base.css +80 -0
- package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
- package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
- package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
- package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
- package/packages/daemon/src/ui/web/hooks.ts +106 -0
- package/packages/daemon/src/ui/web/index.ts +27 -0
- package/packages/daemon/src/ui/web/render.ts +77 -0
- package/packages/daemon/src/ui/web/utils/color.ts +27 -0
- package/packages/daemon/src/validate.ts +217 -0
- package/packages/daemon/src/widgets/changes/README.md +3 -0
- package/packages/daemon/src/widgets/changes/index.tsx +691 -0
- package/packages/daemon/src/widgets/config/README.md +3 -0
- package/packages/daemon/src/widgets/config/index.tsx +481 -0
- package/packages/daemon/src/widgets/explorer/README.md +3 -0
- package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
- package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
- package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
- package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
- package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
- package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
- package/packages/daemon/src/widgets/lib/files.ts +88 -0
- package/packages/daemon/src/widgets/lib/git.ts +88 -0
- package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
- package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
- package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
- package/packages/daemon/src/widgets/lib/theme.ts +194 -0
- package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
- package/packages/daemon/src/widgets/preview/README.md +3 -0
- package/packages/daemon/src/widgets/preview/index.tsx +416 -0
- package/packages/daemon/src/widgets/resolve.ts +121 -0
- package/packages/daemon/src/widgets/setup/README.md +3 -0
- package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
- package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
- package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
- package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
- package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
- package/packages/daemon/src/widgets/setup/index.tsx +341 -0
- package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
- package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
- package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
- package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
- package/packages/tmux-bridge/package.json +22 -0
- package/packages/tmux-bridge/src/errors.ts +28 -0
- package/packages/tmux-bridge/src/index.ts +31 -0
- package/packages/tmux-bridge/src/monitor.ts +77 -0
- package/packages/tmux-bridge/src/panes.ts +136 -0
- package/packages/tmux-bridge/src/runner.test.ts +501 -0
- package/packages/tmux-bridge/src/runner.ts +91 -0
- package/packages/tmux-bridge/src/sessions.ts +126 -0
- package/packages/tmux-bridge/src/targeting.test.ts +107 -0
- package/packages/tmux-bridge/src/targeting.ts +90 -0
- package/scripts/build-tui.mjs +11 -4
- package/scripts/perf-mirror.mjs +313 -0
- package/scripts/postinstall.js +26 -2
- package/skill/SKILL.md +22 -0
- package/templates/AGENTS.md +14 -7
- package/templates/agent-team-monorepo.yml +8 -0
- package/templates/agent-team-nextjs.yml +8 -0
- package/templates/agent-team.yml +10 -0
- package/templates/convex.yml +2 -0
- package/templates/default.yml +11 -5
- package/templates/go.yml +4 -0
- package/templates/missions.yml +6 -0
- package/templates/nextjs.yml +4 -0
- package/templates/python.yml +4 -0
- package/templates/skills/backend.md +5 -12
- package/templates/skills/frontend.md +5 -12
- package/templates/skills/general-worker.md +5 -12
- package/templates/skills/researcher.md +7 -12
- package/templates/skills/reviewer.md +7 -16
- 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
|
+
}
|