ai-notify 0.5.2 → 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 +7 -1
- package/README.md +7 -1
- package/menubar/AiNotifyMenuBar.swift +206 -133
- package/menubar/dist/ai-notify.app/Contents/MacOS/ai-notify-menubar +0 -0
- package/package.json +1 -1
- package/src/cli.mjs +42 -3
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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,17 +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"
|
|
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
|
|
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
|
|
165
247
|
|
|
166
248
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
167
249
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
@@ -230,12 +312,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
230
312
|
if e.type == .rightMouseUp { toggle() } else { showMenu() }
|
|
231
313
|
}
|
|
232
314
|
|
|
233
|
-
// --- "Waiting for input"
|
|
234
|
-
// Driven off the 1s timer
|
|
235
|
-
//
|
|
236
|
-
//
|
|
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).
|
|
237
320
|
private func updateWaitingPopup() {
|
|
238
|
-
var panes: [(tty: String, name: String, ts: Double, msg: String)] = []
|
|
321
|
+
var panes: [(tty: String, name: String, ts: Double, msg: String, portrait: String?)] = []
|
|
239
322
|
if State.popupEnabled {
|
|
240
323
|
let now = Date().timeIntervalSince1970 * 1000
|
|
241
324
|
let delayMs = State.popupDelayMs
|
|
@@ -249,109 +332,56 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
249
332
|
return true
|
|
250
333
|
}
|
|
251
334
|
}
|
|
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
|
|
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)
|
|
280
344
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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)
|
|
287
373
|
}
|
|
288
374
|
}
|
|
289
375
|
|
|
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
|
|
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))
|
|
355
385
|
}
|
|
356
386
|
|
|
357
387
|
private func toggle() { State.setMuted(!State.isMuted); render() }
|
|
@@ -372,6 +402,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
372
402
|
// loop swallows the keystrokes. So naming a pane opens a normal modal dialog
|
|
373
403
|
// (NSAlert with a text field), which takes keyboard focus properly. Empty =>
|
|
374
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
|
+
|
|
375
424
|
@objc private func promptPaneName(_ sender: NSMenuItem) {
|
|
376
425
|
guard let info = sender.representedObject as? [String], let tty = info.first else { return }
|
|
377
426
|
let current = info.count > 1 ? info[1] : ""
|
|
@@ -592,13 +641,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
592
641
|
}
|
|
593
642
|
|
|
594
643
|
menu.addItem(.separator())
|
|
595
|
-
// Waiting-for-input
|
|
596
|
-
|
|
597
|
-
let
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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)
|
|
602
675
|
|
|
603
676
|
menu.addItem(.separator())
|
|
604
677
|
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.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
|
|
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
|