ai-notify 0.4.1 → 0.4.3

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.
@@ -13,6 +13,35 @@
13
13
 
14
14
  import Cocoa
15
15
 
16
+ // NSSlider.trackFillColor is unreliable for blue: AppKit matches the resolved fill
17
+ // to the system accent and routes it through the accent path, which desaturates to
18
+ // gray while the control isn't the key view (a menu slider never is). A non-accent
19
+ // color like the ツンデレ pink is exempt, which is why only the blue volume slider
20
+ // went gray. Drawing the bar ourselves sidesteps the accent path completely.
21
+ final class FilledSliderCell: NSSliderCell {
22
+ var fillColor: NSColor = NSColor(srgbRed: 0, green: 122.0 / 255.0, blue: 1, alpha: 1)
23
+
24
+ override func drawBar(inside rect: NSRect, flipped: Bool) {
25
+ let h: CGFloat = 4
26
+ var bar = rect
27
+ bar.origin.y += (bar.height - h) / 2
28
+ bar.size.height = h
29
+ let radius = h / 2
30
+
31
+ NSColor.tertiaryLabelColor.setFill()
32
+ NSBezierPath(roundedRect: bar, xRadius: radius, yRadius: radius).fill()
33
+
34
+ let span = maxValue - minValue
35
+ guard span > 0 else { return }
36
+ let frac = CGFloat((doubleValue - minValue) / span)
37
+ guard frac > 0 else { return }
38
+ var fill = bar
39
+ fill.size.width = min(bar.width, max(h, bar.width * frac))
40
+ fillColor.setFill()
41
+ NSBezierPath(roundedRect: fill, xRadius: radius, yRadius: radius).fill()
42
+ }
43
+ }
44
+
16
45
  enum State {
17
46
  static func dir() -> String {
18
47
  let env = ProcessInfo.processInfo.environment
@@ -146,6 +175,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
146
175
  @objc private func paneVolumeChanged(_ s: NSSlider) {
147
176
  if let tty = s.identifier?.rawValue { State.cli(["volume-pane", tty, String(format: "%.2f", s.doubleValue)]) }
148
177
  }
178
+ // Editing a text field *inside* an NSMenu is unreliable — the menu's tracking
179
+ // loop swallows the keystrokes. So naming a pane opens a normal modal dialog
180
+ // (NSAlert with a text field), which takes keyboard focus properly. Empty =>
181
+ // clear (the pane falls back to its label / the speakLabel default).
182
+ @objc private func promptPaneName(_ sender: NSMenuItem) {
183
+ guard let info = sender.representedObject as? [String], let tty = info.first else { return }
184
+ let current = info.count > 1 ? info[1] : ""
185
+ let alert = NSAlert()
186
+ alert.messageText = "読み上げ名"
187
+ alert.informativeText = "このペインを通知で読み上げる名前(空欄で解除)"
188
+ let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 240, height: 24))
189
+ field.stringValue = current
190
+ field.placeholderString = "例: バックエンド"
191
+ alert.accessoryView = field
192
+ alert.addButton(withTitle: "保存")
193
+ alert.addButton(withTitle: "キャンセル")
194
+ NSApp.activate(ignoringOtherApps: true)
195
+ alert.window.initialFirstResponder = field
196
+ guard alert.runModal() == .alertFirstButtonReturn else { return }
197
+ let name = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
198
+ State.cli(["name-pane", tty, name.isEmpty ? "clear" : name])
199
+ }
149
200
  // identifier carries the prosody key (speed | pitch | intonation).
150
201
  @objc private func prosodyChanged(_ s: NSSlider) {
151
202
  if let key = s.identifier?.rawValue { State.cli(["voice-prosody", key, String(format: "%.3f", s.doubleValue)]) }
@@ -172,10 +223,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
172
223
  private func sliderRow(value: Double, action: Selector, identifier: String?) -> NSMenuItem {
173
224
  let row = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 26))
