1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
26pub enum NotificationEvent {
27 Custom { title: String, message: String },
29 CommandFailure {
31 command: String,
32 error: String,
33 exit_code: Option<i32>,
34 },
35 ToolFailure {
37 tool_name: String,
38 error: String,
39 details: Option<String>,
40 },
41 ToolSuccess { tool_name: String, details: Option<String> },
43 Error { message: String, context: Option<String> },
45 PolicyApprovalRequest { action: String, details: String },
47 HumanInTheLoop { prompt: String, context: String },
49 PermissionPrompt { title: String, message: String },
51 IdlePrompt { title: String, message: String },
53 Completion {
55 task: String,
56 status: CompletionStatus,
57 details: Option<String>,
58 },
59 Request { request_type: String, details: String },
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65pub enum CompletionStatus {
66 Success,
67 PartialSuccess,
68 Failure,
69 Cancelled,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct NotificationConfig {
75 pub command_failure_notifications: bool,
77 pub tool_failure_notifications: bool,
79 pub error_notifications: bool,
81 pub policy_approval_notifications: bool,
83 pub hitl_notifications: bool,
85 pub completion_success_notifications: bool,
87 pub completion_failure_notifications: bool,
89 pub request_notifications: bool,
91 pub tool_success_notifications: bool,
93 pub terminal_notifications_enabled: bool,
95 pub suppress_when_focused: bool,
97 pub delivery_mode: NotificationDeliveryMode,
99 pub backend: NotificationBackend,
101 pub notification_method: TerminalNotificationMethod,
103 pub notification_condition: NotificationCondition,
106 pub repeat_window_seconds: u64,
108 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 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
223pub struct NotificationManager {
225 config: Arc<RwLock<NotificationConfig>>,
226 terminal_focused: Arc<AtomicBool>,
228 repeat_state: Arc<Mutex<RepeatSuppressionState>>,
229}
230
231impl NotificationManager {
232 pub fn new() -> Self {
234 Self {
235 config: Arc::new(RwLock::new(NotificationConfig::default())),
236 terminal_focused: Arc::new(AtomicBool::new(false)), repeat_state: Arc::new(Mutex::new(RepeatSuppressionState::default())),
238 }
239 }
240
241 pub fn with_config(config: NotificationConfig) -> Self {
243 Self {
244 config: Arc::new(RwLock::new(config)),
245 terminal_focused: Arc::new(AtomicBool::new(false)), repeat_state: Arc::new(Mutex::new(RepeatSuppressionState::default())),
247 }
248 }
249
250 pub async fn send_notification(&self, event: NotificationEvent) -> Result<()> {
252 let config = self.config.read().clone();
253
254 if !config.terminal_notifications_enabled {
256 return Ok(());
257 }
258
259 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 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 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 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 pub async fn update_config(&self, new_config: NotificationConfig) {
576 self.update_config_sync(new_config);
577 }
578
579 pub fn update_config_sync(&self, new_config: NotificationConfig) {
581 let mut config = self.config.write();
582 *config = new_config;
583 }
584
585 pub async fn get_config(&self) -> NotificationConfig {
587 self.get_config_sync()
588 }
589
590 pub fn get_config_sync(&self) -> NotificationConfig {
592 self.config.read().clone()
593 }
594
595 pub fn set_terminal_focused(&self, focused: bool) {
597 self.terminal_focused.store(focused, Ordering::Relaxed);
598 }
599
600 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::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
644static GLOBAL_NOTIFICATION_MANAGER: OnceLock<NotificationManager> = OnceLock::new();
646static GLOBAL_NOTIFICATION_HOOK_ENGINE: OnceLock<RwLock<Option<LifecycleHookEngine>>> = OnceLock::new();
647
648pub 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
656pub 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
664pub 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
678pub 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
687pub 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
693pub 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 let manager = NotificationManager::new();
700 manager.send_notification(event).await
701 }
702}
703
704pub 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
724pub 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
731pub fn is_global_terminal_focused() -> bool {
733 if let Some(manager) = get_global_notification_manager() {
734 manager.is_terminal_focused()
735 } else {
736 false }
738}
739
740#[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
751pub 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#[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#[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
781pub 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 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 let result = manager.send_notification(event).await;
985 result.unwrap();
986 }
987
988 #[tokio::test]
989 async fn test_terminal_notifications_toggle() {
990 let manager = NotificationManager::new();
992 let config = manager.get_config().await;
993 assert!(config.terminal_notifications_enabled);
994
995 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 let result = manager.send_notification(event).await;
1009 result.unwrap(); 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}