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::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#[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 {
43 tool_name: String,
44 details: Option<String>,
45 },
46 Error {
48 message: String,
49 context: Option<String>,
50 },
51 PolicyApprovalRequest { action: String, details: String },
53 HumanInTheLoop { prompt: String, context: String },
55 PermissionPrompt { title: String, message: String },
57 IdlePrompt { title: String, message: String },
59 Completion {
61 task: String,
62 status: CompletionStatus,
63 details: Option<String>,
64 },
65 Request {
67 request_type: String,
68 details: String,
69 },
70}
71
72#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
74pub enum CompletionStatus {
75 Success,
76 PartialSuccess,
77 Failure,
78 Cancelled,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct NotificationConfig {
84 pub command_failure_notifications: bool,
86 pub tool_failure_notifications: bool,
88 pub error_notifications: bool,
90 pub policy_approval_notifications: bool,
92 pub hitl_notifications: bool,
94 pub completion_success_notifications: bool,
96 pub completion_failure_notifications: bool,
98 pub request_notifications: bool,
100 pub tool_success_notifications: bool,
102 pub terminal_notifications_enabled: bool,
104 pub suppress_when_focused: bool,
106 pub delivery_mode: NotificationDeliveryMode,
108 pub backend: NotificationBackend,
110 pub notification_method: TerminalNotificationMethod,
112 pub notification_condition: NotificationCondition,
115 pub repeat_window_seconds: u64,
117 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 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
246pub struct NotificationManager {
248 config: Arc<RwLock<NotificationConfig>>,
249 terminal_focused: Arc<AtomicBool>,
251 repeat_state: Arc<Mutex<RepeatSuppressionState>>,
252}
253
254impl NotificationManager {
255 pub fn new() -> Self {
257 Self {
258 config: Arc::new(RwLock::new(NotificationConfig::default())),
259 terminal_focused: Arc::new(AtomicBool::new(false)), repeat_state: Arc::new(Mutex::new(RepeatSuppressionState::default())),
261 }
262 }
263
264 pub fn with_config(config: NotificationConfig) -> Self {
266 Self {
267 config: Arc::new(RwLock::new(config)),
268 terminal_focused: Arc::new(AtomicBool::new(false)), repeat_state: Arc::new(Mutex::new(RepeatSuppressionState::default())),
270 }
271 }
272
273 pub async fn send_notification(&self, event: NotificationEvent) -> Result<()> {
275 let config = self.config.read().clone();
276
277 if !config.terminal_notifications_enabled {
279 return Ok(());
280 }
281
282 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 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 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 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 pub async fn update_config(&self, new_config: NotificationConfig) {
646 self.update_config_sync(new_config);
647 }
648
649 pub fn update_config_sync(&self, new_config: NotificationConfig) {
651 let mut config = self.config.write();
652 *config = new_config;
653 }
654
655 pub async fn get_config(&self) -> NotificationConfig {
657 self.get_config_sync()
658 }
659
660 pub fn get_config_sync(&self) -> NotificationConfig {
662 self.config.read().clone()
663 }
664
665 pub fn set_terminal_focused(&self, focused: bool) {
667 self.terminal_focused.store(focused, Ordering::Relaxed);
668 }
669
670 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
701use 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
708pub 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
716pub 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
724pub 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
740pub 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
749pub 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
755pub 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 let manager = NotificationManager::new();
762 manager.send_notification(event).await
763 }
764}
765
766pub 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
786pub 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
793pub fn is_global_terminal_focused() -> bool {
795 if let Some(manager) = get_global_notification_manager() {
796 manager.is_terminal_focused()
797 } else {
798 false }
800}
801
802#[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
817pub 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#[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#[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
854pub 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 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 let result = manager.send_notification(event).await;
1021 result.unwrap();
1022 }
1023
1024 #[tokio::test]
1025 async fn test_terminal_notifications_toggle() {
1026 let manager = NotificationManager::new();
1028 let config = manager.get_config().await;
1029 assert!(config.terminal_notifications_enabled);
1030
1031 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 let result = manager.send_notification(event).await;
1045 result.unwrap(); 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}