From 60347b89408ff7d550f32d3f77265cc149e6a04f Mon Sep 17 00:00:00 2001 From: CrepuscularIRIS Date: Sat, 9 May 2026 23:38:26 -0400 Subject: [PATCH] feat(notifications): add LC_TERMINAL fallback probe for Cmux and test coverage Cmux typically does not set TERM_PROGRAM; it sets LC_TERMINAL=Cmux instead. The previous resolve_method() only checked TERM_PROGRAM, causing Cmux users to fall back to the Bel method instead of OSC 9 notifications (#1281). Changes: - Add LC_TERMINAL as a secondary env-var probe in resolve_method(), checked after TERM_PROGRAM. This picks up Cmux (and any other OSC-9 capable terminal that sets LC_TERMINAL rather than TERM_PROGRAM). - Add Cmux to the OSC9_TERMINALS allowlist. - Document that terminals setting neither env var can force OSC 9 with [notifications].method = "osc9" in the config file. - Add two new tests: - auto_detect_picks_osc9_for_cmux_via_lc_terminal - auto_detect_picks_osc9_for_wezterm_via_lc_terminal - Harden existing auto_detect_picks_bel_for_unknown_on_unix to clear LC_TERMINAL before asserting the Bel fallback, preventing flakiness in test runner environments where LC_TERMINAL is set to a known terminal. - Update NotificationsConfig.method doc to mention Cmux and the LC_TERMINAL probe. Signed-off-by: CrepuscularIRIS --- crates/tui/src/config.rs | 10 +-- crates/tui/src/tui/notifications.rs | 104 +++++++++++++++++++++++++--- 2 files changed, 100 insertions(+), 14 deletions(-) diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 6ff800e3..ce33976d 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -432,10 +432,12 @@ fn default_threshold_secs() -> u64 { #[derive(Debug, Clone, Deserialize, Default)] pub struct NotificationsConfig { /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`. - /// `auto` resolves to OSC 9 in iTerm.app / Ghostty / WezTerm; on - /// macOS / Linux it falls back to BEL, and on Windows it falls - /// back to `Off` so the post-turn notification doesn't ring the - /// system error chime (#583). + /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux + /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux + /// it falls back to BEL, and on Windows it falls back to `Off` so the + /// post-turn notification doesn't ring the system error chime (#583). + /// Use `method = "osc9"` explicitly when your terminal is OSC-9 capable + /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`). #[serde(default)] pub method: NotificationMethod, /// Only notify when the turn took at least this many seconds. Default: 30. diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs index 88188349..b1aec19f 100644 --- a/crates/tui/src/tui/notifications.rs +++ b/crates/tui/src/tui/notifications.rs @@ -49,13 +49,17 @@ fn windows_bell() { } } -/// Resolve `Auto` to a concrete method by inspecting `$TERM_PROGRAM`. +/// Resolve `Auto` to a concrete method by inspecting `$TERM_PROGRAM` and +/// `$LC_TERMINAL`. /// -/// Known OSC-9 capable programs: `iTerm.app`, `Ghostty`, `WezTerm` +/// Known OSC-9 capable programs: `iTerm.app`, `Ghostty`, `WezTerm`, `Cmux` /// (these resolve to `Osc9` on every platform, including Windows /// when running inside WezTerm). /// -/// Otherwise the fallback is platform-dependent: +/// 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 @@ -63,13 +67,27 @@ fn windows_bell() { /// 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. #[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(); - match term_program.as_str() { - "iTerm.app" | "Ghostty" | "WezTerm" => Method::Osc9, - _ if cfg!(target_os = "windows") => Method::Off, - _ => Method::Bel, + if OSC9_TERMINALS.contains(&term_program.as_str()) { + return Method::Osc9; + } + + let lc_terminal = std::env::var("LC_TERMINAL").unwrap_or_default(); + if OSC9_TERMINALS.contains(&lc_terminal.as_str()) { + return Method::Osc9; + } + + if cfg!(target_os = "windows") { + Method::Off + } else { + Method::Bel } } @@ -309,20 +327,86 @@ mod tests { assert_eq!(resolved, Method::Osc9); } + /// Cmux in typical configurations does not set `TERM_PROGRAM`; it sets + /// `LC_TERMINAL=Cmux` instead. Verify the `LC_TERMINAL` fallback probe + /// correctly resolves to `Osc9`. + #[test] + fn auto_detect_picks_osc9_for_cmux_via_lc_terminal() { + let _lock = env_lock(); + let prev_tp = std::env::var_os("TERM_PROGRAM"); + let prev_lc = std::env::var_os("LC_TERMINAL"); + // SAFETY: test-only; serialised by env_lock(). + unsafe { + std::env::remove_var("TERM_PROGRAM"); + std::env::set_var("LC_TERMINAL", "Cmux"); + } + 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"), + } + } + assert_eq!(resolved, Method::Osc9); + } + + /// `LC_TERMINAL` should also match other OSC-9 capable terminals in case + /// they set it in addition to or instead of `TERM_PROGRAM`. + #[test] + fn auto_detect_picks_osc9_for_wezterm_via_lc_terminal() { + let _lock = env_lock(); + let prev_tp = std::env::var_os("TERM_PROGRAM"); + let prev_lc = std::env::var_os("LC_TERMINAL"); + // SAFETY: test-only; serialised by env_lock(). + unsafe { + std::env::remove_var("TERM_PROGRAM"); + std::env::set_var("LC_TERMINAL", "WezTerm"); + } + 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"), + } + } + assert_eq!(resolved, Method::Osc9); + } + #[test] #[cfg(not(target_os = "windows"))] fn auto_detect_picks_bel_for_unknown_on_unix() { let _lock = env_lock(); - let prev = std::env::var_os("TERM_PROGRAM"); + let prev_tp = std::env::var_os("TERM_PROGRAM"); + let prev_lc = std::env::var_os("LC_TERMINAL"); // SAFETY: test-only; serialised by env_lock(). - unsafe { std::env::set_var("TERM_PROGRAM", "xterm-256color") }; + // 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. + unsafe { + std::env::set_var("TERM_PROGRAM", "xterm-256color"); + std::env::remove_var("LC_TERMINAL"); + } let resolved = resolve_method(); // SAFETY: test-only; serialised by env_lock(). unsafe { - match prev { + 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"), + } } assert_eq!(resolved, Method::Bel); }