ai-notify 0.5.1 → 0.6.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
@@ -124,7 +124,13 @@ ai-notify popup image ~/zundamon.png # 好きなキャラ画像(PNG/JPG)
124
124
  ai-notify popup off
125
125
  ```
126
126
 
127
- 全アプリ・全スペースの上に浮かび、`<ペイン名> は応答待ち!` を表示。複数同時もまとめて表示します。クリックで消えます。macOS 専用(メニューバーアプリの導入が必要)。
127
+ 全アプリ・全スペースの上に浮かびます。**待っているペインごとに1枚のカード**が右下に積み重なり、各カードはそのペインの **VOICEVOX のボイスのキャラ立ち絵**を表示します(ずんだもんの声のペインはずんだもん、春日部つむぎの声はつむぎ)。カードをクリックで個別に消せます。macOS 専用(メニューバーアプリの導入が必要)。
128
+
129
+ ```sh
130
+ ai-notify popup portraits # 各VOICEVOXキャラの公式立ち絵をキャッシュ(初回のみ・エンジン起動が必要)
131
+ ```
132
+
133
+ ここの設定はすべてメニューバーからも操作できます:**応答待ちポップアップ** →「有効にする/待ち時間/無視ワード/ボイスの立ち絵を取得」。
128
134
 
129
135
  **出す条件を設定できます。** すべての「待ち」で割り込まれたくない人向け。サブエージェントの一瞬の待ちは黙ってほしいが、本当の「入力待ち」は気づきたい——を出し分けられます:
130
136
 
package/README.md CHANGED
@@ -125,7 +125,13 @@ ai-notify popup image ~/zundamon.png # your own character (PNG/JPG); default
125
125
  ai-notify popup off
126
126
  ```
127
127
 
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).
128
+ 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).
129
+
130
+ ```sh
131
+ ai-notify popup portraits # cache every VOICEVOX character's official portrait (run once; engine must be on)
132
+ ```
133
+
134
+ Everything here is also in the menu bar: **応答待ちポップアップ** → enable, 待ち時間 (delay), 無視ワード (ignore), and ボイスの立ち絵を取得 (portraits).
129
135
 
130
136
  **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
137
 
@@ -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,7 +136,7 @@ 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
 
@@ -151,16 +165,85 @@ enum State {
151
165
  }
152
166
  }
153
167
 
