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