ai-notify 0.1.0 → 0.2.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 CHANGED
@@ -2,18 +2,26 @@
2
2
 
3
3
  **Know the moment your terminal AI agent needs you** — a sound, a spoken read-out, and a desktop banner the instant Claude Code, Codex, or another agent finishes a turn or asks for input. One mute switch covers **all of them, across every terminal**. No daemon, no background process.
4
4
 
5
- Long-running agents leave you staring at a quiet terminal. `ai-notify` wires a tiny notification hook into each agent CLI you have installed, so you can look away and get pulled back exactly when there's something to do. And when you're in a meeting, **one tap silences every agent at once** — because they all read the same shared switch.
5
+ ![ai-notify demo](https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/demo.gif)
6
6
 
7
7
  ```sh
8
8
  npm i -g ai-notify
9
9
  ai-notify init # auto-detects your agents and wires them
10
10
  ```
11
11
 
12
- ## Why
12
+ ## What makes it different
13
13
 
14
- - **Get notified even if you never set it up.** The point is to *add* notifications. Muting is just a bonus feature on top.
15
- - **All your agents, one switch.** Use only Claude Code? Only Codex? Both? Plus others? Same experience. The mute flag is shared, so flipping it once is global.
16
- - **Zero friction.** No daemon. Re-run `init` anytime it only wires what's newly detected and never clobbers your existing config.
14
+ Plenty of agents go quiet for minutes. ai-notify pulls you back at the right moment and is built for **running many agents at once**:
15
+
16
+ - 🎙️ **A different voice per terminal.** Give each pane its own spoken voice, so you know *which* window finished just by listening `export AI_NOTIFY_VOICE=Eddy` (or a [VOICEVOX](#-voicevox-character-voices) character).
17
+ - 🌐 **Read out in your language.** An agent's English reply or prompt is translated before it's spoken/shown (key-less, no cost) — great for non-English speakers.
18
+ - 📝 **It tells you *what* was done.** The "done" notification summarizes the agent's last reply (from the transcript), not just "finished".
19
+ - 🔕 **One switch mutes everything.** Every agent in every terminal reads the same flag — one tap silences them all for a meeting.
20
+ - 🔔 **A real menu bar bell, built in.** `ai-notify menubar install` — no Hammerspoon/SwiftBar required.
21
+
22
+ > ### 日本語
23
+ > 複数のAIエージェント(Claude Code / Codex …)を**並列で動かすと、どのターミナルの通知か分からない**——を解決する通知ツール。
24
+ > **ペインごとに声を変えられる**(VOICEVOXのキャラ声も)/**英語の出力を日本語に翻訳して読み上げ**/**完了通知に作業内容の要約**/**1タップで全部ミュート**(MTG用)/**メニューバーのベルも内蔵**。
17
25
 
18
26
  ## Supported agents
19
27
 
@@ -21,63 +29,90 @@ ai-notify init # auto-detects your agents and wires them
21
29
  | ----- | ------ | -------------- |
22
30
  | Claude Code | ✅ | `Notification` + `Stop` hooks in `~/.claude/settings.json` |
23
31
  | Codex CLI | ✅ | `notify` in `~/.codex/config.toml` (`agent-turn-complete`) |
24
- | Gemini CLI | 🧪 detected, hook WIP | see [CONTRIBUTING](CONTRIBUTING.md) — PRs welcome |
32
+ | Gemini CLI | 🧪 detected, hook WIP | PRs welcome |
25
33
 
26
- Adding another agent (aider, opencode, amp, ...) is a small PR: drop a file in `src/providers/`. See [CONTRIBUTING](CONTRIBUTING.md).
34
+ Adding another agent (aider, opencode, amp, ) is a small PR: drop a file in `src/providers/`. See [CONTRIBUTING](CONTRIBUTING.md).
27
35
 
28
- ## Usage
36
+ ## Commands
29
37
 
30
38
  ```sh
31
39
  ai-notify init [--dry-run] [--only claude,codex] # wire detected agents
32
- ai-notify uninstall [--only ...] # cleanly remove wiring
33
- ai-notify toggle | on | off | status # the mute switch
40
+ ai-notify toggle | on | off | status # the mute switch
41
+ ai-notify volume [0.0-2.0] # get/set output volume
42
+ ai-notify voice [number|name|preview|default] # pick the spoken voice
43
+ ai-notify voicevox [on <id>|off|speakers|test] # speak in VOICEVOX voices
44
+ ai-notify translate [on <lang>|off|test] # speak agent text in your language
45
+ ai-notify menubar [install|uninstall|status] # native menu bar app (macOS)
34
46
  ai-notify doctor # check deps & wiring
35
- ai-notify config [init] # print / write config
47
+ ai-notify uninstall # cleanly remove wiring
48
+ ```
49
+
50
+ Per-window overrides — `export` these in a terminal *before* launching the agent:
51
+
52
+ ```sh
53
+ AI_NOTIFY_LABEL=api # name this window in the read-out / notification
54
+ AI_NOTIFY_VOICE=Eddy # this window's `say` voice
55
+ AI_NOTIFY_VOICEVOX_SPEAKER=3 # this window's VOICEVOX speaker id
56
+ AI_NOTIFY_VOLUME=0.5 # this window's volume (0.0–2.0)
57
+ ```
58
+
59
+ ## 🎛️ Native menu bar app — mute, volume, and voices
60
+
61
+ You can't type into the terminal that's running an agent, so drive everything from the **menu bar**:
62
+
63
+ ```sh
64
+ ai-notify menubar install # native menu bar app, starts at login
36
65
  ```
37
66
 
38
- > After `init`, restart any already-running Codex session so it re-reads its config.
67
+ A monochrome waveform icon shows status by color (Adobe-style): plain when idle, a **yellow** dot when an agent is waiting for you, **red + slash** when muted.
39
68
 
40
- ## Mute everythingwithout touching a busy terminal
69
+ - **Left-click** menu: a **volume slider**, the **voice list** (system + VOICEVOX), and **per-pane** controls each open terminal gets its own voice *and* volume.
70
+ - **Right-click** → instant mute toggle.
41
71
 
42
- You can't type a command into the terminal that's running an agent, and you want
43
- the current state visible at a glance. So don't drive this from a prompt — drive
44
- it from the **menu bar / a hotkey**, and show the state where you can always see it.
72
+ No third-party app needed. Prefer something else? There are drop-in recipes for **Hammerspoon**, **SwiftBar/xbar**, **Raycast**, and the built-in **macOS Shortcuts** in [`recipes/`](recipes/). `ai-notify status --icon` prints just `🔔`/`🔕` to embed in tmux / your prompt / Claude Code's status line.
45
73
 
46
- > Toggling works mid-run: the flag is read the next time an agent fires a
47
- > notification, so flipping it instantly affects every running agent. A hotkey
48
- > runs in its own process — it never types into your busy terminal.
74
+ > Toggling works mid-run: the flag is read the next time an agent fires, so flipping it instantly affects every running agent.
49
75
 
50
- **Recommended always-visible menu bar toggle** (pick one):
76
+ ## 🎙️ VOICEVOX character voices
51
77
 
52
- - **Hammerspoon** menu bar icon **and** a global hotkey (⌃⌥M) in ~20 lines. [recipes/hammerspoon](recipes/hammerspoon/).
53
- - **SwiftBar / xbar** — a 🔔/🔕 menu bar item you click to toggle. [recipes/swiftbar](recipes/swiftbar/).
54
- - **macOS Shortcuts** — `ai-notify toggle` pinned to the menu bar / a hotkey / iPhone. [recipes/macos-shortcut](recipes/macos-shortcut/).
55
- - **Raycast** — drop-in script command + hotkey. [recipes/raycast](recipes/raycast/).
78
+ Speak your notifications in [VOICEVOX](https://voicevox.hiroshiba.jp/) character voices (free, local, offline). Run the VOICEVOX app, then:
56
79
 
57
- **Always-visible state** — `ai-notify status --icon` prints just `🔔`/`🔕`, ready to embed:
80
+ ```sh
81
+ ai-notify voicevox speakers # list available characters + ids
82
+ ai-notify voicevox on 3 # use speaker 3 (e.g. ずんだもん)
83
+ ```
84
+
85
+ Give every pane its own character with `AI_NOTIFY_VOICEVOX_SPEAKER`. If the engine isn't running, ai-notify silently falls back to the OS voice.
86
+ *VOICEVOX characters have their own terms of use — credit them per [VOICEVOX's guidelines](https://voicevox.hiroshiba.jp/term/) if you share recordings.*
58
87
 
59
- - **Inside Claude Code's own status line** (the busy terminal shows its own state). [recipes/claude-statusline](recipes/claude-statusline/).
60
- - **tmux status bar / shell prompt / Starship.** [recipes/tmux](recipes/tmux/).
88
+ ## 🌐 Read out in your language
89
+
90
+ ```sh
91
+ ai-notify translate on ja # translate the agent's message, then speak it
92
+ ai-notify translate test "I fixed the auth bug and added 3 tests."
93
+ ```
94
+
95
+ Key-less and no cost (one HTTP request; falls back to a localized template offline). The desktop banner still shows the original text.
96
+
97
+ ## ⏳ Which window, and what it's asking
98
+
99
+ Each notification is titled with the window label — `⏳ <label>` when an agent is waiting, `✓ <label>` when it's done — and the body says **what** (the translated prompt, or a summary of what was done). Set a short `AI_NOTIFY_LABEL` per pane and you can tell ten terminals apart at a glance.
61
100
 
62
101
  ## How it works
63
102
 
64
- `ai-notify` keeps a single mute flag and config under XDG paths:
103
+ A single mute flag and config under XDG paths — no daemon, no coordination:
65
104
 
66
105
  ```
67
106
  ${XDG_STATE_HOME:-~/.local/state}/ai-notify/muted # presence = muted
68
107
  ${XDG_CONFIG_HOME:-~/.config}/ai-notify/config.json # sounds, voice, options
69
108
  ```
70
109
 
71
- Each agent's hook calls `ai-notify hook --source <agent>`, which reads that one flag at fire time. That's why every agent and every terminal stay in sync with no coordination.
72
-
73
- ### Configuration
74
-
75
- `ai-notify config init` writes a config you can edit — per-agent sounds and voice, whether the desktop banner still shows while muted, and whether to speak a read-out. Sounds default to OS built-ins, so nothing is bundled.
110
+ Each agent's hook calls `ai-notify hook --source <agent>`, which reads that one flag at fire time. `ai-notify config init` writes an editable config (per-agent sounds, voice, TTS backend, translation, templates).
76
111
 
77
112
  ## Platforms
78
113
 
79
- macOS is fully supported (`afplay` / `say` / `terminal-notifier` or `osascript`). Linux is best-effort (`paplay`/`canberra`, `notify-send`, `spd-say`/`espeak`). Windows plays a beep and speaks via PowerShell. Missing backends degrade silently — they never error.
114
+ macOS is fully supported (`afplay` / `say` / VOICEVOX / `terminal-notifier` / native menu bar). Linux is best-effort (`paplay`/`canberra`, `notify-send`, `spd-say`/`espeak`, VOICEVOX). Windows plays a beep and speaks via PowerShell. Missing backends degrade silently — they never error.
80
115
 
81
116
  ## License
82
117
 
83
- [MIT](LICENSE).
118
+ [MIT](LICENSE). Zero runtime dependencies.
@@ -1,122 +1,251 @@
1
- // ai-notify menu bar agent — a tiny native NSStatusItem that mirrors the one
2
- // shared mute flag and toggles it on click. No third-party app required.
1
+ // ai-notify menu bar agent — native NSStatusItem, no third-party app.
3
2
  //
4
- // Single source of truth: the same file the CLI and every wired agent read,
5
- // ${XDG_STATE_HOME:-~/.local/state}/ai-notify/muted
6
- // Present = muted (🔕). Absent = on (🔔).
3
+ // Shared state (same files the CLI and every agent read), under
4
+ // ${XDG_STATE_HOME:-~/.local/state}/ai-notify/ :
5
+ // muted present = muted
6
+ // volume 0.0–2.0 (1.0 = normal)
7
+ // cli launcher -> `ai-notify`
7
8
  //
8
- // Left click : toggle mute/unmute (one tap)
9
- // Right click : menu (toggle / quit)
9
+ // Left click : menu volume slider, voice list (flat), per-pane voices, quit
10
+ // Right click : toggle mute (one tap)
10
11
  //
11
12
  // Builds with the system `swiftc` — no Xcode project, no dependencies.
12
13
 
13
14
  import Cocoa
14
15
 
15
- // MARK: - Shared state (must match src/state.mjs)
16
-
17
16
  enum State {
18
- static func stateDir() -> String {
17
+ static func dir() -> String {
19
18
  let env = ProcessInfo.processInfo.environment
20
19
  let base = env["XDG_STATE_HOME"]
21
20
  ?? (NSHomeDirectory() as NSString).appendingPathComponent(".local/state")
22
21
  return (base as NSString).appendingPathComponent("ai-notify")
23
22
  }
23
+ static func file(_ name: String) -> String { (dir() as NSString).appendingPathComponent(name) }
24
24
 
25
- static func flagPath() -> String {
26
- (stateDir() as NSString).appendingPathComponent("muted")
25
+ static var isMuted: Bool { FileManager.default.fileExists(atPath: file("muted")) }
26
+ static func setMuted(_ m: Bool) {
27
+ let p = file("muted"), fm = FileManager.default
28
+ if m { try? fm.createDirectory(atPath: dir(), withIntermediateDirectories: true); fm.createFile(atPath: p, contents: Data()) }
29
+ else { try? fm.removeItem(atPath: p) }
27
30
  }
28
31
 
29
- static var isMuted: Bool {
30
- FileManager.default.fileExists(atPath: flagPath())
32
+ static var volume: Double {
33
+ guard let s = try? String(contentsOfFile: file("volume"), encoding: .utf8),
34
+ let v = Double(s.trimmingCharacters(in: .whitespacesAndNewlines)) else { return 1.0 }
35
+ return min(2, max(0, v))
31
36
  }
32
37
 
33
- static func setMuted(_ muted: Bool) {
34
- let path = flagPath()
35
- let fm = FileManager.default
36
- if muted {
37
- try? fm.createDirectory(atPath: stateDir(), withIntermediateDirectories: true)
38
- fm.createFile(atPath: path, contents: Data())
39
- } else {
40
- try? fm.removeItem(atPath: path)
38
+ // Any pane waiting for input -> the icon shows a yellow status.
39
+ static var hasWaiting: Bool {
40
+ guard let s = try? String(contentsOfFile: file("waiting.json"), encoding: .utf8) else { return false }
41
+ let t = s.trimmingCharacters(in: .whitespacesAndNewlines)
42
+ return !t.isEmpty && t != "{}" && t != "[]"
43
+ }
44
+ static func setVolume(_ v: Double) {
45
+ try? FileManager.default.createDirectory(atPath: dir(), withIntermediateDirectories: true)
46
+ try? String(format: "%.2f", v).write(toFile: file("volume"), atomically: true, encoding: .utf8)
47
+ }
48
+
49
+ @discardableResult
50
+ static func cli(_ args: [String], capture: Bool = false) -> String? {
51
+ let launcher = file("cli")
52
+ guard FileManager.default.isExecutableFile(atPath: launcher) else { return nil }
53
+ let task = Process()
54
+ task.executableURL = URL(fileURLWithPath: launcher)
55
+ task.arguments = args
56
+ let pipe = Pipe()
57
+ if capture { task.standardOutput = pipe; task.standardError = Pipe() }
58
+ do { try task.run() } catch { return nil }
59
+ if capture {
60
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
61
+ task.waitUntilExit()
62
+ return String(data: data, encoding: .utf8)
41
63
  }
64
+ return nil
42
65
  }
43
66
  }
44
67
 
45
- // MARK: - App
46
-
47
68
  final class AppDelegate: NSObject, NSApplicationDelegate {
48
69
  private var statusItem: NSStatusItem!
49
70
  private var timer: Timer?
50
71
 
51
72
  func applicationDidFinishLaunching(_ notification: Notification) {
52
73
  statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
53
- if let button = statusItem.button {
54
- button.action = #selector(handleClick(_:))
55
- button.target = self
56
- button.sendAction(on: [.leftMouseUp, .rightMouseUp])
74
+ if let b = statusItem.button {
75
+ b.action = #selector(handleClick(_:))
76
+ b.target = self
77
+ b.sendAction(on: [.leftMouseUp, .rightMouseUp])
57
78
  }
58
79
  render()
59
- // Reconcile every second so external changes (CLI `ai-notify on/off`,
60
- // another tool) are reflected without any IPC.
61
- timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
62
- self?.render()
80
+ timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in self?.render() }
81
+ }
82
+
83
+ // Black/white waveform silhouette (template, auto-adapting) when idle; a
84
+ // composite with a colored status dot when waiting (yellow) or muted (red +
85
+ // slash) — Adobe-style status-by-color.
86
+ private func statusImage(muted: Bool, waiting: Bool) -> NSImage {
87
+ let cfg = NSImage.SymbolConfiguration(pointSize: 15, weight: .regular)
88
+ let sym = (NSImage(systemSymbolName: "waveform", accessibilityDescription: "ai-notify")?
89
+ .withSymbolConfiguration(cfg)) ?? NSImage()
90
+
91
+ if !muted && !waiting {
92
+ sym.isTemplate = true // system tints to the menu bar color
93
+ return sym
94
+ }
95
+
96
+ let dark = (statusItem.button?.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua)
97
+ let fg: NSColor = muted ? .tertiaryLabelColor : (dark ? .white : .black)
98
+ let size = sym.size
99
+ let img = NSImage(size: size)
100
+ img.lockFocus()
101
+ let rect = NSRect(origin: .zero, size: size)
102
+ sym.draw(in: rect)
103
+ fg.set(); rect.fill(using: .sourceAtop) // tint the silhouette
104
+ // status dot, top-right
105
+ let d: CGFloat = 6
106
+ (muted ? NSColor.systemRed : NSColor.systemYellow).set()
107
+ NSBezierPath(ovalIn: NSRect(x: size.width - d, y: size.height - d, width: d, height: d)).fill()
108
+ if muted { // red slash
109
+ let s = NSBezierPath(); s.lineWidth = 1.6
110
+ s.move(to: NSPoint(x: 1.5, y: 1.5)); s.line(to: NSPoint(x: size.width - 1.5, y: size.height - 1.5))
111
+ NSColor.systemRed.set(); s.stroke()
63
112
  }
113
+ img.unlockFocus()
114
+ img.isTemplate = false
115
+ return img
64
116
  }
65
117
 
66
118
  private func render() {
67
- statusItem.button?.title = State.isMuted ? "🔕" : "🔔"
119
+ guard let b = statusItem.button else { return }
120
+ b.title = ""
121
+ b.image = statusImage(muted: State.isMuted, waiting: State.hasWaiting)
68
122
  }
69
123
 
70
124
  @objc private func handleClick(_ sender: Any?) {
71
- guard let event = NSApp.currentEvent else { toggle(); return }
72
- if event.type == .rightMouseUp {
73
- showMenu()
74
- } else {
75
- toggle()
76
- }
125
+ guard let e = NSApp.currentEvent else { showMenu(); return }
126
+ if e.type == .rightMouseUp { toggle() } else { showMenu() }
77
127
  }
78
128
 
79
- private func toggle() {
80
- let nowMuted = !State.isMuted
81
- State.setMuted(nowMuted)
82
- render()
83
- if !nowMuted { chime() } // brief confirmation on un-mute
129
+ private func toggle() { State.setMuted(!State.isMuted); render() }
130
+ @objc private func quit() { NSApp.terminate(nil) }
131
+
132
+ @objc private func volumeChanged(_ s: NSSlider) { State.setVolume(s.doubleValue) }
133
+ @objc private func paneVolumeChanged(_ s: NSSlider) {
134
+ if let tty = s.identifier?.rawValue { State.cli(["volume-pane", tty, String(format: "%.2f", s.doubleValue)]) }
84
135
  }
85
136
 
86
- @objc private func toggleFromMenu() { toggle() }
137
+ // A 🔊 + slider row. identifier == nil => global (live); otherwise a pane tty
138
+ // (applied on release to avoid a subprocess per drag tick).
139
+ private func sliderRow(value: Double, action: Selector, identifier: String?) -> NSMenuItem {
140
+ let row = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 26))
141
+ let icon = NSTextField(labelWithString: "🔊"); icon.frame = NSRect(x: 12, y: 4, width: 20, height: 18)
142
+ let slider = NSSlider(value: value, minValue: 0, maxValue: 2, target: self, action: action)
143
+ slider.frame = NSRect(x: 36, y: 3, width: 170, height: 20)
144
+ slider.isContinuous = (identifier == nil)
145
+ if let id = identifier { slider.identifier = NSUserInterfaceItemIdentifier(id) }
146
+ row.addSubview(icon); row.addSubview(slider)
147
+ let item = NSMenuItem(); item.view = row
148
+ return item
149
+ }
87
150
 
88
- @objc private func quit() { NSApp.terminate(nil) }
151
+ // representedObject is the full CLI arg array to run.
152
+ @objc private func runItem(_ item: NSMenuItem) {
153
+ if let cmd = item.representedObject as? [String] { State.cli(cmd) }
154
+ }
155
+
156
+ private func disabledHeader(_ title: String) -> NSMenuItem {
157
+ let it = NSMenuItem(title: title, action: nil, keyEquivalent: "")
158
+ it.isEnabled = false
159
+ return it
160
+ }
89
161
 
90
162
  private func showMenu() {
91
- let muted = State.isMuted
92
163
  let menu = NSMenu()
93
- let toggleItem = NSMenuItem(
94
- title: muted ? "通知をオンにする" : "ミュート",
95
- action: #selector(toggleFromMenu), keyEquivalent: "")
96
- toggleItem.target = self
97
- menu.addItem(toggleItem)
164
+
165
+ // Global volume slider.
166
+ menu.addItem(sliderRow(value: State.volume, action: #selector(volumeChanged(_:)), identifier: nil))
167
+ menu.addItem(.separator())
168
+
169
+ // Parse menu-json once.
170
+ let json = (State.cli(["menu-json"], capture: true)?.data(using: .utf8))
171
+ .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }
172
+ let voices = (json?["voices"] as? [[String: Any]]) ?? []
173
+ let panes = (json?["panes"] as? [[String: Any]]) ?? []
174
+
175
+ if voices.isEmpty {
176
+ menu.addItem(disabledHeader("(声の一覧を取得できません)"))
177
+ } else {
178
+ // Global voice list — flat, at the top level.
179
+ menu.addItem(disabledHeader("ボイス(全体)"))
180
+ addVoiceItems(voices, to: menu, paneTty: nil, currentPaneLabel: nil)
181
+ }
182
+
183
+ // Per-pane voices: one submenu per recently-active pane.
184
+ if !panes.isEmpty {
185
+ menu.addItem(.separator())
186
+ menu.addItem(disabledHeader("ペイン別"))
187
+ for p in panes {
188
+ guard let tty = p["tty"] as? String else { continue }
189
+ let label = p["label"] as? String ?? tty
190
+ let cur = p["current"] as? String
191
+ let item = NSMenuItem(title: cur != nil ? "\(label) — \(cur!)" : label, action: nil, keyEquivalent: "")
192
+ let sub = NSMenu()
193
+ // Per-pane volume.
194
+ let pv = (p["volume"] as? Double) ?? State.volume
195
+ sub.addItem(disabledHeader("音量"))
196
+ sub.addItem(sliderRow(value: pv, action: #selector(paneVolumeChanged(_:)), identifier: tty))
197
+ let volDef = NSMenuItem(title: "音量を全体に従う", action: #selector(runItem(_:)), keyEquivalent: "")
198
+ volDef.target = self; volDef.representedObject = ["volume-pane", tty, "clear"]
199
+ volDef.state = (p["volumeSet"] as? Bool ?? false) ? .off : .on
200
+ sub.addItem(volDef)
201
+ sub.addItem(.separator())
202
+ // Per-pane voice.
203
+ sub.addItem(disabledHeader("声"))
204
+ let def = NSMenuItem(title: "デフォルト(全体に従う)", action: #selector(runItem(_:)), keyEquivalent: "")
205
+ def.target = self; def.representedObject = ["voice-pane", tty, "clear"]; def.state = (cur == nil) ? .on : .off
206
+ sub.addItem(def)
207
+ addVoiceItems(voices, to: sub, paneTty: tty, currentPaneLabel: cur)
208
+ item.submenu = sub
209
+ menu.addItem(item)
210
+ }
211
+ }
212
+
98
213
  menu.addItem(.separator())
99
214
  let quitItem = NSMenuItem(title: "ai-notify を終了", action: #selector(quit), keyEquivalent: "q")
100
215
  quitItem.target = self
101
216
  menu.addItem(quitItem)
102
217
 
103
- statusItem.menu = menu
104
- statusItem.button?.performClick(nil)
105
- statusItem.menu = nil // restore left-click-to-toggle
218
+ if let button = statusItem.button {
219
+ menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 4), in: button)
220
+ }
106
221
  }
107
222
 
108
- private func chime() {
109
- let sound = "/System/Library/Sounds/Glass.aiff"
110
- guard FileManager.default.fileExists(atPath: sound) else { return }
111
- let task = Process()
112
- task.executableURL = URL(fileURLWithPath: "/usr/bin/afplay")
113
- task.arguments = ["-v", "2", sound]
114
- try? task.run()
223
+ // Add the voice list to `menu`. paneTty == nil => sets the global voice;
224
+ // otherwise assigns the voice to that pane.
225
+ private func addVoiceItems(_ voices: [[String: Any]], to menu: NSMenu, paneTty: String?, currentPaneLabel: String?) {
226
+ var lastSection = ""
227
+ for v in voices {
228
+ let section = v["section"] as? String ?? ""
229
+ let label = v["label"] as? String ?? "?"
230
+ let kind = v["kind"] as? String ?? "say"
231
+ let ref = v["ref"] as? String ?? ""
232
+ if section != lastSection { menu.addItem(disabledHeader("— \(section) —")); lastSection = section }
233
+ let it = NSMenuItem(title: label, action: #selector(runItem(_:)), keyEquivalent: "")
234
+ it.target = self
235
+ if let tty = paneTty {
236
+ it.representedObject = ["voice-pane", tty, kind, ref]
237
+ it.state = (currentPaneLabel == label) ? .on : .off
238
+ } else {
239
+ it.representedObject = kind == "voicevox" ? ["voicevox", "on", ref] : ["voice", ref]
240
+ it.state = (v["currentGlobal"] as? Bool ?? false) ? .on : .off
241
+ }
242
+ menu.addItem(it)
243
+ }
115
244
  }
116
245
  }
117
246
 
118
247
  let app = NSApplication.shared
119
- app.setActivationPolicy(.accessory) // no Dock icon, menu bar only
248
+ app.setActivationPolicy(.accessory)
120
249
  let delegate = AppDelegate()
121
250
  app.delegate = delegate
122
251
  app.run()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-notify",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Desktop, sound, and spoken notifications for terminal AI coding agents (Claude Code, Codex, Gemini, ...) — with one mute switch that covers all of them, across every terminal.",
5
5
  "type": "module",
6
6
  "bin": {