ai-notify 0.5.2 → 0.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.ja.md +29 -1
- package/README.md +29 -1
- package/menubar/AiNotifyMenuBar.swift +250 -133
- package/menubar/dist/ai-notify.app/Contents/MacOS/ai-notify-menubar +0 -0
- package/package.json +1 -1
- package/src/cli.mjs +102 -5
- package/src/notify.mjs +13 -7
- package/src/providers/claude.mjs +6 -2
- package/src/state.mjs +18 -0
package/README.ja.md
CHANGED
|
@@ -87,6 +87,28 @@ AI_NOTIFY_VOLUME=0.5 # この窓の音量(0.0〜2.0)
|
|
|
87
87
|
AI_NOTIFY_TSUNDERE_LEVEL=0.8 # この窓のツンデレ既定値(0=デレ〜1=ツン)
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
+
## 🔔 どの種類で通知するか
|
|
91
|
+
|
|
92
|
+
すべての出来事に音とバナーが要るわけではありません。ai-notify は各イベントを(Claude Code の `notification_type`・サブエージェント判定で)**種類**に分類し、どの種類で通知するかを選べます:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
ai-notify notify # 一覧を表示
|
|
96
|
+
ai-notify notify done off # ターン完了では鳴らさない
|
|
97
|
+
ai-notify notify subagent-done on # サブエージェント完了で鳴らす
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
| 種類 | いつ | 既定 |
|
|
101
|
+
| ---- | ---- | ---- |
|
|
102
|
+
| `input` | **あなたの入力**待ち(`idle_prompt`) | 🔔 ON |
|
|
103
|
+
| `permission` | **許可**プロンプト | 🔔 ON |
|
|
104
|
+
| `info` | 認証 / MCP elicitation(情報通知) | 🔕 OFF |
|
|
105
|
+
| `done` | ターン**完了**(Stop) | 🔔 ON |
|
|
106
|
+
| `subagent-done` | **サブエージェント**完了(SubagentStop) | 🔕 OFF |
|
|
107
|
+
|
|
108
|
+
OFF の種類は完全に無音(音・バナー・読み上げ・ポップアップなし)。ただし待ち状態は正しく保たれます(無音化した `done` でもポップアップは消えます)。同じ切替はメニューバーの **通知する種類** にもあります。(`subagent-done` は SubagentStop フック配線のため一度 `ai-notify init` が必要)
|
|
109
|
+
|
|
110
|
+
> 注意:Claude は「サブエージェントの実行を待っているだけ」では通知を出しません(`Notification` は**あなたが必要なとき**だけ発火)。つまり「入力待ち」と「サブエージェントで作業中」は別々の通知ではなく、上の種類が実際に区別できる単位です。
|
|
111
|
+
|
|
90
112
|
## 🎛️ ネイティブのメニューバー — ミュート・音量・声
|
|
91
113
|
|
|
92
114
|
エージェントが走っているターミナルにはコマンドを打てないので、**メニューバー**から全部操作します:
|
|
@@ -124,7 +146,13 @@ ai-notify popup image ~/zundamon.png # 好きなキャラ画像(PNG/JPG)
|
|
|
124
146
|
ai-notify popup off
|
|
125
147
|
```
|
|
126
148
|
|
|
127
|
-
|
|
149
|
+
全アプリ・全スペースの上に浮かびます。**待っているペインごとに1枚のカード**が右下に積み重なり、各カードはそのペインの **VOICEVOX のボイスのキャラ立ち絵**を表示します(ずんだもんの声のペインはずんだもん、春日部つむぎの声はつむぎ)。カードをクリックで個別に消せます。macOS 専用(メニューバーアプリの導入が必要)。
|
|
150
|
+
|
|
151
|
+
```sh
|
|
152
|
+
ai-notify popup portraits # 各VOICEVOXキャラの公式立ち絵をキャッシュ(初回のみ・エンジン起動が必要)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
ここの設定はすべてメニューバーからも操作できます:**応答待ちポップアップ** →「有効にする/待ち時間/無視ワード/ボイスの立ち絵を取得」。
|
|
128
156
|
|
|
129
157
|
**出す条件を設定できます。** すべての「待ち」で割り込まれたくない人向け。サブエージェントの一瞬の待ちは黙ってほしいが、本当の「入力待ち」は気づきたい——を出し分けられます:
|
|
130
158
|
|
package/README.md
CHANGED
|
@@ -88,6 +88,28 @@ AI_NOTIFY_TSUNDERE_LEVEL=0.8 # this window's tsundere baseline (0=デレ
|
|
|
88
88
|
AI_NOTIFY_VOLUME=0.5 # this window's volume (0.0–2.0)
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
+
## 🔔 Which events alert
|
|
92
|
+
|
|
93
|
+
Not every agent event deserves a sound and a banner. ai-notify classifies each one (using Claude Code's `notification_type` / sub-agent markers) into a **kind**, and you choose which kinds alert:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
ai-notify notify # show the matrix
|
|
97
|
+
ai-notify notify done off # finished a turn → stay silent
|
|
98
|
+
ai-notify notify subagent-done on # a sub-agent finished → alert
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
| kind | when | default |
|
|
102
|
+
| ---- | ---- | ------- |
|
|
103
|
+
| `input` | Claude is waiting for **your input** (`idle_prompt`) | 🔔 on |
|
|
104
|
+
| `permission` | a **permission** prompt | 🔔 on |
|
|
105
|
+
| `info` | auth / MCP elicitation (informational) | 🔕 off |
|
|
106
|
+
| `done` | a turn **finished** (Stop) | 🔔 on |
|
|
107
|
+
| `subagent-done` | a **sub-agent** finished (SubagentStop) | 🔕 off |
|
|
108
|
+
|
|
109
|
+
A disabled kind is fully silent — no sound, banner, voice, or popup — but still keeps the waiting state correct (a suppressed `done` still clears a popup). Same toggles live in the menu bar under **通知する種類**. (`subagent-done` needs `ai-notify init` once to wire the SubagentStop hook.)
|
|
110
|
+
|
|
111
|
+
> Note: Claude does **not** emit a notification while merely waiting on a sub-agent to run — `Notification` fires only when *you* are needed. So "waiting for input" and "busy with a sub-agent" aren't separate notifications; the kinds above are what's actually distinguishable.
|
|
112
|
+
|
|
91
113
|
## 🎛️ Native menu bar app — mute, volume, and voices
|
|
92
114
|
|
|
93
115
|
You can't type into the terminal that's running an agent, so drive everything from the **menu bar**:
|
|
@@ -125,7 +147,13 @@ ai-notify popup image ~/zundamon.png # your own character (PNG/JPG); default
|
|
|
125
147
|
ai-notify popup off
|
|
126
148
|
```
|
|
127
149
|
|
|
128
|
-
|
|
150
|
+
Each waiting pane gets **its own card** at the bottom-right (they stack), and the card shows the pane's **VOICEVOX voice character** — a pane speaking as ずんだもん shows ずんだもん, one as 春日部つむぎ shows つむぎ. Click a card to dismiss it. macOS-only (needs the menu bar app installed).
|
|
151
|
+
|
|
152
|
+
```sh
|
|
153
|
+
ai-notify popup portraits # cache every VOICEVOX character's official portrait (run once; engine must be on)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Everything here is also in the menu bar: **応答待ちポップアップ** → enable, 待ち時間 (delay), 無視ワード (ignore), and ボイスの立ち絵を取得 (portraits).
|
|
129
157
|
|
|
130
158
|
**Control when it pops up.** Not every wait deserves your attention — a quick sub-agent turnaround isn't worth interrupting you, but a real "needs your input" is. Two knobs:
|
|
131
159
|
|
|
@@ -91,24 +91,38 @@ enum State {
|
|
|
91
91
|
return max(0, v) * 1000
|
|
92
92
|
}
|
|
93
93
|
static var popupIgnoreWords: [String] {
|
|
94
|
-
|
|
95
|
-
return s.lowercased().split(whereSeparator: { $0 == "," || $0 == "\n" })
|
|
94
|
+
popupIgnoreRaw.lowercased().split(whereSeparator: { $0 == "," || $0 == "\n" })
|
|
96
95
|
.map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
|
97
96
|
}
|
|
97
|
+
static var popupIgnoreRaw: String {
|
|
98
|
+
(try? String(contentsOfFile: file("popup-ignore"), encoding: .utf8))?
|
|
99
|
+
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
100
|
+
}
|
|
101
|
+
static var popupDelaySec: Int { Int((popupDelayMs / 1000).rounded()) }
|
|
98
102
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
103
|
+
static func json(_ name: String) -> [String: Any] {
|
|
104
|
+
(try? Data(contentsOf: URL(fileURLWithPath: file(name))))
|
|
105
|
+
.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } ?? [:]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The cached portrait file for a pane's VOICEVOX voice, if it has one and the
|
|
109
|
+
// portrait was synced (`ai-notify popup portraits`). nil => no voice portrait.
|
|
110
|
+
static func voicePortrait(_ tty: String) -> String? {
|
|
111
|
+
guard let pv = json("pane-voices.json")[tty] as? [String: Any],
|
|
112
|
+
(pv["tts"] as? String) == "voicevox",
|
|
113
|
+
let sp = pv["speaker"] as? NSNumber else { return nil }
|
|
114
|
+
let p = file("portraits/\(sp.intValue).png")
|
|
115
|
+
return FileManager.default.fileExists(atPath: p) ? p : nil
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Panes currently waiting for input (most-recent first): name, start time,
|
|
119
|
+
// reason message, and its voice's portrait path (if any). Handles both the
|
|
120
|
+
// old (number) and new ({ts,msg}) waiting.json value shapes.
|
|
121
|
+
static func waitingPanes() -> [(tty: String, name: String, ts: Double, msg: String, portrait: String?)] {
|
|
122
|
+
let waiting = json("waiting.json")
|
|
109
123
|
if waiting.isEmpty { return [] }
|
|
110
|
-
let voices =
|
|
111
|
-
let panes =
|
|
124
|
+
let voices = json("pane-voices.json")
|
|
125
|
+
let panes = json("panes.json")
|
|
112
126
|
func tsmsg(_ v: Any) -> (Double, String) {
|
|
113
127
|
if let n = v as? NSNumber { return (n.doubleValue, "") }
|
|
114
128
|
if let d = v as? [String: Any] { return (((d["ts"] as? NSNumber)?.doubleValue) ?? 0, (d["msg"] as? String) ?? "") }
|
|
@@ -122,10 +136,20 @@ enum State {
|
|
|
122
136
|
let name = ((voices[item.tty] as? [String: Any])?["speakName"] as? String)
|
|
123
137
|
?? ((panes[item.tty] as? [String: Any])?["label"] as? String)
|
|
124
138
|
?? short
|
|
125
|
-
return (item.tty, name.isEmpty ? short : name, item.tm.0, item.tm.1)
|
|
139
|
+
return (item.tty, name.isEmpty ? short : name, item.tm.0, item.tm.1, voicePortrait(item.tty))
|
|
126
140
|
}
|
|
127
141
|
}
|
|
128
142
|
|
|
143
|
+
// Per-kind notification toggles (must mirror state.mjs defaults).
|
|
144
|
+
static func notifyKinds() -> [(key: String, label: String, on: Bool)] {
|
|
145
|
+
let defaults: [String: Bool] = ["input": true, "permission": true, "info": false, "done": true, "subagent-done": false]
|
|
146
|
+
let labels = ["input": "入力待ち", "permission": "許可待ち", "info": "その他の通知", "done": "完了", "subagent-done": "サブエージェント完了"]
|
|
147
|
+
let saved = json("notify-kinds.json")
|
|
148
|
+
return ["input", "permission", "info", "done", "subagent-done"].map { k in
|
|
149
|
+
(k, labels[k] ?? k, (saved[k] as? Bool) ?? defaults[k] ?? true)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
129
153
|
// Tsundere baseline level 0.0 (デレ) – 1.0 (ツン). Same file the CLI reads.
|
|
130
154
|
static func setTsundereLevel(_ v: Double) {
|
|
131
155
|
try? FileManager.default.createDirectory(atPath: dir(), withIntermediateDirectories: true)
|
|
@@ -151,17 +175,105 @@ enum State {
|
|
|
151
175
|
}
|
|
152
176
|
}
|
|
153
177
|
|
|
178
|
+
// A card view that reliably dismisses on click even when its window isn't the
|
|
179
|
+
// active app (a gesture recognizer on a non-key floating window is unreliable;
|
|
180
|
+
// acceptsFirstMouse + mouseDown is not).
|
|
181
|
+
final class ClickableCardView: NSView {
|
|
182
|
+
var onClick: () -> Void = {}
|
|
183
|
+
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
|
|
184
|
+
override func mouseDown(with event: NSEvent) { onClick() }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// One floating "応答待ち" card, built once and updated in place.
|
|
188
|
+
final class PopupCard: NSObject {
|
|
189
|
+
let window: NSWindow
|
|
190
|
+
private let imageView = NSImageView()
|
|
191
|
+
private let face = NSTextField(labelWithString: "(。・ω・。)ノ")
|
|
192
|
+
private let label = NSTextField(wrappingLabelWithString: "")
|
|
193
|
+
var onClick: () -> Void = {}
|
|
194
|
+
|
|
195
|
+
override init() {
|
|
196
|
+
window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 300, height: 96),
|
|
197
|
+
styleMask: [.borderless], backing: .buffered, defer: false)
|
|
198
|
+
super.init()
|
|
199
|
+
window.isOpaque = false
|
|
200
|
+
window.backgroundColor = .clear
|
|
201
|
+
window.level = .floating
|
|
202
|
+
window.hasShadow = true
|
|
203
|
+
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
|
204
|
+
window.ignoresMouseEvents = false
|
|
205
|
+
|
|
206
|
+
let card = ClickableCardView(frame: NSRect(x: 0, y: 0, width: 300, height: 96))
|
|
207
|
+
card.onClick = { [weak self] in self?.onClick() }
|
|
208
|
+
card.wantsLayer = true
|
|
209
|
+
card.layer?.cornerRadius = 16
|
|
210
|
+
card.layer?.backgroundColor = NSColor(calibratedWhite: 0.10, alpha: 0.95).cgColor
|
|
211
|
+
card.layer?.borderWidth = 2
|
|
212
|
+
card.layer?.borderColor = NSColor.systemYellow.withAlphaComponent(0.9).cgColor
|
|
213
|
+
|
|
214
|
+
imageView.frame = NSRect(x: 12, y: 12, width: 72, height: 72)
|
|
215
|
+
imageView.imageScaling = .scaleProportionallyUpOrDown
|
|
216
|
+
imageView.isHidden = true
|
|
217
|
+
card.addSubview(imageView)
|
|
218
|
+
|
|
219
|
+
face.frame = NSRect(x: 8, y: 30, width: 80, height: 36)
|
|
220
|
+
face.alignment = .center
|
|
221
|
+
face.font = .systemFont(ofSize: 17, weight: .semibold)
|
|
222
|
+
face.textColor = .systemYellow
|
|
223
|
+
card.addSubview(face)
|
|
224
|
+
|
|
225
|
+
let badge = NSTextField(labelWithString: "🟡 応答待ち")
|
|
226
|
+
badge.frame = NSRect(x: 96, y: 56, width: 150, height: 22)
|
|
227
|
+
badge.font = .systemFont(ofSize: 13, weight: .bold)
|
|
228
|
+
badge.textColor = .systemYellow
|
|
229
|
+
badge.isBordered = false
|
|
230
|
+
badge.backgroundColor = .clear
|
|
231
|
+
card.addSubview(badge)
|
|
232
|
+
|
|
233
|
+
label.frame = NSRect(x: 96, y: 12, width: 196, height: 44)
|
|
234
|
+
label.font = .systemFont(ofSize: 15, weight: .semibold)
|
|
235
|
+
label.textColor = .white
|
|
236
|
+
label.maximumNumberOfLines = 2
|
|
237
|
+
label.isBordered = false
|
|
238
|
+
label.backgroundColor = .clear
|
|
239
|
+
card.addSubview(label)
|
|
240
|
+
|
|
241
|
+
// Visible close button (✕) at the top-right.
|
|
242
|
+
let close = NSButton(frame: NSRect(x: 272, y: 70, width: 20, height: 20))
|
|
243
|
+
close.title = "✕"
|
|
244
|
+
close.font = .systemFont(ofSize: 11, weight: .bold)
|
|
245
|
+
close.isBordered = false
|
|
246
|
+
close.contentTintColor = .secondaryLabelColor
|
|
247
|
+
close.setButtonType(.momentaryChange)
|
|
248
|
+
close.target = self
|
|
249
|
+
close.action = #selector(clicked)
|
|
250
|
+
card.addSubview(close)
|
|
251
|
+
|
|
252
|
+
window.contentView = card
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
@objc private func clicked() { onClick() }
|
|
256
|
+
|
|
257
|
+
func update(text: String, image: NSImage?) {
|
|
258
|
+
label.stringValue = text
|
|
259
|
+
if let img = image {
|
|
260
|
+
imageView.image = img
|
|
261
|
+
imageView.isHidden = false
|
|
262
|
+
face.isHidden = true
|
|
263
|
+
} else {
|
|
264
|
+
imageView.isHidden = true
|
|
265
|
+
face.isHidden = false
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
154
270
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
155
271
|
private var statusItem: NSStatusItem!
|
|
156
272
|
private var timer: Timer?
|
|
157
273
|
|
|
158
|
-
// The "waiting for input"
|
|
159
|
-
private var
|
|
160
|
-
private var
|
|
161
|
-
private var waitingFace: NSTextField?
|
|
162
|
-
private var waitingLabel: NSTextField?
|
|
163
|
-
private var waitingSig = "" // current panes signature, to avoid needless redraws
|
|
164
|
-
private var waitingDismissedSig = "" // a signature the user clicked away; don't reshow it
|
|
274
|
+
// The "waiting for input" popup — one floating card per waiting pane.
|
|
275
|
+
private var waitingCards: [String: PopupCard] = [:] // keyed by tty
|
|
276
|
+
private var dismissedTtys: Set<String> = [] // clicked away; reshow on the next wait
|
|
165
277
|
|
|
166
278
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
167
279
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
@@ -230,12 +342,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
230
342
|
if e.type == .rightMouseUp { toggle() } else { showMenu() }
|
|
231
343
|
}
|
|
232
344
|
|
|
233
|
-
// --- "Waiting for input"
|
|
234
|
-
// Driven off the 1s timer
|
|
235
|
-
//
|
|
236
|
-
//
|
|
345
|
+
// --- "Waiting for input" popup (one card per waiting pane) ------------
|
|
346
|
+
// Driven off the 1s timer. Each waiting pane gets its own floating card,
|
|
347
|
+
// showing its name and — when the pane's voice is a VOICEVOX character —
|
|
348
|
+
// that character's portrait. Cards stack at the bottom-right and disappear
|
|
349
|
+
// the moment their pane stops waiting (or you click one away).
|
|
237
350
|
private func updateWaitingPopup() {
|
|
238
|
-
var panes: [(tty: String, name: String, ts: Double, msg: String)] = []
|
|
351
|
+
var panes: [(tty: String, name: String, ts: Double, msg: String, portrait: String?)] = []
|
|
239
352
|
if State.popupEnabled {
|
|
240
353
|
let now = Date().timeIntervalSince1970 * 1000
|
|
241
354
|
let delayMs = State.popupDelayMs
|
|
@@ -249,109 +362,56 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
249
362
|
return true
|
|
250
363
|
}
|
|
251
364
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
waitingWindow?.orderFront(nil)
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
private func refreshWaitingPopup(names: [String]) {
|
|
265
|
-
if waitingWindow == nil { buildWaitingWindow() }
|
|
266
|
-
guard let win = waitingWindow else { return }
|
|
267
|
-
let line = names.count == 1
|
|
268
|
-
? "\(names[0]) は応答待ち!"
|
|
269
|
-
: "\(names.count)件 応答待ち:\(names.joined(separator: "、"))"
|
|
270
|
-
waitingLabel?.stringValue = line
|
|
271
|
-
|
|
272
|
-
// Custom character image, or a friendly default kaomoji.
|
|
273
|
-
if let p = State.popupImage, let img = NSImage(contentsOfFile: p) {
|
|
274
|
-
waitingImageView?.image = img
|
|
275
|
-
waitingImageView?.isHidden = false
|
|
276
|
-
waitingFace?.isHidden = true // the image stands in for the default face
|
|
277
|
-
} else {
|
|
278
|
-
waitingImageView?.isHidden = true
|
|
279
|
-
waitingFace?.isHidden = false
|
|
365
|
+
let activeTtys = Set(panes.map { $0.tty })
|
|
366
|
+
dismissedTtys.formIntersection(activeTtys) // a pane that re-enters waiting shows again
|
|
367
|
+
|
|
368
|
+
let visible = panes.filter { !dismissedTtys.contains($0.tty) }
|
|
369
|
+
let visibleTtys = Set(visible.map { $0.tty })
|
|
370
|
+
// Tear down cards for panes that are no longer shown.
|
|
371
|
+
for (tty, card) in waitingCards where !visibleTtys.contains(tty) {
|
|
372
|
+
card.window.orderOut(nil)
|
|
373
|
+
waitingCards.removeValue(forKey: tty)
|
|
280
374
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
375
|
+
// NSScreen.main is nil for a background app with no focused window — fall
|
|
376
|
+
// back to the first screen so the cards always have somewhere to land.
|
|
377
|
+
guard let scr = NSScreen.main ?? NSScreen.screens.first else { return }
|
|
378
|
+
let margin: CGFloat = 24, gap: CGFloat = 12, h: CGFloat = 96, w: CGFloat = 300
|
|
379
|
+
for (i, p) in visible.prefix(6).enumerated() {
|
|
380
|
+
let card: PopupCard
|
|
381
|
+
if let existing = waitingCards[p.tty] {
|
|
382
|
+
card = existing
|
|
383
|
+
} else {
|
|
384
|
+
let c = PopupCard()
|
|
385
|
+
let tty = p.tty
|
|
386
|
+
c.onClick = { [weak self] in
|
|
387
|
+
self?.dismissedTtys.insert(tty)
|
|
388
|
+
self?.waitingCards[tty]?.window.orderOut(nil)
|
|
389
|
+
self?.waitingCards.removeValue(forKey: tty)
|
|
390
|
+
}
|
|
391
|
+
waitingCards[p.tty] = c
|
|
392
|
+
card = c
|
|
393
|
+
}
|
|
394
|
+
// Image priority: the voice's portrait (head-cropped) > a global popup
|
|
395
|
+
// image > the default kaomoji (image == nil).
|
|
396
|
+
var img: NSImage?
|
|
397
|
+
if let pp = p.portrait { img = faceCrop(path: pp) }
|
|
398
|
+
else if let gp = State.popupImage { img = NSImage(contentsOfFile: gp) }
|
|
399
|
+
card.update(text: "\(p.name) は応答待ち!", image: img)
|
|
400
|
+
card.window.setFrameOrigin(NSPoint(x: scr.visibleFrame.maxX - w - margin,
|
|
401
|
+
y: scr.visibleFrame.minY + margin + CGFloat(i) * (h + gap)))
|
|
402
|
+
card.window.orderFront(nil)
|
|
287
403
|
}
|
|
288
404
|
}
|
|
289
405
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
private func buildWaitingWindow() {
|
|
302
|
-
let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 300, height: 96),
|
|
303
|
-
styleMask: [.borderless], backing: .buffered, defer: false)
|
|
304
|
-
w.isOpaque = false
|
|
305
|
-
w.backgroundColor = .clear
|
|
306
|
-
w.level = .floating
|
|
307
|
-
w.hasShadow = true
|
|
308
|
-
w.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
|
309
|
-
w.ignoresMouseEvents = false
|
|
310
|
-
|
|
311
|
-
let card = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 96))
|
|
312
|
-
card.wantsLayer = true
|
|
313
|
-
card.layer?.cornerRadius = 16
|
|
314
|
-
card.layer?.backgroundColor = NSColor(calibratedWhite: 0.10, alpha: 0.95).cgColor
|
|
315
|
-
card.layer?.borderWidth = 2
|
|
316
|
-
card.layer?.borderColor = NSColor.systemYellow.withAlphaComponent(0.9).cgColor
|
|
317
|
-
|
|
318
|
-
let iv = NSImageView(frame: NSRect(x: 12, y: 12, width: 72, height: 72))
|
|
319
|
-
iv.imageScaling = .scaleProportionallyUpOrDown
|
|
320
|
-
iv.isHidden = true
|
|
321
|
-
card.addSubview(iv)
|
|
322
|
-
waitingImageView = iv
|
|
323
|
-
|
|
324
|
-
// A friendly default "character" — a kaomoji — sits behind the image slot
|
|
325
|
-
// so there's always a face even without a custom image.
|
|
326
|
-
let face = NSTextField(labelWithString: "(。・ω・。)ノ")
|
|
327
|
-
face.frame = NSRect(x: 8, y: 30, width: 80, height: 36)
|
|
328
|
-
face.alignment = .center
|
|
329
|
-
face.font = .systemFont(ofSize: 17, weight: .semibold)
|
|
330
|
-
face.textColor = .systemYellow
|
|
331
|
-
card.addSubview(face, positioned: .below, relativeTo: iv)
|
|
332
|
-
waitingFace = face
|
|
333
|
-
|
|
334
|
-
let badge = NSTextField(labelWithString: "🟡 応答待ち")
|
|
335
|
-
badge.frame = NSRect(x: 96, y: 56, width: 196, height: 22)
|
|
336
|
-
badge.font = .systemFont(ofSize: 13, weight: .bold)
|
|
337
|
-
badge.textColor = .systemYellow
|
|
338
|
-
badge.backgroundColor = .clear
|
|
339
|
-
badge.isBordered = false
|
|
340
|
-
card.addSubview(badge)
|
|
341
|
-
|
|
342
|
-
let label = NSTextField(wrappingLabelWithString: "")
|
|
343
|
-
label.frame = NSRect(x: 96, y: 12, width: 196, height: 44)
|
|
344
|
-
label.font = .systemFont(ofSize: 15, weight: .semibold)
|
|
345
|
-
label.textColor = .white
|
|
346
|
-
label.maximumNumberOfLines = 2
|
|
347
|
-
label.backgroundColor = .clear
|
|
348
|
-
label.isBordered = false
|
|
349
|
-
card.addSubview(label)
|
|
350
|
-
waitingLabel = label
|
|
351
|
-
|
|
352
|
-
card.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(waitingClicked)))
|
|
353
|
-
w.contentView = card
|
|
354
|
-
waitingWindow = w
|
|
406
|
+
// Crop a full-body VOICEVOX portrait down to the head (top-center) so it
|
|
407
|
+
// reads at card size. Heuristic fractions that fit the standard 立ち絵.
|
|
408
|
+
private func faceCrop(path: String) -> NSImage? {
|
|
409
|
+
guard let src = NSImage(contentsOfFile: path),
|
|
410
|
+
let cg = src.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
|
|
411
|
+
let W = CGFloat(cg.width), H = CGFloat(cg.height)
|
|
412
|
+
let rect = CGRect(x: W * 0.18, y: H * 0.03, width: W * 0.64, height: H * 0.30)
|
|
413
|
+
guard let cropped = cg.cropping(to: rect) else { return src }
|
|
414
|
+
return NSImage(cgImage: cropped, size: NSSize(width: rect.width, height: rect.height))
|
|
355
415
|
}
|
|
356
416
|
|
|
357
417
|
private func toggle() { State.setMuted(!State.isMuted); render() }
|
|
@@ -372,6 +432,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
372
432
|
// loop swallows the keystrokes. So naming a pane opens a normal modal dialog
|
|
373
433
|
// (NSAlert with a text field), which takes keyboard focus properly. Empty =>
|
|
374
434
|
// clear (the pane falls back to its label / the speakLabel default).
|
|
435
|
+
// Edit the popup "ignore" reason-keywords from the menu (a modal text field,
|
|
436
|
+
// since menus can't host an editable field). Empty clears the filter.
|
|
437
|
+
@objc private func promptPopupIgnore() {
|
|
438
|
+
let alert = NSAlert()
|
|
439
|
+
alert.messageText = "無視ワード"
|
|
440
|
+
alert.informativeText = "待ち理由メッセージにこの語を含む通知はポップアップしません(カンマ区切り・空欄で解除)"
|
|
441
|
+
let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
|
|
442
|
+
field.stringValue = State.popupIgnoreRaw
|
|
443
|
+
field.placeholderString = "例: subagent,task"
|
|
444
|
+
alert.accessoryView = field
|
|
445
|
+
alert.addButton(withTitle: "保存")
|
|
446
|
+
alert.addButton(withTitle: "キャンセル")
|
|
447
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
448
|
+
alert.window.initialFirstResponder = field
|
|
449
|
+
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
|
450
|
+
let v = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
451
|
+
State.cli(["popup", "ignore", v.isEmpty ? "clear" : v])
|
|
452
|
+
}
|
|
453
|
+
|
|
375
454
|
@objc private func promptPaneName(_ sender: NSMenuItem) {
|
|
376
455
|
guard let info = sender.representedObject as? [String], let tty = info.first else { return }
|
|
377
456
|
let current = info.count > 1 ? info[1] : ""
|
|
@@ -592,13 +671,51 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
592
671
|
}
|
|
593
672
|
|
|
594
673
|
menu.addItem(.separator())
|
|
595
|
-
// Waiting-for-input
|
|
596
|
-
|
|
597
|
-
let
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
674
|
+
// Waiting-for-input popup: enable + when-to-show settings, all in a submenu.
|
|
675
|
+
let popupParent = NSMenuItem(title: "応答待ちポップアップ", action: nil, keyEquivalent: "")
|
|
676
|
+
let popupSub = NSMenu()
|
|
677
|
+
let onItem = NSMenuItem(title: "有効にする", action: #selector(runItem(_:)), keyEquivalent: "")
|
|
678
|
+
onItem.target = self
|
|
679
|
+
onItem.representedObject = ["popup", "toggle"]
|
|
680
|
+
onItem.state = State.popupEnabled ? .on : .off
|
|
681
|
+
popupSub.addItem(onItem)
|
|
682
|
+
popupSub.addItem(.separator())
|
|
683
|
+
// Threshold: only show after waiting this long.
|
|
684
|
+
popupSub.addItem(disabledHeader("出すまでの待ち時間"))
|
|
685
|
+
let curDelay = State.popupDelaySec
|
|
686
|
+
for (sec, title) in [(0, "即時"), (5, "5秒"), (10, "10秒"), (15, "15秒"), (30, "30秒"), (60, "60秒")] {
|
|
687
|
+
let it = NSMenuItem(title: title, action: #selector(runItem(_:)), keyEquivalent: "")
|
|
688
|
+
it.target = self
|
|
689
|
+
it.representedObject = ["popup", "delay", String(sec)]
|
|
690
|
+
it.state = (curDelay == sec) ? .on : .off
|
|
691
|
+
popupSub.addItem(it)
|
|
692
|
+
}
|
|
693
|
+
popupSub.addItem(.separator())
|
|
694
|
+
// Reason filter + portrait sync.
|
|
695
|
+
let ig = State.popupIgnoreRaw
|
|
696
|
+
let ignoreItem = NSMenuItem(title: ig.isEmpty ? "無視ワードを設定…" : "無視ワード: \(ig)", action: #selector(promptPopupIgnore), keyEquivalent: "")
|
|
697
|
+
ignoreItem.target = self
|
|
698
|
+
popupSub.addItem(ignoreItem)
|
|
699
|
+
let portItem = NSMenuItem(title: "ボイスの立ち絵を取得(VOICEVOX)", action: #selector(runItem(_:)), keyEquivalent: "")
|
|
700
|
+
portItem.target = self
|
|
701
|
+
portItem.representedObject = ["popup", "portraits"]
|
|
702
|
+
popupSub.addItem(portItem)
|
|
703
|
+
popupParent.submenu = popupSub
|
|
704
|
+
menu.addItem(popupParent)
|
|
705
|
+
|
|
706
|
+
// Which kinds of event actually alert (sound / banner / popup).
|
|
707
|
+
let notifyParent = NSMenuItem(title: "通知する種類", action: nil, keyEquivalent: "")
|
|
708
|
+
let notifySub = NSMenu()
|
|
709
|
+
notifySub.addItem(disabledHeader("チェック=音・バナーを出す"))
|
|
710
|
+
for kind in State.notifyKinds() {
|
|
711
|
+
let it = NSMenuItem(title: kind.label, action: #selector(runItem(_:)), keyEquivalent: "")
|
|
712
|
+
it.target = self
|
|
713
|
+
it.representedObject = ["notify", kind.key, "toggle"]
|
|
714
|
+
it.state = kind.on ? .on : .off
|
|
715
|
+
notifySub.addItem(it)
|
|
716
|
+
}
|
|
717
|
+
notifyParent.submenu = notifySub
|
|
718
|
+
menu.addItem(notifyParent)
|
|
602
719
|
|
|
603
720
|
menu.addItem(.separator())
|
|
604
721
|
let quitItem = NSMenuItem(title: "ai-notify を終了", action: #selector(quit), keyEquivalent: "q")
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-notify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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": {
|
package/src/cli.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// ai-notify — desktop/sound notifications for terminal AI coding agents.
|
|
3
3
|
// One mute switch for all of them, across every terminal. No daemon.
|
|
4
4
|
|
|
5
|
-
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
6
6
|
import { execSync, execFileSync } from 'node:child_process';
|
|
7
7
|
import { providers, byId } from './providers/index.mjs';
|
|
8
8
|
import { emit } from './notify.mjs';
|
|
@@ -41,8 +41,12 @@ import {
|
|
|
41
41
|
setPopupDelay,
|
|
42
42
|
getPopupIgnore,
|
|
43
43
|
setPopupIgnore,
|
|
44
|
+
NOTIFY_KINDS,
|
|
45
|
+
getNotifyKinds,
|
|
46
|
+
setNotifyKind,
|
|
47
|
+
isNotifyKindEnabled,
|
|
44
48
|
} from './state.mjs';
|
|
45
|
-
import { resolve as resolvePath } from 'node:path';
|
|
49
|
+
import { resolve as resolvePath, join as pathJoin } from 'node:path';
|
|
46
50
|
|
|
47
51
|
// Single source of truth: read the version from package.json so `--version`
|
|
48
52
|
// (and the Homebrew formula test that checks it) always matches the release.
|
|
@@ -598,6 +602,38 @@ const cmds = {
|
|
|
598
602
|
log(`✓ ${bits.join(' · ')}`);
|
|
599
603
|
},
|
|
600
604
|
|
|
605
|
+
// Per-kind notification toggles: which kinds of agent event actually alert
|
|
606
|
+
// (sound / banner / voice / popup). Lets you, e.g., keep input-waiting but
|
|
607
|
+
// silence "done", or turn on sub-agent completions.
|
|
608
|
+
// notify [<kind> on|off|toggle]
|
|
609
|
+
notify() {
|
|
610
|
+
const KIND_LABELS = {
|
|
611
|
+
input: '入力待ち (Claude is waiting for your input)',
|
|
612
|
+
permission: '許可待ち (a permission prompt)',
|
|
613
|
+
info: 'その他 (auth / MCP elicitation — informational)',
|
|
614
|
+
done: '完了 (a turn finished)',
|
|
615
|
+
'subagent-done': 'サブエージェント完了 (a sub-agent finished)',
|
|
616
|
+
};
|
|
617
|
+
const [kind, action] = positionals;
|
|
618
|
+
if (kind) {
|
|
619
|
+
if (!NOTIFY_KINDS.includes(kind)) {
|
|
620
|
+
console.error(`unknown kind: ${kind}\n kinds: ${NOTIFY_KINDS.join(', ')}`);
|
|
621
|
+
process.exit(1);
|
|
622
|
+
}
|
|
623
|
+
const cur = !!getNotifyKinds()[kind];
|
|
624
|
+
const on = action === 'toggle' ? !cur : action !== 'off';
|
|
625
|
+
setNotifyKind(kind, on);
|
|
626
|
+
if (kind === 'subagent-done' && on) {
|
|
627
|
+
log('subagent-done: ON — run `ai-notify init` once so the SubagentStop hook is wired.');
|
|
628
|
+
}
|
|
629
|
+
return log(`${kind}: ${on ? '🔔 ON (notify)' : '🔕 OFF (silent)'}`);
|
|
630
|
+
}
|
|
631
|
+
const k = getNotifyKinds();
|
|
632
|
+
log('Notify on these events:\n');
|
|
633
|
+
for (const key of NOTIFY_KINDS) log(` ${k[key] ? '🔔' : '🔕'} ${key.padEnd(14)} ${KIND_LABELS[key]}`);
|
|
634
|
+
log('\nToggle: ai-notify notify done off · ai-notify notify subagent-done on');
|
|
635
|
+
},
|
|
636
|
+
|
|
601
637
|
// The "waiting" character popup (menu bar app): an always-on-top window that
|
|
602
638
|
// shows a character saying which pane is waiting for input. macOS-only effect.
|
|
603
639
|
// popup [on|off|toggle|image <path>|delay <sec>|ignore <kw,kw>|status]
|
|
@@ -625,6 +661,45 @@ const cmds = {
|
|
|
625
661
|
setPopupDelay(Math.max(0, v));
|
|
626
662
|
return log(v > 0 ? `popup delay → ${Math.max(0, v)}s (waits shorter than this are ignored)` : 'popup delay → 0s (immediate)');
|
|
627
663
|
}
|
|
664
|
+
// Cache each VOICEVOX character's official portrait so the popup can show a
|
|
665
|
+
// pane in its own voice's character. Saved per style id: portraits/<id>.png.
|
|
666
|
+
if (sub === 'portraits') {
|
|
667
|
+
const url = readConfig().voicevox?.url || voicevox.DEFAULT_URL;
|
|
668
|
+
if (!voicevox.isAvailable(url)) {
|
|
669
|
+
console.error('VOICEVOX engine not running — start it, then: ai-notify popup portraits');
|
|
670
|
+
process.exit(1);
|
|
671
|
+
}
|
|
672
|
+
const dir = pathJoin(paths.stateDir(), 'portraits');
|
|
673
|
+
mkdirSync(dir, { recursive: true });
|
|
674
|
+
let speakers;
|
|
675
|
+
try {
|
|
676
|
+
speakers = JSON.parse(execSync(`curl -s -m 6 "${url}/speakers"`, { encoding: 'utf8', maxBuffer: 1 << 24 }));
|
|
677
|
+
} catch {
|
|
678
|
+
console.error('could not reach the VOICEVOX engine.');
|
|
679
|
+
process.exit(1);
|
|
680
|
+
}
|
|
681
|
+
let n = 0;
|
|
682
|
+
for (const sp of speakers) {
|
|
683
|
+
let info;
|
|
684
|
+
try {
|
|
685
|
+
const raw = execSync(`curl -s -m 8 "${url}/speaker_info?speaker_uuid=${sp.speaker_uuid}"`, {
|
|
686
|
+
encoding: 'utf8',
|
|
687
|
+
maxBuffer: 1 << 26,
|
|
688
|
+
});
|
|
689
|
+
info = JSON.parse(raw);
|
|
690
|
+
} catch {
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
if (!info.portrait) continue;
|
|
694
|
+
const buf = Buffer.from(info.portrait, 'base64');
|
|
695
|
+
for (const st of sp.styles || []) {
|
|
696
|
+
writeFileSync(pathJoin(dir, `${st.id}.png`), buf);
|
|
697
|
+
n++;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return log(`synced ${n} voice portrait(s) → ${dir}`);
|
|
701
|
+
}
|
|
702
|
+
|
|
628
703
|
// Suppress the popup when the waiting reason contains any of these keywords.
|
|
629
704
|
if (sub === 'ignore') {
|
|
630
705
|
const kw = positionals.slice(1).join(' ').trim();
|
|
@@ -806,6 +881,8 @@ const cmds = {
|
|
|
806
881
|
let event = opt('event', 'done');
|
|
807
882
|
let cwd = '';
|
|
808
883
|
let message = '';
|
|
884
|
+
let ntype = '';
|
|
885
|
+
let isSubagent = false;
|
|
809
886
|
|
|
810
887
|
if (source === 'codex') {
|
|
811
888
|
// Codex passes a single JSON argument.
|
|
@@ -820,16 +897,24 @@ const cmds = {
|
|
|
820
897
|
const data = readStdinJson();
|
|
821
898
|
cwd = data.cwd || '';
|
|
822
899
|
message = data.message || '';
|
|
900
|
+
ntype = data.notification_type || ''; // idle_prompt | permission_prompt | ...
|
|
901
|
+
isSubagent = !!data.agent_id; // present => fired from inside a sub-agent
|
|
823
902
|
// The Stop hook has no message, so "done" would only say "finished".
|
|
824
903
|
// Pull the agent's last reply from the transcript so the notification
|
|
825
904
|
// says WHAT was done.
|
|
826
|
-
if (!message && event === 'done' && data.transcript_path) {
|
|
905
|
+
if (!message && (event === 'done' || event === 'subagent-done') && data.transcript_path) {
|
|
827
906
|
message = lastAssistantText(data.transcript_path);
|
|
828
907
|
}
|
|
829
908
|
}
|
|
830
909
|
|
|
910
|
+
// Classify the event into a kind and honor the per-kind notification toggle.
|
|
911
|
+
// A disabled kind still calls emit (to keep the waiting state correct), but
|
|
912
|
+
// silently. SubagentStop arrives as event "subagent-done" → emit as "done".
|
|
913
|
+
const kind = classifyKind(event, ntype, isSubagent);
|
|
914
|
+
const alert = isNotifyKindEnabled(kind);
|
|
831
915
|
const label = deriveLabel(cwd);
|
|
832
|
-
|
|
916
|
+
const emitEvent = event === 'subagent-done' ? 'done' : event;
|
|
917
|
+
emit({ provider: byId(source) ? source : 'default', event: emitEvent, label, message, alert });
|
|
833
918
|
},
|
|
834
919
|
|
|
835
920
|
version() { log(VERSION); },
|
|
@@ -840,6 +925,17 @@ function emitConfirm() {
|
|
|
840
925
|
emit({ provider: 'default', event: 'done', label: 'ai-notify', message: readConfig().onMessage });
|
|
841
926
|
}
|
|
842
927
|
|
|
928
|
+
// Map a raw hook event + Claude's notification_type/agent_id to a notify "kind"
|
|
929
|
+
// the user can toggle. See state.mjs NOTIFY_KINDS.
|
|
930
|
+
function classifyKind(event, ntype, isSubagent) {
|
|
931
|
+
if (event === 'subagent-done' || (event === 'done' && isSubagent)) return 'subagent-done';
|
|
932
|
+
if (event === 'done') return 'done';
|
|
933
|
+
// waiting (Claude Notification): the type tells us *why*.
|
|
934
|
+
if (ntype === 'permission_prompt') return 'permission';
|
|
935
|
+
if (ntype === 'idle_prompt' || ntype === 'elicitation_dialog' || ntype === '') return 'input';
|
|
936
|
+
return 'info'; // auth_success, elicitation_complete/response, anything else
|
|
937
|
+
}
|
|
938
|
+
|
|
843
939
|
function printHelp() {
|
|
844
940
|
log(`ai-notify ${VERSION} — notifications for terminal AI coding agents
|
|
845
941
|
|
|
@@ -854,7 +950,8 @@ Usage:
|
|
|
854
950
|
ai-notify tsundere [on|off|level <0-1>|test|status] tsundere persona (ツン⇄デレ by urgency)
|
|
855
951
|
ai-notify voice-prosody [speed|pitch|intonation <v>|reset] VOICEVOX read-out tuning
|
|
856
952
|
ai-notify menubar [install|uninstall|status] native menu bar bell (macOS)
|
|
857
|
-
ai-notify
|
|
953
|
+
ai-notify notify [<kind> on|off] which events alert: input|permission|info|done|subagent-done
|
|
954
|
+
ai-notify popup [on|off|image <p>|delay <s>|ignore <kw>|portraits] per-pane "waiting" popup, in the pane's voice (macOS)
|
|
858
955
|
ai-notify translate [on <lang>|off|test] speak agent text in your language
|
|
859
956
|
ai-notify doctor check deps & wiring
|
|
860
957
|
ai-notify config [init] print (or write) config
|
package/src/notify.mjs
CHANGED
|
@@ -145,7 +145,12 @@ const shortenForSpeech = (text, max = 40) => {
|
|
|
145
145
|
};
|
|
146
146
|
|
|
147
147
|
// Public entry. Called by the hook handler with already-parsed fields.
|
|
148
|
-
|
|
148
|
+
// `alert` (default true) gates whether this event actually makes noise — sound,
|
|
149
|
+
// spoken read-out, banner, highlight, and the waiting popup. When false the call
|
|
150
|
+
// still keeps the pane/waiting state correct (so a suppressed "done" still clears
|
|
151
|
+
// a popup), it just stays silent. The hook decides `alert` from the per-kind
|
|
152
|
+
// notification toggles.
|
|
153
|
+
export const emit = ({ provider = 'default', event = 'done', label = '', message = '', alert = true }) => {
|
|
149
154
|
const config = readConfig();
|
|
150
155
|
const muted = isMuted();
|
|
151
156
|
const p = config.providers[provider] || config.providers.default;
|
|
@@ -181,9 +186,10 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
|
|
|
181
186
|
// pane's assigned name. Also remember the pane so the menu bar can list it.
|
|
182
187
|
const tty = controllingTty();
|
|
183
188
|
recordPane(tty, label);
|
|
184
|
-
// waiting -> yellow menu bar status (+ the popup); done clears it.
|
|
185
|
-
//
|
|
186
|
-
|
|
189
|
+
// waiting -> yellow menu bar status (+ the popup); done clears it. A suppressed
|
|
190
|
+
// (alert=false) waiting must NOT light up the popup, so only set it when alert.
|
|
191
|
+
// "done" always clears regardless of alert. Pass the reason text for filtering.
|
|
192
|
+
setPaneWaiting(tty, event === 'waiting' && alert, event === 'waiting' ? message || fromTemplate : '');
|
|
187
193
|
const pane = readPaneSetting(tty);
|
|
188
194
|
|
|
189
195
|
// Name this pane in the read-out, most-reliable identity first:
|
|
@@ -254,7 +260,7 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
|
|
|
254
260
|
}
|
|
255
261
|
}
|
|
256
262
|
|
|
257
|
-
if (!muted) {
|
|
263
|
+
if (alert && !muted) {
|
|
258
264
|
playSound(soundName, outVol);
|
|
259
265
|
if (config.speak && outVol > 0) {
|
|
260
266
|
let spoken = false;
|
|
@@ -266,7 +272,7 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
|
|
|
266
272
|
}
|
|
267
273
|
}
|
|
268
274
|
|
|
269
|
-
if (!muted || config.bannerWhenMuted) {
|
|
275
|
+
if (alert && (!muted || config.bannerWhenMuted)) {
|
|
270
276
|
const waiting = event === 'waiting';
|
|
271
277
|
banner(
|
|
272
278
|
waiting ? `⏳ ${label || 'input'}` : `✓ ${label || 'done'}`,
|
|
@@ -283,7 +289,7 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
|
|
|
283
289
|
// Visual highlight of *this* terminal window so a waiting pane stands out
|
|
284
290
|
// among many. Always best-effort, and applied even when muted (you still want
|
|
285
291
|
// to see which window needs you during a meeting).
|
|
286
|
-
if (config.highlightWaiting) {
|
|
292
|
+
if (alert && config.highlightWaiting) {
|
|
287
293
|
try {
|
|
288
294
|
if (event === 'waiting') highlightWaiting(label, config.highlightColor);
|
|
289
295
|
else if (event === 'done') clearHighlight();
|
package/src/providers/claude.mjs
CHANGED
|
@@ -44,13 +44,17 @@ const entry = (node, cliPath, event) => ({
|
|
|
44
44
|
],
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
// SubagentStop lets the user (optionally) be alerted when a sub-agent finishes,
|
|
48
|
+
// distinctly from the main turn's Stop. Off by default (see notify kinds), but
|
|
49
|
+
// wired so the toggle works. Only the CORE events count toward "wired" status.
|
|
50
|
+
const EVENTS = { Notification: 'waiting', Stop: 'done', SubagentStop: 'subagent-done' };
|
|
51
|
+
const CORE_EVENTS = ['Notification', 'Stop'];
|
|
48
52
|
|
|
49
53
|
export const status = () => {
|
|
50
54
|
if (!detect()) return { installed: false, wired: false };
|
|
51
55
|
const data = load();
|
|
52
56
|
const hooks = data.hooks || {};
|
|
53
|
-
const wired =
|
|
57
|
+
const wired = CORE_EVENTS.every((k) =>
|
|
54
58
|
(hooks[k] || []).some((g) => (g.hooks || []).some((h) => isOurs(h.command)))
|
|
55
59
|
);
|
|
56
60
|
return { installed: true, wired };
|
package/src/state.mjs
CHANGED
|
@@ -234,6 +234,24 @@ export const setPopupImage = (p) => {
|
|
|
234
234
|
else rmSync(popupImagePath(), { force: true });
|
|
235
235
|
};
|
|
236
236
|
|
|
237
|
+
// Per-kind notification toggles — which kinds of agent event actually alert
|
|
238
|
+
// (sound / banner / voice / popup). Lets you, e.g., keep "input waiting" but
|
|
239
|
+
// silence "done", or enable "sub-agent done". Disabled kinds still update the
|
|
240
|
+
// waiting state correctly (so a suppressed "done" still clears a popup).
|
|
241
|
+
export const NOTIFY_KINDS = ['input', 'permission', 'info', 'done', 'subagent-done'];
|
|
242
|
+
const NOTIFY_KIND_DEFAULTS = { input: true, permission: true, info: false, done: true, 'subagent-done': false };
|
|
243
|
+
const notifyKindsPath = () => join(stateDir(), 'notify-kinds.json');
|
|
244
|
+
export const getNotifyKinds = () => ({ ...NOTIFY_KIND_DEFAULTS, ...readJson(notifyKindsPath(), {}) });
|
|
245
|
+
export const isNotifyKindEnabled = (kind) => {
|
|
246
|
+
const k = getNotifyKinds();
|
|
247
|
+
return kind in k ? !!k[kind] : true; // unknown kinds default to alerting
|
|
248
|
+
};
|
|
249
|
+
export const setNotifyKind = (kind, on) => {
|
|
250
|
+
const all = readJson(notifyKindsPath(), {});
|
|
251
|
+
all[kind] = !!on;
|
|
252
|
+
writeJson(notifyKindsPath(), all);
|
|
253
|
+
};
|
|
254
|
+
|
|
237
255
|
// Popup notify threshold: only show the popup once a pane has been waiting this
|
|
238
256
|
// many seconds (0 = immediately) — so transient / sub-agent waits don't nag.
|
|
239
257
|
const popupDelayPath = () => join(stateDir(), 'popup-delay');
|