168
+ // One floating "応答待ち" card, built once and updated in place.
169
+ final class PopupCard: NSObject {
170
+ let window: NSWindow
171
+ private let imageView = NSImageView()
172
+ private let face = NSTextField(labelWithString: "(。・ω・。)ノ")
173
+ private let label = NSTextField(wrappingLabelWithString: "")
174
+ var onClick: () -> Void = {}
175
+
176
+ override init() {
177
+ window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 300, height: 96),
178
+ styleMask: [.borderless], backing: .buffered, defer: false)
179
+ super.init()
180
+ window.isOpaque = false
181
+ window.backgroundColor = .clear
182
+ window.level = .floating
183
+ window.hasShadow = true
184
+ window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
185
+ window.ignoresMouseEvents = false
186
+
187
+ let card = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 96))
188
+ card.wantsLayer = true
189
+ card.layer?.cornerRadius = 16
190
+ card.layer?.backgroundColor = NSColor(calibratedWhite: 0.10, alpha: 0.95).cgColor
191
+ card.layer?.borderWidth = 2
192
+ card.layer?.borderColor = NSColor.systemYellow.withAlphaComponent(0.9).cgColor
193
+
194
+ imageView.frame = NSRect(x: 12, y: 12, width: 72, height: 72)
195
+ imageView.imageScaling = .scaleProportionallyUpOrDown
196
+ imageView.isHidden = true
197
+ card.addSubview(imageView)
198
+
199
+ face.frame = NSRect(x: 8, y: 30, width: 80, height: 36)
200
+ face.alignment = .center
201
+ face.font = .systemFont(ofSize: 17, weight: .semibold)
202
+ face.textColor = .systemYellow
203
+ card.addSubview(face)
204
+
205
+ let badge = NSTextField(labelWithString: "🟡 応答待ち")
206
+ badge.frame = NSRect(x: 96, y: 56, width: 196, height: 22)
207
+ badge.font = .systemFont(ofSize: 13, weight: .bold)
208
+ badge.textColor = .systemYellow
209
+ badge.isBordered = false
210
+ badge.backgroundColor = .clear
211
+ card.addSubview(badge)
212
+
213
+ label.frame = NSRect(x: 96, y: 12, width: 196, height: 44)
214
+ label.font = .systemFont(ofSize: 15, weight: .semibold)
215
+ label.textColor = .white
216
+ label.maximumNumberOfLines = 2
217
+ label.isBordered = false
218
+ label.backgroundColor = .clear
219
+ card.addSubview(label)
220
+
221
+ card.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(clicked)))
222
+ window.contentView = card
223
+ }
224
+
225
+ @objc private func clicked() { onClick() }
226
+
227
+ func update(text: String, image: NSImage?) {
228
+ label.stringValue = text
229
+ if let img = image {
230
+ imageView.image = img
231
+ imageView.isHidden = false
232
+ face.isHidden = true
233
+ } else {
234
+ imageView.isHidden = true
235
+ face.isHidden = false
236
+ }
237
+ }
238
+ }
239
+
154
240
  final class AppDelegate: NSObject, NSApplicationDelegate {
155
241
  private var statusItem: NSStatusItem!
156
242
  private var timer: Timer?
157
243
 
158
- // The "waiting for input" character popup.
159
- private var waitingWindow: NSWindow?
160
- private var waitingImageView: NSImageView?
161
- private var waitingLabel: NSTextField?
162
- private var waitingSig = "" // current panes signature, to avoid needless redraws
163
- private var waitingDismissedSig = "" // a signature the user clicked away; don't reshow it
244
+ // The "waiting for input" popup — one floating card per waiting pane.
245
+ private var waitingCards: [String: PopupCard] = [:] // keyed by tty
246
+ private var dismissedTtys: Set<String> = [] // clicked away; reshow on the next wait
164
247
 
165
248
  func applicationDidFinishLaunching(_ notification: Notification) {
166
249
  statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
@@ -229,12 +312,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
229
312
  if e.type == .rightMouseUp { toggle() } else { showMenu() }
230
313
  }
231
314
 
232
- // --- "Waiting for input" character popup ------------------------------
233
- // Driven off the 1s timer: when popup is enabled and a pane is waiting, show
234
- // an always-on-top window with a character + "<name> は応答待ち!". It hides
235
- // itself the moment nothing is waiting (or the user clicks it away).
315
+ // --- "Waiting for input" popup (one card per waiting pane) ------------
316
+ // Driven off the 1s timer. Each waiting pane gets its own floating card,
317
+ // showing its name and when the pane's voice is a VOICEVOX character —
318
+ // that character's portrait. Cards stack at the bottom-right and disappear
319
+ // the moment their pane stops waiting (or you click one away).
236
320
  private func updateWaitingPopup() {
237
- var panes: [(tty: String, name: String, ts: Double, msg: String)] = []
321
+ var panes: [(tty: String, name: String, ts: Double, msg: String, portrait: String?)] = []
238
322
  if State.popupEnabled {
239
323
  let now = Date().timeIntervalSince1970 * 1000
240
324
  let delayMs = State.popupDelayMs
@@ -248,106 +332,56 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
248
332
  return true
249
333
  }
250
334
  }
251
- if panes.isEmpty {
252
- waitingDismissedSig = "" // reset; the next wait shows again
253
- hideWaitingPopup()
254
- return
255
- }
256
- let names = panes.map { $0.name }
257
- let sig = names.joined(separator: "\u{1}")
258
- if sig == waitingDismissedSig { return } // user dismissed exactly this set
259
- if sig != waitingSig { waitingSig = sig; refreshWaitingPopup(names: names) }
260
- waitingWindow?.orderFront(nil)
261
- }
262
-
263
- private func refreshWaitingPopup(names: [String]) {
264
- if waitingWindow == nil { buildWaitingWindow() }
265
- guard let win = waitingWindow else { return }
266
- let line = names.count == 1
267
- ? "\(names[0]) は応答待ち!"
268
- : "\(names.count)件 応答待ち:\(names.joined(separator: "、"))"
269
- waitingLabel?.stringValue = line
270
-
271
- // Custom character image, or a friendly default kaomoji.
272
- if let p = State.popupImage, let img = NSImage(contentsOfFile: p) {
273
- waitingImageView?.image = img
274
- waitingImageView?.isHidden = false
275
- } else {
276
- waitingImageView?.isHidden = true
335
+ let activeTtys = Set(panes.map { $0.tty })
336
+ dismissedTtys.formIntersection(activeTtys) // a pane that re-enters waiting shows again
337
+
338
+ let visible = panes.filter { !dismissedTtys.contains($0.tty) }
339
+ let visibleTtys = Set(visible.map { $0.tty })
340
+ // Tear down cards for panes that are no longer shown.
341
+ for (tty, card) in waitingCards where !visibleTtys.contains(tty) {
342
+ card.window.orderOut(nil)
343
+ waitingCards.removeValue(forKey: tty)
277
344
  }
278
-
279
- if let scr = NSScreen.main {
280
- let m: CGFloat = 24
281
- let f = win.frame
282
- win.setFrameOrigin(NSPoint(x: scr.visibleFrame.maxX - f.width - m,
283
- y: scr.visibleFrame.minY + m))
345
+ // NSScreen.main is nil for a background app with no focused window — fall
346
+ // back to the first screen so the cards always have somewhere to land.
347
+ guard let scr = NSScreen.main ?? NSScreen.screens.first else { return }
348
+ let margin: CGFloat = 24, gap: CGFloat = 12, h: CGFloat = 96, w: CGFloat = 300
349
+ for (i, p) in visible.prefix(6).enumerated() {
350
+ let card: PopupCard
351
+ if let existing = waitingCards[p.tty] {
352
+ card = existing
353
+ } else {
354
+ let c = PopupCard()
355
+ let tty = p.tty
356
+ c.onClick = { [weak self] in
357
+ self?.dismissedTtys.insert(tty)
358
+ self?.waitingCards[tty]?.window.orderOut(nil)
359
+ self?.waitingCards.removeValue(forKey: tty)
360
+ }
361
+ waitingCards[p.tty] = c
362
+ card = c
363
+ }
364
+ // Image priority: the voice's portrait (head-cropped) > a global popup
365
+ // image > the default kaomoji (image == nil).
366
+ var img: NSImage?
367
+ if let pp = p.portrait { img = faceCrop(path: pp) }
368
+ else if let gp = State.popupImage { img = NSImage(contentsOfFile: gp) }
369
+ card.update(text: "\(p.name) は応答待ち!", image: img)
370
+ card.window.setFrameOrigin(NSPoint(x: scr.visibleFrame.maxX - w - margin,
371
+ y: scr.visibleFrame.minY + margin + CGFloat(i) * (h + gap)))
372
+ card.window.orderFront(nil)
284
373
  }
285
374
  }
286
375
 
287
- private func hideWaitingPopup() {
288
- guard let w = waitingWindow else { return }
289
- w.orderOut(nil)
290
- waitingSig = ""
291
- }
292
-
293
- @objc private func waitingClicked() {
294
- waitingDismissedSig = waitingSig // don't reshow this exact set
295
- hideWaitingPopup()
296
- }
297
-
298
- private func buildWaitingWindow() {
299
- let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 300, height: 96),
300
- styleMask: [.borderless], backing: .buffered, defer: false)
301
- w.isOpaque = false
302
- w.backgroundColor = .clear
303
- w.level = .floating
304
- w.hasShadow = true
305
- w.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
306
- w.ignoresMouseEvents = false
307
-
308
- let card = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 96))
309
- card.wantsLayer = true
310
- card.layer?.cornerRadius = 16
311
- card.layer?.backgroundColor = NSColor(calibratedWhite: 0.10, alpha: 0.95).cgColor
312
- card.layer?.borderWidth = 2
313
- card.layer?.borderColor = NSColor.systemYellow.withAlphaComponent(0.9).cgColor
314
-
315
- let iv = NSImageView(frame: NSRect(x: 12, y: 12, width: 72, height: 72))
316
- iv.imageScaling = .scaleProportionallyUpOrDown
317
- iv.isHidden = true
318
- card.addSubview(iv)
319
- waitingImageView = iv
320
-
321
- // A friendly default "character" — a kaomoji — sits behind the image slot
322
- // so there's always a face even without a custom image.
323
- let face = NSTextField(labelWithString: "(。・ω・。)ノ")
324
- face.frame = NSRect(x: 8, y: 30, width: 80, height: 36)
325
- face.alignment = .center
326
- face.font = .systemFont(ofSize: 17, weight: .semibold)
327
- face.textColor = .systemYellow
328
- card.addSubview(face, positioned: .below, relativeTo: iv)
329
-
330
- let badge = NSTextField(labelWithString: "🟡 応答待ち")
331
- badge.frame = NSRect(x: 96, y: 56, width: 196, height: 22)
332
- badge.font = .systemFont(ofSize: 13, weight: .bold)
333
- badge.textColor = .systemYellow
334
- badge.backgroundColor = .clear
335
- badge.isBordered = false
336
- card.addSubview(badge)
337
-
338
- let label = NSTextField(wrappingLabelWithString: "")
339
- label.frame = NSRect(x: 96, y: 12, width: 196, height: 44)
340
- label.font = .systemFont(ofSize: 15, weight: .semibold)
341
- label.textColor = .white
342
- label.maximumNumberOfLines = 2
343
- label.backgroundColor = .clear
344
- label.isBordered = false
345
- card.addSubview(label)
346
- waitingLabel = label
347
-
348
- card.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(waitingClicked)))
349
- w.contentView = card
350
- waitingWindow = w
376
+ // Crop a full-body VOICEVOX portrait down to the head (top-center) so it
377
+ // reads at card size. Heuristic fractions that fit the standard 立ち絵.
378
+ private func faceCrop(path: String) -> NSImage? {
379
+ guard let src = NSImage(contentsOfFile: path),
380
+ let cg = src.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
381
+ let W = CGFloat(cg.width), H = CGFloat(cg.height)
382
+ let rect = CGRect(x: W * 0.18, y: H * 0.03, width: W * 0.64, height: H * 0.30)
383
+ guard let cropped = cg.cropping(to: rect) else { return src }
384
+ return NSImage(cgImage: cropped, size: NSSize(width: rect.width, height: rect.height))
351
385
  }
