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 <serenitygp@qq.com>
This commit is contained in:
committed by
Hunter Bown
parent
a493b31d44
commit
60347b8940
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user