ai-notify 0.8.0 → 0.9.1

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
@@ -544,7 +736,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
544
736
  // the menu height never jumps.
545
737
  private func tsundereToggleRow(on: Bool) -> NSMenuItem {
546
738
  let row = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 24))
547
- let btn = NSButton(checkboxWithTitle: "ツンデレモード", target: self, action: #selector(tsundereToggled(_:)))
739
+ let btn = NSButton(checkboxWithTitle: "ツンデレ", target: self, action: #selector(tsundereToggled(_:)))
548
740
  btn.frame = NSRect(x: 12, y: 2, width: 196, height: 20)
549
741
  btn.state = on ? .on : .off
550
742
  row.addSubview(btn)
@@ -556,7 +748,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
556
748
  // tsundere; the tsundere level flavors it.
557
749
  private func warToggleRow(on: Bool) -> NSMenuItem {
558
750
  let row = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 24))
559
- let btn = NSButton(checkboxWithTitle: "戦争モード", target: self, action: #selector(warToggled(_:)))
751
+ let btn = NSButton(checkboxWithTitle: "アドレナリン", target: self, action: #selector(warToggled(_:)))
560
752
  btn.frame = NSRect(x: 12, y: 2, width: 196, height: 20)
561
753
  btn.state = on ? .on : .off
562
754
  row.addSubview(btn)
@@ -564,25 +756,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
564
756
  return item
565
757
  }
566
758
 
567
- private func warRow(value: Double) -> NSMenuItem {
568
- let row = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 26))
569
- let left = NSTextField(labelWithString: "平時")
570
- left.frame = NSRect(x: 12, y: 5, width: 30, height: 16)
571
- left.font = .systemFont(ofSize: 10); left.textColor = .secondaryLabelColor
572
- let slider = NSSlider(value: value, minValue: 0, maxValue: 1, target: self, action: #selector(warLevelChanged(_:)))
573
- slider.frame = NSRect(x: 46, y: 3, width: 128, height: 20)
759
+ // A labeled blue level slider (0–1), laid out like the 速さ/高さ/抑揚 rows so
760
+ // ツンデレ / アドレナリン sit with them, aligned and in the same blue.
761
+ private func levelRow(label: String, value: Double, action: Selector) -> NSMenuItem {
762
+ let row = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: 24))
763
+ let cap = NSTextField(labelWithString: label)
764
+ cap.frame = NSRect(x: 12, y: 4, width: 64, height: 16)
765
+ cap.font = .systemFont(ofSize: 11); cap.textColor = .secondaryLabelColor
766
+ let slider = NSSlider(frame: NSRect(x: 78, y: 3, width: 146, height: 20))
767
+ slider.cell = FilledSliderCell() // guaranteed blue fill
768
+ slider.minValue = 0; slider.maxValue = 1; slider.doubleValue = value
769
+ slider.target = self; slider.action = action
574
770
  slider.isContinuous = false
575
- slider.trackFillColor = NSColor(srgbRed: 0.85, green: 0.2, blue: 0.15, alpha: 1) // war red
576
- let right = NSTextField(labelWithString: "危機")
577
- right.frame = NSRect(x: 178, y: 5, width: 30, height: 16)
578
- right.font = .systemFont(ofSize: 10); right.textColor = .secondaryLabelColor
579
- row.addSubview(left); row.addSubview(slider); row.addSubview(right)
771
+ row.addSubview(cap); row.addSubview(slider)
580
772
  let item = NSMenuItem(); item.view = row
581
773
  return item
582
774
  }
583
775
 
584
776
  @objc private func warToggled(_ b: NSButton) { State.cli(["war", "toggle"]) }
585
777
  @objc private func warLevelChanged(_ s: NSSlider) { State.cli(["war", "level", String(format: "%.2f", s.doubleValue)]) }
778
+ @objc private func tsundereLevelDirect(_ s: NSSlider) { State.cli(["tsundere", "level", String(format: "%.2f", s.doubleValue)]) }
586
779
 
587
780
  // representedObject is the full CLI arg array to run.
588
781
  @objc private func runItem(_ item: NSMenuItem) {
@@ -610,16 +803,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
610
803
  // Tsundere mode: checkbox toggle + ツン⇄デレ baseline slider. Both live in
611
804
  // view rows and are always mounted, so toggling never closes the menu nor
612
805
  // shifts its height.
806
+ // Mode toggles (checkboxes only). Their level sliders live below, with the
807
+ // 速さ/高さ/抑揚 sliders, so all the blue adjustment sliders are grouped.
613
808
  let tsun = json?["tsundere"] as? [String: Any]
614
- let tsunOn = (tsun?["enabled"] as? Bool) ?? false
615
809
  let tsunLevel = (tsun?["level"] as? Double) ?? 0.5
616
- menu.addItem(tsundereToggleRow(on: tsunOn))
617
- menu.addItem(tsundereRow(value: tsunLevel))
618
-
619
- // War mode: checkbox + 平時⇄危機 slider (a separate read-out skin).
620
810
  let warJson = json?["war"] as? [String: Any]
811
+ let warLevel = (warJson?["level"] as? Double) ?? 0.5
812
+ menu.addItem(tsundereToggleRow(on: (tsun?["enabled"] as? Bool) ?? false))
621
813
  menu.addItem(warToggleRow(on: (warJson?["enabled"] as? Bool) ?? false))
622
- menu.addItem(warRow(value: (warJson?["level"] as? Double) ?? 0.5))
623
814
  menu.addItem(.separator())
624
815
 
625
816
  // VOICEVOX base prosody (speed / pitch / intonation) — only when VOICEVOX
@@ -643,8 +834,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
643
834
  let v = (pr[key] as? Double) ?? dflt
644
835
  menu.addItem(prosodyRow(label: label, value: v, lo: lo, hi: hi, key: key))
645
836
  }
646
- menu.addItem(.separator())
647
837
  }
838
+ // ツンデレ (好感度) and アドレナリン (強度) levels, below 速さ/高さ/抑揚, in blue.
839
+ menu.addItem(levelRow(label: "ツンデレ", value: tsunLevel, action: #selector(tsundereLevelDirect(_:))))
840
+ menu.addItem(levelRow(label: "アドレナリン", value: warLevel, action: #selector(warLevelChanged(_:))))
841
+ menu.addItem(.separator())
648
842
 
649
843
  if voices.isEmpty {
650
844
  menu.addItem(disabledHeader("(声の一覧を取得できません)"))
@@ -762,6 +956,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
762
956
  notifyParent.submenu = notifySub
763
957
  menu.addItem(notifyParent)
764
958
 
959
+ menu.addItem(.separator())
960
+ let settingsItem = NSMenuItem(title: "⚙ 設定(スライダー・数値・プリセット)…", action: #selector(openSettings), keyEquivalent: ",")
961
+ settingsItem.target = self
962
+ menu.addItem(settingsItem)
963
+
765
964
  menu.addItem(.separator())
766
965
  let quitItem = NSMenuItem(title: "ai-notify を終了", action: #selector(quit), keyEquivalent: "q")
767
966
  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.1",
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