174
225
  let icon = NSTextField(labelWithString: "🔊"); icon.frame = NSRect(x: 12, y: 4, width: 20, height: 18)
175
- let slider = NSSlider(value: value, minValue: 0, maxValue: 2, target: self, action: action)
176
- slider.frame = NSRect(x: 36, y: 3, width: 170, height: 20)
226
+ let slider = NSSlider(frame: NSRect(x: 36, y: 3, width: 170, height: 20))
227
+ slider.cell = FilledSliderCell() // self-drawn blue fill; see FilledSliderCell
228
+ slider.minValue = 0; slider.maxValue = 2; slider.doubleValue = value
229
+ slider.target = self; slider.action = action
177
230
  slider.isContinuous = (identifier == nil)
178
- slider.trackFillColor = .controlAccentColor // stay blue even when not focused
179
231
  if let id = identifier { slider.identifier = NSUserInterfaceItemIdentifier(id) }
180
232
  row.addSubview(icon); row.addSubview(slider)
181
233
  let item = NSMenuItem(); item.view = row
@@ -293,8 +345,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
293
345
  guard let tty = p["tty"] as? String else { continue }
294
346
  let label = p["label"] as? String ?? tty
295
347
  let cur = p["current"] as? String
296
- let item = NSMenuItem(title: cur != nil ? "\(label) — \(cur!)" : label, action: nil, keyEquivalent: "")
348
+ let pname = p["speakName"] as? String ?? ""
349
+ // Parent row shows at a glance WHO the pane is (its custom 🗣 name,
350
+ // or its label/tty) AND which 🔊 voice it uses (omitted when it just
351
+ // follows the global voice). Naming a pane must not hide the voice —
352
+ // surfacing the per-pane voice is what this list is for.
353
+ let who = pname.isEmpty ? label : "🗣 \(pname)"
354
+ let voiceTag = cur != nil ? " — 🔊 \(cur!)" : ""
355
+ let item = NSMenuItem(title: who + voiceTag, action: nil, keyEquivalent: "")
297
356
  let sub = NSMenu()
