Skip to main content

steer_tui/
notifications.rs

1//! Notification module for the TUI.
2//!
3//! Provides centralized, focus-aware notification delivery with OSC 9 as the
4//! primary transport.
5
6use ratatui::crossterm::{Command, execute};
7use std::fmt;
8use std::io::{self, stdout};
9use std::sync::{Arc, Mutex};
10use tracing::debug;
11
12use steer_grpc::client_api::{NotificationTransport, Preferences};
13
14/// High-level notification categories emitted by event processors.
15#[derive(Debug, Clone)]
16pub enum NotificationEvent {
17    ProcessingComplete,
18    ToolApprovalRequested { tool_name: String },
19    Error { message: String },
20}
21
22impl NotificationEvent {
23    fn title() -> &'static str {
24        "Steer"
25    }
26
27    fn body(&self) -> String {
28        match self {
29            NotificationEvent::ProcessingComplete => {
30                "Processing complete - waiting for input".to_string()
31            }
32            NotificationEvent::ToolApprovalRequested { tool_name } => {
33                format!("Tool approval needed: {tool_name}")
34            }
35            NotificationEvent::Error { message } => message.clone(),
36        }
37    }
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41enum EffectiveTransport {
42    Osc9,
43    Off,
44}
45
46/// Focus-aware manager used by event processors to emit notifications.
47#[derive(Debug)]
48pub struct NotificationManager {
49    inner: Mutex<NotificationState>,
50}
51
52#[derive(Debug)]
53struct NotificationState {
54    transport: EffectiveTransport,
55    terminal_focused: bool,
56    focus_events_enabled: bool,
57}
58
59impl NotificationManager {
60    pub fn new(preferences: &Preferences) -> Self {
61        let config = NotificationConfig::from_preferences(preferences);
62        let transport = resolve_transport(config.transport);
63        Self {
64            inner: Mutex::new(NotificationState {
65                transport,
66                terminal_focused: true,
67                focus_events_enabled: false,
68            }),
69        }
70    }
71
72    pub fn set_terminal_focused(&self, focused: bool) {
73        let mut state = self
74            .inner
75            .lock()
76            .unwrap_or_else(std::sync::PoisonError::into_inner);
77        state.terminal_focused = focused;
78    }
79
80    pub fn set_focus_events_enabled(&self, enabled: bool) {
81        let mut state = self
82            .inner
83            .lock()
84            .unwrap_or_else(std::sync::PoisonError::into_inner);
85        state.focus_events_enabled = enabled;
86    }
87
88    pub fn emit(&self, event: NotificationEvent) {
89        let (should_emit, transport, title, body) = {
90            let state = self
91                .inner
92                .lock()
93                .unwrap_or_else(std::sync::PoisonError::into_inner);
94            let should_emit = if state.focus_events_enabled {
95                !state.terminal_focused
96            } else {
97                true
98            };
99            (
100                should_emit,
101                state.transport,
102                NotificationEvent::title().to_string(),
103                event.body(),
104            )
105        };
106
107        if !should_emit || transport == EffectiveTransport::Off {
108            return;
109        }
110
111        match transport {
112            EffectiveTransport::Osc9 => {
113                if let Err(err) = show_osc9_notification(&title, &body) {
114                    debug!("Failed to emit OSC 9 notification: {err}");
115                }
116            }
117            EffectiveTransport::Off => {}
118        }
119    }
120}
121
122fn resolve_transport(transport: NotificationTransport) -> EffectiveTransport {
123    match transport {
124        NotificationTransport::Auto | NotificationTransport::Osc9 => EffectiveTransport::Osc9,
125        NotificationTransport::Off => EffectiveTransport::Off,
126    }
127}
128
129/// Shared handle passed through TUI state and event processors.
130pub type NotificationManagerHandle = Arc<NotificationManager>;
131
132/// Configuration for notifications.
133#[derive(Debug, Clone)]
134pub struct NotificationConfig {
135    pub transport: NotificationTransport,
136}
137
138impl Default for NotificationConfig {
139    fn default() -> Self {
140        Self {
141            transport: NotificationTransport::Auto,
142        }
143    }
144}
145
146impl NotificationConfig {
147    pub fn from_preferences(preferences: &Preferences) -> Self {
148        Self {
149            transport: preferences.ui.notifications.transport,
150        }
151    }
152}
153
154/// Command that emits an OSC 9 notification with a message.
155#[derive(Debug, Clone)]
156struct PostNotification(pub String);
157
158impl Command for PostNotification {
159    fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
160        write!(f, "\x1b]9;{}\x07", self.0)
161    }
162
163    #[cfg(windows)]
164    fn execute_winapi(&self) -> io::Result<()> {
165        Err(std::io::Error::other(
166            "tried to execute PostNotification using WinAPI; use ANSI instead",
167        ))
168    }
169
170    #[cfg(windows)]
171    fn is_ansi_code_supported(&self) -> bool {
172        true
173    }
174}
175
176fn show_osc9_notification(title: &str, message: &str) -> io::Result<()> {
177    let body = if title.is_empty() {
178        message.to_string()
179    } else {
180        format!("{title}: {message}")
181    };
182    execute!(stdout(), PostNotification(body))
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use steer_grpc::client_api::NotificationTransport;
189
190    fn prefs_with_transport(transport: NotificationTransport) -> Preferences {
191        let mut prefs = Preferences::default();
192        prefs.ui.notifications.transport = transport;
193        prefs
194    }
195
196    #[test]
197    fn resolve_auto_to_osc9() {
198        let prefs = prefs_with_transport(NotificationTransport::Auto);
199        let manager = NotificationManager::new(&prefs);
200        let state = manager
201            .inner
202            .lock()
203            .unwrap_or_else(std::sync::PoisonError::into_inner);
204        assert_eq!(state.transport, EffectiveTransport::Osc9);
205    }
206
207    #[test]
208    fn resolve_off_to_off() {
209        let prefs = prefs_with_transport(NotificationTransport::Off);
210        let manager = NotificationManager::new(&prefs);
211        let state = manager
212            .inner
213            .lock()
214            .unwrap_or_else(std::sync::PoisonError::into_inner);
215        assert_eq!(state.transport, EffectiveTransport::Off);
216    }
217
218    #[test]
219    fn event_body_formats_approval() {
220        let body = NotificationEvent::ToolApprovalRequested {
221            tool_name: "bash".to_string(),
222        }
223        .body();
224        assert_eq!(body, "Tool approval needed: bash");
225    }
226}