ai-notify 0.1.0 → 0.1.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/package.json +1 -1
- package/src/cli.mjs +57 -1
- package/src/highlight.mjs +261 -0
- package/src/notify.mjs +45 -15
- package/src/state.mjs +15 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-notify",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.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
|
@@ -9,9 +9,10 @@ import { deriveLabel, cliInvocation, isEphemeralInstall } from './util.mjs';
|
|
|
9
9
|
import { curatedVoices, resolveVoice, previewVoice } from './voices.mjs';
|
|
10
10
|
import * as menubar from './menubar.mjs';
|
|
11
11
|
import { translate } from './translate.mjs';
|
|
12
|
+
import { diagnose as highlightDiagnose, clearHighlight } from './highlight.mjs';
|
|
12
13
|
import { isMuted, setMuted, toggleMuted, readConfig, writeConfig, paths, DEFAULT_CONFIG } from './state.mjs';
|
|
13
14
|
|
|
14
|
-
const VERSION = '0.1.
|
|
15
|
+
const VERSION = '0.1.1';
|
|
15
16
|
|
|
16
17
|
const args = process.argv.slice(2);
|
|
17
18
|
const cmd = args[0];
|
|
@@ -43,6 +44,37 @@ const readStdinJson = () => {
|
|
|
43
44
|
}
|
|
44
45
|
};
|
|
45
46
|
|
|
47
|
+
// Pull the agent's last assistant text from a Claude Code transcript (JSONL),
|
|
48
|
+
// trimmed to a short summary suitable for a notification / read-out.
|
|
49
|
+
const lastAssistantText = (transcriptPath) => {
|
|
50
|
+
try {
|
|
51
|
+
const lines = readFileSync(transcriptPath, 'utf8').split('\n');
|
|
52
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
53
|
+
const line = lines[i].trim();
|
|
54
|
+
if (!line) continue;
|
|
55
|
+
let obj;
|
|
56
|
+
try {
|
|
57
|
+
obj = JSON.parse(line);
|
|
58
|
+
} catch {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (obj.type !== 'assistant') continue;
|
|
62
|
+
const content = obj.message?.content;
|
|
63
|
+
if (!Array.isArray(content)) continue;
|
|
64
|
+
const text = content
|
|
65
|
+
.filter((c) => c?.type === 'text' && c.text)
|
|
66
|
+
.map((c) => c.text)
|
|
67
|
+
.join(' ')
|
|
68
|
+
.replace(/\s+/g, ' ')
|
|
69
|
+
.trim();
|
|
70
|
+
if (text) return text.length > 140 ? `${text.slice(0, 140)}…` : text;
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
/* unreadable transcript — fall back to the template */
|
|
74
|
+
}
|
|
75
|
+
return '';
|
|
76
|
+
};
|
|
77
|
+
|
|
46
78
|
const cmds = {
|
|
47
79
|
init() {
|
|
48
80
|
const dryRun = !!opt('dry-run');
|
|
@@ -244,6 +276,24 @@ const cmds = {
|
|
|
244
276
|
if (!config.translateTo) log('Enable: ai-notify translate on ja');
|
|
245
277
|
},
|
|
246
278
|
|
|
279
|
+
// Diagnose / test the waiting-window highlight. Run it INSIDE the terminal
|
|
280
|
+
// tab you want to test (not piped) so it has a controlling tty.
|
|
281
|
+
highlight() {
|
|
282
|
+
const sub = positionals[0] || 'test';
|
|
283
|
+
if (sub === 'clear') {
|
|
284
|
+
clearHighlight();
|
|
285
|
+
return log('cleared.');
|
|
286
|
+
}
|
|
287
|
+
const color = positionals[1] || readConfig().highlightColor || 'yellow';
|
|
288
|
+
const info = highlightDiagnose(color);
|
|
289
|
+
log(JSON.stringify(info, null, 2));
|
|
290
|
+
log('\nThis tab should now be highlighted. Reset it with: ai-notify highlight clear');
|
|
291
|
+
if (info.appleTerminal && String(info.appleTerminal).startsWith('ERROR')) {
|
|
292
|
+
log('\n→ AppleScript was blocked. Grant permission in:');
|
|
293
|
+
log(' System Settings → Privacy & Security → Automation → (your terminal) → Terminal');
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
|
|
247
297
|
hook() {
|
|
248
298
|
const source = opt('source', 'default');
|
|
249
299
|
let event = opt('event', 'done');
|
|
@@ -263,6 +313,12 @@ const cmds = {
|
|
|
263
313
|
const data = readStdinJson();
|
|
264
314
|
cwd = data.cwd || '';
|
|
265
315
|
message = data.message || '';
|
|
316
|
+
// The Stop hook has no message, so "done" would only say "finished".
|
|
317
|
+
// Pull the agent's last reply from the transcript so the notification
|
|
318
|
+
// says WHAT was done.
|
|
319
|
+
if (!message && event === 'done' && data.transcript_path) {
|
|
320
|
+
message = lastAssistantText(data.transcript_path);
|
|
321
|
+
}
|
|
266
322
|
}
|
|
267
323
|
|
|
268
324
|
const label = deriveLabel(cwd);
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Visually highlight the terminal window/pane that is waiting for input, so it
|
|
2
|
+
// stands out among many open terminals. Best-effort and terminal-specific:
|
|
3
|
+
//
|
|
4
|
+
// - tmux -> color the pane background (select-pane -P)
|
|
5
|
+
// - Apple Terminal -> set the tab's background color via AppleScript,
|
|
6
|
+
// matched by tty, restoring the original on done
|
|
7
|
+
// - others (iTerm2,…) -> OSC 11 default-background + a tab-title marker
|
|
8
|
+
//
|
|
9
|
+
// Everything is wrapped so a failure never affects the notification. The tab
|
|
10
|
+
// title marker is the most portable signal and is always emitted.
|
|
11
|
+
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { writeFileSync, readFileSync, existsSync, rmSync, mkdirSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { stateDir } from './state.mjs';
|
|
16
|
+
|
|
17
|
+
const isMac = process.platform === 'darwin';
|
|
18
|
+
const BEL = '\x07';
|
|
19
|
+
|
|
20
|
+
// Controlling terminal of this process (works even when stdio is piped).
|
|
21
|
+
const ttyName = () => {
|
|
22
|
+
try {
|
|
23
|
+
const t = execFileSync('ps', ['-o', 'tty=', '-p', String(process.pid)], { encoding: 'utf8' }).trim();
|
|
24
|
+
if (!t || t === '??' || t === '?') return null;
|
|
25
|
+
return t.startsWith('/dev/') ? t : `/dev/${t}`;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const writeTty = (seq) => {
|
|
32
|
+
const tty = ttyName() || '/dev/tty';
|
|
33
|
+
try {
|
|
34
|
+
writeFileSync(tty, seq);
|
|
35
|
+
} catch {
|
|
36
|
+
/* no controlling terminal — ignore */
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Default-background (OSC 11) + icon/tab title (OSC 1/2). Reset uses OSC 111.
|
|
41
|
+
const oscSet = (hex, title) => `\x1b]11;${hex}${BEL}\x1b]1;${title}${BEL}\x1b]2;${title}${BEL}`;
|
|
42
|
+
const oscReset = `\x1b]111${BEL}\x1b]1;${BEL}\x1b]2;${BEL}`;
|
|
43
|
+
|
|
44
|
+
// SGR fallback that works even where OSC is ignored (e.g. JetBrains JediTerm):
|
|
45
|
+
// print a bold black-on-yellow bar straight into the pane, plus a BEL so the
|
|
46
|
+
// IDE flags the tab as having activity. Standard ANSI — renders everywhere.
|
|
47
|
+
const sgrBg = { yellow: 103, orange: '48;5;208', red: 101, green: 102 };
|
|
48
|
+
const sgrBar = (label, color) => {
|
|
49
|
+
const bg = sgrBg[color] || sgrBg.yellow;
|
|
50
|
+
return `\r\n\x1b[1;30;${bg}m ⏳ ${label || 'input'} \x1b[0m${BEL}\r\n`;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const colorHex = (c) => {
|
|
54
|
+
const map = { yellow: '#FFD400', orange: '#FF9500', red: '#FF3B30', green: '#34C759' };
|
|
55
|
+
if (!c) return map.yellow;
|
|
56
|
+
return map[c] || (c.startsWith('#') ? c : map.yellow);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// --- tmux ---
|
|
60
|
+
const tmuxPane = () => (process.env.TMUX && process.env.TMUX_PANE ? process.env.TMUX_PANE : null);
|
|
61
|
+
const tmuxSet = (c) => {
|
|
62
|
+
const pane = tmuxPane();
|
|
63
|
+
if (!pane) return;
|
|
64
|
+
const color = c === 'yellow' || !c ? 'colour220' : c;
|
|
65
|
+
try {
|
|
66
|
+
execFileSync('tmux', ['select-pane', '-t', pane, '-P', `bg=${color}`]);
|
|
67
|
+
} catch {
|
|
68
|
+
/* ignore */
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const tmuxReset = () => {
|
|
72
|
+
const pane = tmuxPane();
|
|
73
|
+
if (!pane) return;
|
|
74
|
+
try {
|
|
75
|
+
execFileSync('tmux', ['select-pane', '-t', pane, '-P', 'bg=default']);
|
|
76
|
+
} catch {
|
|
77
|
+
/* ignore */
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// --- Apple Terminal (set the tab bg by tty; store original to restore) ---
|
|
82
|
+
// Some setups don't export TERM_PROGRAM, so detect Apple Terminal robustly:
|
|
83
|
+
// explicit signals first, then — when the terminal is unknown — attempt anyway.
|
|
84
|
+
// The AppleScript matches by tty, so it's a harmless no-op if this isn't really
|
|
85
|
+
// a Terminal.app tab. Known non-Terminal programs (iTerm.app, vscode, …) opt out
|
|
86
|
+
// to avoid needless automation prompts.
|
|
87
|
+
const isAppleTerminal = () => {
|
|
88
|
+
if (!isMac) return false;
|
|
89
|
+
const tp = process.env.TERM_PROGRAM;
|
|
90
|
+
const bundle = process.env.__CFBundleIdentifier || '';
|
|
91
|
+
if (tp === 'Apple_Terminal' || bundle === 'com.apple.Terminal') return true;
|
|
92
|
+
if (tp || bundle) return false; // a known other terminal/IDE (iTerm, WebStorm…)
|
|
93
|
+
return true; // truly unknown -> attempt; the tty match guards it
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// JetBrains IDE terminals (WebStorm, IntelliJ, …) — JediTerm/Gen2.
|
|
97
|
+
const isJetBrains = () =>
|
|
98
|
+
process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm' ||
|
|
99
|
+
/jetbrains/i.test(process.env.__CFBundleIdentifier || '');
|
|
100
|
+
const savePath = (tty) => join(stateDir(), `hl-${tty.replace(/[^\w]+/g, '_')}`);
|
|
101
|
+
|
|
102
|
+
const appleSet = (rgb16) => {
|
|
103
|
+
const tty = ttyName();
|
|
104
|
+
if (!tty) return;
|
|
105
|
+
const script = `tell application "Terminal"
|
|
106
|
+
repeat with w in windows
|
|
107
|
+
repeat with t in tabs of w
|
|
108
|
+
try
|
|
109
|
+
if (tty of t) is "${tty}" then
|
|
110
|
+
set c to background color of t
|
|
111
|
+
set background color of t to {${rgb16}}
|
|
112
|
+
return ((item 1 of c) & "," & (item 2 of c) & "," & (item 3 of c)) as string
|
|
113
|
+
end if
|
|
114
|
+
end try
|
|
115
|
+
end repeat
|
|
116
|
+
end repeat
|
|
117
|
+
return ""
|
|
118
|
+
end tell`;
|
|
119
|
+
try {
|
|
120
|
+
const orig = execFileSync('osascript', ['-e', script], { encoding: 'utf8', timeout: 3000 }).trim();
|
|
121
|
+
if (orig) {
|
|
122
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
123
|
+
writeFileSync(savePath(tty), orig);
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
/* automation permission not granted / ignore */
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const appleReset = () => {
|
|
131
|
+
const tty = ttyName();
|
|
132
|
+
if (!tty) return;
|
|
133
|
+
const p = savePath(tty);
|
|
134
|
+
// Only restore if WE highlighted this tab (a saved original exists). Without
|
|
135
|
+
// this guard a normal 'done' with no prior 'waiting' would blacken the tab.
|
|
136
|
+
if (!existsSync(p)) return;
|
|
137
|
+
let rgb16 = '0, 0, 0';
|
|
138
|
+
try {
|
|
139
|
+
rgb16 = readFileSync(p, 'utf8').trim() || rgb16;
|
|
140
|
+
} catch {
|
|
141
|
+
/* ignore */
|
|
142
|
+
}
|
|
143
|
+
const script = `tell application "Terminal"
|
|
144
|
+
repeat with w in windows
|
|
145
|
+
repeat with t in tabs of w
|
|
146
|
+
try
|
|
147
|
+
if (tty of t) is "${tty}" then set background color of t to {${rgb16}}
|
|
148
|
+
end try
|
|
149
|
+
end repeat
|
|
150
|
+
end repeat
|
|
151
|
+
end tell`;
|
|
152
|
+
try {
|
|
153
|
+
execFileSync('osascript', ['-e', script], { timeout: 3000, stdio: 'ignore' });
|
|
154
|
+
} catch {
|
|
155
|
+
/* ignore */
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
if (existsSync(p)) rmSync(p);
|
|
159
|
+
} catch {
|
|
160
|
+
/* ignore */
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// 16-bit RGB for AppleScript yellow-ish; reuse hex→approx for custom.
|
|
165
|
+
const rgb16From = (c) => {
|
|
166
|
+
if (c === 'orange') return '65535, 38000, 0';
|
|
167
|
+
if (c === 'red') return '65535, 15000, 12000';
|
|
168
|
+
if (c === 'green') return '13000, 51000, 22000';
|
|
169
|
+
return '65535, 54000, 0'; // yellow
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// A per-tty marker so `clear` only touches the terminal when WE highlighted it
|
|
173
|
+
// (a 'done' with no prior 'waiting' must leave the window untouched).
|
|
174
|
+
const markPath = (tty) => join(stateDir(), `hl-on-${tty.replace(/[^\w]+/g, '_')}`);
|
|
175
|
+
|
|
176
|
+
export const highlightWaiting = (label, color = 'yellow') => {
|
|
177
|
+
writeTty(oscSet(colorHex(color), `⏳ ${label || 'input'}`));
|
|
178
|
+
if (isJetBrains()) writeTty(sgrBar(label, color)); // OSC ignored here; SGR works
|
|
179
|
+
if (isAppleTerminal()) appleSet(rgb16From(color));
|
|
180
|
+
tmuxSet(color);
|
|
181
|
+
const tty = ttyName();
|
|
182
|
+
if (tty) {
|
|
183
|
+
try {
|
|
184
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
185
|
+
writeFileSync(markPath(tty), '');
|
|
186
|
+
} catch {
|
|
187
|
+
/* ignore */
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Foreground diagnostic: run the highlight and surface what happened (tty,
|
|
193
|
+
// terminal, AppleScript error/permission) instead of swallowing it.
|
|
194
|
+
export const diagnose = (color = 'yellow') => {
|
|
195
|
+
const info = {
|
|
196
|
+
platform: process.platform,
|
|
197
|
+
TERM_PROGRAM: process.env.TERM_PROGRAM || null,
|
|
198
|
+
__CFBundleIdentifier: process.env.__CFBundleIdentifier || null,
|
|
199
|
+
isAppleTerminal: isAppleTerminal(),
|
|
200
|
+
TMUX: process.env.TMUX ? process.env.TMUX_PANE || true : false,
|
|
201
|
+
tty: ttyName(),
|
|
202
|
+
};
|
|
203
|
+
try {
|
|
204
|
+
writeTty(oscSet(colorHex(color), '⏳ test'));
|
|
205
|
+
info.osc = `wrote to ${ttyName() || '/dev/tty'}`;
|
|
206
|
+
} catch (e) {
|
|
207
|
+
info.osc = `error: ${e.message}`;
|
|
208
|
+
}
|
|
209
|
+
info.jetBrains = isJetBrains();
|
|
210
|
+
if (isJetBrains()) {
|
|
211
|
+
writeTty(sgrBar('test', color)); // should print a yellow bar right here
|
|
212
|
+
info.sgrBar = 'printed (look for the yellow bar above)';
|
|
213
|
+
}
|
|
214
|
+
if (isAppleTerminal()) {
|
|
215
|
+
const tty = ttyName();
|
|
216
|
+
if (!tty) {
|
|
217
|
+
info.appleTerminal = 'no controlling tty';
|
|
218
|
+
} else {
|
|
219
|
+
const script = `tell application "Terminal"
|
|
220
|
+
repeat with w in windows
|
|
221
|
+
repeat with t in tabs of w
|
|
222
|
+
try
|
|
223
|
+
if (tty of t) is "${tty}" then
|
|
224
|
+
set c to background color of t
|
|
225
|
+
set background color of t to {${rgb16From(color)}}
|
|
226
|
+
return "matched tty, original=" & (c as string)
|
|
227
|
+
end if
|
|
228
|
+
end try
|
|
229
|
+
end repeat
|
|
230
|
+
end repeat
|
|
231
|
+
return "no tab matched tty ${tty}"
|
|
232
|
+
end tell`;
|
|
233
|
+
try {
|
|
234
|
+
info.appleTerminal = execFileSync('osascript', ['-e', script], {
|
|
235
|
+
encoding: 'utf8',
|
|
236
|
+
timeout: 5000,
|
|
237
|
+
}).trim();
|
|
238
|
+
} catch (e) {
|
|
239
|
+
info.appleTerminal = `ERROR: ${(e.stderr || e.message || '').toString().trim()}`;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
info.appleTerminal = 'skipped (detected a non-Terminal program)';
|
|
244
|
+
}
|
|
245
|
+
return info;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
export const clearHighlight = () => {
|
|
249
|
+
const tty = ttyName();
|
|
250
|
+
if (tty && !existsSync(markPath(tty))) return; // we never highlighted this one
|
|
251
|
+
writeTty(oscReset);
|
|
252
|
+
if (isAppleTerminal()) appleReset();
|
|
253
|
+
tmuxReset();
|
|
254
|
+
if (tty) {
|
|
255
|
+
try {
|
|
256
|
+
if (existsSync(markPath(tty))) rmSync(markPath(tty));
|
|
257
|
+
} catch {
|
|
258
|
+
/* ignore */
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
};
|
package/src/notify.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { spawn } from 'node:child_process';
|
|
|
8
8
|
import { existsSync } from 'node:fs';
|
|
9
9
|
import { isMuted, readConfig } from './state.mjs';
|
|
10
10
|
import { translate } from './translate.mjs';
|
|
11
|
+
import { highlightWaiting, clearHighlight } from './highlight.mjs';
|
|
11
12
|
|
|
12
13
|
const platform = process.platform; // 'darwin' | 'linux' | 'win32'
|
|
13
14
|
|
|
@@ -70,16 +71,24 @@ const speak = (text, voice) => {
|
|
|
70
71
|
}
|
|
71
72
|
};
|
|
72
73
|
|
|
73
|
-
const banner = (title, subtitle, message) => {
|
|
74
|
+
const banner = (title, subtitle, message, { activate, urgent } = {}) => {
|
|
74
75
|
if (platform === 'darwin') {
|
|
75
76
|
if (which('terminal-notifier')) {
|
|
76
|
-
|
|
77
|
+
const args = ['-title', title, '-subtitle', subtitle, '-message', message];
|
|
78
|
+
if (activate) args.push('-activate', activate); // click the notification -> focus the app
|
|
79
|
+
run('terminal-notifier', args);
|
|
77
80
|
} else {
|
|
78
81
|
const esc = (s) => String(s).replace(/"/g, '\\"');
|
|
79
|
-
run('osascript', [
|
|
82
|
+
run('osascript', [
|
|
83
|
+
'-e',
|
|
84
|
+
`display notification "${esc(message)}" with title "${esc(title)}" subtitle "${esc(subtitle)}"`,
|
|
85
|
+
]);
|
|
80
86
|
}
|
|
81
87
|
} else if (platform === 'linux') {
|
|
82
|
-
if (which('notify-send'))
|
|
88
|
+
if (which('notify-send')) {
|
|
89
|
+
const args = urgent ? ['-u', 'critical'] : [];
|
|
90
|
+
run('notify-send', [...args, `${title}: ${subtitle}`, message]);
|
|
91
|
+
}
|
|
83
92
|
}
|
|
84
93
|
// win32: skipped (no dependency-free toast); sound/voice still fire.
|
|
85
94
|
};
|
|
@@ -101,19 +110,19 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
|
|
|
101
110
|
// (falling back to the template on failure).
|
|
102
111
|
// default -> speak the raw message as-is.
|
|
103
112
|
// The desktop banner always shows the full original message visually.
|
|
104
|
-
|
|
113
|
+
// coreBody = WHAT happened, in the user's language, without the label.
|
|
114
|
+
let coreBody;
|
|
105
115
|
if (config.speakAgentMessage === false) {
|
|
106
|
-
|
|
116
|
+
coreBody = fromTemplate || fallback;
|
|
107
117
|
} else if (message) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
speakText = translated || fromTemplate || fallback;
|
|
111
|
-
} else {
|
|
112
|
-
speakText = message;
|
|
113
|
-
}
|
|
118
|
+
const translated = config.translateTo ? translate(message, config.translateTo) : message;
|
|
119
|
+
coreBody = translated || fromTemplate || fallback;
|
|
114
120
|
} else {
|
|
115
|
-
|
|
121
|
+
coreBody = fromTemplate || fallback;
|
|
116
122
|
}
|
|
123
|
+
// Prefix the window label for the spoken read-out so you can tell which of
|
|
124
|
+
// many terminals is asking (set a short per-window name with $AI_NOTIFY_LABEL).
|
|
125
|
+
const speakText = config.speakLabel !== false && label ? `${label}、${coreBody}` : coreBody;
|
|
117
126
|
|
|
118
127
|
// Voice precedence (most specific first):
|
|
119
128
|
// $AI_NOTIFY_VOICE — set per terminal window/pane to give each its own voice
|
|
@@ -127,7 +136,28 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
|
|
|
127
136
|
}
|
|
128
137
|
|
|
129
138
|
if (!muted || config.bannerWhenMuted) {
|
|
130
|
-
const
|
|
131
|
-
banner(
|
|
139
|
+
const waiting = event === 'waiting';
|
|
140
|
+
banner(
|
|
141
|
+
waiting ? `⏳ ${label || 'input'}` : `✓ ${label || 'done'}`,
|
|
142
|
+
waiting ? 'waiting for input' : '',
|
|
143
|
+
coreBody,
|
|
144
|
+
{
|
|
145
|
+
// Click the notification to bring the waiting app (e.g. the IDE) forward.
|
|
146
|
+
activate: config.notifyActivate !== false ? process.env.__CFBundleIdentifier : undefined,
|
|
147
|
+
urgent: waiting,
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Visual highlight of *this* terminal window so a waiting pane stands out
|
|
153
|
+
// among many. Always best-effort, and applied even when muted (you still want
|
|
154
|
+
// to see which window needs you during a meeting).
|
|
155
|
+
if (config.highlightWaiting) {
|
|
156
|
+
try {
|
|
157
|
+
if (event === 'waiting') highlightWaiting(label, config.highlightColor);
|
|
158
|
+
else if (event === 'done') clearHighlight();
|
|
159
|
+
} catch {
|
|
160
|
+
/* visual is best-effort */
|
|
161
|
+
}
|
|
132
162
|
}
|
|
133
163
|
};
|
package/src/state.mjs
CHANGED
|
@@ -55,6 +55,15 @@ export const DEFAULT_CONFIG = {
|
|
|
55
55
|
bannerWhenMuted: true,
|
|
56
56
|
// Spoken read-out of which terminal finished (helps tell tabs apart).
|
|
57
57
|
speak: true,
|
|
58
|
+
// Prefix the window label to the spoken message so you can tell which of many
|
|
59
|
+
// terminals is asking (set a short per-window name with $AI_NOTIFY_LABEL).
|
|
60
|
+
speakLabel: true,
|
|
61
|
+
// Visually highlight the waiting terminal window/pane (best-effort, by tty).
|
|
62
|
+
// Off by default; the color is yellow / orange / red / green / #RRGGBB.
|
|
63
|
+
highlightWaiting: false,
|
|
64
|
+
highlightColor: 'yellow',
|
|
65
|
+
// Make the desktop notification click bring the terminal/IDE forward.
|
|
66
|
+
notifyActivate: true,
|
|
58
67
|
// Whether to speak the agent's own text (Codex's reply, a Claude prompt).
|
|
59
68
|
// That text is in the agent's language — set this false to keep every spoken
|
|
60
69
|
// read-out in your own language via doneMessage / waitingMessage instead.
|
|
@@ -70,12 +79,12 @@ export const DEFAULT_CONFIG = {
|
|
|
70
79
|
// 'Kyoko'). Empty = OS default voice. Switch it with `ai-notify voice`. A
|
|
71
80
|
// per-provider `voice` below, if set, overrides this for that agent.
|
|
72
81
|
voice: '',
|
|
73
|
-
// Spoken read-out templates for agent events.
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
// these.
|
|
77
|
-
doneMessage: '
|
|
78
|
-
waitingMessage: '
|
|
82
|
+
// Spoken read-out templates for agent events. The window label is added
|
|
83
|
+
// separately (speakLabel), so leave {label} out here to avoid doubling it.
|
|
84
|
+
// Override per language (e.g. Japanese) in config.json. An agent that supplies
|
|
85
|
+
// its own message (Codex's last reply, a Claude prompt) wins over these.
|
|
86
|
+
doneMessage: 'finished',
|
|
87
|
+
waitingMessage: 'is waiting for input',
|
|
79
88
|
providers: {
|
|
80
89
|
claude: { sound: { waiting: 'Glass', done: 'Hero' }, voice: '' },
|
|
81
90
|
codex: { sound: { done: 'Submarine' }, voice: '' },
|