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(
|
|
176
|
-
slider.
|
|
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
|
|
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("音量"))
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-notify",
|
|
3
|
-
"version": "0.4.
|
|
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
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
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 (
|
|
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);
|