ai-notify 0.4.9 → 0.5.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 +14 -0
- package/README.md +14 -0
- package/menubar/AiNotifyMenuBar.swift +159 -1
- package/menubar/dist/ai-notify.app/Contents/MacOS/ai-notify-menubar +0 -0
- package/package.json +1 -1
- package/src/cli.mjs +31 -0
- package/src/state.mjs +25 -0
package/README.ja.md
CHANGED
|
@@ -112,6 +112,20 @@ ai-notify menubar install # ネイティブのメニューバーアプリ・
|
|
|
112
112
|
|
|
113
113
|
> 切替は実行中でも効きます:次にエージェントが発火した時にフラグを読むので、トグルした瞬間に全稼働エージェントへ反映されます。
|
|
114
114
|
|
|
115
|
+
## 🪧 「応答待ち」ポップアップ
|
|
116
|
+
|
|
117
|
+
エージェントが止まってあなたの入力を待っている状態は、ターミナルが多いと見落としがち。とくに IDE(WebStorm・VS Code)のターミナルは画像やリッチ通知を出せません。**常に最前面のキャラポップアップ**をオンにすると、応答待ちのペイン名を表示し、応答した瞬間に消えます:
|
|
118
|
+
|
|
119
|
+

|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
ai-notify popup on # 有効化(メニューバーの「応答待ちポップアップ」でも切替)
|
|
123
|
+
ai-notify popup image ~/zundamon.png # 好きなキャラ画像(PNG/JPG)。既定は顔文字
|
|
124
|
+
ai-notify popup off
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
全アプリ・全スペースの上に浮かび、`<ペイン名> は応答待ち!` を表示。複数同時もまとめて表示します。クリックで消えます。macOS 専用(メニューバーアプリの導入が必要)。
|
|
128
|
+
|
|
115
129
|
## 🎙️ VOICEVOX キャラクターボイス
|
|
116
130
|
|
|
117
131
|
通知を [VOICEVOX](https://voicevox.hiroshiba.jp/) のキャラ声(例:ずんだもん)で読み上げられます(無料・ローカル・オフライン)。
|
package/README.md
CHANGED
|
@@ -113,6 +113,20 @@ No third-party app needed. Prefer something else? There are drop-in recipes for
|
|
|
113
113
|
|
|
114
114
|
> Toggling works mid-run: the flag is read the next time an agent fires, so flipping it instantly affects every running agent.
|
|
115
115
|
|
|
116
|
+
## 🪧 "Waiting for input" popup
|
|
117
|
+
|
|
118
|
+
A stopped agent waiting on you is easy to miss across many terminals — especially in IDE terminals (WebStorm, VS Code) that can't show images or rich notifications. Turn on an **always-on-top character popup** that names the waiting pane and vanishes the moment you respond:
|
|
119
|
+
|
|
120
|
+

|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
ai-notify popup on # enable (also a menu bar toggle: 応答待ちポップアップ)
|
|
124
|
+
ai-notify popup image ~/zundamon.png # your own character (PNG/JPG); default is a kaomoji
|
|
125
|
+
ai-notify popup off
|
|
126
|
+
```
|
|
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).
|
|
129
|
+
|
|
116
130
|
## 🎙️ VOICEVOX character voices
|
|
117
131
|
|
|
118
132
|
Optionally speak your notifications in [VOICEVOX](https://voicevox.hiroshiba.jp/) character voices (e.g. ずんだもん) — free, local, offline.
|
|
@@ -75,6 +75,37 @@ enum State {
|
|
|
75
75
|
try? String(format: "%.2f", v).write(toFile: file("volume"), atomically: true, encoding: .utf8)
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// The "waiting" character popup: a flag file toggles it, an optional file
|
|
79
|
+
// holds a custom character image path. Same plain-file pattern as the others.
|
|
80
|
+
static var popupEnabled: Bool { FileManager.default.fileExists(atPath: file("popup")) }
|
|
81
|
+
static var popupImage: String? {
|
|
82
|
+
guard let s = try? String(contentsOfFile: file("popup-image"), encoding: .utf8) else { return nil }
|
|
83
|
+
let t = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
84
|
+
return t.isEmpty ? nil : t
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Panes currently waiting for input, each with a display name (most-recent
|
|
88
|
+
// first). Name = the pane's spoken name, else its recorded label, else tty.
|
|
89
|
+
static func waitingPanes() -> [(tty: String, name: String)] {
|
|
90
|
+
func obj(_ name: String) -> [String: Any] {
|
|
91
|
+
(try? Data(contentsOf: URL(fileURLWithPath: file(name))))
|
|
92
|
+
.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } ?? [:]
|
|
93
|
+
}
|
|
94
|
+
let waiting = obj("waiting.json")
|
|
95
|
+
if waiting.isEmpty { return [] }
|
|
96
|
+
let voices = obj("pane-voices.json")
|
|
97
|
+
let panes = obj("panes.json")
|
|
98
|
+
return waiting
|
|
99
|
+
.sorted { (((($0.value as? NSNumber)?.doubleValue) ?? 0) > ((($1.value as? NSNumber)?.doubleValue) ?? 0)) }
|
|
100
|
+
.map { (tty, _) in
|
|
101
|
+
let short = tty.replacingOccurrences(of: "/dev/", with: "")
|
|
102
|
+
let name = ((voices[tty] as? [String: Any])?["speakName"] as? String)
|
|
103
|
+
?? ((panes[tty] as? [String: Any])?["label"] as? String)
|
|
104
|
+
?? short
|
|
105
|
+
return (tty, name.isEmpty ? short : name)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
78
109
|
// Tsundere baseline level 0.0 (デレ) – 1.0 (ツン). Same file the CLI reads.
|
|
79
110
|
static func setTsundereLevel(_ v: Double) {
|
|
80
111
|
try? FileManager.default.createDirectory(atPath: dir(), withIntermediateDirectories: true)
|
|
@@ -104,6 +135,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
104
135
|
private var statusItem: NSStatusItem!
|
|
105
136
|
private var timer: Timer?
|
|
106
137
|
|
|
138
|
+
// The "waiting for input" character popup.
|
|
139
|
+
private var waitingWindow: NSWindow?
|
|
140
|
+
private var waitingImageView: NSImageView?
|
|
141
|
+
private var waitingLabel: NSTextField?
|
|
142
|
+
private var waitingSig = "" // current panes signature, to avoid needless redraws
|
|
143
|
+
private var waitingDismissedSig = "" // a signature the user clicked away; don't reshow it
|
|
144
|
+
|
|
107
145
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
108
146
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
109
147
|
if let b = statusItem.button {
|
|
@@ -112,7 +150,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
112
150
|
b.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
|
113
151
|
}
|
|
114
152
|
render()
|
|
115
|
-
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
|
153
|
+
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
|
154
|
+
self?.render()
|
|
155
|
+
self?.updateWaitingPopup()
|
|
156
|
+
}
|
|
116
157
|
|
|
117
158
|
var shotPath = ProcessInfo.processInfo.environment["AI_NOTIFY_SHOT"]
|
|
118
159
|
let args = CommandLine.arguments
|
|
@@ -168,6 +209,114 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
168
209
|
if e.type == .rightMouseUp { toggle() } else { showMenu() }
|
|
169
210
|
}
|
|
170
211
|
|
|
212
|
+
// --- "Waiting for input" character popup ------------------------------
|
|
213
|
+
// Driven off the 1s timer: when popup is enabled and a pane is waiting, show
|
|
214
|
+
// an always-on-top window with a character + "<name> は応答待ち!". It hides
|
|
215
|
+
// itself the moment nothing is waiting (or the user clicks it away).
|
|
216
|
+
private func updateWaitingPopup() {
|
|
217
|
+
let panes = State.popupEnabled ? State.waitingPanes() : []
|
|
218
|
+
if panes.isEmpty {
|
|
219
|
+
waitingDismissedSig = "" // reset; the next wait shows again
|
|
220
|
+
hideWaitingPopup()
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
let names = panes.map { $0.name }
|
|
224
|
+
let sig = names.joined(separator: "\u{1}")
|
|
225
|
+
if sig == waitingDismissedSig { return } // user dismissed exactly this set
|
|
226
|
+
if sig != waitingSig { waitingSig = sig; refreshWaitingPopup(names: names) }
|
|
227
|
+
waitingWindow?.orderFront(nil)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private func refreshWaitingPopup(names: [String]) {
|
|
231
|
+
if waitingWindow == nil { buildWaitingWindow() }
|
|
232
|
+
guard let win = waitingWindow else { return }
|
|
233
|
+
let line = names.count == 1
|
|
234
|
+
? "\(names[0]) は応答待ち!"
|
|
235
|
+
: "\(names.count)件 応答待ち:\(names.joined(separator: "、"))"
|
|
236
|
+
waitingLabel?.stringValue = line
|
|
237
|
+
|
|
238
|
+
// Custom character image, or a friendly default kaomoji.
|
|
239
|
+
if let p = State.popupImage, let img = NSImage(contentsOfFile: p) {
|
|
240
|
+
waitingImageView?.image = img
|
|
241
|
+
waitingImageView?.isHidden = false
|
|
242
|
+
} else {
|
|
243
|
+
waitingImageView?.isHidden = true
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if let scr = NSScreen.main {
|
|
247
|
+
let m: CGFloat = 24
|
|
248
|
+
let f = win.frame
|
|
249
|
+
win.setFrameOrigin(NSPoint(x: scr.visibleFrame.maxX - f.width - m,
|
|
250
|
+
y: scr.visibleFrame.minY + m))
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private func hideWaitingPopup() {
|
|
255
|
+
guard let w = waitingWindow else { return }
|
|
256
|
+
w.orderOut(nil)
|
|
257
|
+
waitingSig = ""
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
@objc private func waitingClicked() {
|
|
261
|
+
waitingDismissedSig = waitingSig // don't reshow this exact set
|
|
262
|
+
hideWaitingPopup()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private func buildWaitingWindow() {
|
|
266
|
+
let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 300, height: 96),
|
|
267
|
+
styleMask: [.borderless], backing: .buffered, defer: false)
|
|
268
|
+
w.isOpaque = false
|
|
269
|
+
w.backgroundColor = .clear
|
|
270
|
+
w.level = .floating
|
|
271
|
+
w.hasShadow = true
|
|
272
|
+
w.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
|
273
|
+
w.ignoresMouseEvents = false
|
|
274
|
+
|
|
275
|
+
let card = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 96))
|
|
276
|
+
card.wantsLayer = true
|
|
277
|
+
card.layer?.cornerRadius = 16
|
|
278
|
+
card.layer?.backgroundColor = NSColor(calibratedWhite: 0.10, alpha: 0.95).cgColor
|
|
279
|
+
card.layer?.borderWidth = 2
|
|
280
|
+
card.layer?.borderColor = NSColor.systemYellow.withAlphaComponent(0.9).cgColor
|
|
281
|
+
|
|
282
|
+
let iv = NSImageView(frame: NSRect(x: 12, y: 12, width: 72, height: 72))
|
|
283
|
+
iv.imageScaling = .scaleProportionallyUpOrDown
|
|
284
|
+
iv.isHidden = true
|
|
285
|
+
card.addSubview(iv)
|
|
286
|
+
waitingImageView = iv
|
|
287
|
+
|
|
288
|
+
// A friendly default "character" — a kaomoji — sits behind the image slot
|
|
289
|
+
// so there's always a face even without a custom image.
|
|
290
|
+
let face = NSTextField(labelWithString: "(。・ω・。)ノ")
|
|
291
|
+
face.frame = NSRect(x: 8, y: 30, width: 80, height: 36)
|
|
292
|
+
face.alignment = .center
|
|
293
|
+
face.font = .systemFont(ofSize: 17, weight: .semibold)
|
|
294
|
+
face.textColor = .systemYellow
|
|
295
|
+
card.addSubview(face, positioned: .below, relativeTo: iv)
|
|
296
|
+
|
|
297
|
+
let badge = NSTextField(labelWithString: "🟡 応答待ち")
|
|
298
|
+
badge.frame = NSRect(x: 96, y: 56, width: 196, height: 22)
|
|
299
|
+
badge.font = .systemFont(ofSize: 13, weight: .bold)
|
|
300
|
+
badge.textColor = .systemYellow
|
|
301
|
+
badge.backgroundColor = .clear
|
|
302
|
+
badge.isBordered = false
|
|
303
|
+
card.addSubview(badge)
|
|
304
|
+
|
|
305
|
+
let label = NSTextField(wrappingLabelWithString: "")
|
|
306
|
+
label.frame = NSRect(x: 96, y: 12, width: 196, height: 44)
|
|
307
|
+
label.font = .systemFont(ofSize: 15, weight: .semibold)
|
|
308
|
+
label.textColor = .white
|
|
309
|
+
label.maximumNumberOfLines = 2
|
|
310
|
+
label.backgroundColor = .clear
|
|
311
|
+
label.isBordered = false
|
|
312
|
+
card.addSubview(label)
|
|
313
|
+
waitingLabel = label
|
|
314
|
+
|
|
315
|
+
card.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(waitingClicked)))
|
|
316
|
+
w.contentView = card
|
|
317
|
+
waitingWindow = w
|
|
318
|
+
}
|
|
319
|
+
|
|
171
320
|
private func toggle() { State.setMuted(!State.isMuted); render() }
|
|
172
321
|
@objc private func quit() { NSApp.terminate(nil) }
|
|
173
322
|
|
|
@@ -405,6 +554,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
405
554
|
}
|
|
406
555
|
}
|
|
407
556
|
|
|
557
|
+
menu.addItem(.separator())
|
|
558
|
+
// Waiting-for-input character popup: on/off (set the character image with
|
|
559
|
+
// `ai-notify popup image <path>`).
|
|
560
|
+
let popupItem = NSMenuItem(title: "応答待ちポップアップ", action: #selector(runItem(_:)), keyEquivalent: "")
|
|
561
|
+
popupItem.target = self
|
|
562
|
+
popupItem.representedObject = ["popup", "toggle"]
|
|
563
|
+
popupItem.state = State.popupEnabled ? .on : .off
|
|
564
|
+
menu.addItem(popupItem)
|
|
565
|
+
|
|
408
566
|
menu.addItem(.separator())
|
|
409
567
|
let quitItem = NSMenuItem(title: "ai-notify を終了", action: #selector(quit), keyEquivalent: "q")
|
|
410
568
|
quitItem.target = self
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-notify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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
|
@@ -33,7 +33,12 @@ import {
|
|
|
33
33
|
readPaneSetting,
|
|
34
34
|
updatePaneSetting,
|
|
35
35
|
firstRunNudge,
|
|
36
|
+
isPopupEnabled,
|
|
37
|
+
setPopupEnabled,
|
|
38
|
+
getPopupImage,
|
|
39
|
+
setPopupImage,
|
|
36
40
|
} from './state.mjs';
|
|
41
|
+
import { resolve as resolvePath } from 'node:path';
|
|
37
42
|
|
|
38
43
|
// Single source of truth: read the version from package.json so `--version`
|
|
39
44
|
// (and the Homebrew formula test that checks it) always matches the release.
|
|
@@ -589,6 +594,31 @@ const cmds = {
|
|
|
589
594
|
log(`✓ ${bits.join(' · ')}`);
|
|
590
595
|
},
|
|
591
596
|
|
|
597
|
+
// The "waiting" character popup (menu bar app): an always-on-top window that
|
|
598
|
+
// shows a character saying which pane is waiting for input. macOS-only effect.
|
|
599
|
+
// popup [on|off|toggle|image <path>|image clear|status]
|
|
600
|
+
popup() {
|
|
601
|
+
const sub = positionals[0] || 'status';
|
|
602
|
+
if (sub === 'on' || sub === 'off' || sub === 'toggle') {
|
|
603
|
+
const on = sub === 'toggle' ? !isPopupEnabled() : sub === 'on';
|
|
604
|
+
setPopupEnabled(on);
|
|
605
|
+
return log(on ? '🪧 waiting popup ON' : 'waiting popup OFF');
|
|
606
|
+
}
|
|
607
|
+
if (sub === 'image') {
|
|
608
|
+
const p = positionals[1];
|
|
609
|
+
if (!p || p === 'clear' || p === 'default') {
|
|
610
|
+
setPopupImage('');
|
|
611
|
+
return log('popup image cleared (using the default character).');
|
|
612
|
+
}
|
|
613
|
+
const abs = resolvePath(p);
|
|
614
|
+
setPopupImage(abs);
|
|
615
|
+
return log(`popup image → ${abs}`);
|
|
616
|
+
}
|
|
617
|
+
log(`waiting popup: ${isPopupEnabled() ? '🪧 ON' : 'OFF'}`);
|
|
618
|
+
log(`character image: ${getPopupImage() || '(default)'}`);
|
|
619
|
+
log('\nEnable: ai-notify popup on Your character: ai-notify popup image /path/to/zunda.png');
|
|
620
|
+
},
|
|
621
|
+
|
|
592
622
|
// Get/set the VOICEVOX base prosody (the normal-tone scales the menu bar
|
|
593
623
|
// sliders drive). With no args, prints the current values as JSON.
|
|
594
624
|
// voice-prosody [speed|pitch|intonation <value> | reset]
|
|
@@ -801,6 +831,7 @@ Usage:
|
|
|
801
831
|
ai-notify tsundere [on|off|level <0-1>|test|status] tsundere persona (ツン⇄デレ by urgency)
|
|
802
832
|
ai-notify voice-prosody [speed|pitch|intonation <v>|reset] VOICEVOX read-out tuning
|
|
803
833
|
ai-notify menubar [install|uninstall|status] native menu bar bell (macOS)
|
|
834
|
+
ai-notify popup [on|off|image <path>] "waiting for input" character popup (macOS)
|
|
804
835
|
ai-notify translate [on <lang>|off|test] speak agent text in your language
|
|
805
836
|
ai-notify doctor check deps & wiring
|
|
806
837
|
ai-notify config [init] print (or write) config
|
package/src/state.mjs
CHANGED
|
@@ -206,6 +206,31 @@ export const setPaneWaiting = (tty, waiting) => {
|
|
|
206
206
|
writeJson(waitingPath(), all);
|
|
207
207
|
};
|
|
208
208
|
export const anyWaiting = () => Object.keys(readJson(waitingPath(), {})).length > 0;
|
|
209
|
+
export const readWaiting = () => readJson(waitingPath(), {});
|
|
210
|
+
|
|
211
|
+
// "Waiting" popup (the menu bar app shows a character that says a pane is waiting
|
|
212
|
+
// for input). Toggle + optional custom character image, kept as small files the
|
|
213
|
+
// Swift app reads directly — same pattern as the mute flag.
|
|
214
|
+
const popupFlagPath = () => join(stateDir(), 'popup');
|
|
215
|
+
const popupImagePath = () => join(stateDir(), 'popup-image');
|
|
216
|
+
export const isPopupEnabled = () => existsSync(popupFlagPath());
|
|
217
|
+
export const setPopupEnabled = (on) => {
|
|
218
|
+
ensureDir(stateDir());
|
|
219
|
+
if (on) writeFileSync(popupFlagPath(), '');
|
|
220
|
+
else rmSync(popupFlagPath(), { force: true });
|
|
221
|
+
};
|
|
222
|
+
export const getPopupImage = () => {
|
|
223
|
+
try {
|
|
224
|
+
return readFileSync(popupImagePath(), 'utf8').trim();
|
|
225
|
+
} catch {
|
|
226
|
+
return '';
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
export const setPopupImage = (p) => {
|
|
230
|
+
ensureDir(stateDir());
|
|
231
|
+
if (p) writeFileSync(popupImagePath(), p);
|
|
232
|
+
else rmSync(popupImagePath(), { force: true });
|
|
233
|
+
};
|
|
209
234
|
|
|
210
235
|
// Record this pane as active (keyed by tty). Keeps the 16 most-recent.
|
|
211
236
|
export const recordPane = (tty, label) => {
|