diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index f741c53ce0..8e1990f965 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1085,7 +1085,7 @@ "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4_process_security_environment_spec": "{\"dependencies\":[{\"name\":\"flatbuffers\",\"req\":\"25\"}],\"features\":{},\"strip_prefix\":\"core/generated/process_security_environment_specification\"}", "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4_sandbox_spec": "{\"dependencies\":[{\"name\":\"flatbuffers\",\"req\":\"25\"}],\"features\":{},\"strip_prefix\":\"core/generated/base_container_specification\"}", "git+https://github.com/microsoft/mxc?rev=6cd3d58f05d3447e67109cfb75e042803b843ca4#6cd3d58f05d3447e67109cfb75e042803b843ca4_wxc_common": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"0.22\"},{\"name\":\"cidr\",\"req\":\"0.3.2\"},{\"name\":\"getrandom\",\"req\":\"0.2\"},{\"default_features\":true,\"features\":[],\"name\":\"mxc_config_contract\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"mxc_schema_support\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"mxc_telemetry\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"nanvix_common\",\"optional\":true},{\"name\":\"schemars\",\"optional\":true,\"req\":\"0.8\"},{\"name\":\"semver\",\"req\":\"1\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":false,\"req\":\"1\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"req\":\"1\"},{\"name\":\"serde_path_to_error\",\"req\":\"0.1\"},{\"name\":\"thiserror\",\"req\":\"2\"},{\"name\":\"unicode-general-category\",\"req\":\"1\"},{\"name\":\"url\",\"req\":\"2\"},{\"default_features\":true,\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"1\"},{\"name\":\"widestring\",\"req\":\"1\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"default_features\":true,\"features\":[\"Win32_Foundation\",\"Win32_Globalization\",\"Win32_NetworkManagement_WindowsFirewall\",\"Win32_Networking_WinSock\",\"Win32_Security\",\"Win32_Security_Authorization\",\"Win32_Security_Credentials\",\"Win32_Security_Isolation\",\"Win32_Storage_FileSystem\",\"Win32_System_Com\",\"Win32_System_Console\",\"Win32_System_Diagnostics_Debug\",\"Win32_System_Diagnostics_Etw\",\"Win32_System_Diagnostics_ToolHelp\",\"Win32_System_Environment\",\"Win32_System_IO\",\"Win32_System_JobObjects\",\"Win32_System_LibraryLoader\",\"Win32_System_Memory\",\"Win32_System_Ole\",\"Win32_System_Pipes\",\"Win32_System_ProcessStatus\",\"Win32_System_Registry\",\"Win32_System_SystemInformation\",\"Win32_System_SystemServices\",\"Win32_System_Threading\",\"Win32_System_Time\",\"Win32_System_Variant\",\"Win32_System_WindowsProgramming\",\"Win32_UI_Shell\"],\"name\":\"windows\",\"optional\":false,\"req\":\"0.62\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"name\":\"windows-core\",\"req\":\"0.62\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"name\":\"winreg\",\"req\":\"0.55\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"name\":\"libc\",\"req\":\"0.2\",\"target\":\"cfg(unix)\"}],\"features\":{\"microvm\":[\"dep:nanvix_common\",\"dep:uuid\"],\"schema-gen\":[\"dep:mxc_schema_support\",\"dep:schemars\"]},\"strip_prefix\":\"core/wxc_common\"}", - "git+https://github.com/openai-oss-forks/crossterm?rev=45fecb9508105988f42fe6ff0441783ed3717f92#45fecb9508105988f42fe6ff0441783ed3717f92_crossterm": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"base64\",\"optional\":true,\"req\":\"0.22\"},{\"default_features\":true,\"features\":[],\"name\":\"bitflags\",\"optional\":false,\"req\":\"2.9\"},{\"default_features\":true,\"features\":[\"is_variant\"],\"name\":\"derive_more\",\"optional\":true,\"req\":\"2.0.0\"},{\"name\":\"document-features\",\"req\":\"0.2.11\"},{\"default_features\":false,\"features\":[],\"name\":\"futures-core\",\"optional\":true,\"req\":\"0.3\"},{\"name\":\"parking_lot\",\"req\":\"0.12\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"1.0\"},{\"default_features\":true,\"features\":[],\"name\":\"filedescriptor\",\"optional\":true,\"req\":\"0.8\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[],\"name\":\"libc\",\"optional\":true,\"req\":\"0.2\",\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"req\":\"1.0\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"optional\":false,\"req\":\"1\",\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"signal-hook\",\"optional\":true,\"req\":\"0.3.17\",\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"req\":\"0.2.4\",\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm_winapi\",\"optional\":true,\"req\":\"0.9.1\",\"target\":\"cfg(windows)\"},{\"default_features\":true,\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"req\":\"0.3.9\",\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"events\",\"windows\",\"derive-more\"],\"derive-more\":[\"dep:derive_more\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"osc52\":[\"dep:base64\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]},\"strip_prefix\":\"\"}", + "git+https://github.com/openai-oss-forks/crossterm?rev=efa177859fd9623d57b9fe7ae9bf491ae1ac6ec4#efa177859fd9623d57b9fe7ae9bf491ae1ac6ec4_crossterm": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"base64\",\"optional\":true,\"req\":\"0.22\"},{\"default_features\":true,\"features\":[],\"name\":\"bitflags\",\"optional\":false,\"req\":\"2.9\"},{\"default_features\":true,\"features\":[\"is_variant\"],\"name\":\"derive_more\",\"optional\":true,\"req\":\"2.0.0\"},{\"name\":\"document-features\",\"req\":\"0.2.11\"},{\"default_features\":false,\"features\":[],\"name\":\"futures-core\",\"optional\":true,\"req\":\"0.3\"},{\"name\":\"parking_lot\",\"req\":\"0.12\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"1.0\"},{\"default_features\":true,\"features\":[],\"name\":\"filedescriptor\",\"optional\":true,\"req\":\"0.8\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[],\"name\":\"libc\",\"optional\":true,\"req\":\"0.2\",\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"req\":\"1.0\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"optional\":false,\"req\":\"1\",\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"signal-hook\",\"optional\":true,\"req\":\"0.3.17\",\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"req\":\"0.2.4\",\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm_winapi\",\"optional\":true,\"req\":\"0.9.1\",\"target\":\"cfg(windows)\"},{\"default_features\":true,\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"req\":\"0.3.9\",\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"events\",\"windows\",\"derive-more\"],\"derive-more\":[\"dep:derive_more\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"osc52\":[\"dep:base64\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]},\"strip_prefix\":\"\"}", "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186#0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"tokio/time\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}", "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=4fffad30fe373adbdcffab9545e9e9bf4f2fc19f#4fffad30fe373adbdcffab9545e9e9bf4f2fc19f_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":false,\"features\":[\"zlib-rs\"],\"name\":\"flate2\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"headers\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"deflate\":[\"headers\",\"flate2\"],\"handshake\":[\"data-encoding\",\"headers\",\"httparse\",\"sha1\"],\"headers\":[\"http\",\"dep:headers\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}", "git+https://github.com/rust-lang/rust-clippy?rev=20ce69b9a63bcd2756cd906fe0964d1e901e042a#20ce69b9a63bcd2756cd906fe0964d1e901e042a_clippy_utils": "{\"dependencies\":[{\"default_features\":false,\"features\":[],\"name\":\"arrayvec\",\"optional\":false},{\"name\":\"itertools\"},{\"name\":\"rustc_apfloat\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":false}],\"features\":{},\"strip_prefix\":\"clippy_utils\"}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 087b257e91..9af76ee9cb 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -5658,7 +5658,7 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crossterm" version = "0.29.0" -source = "git+https://github.com/openai-oss-forks/crossterm?rev=45fecb9508105988f42fe6ff0441783ed3717f92#45fecb9508105988f42fe6ff0441783ed3717f92" +source = "git+https://github.com/openai-oss-forks/crossterm?rev=efa177859fd9623d57b9fe7ae9bf491ae1ac6ec4#efa177859fd9623d57b9fe7ae9bf491ae1ac6ec4" dependencies = [ "bitflags 2.13.1", "crossterm_winapi", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 1a43dd6f77..ac80e2ffe8 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -630,7 +630,7 @@ opt-level = 0 [patch.crates-io] # Uncomment to debug local changes. # ratatui = { path = "../../ratatui" } -crossterm = { git = "https://github.com/openai-oss-forks/crossterm", rev = "45fecb9508105988f42fe6ff0441783ed3717f92" } +crossterm = { git = "https://github.com/openai-oss-forks/crossterm", rev = "efa177859fd9623d57b9fe7ae9bf491ae1ac6ec4" } tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186" } tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 2d8cfd31fc..a08ccd859c 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1918,7 +1918,11 @@ async fn run_ratatui_app( } // Cloud configuration and session selection can change screen policy after first paint. - let use_alt_screen = determine_alt_screen_mode(cli.no_alt_screen, config.tui_alternate_screen); + let use_alt_screen = determine_alt_screen_mode( + cli.no_alt_screen, + config.tui_alternate_screen, + tui.terminal_app_over_ssh, + ); let mode = crate::transcript_mode::TranscriptMode::resolve( config.tui_fullscreen_transcript, use_alt_screen, @@ -2105,13 +2109,21 @@ impl Drop for TerminalRestoreGuard { /// - Otherwise, respect the `tui.alternate_screen` config setting: /// - `always`: Use alternate screen /// - `never`: Inline mode only, preserves scrollback -/// - `auto` (default): Use alternate screen -fn determine_alt_screen_mode(no_alt_screen: bool, tui_alternate_screen: AltScreenMode) -> bool { +/// - `auto` (default): Use native scrollback for Terminal.app over SSH, otherwise alternate screen +fn determine_alt_screen_mode( + no_alt_screen: bool, + tui_alternate_screen: AltScreenMode, + terminal_app_over_ssh: bool, +) -> bool { if no_alt_screen { return false; } - tui_alternate_screen != AltScreenMode::Never + match tui_alternate_screen { + AltScreenMode::Always => true, + AltScreenMode::Never => false, + AltScreenMode::Auto => !terminal_app_over_ssh, + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -2887,23 +2899,28 @@ requires_openai_auth = {requires_openai_auth} } #[test] - fn alternate_screen_auto_uses_alt_screen() { - assert!(determine_alt_screen_mode( - /*no_alt_screen*/ false, - AltScreenMode::Auto, - )); - assert!(determine_alt_screen_mode( - /*no_alt_screen*/ false, - AltScreenMode::Always, - )); - assert!(!determine_alt_screen_mode( - /*no_alt_screen*/ false, - AltScreenMode::Never, - )); - assert!(!determine_alt_screen_mode( - /*no_alt_screen*/ true, - AltScreenMode::Auto, - )); + fn alternate_screen_respects_terminal_compatibility_and_overrides() { + for terminal_app_over_ssh in [false, true] { + for (mode, expected) in [ + (AltScreenMode::Auto, !terminal_app_over_ssh), + (AltScreenMode::Always, true), + (AltScreenMode::Never, false), + ] { + assert_eq!( + determine_alt_screen_mode( + /*no_alt_screen*/ false, + mode, + terminal_app_over_ssh + ), + expected, + ); + assert!(!determine_alt_screen_mode( + /*no_alt_screen*/ true, + mode, + terminal_app_over_ssh + )); + } + } } #[test] diff --git a/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__terminal_app_ssh_startup.snap b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__terminal_app_ssh_startup.snap new file mode 100644 index 0000000000..5d25a1b9ff --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__startup_draft__tests__terminal_app_ssh_startup.snap @@ -0,0 +1,35 @@ +--- +source: tui/src/startup_draft_tests.rs +assertion_line: 162 +expression: "frames.join(\"\\n---\\n\").replace(crate::version::CODEX_CLI_VERSION, \"\")" +--- +terminal_app_over_ssh=false, owned=true +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + + + + + + + +› Ask Codex to do anything + + ? for shortcuts +--- +terminal_app_over_ssh=true, owned=false +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: loading /model to change │ +│ directory: loading │ +╰───────────────────────────────────────╯ + + +› Ask Codex to do anything + + ? for shortcuts diff --git a/codex-rs/tui/src/startup_draft.rs b/codex-rs/tui/src/startup_draft.rs index 7e0a498bf9..c7a127e480 100644 --- a/codex-rs/tui/src/startup_draft.rs +++ b/codex-rs/tui/src/startup_draft.rs @@ -132,6 +132,7 @@ impl StartupDraft { initialized_terminal.enhanced_keys_supported, initialized_terminal.stderr_guard, ); + tui.terminal_app_over_ssh = initialized_terminal.terminal_app_over_ssh; tui.set_alt_screen_enabled(screen.use_alt_screen); let mut pump = StartupDraftPump::new(&tui, initial_screen, session_action); pump.bottom_pane diff --git a/codex-rs/tui/src/startup_draft_tests.rs b/codex-rs/tui/src/startup_draft_tests.rs index 9b8acf2ada..1e9f907227 100644 --- a/codex-rs/tui/src/startup_draft_tests.rs +++ b/codex-rs/tui/src/startup_draft_tests.rs @@ -128,6 +128,61 @@ fn startup_draft_renders_full_empty_and_multiline_composer_frames() { insta::assert_snapshot!("startup_draft_full_frames", snapshots.join("\n---\n")); } +#[test] +fn terminal_app_ssh_fallback_renders_inline_startup() { + let pump = startup_test_pump(std::iter::empty()); + let owned_layout = super::layout::OwnedStartupLayout::new( + &pump.header, + &pump.bottom_pane, + StartupDraftSessionAction::New, + ); + let mut frames = Vec::new(); + for terminal_app_over_ssh in [false, true] { + let owned = crate::determine_alt_screen_mode( + /*no_alt_screen*/ false, + codex_config::types::AltScreenMode::Auto, + terminal_app_over_ssh, + ); + let renderable = if owned { + crate::render::renderable::RenderableItem::Borrowed(&owned_layout) + } else { + startup_draft_renderable( + &pump.header, + &pump.bottom_pane, + StartupDraftSessionAction::New, + ) + }; + let width = 48; + let height = if owned { + 16 + } else { + renderable.desired_height(width) + }; + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); + let mut buffer = Buffer::empty(area); + renderable.render(area, &mut buffer); + let frame = (0..height) + .map(|y| { + (0..width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n"); + frames.push(format!( + "terminal_app_over_ssh={terminal_app_over_ssh}, owned={owned}\n{frame}" + )); + } + insta::assert_snapshot!( + "terminal_app_ssh_startup", + frames + .join("\n---\n") + .replace(crate::version::CODEX_CLI_VERSION, "") + ); +} + #[tokio::test] async fn startup_draft_clears_loading_status_when_starting_fresh() { let mut snapshots = Vec::new(); diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index bc58e93edc..bef842a8aa 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -275,8 +275,22 @@ pub(super) async fn run_main_inner( let startup_presentation::StartupPresentation { bootstrap_config, config_cwd, - screen, + mut screen, } = presentation; + screen.use_alt_screen = determine_alt_screen_mode( + cli.no_alt_screen, + bootstrap_config + .config_toml + .tui + .as_ref() + .map(|tui| tui.alternate_screen) + .unwrap_or_default(), + initialized_terminal.terminal_app_over_ssh, + ); + screen.transcript_mode = crate::transcript_mode::TranscriptMode::resolve( + screen.transcript_mode.is_owned(), + screen.use_alt_screen, + ); let mut startup_draft = startup_draft::StartupDraft::new( initialized_terminal, terminal_restore_guard, diff --git a/codex-rs/tui/src/startup_presentation.rs b/codex-rs/tui/src/startup_presentation.rs index f1c917ff18..d718b0cd71 100644 --- a/codex-rs/tui/src/startup_presentation.rs +++ b/codex-rs/tui/src/startup_presentation.rs @@ -56,7 +56,12 @@ pub(super) async fn load( .as_ref() .map(|tui| tui.alternate_screen) .unwrap_or_default(); - let use_alt_screen = crate::determine_alt_screen_mode(cli.no_alt_screen, alternate_screen); + // Terminal probing happens after configuration loads; startup finalizes this before painting. + let use_alt_screen = crate::determine_alt_screen_mode( + cli.no_alt_screen, + alternate_screen, + /*terminal_app_over_ssh*/ false, + ); let transcript_mode = crate::transcript_mode::TranscriptMode::resolve( config_toml .tui diff --git a/codex-rs/tui/src/terminal_probe.rs b/codex-rs/tui/src/terminal_probe.rs index 1eb2a1972d..f816935450 100644 --- a/codex-rs/tui/src/terminal_probe.rs +++ b/codex-rs/tui/src/terminal_probe.rs @@ -16,6 +16,9 @@ use std::time::Duration; #[cfg(unix)] mod startup_replay; +#[cfg(unix)] +mod terminal_identity; + #[cfg(any(windows, test))] #[path = "terminal_probe/windows_replay.rs"] mod windows_replay; @@ -38,6 +41,7 @@ mod imp { use super::DefaultColors; use super::parse_default_colors; use super::startup_replay::startup_replay_input; + use super::terminal_identity; use std::fs::File; use std::fs::OpenOptions; use std::io; @@ -61,6 +65,7 @@ mod imp { pub(crate) cursor_position: Option, pub(crate) default_colors: Option, pub(crate) keyboard_enhancement_supported: Option, + pub(crate) terminal_app_over_ssh: Option, } /// Whether the startup probe should query keyboard enhancement support. @@ -274,17 +279,32 @@ mod imp { keyboard_probe: StartupKeyboardEnhancementProbe, ) -> io::Result { let mut tty = Tty::open()?; - match keyboard_probe { - StartupKeyboardEnhancementProbe::Query => { - tty.write_all(b"\x1B[6n\x1B]10;?\x1B\\\x1B]11;?\x1B\\\x1B[?u\x1B[c")?; - } - StartupKeyboardEnhancementProbe::Skip => { - tty.write_all(b"\x1B[6n\x1B]10;?\x1B\\\x1B]11;?\x1B\\")?; - } + let query_identity = codex_terminal_detection::terminal_info() + .multiplexer + .is_none() + && (std::env::var_os("SSH_TTY").is_some() + || std::env::var_os("SSH_CONNECTION").is_some()); + let mut query = b"\x1B[6n\x1B]10;?\x1B\\\x1B]11;?\x1B\\".to_vec(); + if keyboard_probe == StartupKeyboardEnhancementProbe::Query { + query.extend_from_slice(b"\x1B[?u"); } + // DA1 must follow the keyboard query: its reply also finishes keyboard detection. + if keyboard_probe == StartupKeyboardEnhancementProbe::Query || query_identity { + query.extend_from_slice(b"\x1B[c"); + } + if query_identity { + query.extend_from_slice(b"\x1B[>c"); + } + tty.write_all(&query)?; let mut buffer = Vec::new(); - let result = read_startup_probe(&mut tty, timeout, keyboard_probe, &mut buffer); + let result = read_startup_probe( + &mut tty, + timeout, + keyboard_probe, + query_identity, + &mut buffer, + ); crossterm::event::buffer_input(&startup_replay_input(&buffer))?; result } @@ -320,6 +340,7 @@ mod imp { tty: &mut Tty, timeout: Duration, keyboard_probe: StartupKeyboardEnhancementProbe, + query_identity: bool, buffer: &mut Vec, ) -> io::Result { let deadline = Instant::now() + timeout; @@ -327,6 +348,7 @@ mod imp { cursor_position: None, default_colors: None, keyboard_enhancement_supported: None, + terminal_app_over_ssh: (!query_identity).then_some(/*t*/ false), }; let mut saw_supported_keyboard = false; loop { @@ -364,6 +386,9 @@ mod imp { if probe.default_colors.is_none() { probe.default_colors = parse_default_colors(buffer); } + if probe.terminal_app_over_ssh.is_none() { + probe.terminal_app_over_ssh = terminal_identity::is_apple_terminal(buffer); + } if keyboard_probe == StartupKeyboardEnhancementProbe::Skip || probe.keyboard_enhancement_supported.is_some() { @@ -389,6 +414,7 @@ mod imp { ) -> bool { probe.cursor_position.is_some() && probe.default_colors.is_some() + && probe.terminal_app_over_ssh.is_some() && (keyboard_probe == StartupKeyboardEnhancementProbe::Skip || probe.keyboard_enhancement_supported.is_some()) } @@ -633,6 +659,7 @@ mod imp { cursor_position: None, default_colors: None, keyboard_enhancement_supported: None, + terminal_app_over_ssh: Some(false), }; let mut saw_supported_keyboard = false; update_startup_probe( @@ -651,6 +678,7 @@ mod imp { bg: (17, 17, 17), }), keyboard_enhancement_supported: Some(true), + terminal_app_over_ssh: Some(false), } ); assert!(startup_probe_complete( @@ -659,6 +687,57 @@ mod imp { )); } + #[test] + fn startup_probe_waits_for_identity_even_when_keyboard_probe_is_skipped() { + let mut probe = StartupProbe { + cursor_position: None, + default_colors: None, + keyboard_enhancement_supported: None, + terminal_app_over_ssh: None, + }; + let mut supported_keyboard = false; + let input = + b"\x1b[1;1R\x1b]10;rgb:eeee/eeee/eeee\x07\x1b]11;rgb:1111/1111/1111\x07\x1b[?1;2c"; + update_startup_probe( + &mut probe, + &mut supported_keyboard, + input, + StartupKeyboardEnhancementProbe::Skip, + ); + assert!(!startup_probe_complete( + &probe, + StartupKeyboardEnhancementProbe::Skip + )); + let mut input = input.to_vec(); + input.extend_from_slice(b"\x1b[>1;95;0c"); + update_startup_probe( + &mut probe, + &mut supported_keyboard, + &input, + StartupKeyboardEnhancementProbe::Skip, + ); + assert_eq!(probe.terminal_app_over_ssh, Some(true)); + assert!(startup_probe_complete( + &probe, + StartupKeyboardEnhancementProbe::Skip + )); + } + + #[test] + fn missing_identity_remains_unknown_at_the_deadline() { + let mut tty = tty_with_buffered_input(b"\x1b[?1;2c"); + let mut buffer = Vec::new(); + let probe = read_startup_probe( + &mut tty, + Duration::from_millis(/*millis*/ 5), + StartupKeyboardEnhancementProbe::Skip, + /*query_identity*/ true, + &mut buffer, + ) + .expect("finish within probe deadline"); + assert_eq!(probe.terminal_app_over_ssh, None); + } + #[test] fn startup_probe_finds_responses_after_large_typeahead() { let mut input = vec![b'x'; 2_048]; @@ -672,6 +751,7 @@ mod imp { &mut tty, Duration::from_secs(/*secs*/ 1), StartupKeyboardEnhancementProbe::Query, + /*query_identity*/ false, &mut buffer, ) .expect("read terminal responses after buffered typeahead"); @@ -685,6 +765,7 @@ mod imp { bg: (17, 17, 17), }), keyboard_enhancement_supported: Some(true), + terminal_app_over_ssh: Some(false), } ); let mut expected_replay = vec![b'x'; 2_048]; diff --git a/codex-rs/tui/src/terminal_probe/terminal_identity.rs b/codex-rs/tui/src/terminal_probe/terminal_identity.rs new file mode 100644 index 0000000000..17bf74dbd7 --- /dev/null +++ b/codex-rs/tui/src/terminal_probe/terminal_identity.rs @@ -0,0 +1,35 @@ +//! Recognize Terminal.app's device attributes without interpreting pasted text as a reply. + +/// A complete pair is required; missing or incomplete replies remain unknown. +pub(super) fn is_apple_terminal(input: &[u8]) -> Option { + let primary = device_attributes(input, b"[?")?; + let secondary = device_attributes(input, b"[>")?; + Some(primary == b"1;2" && secondary == b"1;95;0") +} + +fn device_attributes<'a>(input: &'a [u8], prefix: &[u8]) -> Option<&'a [u8]> { + let mut inside_paste = false; + for sequence in input.split(|byte| *byte == b'\x1b').skip(/*n*/ 1) { + match sequence { + [b'[', b'2', b'0', b'0', b'~', ..] => inside_paste = true, + [b'[', b'2', b'0', b'1', b'~', ..] => inside_paste = false, + _ if !inside_paste => { + if let Some(payload) = sequence.strip_prefix(prefix) + && let Some(end) = payload + .iter() + .take(/*n*/ 64) + .position(|byte| (0x40..=0x7e).contains(byte)) + && payload[end] == b'c' + { + return Some(&payload[..end]); + } + } + _ => {} + } + } + None +} + +#[cfg(test)] +#[path = "terminal_identity_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/terminal_probe/terminal_identity_tests.rs b/codex-rs/tui/src/terminal_probe/terminal_identity_tests.rs new file mode 100644 index 0000000000..3b535148cd --- /dev/null +++ b/codex-rs/tui/src/terminal_probe/terminal_identity_tests.rs @@ -0,0 +1,59 @@ +//! Cover observed terminal signatures, fragmented replies, and pasted lookalikes. + +use super::is_apple_terminal; +use pretty_assertions::assert_eq; + +#[test] +fn distinguishes_the_native_terminal_replies() { + for (primary, secondary, expected) in [ + ("1;2", "1;95;0", true), + ("62;22;52", "1;10;0", false), + ("64;1;2;4;6;17;18;21;22;52", "64;2500;0", false), + ("62;52;", "1;4000;46", false), + ("65;4;6;18;22", "1;277;0", false), + ("6", "0;2600;1", false), + ("1;2", "0;95;0", false), + ("6", "1;95;0", false), + ("1;2", "1;95;00", false), + ("1;2", "1;95;1", false), + ] { + let primary = format!("\x1b[?{primary}c"); + let secondary = format!("\x1b[>{secondary}c"); + for input in [ + format!("{primary}{secondary}"), + format!("{secondary}{primary}"), + ] { + assert_eq!( + is_apple_terminal(input.as_bytes()), + Some(expected), + "{input:?}" + ); + } + } +} + +#[test] +fn unrelated_csi_replies_cannot_consume_typed_text_as_device_attributes() { + for reply in [b"\x1b[?7u".as_slice(), b"\x1b[>4;2m".as_slice()] { + let mut input = reply.to_vec(); + input.extend_from_slice(b"c\x1b[>1;95;0c"); + assert_eq!(is_apple_terminal(&input), None); + input.extend_from_slice(b"\x1b[?1;2c"); + assert_eq!(is_apple_terminal(&input), Some(true)); + } +} + +#[test] +fn identity_waits_for_complete_replies_and_ignores_pasted_signatures() { + let signature = b"\x1b[?1;2c\x1b[>1;95;0c"; + for end in 0..signature.len() { + assert_eq!(is_apple_terminal(&signature[..end]), None); + } + let mut input = b"typed\x1b[200~".to_vec(); + input.extend_from_slice(signature); + assert_eq!(is_apple_terminal(&input), None); + input.extend_from_slice(b"\x1b[201~more typed"); + assert_eq!(is_apple_terminal(&input), None); + input.extend_from_slice(signature); + assert_eq!(is_apple_terminal(&input), Some(true)); +} diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 01b6273094..a2ed6f98d0 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -99,6 +99,7 @@ pub type Terminal = CustomTerminal>; pub(crate) struct InitializedTerminal { pub(crate) terminal: Terminal, pub(crate) enhanced_keys_supported: bool, + pub(crate) terminal_app_over_ssh: bool, pub(crate) stderr_guard: terminal_stderr::TerminalStderrGuard, } @@ -474,6 +475,7 @@ pub(crate) fn init() -> Result { cursor_position: None, default_colors: None, keyboard_enhancement_supported: None, + terminal_app_over_ssh: None, } } } @@ -516,6 +518,12 @@ pub(crate) fn init() -> Result { let initialized_terminal = InitializedTerminal { terminal: tui, enhanced_keys_supported, + #[cfg(unix)] + terminal_app_over_ssh: startup_probe + .terminal_app_over_ssh + .unwrap_or(/*default*/ false), + #[cfg(not(unix))] + terminal_app_over_ssh: false, stderr_guard, }; restore_guard.active = false; @@ -630,6 +638,8 @@ pub struct Tui { // True when terminal/tab is focused; updated internally from crossterm events terminal_focused: Arc, enhanced_keys_supported: bool, + // Cache the startup result so later configuration loads use the same terminal policy. + pub(crate) terminal_app_over_ssh: bool, notification_backend: Option, notification_condition: NotificationCondition, scrollback: ScrollbackStrategy, @@ -701,6 +711,7 @@ impl Tui { alt_screen_active: Arc::new(AtomicBool::new(false)), terminal_focused: Arc::new(AtomicBool::new(true)), enhanced_keys_supported, + terminal_app_over_ssh: false, notification_backend: Some(detect_backend(NotificationMethod::default())), notification_condition: NotificationCondition::default(), scrollback,