352
386
 
353
387
  private func toggle() { State.setMuted(!State.isMuted); render() }
@@ -368,6 +402,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
368
402
  // loop swallows the keystrokes. So naming a pane opens a normal modal dialog
369
403
  // (NSAlert with a text field), which takes keyboard focus properly. Empty =>
370
404
  // clear (the pane falls back to its label / the speakLabel default).
405
+ // Edit the popup "ignore" reason-keywords from the menu (a modal text field,
406
+ // since menus can't host an editable field). Empty clears the filter.
407
+ @objc private func promptPopupIgnore() {
408
+ let alert = NSAlert()
409
+ alert.messageText = "無視ワード"
410
+ alert.informativeText = "待ち理由メッセージにこの語を含む通知はポップアップしません(カンマ区切り・空欄で解除)"
411
+ let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
412
+ field.stringValue = State.popupIgnoreRaw
413
+ field.placeholderString = "例: subagent,task"
414
+ alert.accessoryView = field
415
+ alert.addButton(withTitle: "保存")
416
+ alert.addButton(withTitle: "キャンセル")
417
+ NSApp.activate(ignoringOtherApps: true)
418
+ alert.window.initialFirstResponder = field
419
+ guard alert.runModal() == .alertFirstButtonReturn else { return }
420
+ let v = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
421
+ State.cli(["popup", "ignore", v.isEmpty ? "clear" : v])
422
+ }
423
+
371
424
  @objc private func promptPaneName(_ sender: NSMenuItem) {
372
425
  guard let info = sender.representedObject as? [String], let tty = info.first else { return }
373
426
  let current = info.count > 1 ? info[1] : ""
@@ -588,13 +641,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
588
641
  }
