steer_tui/
notifications.rs

1//! Notification module for the TUI
2//!
3//! Provides sound and desktop notifications when processing completes.
4
5use crate::error::Result;
6use notify_rust::Notification;
7use process_wrap::tokio::{ProcessGroup, TokioCommandWrap};
8use std::fmt;
9use std::str::FromStr;
10use std::time::Duration;
11use tokio::time::sleep;
12use tracing::debug;
13
14/// Type of notification sound to play
15#[derive(Debug, Clone, Copy)]
16pub enum NotificationSound {
17    /// Processing complete - ascending tones
18    ProcessingComplete,
19    /// Tool approval needed - urgent double beep
20    ToolApproval,
21    /// Error occurred - descending tones
22    Error,
23}
24
25impl FromStr for NotificationSound {
26    type Err = ();
27
28    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
29        match s {
30            "ProcessingComplete" => Ok(NotificationSound::ProcessingComplete),
31            "ToolApproval" => Ok(NotificationSound::ToolApproval),
32            "Error" => Ok(NotificationSound::Error),
33            _ => Err(()),
34        }
35    }
36}
37
38impl fmt::Display for NotificationSound {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        let s = match self {
41            NotificationSound::ProcessingComplete => "ProcessingComplete",
42            NotificationSound::ToolApproval => "ToolApproval",
43            NotificationSound::Error => "Error",
44        };
45        write!(f, "{s}")
46    }
47}
48
49/// Get the appropriate system sound name for the notification type
50fn get_sound_name(sound_type: NotificationSound) -> &'static str {
51    #[cfg(target_os = "macos")]
52    {
53        match sound_type {
54            NotificationSound::ProcessingComplete => "Glass", // Pleasant completion sound
55            NotificationSound::ToolApproval => "Ping",        // Attention-getting sound
56            NotificationSound::Error => "Basso",              // Error/failure sound
57        }
58    }
59
60    #[cfg(target_os = "linux")]
61    {
62        match sound_type {
63            NotificationSound::ProcessingComplete => "message-new-instant", // Completion sound
64            NotificationSound::ToolApproval => "dialog-warning", // Warning/attention sound
65            NotificationSound::Error => "dialog-error",          // Error sound
66        }
67    }
68
69    #[cfg(target_os = "windows")]
70    {
71        // Windows has limited notification sound options
72        match sound_type {
73            NotificationSound::ProcessingComplete => "default",
74            NotificationSound::ToolApproval => "default",
75            NotificationSound::Error => "default",
76        }
77    }
78
79    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
80    {
81        "default"
82    }
83}
84
85/// Show a desktop notification with optional sound
86pub fn show_notification_with_sound(
87    title: &str,
88    message: &str,
89    sound_type: Option<NotificationSound>,
90) -> Result<()> {
91    let mut notification = Notification::new();
92    notification
93        .summary(title)
94        .body(message)
95        .appname("steer")
96        .timeout(5000);
97
98    // Add sound if specified
99    if let Some(sound) = sound_type {
100        notification.sound_name(get_sound_name(sound));
101    }
102
103    #[cfg(target_os = "linux")]
104    {
105        notification.icon("terminal").timeout(5000);
106    }
107
108    notification.show()?;
109    Ok(())
110}
111
112/// Configuration for notifications
113#[derive(Debug, Clone)]
114pub struct NotificationConfig {
115    pub enable_sound: bool,
116    pub enable_desktop_notification: bool,
117}
118
119impl Default for NotificationConfig {
120    fn default() -> Self {
121        Self {
122            enable_sound: true,
123            enable_desktop_notification: true,
124        }
125    }
126}
127
128impl NotificationConfig {
129    /// Load configuration from environment variables
130    pub fn from_env() -> Self {
131        Self {
132            enable_sound: std::env::var("STEER_NOTIFICATION_SOUND")
133                .map(|v| v != "false" && v != "0")
134                .unwrap_or(true),
135            enable_desktop_notification: std::env::var("STEER_NOTIFICATION_DESKTOP")
136                .map(|v| v != "false" && v != "0")
137                .unwrap_or(true),
138        }
139    }
140}
141
142/// We need to do this in a subprocess because on mac at least, notify-rust's Notification::show()
143/// **NEVER RETURNS**.
144/// This is a workaround to ensure that both:
145/// 1. The notification is shown
146/// 2. We don't leak tokio tasks / threads
147/// 3. We don't end up with blocking tokio tasks which prevent the main thread from exiting.
148async fn trigger_notification_subprocess(
149    title: &str,
150    message: &str,
151    sound: Option<NotificationSound>,
152) -> Result<()> {
153    let current_exe = std::env::current_exe()?;
154    let mut args = vec![
155        "notify".to_string(),
156        "--title".to_string(),
157        title.to_string(),
158        "--message".to_string(),
159        message.to_string(),
160    ];
161
162    if let Some(sound_type) = sound {
163        args.push("--sound".to_string());
164        args.push(sound_type.to_string());
165    }
166
167    let mut child = TokioCommandWrap::with_new(current_exe, |command| {
168        command.args(args);
169    })
170    .wrap(ProcessGroup::leader())
171    .spawn()?;
172
173    tokio::spawn(async move {
174        sleep(Duration::from_secs(2)).await;
175        match child.start_kill() {
176            Ok(_) => {}
177            Err(e) => {
178                debug!("Failed to kill notification subprocess: {}", e);
179            }
180        }
181    });
182
183    Ok(())
184}
185
186/// Trigger notifications with specific sound
187pub async fn notify_with_sound(
188    config: &NotificationConfig,
189    sound: NotificationSound,
190    message: &str,
191) {
192    notify_with_title_and_sound(config, sound, "Steer", message).await;
193}
194
195/// Trigger notifications with custom title and sound
196pub async fn notify_with_title_and_sound(
197    config: &NotificationConfig,
198    sound: NotificationSound,
199    title: &str,
200    message: &str,
201) {
202    if config.enable_desktop_notification {
203        let sound_option = if config.enable_sound {
204            Some(sound)
205        } else {
206            None
207        };
208        match trigger_notification_subprocess(title, message, sound_option).await {
209            Ok(_) => {}
210            Err(e) => {
211                debug!("Failed to trigger notification subprocess: {}", e);
212            }
213        }
214    }
215}