steer_tui/
notifications.rs1use 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#[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#[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
129pub type NotificationManagerHandle = Arc<NotificationManager>;
131
132#[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#[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}