opencode-smart-voice-notify 1.2.5 → 1.3.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/LICENSE +21 -21
- package/README.md +178 -25
- package/example.config.jsonc +139 -158
- package/index.js +541 -51
- package/package.json +10 -3
- package/util/ai-messages.js +73 -0
- package/util/config.js +307 -27
- package/util/desktop-notify.js +319 -0
- package/util/focus-detect.js +372 -0
- package/util/per-project-sound.js +90 -0
- package/util/sound-theme.js +129 -0
- package/util/tts.js +26 -8
- package/util/webhook.js +743 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import notifier from 'node-notifier';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Desktop Notification Module for OpenCode Smart Voice Notify
|
|
8
|
+
*
|
|
9
|
+
* Provides cross-platform native desktop notifications using node-notifier.
|
|
10
|
+
* Supports Windows Toast, macOS Notification Center, and Linux notify-send.
|
|
11
|
+
*
|
|
12
|
+
* Platform-specific behaviors:
|
|
13
|
+
* - Windows: Uses SnoreToast for Windows 8+ toast notifications
|
|
14
|
+
* - macOS: Uses terminal-notifier for Notification Center
|
|
15
|
+
* - Linux: Uses notify-send (requires libnotify-bin package)
|
|
16
|
+
*
|
|
17
|
+
* @module util/desktop-notify
|
|
18
|
+
* @see docs/ARCHITECT_PLAN.md - Phase 1, Task 1.2
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Debug logging to file.
|
|
23
|
+
* Only logs when config.debugLog is enabled.
|
|
24
|
+
* Writes to ~/.config/opencode/logs/smart-voice-notify-debug.log
|
|
25
|
+
*
|
|
26
|
+
* @param {string} message - Message to log
|
|
27
|
+
* @param {boolean} enabled - Whether debug logging is enabled
|
|
28
|
+
*/
|
|
29
|
+
const debugLog = (message, enabled = false) => {
|
|
30
|
+
if (!enabled) return;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
|
|
34
|
+
const logsDir = path.join(configDir, 'logs');
|
|
35
|
+
|
|
36
|
+
if (!fs.existsSync(logsDir)) {
|
|
37
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
|
|
41
|
+
const timestamp = new Date().toISOString();
|
|
42
|
+
fs.appendFileSync(logFile, `[${timestamp}] [desktop-notify] ${message}\n`);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
// Silently fail - logging should never break the plugin
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get the current platform identifier.
|
|
50
|
+
* @returns {'darwin' | 'win32' | 'linux'} Platform string
|
|
51
|
+
*/
|
|
52
|
+
export const getPlatform = () => os.platform();
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if desktop notifications are likely to work on this platform.
|
|
56
|
+
*
|
|
57
|
+
* @returns {{ supported: boolean, reason?: string }} Support status and reason if not supported
|
|
58
|
+
*/
|
|
59
|
+
export const checkNotificationSupport = () => {
|
|
60
|
+
const platform = getPlatform();
|
|
61
|
+
|
|
62
|
+
switch (platform) {
|
|
63
|
+
case 'darwin':
|
|
64
|
+
// macOS always supports notifications via terminal-notifier (bundled)
|
|
65
|
+
return { supported: true };
|
|
66
|
+
|
|
67
|
+
case 'win32':
|
|
68
|
+
// Windows 8+ supports toast notifications via SnoreToast (bundled)
|
|
69
|
+
return { supported: true };
|
|
70
|
+
|
|
71
|
+
case 'linux':
|
|
72
|
+
// Linux requires notify-send from libnotify-bin package
|
|
73
|
+
// We don't check for its existence here - node-notifier handles the fallback
|
|
74
|
+
return { supported: true };
|
|
75
|
+
|
|
76
|
+
default:
|
|
77
|
+
return { supported: false, reason: `Unsupported platform: ${platform}` };
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build platform-specific notification options.
|
|
83
|
+
* Normalizes options across different platforms while respecting their unique capabilities.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} title - Notification title
|
|
86
|
+
* @param {string} message - Notification body/message
|
|
87
|
+
* @param {object} options - Additional options
|
|
88
|
+
* @param {number} [options.timeout=5] - Notification timeout in seconds
|
|
89
|
+
* @param {boolean} [options.sound=false] - Whether to play a sound (platform-specific)
|
|
90
|
+
* @param {string} [options.icon] - Absolute path to notification icon
|
|
91
|
+
* @param {string} [options.subtitle] - Subtitle (macOS only)
|
|
92
|
+
* @param {string} [options.urgency] - Urgency level: 'low', 'normal', 'critical' (Linux only)
|
|
93
|
+
* @returns {object} Platform-normalized notification options
|
|
94
|
+
*/
|
|
95
|
+
const buildPlatformOptions = (title, message, options = {}) => {
|
|
96
|
+
const platform = getPlatform();
|
|
97
|
+
const { timeout = 5, sound = false, icon, subtitle, urgency } = options;
|
|
98
|
+
|
|
99
|
+
// Base options common to all platforms
|
|
100
|
+
const baseOptions = {
|
|
101
|
+
title: title || 'OpenCode',
|
|
102
|
+
message: message || '',
|
|
103
|
+
sound: sound,
|
|
104
|
+
wait: false // Don't block - fire and forget
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Add icon if provided and exists
|
|
108
|
+
if (icon && fs.existsSync(icon)) {
|
|
109
|
+
baseOptions.icon = icon;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Platform-specific options
|
|
113
|
+
switch (platform) {
|
|
114
|
+
case 'darwin':
|
|
115
|
+
// macOS Notification Center options
|
|
116
|
+
return {
|
|
117
|
+
...baseOptions,
|
|
118
|
+
timeout: timeout,
|
|
119
|
+
subtitle: subtitle || undefined
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
case 'win32':
|
|
123
|
+
// Windows Toast options
|
|
124
|
+
return {
|
|
125
|
+
...baseOptions,
|
|
126
|
+
// Windows doesn't use timeout the same way - notifications persist until dismissed
|
|
127
|
+
// sound can be true/false or a system sound name
|
|
128
|
+
sound: sound
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
case 'linux':
|
|
132
|
+
// Linux notify-send options
|
|
133
|
+
return {
|
|
134
|
+
...baseOptions,
|
|
135
|
+
timeout: timeout, // Timeout in seconds
|
|
136
|
+
urgency: urgency || 'normal', // low, normal, critical
|
|
137
|
+
'app-name': 'OpenCode Smart Notify'
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
default:
|
|
141
|
+
return baseOptions;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Send a native desktop notification.
|
|
147
|
+
*
|
|
148
|
+
* This is the main function for sending cross-platform desktop notifications.
|
|
149
|
+
* It handles platform-specific options and gracefully fails if notifications
|
|
150
|
+
* are not supported or the notifier encounters an error.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} title - Notification title
|
|
153
|
+
* @param {string} message - Notification body/message
|
|
154
|
+
* @param {object} [options={}] - Notification options
|
|
155
|
+
* @param {number} [options.timeout=5] - Notification timeout in seconds
|
|
156
|
+
* @param {boolean} [options.sound=false] - Whether to play a sound
|
|
157
|
+
* @param {string} [options.icon] - Absolute path to notification icon
|
|
158
|
+
* @param {string} [options.subtitle] - Subtitle (macOS only)
|
|
159
|
+
* @param {string} [options.urgency='normal'] - Urgency level (Linux only)
|
|
160
|
+
* @param {boolean} [options.debugLog=false] - Enable debug logging
|
|
161
|
+
* @returns {Promise<{ success: boolean, error?: string }>} Result object
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* // Simple notification
|
|
165
|
+
* await sendDesktopNotification('Task Complete', 'Your code is ready for review');
|
|
166
|
+
*
|
|
167
|
+
* @example
|
|
168
|
+
* // With options
|
|
169
|
+
* await sendDesktopNotification('Permission Required', 'Agent needs approval', {
|
|
170
|
+
* timeout: 10,
|
|
171
|
+
* urgency: 'critical',
|
|
172
|
+
* sound: true
|
|
173
|
+
* });
|
|
174
|
+
*/
|
|
175
|
+
export const sendDesktopNotification = async (title, message, options = {}) => {
|
|
176
|
+
// Handle null/undefined options gracefully
|
|
177
|
+
const opts = options || {};
|
|
178
|
+
const debug = opts.debugLog || false;
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
// Check platform support
|
|
182
|
+
const support = checkNotificationSupport();
|
|
183
|
+
if (!support.supported) {
|
|
184
|
+
debugLog(`Notification not supported: ${support.reason}`, debug);
|
|
185
|
+
return { success: false, error: support.reason };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Build platform-specific options
|
|
189
|
+
const notifyOptions = buildPlatformOptions(title, message, opts);
|
|
190
|
+
|
|
191
|
+
debugLog(`Sending notification: "${title}" - "${message}" (platform: ${getPlatform()})`, debug);
|
|
192
|
+
|
|
193
|
+
// Send notification using promise wrapper
|
|
194
|
+
return new Promise((resolve) => {
|
|
195
|
+
notifier.notify(notifyOptions, (error, response) => {
|
|
196
|
+
if (error) {
|
|
197
|
+
debugLog(`Notification error: ${error.message}`, debug);
|
|
198
|
+
resolve({ success: false, error: error.message });
|
|
199
|
+
} else {
|
|
200
|
+
debugLog(`Notification sent successfully (response: ${response})`, debug);
|
|
201
|
+
resolve({ success: true });
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
} catch (error) {
|
|
206
|
+
debugLog(`Notification exception: ${error.message}`, debug);
|
|
207
|
+
return { success: false, error: error.message };
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Send a notification for session idle (task completion).
|
|
213
|
+
* Pre-configured for task completion notifications.
|
|
214
|
+
*
|
|
215
|
+
* @param {string} message - Notification message
|
|
216
|
+
* @param {object} [options={}] - Additional options
|
|
217
|
+
* @param {string} [options.projectName] - Project name to include in title
|
|
218
|
+
* @param {boolean} [options.debugLog=false] - Enable debug logging
|
|
219
|
+
* @returns {Promise<{ success: boolean, error?: string }>} Result object
|
|
220
|
+
*/
|
|
221
|
+
export const notifyTaskComplete = async (message, options = {}) => {
|
|
222
|
+
const title = options.projectName
|
|
223
|
+
? `✅ ${options.projectName} - Task Complete`
|
|
224
|
+
: '✅ OpenCode - Task Complete';
|
|
225
|
+
|
|
226
|
+
return sendDesktopNotification(title, message, {
|
|
227
|
+
timeout: 5,
|
|
228
|
+
sound: false, // We handle sound separately in the main plugin
|
|
229
|
+
...options
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Send a notification for permission requests.
|
|
235
|
+
* Pre-configured for permission request notifications (more urgent).
|
|
236
|
+
*
|
|
237
|
+
* @param {string} message - Notification message
|
|
238
|
+
* @param {object} [options={}] - Additional options
|
|
239
|
+
* @param {string} [options.projectName] - Project name to include in title
|
|
240
|
+
* @param {number} [options.count=1] - Number of permission requests
|
|
241
|
+
* @param {boolean} [options.debugLog=false] - Enable debug logging
|
|
242
|
+
* @returns {Promise<{ success: boolean, error?: string }>} Result object
|
|
243
|
+
*/
|
|
244
|
+
export const notifyPermissionRequest = async (message, options = {}) => {
|
|
245
|
+
const count = options.count || 1;
|
|
246
|
+
const title = options.projectName
|
|
247
|
+
? `⚠️ ${options.projectName} - Permission Required`
|
|
248
|
+
: count > 1
|
|
249
|
+
? `⚠️ ${count} Permissions Required`
|
|
250
|
+
: '⚠️ OpenCode - Permission Required';
|
|
251
|
+
|
|
252
|
+
return sendDesktopNotification(title, message, {
|
|
253
|
+
timeout: 10, // Longer timeout for permissions
|
|
254
|
+
urgency: 'critical', // Higher urgency on Linux
|
|
255
|
+
sound: false, // We handle sound separately
|
|
256
|
+
...options
|
|
257
|
+
});
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Send a notification for question requests (SDK v1.1.7+).
|
|
262
|
+
* Pre-configured for question notifications.
|
|
263
|
+
*
|
|
264
|
+
* @param {string} message - Notification message
|
|
265
|
+
* @param {object} [options={}] - Additional options
|
|
266
|
+
* @param {string} [options.projectName] - Project name to include in title
|
|
267
|
+
* @param {number} [options.count=1] - Number of questions
|
|
268
|
+
* @param {boolean} [options.debugLog=false] - Enable debug logging
|
|
269
|
+
* @returns {Promise<{ success: boolean, error?: string }>} Result object
|
|
270
|
+
*/
|
|
271
|
+
export const notifyQuestion = async (message, options = {}) => {
|
|
272
|
+
const count = options.count || 1;
|
|
273
|
+
const title = options.projectName
|
|
274
|
+
? `❓ ${options.projectName} - Question`
|
|
275
|
+
: count > 1
|
|
276
|
+
? `❓ ${count} Questions Need Your Input`
|
|
277
|
+
: '❓ OpenCode - Question';
|
|
278
|
+
|
|
279
|
+
return sendDesktopNotification(title, message, {
|
|
280
|
+
timeout: 8,
|
|
281
|
+
urgency: 'normal',
|
|
282
|
+
sound: false, // We handle sound separately
|
|
283
|
+
...options
|
|
284
|
+
});
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Send a notification for error events.
|
|
289
|
+
* Pre-configured for error notifications (most urgent).
|
|
290
|
+
*
|
|
291
|
+
* @param {string} message - Notification message
|
|
292
|
+
* @param {object} [options={}] - Additional options
|
|
293
|
+
* @param {string} [options.projectName] - Project name to include in title
|
|
294
|
+
* @param {boolean} [options.debugLog=false] - Enable debug logging
|
|
295
|
+
* @returns {Promise<{ success: boolean, error?: string }>} Result object
|
|
296
|
+
*/
|
|
297
|
+
export const notifyError = async (message, options = {}) => {
|
|
298
|
+
const title = options.projectName
|
|
299
|
+
? `❌ ${options.projectName} - Error`
|
|
300
|
+
: '❌ OpenCode - Error';
|
|
301
|
+
|
|
302
|
+
return sendDesktopNotification(title, message, {
|
|
303
|
+
timeout: 15, // Longer timeout for errors
|
|
304
|
+
urgency: 'critical',
|
|
305
|
+
sound: false, // We handle sound separately
|
|
306
|
+
...options
|
|
307
|
+
});
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// Default export for convenience
|
|
311
|
+
export default {
|
|
312
|
+
sendDesktopNotification,
|
|
313
|
+
notifyTaskComplete,
|
|
314
|
+
notifyPermissionRequest,
|
|
315
|
+
notifyQuestion,
|
|
316
|
+
notifyError,
|
|
317
|
+
checkNotificationSupport,
|
|
318
|
+
getPlatform
|
|
319
|
+
};
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { exec } from 'child_process';
|
|
5
|
+
import { promisify } from 'util';
|
|
6
|
+
import detectTerminal from 'detect-terminal';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Focus Detection Module for OpenCode Smart Voice Notify
|
|
10
|
+
*
|
|
11
|
+
* Detects whether the user is currently looking at the OpenCode terminal.
|
|
12
|
+
* Used to suppress notifications when the user is already focused on the terminal.
|
|
13
|
+
*
|
|
14
|
+
* Platform support:
|
|
15
|
+
* - macOS: Full support using AppleScript to check frontmost app
|
|
16
|
+
* - Windows: Not supported (returns false - no reliable API)
|
|
17
|
+
* - Linux: Not supported (returns false - varies by desktop environment)
|
|
18
|
+
*
|
|
19
|
+
* @module util/focus-detect
|
|
20
|
+
* @see docs/ARCHITECT_PLAN.md - Phase 3, Task 3.2
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const execAsync = promisify(exec);
|
|
24
|
+
|
|
25
|
+
// ========================================
|
|
26
|
+
// CACHING CONFIGURATION
|
|
27
|
+
// ========================================
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Cache for focus detection results.
|
|
31
|
+
* Prevents excessive system calls (AppleScript execution).
|
|
32
|
+
*/
|
|
33
|
+
let focusCache = {
|
|
34
|
+
isFocused: false,
|
|
35
|
+
timestamp: 0,
|
|
36
|
+
terminalName: null
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Cache TTL in milliseconds.
|
|
41
|
+
* Focus detection results are cached for this duration.
|
|
42
|
+
* 500ms provides a good balance between responsiveness and performance.
|
|
43
|
+
*/
|
|
44
|
+
const CACHE_TTL_MS = 500;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* List of known terminal application names for macOS.
|
|
48
|
+
* These are matched against the frontmost application name.
|
|
49
|
+
* The detect-terminal package helps identify which terminal is in use.
|
|
50
|
+
*/
|
|
51
|
+
export const KNOWN_TERMINALS_MACOS = [
|
|
52
|
+
'Terminal',
|
|
53
|
+
'iTerm',
|
|
54
|
+
'iTerm2',
|
|
55
|
+
'Hyper',
|
|
56
|
+
'Alacritty',
|
|
57
|
+
'kitty',
|
|
58
|
+
'WezTerm',
|
|
59
|
+
'Tabby',
|
|
60
|
+
'Warp',
|
|
61
|
+
'Rio',
|
|
62
|
+
'Ghostty',
|
|
63
|
+
// VS Code and other IDEs with integrated terminals
|
|
64
|
+
'Code',
|
|
65
|
+
'Visual Studio Code',
|
|
66
|
+
'VSCodium',
|
|
67
|
+
'Cursor',
|
|
68
|
+
'Windsurf',
|
|
69
|
+
'Zed',
|
|
70
|
+
// JetBrains IDEs
|
|
71
|
+
'IntelliJ IDEA',
|
|
72
|
+
'WebStorm',
|
|
73
|
+
'PyCharm',
|
|
74
|
+
'PhpStorm',
|
|
75
|
+
'GoLand',
|
|
76
|
+
'RubyMine',
|
|
77
|
+
'CLion',
|
|
78
|
+
'DataGrip',
|
|
79
|
+
'Rider',
|
|
80
|
+
'Android Studio'
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
// ========================================
|
|
84
|
+
// DEBUG LOGGING
|
|
85
|
+
// ========================================
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Debug logging to file.
|
|
89
|
+
* Only logs when enabled.
|
|
90
|
+
* Writes to ~/.config/opencode/logs/smart-voice-notify-debug.log
|
|
91
|
+
*
|
|
92
|
+
* @param {string} message - Message to log
|
|
93
|
+
* @param {boolean} enabled - Whether debug logging is enabled
|
|
94
|
+
*/
|
|
95
|
+
const debugLog = (message, enabled = false) => {
|
|
96
|
+
if (!enabled) return;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
|
|
100
|
+
const logsDir = path.join(configDir, 'logs');
|
|
101
|
+
|
|
102
|
+
if (!fs.existsSync(logsDir)) {
|
|
103
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
|
|
107
|
+
const timestamp = new Date().toISOString();
|
|
108
|
+
fs.appendFileSync(logFile, `[${timestamp}] [focus-detect] ${message}\n`);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
// Silently fail - logging should never break the plugin
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// ========================================
|
|
115
|
+
// PLATFORM DETECTION
|
|
116
|
+
// ========================================
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get the current platform identifier.
|
|
120
|
+
* @returns {'darwin' | 'win32' | 'linux'} Platform string
|
|
121
|
+
*/
|
|
122
|
+
export const getPlatform = () => os.platform();
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Check if focus detection is supported on this platform.
|
|
126
|
+
*
|
|
127
|
+
* @returns {{ supported: boolean, reason?: string }} Support status
|
|
128
|
+
*/
|
|
129
|
+
export const isFocusDetectionSupported = () => {
|
|
130
|
+
const platform = getPlatform();
|
|
131
|
+
|
|
132
|
+
switch (platform) {
|
|
133
|
+
case 'darwin':
|
|
134
|
+
return { supported: true };
|
|
135
|
+
case 'win32':
|
|
136
|
+
return { supported: false, reason: 'Windows focus detection not supported - no reliable API' };
|
|
137
|
+
case 'linux':
|
|
138
|
+
return { supported: false, reason: 'Linux focus detection not supported - varies by desktop environment' };
|
|
139
|
+
default:
|
|
140
|
+
return { supported: false, reason: `Unsupported platform: ${platform}` };
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// ========================================
|
|
145
|
+
// TERMINAL DETECTION
|
|
146
|
+
// ========================================
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Detect the current terminal emulator using detect-terminal package.
|
|
150
|
+
* Caches the result since the terminal doesn't change during execution.
|
|
151
|
+
*
|
|
152
|
+
* @param {boolean} debug - Enable debug logging
|
|
153
|
+
* @returns {string | null} Terminal name or null if not detected
|
|
154
|
+
*/
|
|
155
|
+
let cachedTerminalName = null;
|
|
156
|
+
let terminalDetectionAttempted = false;
|
|
157
|
+
|
|
158
|
+
export const getTerminalName = (debug = false) => {
|
|
159
|
+
// Return cached result if already detected
|
|
160
|
+
if (terminalDetectionAttempted) {
|
|
161
|
+
return cachedTerminalName;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
terminalDetectionAttempted = true;
|
|
166
|
+
// Prefer the outer terminal (GUI app) over multiplexers like tmux/screen
|
|
167
|
+
const terminal = detectTerminal({ preferOuter: true });
|
|
168
|
+
cachedTerminalName = terminal || null;
|
|
169
|
+
debugLog(`Detected terminal: ${cachedTerminalName}`, debug);
|
|
170
|
+
return cachedTerminalName;
|
|
171
|
+
} catch (e) {
|
|
172
|
+
debugLog(`Terminal detection failed: ${e.message}`, debug);
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// ========================================
|
|
178
|
+
// FOCUS DETECTION - macOS
|
|
179
|
+
// ========================================
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* AppleScript to get the frontmost application name.
|
|
183
|
+
* Uses System Events to determine which app is currently focused.
|
|
184
|
+
*/
|
|
185
|
+
const APPLESCRIPT_GET_FRONTMOST = `
|
|
186
|
+
tell application "System Events"
|
|
187
|
+
set frontApp to first application process whose frontmost is true
|
|
188
|
+
return name of frontApp
|
|
189
|
+
end tell
|
|
190
|
+
`;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Get the name of the frontmost application on macOS.
|
|
194
|
+
*
|
|
195
|
+
* @param {boolean} debug - Enable debug logging
|
|
196
|
+
* @returns {Promise<string | null>} Frontmost app name or null on error
|
|
197
|
+
*/
|
|
198
|
+
const getFrontmostAppMacOS = async (debug = false) => {
|
|
199
|
+
try {
|
|
200
|
+
const { stdout } = await execAsync(`osascript -e '${APPLESCRIPT_GET_FRONTMOST}'`, {
|
|
201
|
+
timeout: 2000, // 2 second timeout
|
|
202
|
+
maxBuffer: 1024 // Small buffer - we only expect app name
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const appName = stdout.trim();
|
|
206
|
+
debugLog(`Frontmost app: "${appName}"`, debug);
|
|
207
|
+
return appName;
|
|
208
|
+
} catch (e) {
|
|
209
|
+
debugLog(`Failed to get frontmost app: ${e.message}`, debug);
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Check if the frontmost app is a known terminal on macOS.
|
|
216
|
+
*
|
|
217
|
+
* @param {string} appName - The frontmost application name
|
|
218
|
+
* @param {boolean} debug - Enable debug logging
|
|
219
|
+
* @returns {boolean} True if the app is a known terminal
|
|
220
|
+
*/
|
|
221
|
+
const isKnownTerminal = (appName, debug = false) => {
|
|
222
|
+
if (!appName) return false;
|
|
223
|
+
|
|
224
|
+
// Direct match
|
|
225
|
+
if (KNOWN_TERMINALS_MACOS.some(t => t.toLowerCase() === appName.toLowerCase())) {
|
|
226
|
+
debugLog(`"${appName}" is a known terminal (direct match)`, debug);
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Partial match (for apps like "iTerm2" matching "iTerm")
|
|
231
|
+
if (KNOWN_TERMINALS_MACOS.some(t => appName.toLowerCase().includes(t.toLowerCase()))) {
|
|
232
|
+
debugLog(`"${appName}" is a known terminal (partial match)`, debug);
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Check if the detected terminal from detect-terminal matches
|
|
237
|
+
const detectedTerminal = getTerminalName(debug);
|
|
238
|
+
if (detectedTerminal && appName.toLowerCase().includes(detectedTerminal.toLowerCase())) {
|
|
239
|
+
debugLog(`"${appName}" matches detected terminal "${detectedTerminal}"`, debug);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
debugLog(`"${appName}" is NOT a known terminal`, debug);
|
|
244
|
+
return false;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// ========================================
|
|
248
|
+
// MAIN FOCUS DETECTION FUNCTION
|
|
249
|
+
// ========================================
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Check if the OpenCode terminal is currently focused.
|
|
253
|
+
*
|
|
254
|
+
* This function detects whether the user is currently looking at the terminal
|
|
255
|
+
* where OpenCode is running. Used to suppress notifications when the user
|
|
256
|
+
* is already paying attention to the terminal.
|
|
257
|
+
*
|
|
258
|
+
* Platform behavior:
|
|
259
|
+
* - macOS: Uses AppleScript to check the frontmost application
|
|
260
|
+
* - Windows: Always returns false (not supported)
|
|
261
|
+
* - Linux: Always returns false (not supported)
|
|
262
|
+
*
|
|
263
|
+
* Results are cached for 500ms to avoid excessive system calls.
|
|
264
|
+
*
|
|
265
|
+
* @param {object} [options={}] - Options
|
|
266
|
+
* @param {boolean} [options.debugLog=false] - Enable debug logging
|
|
267
|
+
* @returns {Promise<boolean>} True if terminal is focused, false otherwise
|
|
268
|
+
*
|
|
269
|
+
* @example
|
|
270
|
+
* const focused = await isTerminalFocused({ debugLog: true });
|
|
271
|
+
* if (focused) {
|
|
272
|
+
* console.log('User is looking at the terminal - skip notification');
|
|
273
|
+
* }
|
|
274
|
+
*/
|
|
275
|
+
export const isTerminalFocused = async (options = {}) => {
|
|
276
|
+
const debug = options?.debugLog || false;
|
|
277
|
+
const now = Date.now();
|
|
278
|
+
|
|
279
|
+
// Check cache first
|
|
280
|
+
if (now - focusCache.timestamp < CACHE_TTL_MS) {
|
|
281
|
+
debugLog(`Using cached focus result: ${focusCache.isFocused}`, debug);
|
|
282
|
+
return focusCache.isFocused;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const platform = getPlatform();
|
|
286
|
+
|
|
287
|
+
// Platform-specific implementation
|
|
288
|
+
if (platform === 'darwin') {
|
|
289
|
+
try {
|
|
290
|
+
const frontmostApp = await getFrontmostAppMacOS(debug);
|
|
291
|
+
const isFocused = isKnownTerminal(frontmostApp, debug);
|
|
292
|
+
|
|
293
|
+
// Update cache
|
|
294
|
+
focusCache = {
|
|
295
|
+
isFocused,
|
|
296
|
+
timestamp: now,
|
|
297
|
+
terminalName: frontmostApp
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
debugLog(`Focus detection complete: ${isFocused} (frontmost: "${frontmostApp}")`, debug);
|
|
301
|
+
return isFocused;
|
|
302
|
+
} catch (e) {
|
|
303
|
+
debugLog(`Focus detection error: ${e.message}`, debug);
|
|
304
|
+
// On error, assume not focused (fail open - still notify)
|
|
305
|
+
focusCache = {
|
|
306
|
+
isFocused: false,
|
|
307
|
+
timestamp: now,
|
|
308
|
+
terminalName: null
|
|
309
|
+
};
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Windows and Linux: Not supported
|
|
315
|
+
if (platform === 'win32') {
|
|
316
|
+
debugLog('Focus detection not supported on Windows', debug);
|
|
317
|
+
} else if (platform === 'linux') {
|
|
318
|
+
debugLog('Focus detection not supported on Linux', debug);
|
|
319
|
+
} else {
|
|
320
|
+
debugLog(`Focus detection not supported on platform: ${platform}`, debug);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Cache the result even for unsupported platforms
|
|
324
|
+
focusCache = {
|
|
325
|
+
isFocused: false,
|
|
326
|
+
timestamp: now,
|
|
327
|
+
terminalName: null
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
return false;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Clear the focus detection cache.
|
|
335
|
+
* Useful for testing or when forcing a fresh check.
|
|
336
|
+
*/
|
|
337
|
+
export const clearFocusCache = () => {
|
|
338
|
+
focusCache = {
|
|
339
|
+
isFocused: false,
|
|
340
|
+
timestamp: 0,
|
|
341
|
+
terminalName: null
|
|
342
|
+
};
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Reset the terminal detection cache.
|
|
347
|
+
* Useful for testing.
|
|
348
|
+
*/
|
|
349
|
+
export const resetTerminalDetection = () => {
|
|
350
|
+
cachedTerminalName = null;
|
|
351
|
+
terminalDetectionAttempted = false;
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Get the current cache state.
|
|
356
|
+
* Useful for testing and debugging.
|
|
357
|
+
*
|
|
358
|
+
* @returns {{ isFocused: boolean, timestamp: number, terminalName: string | null }} Cache state
|
|
359
|
+
*/
|
|
360
|
+
export const getCacheState = () => ({ ...focusCache });
|
|
361
|
+
|
|
362
|
+
// Default export for convenience
|
|
363
|
+
export default {
|
|
364
|
+
isTerminalFocused,
|
|
365
|
+
isFocusDetectionSupported,
|
|
366
|
+
getTerminalName,
|
|
367
|
+
getPlatform,
|
|
368
|
+
clearFocusCache,
|
|
369
|
+
resetTerminalDetection,
|
|
370
|
+
getCacheState,
|
|
371
|
+
KNOWN_TERMINALS_MACOS
|
|
372
|
+
};
|