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 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
- 全アプリ・全スペースの上に浮かび、`<ペイン名> は応答待ち!` を表示。複数同時もまとめて表示します。クリックで消えます。macOS 専用(メニューバーアプリの導入が必要)。
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
- It floats over every app and Space, shows `<pane name> は応答待ち!`, and lists multiple waiting panes at once. Click it to dismiss. macOS-only (needs the menu bar app installed).
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
- guard let s = try? String(contentsOfFile: file("popup-ignore"), encoding: .utf8) else { return [] }
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
- // Panes currently waiting for input (most-recent first), each with a display
100
- // name, the wait-start time, and the reason message. Name = spoken name,
101
- // else recorded label, else tty. Handles both the old (number) and new
102
- // ({ts,msg}) waiting.json value shapes.
103
- static func waitingPanes() -> [(tty: String, name: String, ts: Double, msg: String)] {
104
- func obj(_ name: String) -> [String: Any] {
105
- (try? Data(contentsOf: URL(fileURLWithPath: file(name))))
106
- .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } ?? [:]
107
- }
108
- let waiting = obj("waiting.json")
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 = obj("pane-voices.json")
111
- let panes = obj("panes.json")
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" character popup.
159
- private var waitingWindow: NSWindow?
160
- private var waitingImageView: NSImageView?
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" character popup ------------------------------
234
- // Driven off the 1s timer: when popup is enabled and a pane is waiting, show
235
- // an always-on-top window with a character + "<name> は応答待ち!". It hides
236
- // itself the moment nothing is waiting (or the user clicks it away).
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
- if panes.isEmpty {
253
- waitingDismissedSig = "" // reset; the next wait shows again
254
- hideWaitingPopup()
255
- return
256
- }
257
- let names = panes.map { $0.name }
258
- let sig = names.joined(separator: "\u{1}")
259
- if sig == waitingDismissedSig { return } // user dismissed exactly this set
260
- if sig != waitingSig { waitingSig = sig; refreshWaitingPopup(names: names) }
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
- if let scr = NSScreen.main {
283
- let m: CGFloat = 24
284
- let f = win.frame
285
- win.setFrameOrigin(NSPoint(x: scr.visibleFrame.maxX - f.width - m,
286
- y: scr.visibleFrame.minY + m))
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
- private func hideWaitingPopup() {
291
- guard let w = waitingWindow else { return }
292
- w.orderOut(nil)
293
- waitingSig = ""
294
- }
295
-
296
- @objc private func waitingClicked() {
297
- waitingDismissedSig = waitingSig // don't reshow this exact set
298
- hideWaitingPopup()
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 character popup: on/off (set the character image with
596
- // `ai-notify popup image <path>`).
597
- let popupItem = NSMenuItem(title: "応答待ちポップアップ", action: #selector(runItem(_:)), keyEquivalent: "")
598
- popupItem.target = self
599
- popupItem.representedObject = ["popup", "toggle"]
600
- popupItem.state = State.popupEnabled ? .on : .off
601
- menu.addItem(popupItem)
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")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-notify",
3
- "version": "0.5.2",
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
- emit({ provider: byId(source) ? source : 'default', event, label, message });
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 popup [on|off|image <p>|delay <s>|ignore <kw>] "waiting for input" popup + when it shows (macOS)
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
- export const emit = ({ provider = 'default', event = 'done', label = '', message = '' }) => {
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. Pass the
185
- // reason text so the popup can filter by it (e.g. ignore sub-agent waits).
186
- setPaneWaiting(tty, event === 'waiting', event === 'waiting' ? message || fromTemplate : '');
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();
@@ -44,13 +44,17 @@ const entry = (node, cliPath, event) => ({
44
44
  ],
45
45
  });
46
46
 
47
- const EVENTS = { Notification: 'waiting', Stop: 'done' };
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 = Object.keys(EVENTS).every((k) =>
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');