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 +70 -35
- package/menubar/AiNotifyMenuBar.swift +192 -63
- package/menubar/dist/ai-notify.app/Contents/MacOS/ai-notify-menubar +0 -0
- package/package.json +1 -1
- package/src/cli.mjs +244 -4
- package/src/highlight.mjs +261 -0
- package/src/menubar.mjs +15 -1
- package/src/notify.mjs +124 -31
- package/src/state.mjs +117 -11
- package/src/util.mjs +16 -0
- package/src/voicevox.mjs +120 -0
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
|
-
|
|
5
|
+

|
|
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
|
-
##
|
|
12
|
+
## What makes it different
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
- **
|
|
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 |
|
|
32
|
+
| Gemini CLI | 🧪 detected, hook WIP | PRs welcome |
|
|
25
33
|
|
|
26
|
-
Adding another agent (aider, opencode, amp,
|
|
34
|
+
Adding another agent (aider, opencode, amp, …) is a small PR: drop a file in `src/providers/`. See [CONTRIBUTING](CONTRIBUTING.md).
|
|
27
35
|
|
|
28
|
-
##
|
|
36
|
+
## Commands
|
|
29
37
|
|
|
30
38
|
```sh
|
|
31
39
|
ai-notify init [--dry-run] [--only claude,codex] # wire detected agents
|
|
32
|
-
ai-notify
|
|
33
|
-
ai-notify
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
76
|
+
## 🎙️ VOICEVOX character voices
|
|
51
77
|
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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.
|
|
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`
|
|
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 —
|
|
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
|
-
//
|
|
5
|
-
// ${XDG_STATE_HOME:-~/.local/state}/ai-notify/
|
|
6
|
-
//
|
|
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 :
|
|
9
|
-
// Right click :
|
|
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
|
|
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
|
|
26
|
-
|
|
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
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
let
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
72
|
-
if
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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.
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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)
|
|
248
|
+
app.setActivationPolicy(.accessory)
|
|
120
249
|
let delegate = AppDelegate()
|
|
121
250
|
app.delegate = delegate
|
|
122
251
|
app.run()
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-notify",
|
|
3
|
-
"version": "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": {
|