ai-notify 0.8.0 → 0.9.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
@@ -121,6 +121,7 @@ ai-notify menubar install # ネイティブのメニューバーアプリ・
121
121
 
122
122
  - **左クリック** → メニュー:**音量スライダー**、**ツンデレ**トグル+デレ⇄ツンスライダー、**声の一覧**(システム+VOICEVOX)、**ペイン別**設定。開いている各ターミナルに、**読み上げ名**(どのペインが終わったか声で分かる)・**声**・**音量**を個別設定でき、各行にそのペインの声が一覧表示されます。
123
123
  - **右クリック** → 即ミュート切替。
124
+ - **⚙ 設定…** → **整列したスライダー+編集可能な数値フィールド**(音量・ツンデレ・戦争・速さ/高さ/抑揚)と**プリセット保存**(`ai-notify preset save <名前>` / `load` / `delete`)の設定ウィンドウ。毎回調整し直さなくて済みます。
124
125
 
125
126
  <p>
126
127
  <img alt="ai-notify メニュー — 音量・ツンデレ・声" src="https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/menubar.png" width="250">
package/README.md CHANGED
@@ -122,6 +122,7 @@ A monochrome waveform icon shows status by color (Adobe-style): plain when idle,
122
122
 
123
123
  - **Left-click** → menu: a **volume slider**, a **tsundere** toggle + デレ⇄ツン slider, the **voice list** (system + VOICEVOX), and **per-pane** controls. Each open terminal gets its own **spoken name** (read out so you know *which* pane finished), **voice**, and **volume** — the row shows each pane's voice at a glance.
124
124
  - **Right-click** → instant mute toggle.
125
+ - **⚙ 設定…** → a settings window with **aligned sliders + editable numeric fields** (volume, tsundere, war, speed/pitch/intonation) and **saveable presets** (`ai-notify preset save <name>` / `load` / `delete`), so you don't re-tune every time.
125
126
 
126
127
  <p>
127
128
  <img alt="ai-notify menu — volume, tsundere, and voice controls" src="https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/menubar.png" width="250">
@@ -275,9 +275,200 @@ final class PopupCard: NSObject {
275
275
  }
276
276
  }
277
277
 