589
642
 
590
643
  menu.addItem(.separator())
591
- // Waiting-for-input character popup: on/off (set the character image with
592
- // `ai-notify popup image <path>`).
593
- let popupItem = NSMenuItem(title: "応答待ちポップアップ", action: #selector(runItem(_:)), keyEquivalent: "")
594
- popupItem.target = self
595
- popupItem.representedObject = ["popup", "toggle"]
596
- popupItem.state = State.popupEnabled ? .on : .off
597
- menu.addItem(popupItem)
644
+ // Waiting-for-input popup: enable + when-to-show settings, all in a submenu.
645
+ let popupParent = NSMenuItem(title: "応答待ちポップアップ", action: nil, keyEquivalent: "")
646
+ let popupSub = NSMenu()
647
+ let onItem = NSMenuItem(title: "有効にする", action: #selector(runItem(_:)), keyEquivalent: "")
648
+ onItem.target = self
649
+ onItem.representedObject = ["popup", "toggle"]
650
+ onItem.state = State.popupEnabled ? .on : .off
651
+ popupSub.addItem(onItem)
652
+ popupSub.addItem(.separator())
653
+ // Threshold: only show after waiting this long.
654
+ popupSub.addItem(disabledHeader("出すまでの待ち時間"))
655
+ let curDelay = State.popupDelaySec
656
+ for (sec, title) in [(0, "即時"), (5, "5秒"), (10, "10秒"), (15, "15秒"), (30, "30秒"), (60, "60秒")] {
657
+ let it = NSMenuItem(title: title, action: #selector(runItem(_:)), keyEquivalent: "")
658
+ it.target = self
659
+ it.representedObject = ["popup", "delay", String(sec)]
660
+ it.state = (curDelay == sec) ? .on : .off
661
+ popupSub.addItem(it)
662
+ }
663
+ popupSub.addItem(.separator())
664
+ // Reason filter + portrait sync.
665
+ let ig = State.popupIgnoreRaw
666
+ let ignoreItem = NSMenuItem(title: ig.isEmpty ? "無視ワードを設定…" : "無視ワード: \(ig)", action: #selector(promptPopupIgnore), keyEquivalent: "")
667
+ ignoreItem.target = self
668
+ popupSub.addItem(ignoreItem)
669
+ let portItem = NSMenuItem(title: "ボイスの立ち絵を取得(VOICEVOX)", action: #selector(runItem(_:)), keyEquivalent: "")
670
+ portItem.target = self
671
+ portItem.representedObject = ["popup", "portraits"]
672
+ popupSub.addItem(portItem)
673
+ popupParent.submenu = popupSub
674
+ menu.addItem(popupParent)
598
675
 
599
676
  menu.addItem(.separator())
600
677
  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.1",
3
+ "version": "0.6.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';
@@ -42,7 +42,7 @@ import {
42
42
  getPopupIgnore,
43
43
  setPopupIgnore,
44
44
  } from './state.mjs';
45
- import { resolve as resolvePath } from 'node:path';
45
+ import { resolve as resolvePath, join as pathJoin } from 'node:path';
46
46
 
47
47
  // Single source of truth: read the version from package.json so `--version`
48
48
  // (and the Homebrew formula test that checks it) always matches the release.
@@ -625,6 +625,45 @@ const cmds = {
625
625
  setPopupDelay(Math.max(0, v));
626
626
  return log(v > 0 ? `popup delay → ${Math.max(0, v)}s (waits shorter than this are ignored)` : 'popup delay → 0s (immediate)');
627
627
  }
628
+ // Cache each VOICEVOX character's official portrait so the popup can show a
629
+ // pane in its own voice's character. Saved per style id: portraits/<id>.png.
630
+ if (sub === 'portraits') {
631
+ const url = readConfig().voicevox?.url || voicevox.DEFAULT_URL;
632
+ if (!voicevox.isAvailable(url)) {
633
+ console.error('VOICEVOX engine not running — start it, then: ai-notify popup portraits');
634
+ process.exit(1);
635
+ }
636
+ const dir = pathJoin(paths.stateDir(), 'portraits');
637
+ mkdirSync(dir, { recursive: true });
638
+ let speakers;
639
+ try {
640
+ speakers = JSON.parse(execSync(`curl -s -m 6 "${url}/speakers"`, { encoding: 'utf8', maxBuffer: 1 << 24 }));
641
+ } catch {
642
+ console.error('could not reach the VOICEVOX engine.');
643
+ process.exit(1);
644
+ }
645
+ let n = 0;
646
+ for (const sp of speakers) {
647
+ let info;
648
+ try {
649
+ const raw = execSync(`curl -s -m 8 "${url}/speaker_info?speaker_uuid=${sp.speaker_uuid}"`, {
650
+ encoding: 'utf8',
651
+ maxBuffer: 1 << 26,
652
+ });
653
+ info = JSON.parse(raw);
654
+ } catch {
655
+ continue;
656
+ }
657
+ if (!info.portrait) continue;
658
+ const buf = Buffer.from(info.portrait, 'base64');
659
+ for (const st of sp.styles || []) {
660
+ writeFileSync(pathJoin(dir, `${st.id}.png`), buf);
661
+ n++;
662
+ }
663
+ }
664
+ return log(`synced ${n} voice portrait(s) → ${dir}`);
665
+ }
666
+
628
667
  // Suppress the popup when the waiting reason contains any of these keywords.
629
668
  if (sub === 'ignore') {
630
669
  const kw = positionals.slice(1).join(' ').trim();
@@ -854,7 +893,7 @@ Usage:
854
893
  ai-notify tsundere [on|off|level <0-1>|test|status] tsundere persona (ツン⇄デレ by urgency)
855
894
  ai-notify voice-prosody [speed|pitch|intonation <v>|reset] VOICEVOX read-out tuning
856
895
  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)
896
+ ai-notify popup [on|off|image <p>|delay <s>|ignore <kw>|portraits] per-pane "waiting" popup, in the pane's voice (macOS)
858
897
  ai-notify translate [on <lang>|off|test] speak agent text in your language
859
898
  ai-notify doctor check deps & wiring
860
899
  ai-notify config [init] print (or write) config