| 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 |