diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index d57ba01484..18149ce607 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -4348,6 +4348,7 @@ mod tests { fn terminal_inputs() -> TerminalCheckInputs { TerminalCheckInputs { info: TerminalInfo { + backend: None, name: TerminalName::Unknown, term_program: None, version: None, diff --git a/codex-rs/terminal-detection/src/lib.rs b/codex-rs/terminal-detection/src/lib.rs index 823a180ab2..26b51fa5b5 100644 --- a/codex-rs/terminal-detection/src/lib.rs +++ b/codex-rs/terminal-detection/src/lib.rs @@ -19,6 +19,18 @@ pub struct TerminalInfo { pub term: Option, /// Multiplexer metadata when a terminal multiplexer is active. pub multiplexer: Option, + /// Backend metadata observed independently of the terminal application identity. + pub backend: Option, +} + +/// Known terminal rendering backends derived from environment metadata. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TerminalBackend { + /// VTE, used by GNOME Terminal and other terminal applications. + Vte { + /// Encoded as major * 10_000 + minor * 100 + micro; unknown if malformed. + version: Option, + }, } /// Known terminal name categories derived from environment variables. @@ -86,6 +98,7 @@ impl TerminalInfo { version, term, multiplexer, + backend: None, } } @@ -252,6 +265,24 @@ pub fn terminal_info() -> TerminalInfo { /// `TERM_SESSION_ID` is not terminal-specific and cannot identify Apple Terminal. /// - Finally, `TERM` is used as the capability fallback. fn detect_terminal_info_from_env(env: &dyn Environment) -> TerminalInfo { + let mut terminal = detect_terminal_identity_from_env(env); + // Identity probes can return before VTE is considered. Keep its backend + // metadata even when GNOME or another TERM_PROGRAM identifies the application. + let vte_version = env.var("VTE_VERSION"); + if vte_version.is_some() || terminal.name == TerminalName::Vte { + terminal.backend = Some(TerminalBackend::Vte { + // Choose the raw value before parsing so malformed values retain + // precedence over the terminal-version fallback. + version: vte_version + .as_deref() + .or(terminal.version.as_deref()) + .and_then(|version| version.parse().ok()), + }); + } + terminal +} + +fn detect_terminal_identity_from_env(env: &dyn Environment) -> TerminalInfo { let multiplexer = detect_multiplexer(env); if let Some(term_program) = env.var_non_empty("TERM_PROGRAM") diff --git a/codex-rs/terminal-detection/src/terminal_tests.rs b/codex-rs/terminal-detection/src/terminal_tests.rs index 49d5d1583e..d6b8e12635 100644 --- a/codex-rs/terminal-detection/src/terminal_tests.rs +++ b/codex-rs/terminal-detection/src/terminal_tests.rs @@ -33,6 +33,7 @@ fn terminal_info( multiplexer: Option, ) -> TerminalInfo { TerminalInfo { + backend: None, name, term_program: term_program.map(ToString::to_string), version: version.map(ToString::to_string), @@ -299,6 +300,7 @@ fn detects_zellij_multiplexer() { assert_eq!( terminal, TerminalInfo { + backend: None, name: TerminalName::Unknown, term_program: None, version: None, @@ -665,13 +667,18 @@ fn detects_vte() { let terminal = detect_terminal_info_from_env(&env); assert_eq!( terminal, - terminal_info( - TerminalName::Vte, - /*term_program*/ None, - Some("7000"), - /*term*/ None, - /*multiplexer*/ None - ), + TerminalInfo { + backend: Some(TerminalBackend::Vte { + version: Some(7000) + }), + ..terminal_info( + TerminalName::Vte, + /*term_program*/ None, + Some("7000"), + /*term*/ None, + /*multiplexer*/ None + ) + }, "vte_version_info" ); assert_eq!( @@ -686,13 +693,18 @@ fn detects_vte() { let terminal = detect_terminal_info_from_env(&env); assert_eq!( terminal, - terminal_info( - TerminalName::Vte, - Some("VTE"), - Some("7000"), - /*term*/ None, - /*multiplexer*/ None - ), + TerminalInfo { + backend: Some(TerminalBackend::Vte { + version: Some(7000) + }), + ..terminal_info( + TerminalName::Vte, + Some("VTE"), + Some("7000"), + /*term*/ None, + /*multiplexer*/ None + ) + }, "vte_term_program_info" ); assert_eq!( @@ -705,13 +717,16 @@ fn detects_vte() { let terminal = detect_terminal_info_from_env(&env); assert_eq!( terminal, - terminal_info( - TerminalName::Vte, - /*term_program*/ None, - /*version*/ None, - /*term*/ None, - /*multiplexer*/ None - ), + TerminalInfo { + backend: Some(TerminalBackend::Vte { version: None }), + ..terminal_info( + TerminalName::Vte, + /*term_program*/ None, + /*version*/ None, + /*term*/ None, + /*multiplexer*/ None + ) + }, "vte_empty_info" ); assert_eq!(terminal.user_agent_token(), "VTE", "vte_empty_user_agent"); @@ -862,3 +877,46 @@ fn tmux_preserves_safe_underlying_terminal_identifiers() { ); } } + +#[test] +fn captures_vte_backend_when_gnome_identity_takes_precedence() { + let env = FakeEnvironment::new() + .with_var("GNOME_TERMINAL_SCREEN", "1") + .with_var("VTE_VERSION", "5204") + .with_var("TMUX", "session"); + assert_eq!( + detect_terminal_info_from_env(&env), + TerminalInfo { + name: TerminalName::GnomeTerminal, + term_program: None, + version: None, + term: None, + multiplexer: Some(Multiplexer::Tmux { version: None }), + backend: Some(TerminalBackend::Vte { + version: Some(5204) + }), + } + ); +} + +#[test] +fn raw_vte_version_takes_precedence_even_when_malformed() { + for (raw_version, version) in [("5204", Some(5204)), ("invalid", None), ("", None)] { + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "VTE") + .with_var("TERM_PROGRAM_VERSION", "7600") + .with_var("VTE_VERSION", raw_version); + assert_eq!( + detect_terminal_info_from_env(&env), + TerminalInfo { + name: TerminalName::Vte, + term_program: Some("VTE".to_string()), + version: Some("7600".to_string()), + term: None, + multiplexer: None, + backend: Some(TerminalBackend::Vte { version }), + }, + "VTE_VERSION={raw_version:?}" + ); + } +} diff --git a/codex-rs/tui/src/iterm_session_status_tests.rs b/codex-rs/tui/src/iterm_session_status_tests.rs index 7612916829..f3684e297e 100644 --- a/codex-rs/tui/src/iterm_session_status_tests.rs +++ b/codex-rs/tui/src/iterm_session_status_tests.rs @@ -1,6 +1,7 @@ use std::io; use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalBackend; use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use crossterm::Command; @@ -146,6 +147,7 @@ fn cache_changes_only_after_a_successful_write() { #[test] fn selects_protocols_for_terminal_environment() { let direct_iterm = TerminalInfo { + backend: None, name: TerminalName::Iterm2, term_program: Some("iTerm.app".to_string()), version: Some("3.7.0".to_string()), @@ -206,4 +208,50 @@ fn selects_protocols_for_terminal_environment() { ), generic_protocol ); + assert_eq!( + terminal_program_status_support( + &TerminalInfo { + backend: Some(TerminalBackend::Vte { + version: Some(5204) + }), + ..direct_iterm + }, + /*stdout_is_terminal*/ true, + /*screen_active*/ false, + ), + no_protocols + ); +} + +#[test] +fn selects_protocols_for_vte_backends() { + for name in [TerminalName::GnomeTerminal, TerminalName::Vte] { + for (version, osc_7501) in [ + (Some(5204), false), + (Some(5300), false), + (Some(5399), false), + (Some(5400), true), + (Some(7600), true), + (None, true), + ] { + let terminal = TerminalInfo { + name, + term_program: None, + version: None, + term: None, + multiplexer: None, + backend: Some(TerminalBackend::Vte { version }), + }; + assert_eq!( + terminal_program_status_support( + &terminal, /*stdout_is_terminal*/ true, /*screen_active*/ false, + ), + TerminalProgramStatusSupport { + osc_7501, + osc_21337: false, + }, + "terminal {name:?}, VTE backend version={version:?}" + ); + } + } } diff --git a/codex-rs/tui/src/local_settings_tests.rs b/codex-rs/tui/src/local_settings_tests.rs index 8515a72b2b..c67c874ee0 100644 --- a/codex-rs/tui/src/local_settings_tests.rs +++ b/codex-rs/tui/src/local_settings_tests.rs @@ -280,6 +280,7 @@ async fn copy_on_select_respects_terminal_defaults_and_config_overrides() -> any ), ] { let terminal = TerminalInfo { + backend: None, name, multiplexer, term_program: None, diff --git a/codex-rs/tui/src/markdown_render/web_links_tests.rs b/codex-rs/tui/src/markdown_render/web_links_tests.rs index cf8a08f929..3d5ea55cf6 100644 --- a/codex-rs/tui/src/markdown_render/web_links_tests.rs +++ b/codex-rs/tui/src/markdown_render/web_links_tests.rs @@ -67,6 +67,7 @@ fn bare_urls_keep_link_style_when_wrapped_in_tables() { fn terminal(name: TerminalName) -> TerminalInfo { TerminalInfo { + backend: None, name, term_program: None, version: None, diff --git a/codex-rs/tui/src/notifications/mod.rs b/codex-rs/tui/src/notifications/mod.rs index ee6694b357..5e2053c838 100644 --- a/codex-rs/tui/src/notifications/mod.rs +++ b/codex-rs/tui/src/notifications/mod.rs @@ -72,6 +72,7 @@ mod tests { fn test_terminal(name: TerminalName) -> TerminalInfo { TerminalInfo { + backend: None, name, term_program: None, version: None, diff --git a/codex-rs/tui/src/pets/image_protocol.rs b/codex-rs/tui/src/pets/image_protocol.rs index 947cedc180..9cbcb96a5e 100644 --- a/codex-rs/tui/src/pets/image_protocol.rs +++ b/codex-rs/tui/src/pets/image_protocol.rs @@ -637,6 +637,7 @@ mod tests { term: Option<&str>, ) -> TerminalInfo { TerminalInfo { + backend: None, name, term_program: term_program.map(str::to_string), version: version.map(str::to_string), diff --git a/codex-rs/tui/src/resize_reflow_cap.rs b/codex-rs/tui/src/resize_reflow_cap.rs index 4dd9ffb199..e690a77eb4 100644 --- a/codex-rs/tui/src/resize_reflow_cap.rs +++ b/codex-rs/tui/src/resize_reflow_cap.rs @@ -82,6 +82,7 @@ mod tests { fn test_terminal(name: TerminalName) -> TerminalInfo { TerminalInfo { + backend: None, name, term_program: None, version: None, @@ -165,6 +166,7 @@ mod tests { #[test] fn unknown_terminal_uses_fallback_even_under_multiplexer() { let terminal = TerminalInfo { + backend: None, name: TerminalName::Unknown, term_program: None, version: None, diff --git a/codex-rs/tui/src/terminal_program_status.rs b/codex-rs/tui/src/terminal_program_status.rs index 427b47d81d..f5b6788610 100644 --- a/codex-rs/tui/src/terminal_program_status.rs +++ b/codex-rs/tui/src/terminal_program_status.rs @@ -12,6 +12,7 @@ use std::io::stdout; use std::sync::Mutex; use std::sync::OnceLock; +use codex_terminal_detection::TerminalBackend; use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; @@ -21,6 +22,9 @@ use ratatui::crossterm::execute; static SESSION_STATUS_STATE: SessionStatusState = SessionStatusState::new(); static TERMINAL_PROGRAM_STATUS_SUPPORT: OnceLock = OnceLock::new(); +// VTE 0.54 introduced a parser that safely ignores unknown OSC sequences. +const MIN_VTE_VERSION_WITH_SAFE_OSC: u32 = 5400; + struct SessionStatusState { last_emitted: Mutex, } @@ -217,12 +221,19 @@ fn terminal_program_status_support( stdout_is_terminal: bool, screen_active: bool, ) -> TerminalProgramStatusSupport { - let osc_21337 = stdout_is_terminal + let osc_7501 = stdout_is_terminal + && match terminal.backend { + Some(TerminalBackend::Vte { version }) => { + version.is_none_or(|version| version >= MIN_VTE_VERSION_WITH_SAFE_OSC) + } + None => true, + }; + let osc_21337 = osc_7501 && terminal.name == TerminalName::Iterm2 && terminal.multiplexer.is_none() && !screen_active; TerminalProgramStatusSupport { - osc_7501: stdout_is_terminal, + osc_7501, osc_21337, } } diff --git a/codex-rs/tui/src/tui/scrollback_tests.rs b/codex-rs/tui/src/tui/scrollback_tests.rs index 33fdeb7052..27312d89a5 100644 --- a/codex-rs/tui/src/tui/scrollback_tests.rs +++ b/codex-rs/tui/src/tui/scrollback_tests.rs @@ -20,6 +20,7 @@ use ratatui::text::Line; #[test] fn windows_terminal_uses_full_screen_unless_zellij_is_active() { let mut terminal = TerminalInfo { + backend: None, name: TerminalName::WindowsTerminal, term_program: None, version: None, diff --git a/codex-rs/tui/src/tui/size_monitor_tests.rs b/codex-rs/tui/src/tui/size_monitor_tests.rs index 1986104da4..957b055008 100644 --- a/codex-rs/tui/src/tui/size_monitor_tests.rs +++ b/codex-rs/tui/src/tui/size_monitor_tests.rs @@ -245,6 +245,7 @@ async fn drop_does_not_wait_for_a_blocked_query_or_start_another() { fn monitor_requires_tmux_detection() { let (draw_tx, _) = broadcast::channel(/*capacity*/ 1); let mut info = TerminalInfo { + backend: None, name: TerminalName::Ghostty, term_program: None, version: None,