From c14e6813fbba10301313946e089ac0e09ecfdb3f Mon Sep 17 00:00:00 2001 From: Jeff Mickey Date: Tue, 20 Jan 2026 16:17:38 -0800 Subject: [PATCH] [codex-tui] exit when terminal is dumb (#9293) Using terminal with TERM=dumb specifically mean that TUIs and the like don't work. Ensure that codex doesn't run in these environments and exit with odd errors like crossterm's "Error: The cursor position could not be read within a normal duration" --------- Co-authored-by: Josh McKinney --- codex-rs/cli/src/main.rs | 30 ++++++++++++++++++++++++++++++ codex-rs/core/src/terminal.rs | 20 +++++++++++++++++++- codex-rs/tui/src/app.rs | 11 +++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 25c1161c8..dc4f648a4 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -28,6 +28,7 @@ use codex_tui::ExitReason; use codex_tui::update_action::UpdateAction; use codex_tui2 as tui2; use owo_colors::OwoColorize; +use std::io::IsTerminal; use std::path::PathBuf; use supports_color::Stream; @@ -45,6 +46,7 @@ use codex_core::features::Feature; use codex_core::features::FeatureOverrides; use codex_core::features::Features; use codex_core::features::is_known_feature_key; +use codex_core::terminal::TerminalName; use codex_utils_absolute_path::AbsolutePathBuf; /// Codex CLI @@ -735,6 +737,25 @@ async fn run_interactive_tui( // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state. interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n")); } + + let terminal_info = codex_core::terminal::terminal_info(); + if terminal_info.name == TerminalName::Dumb { + if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) { + return Ok(AppExitInfo::fatal( + "TERM is set to \"dumb\". Refusing to start the interactive TUI because no terminal is available for a confirmation prompt (stdin/stderr is not a TTY). Run in a supported terminal or unset TERM.", + )); + } + + eprintln!( + "WARNING: TERM is set to \"dumb\". Codex's interactive TUI may not work in this terminal." + ); + if !confirm("Continue anyway? [y/N]: ")? { + return Ok(AppExitInfo::fatal( + "Refusing to start the interactive TUI because TERM is set to \"dumb\". Run in a supported terminal or unset TERM.", + )); + } + } + if is_tui2_enabled(&interactive).await? { let result = tui2::run_main(interactive.into(), codex_linux_sandbox_exe).await?; Ok(result.into()) @@ -743,6 +764,15 @@ async fn run_interactive_tui( } } +fn confirm(prompt: &str) -> std::io::Result { + eprintln!("{prompt}"); + + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let answer = input.trim(); + Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes")) +} + /// Returns `Ok(true)` when the resolved configuration enables the `tui2` feature flag. /// /// This performs a lightweight config load (honoring the same precedence as the lower-level TUI diff --git a/codex-rs/core/src/terminal.rs b/codex-rs/core/src/terminal.rs index 32421aef7..8437962f6 100644 --- a/codex-rs/core/src/terminal.rs +++ b/codex-rs/core/src/terminal.rs @@ -47,6 +47,8 @@ pub enum TerminalName { Vte, /// Windows Terminal emulator. WindowsTerminal, + /// Dumb terminal (TERM=dumb). + Dumb, /// Unknown or missing terminal identification. Unknown, } @@ -131,7 +133,12 @@ impl TerminalInfo { /// Creates terminal metadata from a `TERM` capability value. fn from_term(term: String, multiplexer: Option) -> Self { - Self::new(TerminalName::Unknown, None, None, Some(term), multiplexer) + let name = if term == "dumb" { + TerminalName::Dumb + } else { + TerminalName::Unknown + }; + Self::new(name, None, None, Some(term), multiplexer) } /// Creates terminal metadata for unknown terminals. @@ -166,6 +173,7 @@ impl TerminalInfo { TerminalName::GnomeTerminal => "gnome-terminal".to_string(), TerminalName::Vte => format_terminal_version("VTE", &self.version), TerminalName::WindowsTerminal => "WindowsTerminal".to_string(), + TerminalName::Dumb => "dumb".to_string(), TerminalName::Unknown => "unknown".to_string(), } }; @@ -435,6 +443,7 @@ fn terminal_name_from_term_program(value: &str) -> Option { "gnometerminal" => Some(TerminalName::GnomeTerminal), "vte" => Some(TerminalName::Vte), "windowsterminal" => Some(TerminalName::WindowsTerminal), + "dumb" => Some(TerminalName::Dumb), _ => None, } } @@ -1136,6 +1145,15 @@ mod tests { "term_fallback_user_agent" ); + let env = FakeEnvironment::new().with_var("TERM", "dumb"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Dumb, None, None, Some("dumb"), None), + "dumb_term_info" + ); + assert_eq!(terminal.user_agent_token(), "dumb", "dumb_term_user_agent"); + let env = FakeEnvironment::new(); let terminal = detect_terminal_info_from_env(&env); assert_eq!( diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b361425ae..8d879d55a 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -84,6 +84,17 @@ pub struct AppExitInfo { pub exit_reason: ExitReason, } +impl AppExitInfo { + pub fn fatal(message: impl Into) -> Self { + Self { + token_usage: TokenUsage::default(), + thread_id: None, + update_action: None, + exit_reason: ExitReason::Fatal(message.into()), + } + } +} + #[derive(Debug)] pub(crate) enum AppRunControl { Continue,