ai-notify 0.5.0 → 0.5.2
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 +10 -0
- package/README.md +10 -0
- package/menubar/AiNotifyMenuBar.swift +47 -10
- package/menubar/dist/ai-notify.app/Contents/MacOS/ai-notify-menubar +0 -0
- package/package.json +1 -1
- package/src/cli.mjs +26 -3
- package/src/notify.mjs +3 -1
- package/src/state.mjs +36 -2
package/README.ja.md
CHANGED
|
@@ -126,6 +126,16 @@ ai-notify popup off
|
|
|
126
126
|
|
|
127
127
|
全アプリ・全スペースの上に浮かび、`<ペイン名> は応答待ち!` を表示。複数同時もまとめて表示します。クリックで消えます。macOS 専用(メニューバーアプリの導入が必要)。
|
|
128
128
|
|
|
129
|
+
**出す条件を設定できます。** すべての「待ち」で割り込まれたくない人向け。サブエージェントの一瞬の待ちは黙ってほしいが、本当の「入力待ち」は気づきたい——を出し分けられます:
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
ai-notify popup delay 15 # 15秒以上待っている時だけ出す(一瞬の待ちは無視)
|
|
133
|
+
ai-notify popup ignore subagent,task # 待ち理由テキストにこの語を含む時は出さない
|
|
134
|
+
ai-notify popup ignore clear # フィルタ解除
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
フィルタは Claude Code の通知理由(例「waiting for your input」やサブエージェント系メッセージ)に対して効くので、入力/許可待ちは残しつつ、それ以外を黙らせられます。
|
|
138
|
+
|
|
129
139
|
## 🎙️ VOICEVOX キャラクターボイス
|
|
130
140
|
|
|
131
141
|
通知を [VOICEVOX](https://voicevox.hiroshiba.jp/) のキャラ声(例:ずんだもん)で読み上げられます(無料・ローカル・オフライン)。
|
package/README.md
CHANGED
|
@@ -127,6 +127,16 @@ ai-notify popup off
|
|
|
127
127
|
|
|
128
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
129
|
|
|
130
|
+
**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
|
+
|
|
132
|
+
```sh
|
|
133
|
+
ai-notify popup delay 15 # only pop up after waiting ≥ 15s (skip transient waits)
|
|
134
|
+
ai-notify popup ignore subagent,task # skip waits whose reason text matches these words
|
|
135
|
+
ai-notify popup ignore clear # remove the filter
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The filter matches Claude Code's notification reason (e.g. "waiting for your input" vs a sub-agent message), so you can keep input/permission prompts and silence the rest.
|
|
139
|
+
|
|
130
140
|
## 🎙️ VOICEVOX character voices
|
|
131
141
|
|
|
132
142
|
Optionally speak your notifications in [VOICEVOX](https://voicevox.hiroshiba.jp/) character voices (e.g. ずんだもん) — free, local, offline.
|
|
@@ -84,9 +84,23 @@ enum State {
|
|
|
84
84
|
return t.isEmpty ? nil : t
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
// Numbers/strings for the popup threshold + reason filtering.
|
|
88
|
+
static var popupDelayMs: Double {
|
|
89
|
+
guard let s = try? String(contentsOfFile: file("popup-delay"), encoding: .utf8),
|
|
90
|
+
let v = Double(s.trimmingCharacters(in: .whitespacesAndNewlines)) else { return 0 }
|
|
91
|
+
return max(0, v) * 1000
|
|
92
|
+
}
|
|
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" })
|
|
96
|
+
.map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
|
97
|
+
}
|
|
98
|
+
|
|
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)] {
|
|
90
104
|
func obj(_ name: String) -> [String: Any] {
|
|
91
105
|
(try? Data(contentsOf: URL(fileURLWithPath: file(name))))
|
|
92
106
|
.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } ?? [:]
|
|
@@ -95,14 +109,20 @@ enum State {
|
|
|
95
109
|
if waiting.isEmpty { return [] }
|
|
96
110
|
let voices = obj("pane-voices.json")
|
|
97
111
|
let panes = obj("panes.json")
|
|
112
|
+
func tsmsg(_ v: Any) -> (Double, String) {
|
|
113
|
+
if let n = v as? NSNumber { return (n.doubleValue, "") }
|
|
114
|
+
if let d = v as? [String: Any] { return (((d["ts"] as? NSNumber)?.doubleValue) ?? 0, (d["msg"] as? String) ?? "") }
|
|
115
|
+
return (0, "")
|
|
116
|
+
}
|
|
98
117
|
return waiting
|
|
99
|
-
.
|
|
100
|
-
.
|
|
101
|
-
|
|
102
|
-
let
|
|
103
|
-
|
|
118
|
+
.map { (tty: $0.key, tm: tsmsg($0.value)) }
|
|
119
|
+
.sorted { $0.tm.0 > $1.tm.0 }
|
|
120
|
+
.map { item in
|
|
121
|
+
let short = item.tty.replacingOccurrences(of: "/dev/", with: "")
|
|
122
|
+
let name = ((voices[item.tty] as? [String: Any])?["speakName"] as? String)
|
|
123
|
+
?? ((panes[item.tty] as? [String: Any])?["label"] as? String)
|
|
104
124
|
?? short
|
|
105
|
-
return (tty, name.isEmpty ? short : name)
|
|
125
|
+
return (item.tty, name.isEmpty ? short : name, item.tm.0, item.tm.1)
|
|
106
126
|
}
|
|
107
127
|
}
|
|
108
128
|
|
|
@@ -138,6 +158,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
138
158
|
// The "waiting for input" character popup.
|
|
139
159
|
private var waitingWindow: NSWindow?
|
|
140
160
|
private var waitingImageView: NSImageView?
|
|
161
|
+
private var waitingFace: NSTextField?
|
|
141
162
|
private var waitingLabel: NSTextField?
|
|
142
163
|
private var waitingSig = "" // current panes signature, to avoid needless redraws
|
|
143
164
|
private var waitingDismissedSig = "" // a signature the user clicked away; don't reshow it
|
|
@@ -214,7 +235,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
214
235
|
// an always-on-top window with a character + "<name> は応答待ち!". It hides
|
|
215
236
|
// itself the moment nothing is waiting (or the user clicks it away).
|
|
216
237
|
private func updateWaitingPopup() {
|
|
217
|
-
|
|
238
|
+
var panes: [(tty: String, name: String, ts: Double, msg: String)] = []
|
|
239
|
+
if State.popupEnabled {
|
|
240
|
+
let now = Date().timeIntervalSince1970 * 1000
|
|
241
|
+
let delayMs = State.popupDelayMs
|
|
242
|
+
let ignore = State.popupIgnoreWords
|
|
243
|
+
panes = State.waitingPanes().filter { p in
|
|
244
|
+
if now - p.ts < delayMs { return false } // hasn't waited long enough yet
|
|
245
|
+
if !ignore.isEmpty {
|
|
246
|
+
let m = p.msg.lowercased()
|
|
247
|
+
if ignore.contains(where: { m.contains($0) }) { return false } // a skipped reason
|
|
248
|
+
}
|
|
249
|
+
return true
|
|
250
|
+
}
|
|
251
|
+
}
|
|
218
252
|
if panes.isEmpty {
|
|
219
253
|
waitingDismissedSig = "" // reset; the next wait shows again
|
|
220
254
|
hideWaitingPopup()
|
|
@@ -239,8 +273,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
239
273
|
if let p = State.popupImage, let img = NSImage(contentsOfFile: p) {
|
|
240
274
|
waitingImageView?.image = img
|
|
241
275
|
waitingImageView?.isHidden = false
|
|
276
|
+
waitingFace?.isHidden = true // the image stands in for the default face
|
|
242
277
|
} else {
|
|
243
278
|
waitingImageView?.isHidden = true
|
|
279
|
+
waitingFace?.isHidden = false
|
|
244
280
|
}
|
|
245
281
|
|
|
246
282
|
if let scr = NSScreen.main {
|
|
@@ -293,6 +329,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
293
329
|
face.font = .systemFont(ofSize: 17, weight: .semibold)
|
|
294
330
|
face.textColor = .systemYellow
|
|
295
331
|
card.addSubview(face, positioned: .below, relativeTo: iv)
|
|
332
|
+
waitingFace = face
|
|
296
333
|
|
|
297
334
|
let badge = NSTextField(labelWithString: "🟡 応答待ち")
|
|
298
335
|
badge.frame = NSRect(x: 96, y: 56, width: 196, height: 22)
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-notify",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
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
|
@@ -37,6 +37,10 @@ import {
|
|
|
37
37
|
setPopupEnabled,
|
|
38
38
|
getPopupImage,
|
|
39
39
|
setPopupImage,
|
|
40
|
+
getPopupDelay,
|
|
41
|
+
setPopupDelay,
|
|
42
|
+
getPopupIgnore,
|
|
43
|
+
setPopupIgnore,
|
|
40
44
|
} from './state.mjs';
|
|
41
45
|
import { resolve as resolvePath } from 'node:path';
|
|
42
46
|
|
|
@@ -596,7 +600,7 @@ const cmds = {
|
|
|
596
600
|
|
|
597
601
|
// The "waiting" character popup (menu bar app): an always-on-top window that
|
|
598
602
|
// shows a character saying which pane is waiting for input. macOS-only effect.
|
|
599
|
-
// popup [on|off|toggle|image <path>|
|
|
603
|
+
// popup [on|off|toggle|image <path>|delay <sec>|ignore <kw,kw>|status]
|
|
600
604
|
popup() {
|
|
601
605
|
const sub = positionals[0] || 'status';
|
|
602
606
|
if (sub === 'on' || sub === 'off' || sub === 'toggle') {
|
|
@@ -614,9 +618,28 @@ const cmds = {
|
|
|
614
618
|
setPopupImage(abs);
|
|
615
619
|
return log(`popup image → ${abs}`);
|
|
616
620
|
}
|
|
621
|
+
// Threshold: only pop up after a pane has been waiting this many seconds.
|
|
622
|
+
if (sub === 'delay') {
|
|
623
|
+
const v = parseFloat(positionals[1]);
|
|
624
|
+
if (!Number.isFinite(v)) return log(`popup delay: ${getPopupDelay()}s`);
|
|
625
|
+
setPopupDelay(Math.max(0, v));
|
|
626
|
+
return log(v > 0 ? `popup delay → ${Math.max(0, v)}s (waits shorter than this are ignored)` : 'popup delay → 0s (immediate)');
|
|
627
|
+
}
|
|
628
|
+
// Suppress the popup when the waiting reason contains any of these keywords.
|
|
629
|
+
if (sub === 'ignore') {
|
|
630
|
+
const kw = positionals.slice(1).join(' ').trim();
|
|
631
|
+
if (!kw || kw === 'clear') {
|
|
632
|
+
setPopupIgnore('');
|
|
633
|
+
return log('popup ignore cleared (no message filtering).');
|
|
634
|
+
}
|
|
635
|
+
setPopupIgnore(kw);
|
|
636
|
+
return log(`popup ignore → ${kw}`);
|
|
637
|
+
}
|
|
617
638
|
log(`waiting popup: ${isPopupEnabled() ? '🪧 ON' : 'OFF'}`);
|
|
618
639
|
log(`character image: ${getPopupImage() || '(default)'}`);
|
|
619
|
-
log(
|
|
640
|
+
log(`delay: ${getPopupDelay()}s${getPopupDelay() > 0 ? ' (ignore shorter waits)' : ' (immediate)'}`);
|
|
641
|
+
log(`ignore words: ${getPopupIgnore() || '(none)'}`);
|
|
642
|
+
log('\nEnable: ai-notify popup on | Threshold: ai-notify popup delay 15 | Skip reasons: ai-notify popup ignore subagent,task');
|
|
620
643
|
},
|
|
621
644
|
|
|
622
645
|
// Get/set the VOICEVOX base prosody (the normal-tone scales the menu bar
|
|
@@ -831,7 +854,7 @@ Usage:
|
|
|
831
854
|
ai-notify tsundere [on|off|level <0-1>|test|status] tsundere persona (ツン⇄デレ by urgency)
|
|
832
855
|
ai-notify voice-prosody [speed|pitch|intonation <v>|reset] VOICEVOX read-out tuning
|
|
833
856
|
ai-notify menubar [install|uninstall|status] native menu bar bell (macOS)
|
|
834
|
-
ai-notify popup [on|off|image <
|
|
857
|
+
ai-notify popup [on|off|image <p>|delay <s>|ignore <kw>] "waiting for input" popup + when it shows (macOS)
|
|
835
858
|
ai-notify translate [on <lang>|off|test] speak agent text in your language
|
|
836
859
|
ai-notify doctor check deps & wiring
|
|
837
860
|
ai-notify config [init] print (or write) config
|
package/src/notify.mjs
CHANGED
|
@@ -181,7 +181,9 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
|
|
|
181
181
|
// pane's assigned name. Also remember the pane so the menu bar can list it.
|
|
182
182
|
const tty = controllingTty();
|
|
183
183
|
recordPane(tty, label);
|
|
184
|
-
|
|
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 : '');
|
|
185
187
|
const pane = readPaneSetting(tty);
|
|
186
188
|
|
|
187
189
|
// Name this pane in the read-out, most-reliable identity first:
|
package/src/state.mjs
CHANGED
|
@@ -198,10 +198,12 @@ const waitingPath = () => join(stateDir(), 'waiting.json');
|
|
|
198
198
|
|
|
199
199
|
// Track which panes are waiting for input, so the menu bar icon can show a
|
|
200
200
|
// status color (yellow) when any agent needs you.
|
|
201
|
-
export const setPaneWaiting = (tty, waiting) => {
|
|
201
|
+
export const setPaneWaiting = (tty, waiting, message = '') => {
|
|
202
202
|
if (!tty) return;
|
|
203
203
|
const all = readJson(waitingPath(), {});
|
|
204
|
-
|
|
204
|
+
// Store the reason text alongside the start time so the popup can filter by
|
|
205
|
+
// wait duration and by message (e.g. ignore sub-agent waits, keep input waits).
|
|
206
|
+
if (waiting) all[tty] = { ts: Date.now(), msg: String(message || '') };
|
|
205
207
|
else delete all[tty];
|
|
206
208
|
writeJson(waitingPath(), all);
|
|
207
209
|
};
|
|
@@ -232,6 +234,38 @@ export const setPopupImage = (p) => {
|
|
|
232
234
|
else rmSync(popupImagePath(), { force: true });
|
|
233
235
|
};
|
|
234
236
|
|
|
237
|
+
// Popup notify threshold: only show the popup once a pane has been waiting this
|
|
238
|
+
// many seconds (0 = immediately) — so transient / sub-agent waits don't nag.
|
|
239
|
+
const popupDelayPath = () => join(stateDir(), 'popup-delay');
|
|
240
|
+
export const getPopupDelay = () => {
|
|
241
|
+
try {
|
|
242
|
+
return Math.max(0, parseFloat(readFileSync(popupDelayPath(), 'utf8')) || 0);
|
|
243
|
+
} catch {
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
export const setPopupDelay = (sec) => {
|
|
248
|
+
ensureDir(stateDir());
|
|
249
|
+
if (sec > 0) writeFileSync(popupDelayPath(), String(sec));
|
|
250
|
+
else rmSync(popupDelayPath(), { force: true });
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// Comma-separated keywords: if a waiting reason message contains any of them,
|
|
254
|
+
// the popup is suppressed for that pane (e.g. "subagent,sub-agent,task").
|
|
255
|
+
const popupIgnorePath = () => join(stateDir(), 'popup-ignore');
|
|
256
|
+
export const getPopupIgnore = () => {
|
|
257
|
+
try {
|
|
258
|
+
return readFileSync(popupIgnorePath(), 'utf8').trim();
|
|
259
|
+
} catch {
|
|
260
|
+
return '';
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
export const setPopupIgnore = (s) => {
|
|
264
|
+
ensureDir(stateDir());
|
|
265
|
+
if (s) writeFileSync(popupIgnorePath(), s);
|
|
266
|
+
else rmSync(popupIgnorePath(), { force: true });
|
|
267
|
+
};
|
|
268
|
+
|
|
235
269
|
// Record this pane as active (keyed by tty). Keeps the 16 most-recent.
|
|
236
270
|
export const recordPane = (tty, label) => {
|
|
237
271
|
if (!tty) return;
|