Skip to main content

vtcode_core/notifications/
mod.rs

1//! Push notification system for VT Code terminal clients
2//! Handles important events like command failures, errors, policy approval requests,
3//! human in the loop interactions, completion and requests.
4
5use anyhow::Result;
6use parking_lot::{Mutex, RwLock};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9#[cfg(target_os = "macos")]
10use std::io::Write;
11#[cfg(target_os = "macos")]
12use std::process::{Command, Stdio};
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::sync::{Arc, OnceLock};
15use std::time::{Duration, Instant};
16
17use crate::config::loader::VTCodeConfig;
18use crate::hooks::{LifecycleHookEngine, NotificationHookType};
19use vtcode_config::{
20    NotificationBackend, NotificationCondition, NotificationDeliveryMode, TerminalNotificationMethod,
21    TuiNotificationEvent, TuiNotificationsConfig,
22};
23
24/// Types of important events that trigger notifications
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub enum NotificationEvent {
27    /// Generic ad-hoc notification
28    Custom { title: String, message: String },
29    /// Command execution failed
30    CommandFailure {
31        command: String,
32        error: String,
33        exit_code: Option<i32>,
34    },
35    /// Tool execution failed
36    ToolFailure {
37        tool_name: String,
38        error: String,
39        details: Option<String>,
40    },
41    /// Tool execution succeeded
42    ToolSuccess { tool_name: String, details: Option<String> },
43    /// General error occurred
44    Error { message: String, context: Option<String> },
45    /// Policy approval required for action
46    PolicyApprovalRequest { action: String, details: String },
47    /// Human in the loop interaction required
48    HumanInTheLoop { prompt: String, context: String },
49    /// Approval or elicitation prompt that should surface as a permission request
50    PermissionPrompt { title: String, message: String },
51    /// VT Code has been waiting for user input long enough to notify
52    IdlePrompt { title: String, message: String },
53    /// Task or operation completed
54    Completion {
55        task: String,
56        status: CompletionStatus,
57        details: Option<String>,
58    },
59    /// Request received
60    Request { request_type: String, details: String },
61}
62
63/// Status of a completed task
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65pub enum CompletionStatus {
66    Success,
67    PartialSuccess,
68    Failure,
69    Cancelled,
70}
71
72/// Notification configuration
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct NotificationConfig {
75    /// Enable command failure notifications
76    pub command_failure_notifications: bool,
77    /// Enable tool failure notifications
78    pub tool_failure_notifications: bool,
79    /// Enable error notifications
80    pub error_notifications: bool,
81    /// Enable policy approval request notifications
82    pub policy_approval_notifications: bool,
83    /// Enable human in the loop notifications
84    pub hitl_notifications: bool,
85    /// Enable completion notifications for successful turns/tasks
86    pub completion_success_notifications: bool,
87    /// Enable completion notifications for partial/failure/cancelled turns/tasks
88    pub completion_failure_notifications: bool,
89    /// Enable request notifications
90    pub request_notifications: bool,
91    /// Enable tool success notifications
92    pub tool_success_notifications: bool,
93    /// Enable/disable all terminal notifications (overrides other settings)
94    pub terminal_notifications_enabled: bool,
95    /// Suppress notifications while terminal is focused.
96    pub suppress_when_focused: bool,
97    /// Delivery mode for notifications.
98    pub delivery_mode: NotificationDeliveryMode,
99    /// Preferred backend for desktop notification delivery.
100    pub backend: NotificationBackend,
101    /// Preferred terminal notification transport.
102    pub notification_method: TerminalNotificationMethod,
103    /// When to deliver notifications relative to terminal focus.
104    /// Defaults to `Unfocused` (only when terminal is not focused).
105    pub notification_condition: NotificationCondition,
106    /// Time window for suppressing repeated identical notifications.
107    pub repeat_window_seconds: u64,
108    /// Maximum identical notifications allowed per suppression window.
109    pub max_identical_notifications_in_window: u32,
110}
111
112impl Default for NotificationConfig {
113    fn default() -> Self {
114        Self {
115            command_failure_notifications: false,
116            tool_failure_notifications: false,
117            error_notifications: true,
118            policy_approval_notifications: true,
119            hitl_notifications: true,
120            completion_success_notifications: false,
121            completion_failure_notifications: true,
122            request_notifications: false,
123            tool_success_notifications: false,
124            terminal_notifications_enabled: true,
125            suppress_when_focused: true,
126            delivery_mode: NotificationDeliveryMode::Desktop,
127            backend: NotificationBackend::Auto,
128            notification_method: TerminalNotificationMethod::Auto,
129            notification_condition: NotificationCondition::default(),
130            repeat_window_seconds: 30,
131            max_identical_notifications_in_window: 1,
132        }
133    }
134}
135
136impl NotificationConfig {
137    /// Build runtime notification config from full VTCodeConfig.
138    pub fn from_vtcode_config(config: &VTCodeConfig) -> Self {
139        let notifications = &config.ui.notifications;
140        let mut resolved = Self {
141            command_failure_notifications: notifications.command_failure.unwrap_or(notifications.tool_failure),
142            tool_failure_notifications: notifications.tool_failure,
143            error_notifications: notifications.error,
144            policy_approval_notifications: notifications.policy_approval.unwrap_or(notifications.hitl),
145            hitl_notifications: notifications.hitl,
146            completion_success_notifications: notifications.completion_success.unwrap_or(notifications.completion),
147            completion_failure_notifications: notifications.completion_failure.unwrap_or(notifications.completion),
148            request_notifications: notifications.request.unwrap_or(notifications.hitl),
149            tool_success_notifications: notifications.tool_success,
150            terminal_notifications_enabled: notifications.enabled,
151            suppress_when_focused: notifications.suppress_when_focused,
152            delivery_mode: notifications.delivery_mode,
153            backend: notifications.backend,
154            notification_method: config.tui.notification_method.unwrap_or_default(),
155            notification_condition: config.tui.notification_condition.unwrap_or_default(),
156            repeat_window_seconds: notifications.repeat_window_seconds,
157            max_identical_notifications_in_window: notifications.max_identical_in_window,
158        };
159
160        if let Some(tui_notifications) = &config.tui.notifications {
161            match tui_notifications {
162                TuiNotificationsConfig::Enabled(enabled) => {
163                    resolved.terminal_notifications_enabled = *enabled;
164                }
165                TuiNotificationsConfig::Events(events) => {
166                    let turn_complete = events.contains(&TuiNotificationEvent::AgentTurnComplete);
167                    let approval_requested = events.contains(&TuiNotificationEvent::ApprovalRequested);
168                    resolved.terminal_notifications_enabled = true;
169                    resolved.command_failure_notifications = false;
170                    resolved.tool_failure_notifications = false;
171                    resolved.error_notifications = false;
172                    resolved.tool_success_notifications = false;
173                    resolved.completion_success_notifications = turn_complete;
174                    resolved.completion_failure_notifications = turn_complete;
175                    resolved.policy_approval_notifications = approval_requested;
176                    resolved.hitl_notifications = approval_requested;
177                    resolved.request_notifications = approval_requested;
178                }
179            }
180        }
181
182        resolved
183    }
184}
185
186#[derive(Debug)]
187struct RepeatEntry {
188    window_start: Instant,
189    sent_in_window: u32,
190}
191
192impl RepeatEntry {
193    fn new(now: Instant) -> Self {
194        Self { window_start: now, sent_in_window: 0 }
195    }
196}
197
198#[derive(Debug, Default)]
199struct RepeatSuppressionState {
200    entries: HashMap<String, RepeatEntry>,
201}
202
203#[derive(Debug)]
204enum RepeatDecision {
205    Deliver,
206    Suppress,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210enum DesktopNotificationBackend {
211    #[cfg(target_os = "macos")]
212    Osascript,
213    NotifyRust,
214}
215
216const AUTO_DESKTOP_NOTIFICATION_BACKENDS: &[DesktopNotificationBackend] = &[DesktopNotificationBackend::NotifyRust];
217#[cfg(target_os = "macos")]
218const OSASCRIPT_DESKTOP_NOTIFICATION_BACKENDS: &[DesktopNotificationBackend] = &[DesktopNotificationBackend::Osascript];
219const NOTIFY_RUST_DESKTOP_NOTIFICATION_BACKENDS: &[DesktopNotificationBackend] =
220    &[DesktopNotificationBackend::NotifyRust];
221const NO_DESKTOP_NOTIFICATION_BACKENDS: &[DesktopNotificationBackend] = &[];
222
223/// Notification manager that handles sending notifications
224pub struct NotificationManager {
225    config: Arc<RwLock<NotificationConfig>>,
226    /// Track if the terminal is currently focused/active
227    terminal_focused: Arc<AtomicBool>,
228    repeat_state: Arc<Mutex<RepeatSuppressionState>>,
229}
230
231impl NotificationManager {
232    /// Create a new notification manager with default configuration
233    pub fn new() -> Self {
234        Self {
235            config: Arc::new(RwLock::new(NotificationConfig::default())),
236            terminal_focused: Arc::new(AtomicBool::new(false)), // Start as not focused
237            repeat_state: Arc::new(Mutex::new(RepeatSuppressionState::default())),
238        }
239    }
240
241    /// Create a new notification manager with custom configuration
242    pub fn with_config(config: NotificationConfig) -> Self {
243        Self {
244            config: Arc::new(RwLock::new(config)),
245            terminal_focused: Arc::new(AtomicBool::new(false)), // Start as not focused
246            repeat_state: Arc::new(Mutex::new(RepeatSuppressionState::default())),
247        }
248    }
249
250    /// Send a notification for an event
251    pub async fn send_notification(&self, event: NotificationEvent) -> Result<()> {
252        let config = self.config.read().clone();
253
254        // Check if terminal notifications are enabled globally first
255        if !config.terminal_notifications_enabled {
256            return Ok(());
257        }
258
259        // Evaluate notification condition based on configuration
260        // `Unfocused` (default): only deliver when terminal is not focused
261        // `Always`: deliver regardless of focus state
262        let is_terminal_active = self.terminal_focused.load(Ordering::Relaxed);
263        let should_suppress_for_focus = match config.notification_condition {
264            NotificationCondition::Unfocused => is_terminal_active && config.suppress_when_focused,
265            NotificationCondition::Always | NotificationCondition::Unknown => false,
266        };
267        if should_suppress_for_focus {
268            return Ok(());
269        }
270
271        if !self.event_enabled(&event, &config) {
272            return Ok(());
273        }
274
275        match self.repeat_decision(&event, &config) {
276            RepeatDecision::Deliver => {
277                self.send_notification_impl(&event, &config).await?;
278                self.run_notification_hook_if_configured(&event).await;
279            }
280            RepeatDecision::Suppress => {
281                return Ok(());
282            }
283        }
284
285        Ok(())
286    }
287
288    fn event_enabled(&self, event: &NotificationEvent, config: &NotificationConfig) -> bool {
289        match event {
290            NotificationEvent::Custom { .. } => true,
291            NotificationEvent::CommandFailure { .. } => config.command_failure_notifications,
292            NotificationEvent::ToolFailure { .. } => config.tool_failure_notifications,
293            NotificationEvent::ToolSuccess { .. } => config.tool_success_notifications,
294            NotificationEvent::Error { .. } => config.error_notifications,
295            NotificationEvent::PolicyApprovalRequest { .. } => config.policy_approval_notifications,
296            NotificationEvent::HumanInTheLoop { .. } => config.hitl_notifications,
297            NotificationEvent::PermissionPrompt { .. } => {
298                config.policy_approval_notifications || config.hitl_notifications
299            }
300            NotificationEvent::IdlePrompt { .. } => config.request_notifications,
301            NotificationEvent::Completion { status, .. } => match status {
302                CompletionStatus::Success => config.completion_success_notifications,
303                CompletionStatus::PartialSuccess | CompletionStatus::Failure | CompletionStatus::Cancelled => {
304                    config.completion_failure_notifications
305                }
306            },
307            NotificationEvent::Request { .. } => config.request_notifications,
308        }
309    }
310
311    fn repeat_decision(&self, event: &NotificationEvent, config: &NotificationConfig) -> RepeatDecision {
312        if config.repeat_window_seconds == 0 {
313            return RepeatDecision::Deliver;
314        }
315
316        let Some(fingerprint) = self.repeat_fingerprint(event) else {
317            return RepeatDecision::Deliver;
318        };
319
320        let window = Duration::from_secs(config.repeat_window_seconds.max(1));
321        let max_allowed = config.max_identical_notifications_in_window.max(1);
322        let now = Instant::now();
323
324        let mut state = self.repeat_state.lock();
325
326        if state.entries.len() > 1024 {
327            state.entries.retain(|_, entry| now.duration_since(entry.window_start) < window);
328        }
329
330        let entry = state.entries.entry(fingerprint).or_insert_with(|| RepeatEntry::new(now));
331
332        if now.duration_since(entry.window_start) >= window {
333            *entry = RepeatEntry::new(now);
334        }
335
336        if entry.sent_in_window < max_allowed {
337            entry.sent_in_window += 1;
338            RepeatDecision::Deliver
339        } else {
340            RepeatDecision::Suppress
341        }
342    }
343
344    /// Internal method to send the actual notification
345    async fn send_notification_impl(&self, event: &NotificationEvent, config: &NotificationConfig) -> Result<()> {
346        let message = self.format_notification_message(event);
347        self.send_message(&message, config).await
348    }
349
350    async fn run_notification_hook_if_configured(&self, event: &NotificationEvent) {
351        let Some((notification_type, title, message)) = self.notification_hook_payload(event) else {
352            return;
353        };
354        let Some(engine) = get_global_notification_hook_engine() else {
355            return;
356        };
357
358        if let Err(error) = engine
359            .run_notification(notification_type, title.as_str(), message.as_str())
360            .await
361        {
362            tracing::warn!(
363                error = %error,
364                notification_type = notification_type.as_str(),
365                "Failed to run notification lifecycle hook"
366            );
367        }
368    }
369
370    async fn send_message(&self, message: &str, config: &NotificationConfig) -> Result<()> {
371        match config.delivery_mode {
372            NotificationDeliveryMode::Terminal | NotificationDeliveryMode::Unknown => {
373                self.send_terminal_bell(message).await;
374            }
375            NotificationDeliveryMode::Hybrid => {
376                self.send_terminal_bell(message).await;
377                if config.backend != NotificationBackend::Terminal {
378                    let _ = self.send_desktop_notification(message, config).await;
379                }
380            }
381            NotificationDeliveryMode::Desktop => {
382                if config.backend == NotificationBackend::Terminal {
383                    self.send_terminal_bell(message).await;
384                } else {
385                    let _ = self.send_desktop_notification(message, config).await;
386                }
387            }
388        }
389
390        Ok(())
391    }
392
393    fn repeat_fingerprint(&self, event: &NotificationEvent) -> Option<String> {
394        let event_type = match event {
395            NotificationEvent::Custom { .. } => "custom",
396            NotificationEvent::CommandFailure { .. } => "command_failure",
397            NotificationEvent::ToolFailure { .. } => "tool_failure",
398            NotificationEvent::ToolSuccess { .. } => "tool_success",
399            NotificationEvent::Error { .. } => "error",
400            NotificationEvent::Completion { .. } => "completion",
401            NotificationEvent::IdlePrompt { .. } => "idle_prompt",
402            NotificationEvent::Request { .. } => "request",
403            NotificationEvent::PolicyApprovalRequest { .. }
404            | NotificationEvent::HumanInTheLoop { .. }
405            | NotificationEvent::PermissionPrompt { .. } => {
406                return None;
407            }
408        };
409
410        let normalized_message = self.normalize_message(&self.format_notification_message(event));
411        Some(format!("{event_type}:{normalized_message}"))
412    }
413
414    fn normalize_message(&self, message: &str) -> String {
415        message.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase()
416    }
417
418    /// Format a notification message based on the event
419    fn format_notification_message(&self, event: &NotificationEvent) -> String {
420        match event {
421            NotificationEvent::Custom { title, message } => {
422                let title = title.trim();
423                if title.is_empty() {
424                    message.clone()
425                } else {
426                    format!("{title}: {message}")
427                }
428            }
429            NotificationEvent::CommandFailure { command, error, exit_code } => {
430                let exit_code_str = exit_code.map(|code| format!(" (exit code: {code})")).unwrap_or_default();
431                format!("Command failed: {command}{exit_code_str} - Error: {error}")
432            }
433            NotificationEvent::ToolFailure { tool_name, error, details } => {
434                let details_str = details.as_ref().map(|d| format!(" - Details: {d}")).unwrap_or_default();
435                format!("Tool '{tool_name}' failed: {error}{details_str}")
436            }
437            NotificationEvent::ToolSuccess { tool_name, details } => {
438                let details_str = details.as_ref().map(|d| format!(" - {d}")).unwrap_or_default();
439                format!("Tool '{tool_name}' completed{details_str}")
440            }
441            NotificationEvent::Error { message, context } => {
442                let context_str = context.as_ref().map(|ctx| format!(" [{ctx}]")).unwrap_or_default();
443                format!("Error occurred{context_str}: {message}")
444            }
445            NotificationEvent::PolicyApprovalRequest { action, details } => {
446                format!("Policy approval required: {action} - {details}")
447            }
448            NotificationEvent::HumanInTheLoop { prompt, context } => {
449                format!("Human input required: {prompt} [Context: {context}]")
450            }
451            NotificationEvent::PermissionPrompt { title, message } => {
452                format!("{title}: {message}")
453            }
454            NotificationEvent::IdlePrompt { title, message } => {
455                format!("{title}: {message}")
456            }
457            NotificationEvent::Completion { task, status, details } => {
458                let status_str = match status {
459                    CompletionStatus::Success => "completed successfully",
460                    CompletionStatus::PartialSuccess => "partially completed",
461                    CompletionStatus::Failure => "failed",
462                    CompletionStatus::Cancelled => "was cancelled",
463                };
464                let details_str = details.as_ref().map(|d| format!(" - {d}")).unwrap_or_default();
465                if task == "turn" {
466                    format!("Agent turn ended: {status_str}{details_str}")
467                } else if task == "session" {
468                    format!("Agent session ended: {status_str}{details_str}")
469                } else {
470                    format!("Task '{task}' {status_str}{details_str}")
471                }
472            }
473            NotificationEvent::Request { request_type, details } => {
474                format!("New {request_type} request: {details}")
475            }
476        }
477    }
478
479    /// Send a terminal bell notification
480    async fn send_terminal_bell(&self, message: &str) {
481        use crate::utils::ansi_codes::notify_attention_with_terminal_method;
482        let method = self.config.read().notification_method;
483        notify_attention_with_terminal_method(true, Some(message), method);
484    }
485
486    async fn send_desktop_notification(&self, message: &str, config: &NotificationConfig) -> bool {
487        tracing::info!("Notification: {}", message);
488
489        for backend in self.desktop_notification_backends(config.backend) {
490            if self.try_send_desktop_notification_backend(*backend, message) {
491                return true;
492            }
493        }
494
495        false
496    }
497
498    fn desktop_notification_backends(&self, backend: NotificationBackend) -> &'static [DesktopNotificationBackend] {
499        match backend {
500            NotificationBackend::Auto => AUTO_DESKTOP_NOTIFICATION_BACKENDS,
501            NotificationBackend::Osascript => {
502                #[cfg(target_os = "macos")]
503                {
504                    OSASCRIPT_DESKTOP_NOTIFICATION_BACKENDS
505                }
506                #[cfg(not(target_os = "macos"))]
507                {
508                    tracing::warn!("osascript notification backend is only supported on macOS");
509                    NO_DESKTOP_NOTIFICATION_BACKENDS
510                }
511            }
512            NotificationBackend::NotifyRust => NOTIFY_RUST_DESKTOP_NOTIFICATION_BACKENDS,
513            NotificationBackend::Terminal | NotificationBackend::Unknown => NO_DESKTOP_NOTIFICATION_BACKENDS,
514        }
515    }
516
517    fn try_send_desktop_notification_backend(&self, backend: DesktopNotificationBackend, message: &str) -> bool {
518        match backend {
519            #[cfg(target_os = "macos")]
520            DesktopNotificationBackend::Osascript => self.send_osascript_notification(message),
521            DesktopNotificationBackend::NotifyRust => self.send_notify_rust_notification(message),
522        }
523    }
524
525    #[cfg(target_os = "macos")]
526    fn send_osascript_notification(&self, message: &str) -> bool {
527        match send_macos_osascript_notification("VT Code", message) {
528            Ok(()) => true,
529            Err(error) => {
530                tracing::warn!(error = %error, "Failed to send macOS osascript notification");
531                false
532            }
533        }
534    }
535
536    fn send_notify_rust_notification(&self, message: &str) -> bool {
537        #[cfg(feature = "desktop-notifications")]
538        {
539            use std::time::Duration;
540            let mut notification = notify_rust::Notification::new();
541            notification
542                .summary("VT Code")
543                .body(message)
544                .icon("dialog-information")
545                .timeout(Duration::from_secs(5));
546
547            #[cfg(target_os = "macos")]
548            let result = send_after_macos_notification_setup(ensure_macos_notification_application, || {
549                notification.show().map_err(Into::into)
550            });
551            #[cfg(not(target_os = "macos"))]
552            let result = notification.show();
553
554            match result {
555                Ok(notification) => {
556                    tracing::debug!("Desktop notification sent: {:?}", notification);
557                    true
558                }
559                Err(error) => {
560                    tracing::warn!("Failed to send desktop notification: {}", error);
561                    false
562                }
563            }
564        }
565
566        #[cfg(not(feature = "desktop-notifications"))]
567        {
568            let _ = message;
569            tracing::warn!("notify_rust notification backend is unavailable in this build");
570            false
571        }
572    }
573
574    /// Update the notification configuration
575    pub async fn update_config(&self, new_config: NotificationConfig) {
576        self.update_config_sync(new_config);
577    }
578
579    /// Synchronously update notification configuration.
580    pub fn update_config_sync(&self, new_config: NotificationConfig) {
581        let mut config = self.config.write();
582        *config = new_config;
583    }
584
585    /// Get the current notification configuration
586    pub async fn get_config(&self) -> NotificationConfig {
587        self.get_config_sync()
588    }
589
590    /// Get the current notification configuration synchronously.
591    pub fn get_config_sync(&self) -> NotificationConfig {
592        self.config.read().clone()
593    }
594
595    /// Update the terminal focus state - true if terminal is focused/active, false otherwise
596    pub fn set_terminal_focused(&self, focused: bool) {
597        self.terminal_focused.store(focused, Ordering::Relaxed);
598    }
599
600    /// Get the current terminal focus state
601    pub fn is_terminal_focused(&self) -> bool {
602        self.terminal_focused.load(Ordering::Relaxed)
603    }
604
605    fn notification_hook_payload(&self, event: &NotificationEvent) -> Option<(NotificationHookType, String, String)> {
606        match event {
607            NotificationEvent::PermissionPrompt { title, message } => {
608                Some((NotificationHookType::PermissionPrompt, title.clone(), message.clone()))
609            }
610            NotificationEvent::IdlePrompt { title, message } => {
611                Some((NotificationHookType::IdlePrompt, title.clone(), message.clone()))
612            }
613            _ => None,
614        }
615    }
616}
617
618#[cfg(all(target_os = "macos", feature = "desktop-notifications"))]
619fn ensure_macos_notification_application() -> Result<()> {
620    // notify-rust otherwise asks mac-notification-sys to discover an app with
621    // AppleScript, which triggers a macOS Automation permission dialog.
622    // `set_application` is idempotent after success and retryable after failure
623    // (patches/mac-notification-sys/PATCH.md). Call it on every send instead of
624    // caching a failure in `OnceLock` for the process lifetime.
625    notify_rust::set_application("com.apple.finder")
626        .map_err(|error| anyhow::anyhow!("failed to configure macOS notification application: {error}"))
627}
628
629#[cfg(all(target_os = "macos", feature = "desktop-notifications"))]
630fn send_after_macos_notification_setup<T>(
631    setup: impl FnOnce() -> Result<()>,
632    send: impl FnOnce() -> Result<T>,
633) -> Result<T> {
634    setup()?;
635    send()
636}
637
638impl Default for NotificationManager {
639    fn default() -> Self {
640        Self::new()
641    }
642}
643
644/// Global notification manager instance for easy access
645static GLOBAL_NOTIFICATION_MANAGER: OnceLock<NotificationManager> = OnceLock::new();
646static GLOBAL_NOTIFICATION_HOOK_ENGINE: OnceLock<RwLock<Option<LifecycleHookEngine>>> = OnceLock::new();
647
648/// Initialize the global notification manager
649pub fn init_global_notification_manager() -> Result<()> {
650    let manager = NotificationManager::new();
651    GLOBAL_NOTIFICATION_MANAGER
652        .set(manager)
653        .map_err(|_e| anyhow::anyhow!("Failed to set global notification manager"))
654}
655
656/// Initialize the global notification manager with explicit configuration.
657pub fn init_global_notification_manager_with_config(config: NotificationConfig) -> Result<()> {
658    let manager = NotificationManager::with_config(config);
659    GLOBAL_NOTIFICATION_MANAGER
660        .set(manager)
661        .map_err(|_e| anyhow::anyhow!("Failed to set global notification manager"))
662}
663
664/// Get a reference to the global notification manager
665pub fn get_global_notification_manager() -> Option<&'static NotificationManager> {
666    GLOBAL_NOTIFICATION_MANAGER.get()
667}
668
669pub fn set_global_notification_hook_engine(engine: Option<LifecycleHookEngine>) {
670    let slot = GLOBAL_NOTIFICATION_HOOK_ENGINE.get_or_init(|| RwLock::new(None));
671    *slot.write() = engine;
672}
673
674fn get_global_notification_hook_engine() -> Option<LifecycleHookEngine> {
675    GLOBAL_NOTIFICATION_HOOK_ENGINE.get().and_then(|slot| slot.read().clone())
676}
677
678/// Ensure the global manager is initialized, then apply updated configuration.
679pub fn apply_global_notification_config(config: NotificationConfig) -> Result<()> {
680    if let Some(manager) = get_global_notification_manager() {
681        manager.update_config_sync(config);
682        return Ok(());
683    }
684    init_global_notification_manager_with_config(config)
685}
686
687/// Build and apply notification settings from VTCodeConfig.
688pub fn apply_global_notification_config_from_vtcode(config: &VTCodeConfig) -> Result<()> {
689    let notification_config = NotificationConfig::from_vtcode_config(config);
690    apply_global_notification_config(notification_config)
691}
692
693/// Send a notification using the global notification manager
694pub async fn send_global_notification(event: NotificationEvent) -> Result<(), anyhow::Error> {
695    if let Some(manager) = get_global_notification_manager() {
696        manager.send_notification(event).await
697    } else {
698        // If global manager isn't initialized, create a temporary one for this notification
699        let manager = NotificationManager::new();
700        manager.send_notification(event).await
701    }
702}
703
704/// Send a notification immediately, even when the terminal is currently focused.
705pub async fn send_global_notification_force(event: NotificationEvent) -> Result<(), anyhow::Error> {
706    if let Some(manager) = get_global_notification_manager() {
707        let original = manager.get_config().await;
708        let mut forced = original.clone();
709        forced.suppress_when_focused = false;
710        manager.update_config(forced).await;
711        let result = manager.send_notification(event).await;
712        manager.update_config(original).await;
713        result
714    } else {
715        let config = NotificationConfig {
716            suppress_when_focused: false,
717            ..NotificationConfig::default()
718        };
719        let manager = NotificationManager::with_config(config);
720        manager.send_notification(event).await
721    }
722}
723
724/// Set the terminal focus state using the global notification manager
725pub fn set_global_terminal_focused(focused: bool) {
726    if let Some(manager) = get_global_notification_manager() {
727        manager.set_terminal_focused(focused);
728    }
729}
730
731/// Check if the terminal is focused using the global notification manager
732pub fn is_global_terminal_focused() -> bool {
733    if let Some(manager) = get_global_notification_manager() {
734        manager.is_terminal_focused()
735    } else {
736        false // Default to not focused if manager isn't initialized
737    }
738}
739
740/// Convenience function to send a tool failure notification
741#[cold]
742pub async fn notify_tool_failure(tool_name: &str, error: &str, details: Option<&str>) -> Result<(), anyhow::Error> {
743    let event = NotificationEvent::ToolFailure {
744        tool_name: tool_name.to_string(),
745        error: error.to_string(),
746        details: details.map(|s| s.to_string()),
747    };
748    send_global_notification(event).await
749}
750
751/// Convenience function to send a tool success notification
752pub async fn notify_tool_success(tool_name: &str, details: Option<&str>) -> Result<(), anyhow::Error> {
753    let event = NotificationEvent::ToolSuccess {
754        tool_name: tool_name.to_string(),
755        details: details.map(|s| s.to_string()),
756    };
757    send_global_notification(event).await
758}
759
760/// Convenience function to send a command failure notification
761#[cold]
762pub async fn notify_command_failure(command: &str, error: &str, exit_code: Option<i32>) -> Result<(), anyhow::Error> {
763    let event = NotificationEvent::CommandFailure {
764        command: command.to_string(),
765        error: error.to_string(),
766        exit_code,
767    };
768    send_global_notification(event).await
769}
770
771/// Convenience function to send an error notification
772#[cold]
773pub async fn notify_error(message: &str, context: Option<&str>) -> Result<(), anyhow::Error> {
774    let event = NotificationEvent::Error {
775        message: message.to_string(),
776        context: context.map(|s| s.to_string()),
777    };
778    send_global_notification(event).await
779}
780
781/// Convenience function to send a human in the loop notification
782pub async fn notify_human_in_the_loop(prompt: &str, context: &str) -> Result<(), anyhow::Error> {
783    let event = NotificationEvent::HumanInTheLoop {
784        prompt: prompt.to_string(),
785        context: context.to_string(),
786    };
787    send_global_notification(event).await
788}
789
790#[cfg(target_os = "macos")]
791fn send_macos_osascript_notification(title: &str, message: &str) -> Result<()> {
792    let mut child = Command::new("/usr/bin/osascript")
793        .arg("-")
794        .arg(message)
795        .arg(title)
796        .stdin(Stdio::piped())
797        .stdout(Stdio::null())
798        .stderr(Stdio::piped())
799        .spawn()?;
800
801    let script = r#"on run argv
802display notification (item 1 of argv) with title (item 2 of argv)
803end run
804"#;
805
806    let mut stdin = child
807        .stdin
808        .take()
809        .ok_or_else(|| anyhow::anyhow!("failed to open osascript stdin"))?;
810    stdin.write_all(script.as_bytes())?;
811    drop(stdin);
812
813    let output = child.wait_with_output()?;
814    if output.status.success() {
815        Ok(())
816    } else {
817        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
818        if stderr.is_empty() {
819            anyhow::bail!("osascript exited with status {}", output.status);
820        }
821        anyhow::bail!("osascript exited with status {}: {}", output.status, stderr);
822    }
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828
829    #[tokio::test]
830    async fn test_notification_manager_creation() {
831        let manager = NotificationManager::new();
832        let config = manager.get_config().await;
833
834        assert!(!config.command_failure_notifications);
835        assert!(!config.tool_failure_notifications);
836        assert!(config.error_notifications);
837        assert!(!config.completion_success_notifications);
838        assert!(config.completion_failure_notifications);
839        assert_eq!(config.delivery_mode, NotificationDeliveryMode::Desktop);
840        assert_eq!(config.backend, NotificationBackend::Auto);
841        assert_eq!(config.repeat_window_seconds, 30);
842        assert_eq!(config.max_identical_notifications_in_window, 1);
843    }
844
845    #[test]
846    fn runtime_notification_config_respects_backend_preference() {
847        let mut config = VTCodeConfig::default();
848        config.ui.notifications.backend = NotificationBackend::Terminal;
849
850        let runtime = NotificationConfig::from_vtcode_config(&config);
851
852        assert_eq!(runtime.backend, NotificationBackend::Terminal);
853    }
854
855    #[test]
856    fn terminal_backend_skips_desktop_backends() {
857        let manager = NotificationManager::new();
858
859        assert_eq!(
860            manager.desktop_notification_backends(NotificationBackend::Terminal),
861            NO_DESKTOP_NOTIFICATION_BACKENDS
862        );
863    }
864
865    #[test]
866    fn notify_rust_backend_selects_only_notify_rust() {
867        let manager = NotificationManager::new();
868
869        assert_eq!(
870            manager.desktop_notification_backends(NotificationBackend::NotifyRust),
871            NOTIFY_RUST_DESKTOP_NOTIFICATION_BACKENDS
872        );
873    }
874
875    #[test]
876    fn auto_backend_uses_expected_platform_order() {
877        let manager = NotificationManager::new();
878
879        assert_eq!(
880            manager.desktop_notification_backends(NotificationBackend::Auto),
881            AUTO_DESKTOP_NOTIFICATION_BACKENDS
882        );
883    }
884
885    #[cfg(all(target_os = "macos", feature = "desktop-notifications"))]
886    #[test]
887    fn macos_notification_application_registration_succeeds() {
888        ensure_macos_notification_application().expect("Finder must be available for macOS notifications");
889    }
890
891    #[cfg(all(target_os = "macos", feature = "desktop-notifications"))]
892    #[test]
893    fn macos_notification_setup_runs_before_delivery_and_failure_skips_it() {
894        use std::cell::Cell;
895
896        let step = Cell::new(0);
897        let delivered = send_after_macos_notification_setup(
898            || {
899                assert_eq!(step.get(), 0);
900                step.set(1);
901                Ok(())
902            },
903            || {
904                assert_eq!(step.get(), 1);
905                step.set(2);
906                Ok(42)
907            },
908        );
909        assert_eq!(delivered.unwrap(), 42);
910        assert_eq!(step.get(), 2);
911
912        let send_called = Cell::new(false);
913        let failed = send_after_macos_notification_setup(
914            || anyhow::bail!("application registration failed"),
915            || {
916                send_called.set(true);
917                Ok(())
918            },
919        );
920        assert!(failed.is_err());
921        assert!(!send_called.get());
922    }
923
924    #[tokio::test]
925    async fn desktop_delivery_does_not_fall_back_to_terminal_attention() {
926        let config = NotificationConfig {
927            delivery_mode: NotificationDeliveryMode::Desktop,
928            backend: NotificationBackend::NotifyRust,
929            ..Default::default()
930        };
931        let manager = NotificationManager::with_config(config);
932
933        let result = manager.send_message("Session started", &manager.get_config_sync()).await;
934
935        result.unwrap();
936    }
937
938    #[cfg(target_os = "macos")]
939    #[test]
940    fn osascript_backend_selects_only_osascript_on_macos() {
941        let manager = NotificationManager::new();
942
943        assert_eq!(
944            manager.desktop_notification_backends(NotificationBackend::Osascript),
945            OSASCRIPT_DESKTOP_NOTIFICATION_BACKENDS
946        );
947    }
948
949    #[cfg(not(target_os = "macos"))]
950    #[test]
951    fn osascript_backend_is_empty_off_macos() {
952        let manager = NotificationManager::new();
953
954        assert_eq!(
955            manager.desktop_notification_backends(NotificationBackend::Osascript),
956            NO_DESKTOP_NOTIFICATION_BACKENDS
957        );
958    }
959
960    #[tokio::test]
961    async fn test_command_failure_notification() {
962        let manager = NotificationManager::new();
963        let event = NotificationEvent::CommandFailure {
964            command: "git status".to_string(),
965            error: "Not a git repository".to_string(),
966            exit_code: Some(128),
967        };
968
969        // This should not panic
970        let result = manager.send_notification(event).await;
971        result.unwrap();
972    }
973
974    #[tokio::test]
975    async fn test_tool_failure_notification() {
976        let manager = NotificationManager::new();
977        let event = NotificationEvent::ToolFailure {
978            tool_name: "read_file".to_string(),
979            error: "File not found".to_string(),
980            details: Some("Attempted to read /nonexistent/file.txt".to_string()),
981        };
982
983        // This should not panic
984        let result = manager.send_notification(event).await;
985        result.unwrap();
986    }
987
988    #[tokio::test]
989    async fn test_terminal_notifications_toggle() {
990        // Test with notifications enabled (default)
991        let manager = NotificationManager::new();
992        let config = manager.get_config().await;
993        assert!(config.terminal_notifications_enabled);
994
995        // Test with notifications disabled
996        let config = NotificationConfig {
997            terminal_notifications_enabled: false,
998            ..Default::default()
999        };
1000        let manager = NotificationManager::with_config(config);
1001        let event = NotificationEvent::CommandFailure {
1002            command: "test".to_string(),
1003            error: "test error".to_string(),
1004            exit_code: None,
1005        };
1006
1007        // This should not send notification when disabled
1008        let result = manager.send_notification(event).await;
1009        result.unwrap(); // Should not error, but notification won't be sent
1010
1011        // Verify the setting worked by checking the config
1012        let current_config = manager.get_config().await;
1013        assert!(!current_config.terminal_notifications_enabled);
1014    }
1015
1016    #[test]
1017    fn completion_notifications_are_split_by_status() {
1018        let manager = NotificationManager::new();
1019        let config = NotificationConfig {
1020            completion_success_notifications: false,
1021            completion_failure_notifications: true,
1022            ..Default::default()
1023        };
1024
1025        let success_event = NotificationEvent::Completion {
1026            task: "turn".to_string(),
1027            status: CompletionStatus::Success,
1028            details: None,
1029        };
1030        let failure_event = NotificationEvent::Completion {
1031            task: "turn".to_string(),
1032            status: CompletionStatus::Failure,
1033            details: None,
1034        };
1035
1036        assert!(!manager.event_enabled(&success_event, &config));
1037        assert!(manager.event_enabled(&failure_event, &config));
1038    }
1039
1040    #[test]
1041    fn repeat_suppression_limits_identical_notifications() {
1042        let manager = NotificationManager::new();
1043        let config = NotificationConfig {
1044            repeat_window_seconds: 30,
1045            max_identical_notifications_in_window: 1,
1046            ..Default::default()
1047        };
1048        let event = NotificationEvent::ToolFailure {
1049            tool_name: "read_file".to_string(),
1050            error: "File not found".to_string(),
1051            details: None,
1052        };
1053
1054        assert!(matches!(manager.repeat_decision(&event, &config), RepeatDecision::Deliver));
1055        assert!(matches!(manager.repeat_decision(&event, &config), RepeatDecision::Suppress));
1056    }
1057
1058    #[test]
1059    fn custom_notifications_format_title_and_message() {
1060        let manager = NotificationManager::new();
1061        let event = NotificationEvent::Custom {
1062            title: "VT Code".to_string(),
1063            message: "Session started".to_string(),
1064        };
1065
1066        assert_eq!(manager.format_notification_message(&event), "VT Code: Session started");
1067    }
1068
1069    #[cfg(target_os = "macos")]
1070    #[test]
1071    fn macos_osascript_notification_script_mentions_display_notification() {
1072        let script = r#"on run argv
1073display notification (item 1 of argv) with title (item 2 of argv)
1074end run
1075"#;
1076        assert!(script.contains("display notification"));
1077        assert!(script.contains("with title"));
1078    }
1079
1080    #[test]
1081    fn session_completion_format_message_reads_naturally() {
1082        let manager = NotificationManager::new();
1083        let event = NotificationEvent::Completion {
1084            task: "session".to_string(),
1085            status: CompletionStatus::Success,
1086            details: Some("7 turn(s)".to_string()),
1087        };
1088        assert_eq!(
1089            manager.format_notification_message(&event),
1090            "Agent session ended: completed successfully - 7 turn(s)"
1091        );
1092    }
1093}