ai-notify 0.1.0 → 0.2.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.md +70 -35
- package/menubar/AiNotifyMenuBar.swift +192 -63
- package/menubar/dist/ai-notify.app/Contents/MacOS/ai-notify-menubar +0 -0
- package/package.json +1 -1
- package/src/cli.mjs +244 -4
- package/src/highlight.mjs +261 -0
- package/src/menubar.mjs +15 -1
- package/src/notify.mjs +124 -31
- package/src/state.mjs +117 -11
- package/src/util.mjs +16 -0
- package/src/voicevox.mjs +120 -0
package/src/cli.mjs
CHANGED
|
@@ -3,15 +3,31 @@
|
|
|
3
3
|
// One mute switch for all of them, across every terminal. No daemon.
|
|
4
4
|
|
|
5
5
|
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
6
7
|
import { providers, byId } from './providers/index.mjs';
|
|
7
8
|
import { emit } from './notify.mjs';
|
|
8
9
|
import { deriveLabel, cliInvocation, isEphemeralInstall } from './util.mjs';
|
|
9
10
|
import { curatedVoices, resolveVoice, previewVoice } from './voices.mjs';
|
|
10
11
|
import * as menubar from './menubar.mjs';
|
|
11
12
|
import { translate } from './translate.mjs';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
import { diagnose as highlightDiagnose, clearHighlight } from './highlight.mjs';
|
|
14
|
+
import * as voicevox from './voicevox.mjs';
|
|
15
|
+
import {
|
|
16
|
+
isMuted,
|
|
17
|
+
setMuted,
|
|
18
|
+
toggleMuted,
|
|
19
|
+
readConfig,
|
|
20
|
+
writeConfig,
|
|
21
|
+
paths,
|
|
22
|
+
DEFAULT_CONFIG,
|
|
23
|
+
readVolume,
|
|
24
|
+
setVolume,
|
|
25
|
+
readPanes,
|
|
26
|
+
readPaneSetting,
|
|
27
|
+
updatePaneSetting,
|
|
28
|
+
} from './state.mjs';
|
|
29
|
+
|
|
30
|
+
const VERSION = '0.2.0';
|
|
15
31
|
|
|
16
32
|
const args = process.argv.slice(2);
|
|
17
33
|
const cmd = args[0];
|
|
@@ -43,6 +59,57 @@ const readStdinJson = () => {
|
|
|
43
59
|
}
|
|
44
60
|
};
|
|
45
61
|
|
|
62
|
+
// Pull the agent's last assistant text from a Claude Code transcript (JSONL),
|
|
63
|
+
// trimmed to a short summary suitable for a notification / read-out.
|
|
64
|
+
const lastAssistantText = (transcriptPath) => {
|
|
65
|
+
try {
|
|
66
|
+
const lines = readFileSync(transcriptPath, 'utf8').split('\n');
|
|
67
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
68
|
+
const line = lines[i].trim();
|
|
69
|
+
if (!line) continue;
|
|
70
|
+
let obj;
|
|
71
|
+
try {
|
|
72
|
+
obj = JSON.parse(line);
|
|
73
|
+
} catch {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (obj.type !== 'assistant') continue;
|
|
77
|
+
const content = obj.message?.content;
|
|
78
|
+
if (!Array.isArray(content)) continue;
|
|
79
|
+
const text = content
|
|
80
|
+
.filter((c) => c?.type === 'text' && c.text)
|
|
81
|
+
.map((c) => c.text)
|
|
82
|
+
.join(' ')
|
|
83
|
+
.replace(/\s+/g, ' ')
|
|
84
|
+
.trim();
|
|
85
|
+
if (text) return text.length > 140 ? `${text.slice(0, 140)}…` : text;
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
/* unreadable transcript — fall back to the template */
|
|
89
|
+
}
|
|
90
|
+
return '';
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Terminals (ttys) currently running a wired agent — so all open panes can be
|
|
94
|
+
// assigned a voice from the menu bar without first firing a notification.
|
|
95
|
+
const livePanes = () => {
|
|
96
|
+
try {
|
|
97
|
+
const out = execSync('ps -Ao tty=,command=', { encoding: 'utf8', maxBuffer: 1 << 22 });
|
|
98
|
+
const ttys = new Set();
|
|
99
|
+
for (const line of out.split('\n')) {
|
|
100
|
+
const m = line.match(/^(\S+)\s+(.*)$/);
|
|
101
|
+
if (!m) continue;
|
|
102
|
+
const [, tty, cmd] = m;
|
|
103
|
+
if (tty === '??' || tty === '?') continue;
|
|
104
|
+
if (/ai-notify|menubar/.test(cmd)) continue; // skip our own hook/agent
|
|
105
|
+
if (/\bclaude\b|\bcodex\b|\bgemini\b/i.test(cmd)) ttys.add(`/dev/${tty}`);
|
|
106
|
+
}
|
|
107
|
+
return [...ttys];
|
|
108
|
+
} catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
46
113
|
const cmds = {
|
|
47
114
|
init() {
|
|
48
115
|
const dryRun = !!opt('dry-run');
|
|
@@ -130,6 +197,7 @@ const cmds = {
|
|
|
130
197
|
|
|
131
198
|
const setVoice = (name) => {
|
|
132
199
|
config.voice = name; // '' = OS default
|
|
200
|
+
config.tts = 'say'; // choosing a system voice switches the backend off VOICEVOX
|
|
133
201
|
// Global voice wins only if no per-provider override; clear them so the
|
|
134
202
|
// single switch actually takes effect everywhere.
|
|
135
203
|
for (const k of Object.keys(config.providers || {})) {
|
|
@@ -179,6 +247,152 @@ const cmds = {
|
|
|
179
247
|
log(' Reset: ai-notify voice default');
|
|
180
248
|
},
|
|
181
249
|
|
|
250
|
+
// Speak in VOICEVOX character voices (local engine, free, offline).
|
|
251
|
+
voicevox() {
|
|
252
|
+
const sub = positionals[0] || 'status';
|
|
253
|
+
const config = readConfig();
|
|
254
|
+
const url = config.voicevox?.url || voicevox.DEFAULT_URL;
|
|
255
|
+
|
|
256
|
+
if (sub === 'speakers') {
|
|
257
|
+
const list = voicevox.listSpeakers(url);
|
|
258
|
+
if (!list.length) return log(`No speakers (is VOICEVOX running at ${url}?).`);
|
|
259
|
+
list.forEach((s) => log(` ${String(s.id).padStart(3)} ${s.name}`));
|
|
260
|
+
log(`\nUse one: ai-notify voicevox on <id>`);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (sub === 'on') {
|
|
264
|
+
if (!voicevox.isAvailable(url)) {
|
|
265
|
+
console.error(`VOICEVOX engine not reachable at ${url}. Start the VOICEVOX app first.`);
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
const speaker = Number(positionals[1] || config.voicevox?.speaker || 3);
|
|
269
|
+
config.tts = 'voicevox';
|
|
270
|
+
config.voicevox = { ...(config.voicevox || {}), url, speaker };
|
|
271
|
+
writeConfig(config);
|
|
272
|
+
log(`✓ VOICEVOX on (speaker ${speaker}). Testing…`);
|
|
273
|
+
voicevox.speak('ボイスボックスで読み上げます。', speaker, url);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (sub === 'off') {
|
|
277
|
+
config.tts = 'say';
|
|
278
|
+
writeConfig(config);
|
|
279
|
+
return log('VOICEVOX off — using the OS voice.');
|
|
280
|
+
}
|
|
281
|
+
if (sub === 'test') {
|
|
282
|
+
const speaker = Number(positionals[1] || config.voicevox?.speaker || 3);
|
|
283
|
+
const ok = voicevox.speak('これはテスト読み上げです。完了しました。', speaker, url);
|
|
284
|
+
return log(ok ? `spoke with speaker ${speaker}` : `⚠ failed (is VOICEVOX running at ${url}?)`);
|
|
285
|
+
}
|
|
286
|
+
// status
|
|
287
|
+
log(`VOICEVOX: ${config.tts === 'voicevox' ? `on (speaker ${config.voicevox?.speaker})` : 'off'}`);
|
|
288
|
+
log(` engine ${url}: ${voicevox.isAvailable(url) ? '✓ reachable' : '✗ not running'}`);
|
|
289
|
+
if (config.tts !== 'voicevox') log('\nEnable: ai-notify voicevox on (list voices: ai-notify voicevox speakers)');
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
// Output volume 0.0–2.0 (1.0 = normal). Written to a state file the menu bar
|
|
293
|
+
// slider also drives; $AI_NOTIFY_VOLUME overrides per window.
|
|
294
|
+
volume() {
|
|
295
|
+
const arg = positionals[0];
|
|
296
|
+
if (arg === undefined) {
|
|
297
|
+
const config = readConfig();
|
|
298
|
+
const v = readVolume();
|
|
299
|
+
return log(`volume: ${v != null ? v : typeof config.volume === 'number' ? config.volume : 1}`);
|
|
300
|
+
}
|
|
301
|
+
const n = setVolume(arg);
|
|
302
|
+
log(`🔊 volume → ${n}`);
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
// Assign a voice to a specific pane (by tty), from the menu bar.
|
|
306
|
+
// voice-pane <tty> voicevox <id> | say <name> | clear
|
|
307
|
+
'voice-pane'() {
|
|
308
|
+
const [tty, kind, ref] = positionals;
|
|
309
|
+
if (!tty) {
|
|
310
|
+
console.error('usage: voice-pane <tty> voicevox <id> | say <name> | clear');
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
if (!kind || kind === 'clear') {
|
|
314
|
+
updatePaneSetting(tty, { tts: null, speaker: null, voice: null });
|
|
315
|
+
return log(`pane ${tty}: voice reset to default`);
|
|
316
|
+
}
|
|
317
|
+
if (kind === 'voicevox') updatePaneSetting(tty, { tts: 'voicevox', speaker: Number(ref), voice: null });
|
|
318
|
+
else if (kind === 'say') updatePaneSetting(tty, { tts: 'say', voice: ref, speaker: null });
|
|
319
|
+
else {
|
|
320
|
+
console.error(`unknown kind: ${kind}`);
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
log(`pane ${tty}: ${kind} ${ref}`);
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
// Set a specific pane's output volume (0.0–2.0), or `clear` to follow global.
|
|
327
|
+
// volume-pane <tty> <0.0-2.0|clear>
|
|
328
|
+
'volume-pane'() {
|
|
329
|
+
const [tty, arg] = positionals;
|
|
330
|
+
if (!tty || arg === undefined) {
|
|
331
|
+
console.error('usage: volume-pane <tty> <0.0-2.0|clear>');
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
if (arg === 'clear') {
|
|
335
|
+
updatePaneSetting(tty, { volume: null });
|
|
336
|
+
return log(`pane ${tty}: volume reset to global`);
|
|
337
|
+
}
|
|
338
|
+
const v = Math.min(2, Math.max(0, Number(arg)));
|
|
339
|
+
updatePaneSetting(tty, { volume: v });
|
|
340
|
+
log(`pane ${tty}: volume ${v}`);
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
// Machine-readable state for the menu bar agent: mute, volume, the selectable
|
|
344
|
+
// voices, and the recently-active panes (for per-pane assignment). Not human.
|
|
345
|
+
'menu-json'() {
|
|
346
|
+
const config = readConfig();
|
|
347
|
+
const url = config.voicevox?.url || voicevox.DEFAULT_URL;
|
|
348
|
+
const chars = voicevox.isAvailable(url) ? voicevox.listCharacters(url) : [];
|
|
349
|
+
const idName = new Map(chars.map((c) => [c.id, c.name]));
|
|
350
|
+
const voices = [];
|
|
351
|
+
for (const c of chars)
|
|
352
|
+
voices.push({
|
|
353
|
+
section: 'VOICEVOX',
|
|
354
|
+
label: c.name,
|
|
355
|
+
kind: 'voicevox',
|
|
356
|
+
ref: String(c.id),
|
|
357
|
+
currentGlobal: config.tts === 'voicevox' && Number(config.voicevox?.speaker) === c.id,
|
|
358
|
+
});
|
|
359
|
+
for (const n of curatedVoices(10))
|
|
360
|
+
voices.push({
|
|
361
|
+
section: 'System',
|
|
362
|
+
label: n,
|
|
363
|
+
kind: 'say',
|
|
364
|
+
ref: n,
|
|
365
|
+
currentGlobal: config.tts !== 'voicevox' && config.voice === n,
|
|
366
|
+
});
|
|
367
|
+
const labelFor = (pv) => {
|
|
368
|
+
if (!pv) return null;
|
|
369
|
+
return pv.tts === 'voicevox' ? idName.get(Number(pv.speaker)) || `VOICEVOX ${pv.speaker}` : pv.voice || 'system';
|
|
370
|
+
};
|
|
371
|
+
// Panes = live terminals currently running an agent (so they show up before
|
|
372
|
+
// they ever fire a notification) merged with previously-recorded ones.
|
|
373
|
+
const globalVol = readVolume() != null ? readVolume() : typeof config.volume === 'number' ? config.volume : 1;
|
|
374
|
+
const recorded = new Map(readPanes().map((p) => [p.tty, p.label]));
|
|
375
|
+
const ttys = new Set([...livePanes(), ...recorded.keys()]);
|
|
376
|
+
const panes = [...ttys].map((tty) => {
|
|
377
|
+
const s = readPaneSetting(tty);
|
|
378
|
+
return {
|
|
379
|
+
tty,
|
|
380
|
+
label: recorded.get(tty) || tty.replace('/dev/', ''),
|
|
381
|
+
current: labelFor(s.tts ? s : null),
|
|
382
|
+
volume: typeof s.volume === 'number' ? s.volume : globalVol,
|
|
383
|
+
volumeSet: typeof s.volume === 'number',
|
|
384
|
+
};
|
|
385
|
+
});
|
|
386
|
+
log(
|
|
387
|
+
JSON.stringify({
|
|
388
|
+
muted: isMuted(),
|
|
389
|
+
volume: readVolume() != null ? readVolume() : typeof config.volume === 'number' ? config.volume : 1,
|
|
390
|
+
voices,
|
|
391
|
+
panes,
|
|
392
|
+
})
|
|
393
|
+
);
|
|
394
|
+
},
|
|
395
|
+
|
|
182
396
|
// Native menu bar bell (macOS). Self-contained — no Hammerspoon/SwiftBar.
|
|
183
397
|
menubar() {
|
|
184
398
|
const sub = positionals[0] || 'status';
|
|
@@ -190,7 +404,7 @@ const cmds = {
|
|
|
190
404
|
const r = menubar.install();
|
|
191
405
|
log(` ✓ app: ${r.app}`);
|
|
192
406
|
log(` ✓ agent: ${r.plist} (starts at login)`);
|
|
193
|
-
log('A 🔔
|
|
407
|
+
log('A 🔔 is now in your menu bar. Left-click for the menu (volume, voices), right-click to mute.');
|
|
194
408
|
return;
|
|
195
409
|
}
|
|
196
410
|
if (sub === 'uninstall') {
|
|
@@ -244,6 +458,24 @@ const cmds = {
|
|
|
244
458
|
if (!config.translateTo) log('Enable: ai-notify translate on ja');
|
|
245
459
|
},
|
|
246
460
|
|
|
461
|
+
// Diagnose / test the waiting-window highlight. Run it INSIDE the terminal
|
|
462
|
+
// tab you want to test (not piped) so it has a controlling tty.
|
|
463
|
+
highlight() {
|
|
464
|
+
const sub = positionals[0] || 'test';
|
|
465
|
+
if (sub === 'clear') {
|
|
466
|
+
clearHighlight();
|
|
467
|
+
return log('cleared.');
|
|
468
|
+
}
|
|
469
|
+
const color = positionals[1] || readConfig().highlightColor || 'yellow';
|
|
470
|
+
const info = highlightDiagnose(color);
|
|
471
|
+
log(JSON.stringify(info, null, 2));
|
|
472
|
+
log('\nThis tab should now be highlighted. Reset it with: ai-notify highlight clear');
|
|
473
|
+
if (info.appleTerminal && String(info.appleTerminal).startsWith('ERROR')) {
|
|
474
|
+
log('\n→ AppleScript was blocked. Grant permission in:');
|
|
475
|
+
log(' System Settings → Privacy & Security → Automation → (your terminal) → Terminal');
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
|
|
247
479
|
hook() {
|
|
248
480
|
const source = opt('source', 'default');
|
|
249
481
|
let event = opt('event', 'done');
|
|
@@ -263,6 +495,12 @@ const cmds = {
|
|
|
263
495
|
const data = readStdinJson();
|
|
264
496
|
cwd = data.cwd || '';
|
|
265
497
|
message = data.message || '';
|
|
498
|
+
// The Stop hook has no message, so "done" would only say "finished".
|
|
499
|
+
// Pull the agent's last reply from the transcript so the notification
|
|
500
|
+
// says WHAT was done.
|
|
501
|
+
if (!message && event === 'done' && data.transcript_path) {
|
|
502
|
+
message = lastAssistantText(data.transcript_path);
|
|
503
|
+
}
|
|
266
504
|
}
|
|
267
505
|
|
|
268
506
|
const label = deriveLabel(cwd);
|
|
@@ -284,7 +522,9 @@ Usage:
|
|
|
284
522
|
ai-notify init [--dry-run] [--only claude,codex] wire detected agents
|
|
285
523
|
ai-notify uninstall [--only ...] remove wiring
|
|
286
524
|
ai-notify toggle | on | off | status control the mute switch
|
|
525
|
+
ai-notify volume [0.0-2.0] get/set output volume
|
|
287
526
|
ai-notify voice [number|name|preview|default] pick the spoken voice
|
|
527
|
+
ai-notify voicevox [on <id>|off|speakers|test] speak in VOICEVOX character voices
|
|
288
528
|
ai-notify menubar [install|uninstall|status] native menu bar bell (macOS)
|
|
289
529
|
ai-notify translate [on <lang>|off|test] speak agent text in your language
|
|
290
530
|
ai-notify doctor check deps & wiring
|
|
@@ -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/menubar.mjs
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
import { homedir, platform } from 'node:os';
|
|
11
11
|
import { join, dirname } from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
|
-
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
13
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync, chmodSync } from 'node:fs';
|
|
14
14
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
15
|
+
import { cliInvocation } from './util.mjs';
|
|
16
|
+
import { stateDir } from './state.mjs';
|
|
15
17
|
|
|
16
18
|
export const LABEL = 'com.ai-notify.menubar';
|
|
17
19
|
|
|
@@ -81,9 +83,21 @@ const unload = () => {
|
|
|
81
83
|
return r;
|
|
82
84
|
};
|
|
83
85
|
|
|
86
|
+
// A tiny launcher the menu bar app shells out to for data/actions, so it works
|
|
87
|
+
// regardless of PATH (embeds the resolved node + cli path).
|
|
88
|
+
const writeCliWrapper = () => {
|
|
89
|
+
const { node, cliPath } = cliInvocation();
|
|
90
|
+
const p = join(stateDir(), 'cli');
|
|
91
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
92
|
+
writeFileSync(p, `#!/bin/sh\nexec "${node}" "${cliPath}" "$@"\n`);
|
|
93
|
+
chmodSync(p, 0o755);
|
|
94
|
+
return p;
|
|
95
|
+
};
|
|
96
|
+
|
|
84
97
|
export const install = () => {
|
|
85
98
|
if (!isMac()) throw new Error('the menu bar agent is macOS-only');
|
|
86
99
|
if (!isBuilt()) build();
|
|
100
|
+
writeCliWrapper();
|
|
87
101
|
writePlist();
|
|
88
102
|
load();
|
|
89
103
|
return { app: appPath(), plist: plistPath() };
|