diff --git a/codex-rs/terminal-detection/src/lib.rs b/codex-rs/terminal-detection/src/lib.rs index 69b6f4745..6474c189a 100644 --- a/codex-rs/terminal-detection/src/lib.rs +++ b/codex-rs/terminal-detection/src/lib.rs @@ -145,10 +145,10 @@ impl TerminalInfo { /// Creates terminal metadata from a `TERM` capability value. fn from_term(term: String, multiplexer: Option) -> Self { - let name = if term == "dumb" { - TerminalName::Dumb - } else { - TerminalName::Unknown + let name = match term.as_str() { + "dumb" => TerminalName::Dumb, + "wezterm" | "wezterm-mux" => TerminalName::WezTerm, + _ => TerminalName::Unknown, }; Self::new( name, diff --git a/codex-rs/terminal-detection/src/terminal_tests.rs b/codex-rs/terminal-detection/src/terminal_tests.rs index 52974a0c3..ec2a3b116 100644 --- a/codex-rs/terminal-detection/src/terminal_tests.rs +++ b/codex-rs/terminal-detection/src/terminal_tests.rs @@ -456,6 +456,44 @@ fn detects_wezterm() { "WezTerm", "wezterm_empty_user_agent" ); + + let env = FakeEnvironment::new().with_var("TERM", "wezterm"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::WezTerm, + /*term_program*/ None, + /*version*/ None, + Some("wezterm"), + /*multiplexer*/ None + ), + "wezterm_term_info" + ); + assert_eq!( + terminal.user_agent_token(), + "wezterm", + "wezterm_term_user_agent" + ); + + let env = FakeEnvironment::new().with_var("TERM", "wezterm-mux"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::WezTerm, + /*term_program*/ None, + /*version*/ None, + Some("wezterm-mux"), + /*multiplexer*/ None + ), + "wezterm_mux_term_info" + ); + assert_eq!( + terminal.user_agent_token(), + "wezterm-mux", + "wezterm_mux_term_user_agent" + ); } #[test] diff --git a/codex-rs/tui/src/notifications/mod.rs b/codex-rs/tui/src/notifications/mod.rs index e5e108599..ee6694b35 100644 --- a/codex-rs/tui/src/notifications/mod.rs +++ b/codex-rs/tui/src/notifications/mod.rs @@ -1,11 +1,13 @@ mod bel; mod osc9; -use std::env; use std::io; use bel::BelBackend; use codex_config::types::NotificationMethod; +use codex_terminal_detection::TerminalInfo; +use codex_terminal_detection::TerminalName; +use codex_terminal_detection::terminal_info; use osc9::Osc9Backend; #[derive(Debug)] @@ -18,13 +20,13 @@ impl DesktopNotificationBackend { pub fn for_method(method: NotificationMethod) -> Self { match method { NotificationMethod::Auto => { - if supports_osc9() { - Self::Osc9(Osc9Backend) + if supports_osc9(&terminal_info()) { + Self::Osc9(Osc9Backend::new()) } else { Self::Bel(BelBackend) } } - NotificationMethod::Osc9 => Self::Osc9(Osc9Backend), + NotificationMethod::Osc9 => Self::Osc9(Osc9Backend::new()), NotificationMethod::Bel => Self::Bel(BelBackend), } } @@ -48,67 +50,33 @@ pub fn detect_backend(method: NotificationMethod) -> DesktopNotificationBackend DesktopNotificationBackend::for_method(method) } -fn supports_osc9() -> bool { - if env::var_os("WT_SESSION").is_some() { - return false; - } - // Prefer TERM_PROGRAM when present, but keep fallbacks for shells/launchers - // that don't set it (e.g., tmux/ssh) to avoid regressing OSC 9 support. - if matches!( - env::var("TERM_PROGRAM").ok().as_deref(), - Some("WezTerm" | "WarpTerminal" | "ghostty") - ) { - return true; - } - // iTerm still provides a strong session signal even when TERM_PROGRAM is missing. - if env::var_os("ITERM_SESSION_ID").is_some() { - return true; - } - // TERM-based hints cover kitty/wezterm setups without TERM_PROGRAM. +fn supports_osc9(terminal: &TerminalInfo) -> bool { matches!( - env::var("TERM").ok().as_deref(), - Some("xterm-kitty" | "wezterm" | "wezterm-mux") + terminal.name, + TerminalName::Ghostty + | TerminalName::Iterm2 + | TerminalName::Kitty + | TerminalName::WarpTerminal + | TerminalName::WezTerm ) } #[cfg(test)] mod tests { use super::detect_backend; + use super::supports_osc9; use codex_config::types::NotificationMethod; - use serial_test::serial; - use std::ffi::OsString; + use codex_terminal_detection::TerminalInfo; + use codex_terminal_detection::TerminalName; + use pretty_assertions::assert_eq; - struct EnvVarGuard { - key: &'static str, - original: Option, - } - - impl EnvVarGuard { - fn set(key: &'static str, value: &str) -> Self { - let original = std::env::var_os(key); - unsafe { - std::env::set_var(key, value); - } - Self { key, original } - } - - fn remove(key: &'static str) -> Self { - let original = std::env::var_os(key); - unsafe { - std::env::remove_var(key); - } - Self { key, original } - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - unsafe { - match &self.original { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), - } - } + fn test_terminal(name: TerminalName) -> TerminalInfo { + TerminalInfo { + name, + term_program: None, + version: None, + term: None, + multiplexer: None, } } @@ -129,28 +97,39 @@ mod tests { } #[test] - #[serial] - fn auto_prefers_bel_without_hints() { - let _term = EnvVarGuard::remove("TERM"); - let _term_program = EnvVarGuard::remove("TERM_PROGRAM"); - let _iterm = EnvVarGuard::remove("ITERM_SESSION_ID"); - let _wt = EnvVarGuard::remove("WT_SESSION"); - assert!(matches!( - detect_backend(NotificationMethod::Auto), - super::DesktopNotificationBackend::Bel(_) - )); + fn supports_osc9_for_supported_terminals() { + for name in [ + TerminalName::Ghostty, + TerminalName::Iterm2, + TerminalName::Kitty, + TerminalName::WarpTerminal, + TerminalName::WezTerm, + ] { + assert!( + supports_osc9(&test_terminal(name)), + "{name:?} should support OSC 9" + ); + } } #[test] - #[serial] - fn auto_uses_osc9_for_iterm() { - let _term = EnvVarGuard::remove("TERM"); - let _term_program = EnvVarGuard::remove("TERM_PROGRAM"); - let _iterm = EnvVarGuard::set("ITERM_SESSION_ID", "abc"); - let _wt = EnvVarGuard::remove("WT_SESSION"); - assert!(matches!( - detect_backend(NotificationMethod::Auto), - super::DesktopNotificationBackend::Osc9(_) - )); + fn supports_osc9_for_unsupported_terminals() { + for name in [ + TerminalName::AppleTerminal, + TerminalName::Alacritty, + TerminalName::Dumb, + TerminalName::GnomeTerminal, + TerminalName::Konsole, + TerminalName::Unknown, + TerminalName::VsCode, + TerminalName::Vte, + TerminalName::WindowsTerminal, + ] { + assert_eq!( + supports_osc9(&test_terminal(name)), + false, + "{name:?} should not support OSC 9" + ); + } } } diff --git a/codex-rs/tui/src/notifications/osc9.rs b/codex-rs/tui/src/notifications/osc9.rs index fd3a8ad96..bbe48f176 100644 --- a/codex-rs/tui/src/notifications/osc9.rs +++ b/codex-rs/tui/src/notifications/osc9.rs @@ -2,25 +2,55 @@ use std::fmt; use std::io; use std::io::stdout; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::terminal_info; use crossterm::Command; use ratatui::crossterm::execute; -#[derive(Debug, Default)] -pub struct Osc9Backend; +#[derive(Debug)] +pub struct Osc9Backend { + dcs_passthrough: bool, +} + +impl Default for Osc9Backend { + fn default() -> Self { + Self::new() + } +} impl Osc9Backend { + pub fn new() -> Self { + Self { + dcs_passthrough: matches!(terminal_info().multiplexer, Some(Multiplexer::Tmux { .. })), + } + } + pub fn notify(&mut self, message: &str) -> io::Result<()> { - execute!(stdout(), PostNotification(message.to_string())) + execute!( + stdout(), + PostNotification { + message: message.to_string(), + dcs_passthrough: self.dcs_passthrough, + } + ) } } /// Command that emits an OSC 9 desktop notification with a message. #[derive(Debug, Clone)] -pub struct PostNotification(pub String); +pub struct PostNotification { + pub message: String, + pub dcs_passthrough: bool, +} impl Command for PostNotification { fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { - write!(f, "\x1b]9;{}\x07", self.0) + if self.dcs_passthrough { + let escaped_message = escape_tmux_dcs_passthrough_payload(&self.message); + write!(f, "\x1bPtmux;\x1b\x1b]9;{escaped_message}\x07\x1b\\") + } else { + write!(f, "\x1b]9;{}\x07", self.message) + } } #[cfg(windows)] @@ -35,3 +65,63 @@ impl Command for PostNotification { true } } + +fn escape_tmux_dcs_passthrough_payload(message: &str) -> String { + message.replace('\u{1b}', "\u{1b}\u{1b}") +} + +#[cfg(test)] +mod tests { + use crossterm::Command; + use pretty_assertions::assert_eq; + + use super::PostNotification; + + #[test] + fn post_notification_writes_plain_osc9_sequence() { + let mut ansi = String::new(); + let command = PostNotification { + message: "hello".to_string(), + dcs_passthrough: false, + }; + + command + .write_ansi(&mut ansi) + .expect("OSC 9 command should format"); + + assert_eq!(ansi, "\u{1b}]9;hello\u{7}"); + } + + #[test] + fn post_notification_writes_tmux_dcs_wrapped_osc9_sequence() { + let mut ansi = String::new(); + let command = PostNotification { + message: "done".to_string(), + dcs_passthrough: true, + }; + + command + .write_ansi(&mut ansi) + .expect("OSC 9 command should format"); + + assert_eq!(ansi, "\u{1b}Ptmux;\u{1b}\u{1b}]9;done\u{7}\u{1b}\\"); + } + + #[test] + fn post_notification_escapes_escape_bytes_inside_tmux_payload() { + let mut ansi = String::new(); + let command = PostNotification { + message: "danger\u{1b}[31m".to_string(), + dcs_passthrough: true, + }; + + command + .write_ansi(&mut ansi) + .expect("OSC 9 command should format"); + + assert_eq!( + ansi, + "\u{1b}Ptmux;\u{1b}\u{1b}]9;danger\u{1b}\u{1b}[31m\u{7}\u{1b}\\" + ); + } +}