返回 DeepSeek-TUI-2026
notifications.rs
根目录 / crates / tui / src / tui / notifications.rs
1 //! OSC 9 / BEL desktop notifications for long agent-turn completion.
2 //!
3 //! Writes a terminal escape to the provided sink (or stdout for the public
4 //! API) when a turn takes longer than the configured threshold. Supports
5 //! tmux DCS passthrough so OSC 9 reaches the outer terminal even when
6 //! running inside a tmux session.
7
8 #[cfg(target_os = "windows")]
9 use windows::Win32::System::Diagnostics::Debug::MessageBeep;
10 #[cfg(target_os = "windows")]
11 use windows::Win32::UI::WindowsAndMessaging::MESSAGEBOX_STYLE;
12
13 use std::io::{self, Write};
14 use std::time::Duration;
15
16 /// Notification delivery method.
17 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18 pub enum Method {
19 /// Automatically pick `Osc9` for known capable terminals
20 /// (`iTerm.app`, `Ghostty`, `WezTerm`); fall back to `Bel` on
21 /// macOS / Linux. On Windows the fallback is `Off` instead of
22 /// `Bel`, because the OS audio stack maps `\x07` to the
23 /// `SystemAsterisk` / `MB_OK` chime — the same sound used by
24 /// application error popups (#583). Windows users who want an
25 /// audible cue can opt in by setting
26 /// `[notifications].method = "bel"` explicitly.
27 #[default]
28 Auto,
29 /// OSC 9 escape: `\x1b]9;<msg>\x07`
30 Osc9,
31 /// Plain BEL character: `\x07`
32 Bel,
33 /// Suppress all notifications.
34 Off,
35 }
36
37 /// Emit a Windows system beep via `MessageBeep(MB_OK)`.
38 ///
39 /// Writing BEL (`\\x07`) to the terminal is silent on most Windows
40 /// terminals (Windows Terminal, Conhost, etc.), so we call the Win32
41 /// API directly to produce the standard notification sound.
42 #[cfg(target_os = "windows")]
43 fn windows_bell() {
44 // MB_OK = 0x00000000 — plays the default system sound. Best-effort: a
45 // failed beep is not worth surfacing to the caller, so the Result is
46 // discarded.
47 unsafe {
48 let _ = MessageBeep(MESSAGEBOX_STYLE(0));
49 }
50 }
51
52 /// Resolve `Auto` to a concrete method by inspecting `$TERM_PROGRAM`.
53 ///
54 /// Known OSC-9 capable programs: `iTerm.app`, `Ghostty`, `WezTerm`
55 /// (these resolve to `Osc9` on every platform, including Windows
56 /// when running inside WezTerm).
57 ///
58 /// Otherwise the fallback is platform-dependent:
59 /// - **macOS / Linux / other Unix:** `Bel` (a single `\x07` byte).
60 /// - **Windows:** `Off`. BEL is mapped by the Windows audio stack
61 /// to `SystemAsterisk` / `MB_OK`, the same chime used by
62 /// application error popups, so it sounds like an error
63 /// notification even though the turn completed successfully (#583).
64 /// Users can opt back in with `[notifications].method = "bel"` or
65 /// pick a known OSC-9 terminal.
66 #[must_use]
67 fn resolve_method() -> Method {
68 let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
69 match term_program.as_str() {
70 "iTerm.app" | "Ghostty" | "WezTerm" => Method::Osc9,
71 _ if cfg!(target_os = "windows") => Method::Off,
72 _ => Method::Bel,
73 }
74 }
75
76 /// Build the raw escape bytes for the given method and message.
77 ///
78 /// When `in_tmux` is `true` and the method is `Osc9`, the sequence is
79 /// wrapped in a DCS passthrough so tmux forwards it to the outer terminal:
80 /// `\x1bPtmux;\x1b<OSC-9>\x1b\\`
81 #[must_use]
82 fn build_escape(method: Method, in_tmux: bool, msg: &str) -> Vec<u8> {
83 match method {
84 Method::Bel => vec![b'\x07'],
85 Method::Osc9 => {
86 let inner = format!("\x1b]9;{msg}\x07");
87 if in_tmux {
88 // DCS passthrough: every ESC inside the payload must be
89 // doubled so tmux does not interpret it as DCS end.
90 let escaped_inner = inner.replace('\x1b', "\x1b\x1b");
91 format!("\x1bPtmux;{escaped_inner}\x1b\\").into_bytes()
92 } else {
93 inner.into_bytes()
94 }
95 }
96 // Auto and Off should not reach build_escape.
97 Method::Auto | Method::Off => vec![],
98 }
99 }
100
101 /// Emit a turn-complete notification to `sink` if the elapsed time meets or
102 /// exceeds `threshold`, and `method` is not `Off`.
103 ///
104 /// This variant takes a `W: Write` sink for testability.
105 pub fn notify_done_to<W: Write>(
106 method: Method,
107 in_tmux: bool,
108 msg: &str,
109 threshold: Duration,
110 elapsed: Duration,
111 sink: &mut W,
112 ) {
113 if elapsed < threshold {
114 return;
115 }
116 let effective = match method {
117 Method::Off => return,
118 Method::Auto => resolve_method(),
119 other => other,
120 };
121 let bytes = build_escape(effective, in_tmux, msg);
122 if bytes.is_empty() {
123 return;
124 }
125 // Best-effort: ignore write errors (e.g. stdout closed).
126 let _ = sink.write_all(&bytes);
127 let _ = sink.flush();
128
129 // On Windows, writing BEL (`\x07`) to the terminal is silent in most
130 // terminals (Windows Terminal, Conhost, etc.). Call MessageBeep to
131 // produce an actual notification sound via the system audio scheme.
132 #[cfg(target_os = "windows")]
133 if effective == Method::Bel {
134 windows_bell();
135 }
136 }
137
138 /// Emit a turn-complete notification to **stdout** if `elapsed >= threshold`.
139 ///
140 /// With `method = Auto`, selects `Osc9` for known capable terminals
141 /// (`iTerm.app`, `Ghostty`, `WezTerm`); the unknown-terminal fallback is
142 /// platform-aware — `Bel` on macOS / Linux, `Off` on Windows (where BEL
143 /// maps to the `SystemAsterisk` / `MB_OK` error chime, #583). See
144 /// [`resolve_method`] for the canonical resolution table. Pass
145 /// `in_tmux = true` (i.e. `$TMUX` is non-empty at runtime) to wrap OSC 9
146 /// in a DCS passthrough.
147 pub fn notify_done(
148 method: Method,
149 in_tmux: bool,
150 msg: &str,
151 threshold: Duration,
152 elapsed: Duration,
153 ) {
154 notify_done_to(method, in_tmux, msg, threshold, elapsed, &mut io::stdout());
155 }
156
157 /// Return a human-readable duration string, capped at two units so
158 /// it stays compact in headers and notifications.
159 ///
160 /// Examples:
161 /// * `"45s"`, `"1m"`, `"1m 12s"`
162 /// * `"1h"`, `"3h 12m"` (#447 — was previously `"192m"` form)
163 /// * `"1d"`, `"2d 5h"` (#447 — multi-day sessions/cycles)
164 /// * `"1w"`, `"3w 2d"` (#447 — long-running automations)
165 ///
166 /// The output drops the secondary unit when it's zero, so `"1h"`
167 /// rather than `"1h 0m"`. Sub-minute precision is dropped at the
168 /// hour mark and above; the goal is "is this a couple of hours or
169 /// a couple of days," not stopwatch accuracy.
170 #[must_use]
171 pub fn humanize_duration(d: Duration) -> String {
172 const MINUTE: u64 = 60;
173 const HOUR: u64 = 60 * MINUTE;
174 const DAY: u64 = 24 * HOUR;
175 const WEEK: u64 = 7 * DAY;
176
177 let total = d.as_secs();
178 if total == 0 {
179 return "0s".to_string();
180 }
181 if total >= WEEK {
182 let w = total / WEEK;
183 let days = (total % WEEK) / DAY;
184 return if days == 0 {
185 format!("{w}w")
186 } else {
187 format!("{w}w {days}d")
188 };
189 }
190 if total >= DAY {
191 let days = total / DAY;
192 let h = (total % DAY) / HOUR;
193 return if h == 0 {
194 format!("{days}d")
195 } else {
196 format!("{days}d {h}h")
197 };
198 }
199 if total >= HOUR {
200 let h = total / HOUR;
201 let m = (total % HOUR) / MINUTE;
202 return if m == 0 {
203 format!("{h}h")
204 } else {
205 format!("{h}h {m}m")
206 };
207 }
208 if total >= MINUTE {
209 let m = total / MINUTE;
210 let s = total % MINUTE;
211 return if s == 0 {
212 format!("{m}m")
213 } else {
214 format!("{m}m {s}s")
215 };
216 }
217 format!("{total}s")
218 }
219
220 #[cfg(test)]
221 mod tests {
222 use std::sync::{Mutex, OnceLock};
223
224 use super::*;
225
226 /// Serialise all tests that mutate `TERM_PROGRAM` to prevent data races
227 /// when the test harness runs them in parallel threads.
228 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
229 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
230 LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
231 }
232
233 fn capture(
234 method: Method,
235 in_tmux: bool,
236 msg: &str,
237 threshold_secs: u64,
238 elapsed_secs: u64,
239 ) -> Vec<u8> {
240 let mut buf = Vec::new();
241 notify_done_to(
242 method,
243 in_tmux,
244 msg,
245 Duration::from_secs(threshold_secs),
246 Duration::from_secs(elapsed_secs),
247 &mut buf,
248 );
249 buf
250 }
251
252 #[test]
253 fn osc9_body_format() {
254 let out = capture(Method::Osc9, false, "deepseek: done", 0, 1);
255 assert_eq!(out, b"\x1b]9;deepseek: done\x07");
256 }
257
258 #[test]
259 fn bel_emits_exactly_one_byte() {
260 let out = capture(Method::Bel, false, "ignored", 0, 1);
261 assert_eq!(out, b"\x07");
262 }
263
264 #[test]
265 fn off_mode_emits_nothing() {
266 let out = capture(Method::Off, false, "ignored", 0, 9999);
267 assert!(out.is_empty());
268 }
269
270 #[test]
271 fn below_threshold_emits_nothing() {
272 let out = capture(Method::Osc9, false, "msg", 30, 29);
273 assert!(out.is_empty());
274 }
275
276 #[test]
277 fn at_threshold_emits() {
278 let out = capture(Method::Osc9, false, "msg", 30, 30);
279 assert!(!out.is_empty());
280 }
281
282 #[test]
283 fn tmux_dcs_passthrough_wraps_osc9() {
284 let out = capture(Method::Osc9, true, "hello", 0, 1);
285 let s = String::from_utf8(out).unwrap();
286 assert!(
287 s.starts_with("\x1bPtmux;"),
288 "should start with DCS passthrough"
289 );
290 assert!(s.ends_with("\x1b\\"), "should end with ST");
291 assert!(s.contains("hello"), "should contain message");
292 }
293
294 #[test]
295 fn auto_detect_picks_osc9_for_iterm() {
296 let _lock = env_lock();
297 let prev = std::env::var_os("TERM_PROGRAM");
298 // SAFETY: test-only; serialised by env_lock().
299 unsafe { std::env::set_var("TERM_PROGRAM", "iTerm.app") };
300 let resolved = resolve_method();
301 // Restore previous value.
302 // SAFETY: test-only; serialised by env_lock().
303 unsafe {
304 match prev {
305 Some(v) => std::env::set_var("TERM_PROGRAM", v),
306 None => std::env::remove_var("TERM_PROGRAM"),
307 }
308 }
309 assert_eq!(resolved, Method::Osc9);
310 }
311
312 #[test]
313 #[cfg(not(target_os = "windows"))]
314 fn auto_detect_picks_bel_for_unknown_on_unix() {
315 let _lock = env_lock();
316 let prev = std::env::var_os("TERM_PROGRAM");
317 // SAFETY: test-only; serialised by env_lock().
318 unsafe { std::env::set_var("TERM_PROGRAM", "xterm-256color") };
319 let resolved = resolve_method();
320 // SAFETY: test-only; serialised by env_lock().
321 unsafe {
322 match prev {
323 Some(v) => std::env::set_var("TERM_PROGRAM", v),
324 None => std::env::remove_var("TERM_PROGRAM"),
325 }
326 }
327 assert_eq!(resolved, Method::Bel);
328 }
329
330 /// #583: on Windows, an unknown TERM_PROGRAM resolves to `Off`
331 /// (not `Bel`) so the post-turn notification doesn't ring the
332 /// `SystemAsterisk` / `MB_OK` chime.
333 #[test]
334 #[cfg(target_os = "windows")]
335 fn auto_detect_picks_off_for_unknown_on_windows() {
336 let _lock = env_lock();
337 let prev = std::env::var_os("TERM_PROGRAM");
338 // SAFETY: test-only; serialised by env_lock().
339 unsafe { std::env::set_var("TERM_PROGRAM", "Windows Terminal") };
340 let resolved = resolve_method();
341 // SAFETY: test-only; serialised by env_lock().
342 unsafe {
343 match prev {
344 Some(v) => std::env::set_var("TERM_PROGRAM", v),
345 None => std::env::remove_var("TERM_PROGRAM"),
346 }
347 }
348 assert_eq!(resolved, Method::Off);
349 }
350
351 /// #583: known OSC-9 terminals must still resolve to `Osc9` on
352 /// Windows — the off-fallback only applies to unrecognised
353 /// `TERM_PROGRAM`. The cross-platform iTerm test above is a thin
354 /// proxy because iTerm itself only runs on macOS; if the WezTerm
355 /// arm of the match silently disappeared, that test would still
356 /// pass on the Windows runner and we'd lose the WezTerm-on-Windows
357 /// compatibility guarantee. Pin it directly.
358 #[test]
359 #[cfg(target_os = "windows")]
360 fn auto_detect_picks_osc9_for_wezterm_on_windows() {
361 let _lock = env_lock();
362 let prev = std::env::var_os("TERM_PROGRAM");
363 // SAFETY: test-only; serialised by env_lock().
364 unsafe { std::env::set_var("TERM_PROGRAM", "WezTerm") };
365 let resolved = resolve_method();
366 // SAFETY: test-only; serialised by env_lock().
367 unsafe {
368 match prev {
369 Some(v) => std::env::set_var("TERM_PROGRAM", v),
370 None => std::env::remove_var("TERM_PROGRAM"),
371 }
372 }
373 assert_eq!(resolved, Method::Osc9);
374 }
375
376 #[test]
377 fn humanize_duration_seconds_and_minutes() {
378 assert_eq!(humanize_duration(Duration::from_secs(0)), "0s");
379 assert_eq!(humanize_duration(Duration::from_secs(45)), "45s");
380 assert_eq!(humanize_duration(Duration::from_secs(60)), "1m");
381 assert_eq!(humanize_duration(Duration::from_secs(72)), "1m 12s");
382 // 59m 59s — still under the hour boundary.
383 assert_eq!(humanize_duration(Duration::from_secs(3599)), "59m 59s");
384 }
385
386 #[test]
387 fn humanize_duration_promotes_to_hours_at_one_hour() {
388 // 3661s = 1h 1m 1s — under the new format the seconds fall
389 // off; we keep just the top two units at the hour mark.
390 assert_eq!(humanize_duration(Duration::from_secs(3661)), "1h 1m");
391 assert_eq!(humanize_duration(Duration::from_secs(3600)), "1h");
392 assert_eq!(humanize_duration(Duration::from_secs(7200)), "2h");
393 assert_eq!(humanize_duration(Duration::from_secs(7320)), "2h 2m");
394 // 3h 12m — the previous "192m 30s" case that motivated #447.
395 assert_eq!(humanize_duration(Duration::from_secs(11_550)), "3h 12m");
396 }
397
398 #[test]
399 fn humanize_duration_handles_multi_day_sessions() {
400 // Exactly one day.
401 assert_eq!(humanize_duration(Duration::from_secs(86_400)), "1d");
402 // 1d 1h.
403 assert_eq!(humanize_duration(Duration::from_secs(90_000)), "1d 1h");
404 // 2d 5h — the two-tier rule drops minutes/seconds.
405 assert_eq!(
406 humanize_duration(Duration::from_secs(2 * 86_400 + 5 * 3600 + 17 * 60)),
407 "2d 5h"
408 );
409 }
410
411 #[test]
412 fn humanize_duration_promotes_to_weeks_after_seven_days() {
413 assert_eq!(humanize_duration(Duration::from_secs(604_800)), "1w");
414 assert_eq!(
415 humanize_duration(Duration::from_secs(604_800 + 86_400)),
416 "1w 1d"
417 );
418 // 3w 2d — long-running automation case.
419 assert_eq!(
420 humanize_duration(Duration::from_secs(3 * 604_800 + 2 * 86_400 + 17 * 3600)),
421 "3w 2d"
422 );
423 }
424 }
425
425 lines RUST