ai-notify 0.4.2 → 0.4.4
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 +23 -1
- package/README.md +24 -1
- package/menubar/AiNotifyMenuBar.swift +106 -2
- package/menubar/dist/ai-notify.app/Contents/MacOS/ai-notify-menubar +0 -0
- package/package.json +1 -1
- package/src/cli.mjs +29 -1
- package/src/notify.mjs +12 -10
- package/src/state.mjs +17 -0
package/README.ja.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) · **日本語**
|
|
4
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/ai-notify)
|
|
6
|
+
[](https://www.npmjs.com/package/ai-notify)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+

|
|
9
|
+
[](https://github.com/unoryota/ai-notify)
|
|
10
|
+
|
|
5
11
|
**ターミナルのAIエージェントが「あなたを必要とした瞬間」を逃さない** — Claude Code・Codex などのエージェントがターンを終えた/入力を求めた瞬間に、音・読み上げ・デスクトップ通知で知らせます。**全エージェント・全ターミナルを1つのスイッチで一括ミュート**。デーモンも常駐プロセスも無し。
|
|
6
12
|
|
|
7
13
|

|
|
@@ -13,6 +19,10 @@ brew install unoryota/tap/ai-notify # macOS(Homebrew)
|
|
|
13
19
|
ai-notify init # インストール済みのエージェントを自動検出して配線
|
|
14
20
|
```
|
|
15
21
|
|
|
22
|
+
セットアップはこれだけ。`init` が Claude Code / Codex / Gemini を見つけてフックを配線します。あとは1つのスイッチで全部を操作できます:
|
|
23
|
+
|
|
24
|
+

|
|
25
|
+
|
|
16
26
|
## 何が違うか
|
|
17
27
|
|
|
18
28
|
エージェントは数分間も沈黙しがち。ai-notify は適切な瞬間に呼び戻します。とくに **AIを並列でたくさん動かす運用**のために作られています:
|
|
@@ -23,6 +33,10 @@ ai-notify init # インストール済みのエージェントを自動
|
|
|
23
33
|
- 🔕 **1スイッチで全部ミュート。** 全エージェント・全ターミナルが同じフラグを読むので、会議中はワンタップで全部静かに。
|
|
24
34
|
- 🔔 **ネイティブのメニューバーも内蔵。** `ai-notify menubar install` — Hammerspoon/SwiftBar 不要。
|
|
25
35
|
|
|
36
|
+
エージェントの返答を翻訳・VOICEVOX キャラの一覧・ツンデレ口調のクイックツアー:
|
|
37
|
+
|
|
38
|
+

|
|
39
|
+
|
|
26
40
|
## 対応エージェント
|
|
27
41
|
|
|
28
42
|
| エージェント | 状態 | 配線方法 |
|
|
@@ -68,9 +82,17 @@ ai-notify menubar install # ネイティブのメニューバーアプリ・
|
|
|
68
82
|
|
|
69
83
|
モノクロの波形アイコンが**状態を色で**表します(Adobe風):通常はシルエットのみ、入力待ちがあると**黄ドット**、ミュート中は**赤+斜線**。
|
|
70
84
|
|
|
71
|
-
- **左クリック** → メニュー:**音量スライダー**、**ツンデレ**トグル+デレ⇄ツンスライダー、**声の一覧**(システム+VOICEVOX
|
|
85
|
+
- **左クリック** → メニュー:**音量スライダー**、**ツンデレ**トグル+デレ⇄ツンスライダー、**声の一覧**(システム+VOICEVOX)、**ペイン別**設定。開いている各ターミナルに、**読み上げ名**(どのペインが終わったか声で分かる)・**声**・**音量**を個別設定でき、各行にそのペインの声が一覧表示されます。
|
|
72
86
|
- **右クリック** → 即ミュート切替。
|
|
73
87
|
|
|
88
|
+
<p>
|
|
89
|
+
<img alt="ai-notify メニュー — 音量・ツンデレ・声" src="https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/menubar.png" width="250">
|
|
90
|
+
|
|
91
|
+
<img alt="ペインごとの読み上げ名と声(1ターミナル1行)" src="https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/menubar-panes.png" width="250">
|
|
92
|
+
</p>
|
|
93
|
+
|
|
94
|
+
*左:全体の音量/ツンデレ/声。右:各ペインに名前と声を割り当て(🗣 バックエンド → Kyoko、infra → ずんだもん)。*
|
|
95
|
+
|
|
74
96
|
第三者アプリ不要。別の方法が好みなら、**Hammerspoon**・**SwiftBar/xbar**・**Raycast**・標準の**ショートカット**用レシピが [`recipes/`](recipes/) にあります。`ai-notify status --icon` は `🔔`/`🔕` だけを出力するので、tmux・プロンプト・Claude Code のステータスラインに埋め込めます。
|
|
75
97
|
|
|
76
98
|
> 切替は実行中でも効きます:次にエージェントが発火した時にフラグを読むので、トグルした瞬間に全稼働エージェントへ反映されます。
|
package/README.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
**English** · [日本語](README.ja.md)
|
|
4
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/ai-notify)
|
|
6
|
+
[](https://www.npmjs.com/package/ai-notify)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+

|
|
9
|
+
[](https://github.com/unoryota/ai-notify)
|
|
10
|
+
|
|
5
11
|
**Know the moment your terminal AI agent needs you** — a sound, a spoken read-out, and a desktop banner the instant Claude Code, Codex, or another agent finishes a turn or asks for input. One mute switch covers **all of them, across every terminal**. No daemon, no background process.
|
|
6
12
|
|
|
7
13
|

|
|
@@ -13,6 +19,11 @@ brew install unoryota/tap/ai-notify # macOS (Homebrew)
|
|
|
13
19
|
ai-notify init # auto-detects your agents and wires them
|
|
14
20
|
```
|
|
15
21
|
|
|
22
|
+
That's the whole setup — `init` finds Claude Code / Codex / Gemini and wires
|
|
23
|
+
their hooks. From then on you control everything with one switch:
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
16
27
|
## What makes it different
|
|
17
28
|
|
|
18
29
|
Plenty of agents go quiet for minutes. ai-notify pulls you back at the right moment — and is built for **running many agents at once**:
|
|
@@ -23,6 +34,10 @@ Plenty of agents go quiet for minutes. ai-notify pulls you back at the right mom
|
|
|
23
34
|
- 🔕 **One switch mutes everything.** Every agent in every terminal reads the same flag — one tap silences them all for a meeting.
|
|
24
35
|
- 🔔 **A real menu bar bell, built in.** `ai-notify menubar install` — no Hammerspoon/SwiftBar required.
|
|
25
36
|
|
|
37
|
+
A quick tour — translate an agent's reply, list VOICEVOX character voices, and the tsundere persona:
|
|
38
|
+
|
|
39
|
+

|
|
40
|
+
|
|
26
41
|
## Supported agents
|
|
27
42
|
|
|
28
43
|
| Agent | Status | How it's wired |
|
|
@@ -68,9 +83,17 @@ ai-notify menubar install # native menu bar app, starts at login
|
|
|
68
83
|
|
|
69
84
|
A monochrome waveform icon shows status by color (Adobe-style): plain when idle, a **yellow** dot when an agent is waiting for you, **red + slash** when muted.
|
|
70
85
|
|
|
71
|
-
- **Left-click** → menu: a **volume slider**, a **tsundere** toggle + デレ⇄ツン slider, the **voice list** (system + VOICEVOX), and **per-pane** controls
|
|
86
|
+
- **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.
|
|
72
87
|
- **Right-click** → instant mute toggle.
|
|
73
88
|
|
|
89
|
+
<p>
|
|
90
|
+
<img alt="ai-notify menu — volume, tsundere, and voice controls" src="https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/menubar.png" width="250">
|
|
91
|
+
|
|
92
|
+
<img alt="per-pane spoken name and voice, one row per terminal" src="https://raw.githubusercontent.com/unoryota/ai-notify/main/assets/menubar-panes.png" width="250">
|
|
93
|
+
</p>
|
|
94
|
+
|
|
95
|
+
*Left: global volume / tsundere / voices. Right: each pane named and given its own voice (🗣 バックエンド → Kyoko, infra → ずんだもん).*
|
|
96
|
+
|
|
74
97
|
No third-party app needed. Prefer something else? There are drop-in recipes for **Hammerspoon**, **SwiftBar/xbar**, **Raycast**, and the built-in **macOS Shortcuts** in [`recipes/`](recipes/). `ai-notify status --icon` prints just `🔔`/`🔕` to embed in tmux / your prompt / Claude Code's status line.
|
|
75
98
|
|
|
76
99
|
> Toggling works mid-run: the flag is read the next time an agent fires, so flipping it instantly affects every running agent.
|
|
@@ -113,6 +113,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
113
113
|
}
|
|
114
114
|
render()
|
|
115
115
|
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in self?.render() }
|
|
116
|
+
|
|
117
|
+
var shotPath = ProcessInfo.processInfo.environment["AI_NOTIFY_SHOT"]
|
|
118
|
+
let args = CommandLine.arguments
|
|
119
|
+
if let i = args.firstIndex(of: "--shot"), i + 1 < args.count { shotPath = args[i + 1] }
|
|
120
|
+
if let shot = shotPath, !shot.isEmpty {
|
|
121
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.captureMenuShot(shot) }
|
|
122
|
+
}
|
|
116
123
|
}
|
|
117
124
|
|
|
118
125
|
// Black/white waveform silhouette (template, auto-adapting) when idle; a
|
|
@@ -175,6 +182,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
175
182
|
@objc private func paneVolumeChanged(_ s: NSSlider) {
|
|
176
183
|
if let tty = s.identifier?.rawValue { State.cli(["volume-pane", tty, String(format: "%.2f", s.doubleValue)]) }
|
|
177
184
|
}
|
|
185
|
+
// Editing a text field *inside* an NSMenu is unreliable — the menu's tracking
|
|
186
|
+
// loop swallows the keystrokes. So naming a pane opens a normal modal dialog
|
|
187
|
+
// (NSAlert with a text field), which takes keyboard focus properly. Empty =>
|
|
188
|
+
// clear (the pane falls back to its label / the speakLabel default).
|
|
189
|
+
@objc private func promptPaneName(_ sender: NSMenuItem) {
|
|
190
|
+
guard let info = sender.representedObject as? [String], let tty = info.first else { return }
|
|
191
|
+
let current = info.count > 1 ? info[1] : ""
|
|
192
|
+
let alert = NSAlert()
|
|
193
|
+
alert.messageText = "読み上げ名"
|
|
194
|
+
alert.informativeText = "このペインを通知で読み上げる名前(空欄で解除)"
|
|
195
|
+
let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 240, height: 24))
|
|
196
|
+
field.stringValue = current
|
|
197
|
+
field.placeholderString = "例: バックエンド"
|
|
198
|
+
alert.accessoryView = field
|
|
199
|
+
alert.addButton(withTitle: "保存")
|
|
200
|
+
alert.addButton(withTitle: "キャンセル")
|
|
201
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
202
|
+
alert.window.initialFirstResponder = field
|
|
203
|
+
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
|
204
|
+
let name = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
205
|
+
State.cli(["name-pane", tty, name.isEmpty ? "clear" : name])
|
|
206
|
+
}
|
|
178
207
|
// identifier carries the prosody key (speed | pitch | intonation).
|
|
179
208
|
@objc private func prosodyChanged(_ s: NSSlider) {
|
|
180
209
|
if let key = s.identifier?.rawValue { State.cli(["voice-prosody", key, String(format: "%.3f", s.doubleValue)]) }
|
|
@@ -261,7 +290,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
261
290
|
return it
|
|
262
291
|
}
|
|
263
292
|
|
|
264
|
-
private func
|
|
293
|
+
private func buildMenu() -> NSMenu {
|
|
265
294
|
let menu = NSMenu()
|
|
266
295
|
|
|
267
296
|
// Parse menu-json once.
|
|
@@ -323,8 +352,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
323
352
|
guard let tty = p["tty"] as? String else { continue }
|
|
324
353
|
let label = p["label"] as? String ?? tty
|
|
325
354
|
let cur = p["current"] as? String
|
|
326
|
-
let
|
|
355
|
+
let pname = p["speakName"] as? String ?? ""
|
|
356
|
+
// Parent row shows at a glance WHO the pane is (its custom 🗣 name,
|
|
357
|
+
// or its label/tty) AND which 🔊 voice it uses (omitted when it just
|
|
358
|
+
// follows the global voice). Naming a pane must not hide the voice —
|
|
359
|
+
// surfacing the per-pane voice is what this list is for.
|
|
360
|
+
let who = pname.isEmpty ? label : "🗣 \(pname)"
|
|
361
|
+
let voiceTag = cur != nil ? " — 🔊 \(cur!)" : ""
|
|
362
|
+
let item = NSMenuItem(title: who + voiceTag, action: nil, keyEquivalent: "")
|
|
327
363
|
let sub = NSMenu()
|
|
364
|
+
// Per-pane spoken name — opens a dialog (menu fields can't type).
|
|
365
|
+
sub.addItem(disabledHeader("読み上げ名"))
|
|
366
|
+
let nameItem = NSMenuItem(
|
|
367
|
+
title: pname.isEmpty ? "(クリックして設定…)" : "「\(pname)」を変更…",
|
|
368
|
+
action: #selector(promptPaneName(_:)), keyEquivalent: ""
|
|
369
|
+
)
|
|
370
|
+
nameItem.target = self
|
|
371
|
+
nameItem.representedObject = [tty, pname]
|
|
372
|
+
sub.addItem(nameItem)
|
|
373
|
+
if !pname.isEmpty {
|
|
374
|
+
let clr = NSMenuItem(title: "読み上げ名を解除", action: #selector(runItem(_:)), keyEquivalent: "")
|
|
375
|
+
clr.target = self; clr.representedObject = ["name-pane", tty, "clear"]
|
|
376
|
+
sub.addItem(clr)
|
|
377
|
+
}
|
|
378
|
+
sub.addItem(.separator())
|
|
328
379
|
// Per-pane volume.
|
|
329
380
|
let pv = (p["volume"] as? Double) ?? State.volume
|
|
330
381
|
sub.addItem(disabledHeader("音量"))
|
|
@@ -359,6 +410,59 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
359
410
|
quitItem.target = self
|
|
360
411
|
menu.addItem(quitItem)
|
|
361
412
|
|
|
413
|
+
return menu
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private func showMenu() {
|
|
417
|
+
let menu = buildMenu()
|
|
418
|
+
if let button = statusItem.button {
|
|
419
|
+
menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 4), in: button)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Screenshot mode (env AI_NOTIFY_SHOT=/path): open the menu, capture just its
|
|
424
|
+
// window via `screencapture`, then quit. Used to regenerate the README image
|
|
425
|
+
// headlessly — never reached in normal operation.
|
|
426
|
+
private var shotMenu: NSMenu?
|
|
427
|
+
func captureMenuShot(_ path: String) {
|
|
428
|
+
let menu = buildMenu()
|
|
429
|
+
shotMenu = menu
|
|
430
|
+
let pid = ProcessInfo.processInfo.processIdentifier
|
|
431
|
+
// An open NSMenu runs the runloop in event-tracking mode, so the timer
|
|
432
|
+
// must be registered in .common mode to fire while the menu is on screen.
|
|
433
|
+
// The menu window can take a few ticks to register with the window
|
|
434
|
+
// server, so poll for it rather than assuming a single fixed delay.
|
|
435
|
+
var ticks = 0
|
|
436
|
+
let t = Timer(timeInterval: 0.25, repeats: true) { [weak self] timer in
|
|
437
|
+
ticks += 1
|
|
438
|
+
let infos = (CGWindowListCopyWindowInfo([.optionOnScreenOnly], kCGNullWindowID) as? [[String: Any]]) ?? []
|
|
439
|
+
var bestId: CGWindowID = 0
|
|
440
|
+
var bestArea: CGFloat = 0
|
|
441
|
+
for w in infos {
|
|
442
|
+
guard (w[kCGWindowOwnerPID as String] as? pid_t) == pid else { continue }
|
|
443
|
+
guard let b = w[kCGWindowBounds as String] as? [String: CGFloat],
|
|
444
|
+
let wd = b["Width"], let ht = b["Height"],
|
|
445
|
+
let num = w[kCGWindowNumber as String] as? Int else { continue }
|
|
446
|
+
// The menu is far taller than the status-bar button window.
|
|
447
|
+
let area = wd * ht
|
|
448
|
+
if ht > 120 && area > bestArea { bestArea = area; bestId = CGWindowID(num) }
|
|
449
|
+
}
|
|
450
|
+
if bestId == 0 && ticks < 16 { return } // menu window not up yet — keep polling
|
|
451
|
+
if bestId != 0 {
|
|
452
|
+
// -l captures the window at native resolution and crops tight to
|
|
453
|
+
// it; -o drops the drop-shadow for a clean asset.
|
|
454
|
+
let p = Process()
|
|
455
|
+
p.launchPath = "/usr/sbin/screencapture"
|
|
456
|
+
p.arguments = ["-x", "-o", "-l", String(bestId), path]
|
|
457
|
+
try? p.run(); p.waitUntilExit()
|
|
458
|
+
}
|
|
459
|
+
timer.invalidate()
|
|
460
|
+
menu.cancelTracking()
|
|
461
|
+
self?.shotMenu = nil
|
|
462
|
+
NSApp.terminate(nil)
|
|
463
|
+
}
|
|
464
|
+
RunLoop.main.add(t, forMode: .common)
|
|
465
|
+
NSApp.activate(ignoringOtherApps: true) // a popUp only shows for the active app
|
|
362
466
|
if let button = statusItem.button {
|
|
363
467
|
menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 4), in: button)
|
|
364
468
|
}
|
|
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.4",
|
|
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
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
readPanes,
|
|
33
33
|
readPaneSetting,
|
|
34
34
|
updatePaneSetting,
|
|
35
|
+
firstRunNudge,
|
|
35
36
|
} from './state.mjs';
|
|
36
37
|
|
|
37
38
|
// Single source of truth: read the version from package.json so `--version`
|
|
@@ -138,7 +139,15 @@ const cmds = {
|
|
|
138
139
|
}
|
|
139
140
|
if (!any) log(' No supported agents detected (looked for Claude Code, Codex, Gemini).');
|
|
140
141
|
log(`\nMute toggle: ai-notify toggle Status: ai-notify status`);
|
|
141
|
-
if (!dryRun)
|
|
142
|
+
if (!dryRun) {
|
|
143
|
+
log('Restart already-running Codex sessions to pick up the change.');
|
|
144
|
+
// A quiet, one-time nudge — `init` is a setup command, run once. Shown
|
|
145
|
+
// only on the first successful wiring so it never nags on re-runs.
|
|
146
|
+
if (any && firstRunNudge()) {
|
|
147
|
+
log('\n⭐ Useful? A GitHub star really helps it reach others:');
|
|
148
|
+
log(' https://github.com/unoryota/ai-notify');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
142
151
|
},
|
|
143
152
|
|
|
144
153
|
uninstall() {
|
|
@@ -479,6 +488,24 @@ const cmds = {
|
|
|
479
488
|
log(`pane ${tty}: tsundere level ${v}`);
|
|
480
489
|
},
|
|
481
490
|
|
|
491
|
+
// Name a specific pane in the spoken read-out (set from the menu bar), or
|
|
492
|
+
// `clear` to fall back to the label / speakLabel default.
|
|
493
|
+
// name-pane <tty> <name|clear>
|
|
494
|
+
'name-pane'() {
|
|
495
|
+
const [tty, ...rest] = positionals;
|
|
496
|
+
const arg = rest.join(' ').trim(); // a name may contain spaces
|
|
497
|
+
if (!tty || arg === '') {
|
|
498
|
+
console.error('usage: name-pane <tty> <name|clear>');
|
|
499
|
+
process.exit(1);
|
|
500
|
+
}
|
|
501
|
+
if (arg === 'clear') {
|
|
502
|
+
updatePaneSetting(tty, { speakName: null });
|
|
503
|
+
return log(`pane ${tty}: name cleared`);
|
|
504
|
+
}
|
|
505
|
+
updatePaneSetting(tty, { speakName: arg });
|
|
506
|
+
log(`pane ${tty}: name ${arg}`);
|
|
507
|
+
},
|
|
508
|
+
|
|
482
509
|
// Get/set the VOICEVOX base prosody (the normal-tone scales the menu bar
|
|
483
510
|
// sliders drive). With no args, prints the current values as JSON.
|
|
484
511
|
// voice-prosody [speed|pitch|intonation <value> | reset]
|
|
@@ -534,6 +561,7 @@ const cmds = {
|
|
|
534
561
|
tty,
|
|
535
562
|
label: recorded.get(tty) || tty.replace('/dev/', ''),
|
|
536
563
|
current: labelFor(s.tts ? s : null),
|
|
564
|
+
speakName: typeof s.speakName === 'string' ? s.speakName : '',
|
|
537
565
|
volume: typeof s.volume === 'number' ? s.volume : globalVol,
|
|
538
566
|
volumeSet: typeof s.volume === 'number',
|
|
539
567
|
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);
|
package/src/state.mjs
CHANGED
|
@@ -159,6 +159,23 @@ export const nextCounter = (name) => {
|
|
|
159
159
|
return n;
|
|
160
160
|
};
|
|
161
161
|
|
|
162
|
+
// One-time UI nudges (e.g. the post-`init` star hint). Returns true the FIRST
|
|
163
|
+
// time it's called for a given key, then records a marker so it never fires
|
|
164
|
+
// again — so setup hints inform once without ever nagging on re-runs.
|
|
165
|
+
export const firstRunNudge = (key = 'star') => {
|
|
166
|
+
const p = join(stateDir(), `nudged-${key}`);
|
|
167
|
+
if (existsSync(p)) return false;
|
|
168
|
+
try {
|
|
169
|
+
ensureDir(stateDir());
|
|
170
|
+
writeFileSync(p, '');
|
|
171
|
+
} catch {
|
|
172
|
+
/* best-effort: if we can't persist, don't nag repeatedly is preferred, so
|
|
173
|
+
treat a write failure as "already shown". */
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
};
|
|
178
|
+
|
|
162
179
|
// --- Per-pane state --------------------------------------------------------
|
|
163
180
|
// Recently-active terminal panes (so the menu bar can offer per-pane voices),
|
|
164
181
|
// and a per-tty voice override. Both are small JSON files in the state dir.
|