1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
//! Notification and alert handling for the terminal.
//!
//! This module handles:
//! - Desktop notifications (OSC 9/777)
//! - Bell events (audio, visual, desktop)
use super::WindowState;
impl WindowState {
/// Check for OSC 9/777 notifications from the focused pane's terminal.
pub(crate) fn check_notifications(&mut self) {
let tab = if let Some(t) = self.tab_manager.active_tab() {
t
} else {
return;
};
// Use focused pane's terminal (not tab.terminal, which may differ after a split)
let terminal = tab
.pane_manager
.as_ref()
.and_then(|pm| pm.focused_pane())
.map(|pane| std::sync::Arc::clone(&pane.terminal))
.unwrap_or_else(|| std::sync::Arc::clone(&tab.terminal));
// try_lock: intentional — OSC notification polling in about_to_wait (sync loop).
// On miss: notifications are deferred to the next poll frame. Low risk; OSC
// notifications are informational and a one-frame delay is imperceptible.
if let Ok(term) = terminal.try_write() {
// Check for OSC 9/777 notifications
if term.has_notifications() {
let notifications = term.take_notifications();
for notif in notifications {
self.deliver_notification(¬if.title, ¬if.message);
}
}
}
}
/// Check for bell events and trigger appropriate feedback.
pub(crate) fn check_bell(&mut self) {
// Skip if all bell notifications are disabled
if self.config.notifications.notification_bell_sound == 0
&& !self.config.notifications.notification_bell_visual
&& !self.config.notifications.notification_bell_desktop
{
return;
}
// Get current bell count from focused pane's terminal (not tab.terminal,
// which may differ from the focused pane's terminal after a split).
let (current_bell_count, last_count) = {
let tab = if let Some(t) = self.tab_manager.active_tab() {
t
} else {
return;
};
// Get the focused pane's terminal (falls back to tab terminal if no pane manager)
let terminal = tab
.pane_manager
.as_ref()
.and_then(|pm| pm.focused_pane())
.map(|pane| std::sync::Arc::clone(&pane.terminal))
.unwrap_or_else(|| std::sync::Arc::clone(&tab.terminal));
// try_lock: intentional — bell count polling in about_to_wait (sync event loop).
// On miss: bell detection is skipped this frame. The bell event will be seen
// on the next poll. A one-frame delay in bell feedback is imperceptible.
if let Ok(term) = terminal.try_write() {
(term.bell_count(), tab.active_bell().last_count)
} else {
return;
}
};
if current_bell_count > last_count {
// Bell event(s) occurred
let bell_events = current_bell_count - last_count;
log::info!("Bell event detected ({} bell(s))", bell_events);
log::info!(
" Config: sound={}, visual={}, desktop={}",
self.config.notifications.notification_bell_sound,
self.config.notifications.notification_bell_visual,
self.config.notifications.notification_bell_desktop
);
// Play audio bell if enabled (volume > 0)
// Check alert_sounds config first, fall back to legacy bell_sound setting
if let Some(alert_cfg) = self
.config
.notifications
.alert_sounds
.get(&crate::config::AlertEvent::Bell)
{
if alert_cfg.enabled
&& alert_cfg.volume > 0
&& let Some(tab) = self.tab_manager.active_tab()
&& let Some(ref audio_bell) = tab.active_bell().audio
{
log::info!(
" Playing alert sound for bell at {}% volume",
alert_cfg.volume
);
audio_bell.play_alert(alert_cfg);
}
} else if self.config.notifications.notification_bell_sound > 0 {
if let Some(tab) = self.tab_manager.active_tab()
&& let Some(ref audio_bell) = tab.active_bell().audio
{
log::info!(
" Playing audio bell at {}% volume",
self.config.notifications.notification_bell_sound
);
audio_bell.play(self.config.notifications.notification_bell_sound);
} else {
log::warn!(" Audio bell requested but not initialized");
}
} else {
log::debug!(" Audio bell disabled (volume=0)");
}
// Trigger visual bell flash if enabled
if self.config.notifications.notification_bell_visual {
log::info!(" Triggering visual bell flash");
if let Some(tab) = self.tab_manager.active_tab_mut() {
tab.active_bell_mut().visual_flash = Some(std::time::Instant::now());
}
// Request immediate redraw to show flash
self.request_redraw();
} else {
log::debug!(" Visual bell disabled");
}
// Send desktop notification if enabled
if self.config.notifications.notification_bell_desktop {
log::info!(" Sending desktop notification");
let message = if bell_events == 1 {
"Terminal bell".to_string()
} else {
format!("Terminal bell ({} events)", bell_events)
};
self.deliver_notification("Terminal", &message);
} else {
log::debug!(" Desktop notification disabled");
}
// Update last count
if let Some(tab) = self.tab_manager.active_tab_mut() {
tab.active_bell_mut().last_count = current_bell_count;
}
}
}
/// Play an alert sound for the given event, if configured.
pub(crate) fn play_alert_sound(&self, event: crate::config::AlertEvent) {
if let Some(alert_cfg) = self.config.notifications.alert_sounds.get(&event)
&& alert_cfg.enabled
&& alert_cfg.volume > 0
&& let Some(tab) = self.tab_manager.active_tab()
&& let Some(ref audio_bell) = tab.active_bell().audio
{
log::info!(
"Playing alert sound for {:?} at {}% volume",
event,
alert_cfg.volume
);
audio_bell.play_alert(alert_cfg);
}
}
/// Check for session exit notifications across all tabs.
///
/// Notifies the user when a shell/process exits, useful for long-running commands
/// where the user may have switched to other applications.
pub(crate) fn check_session_exit_notifications(&mut self) {
if !self.config.notifications.notification_session_ended {
return;
}
let mut notifications_to_send: Vec<(String, String)> = Vec::new();
for tab in self.tab_manager.tabs_mut() {
// Skip if already notified for this tab
if tab.activity.exit_notified {
continue;
}
// Check if the terminal has exited
// try_lock: intentional — exit check in about_to_wait (sync event loop).
// On miss: this tab's exit is not detected this frame; it will be on the next.
let has_exited = if let Ok(term) = tab.terminal.try_write() {
!term.is_running()
} else {
continue; // Skip if terminal is locked
};
if has_exited {
tab.activity.exit_notified = true;
let title = format!("Session Ended: {}", tab.title);
let message = "The shell process has exited".to_string();
log::info!("Session exit notification: {} has exited", tab.title);
notifications_to_send.push((title, message));
}
}
// Send collected notifications (after releasing mutable borrow)
for (title, message) in notifications_to_send {
self.deliver_notification(&title, &message);
}
}
/// Check for activity/idle notifications across all tabs.
///
/// This method handles two types of notifications:
/// - **Activity notification**: Triggered when terminal output resumes after a period of
/// inactivity (useful for long-running commands completing).
/// - **Silence notification**: Triggered when a terminal has been idle for longer than the
/// configured threshold (useful for detecting stalled processes).
pub(crate) fn check_activity_idle_notifications(&mut self) {
// Skip if both notification types are disabled
if !self.config.notifications.notification_activity_enabled
&& !self.config.notifications.notification_silence_enabled
{
return;
}
let now = std::time::Instant::now();
let activity_threshold = std::time::Duration::from_secs(
self.config.notifications.notification_activity_threshold,
);
let silence_threshold = std::time::Duration::from_secs(
self.config.notifications.notification_silence_threshold,
);
// Collect notification data for all tabs to avoid borrow conflicts
let mut notifications_to_send: Vec<(String, String)> = Vec::new();
for tab in self.tab_manager.tabs_mut() {
// Get current terminal generation to detect new output
// try_lock: intentional — activity/generation check in about_to_wait (sync loop).
// On miss: activity tracking skipped for this tab this frame. Harmless.
let current_generation = if let Ok(term) = tab.terminal.try_write() {
term.update_generation()
} else {
continue; // Skip if terminal is locked
};
let time_since_activity = now.duration_since(tab.activity.last_activity_time);
// Check if there's new terminal output
if current_generation > tab.activity.last_seen_generation {
// New output detected - this is "activity"
let was_idle = time_since_activity >= activity_threshold;
// Update tracking state
tab.activity.last_seen_generation = current_generation;
tab.activity.last_activity_time = now;
tab.activity.silence_notified = false; // Reset silence notification flag
// Activity notification: notify if we were idle long enough
if self.config.notifications.notification_activity_enabled && was_idle {
let title = format!("Activity in {}", tab.title);
let message = format!(
"Terminal output resumed after {} seconds of inactivity",
time_since_activity.as_secs()
);
log::info!(
"Activity notification: {} idle for {}s, now active",
tab.title,
time_since_activity.as_secs()
);
notifications_to_send.push((title, message));
}
} else {
// No new output - check for silence notification
if self.config.notifications.notification_silence_enabled
&& !tab.activity.silence_notified
&& time_since_activity >= silence_threshold
{
// Terminal has been silent for longer than threshold
tab.activity.silence_notified = true;
let title = format!("Silence in {}", tab.title);
let message =
format!("No output for {} seconds", time_since_activity.as_secs());
log::info!(
"Silence notification: {} silent for {}s",
tab.title,
time_since_activity.as_secs()
);
notifications_to_send.push((title, message));
}
}
}
// Send collected notifications (after releasing mutable borrow)
for (title, message) in notifications_to_send {
self.deliver_notification(&title, &message);
}
}
/// Deliver a notification unconditionally (bypasses focus suppression).
///
/// Used for trigger-generated notifications which the user explicitly configured,
/// so they should always be delivered regardless of window focus state.
pub(crate) fn deliver_notification_force(&self, title: &str, message: &str) {
self.deliver_notification_inner(title, message, true);
}
/// Deliver a notification via desktop notification system and logs.
///
/// If `suppress_notifications_when_focused` is enabled and the window is focused,
/// only log the notification without sending a desktop notification (since the user
/// is already looking at the terminal).
pub(crate) fn deliver_notification(&self, title: &str, message: &str) {
self.deliver_notification_inner(title, message, false);
}
/// Inner notification delivery with force option.
///
/// When `force` is true, bypasses focus suppression (used for trigger notifications).
fn deliver_notification_inner(&self, title: &str, message: &str, force: bool) {
// Always log notifications
if !title.is_empty() {
log::info!("=== Notification: {} ===", title);
log::info!("{}", message);
log::info!("===========================");
} else {
log::info!("=== Notification ===");
log::info!("{}", message);
log::info!("===================");
}
// Skip desktop notification if window is focused and suppression is enabled
// (unless force is set, e.g. for trigger-generated notifications)
if !force
&& self
.config
.notifications
.suppress_notifications_when_focused
&& self.focus_state.is_focused
{
log::debug!(
"Suppressing desktop notification (window is focused): {}",
title
);
return;
}
// Send desktop notification via the platform abstraction layer
crate::platform::deliver_desktop_notification(title, message, 3000);
}
}