[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 <joshka@openai.com>
This commit is contained in:
Jeff Mickey 2026-01-20 16:17:38 -08:00 committed by GitHub
parent 80f80181c2
commit c14e6813fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 60 additions and 1 deletions

View file

@ -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<bool> {
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

View file

@ -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<Multiplexer>) -> 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<TerminalName> {
"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!(

View file

@ -84,6 +84,17 @@ pub struct AppExitInfo {
pub exit_reason: ExitReason,
}
impl AppExitInfo {
pub fn fatal(message: impl Into<String>) -> 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,