diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index ef96ea34..ee46fbc4 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -418,15 +418,18 @@ pub enum NotificationCondition { #[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 on - /// macOS / Linux otherwise; on Windows the fallback is `Off` - /// because BEL maps to the system error chime there (#583). + /// Auto-detect: picks the best protocol for the current terminal + /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel). #[default] Auto, /// OSC 9 escape. Osc9, /// Plain BEL character. Bel, + /// Kitty notification protocol (OSC 99). + Kitty, + /// Ghostty notification protocol (OSC 777). + Ghostty, /// Disable notifications. Off, } diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs index b1aec19f..94f2e7c2 100644 --- a/crates/tui/src/tui/notifications.rs +++ b/crates/tui/src/tui/notifications.rs @@ -1,9 +1,15 @@ -//! OSC 9 / BEL desktop notifications for long agent-turn completion. +//! Desktop notifications for 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. +//! Supports five delivery mechanisms: +//! - **OSC 9** — terminal escape sequence (`\x1b]9;…\x07`) for iTerm2, +//! Ghostty, WezTerm, and tmux (with DCS passthrough). +//! - **Kitty** — OSC 99 protocol with ST terminator (no audible beep). +//! - **Ghostty** — OSC 777 notification protocol. +//! - **BEL** — audible bell (`\x07`) as a last-resort fallback. +//! +//! When `method = "auto"`, the resolver picks the best method for the +//! current terminal; Windows falls back to `Off` to avoid the error chime +//! (#583). #[cfg(target_os = "windows")] use windows::Win32::System::Diagnostics::Debug::MessageBeep; @@ -16,20 +22,20 @@ 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`); fall back to `Bel` on - /// macOS / Linux. On Windows the fallback is `Off` instead of - /// `Bel`, because the OS audio stack maps `\x07` to the - /// `SystemAsterisk` / `MB_OK` chime — the same sound used by - /// application error popups (#583). Windows users who want an - /// audible cue can opt in by setting - /// `[notifications].method = "bel"` explicitly. + /// Automatically pick the best protocol for the current terminal. + /// See [`resolve_method`] for the canonical resolution table. #[default] Auto, /// OSC 9 escape: `\x1b]9;\x07` Osc9, /// Plain BEL character: `\x07` Bel, + /// Kitty notification protocol (OSC 99) with ST terminator. + /// Uses `ESC ] 99 ; params ST` — no audible beep, unlike BEL. + Kitty, + /// Ghostty notification protocol (OSC 777). + /// Uses `ESC ] 777 ; notify ; title ; message BEL`. + Ghostty, /// Suppress all notifications. Off, } @@ -49,53 +55,70 @@ fn windows_bell() { } } -/// Resolve `Auto` to a concrete method by inspecting `$TERM_PROGRAM` and -/// `$LC_TERMINAL`. +/// Resolve `Auto` to a concrete method by inspecting `$TERM_PROGRAM`, +/// `$LC_TERMINAL`, and `$TERM`. /// -/// Known OSC-9 capable programs: `iTerm.app`, `Ghostty`, `WezTerm`, `Cmux` -/// (these resolve to `Osc9` on every platform, including Windows -/// when running inside WezTerm). -/// -/// The probe order is: `$TERM_PROGRAM` first, then `$LC_TERMINAL` as a -/// fallback for terminals (e.g. Cmux) that set `LC_TERMINAL` instead of -/// `TERM_PROGRAM`. If neither env var matches a known OSC-9 capable terminal, -/// the fallback is platform-dependent: -/// - **macOS / Linux / other Unix:** `Bel` (a single `\x07` byte). -/// - **Windows:** `Off`. BEL is mapped by the Windows audio stack -/// to `SystemAsterisk` / `MB_OK`, the same chime used by -/// application error popups, so it sounds like an error -/// notification even though the turn completed successfully (#583). -/// Users can opt back in with `[notifications].method = "bel"` or -/// pick a known OSC-9 terminal. -/// -/// Terminals that set neither env var (e.g. Cmux in some configurations) -/// can force OSC 9 with `[notifications].method = "osc9"` in the config file. +/// Resolution table: +/// - `iTerm.app`, `WezTerm`, `Cmux` → `Osc9` +/// - `Ghostty` → `Ghostty` (OSC 777) +/// - `kitty` → `Kitty` (OSC 99) +/// - `$LC_TERMINAL` matches OSC-9 capable → `Osc9` (Cmux that sets LC_TERMINAL) +/// - `$TERM` contains `ghostty` → `Osc9` (cmux etc.) +/// - `$TERM` contains `kitty` → `Kitty` +/// - Unix unknown → `Bel` +/// - Windows unknown → `Off` #[must_use] fn resolve_method() -> Method { - const OSC9_TERMINALS: &[&str] = &["iTerm.app", "Ghostty", "WezTerm", "Cmux"]; - let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default(); - if OSC9_TERMINALS.contains(&term_program.as_str()) { - return Method::Osc9; + match term_program.as_str() { + "iTerm.app" | "WezTerm" | "Cmux" => return Method::Osc9, + "Ghostty" => return Method::Ghostty, + "kitty" => return Method::Kitty, + _ => {} } + // LC_TERMINAL fallback for terminals (e.g. Cmux) that set + // LC_TERMINAL instead of TERM_PROGRAM. let lc_terminal = std::env::var("LC_TERMINAL").unwrap_or_default(); - if OSC9_TERMINALS.contains(&lc_terminal.as_str()) { - return Method::Osc9; + match lc_terminal.as_str() { + "iTerm.app" | "Ghostty" | "WezTerm" | "Cmux" => return Method::Osc9, + _ => {} } if cfg!(target_os = "windows") { - Method::Off + return Method::Off; + } + + // Ghostty-based terminals (cmux, etc.) may not set their own + // TERM_PROGRAM but do set TERM=xterm-ghostty. Likewise for Kitty. + let term = std::env::var("TERM").unwrap_or_default(); + if term.contains("ghostty") { + Method::Osc9 + } else if term.contains("kitty") { + Method::Kitty } else { Method::Bel } } +/// Wrap an escape sequence for terminal multiplexer passthrough. +/// +/// tmux intercepts escape sequences; DCS passthrough tunnels them to +/// the outer terminal unmodified. Every ESC inside the payload is +/// doubled so tmux does not interpret it as DCS end. +fn wrap_for_multiplexer(seq: &str, in_tmux: bool) -> String { + if in_tmux { + let escaped = seq.replace('\x1b', "\x1b\x1b"); + format!("\x1bPtmux;{escaped}\x1b\\") + } else { + seq.to_string() + } +} + /// 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\\` +/// When `in_tmux` is `true`, OSC sequences are wrapped in DCS passthrough +/// so tmux forwards them to the outer terminal. #[must_use] fn build_escape(method: Method, in_tmux: bool, msg: &str) -> Vec { match method { @@ -103,14 +126,26 @@ fn build_escape(method: Method, in_tmux: bool, msg: &str) -> Vec { 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() } } + Method::Kitty => { + // Kitty notification: OSC 99 ; params ST + // ST terminator (ESC \) instead of BEL to avoid audible beep. + let title_seq = "\x1b]99;d=0:p=title\x1b\\"; + let body_seq = format!("\x1b]99;p=body;{msg}\x1b\\"); + let focus_seq = "\x1b]99;d=1:a=focus\x1b\\"; + let combined = format!("{title_seq}{body_seq}{focus_seq}"); + wrap_for_multiplexer(&combined, in_tmux).into_bytes() + } + Method::Ghostty => { + // Ghostty notification: OSC 777 ; notify ; title ; message BEL + let seq = format!("\x1b]777;notify;DeepSeek TUI;{msg}\x07"); + wrap_for_multiplexer(&seq, in_tmux).into_bytes() + } // Auto and Off should not reach build_escape. Method::Auto | Method::Off => vec![], } @@ -155,13 +190,13 @@ pub fn notify_done_to( /// Emit a turn-complete notification to **stdout** if `elapsed >= threshold`. /// -/// With `method = Auto`, selects `Osc9` for known capable terminals -/// (`iTerm.app`, `Ghostty`, `WezTerm`); the unknown-terminal fallback is -/// platform-aware — `Bel` on macOS / Linux, `Off` on Windows (where BEL -/// maps to the `SystemAsterisk` / `MB_OK` error chime, #583). See -/// [`resolve_method`] for the canonical resolution table. Pass -/// `in_tmux = true` (i.e. `$TMUX` is non-empty at runtime) to wrap OSC 9 -/// in a DCS passthrough. +/// With `method = Auto`, selects the best protocol for the current terminal +/// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel). The unknown-terminal +/// fallback is platform-aware — `Bel` on macOS / Linux, `Off` on Windows +/// (where BEL maps to the `SystemAsterisk` / `MB_OK` error chime, #583). +/// See [`resolve_method`] for the canonical resolution table. Pass +/// `in_tmux = true` (i.e. `$TMUX` is non-empty at runtime) to wrap OSC +/// sequences in a DCS passthrough. pub fn notify_done( method: Method, in_tmux: bool, @@ -285,6 +320,41 @@ mod tests { assert!(out.is_empty()); } + #[test] + fn kitty_escape_uses_st_terminator() { + let out = capture(Method::Kitty, false, "done", 0, 1); + let s = String::from_utf8(out).unwrap(); + assert!(s.contains("99;"), "should have kitty OSC 99"); + assert!(s.contains("\x1b\\"), "kitty uses ST terminator"); + assert!(!s.contains("\x07"), "kitty should NOT use BEL"); + } + + #[test] + fn ghostty_escape_format() { + let out = capture(Method::Ghostty, false, "done", 0, 1); + let s = String::from_utf8(out).unwrap(); + assert!( + s.contains("777;notify;DeepSeek TUI;done"), + "should have ghostty seq" + ); + } + + #[test] + fn kitty_tmux_dcs_passthrough() { + let out = capture(Method::Kitty, true, "hello", 0, 1); + let s = String::from_utf8(out).unwrap(); + assert!(s.starts_with("\x1bPtmux;"), "should start with DCS"); + assert!(s.ends_with("\x1b\\"), "should end with ST"); + } + + #[test] + fn ghostty_tmux_dcs_passthrough() { + let out = capture(Method::Ghostty, true, "hello", 0, 1); + let s = String::from_utf8(out).unwrap(); + assert!(s.starts_with("\x1bPtmux;"), "should start with DCS"); + assert!(s.ends_with("\x1b\\"), "should end with ST"); + } + #[test] fn below_threshold_emits_nothing() { let out = capture(Method::Osc9, false, "msg", 30, 29); @@ -388,13 +458,15 @@ mod tests { let _lock = env_lock(); let prev_tp = std::env::var_os("TERM_PROGRAM"); let prev_lc = std::env::var_os("LC_TERMINAL"); + let prev_term = std::env::var_os("TERM"); // SAFETY: test-only; serialised by env_lock(). - // Clear LC_TERMINAL so the LC_TERMINAL fallback probe does not - // accidentally pick up an OSC-9 capable terminal from the test runner - // environment and shadow the Bel fallback we're trying to verify. + // Clear LC_TERMINAL and TERM so the fallback probes don't + // accidentally pick up an OSC-9 / Kitty / Ghostty capable + // terminal from the test runner environment. unsafe { std::env::set_var("TERM_PROGRAM", "xterm-256color"); std::env::remove_var("LC_TERMINAL"); + std::env::set_var("TERM", "xterm-256color"); } let resolved = resolve_method(); // SAFETY: test-only; serialised by env_lock(). @@ -407,6 +479,10 @@ mod tests { Some(v) => std::env::set_var("LC_TERMINAL", v), None => std::env::remove_var("LC_TERMINAL"), } + match prev_term { + Some(v) => std::env::set_var("TERM", v), + None => std::env::remove_var("TERM"), + } } assert_eq!(resolved, Method::Bel); } @@ -457,6 +533,143 @@ mod tests { assert_eq!(resolved, Method::Osc9); } + /// Ghostty-based terminals (cmux, etc.) may not set + /// `TERM_PROGRAM` but do set `TERM=xterm-ghostty`. The `$TERM` + /// fallback should catch them. + #[test] + #[cfg(not(target_os = "windows"))] + fn auto_detect_picks_osc9_for_xterm_ghostty_term_fallback() { + let _lock = env_lock(); + let prev_tp = std::env::var_os("TERM_PROGRAM"); + let prev_lc = std::env::var_os("LC_TERMINAL"); + let prev_term = std::env::var_os("TERM"); + // Simulate a Ghostty-based terminal that only sets TERM. + // SAFETY: test-only; serialised by env_lock(). + unsafe { + std::env::remove_var("TERM_PROGRAM"); + std::env::remove_var("LC_TERMINAL"); + std::env::set_var("TERM", "xterm-ghostty"); + } + let resolved = resolve_method(); + // SAFETY: test-only; serialised by env_lock(). + unsafe { + match prev_tp { + Some(v) => std::env::set_var("TERM_PROGRAM", v), + None => std::env::remove_var("TERM_PROGRAM"), + } + match prev_lc { + Some(v) => std::env::set_var("LC_TERMINAL", v), + None => std::env::remove_var("LC_TERMINAL"), + } + match prev_term { + Some(v) => std::env::set_var("TERM", v), + None => std::env::remove_var("TERM"), + } + } + assert_eq!(resolved, Method::Osc9); + } + + /// Ghostty now has its own protocol (OSC 777). + #[test] + fn auto_detect_picks_ghostty_from_term_program() { + 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", "Ghostty") }; + 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::Ghostty); + } + + #[test] + fn auto_detect_picks_kitty_from_term_program() { + 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", "kitty") }; + 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::Kitty); + } + + #[test] + #[cfg(not(target_os = "windows"))] + fn auto_detect_picks_kitty_from_term_fallback() { + let _lock = env_lock(); + let prev_tp = std::env::var_os("TERM_PROGRAM"); + let prev_lc = std::env::var_os("LC_TERMINAL"); + let prev_term = std::env::var_os("TERM"); + // SAFETY: test-only; serialised by env_lock(). + unsafe { + std::env::remove_var("TERM_PROGRAM"); + std::env::remove_var("LC_TERMINAL"); + std::env::set_var("TERM", "xterm-kitty"); + } + let resolved = resolve_method(); + // SAFETY: test-only; serialised by env_lock(). + unsafe { + match prev_tp { + Some(v) => std::env::set_var("TERM_PROGRAM", v), + None => std::env::remove_var("TERM_PROGRAM"), + } + match prev_lc { + Some(v) => std::env::set_var("LC_TERMINAL", v), + None => std::env::remove_var("LC_TERMINAL"), + } + match prev_term { + Some(v) => std::env::set_var("TERM", v), + None => std::env::remove_var("TERM"), + } + } + assert_eq!(resolved, Method::Kitty); + } + + /// When neither `TERM_PROGRAM` nor `TERM` suggests a known capable + /// terminal, the fallback on Unix is `Bel`. + #[test] + #[cfg(not(target_os = "windows"))] + fn auto_detect_falls_back_to_bel_for_unrelated_term() { + let _lock = env_lock(); + let prev_tp = std::env::var_os("TERM_PROGRAM"); + let prev_lc = std::env::var_os("LC_TERMINAL"); + let prev_term = std::env::var_os("TERM"); + // SAFETY: test-only; serialised by env_lock(). + unsafe { + std::env::remove_var("TERM_PROGRAM"); + std::env::remove_var("LC_TERMINAL"); + std::env::set_var("TERM", "xterm-256color"); + } + let resolved = resolve_method(); + // SAFETY: test-only; serialised by env_lock(). + unsafe { + match prev_tp { + Some(v) => std::env::set_var("TERM_PROGRAM", v), + None => std::env::remove_var("TERM_PROGRAM"), + } + match prev_lc { + Some(v) => std::env::set_var("LC_TERMINAL", v), + None => std::env::remove_var("LC_TERMINAL"), + } + match prev_term { + Some(v) => std::env::set_var("TERM", v), + None => std::env::remove_var("TERM"), + } + } + assert_eq!(resolved, Method::Bel); + } + #[test] fn humanize_duration_seconds_and_minutes() { assert_eq!(humanize_duration(Duration::from_secs(0)), "0s"); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 366c65f6..74086f94 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3674,6 +3674,8 @@ fn notification_settings( crate::config::NotificationMethod::Auto => crate::tui::notifications::Method::Auto, crate::config::NotificationMethod::Osc9 => crate::tui::notifications::Method::Osc9, crate::config::NotificationMethod::Bel => crate::tui::notifications::Method::Bel, + crate::config::NotificationMethod::Kitty => crate::tui::notifications::Method::Kitty, + crate::config::NotificationMethod::Ghostty => crate::tui::notifications::Method::Ghostty, crate::config::NotificationMethod::Off => crate::tui::notifications::Method::Off, };