278
+ // One settings row: a label (or checkbox) + a slider + an editable numeric field,
279
+ // kept on a single shared grid so every row lines up. The slider and the field
280
+ // stay in sync; both call `onChange`.
281
+ final class SettingsRow: NSObject {
282
+ let view = NSView(frame: NSRect(x: 0, y: 0, width: 470, height: 32))
283
+ private let slider: NSSlider
284
+ private let field = NSTextField()
285
+ private let lo: Double
286
+ private let hi: Double
287
+ private let onChange: (Double) -> Void
288
+ private let onToggle: (() -> Void)?
289
+
290
+ init(title: String, asCheckbox: Bool, on: Bool, lo: Double, hi: Double, value: Double,
291
+ fill: NSColor, onToggle: (() -> Void)? = nil, onChange: @escaping (Double) -> Void) {
292
+ self.lo = lo; self.hi = hi; self.onChange = onChange; self.onToggle = onToggle
293
+ slider = NSSlider(value: value, minValue: lo, maxValue: hi, target: nil, action: nil)
294
+ super.init()
295
+
296
+ if asCheckbox {
297
+ let cb = NSButton(checkboxWithTitle: title, target: self, action: #selector(toggled))
298
+ cb.frame = NSRect(x: 16, y: 6, width: 106, height: 20)
299
+ cb.state = on ? .on : .off
300
+ view.addSubview(cb)
301
+ } else {
302
+ let lbl = NSTextField(labelWithString: title)
303
+ lbl.frame = NSRect(x: 16, y: 7, width: 106, height: 18)
304
+ lbl.textColor = .labelColor
305
+ view.addSubview(lbl)
306
+ }
307
+ // Unified grid: slider always at the same x/width, field always after it.
308
+ slider.frame = NSRect(x: 128, y: 5, width: 250, height: 20)
309
+ slider.target = self; slider.action = #selector(sliderMoved)
310
+ slider.isContinuous = true
311
+ slider.trackFillColor = fill
312
+ view.addSubview(slider)
313
+
314
+ field.frame = NSRect(x: 392, y: 5, width: 56, height: 20)
315
+ field.alignment = .right
316
+ field.target = self; field.action = #selector(fieldEdited)
317
+ view.addSubview(field)
318
+ setField(value)
319
+ }
320
+
321
+ private func setField(_ v: Double) { field.stringValue = String(format: "%.2f", v) }
322
+ @objc private func toggled() { onToggle?() }
323
+ @objc private func sliderMoved() { setField(slider.doubleValue); onChange(slider.doubleValue) }
324
+ @objc private func fieldEdited() {
325
+ var v = Double(field.stringValue) ?? slider.doubleValue
326
+ v = min(hi, max(lo, v))
327
+ slider.doubleValue = v; setField(v); onChange(v)
328
+ }
329
+ }
330
+
331
+ // The settings window: aligned sliders + editable numeric fields for volume,
332
+ // tsundere, war, and the VOICEVOX prosody, plus a saveable preset bar.
333
+ final class SettingsWindowController: NSObject {
334
+ private var window: NSWindow?
335
+ private var presetPopup: NSPopUpButton?
336
+ var windowNumber: Int { window?.windowNumber ?? 0 }
337
+
338
+ func show() {
339
+ if window == nil { build() }
340
+ reloadValues()
341
+ NSApp.activate(ignoringOtherApps: true)
342
+ window?.center()
343
+ window?.makeKeyAndOrderFront(nil)
344
+ window?.orderFrontRegardless()
345
+ }
346
+
347
+ private func menuJSON() -> [String: Any] {
348
+ (State.cli(["menu-json"], capture: true)?.data(using: .utf8))
349
+ .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } ?? [:]
350
+ }
351
+
352
+ private func build() {
353
+ let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 470, height: 360),
354
+ styleMask: [.titled, .closable], backing: .buffered, defer: false)
355
+ w.title = "ai-notify 設定"
356
+ w.isReleasedWhenClosed = false
357
+ window = w
358
+ rebuildContent()
359
+ }
360
+
361
+ // Re-create the rows from the current state (also used after loading a preset).
362
+ private func rebuildContent() {
363
+ guard let w = window else { return }
364
+ let j = menuJSON()
365
+ let content = NSView(frame: NSRect(x: 0, y: 0, width: 470, height: 360))
366
+
367
+ // Preset bar.
368
+ let presetLabel = NSTextField(labelWithString: "プリセット")
369
+ presetLabel.frame = NSRect(x: 16, y: 322, width: 70, height: 18)
370
+ content.addSubview(presetLabel)
371
+ let popup = NSPopUpButton(frame: NSRect(x: 92, y: 318, width: 180, height: 26))
372
+ for name in presetNames() { popup.addItem(withTitle: name) }
373
+ if popup.numberOfItems == 0 { popup.addItem(withTitle: "(なし)"); popup.isEnabled = false }
374
+ content.addSubview(popup)
375
+ presetPopup = popup
376
+ let apply = NSButton(title: "適用", target: self, action: #selector(applyPreset)); apply.frame = NSRect(x: 278, y: 318, width: 52, height: 26); apply.bezelStyle = .rounded; content.addSubview(apply)
377
+ let save = NSButton(title: "保存…", target: self, action: #selector(savePreset)); save.frame = NSRect(x: 332, y: 318, width: 60, height: 26); save.bezelStyle = .rounded; content.addSubview(save)
378
+ let del = NSButton(title: "削除", target: self, action: #selector(deletePreset)); del.frame = NSRect(x: 394, y: 318, width: 52, height: 26); del.bezelStyle = .rounded; content.addSubview(del)
379
+
380
+ let sep = NSBox(frame: NSRect(x: 12, y: 306, width: 446, height: 1)); sep.boxType = .separator; content.addSubview(sep)
381
+
382
+ // Parameter rows (top-down).
383
+ let blue = NSColor(srgbRed: 0, green: 122.0 / 255.0, blue: 1, alpha: 1)
384
+ let pink = NSColor.systemPink
385
+ let red = NSColor(srgbRed: 0.85, green: 0.2, blue: 0.15, alpha: 1)
386
+ let tsun = j["tsundere"] as? [String: Any]
387
+ let warj = j["war"] as? [String: Any]
388
+ let pr = j["prosody"] as? [String: Any] ?? [:]
389
+ let range = j["prosodyRange"] as? [String: Any] ?? [:]
390
+ func bound(_ key: String, _ dlo: Double, _ dhi: Double) -> (Double, Double) {
391
+ let r = range[key] as? [Any]
392
+ return ((r?.first as? Double) ?? dlo, (r?.last as? Double) ?? dhi)
393
+ }
394
+ let (slo, shi) = bound("speed", 0.5, 1.5)
395
+ let (plo, phi) = bound("pitch", -0.15, 0.15)
396
+ let (ilo, ihi) = bound("intonation", 0.0, 1.5)
397
+
398
+ let rows: [SettingsRow] = [
399
+ SettingsRow(title: "音量", asCheckbox: false, on: false, lo: 0, hi: 2, value: (j["volume"] as? Double) ?? 1, fill: blue,
400
+ onChange: { State.cli(["volume", String(format: "%.2f", $0)]) }),
401
+ SettingsRow(title: "ツンデレ", asCheckbox: true, on: (tsun?["enabled"] as? Bool) ?? false, lo: 0, hi: 1, value: (tsun?["level"] as? Double) ?? 0.5, fill: pink,
402
+ onToggle: { State.cli(["tsundere", "toggle"]) },
403
+ onChange: { State.cli(["tsundere", "level", String(format: "%.2f", $0)]) }),
404
+ SettingsRow(title: "戦争", asCheckbox: true, on: (warj?["enabled"] as? Bool) ?? false, lo: 0, hi: 1, value: (warj?["level"] as? Double) ?? 0.5, fill: red,
405
+ onToggle: { State.cli(["war", "toggle"]) },
406
+ onChange: { State.cli(["war", "level", String(format: "%.2f", $0)]) }),
407
+ SettingsRow(title: "速さ", asCheckbox: false, on: false, lo: slo, hi: shi, value: (pr["speed"] as? Double) ?? 1, fill: blue,
408
+ onChange: { State.cli(["voice-prosody", "speed", String(format: "%.3f", $0)]) }),
409
+ SettingsRow(title: "高さ", asCheckbox: false, on: false, lo: plo, hi: phi, value: (pr["pitch"] as? Double) ?? 0, fill: blue,
410
+ onChange: { State.cli(["voice-prosody", "pitch", String(format: "%.3f", $0)]) }),
411
+ SettingsRow(title: "抑揚", asCheckbox: false, on: false, lo: ilo, hi: ihi, value: (pr["intonation"] as? Double) ?? 1, fill: blue,
412
+ onChange: { State.cli(["voice-prosody", "intonation", String(format: "%.3f", $0)]) }),
413
+ ]
414
+ var y = 264
415
+ let header = NSTextField(labelWithString: "ツンデレ/戦争は 0=デレ・平時 〜 1=ツン・危機")
416
+ header.frame = NSRect(x: 16, y: 286, width: 440, height: 16)
417
+ header.font = .systemFont(ofSize: 11); header.textColor = .secondaryLabelColor
418
+ content.addSubview(header)
419
+ for r in rows {
420
+ r.view.frame.origin = NSPoint(x: 0, y: CGFloat(y))
421
+ content.addSubview(r.view)
422
+ self.rows.append(r) // retain
423
+ y -= 36
424
+ }
425
+ w.contentView = content
426
+ }
427
+
428
+ private var rows: [SettingsRow] = []
429
+
430
+ private func presetNames() -> [String] {
431
+ let out = State.cli(["preset", "list"], capture: true) ?? ""
432
+ return out.split(separator: "\n").map { String($0).trimmingCharacters(in: .whitespaces) }
433
+ .filter { !$0.isEmpty && $0 != "(no presets)" }
434
+ }
435
+
436
+ private func reloadValues() {
437
+ rows.removeAll()
438
+ rebuildContent()
439
+ }
440
+
441
+ @objc private func applyPreset() {
442
+ guard let name = presetPopup?.titleOfSelectedItem, name != "(なし)" else { return }
443
+ State.cli(["preset", "load", name])
444
+ reloadValues()
445
+ }
446
+ @objc private func deletePreset() {
447
+ guard let name = presetPopup?.titleOfSelectedItem, name != "(なし)" else { return }
448
+ State.cli(["preset", "delete", name])
449
+ reloadValues()
450
+ }
451
+ @objc private func savePreset() {
452
+ let alert = NSAlert()
453
+ alert.messageText = "プリセットを保存"
454
+ alert.informativeText = "現在の音量・ツンデレ・戦争・読み上げ設定を名前を付けて保存します"
455
+ let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 240, height: 24))
456
+ field.placeholderString = "例: 集中モード"
457
+ alert.accessoryView = field
458
+ alert.addButton(withTitle: "保存"); alert.addButton(withTitle: "キャンセル")
459
+ alert.window.initialFirstResponder = field
460
+ guard alert.runModal() == .alertFirstButtonReturn else { return }
461
+ let name = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
462
+ guard !name.isEmpty else { return }
463
+ State.cli(["preset", "save", name])
464
+ reloadValues()
465
+ }
466
+ }
467
+
278
468
  final class AppDelegate: NSObject, NSApplicationDelegate {
279
469
  private var statusItem: NSStatusItem!
280
470
  private var timer: Timer?
471
+ private let settings = SettingsWindowController()
281
472
 
282
473
  // The "waiting for input" popup — one floating card per waiting pane.
283
474
  private var waitingCards: [String: PopupCard] = [:] // keyed by tty
@@ -424,6 +615,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
424
615
 
425
616
  private func toggle() { State.setMuted(!State.isMuted); render() }
426
617
  @objc private func quit() { NSApp.terminate(nil) }
618
+ @objc private func openSettings() { settings.show() }
427
619
 
428
620
  @objc private func volumeChanged(_ s: NSSlider) { State.setVolume(s.doubleValue) }
429
621
  // Slider is shown reversed (left = ツン, right = デレ) but the file keeps the
@@ -762,6 +954,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
762
954
  notifyParent.submenu = notifySub
763
955
  menu.addItem(notifyParent)
764
956
 
957
+ menu.addItem(.separator())
958
+ let settingsItem = NSMenuItem(title: "⚙ 設定(スライダー・数値・プリセット)…", action: #selector(openSettings), keyEquivalent: ",")
959
+ settingsItem.target = self
960
+ menu.addItem(settingsItem)
961
+
765
962
  menu.addItem(.separator())
766
963
  let quitItem = NSMenuItem(title: "ai-notify を終了", action: #selector(quit), keyEquivalent: "q")
767
964
  quitItem.target = self
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-notify",
3
- "version": "0.8.0",
3
+ "version": "0.9.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
@@ -451,6 +451,74 @@ const cmds = {
451
451
  if (!ts.enabled) log('\nEnable: ai-notify tsundere on 試聴: ai-notify tsundere test');
452
452
  },
453
453
 
454
+ // Named presets: snapshot the current volume / prosody / tsundere / war and
455
+ // restore them later, so you don't re-tune every time.
456
+ // preset [list | save <name> | load <name> | delete <name>]
457
+ preset() {
458
+ const sub = positionals[0] || 'list';
459
+ const file = pathJoin(paths.stateDir(), 'presets.json');
460
+ const read = () => {
461
+ try {
462
+ return JSON.parse(readFileSync(file, 'utf8'));
463
+ } catch {
464
+ return {};
465
+ }
466
+ };
467
+ const write = (o) => {
468
+ mkdirSync(paths.stateDir(), { recursive: true });
469
+ writeFileSync(file, JSON.stringify(o, null, 2));
470
+ };
471
+ const name = positionals.slice(1).join(' ').trim();
472
+
473
+ if (sub === 'list') {
474
+ const names = Object.keys(read());
475
+ return log(names.length ? names.join('\n') : '(no presets)');
476
+ }
477
+ if (sub === 'save') {
478
+ if (!name) return void (console.error('usage: preset save <name>'), process.exit(1));
479
+ const config = readConfig();
480
+ const snap = {
481
+ volume: readVolume() != null ? readVolume() : config.volume ?? 1,
482
+ prosody: readVoiceProsody(),
483
+ tsundere: {
484
+ enabled: !!config.tsundere?.enabled,
485
+ level: readTsundereLevel() != null ? readTsundereLevel() : config.tsundere?.level ?? 0.5,
486
+ },
487
+ war: { enabled: isWarEnabled(), level: readWarLevel() },
488
+ };
489
+ const p = read();
490
+ p[name] = snap;
491
+ write(p);
492
+ return log(`💾 saved preset "${name}"`);
493
+ }
494
+ if (sub === 'delete' || sub === 'rm') {
495
+ const p = read();
496
+ if (!(name in p)) return void (console.error(`no preset: ${name}`), process.exit(1));
497
+ delete p[name];
498
+ write(p);
499
+ return log(`deleted "${name}"`);
500
+ }
501
+ if (sub === 'load' || sub === 'apply') {
502
+ const s = read()[name];
503
+ if (!s) return void (console.error(`no preset: ${name}`), process.exit(1));
504
+ if (typeof s.volume === 'number') setVolume(s.volume);
505
+ if (s.prosody) for (const k of ['speed', 'pitch', 'intonation']) if (typeof s.prosody[k] === 'number') setVoiceProsody(k, s.prosody[k]);
506
+ if (s.tsundere) {
507
+ if (typeof s.tsundere.level === 'number') setTsundereLevel(s.tsundere.level);
508
+ const config = readConfig();
509
+ config.tsundere = { ...(config.tsundere || {}), enabled: !!s.tsundere.enabled };
510
+ writeConfig(config);
511
+ }
512
+ if (s.war) {
513
+ setWarEnabled(!!s.war.enabled);
514
+ if (typeof s.war.level === 'number') setWarLevel(s.war.level);
515
+ }
516
+ return log(`▶ loaded preset "${name}"`);
517
+ }
518
+ console.error('usage: preset [list | save <name> | load <name> | delete <name>]');
519
+ process.exit(1);
520
+ },
521
+
454
522
  // War mode: a military-ops-room read-out skin. Level: min 平時 / mid 戦闘中 /
455
523
  // max 危機的. Combined with the tsundere level for the operator's 好感度.
456
524
  // war [on|off|toggle|level <0-1>|test|status]
@@ -1033,6 +1101,7 @@ Usage:
1033
1101
  ai-notify voicevox [setup|on <id>|off|speakers|test] speak in VOICEVOX character voices
1034
1102
  ai-notify tsundere [on|off|level <0-1>|test|status] tsundere persona (ツン⇄デレ by urgency)
1035
1103
  ai-notify war [on|off|level <0-1>|test|status] war mode (平時⇄戦闘⇄危機; tsundere level = 好感度)
1104
+ ai-notify preset [list|save <name>|load <name>|delete <name>] save/restore volume+tsundere+war+prosody
1036
1105
  ai-notify voice-prosody [speed|pitch|intonation <v>|reset] VOICEVOX read-out tuning
1037
1106
  ai-notify menubar [install|uninstall|status] native menu bar bell (macOS)
1038
1107
  ai-notify notify [<kind> on|off] which events alert: input|permission|info|done|subagent-done