From 6432d47c5363fd544ecb6142ac7732ccaabf263a Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 27 Apr 2026 23:52:58 -0500 Subject: [PATCH] feat: #132 emit OSC 9 / BEL desktop notification on long turn completion Adds crates/tui/src/tui/notifications.rs with Method enum (Auto/Osc9/Bel/Off), notify_done / notify_done_to helpers, tmux DCS passthrough, and 9 unit tests. Wires the hook at the TurnComplete event in tui/ui.rs so turns >= threshold_secs (default 30 s) emit an escape to stdout; method auto-detects iTerm.app/Ghostty/ WezTerm for OSC 9 and falls back to BEL. Config exposed under [notifications] in config.toml and documented in config.example.toml. Co-Authored-By: Claude Sonnet 4.6 --- config.example.toml | 18 ++ crates/tui/src/config.rs | 45 +++++ crates/tui/src/tui/mod.rs | 1 + crates/tui/src/tui/notifications.rs | 256 ++++++++++++++++++++++++++++ crates/tui/src/tui/ui.rs | 45 ++++- 5 files changed, 361 insertions(+), 4 deletions(-) create mode 100644 crates/tui/src/tui/notifications.rs diff --git a/config.example.toml b/config.example.toml index b438f6a0..19c48dcb 100644 --- a/config.example.toml +++ b/config.example.toml @@ -161,6 +161,24 @@ api_key = "YOUR_NVIDIA_API_KEY" base_url = "https://integrate.api.nvidia.com/v1" default_text_model = "deepseek-ai/deepseek-v4-pro" +# ───────────────────────────────────────────────────────────────────────────────── +# Desktop Notifications (OSC 9 / BEL on long agent-turn completion) +# ───────────────────────────────────────────────────────────────────────────────── +# Emits an escape sequence to the terminal when a turn finishes and took longer +# than `threshold_secs`. Useful when you tab away from the TUI and want an alert. +# +# method = "auto" # auto | osc9 | bel | off +# auto: OSC 9 for iTerm.app / Ghostty / WezTerm, BEL otherwise. +# osc9: \x1b]9;\x07 (iTerm2-style; shows macOS notification) +# bel: plain \x07 beep +# off: disable entirely +# threshold_secs = 30 # only notify when the turn took >= this many seconds +# include_summary = false # include elapsed time + cost in the notification body +[notifications] +# method = "auto" +# threshold_secs = 30 +# include_summary = false + # ───────────────────────────────────────────────────────────────────────────────── # Hooks (optional) # ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 9c4f24ca..a7681383 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -158,6 +158,40 @@ pub struct TuiConfig { pub status_items: Option>, } +/// Notification delivery method (mirrors `tui::notifications::Method`). +#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum NotificationMethod { + /// Auto-detect: OSC 9 for iTerm.app / Ghostty / WezTerm; BEL otherwise. + #[default] + Auto, + /// OSC 9 escape. + Osc9, + /// Plain BEL character. + Bel, + /// Disable notifications. + Off, +} + +fn default_threshold_secs() -> u64 { + 30 +} + +/// Desktop-notification configuration (OSC 9 / BEL on turn completion). +#[derive(Debug, Clone, Deserialize, Default)] +pub struct NotificationsConfig { + /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`. + #[serde(default)] + pub method: NotificationMethod, + /// Only notify when the turn took at least this many seconds. Default: 30. + #[serde(default = "default_threshold_secs")] + pub threshold_secs: u64, + /// Include a short summary (elapsed time + cost) in the notification body. + /// Default: `false`. + #[serde(default)] + pub include_summary: bool, +} + /// One configurable footer item. /// /// Order in the user's `Vec` is preserved: items in the left @@ -386,6 +420,10 @@ pub struct Config { /// Provider-specific credentials and defaults shared with the `deepseek` facade. #[serde(default)] pub providers: Option, + + /// Desktop notification settings (OSC 9 / BEL on long turn completion). + #[serde(default)] + pub notifications: Option, } #[derive(Debug, Clone, Default, Deserialize)] @@ -776,6 +814,12 @@ impl Config { self.hooks.clone().unwrap_or_default() } + /// Resolve the notifications configuration with defaults applied. + #[must_use] + pub fn notifications_config(&self) -> NotificationsConfig { + self.notifications.clone().unwrap_or_default() + } + /// Resolve enabled features from defaults and config entries. #[must_use] pub fn features(&self) -> Features { @@ -1283,6 +1327,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { hooks: override_cfg.hooks.or(base.hooks), providers: merge_providers(base.providers, override_cfg.providers), features: merge_features(base.features, override_cfg.features), + notifications: override_cfg.notifications.or(base.notifications), } } diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index 4d2db80f..5eeca1a1 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -18,6 +18,7 @@ pub mod keybindings; pub mod live_transcript; pub mod markdown_render; pub mod model_picker; +pub mod notifications; pub mod onboarding; pub mod pager; pub mod paste; diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs new file mode 100644 index 00000000..6206ca7f --- /dev/null +++ b/crates/tui/src/tui/notifications.rs @@ -0,0 +1,256 @@ +//! OSC 9 / BEL desktop notifications for long agent-turn completion. +//! +//! Writes a terminal escape to the provided sink (or stdout for the public +//! API) when a turn takes longer than the configured threshold. Supports +//! tmux DCS passthrough so OSC 9 reaches the outer terminal even when +//! running inside a tmux session. + +use std::io::{self, Write}; +use std::time::Duration; + +/// Notification delivery method. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Method { + /// Automatically pick `Osc9` for known capable terminals + /// (`iTerm.app`, `Ghostty`, `WezTerm`) and fall back to `Bel` otherwise. + #[default] + Auto, + /// OSC 9 escape: `\x1b]9;\x07` + Osc9, + /// Plain BEL character: `\x07` + Bel, + /// Suppress all notifications. + Off, +} + +impl Method { + /// Parse from a configuration string (case-insensitive). + #[must_use] + pub fn from_str(s: &str) -> Self { + match s.trim().to_ascii_lowercase().as_str() { + "osc9" | "osc-9" => Self::Osc9, + "bel" => Self::Bel, + "off" | "disabled" | "none" => Self::Off, + _ => Self::Auto, + } + } +} + +/// Resolve `Auto` to a concrete method by inspecting `$TERM_PROGRAM`. +/// +/// Known OSC-9 capable programs: `iTerm.app`, `Ghostty`, `WezTerm`. +/// Everything else falls back to `Bel`. +#[must_use] +fn resolve_method() -> Method { + let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default(); + match term_program.as_str() { + "iTerm.app" | "Ghostty" | "WezTerm" => Method::Osc9, + _ => Method::Bel, + } +} + +/// Build the raw escape bytes for the given method and message. +/// +/// When `in_tmux` is `true` and the method is `Osc9`, the sequence is +/// wrapped in a DCS passthrough so tmux forwards it to the outer terminal: +/// `\x1bPtmux;\x1b\x1b\\` +#[must_use] +fn build_escape(method: Method, in_tmux: bool, msg: &str) -> Vec { + match method { + Method::Bel => vec![b'\x07'], + Method::Osc9 => { + let inner = format!("\x1b]9;{msg}\x07"); + if in_tmux { + // DCS passthrough: every ESC inside the payload must be + // doubled so tmux does not interpret it as DCS end. + let escaped_inner = inner.replace('\x1b', "\x1b\x1b"); + format!("\x1bPtmux;{escaped_inner}\x1b\\").into_bytes() + } else { + inner.into_bytes() + } + } + // Auto and Off should not reach build_escape. + Method::Auto | Method::Off => vec![], + } +} + +/// Emit a turn-complete notification to `sink` if the elapsed time meets or +/// exceeds `threshold`, and `method` is not `Off`. +/// +/// This variant takes a `W: Write` sink for testability. +pub fn notify_done_to( + method: Method, + in_tmux: bool, + msg: &str, + threshold: Duration, + elapsed: Duration, + sink: &mut W, +) { + if elapsed < threshold { + return; + } + let effective = match method { + Method::Off => return, + Method::Auto => resolve_method(), + other => other, + }; + let bytes = build_escape(effective, in_tmux, msg); + if bytes.is_empty() { + return; + } + // Best-effort: ignore write errors (e.g. stdout closed). + let _ = sink.write_all(&bytes); + let _ = sink.flush(); +} + +/// Emit a turn-complete notification to **stdout** if `elapsed >= threshold`. +/// +/// With `method = Auto`, selects `Osc9` for known capable terminals and `Bel` +/// otherwise. Pass `in_tmux = true` (i.e. `$TMUX` is non-empty at runtime) +/// to wrap OSC 9 in a DCS passthrough. +pub fn notify_done( + method: Method, + in_tmux: bool, + msg: &str, + threshold: Duration, + elapsed: Duration, +) { + notify_done_to(method, in_tmux, msg, threshold, elapsed, &mut io::stdout()); +} + +/// Return a human-readable duration string, e.g. `"1m 12s"` or `"45s"`. +#[must_use] +pub fn humanize_duration(d: Duration) -> String { + let total = d.as_secs(); + if total == 0 { + return "0s".to_string(); + } + let minutes = total / 60; + let seconds = total % 60; + if minutes == 0 { + format!("{seconds}s") + } else if seconds == 0 { + format!("{minutes}m") + } else { + format!("{minutes}m {seconds}s") + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Mutex, OnceLock}; + + use super::*; + + /// Serialise all tests that mutate `TERM_PROGRAM` to prevent data races + /// when the test harness runs them in parallel threads. + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() + } + + fn capture( + method: Method, + in_tmux: bool, + msg: &str, + threshold_secs: u64, + elapsed_secs: u64, + ) -> Vec { + let mut buf = Vec::new(); + notify_done_to( + method, + in_tmux, + msg, + Duration::from_secs(threshold_secs), + Duration::from_secs(elapsed_secs), + &mut buf, + ); + buf + } + + #[test] + fn osc9_body_format() { + let out = capture(Method::Osc9, false, "deepseek: done", 0, 1); + assert_eq!(out, b"\x1b]9;deepseek: done\x07"); + } + + #[test] + fn bel_emits_exactly_one_byte() { + let out = capture(Method::Bel, false, "ignored", 0, 1); + assert_eq!(out, b"\x07"); + } + + #[test] + fn off_mode_emits_nothing() { + let out = capture(Method::Off, false, "ignored", 0, 9999); + assert!(out.is_empty()); + } + + #[test] + fn below_threshold_emits_nothing() { + let out = capture(Method::Osc9, false, "msg", 30, 29); + assert!(out.is_empty()); + } + + #[test] + fn at_threshold_emits() { + let out = capture(Method::Osc9, false, "msg", 30, 30); + assert!(!out.is_empty()); + } + + #[test] + fn tmux_dcs_passthrough_wraps_osc9() { + let out = capture(Method::Osc9, true, "hello", 0, 1); + let s = String::from_utf8(out).unwrap(); + assert!( + s.starts_with("\x1bPtmux;"), + "should start with DCS passthrough" + ); + assert!(s.ends_with("\x1b\\"), "should end with ST"); + assert!(s.contains("hello"), "should contain message"); + } + + #[test] + fn auto_detect_picks_osc9_for_iterm() { + let _lock = env_lock(); + let prev = std::env::var_os("TERM_PROGRAM"); + // SAFETY: test-only; serialised by env_lock(). + unsafe { std::env::set_var("TERM_PROGRAM", "iTerm.app") }; + let resolved = resolve_method(); + // Restore previous value. + // SAFETY: test-only; serialised by env_lock(). + unsafe { + match prev { + Some(v) => std::env::set_var("TERM_PROGRAM", v), + None => std::env::remove_var("TERM_PROGRAM"), + } + } + assert_eq!(resolved, Method::Osc9); + } + + #[test] + fn auto_detect_picks_bel_for_unknown() { + let _lock = env_lock(); + let prev = std::env::var_os("TERM_PROGRAM"); + // SAFETY: test-only; serialised by env_lock(). + unsafe { std::env::set_var("TERM_PROGRAM", "xterm-256color") }; + let resolved = resolve_method(); + // SAFETY: test-only; serialised by env_lock(). + unsafe { + match prev { + Some(v) => std::env::set_var("TERM_PROGRAM", v), + None => std::env::remove_var("TERM_PROGRAM"), + } + } + assert_eq!(resolved, Method::Bel); + } + + #[test] + fn humanize_duration_formats_correctly() { + assert_eq!(humanize_duration(Duration::from_secs(0)), "0s"); + assert_eq!(humanize_duration(Duration::from_secs(45)), "45s"); + assert_eq!(humanize_duration(Duration::from_secs(60)), "1m"); + assert_eq!(humanize_duration(Duration::from_secs(72)), "1m 12s"); + assert_eq!(humanize_duration(Duration::from_secs(3661)), "61m 1s"); + } +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index b1fbeb05..f157f519 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -617,6 +617,10 @@ async fn run_event_loop( app.is_loading = false; app.offline_mode = false; app.streaming_state.reset(); + // Capture elapsed before clearing turn_started_at so + // notifications can use the real wall-clock duration. + let turn_elapsed = + app.turn_started_at.map(|t| t.elapsed()).unwrap_or_default(); app.turn_started_at = None; // Stream lock applies per-turn; clear it so the next // turn's chunks pull the view down again until the @@ -645,10 +649,43 @@ async fn run_event_loop( } // Update session cost - if let Some(turn_cost) = - crate::pricing::calculate_turn_cost_from_usage(&app.model, &usage) - { - app.session_cost += turn_cost; + let turn_cost = + crate::pricing::calculate_turn_cost_from_usage(&app.model, &usage); + if let Some(cost) = turn_cost { + app.session_cost += cost; + } + + // Emit OSC 9 / BEL desktop notification for long turns. + if status == crate::core::events::TurnOutcomeStatus::Completed { + let notif = config.notifications_config(); + let method = + crate::tui::notifications::Method::from_str(match ¬if.method { + crate::config::NotificationMethod::Auto => "auto", + crate::config::NotificationMethod::Osc9 => "osc9", + crate::config::NotificationMethod::Bel => "bel", + crate::config::NotificationMethod::Off => "off", + }); + let threshold = std::time::Duration::from_secs(notif.threshold_secs); + let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty()); + let msg = if notif.include_summary { + let human = + crate::tui::notifications::humanize_duration(turn_elapsed); + match turn_cost { + Some(c) => { + format!("deepseek: turn complete ({human}, ${c:.2})") + } + None => format!("deepseek: turn complete ({human})"), + } + } else { + "deepseek: turn complete".to_string() + }; + crate::tui::notifications::notify_done( + method, + in_tmux, + &msg, + threshold, + turn_elapsed, + ); } // Auto-save completed turn and clear crash checkpoint.