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 CHANGED
@@ -112,6 +112,20 @@ ai-notify menubar install # ネイティブのメニューバーアプリ・
112
112
 
113
113
  > 切替は実行中でも効きます:次にエージェントが発火した時にフラグを読むので、トグルした瞬間に全稼働エージェントへ反映されます。
114
114
 
115
+ ## 🪧 「応答待ち」ポップアップ
116
+
117
+ エージェントが止まってあなたの入力を待っている状態は、ターミナルが多いと見落としがち。とくに IDE(WebStorm・VS Code)のターミナルは画像やリッチ通知を出せません。**常に最前面のキャラポップアップ**をオンにすると、応答待ちのペイン名を表示し、応答した瞬間に消えます:
118
+
119
+ ![ai-notify 応答待ちポップアップ](https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/popup.png)
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
+ ![ai-notify waiting popup](https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/popup.png)
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 self?.render() }
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-notify",
3
- "version": "0.4.9",
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) => {