357
+ // Per-pane spoken name — opens a dialog (menu fields can't type).
358
+ sub.addItem(disabledHeader("読み上げ名"))
359
+ let nameItem = NSMenuItem(
360
+ title: pname.isEmpty ? "(クリックして設定…)" : "「\(pname)」を変更…",
361
+ action: #selector(promptPaneName(_:)), keyEquivalent: ""
362
+ )
363
+ nameItem.target = self
364
+ nameItem.representedObject = [tty, pname]
365
+ sub.addItem(nameItem)
366
+ if !pname.isEmpty {
367
+ let clr = NSMenuItem(title: "読み上げ名を解除", action: #selector(runItem(_:)), keyEquivalent: "")
368
+ clr.target = self; clr.representedObject = ["name-pane", tty, "clear"]
369
+ sub.addItem(clr)
370
+ }
371
+ sub.addItem(.separator())
298
372
  // Per-pane volume.
299
373
  let pv = (p["volume"] as? Double) ?? State.volume
300
374
  sub.addItem(disabledHeader("音量"))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-notify",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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
@@ -479,6 +479,24 @@ const cmds = {
479
479
  log(`pane ${tty}: tsundere level ${v}`);
480
480
  },
481
481
 
482
+ // Name a specific pane in the spoken read-out (set from the menu bar), or
483
+ // `clear` to fall back to the label / speakLabel default.
484
+ // name-pane <tty> <name|clear>
485
+ 'name-pane'() {
486
+ const [tty, ...rest] = positionals;
487
+ const arg = rest.join(' ').trim(); // a name may contain spaces
488
+ if (!tty || arg === '') {
489
+ console.error('usage: name-pane <tty> <name|clear>');
490
+ process.exit(1);
491
+ }
492
+ if (arg === 'clear') {
493
+ updatePaneSetting(tty, { speakName: null });
494
+ return log(`pane ${tty}: name cleared`);
495
+ }
496
+ updatePaneSetting(tty, { speakName: arg });
497
+ log(`pane ${tty}: name ${arg}`);
498
+ },
499
+
482
500
  // Get/set the VOICEVOX base prosody (the normal-tone scales the menu bar
483
501
  // sliders drive). With no args, prints the current values as JSON.
484
502
  // voice-prosody [speed|pitch|intonation <value> | reset]
@@ -534,6 +552,7 @@ const cmds = {
534
552
  tty,
535
553
  label: recorded.get(tty) || tty.replace('/dev/', ''),
536
554
  current: labelFor(s.tts ? s : null),
555
+ speakName: typeof s.speakName === 'string' ? s.speakName : '',
537
556
  volume: typeof s.volume === 'number' ? s.volume : globalVol,
538
557
  volumeSet: typeof s.volume === 'number',
539
558
  tsundere: typeof s.tsundere === 'number' ? s.tsundere : tsLevel,
package/src/notify.mjs CHANGED
@@ -176,19 +176,21 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
176
176
  if (!message) spokenBody = fromTemplate || fallback;
177
177
  else if (config.speakAgentMessage) spokenBody = fullBody;
178
178
  else spokenBody = shortenForSpeech(fullBody, config.speakMaxChars || 40);
179
- // The task gist already tells you which pane; the label (often the working
180
- // dir) is just slow filler. Prefix it only if explicitly enabled.
181
- const speakText = config.speakLabel === true && label ? `${label}、${spokenBody}` : spokenBody;
182
-
183
- // Per-pane voice: remember this pane (so the menu bar can list it) and apply
184
- // any voice assigned to it. Precedence (most specific first):
185
- // $AI_NOTIFY_* env — set in the pane's shell
186
- // this pane's pick — assigned from the menu bar (keyed by tty)
187
- // provider / global — config defaults
179
+ // Per-pane settings (voice / volume / tsundere / name), keyed by tty. Read
180
+ // here — before the read-out is assembled so the spoken text can use this
181
+ // pane's assigned name. Also remember the pane so the menu bar can list it.
188
182
  const tty = controllingTty();
189
183
  recordPane(tty, label);
190
184
  setPaneWaiting(tty, event === 'waiting'); // waiting -> yellow menu bar status; done clears it
191
185
  const pane = readPaneSetting(tty);
186
+
187
+ // Name this pane in the read-out. An explicit per-pane name (set from the menu
188
+ // bar) is ALWAYS spoken; the auto-derived label (often just the working dir)
189
+ // is prefixed only when speakLabel is on — it's slow filler otherwise.
190
+ const spokenName = pane.speakName || (config.speakLabel === true && label ? label : '');
191
+ const speakText = spokenName ? `${spokenName}、${spokenBody}` : spokenBody;
192
+
193
+ // Per-pane voice (precedence: $AI_NOTIFY_* env > this pane's pick > global).
192
194
  const tts = pane.tts || config.tts;
193
195
  const voice = process.env.AI_NOTIFY_VOICE || pane.voice || p.voice || config.voice;
194
196
  const speaker = process.env.AI_NOTIFY_VOICEVOX_SPEAKER || pane.speaker || config.voicevox?.speaker;
@@ -231,7 +233,7 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
231
233
  speakTone = tsundere.axisFor(eff);
232
234
  outVol = Math.min(2, Math.max(0, vol * tsundere.volumeMul(tier, ts.volumeBoost !== false)));
233
235
  outText = tsundere.wrap(spokenBody, eff, tier, ts.lang || 'ja', nextCounter('tsundere'));
234
- if (config.speakLabel === true && label) outText = `${label}、${outText}`;
236
+ if (spokenName) outText = `${spokenName}、${outText}`;
235
237
  if (tts === 'voicevox') {
236
238
  const sm = ts.styleMap || voicevox.resolveStyles(outSpeaker, config.voicevox?.url);
237
239
  const axis = tsundere.axisFor(eff);