Use native scrollback for Terminal.app over SSH in auto mode (#47417)

## What changed

- Detect Terminal.app from primary and secondary device attributes during startup on Unix SSH sessions without a detected multiplexer. Ignore signatures inside bracketed paste and leave incomplete replies unknown.
- Use inline mode when Terminal.app is detected and `tui.alternate_screen` is `auto`. Apply the decision before the first startup frame and retain it for later configuration loads. Preserve explicit `always`, `never`, and `--no-alt-screen` overrides.
- Update the `crossterm` dependency revision and lockfiles.

## Testing

Add coverage for terminal signatures, fragmented and pasted replies, probe completion and timeout behavior, screen-mode overrides, and an inline startup snapshot.

GitOrigin-RevId: 9b937824b080f9bd6de29df1d9e84f42a698af46
This commit is contained in:
Felipe Coury
2026-09-23 01:20:29 +00:00
committed by copyberry
parent 39598ed178
commit 2925d06f5a
13 changed files with 347 additions and 34 deletions
+1 -1
View File
@@ -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\"}",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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" }
+38 -21
View File
@@ -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]
@@ -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, \"<VERSION>\")"
---
terminal_app_over_ssh=false, owned=true
╭───────────────────────────────────────╮
│ >_ OpenAI Codex (v<VERSION>) │
│ │
│ model: loading /model to change │
│ directory: loading │
╰───────────────────────────────────────╯
› Ask Codex to do anything
? for shortcuts
---
terminal_app_over_ssh=true, owned=false
╭───────────────────────────────────────╮
│ >_ OpenAI Codex (v<VERSION>) │
│ │
│ model: loading /model to change │
│ directory: loading │
╰───────────────────────────────────────╯
› Ask Codex to do anything
? for shortcuts
+1
View File
@@ -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
+55
View File
@@ -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::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.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, "<VERSION>")
);
}
#[tokio::test]
async fn startup_draft_clears_loading_status_when_starting_fresh() {
let mut snapshots = Vec::new();
+15 -1
View File
@@ -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,
+6 -1
View File
@@ -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
+89 -8
View File
@@ -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<Position>,
pub(crate) default_colors: Option<DefaultColors>,
pub(crate) keyboard_enhancement_supported: Option<bool>,
pub(crate) terminal_app_over_ssh: Option<bool>,
}
/// Whether the startup probe should query keyboard enhancement support.
@@ -274,17 +279,32 @@ mod imp {
keyboard_probe: StartupKeyboardEnhancementProbe,
) -> io::Result<StartupProbe> {
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<u8>,
) -> io::Result<StartupProbe> {
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];
@@ -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<bool> {
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;
@@ -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));
}
+11
View File
@@ -99,6 +99,7 @@ pub type Terminal = CustomTerminal<CrosstermBackend<Stdout>>;
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<InitializedTerminal> {
cursor_position: None,
default_colors: None,
keyboard_enhancement_supported: None,
terminal_app_over_ssh: None,
}
}
}
@@ -516,6 +518,12 @@ pub(crate) fn init() -> Result<InitializedTerminal> {
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<AtomicBool>,
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<DesktopNotificationBackend>,
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,