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.
@@ -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
+ };