opencode-smart-voice-notify 1.3.0 → 1.3.2
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 +585 -460
- package/example.config.jsonc +369 -369
- package/index.js +1511 -1468
- package/package.json +2 -2
- package/util/ai-messages.js +278 -278
- package/util/config.js +1058 -1058
- package/util/desktop-notify.js +319 -319
- package/util/focus-detect.js +372 -372
- package/util/per-project-sound.js +90 -90
- package/util/sound-theme.js +120 -120
- package/util/tts.js +720 -684
- package/util/webhook.js +743 -743
package/util/focus-detect.js
CHANGED
|
@@ -1,372 +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
|
-
};
|
|
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
|
+
};
|