Files
codex/codex-rs/core/src/terminal.rs
Felipe Coury 3b5996f988 fix(tui): promote windows terminal diff ansi16 to truecolor (#13016)
## Summary

- Promote ANSI-16 to truecolor for diff rendering when running inside
Windows Terminal
- Respect explicit `FORCE_COLOR` override, skipping promotion when set
- Extract a pure `diff_color_level_for_terminal` function for
testability
- Strip background tints from ANSI-16 diff output, rendering add/delete
lines with foreground color only
- Introduce `RichDiffColorLevel` to type-safely restrict background
fills to truecolor and ansi256

## Problem

Windows Terminal fully supports 24-bit (truecolor) rendering but often
does not provide the usual TERM metadata (`TERM`, `TERM_PROGRAM`,
`COLORTERM`) in `cmd.exe`/PowerShell sessions. In those environments,
`supports-color` can report only ANSI-16 support. The diff renderer
therefore falls back to a 16-color palette, producing washed-out,
hard-to-read diffs.

The screenshots below demonstrate that both PowerShell and cmd.exe don't
set any `*TERM*` environment variables.

| PowerShell | cmd.exe |
|---|---|
| <img width="2032" height="1162" alt="SCR-20260226-nfvy"
src="https://github.com/user-attachments/assets/59e968cc-4add-4c7b-a415-07163297e86a"
/> | <img width="2032" height="1162" alt="SCR-20260226-nfyc"
src="https://github.com/user-attachments/assets/d06b3e39-bf91-4ce3-9705-82bf9563a01b"
/> |


## Mental model

`StdoutColorLevel` (from `supports-color`) is the _detected_ capability.
`DiffColorLevel` is the _intended_ capability for diff rendering. A new
intermediary — `diff_color_level_for_terminal` — maps one to the other
and is the single place where terminal-specific overrides live.

Windows Terminal is detected two independent ways: the `TerminalName`
parsed by `terminal_info()` and the raw presence of `WT_SESSION`. When
`WT_SESSION` is present and `FORCE_COLOR` is not set, we promote
unconditionally to truecolor. When `WT_SESSION` is absent but
`TerminalName::WindowsTerminal` is detected, we promote only the ANSI-16
level (not `Unknown`).

A single override helper — `has_force_color_override()` — checks whether
`FORCE_COLOR` is set. When it is, both the `WT_SESSION` fast-path and
the `TerminalName`-based promotion are suppressed, preserving explicit
user intent.

| PowerShell | cmd.exe | WSL | Bash for Windows |
|---|---|---|---|
|
![SCR-20260226-msrh](https://github.com/user-attachments/assets/0f6297a6-4241-4dbf-b7ff-cf02da8941b0)
|
![SCR-20260226-nbao](https://github.com/user-attachments/assets/bb5ff8a9-903c-4677-a2de-1f6e1f34b18e)
|
![SCR-20260226-nbej](https://github.com/user-attachments/assets/26ecec2c-a7e9-410a-8702-f73995b490a6)
|
![SCR-20260226-nbkz](https://github.com/user-attachments/assets/80c4bf9a-3b41-40e1-bc87-f5c565f96075)
|

## Non-goals

- This does not change color detection for anything outside the diff
renderer (e.g. the chat widget, markdown rendering).
- This does not add a user-facing config knob; `FORCE_COLOR` already
serves that role.

## Tradeoffs

- The `has_wt_session` signal is intentionally kept separate from
`TerminalName::WindowsTerminal`. `terminal_info()` is derived with
`TERM_PROGRAM` precedence, so it can differ from raw `WT_SESSION`.
- Real-world validation in this issue: in both `cmd.exe` and PowerShell,
`TERM`/`TERM_PROGRAM`/`COLORTERM` were absent, so TERM-based capability
hints were unavailable in those sessions.
- Checking `FORCE_COLOR` for presence rather than parsing its value is a
simplification. In practice `supports-color` has already parsed it, so
our check is a coarse "did the user set _anything_?" gate. The effective
color level still comes from `supports-color`.
- When `WT_SESSION` is present without `FORCE_COLOR`, we promote to
truecolor regardless of `stdout_level` (including `Unknown`). This is
aggressive but correct: `WT_SESSION` is a strong signal that we're in
Windows Terminal.
- ANSI-16 add/delete backgrounds (bright green/red) overpower
syntax-highlighted token colors, making diffs harder to read.
Foreground-only cues (colored text, gutter signs) preserve readability
on low-color terminals.

## Architecture

```
stdout_color_level()  ──┐
terminal_info().name  ──┤
WT_SESSION presence   ──┼──▶ diff_color_level_for_terminal() ──▶ DiffColorLevel
FORCE_COLOR presence  ──┘                                            │
                                                                     ▼
                                                          RichDiffColorLevel::from_diff_color_level()
                                                                     │
                                                          ┌──────────┴──────────┐
                                                          │ Some(TrueColor|256) │ → bg tints
                                                          │ None (Ansi16)       │ → fg only
                                                          └─────────────────────┘
```

`diff_color_level()` is the environment-reading entry point; it gathers
the four runtime signals and delegates to the pure, testable
`diff_color_level_for_terminal()`.

## Observability

No new logs or metrics. Incorrect color selection is immediately visible
as broken diff rendering; the test suite covers the decision matrix
exhaustively.

## Tests

Six new unit tests exercise every branch of
`diff_color_level_for_terminal`:

| Test | Inputs | Expected |
|------|--------|----------|
| `windows_terminal_promotes_ansi16_to_truecolor_for_diffs` | Ansi16 +
WindowsTerminal name | TrueColor |
| `wt_session_promotes_ansi16_to_truecolor_for_diffs` | Ansi16 +
WT_SESSION only | TrueColor |
| `non_windows_terminal_keeps_ansi16_diff_palette` | Ansi16 + WezTerm |
Ansi16 |
| `wt_session_promotes_unknown_color_level_to_truecolor` | Unknown +
WT_SESSION | TrueColor |
| `explicit_force_override_keeps_ansi16_on_windows_terminal` | Ansi16 +
WindowsTerminal + FORCE_COLOR | Ansi16 |
| `explicit_force_override_keeps_ansi256_on_windows_terminal` | Ansi256
+ WT_SESSION + FORCE_COLOR | Ansi256 |
| `ansi16_add_style_uses_foreground_only` | Dark + Ansi16 | fg=Green,
bg=None |
| (and any other new snapshot/assertion tests from commits d757fee and
d7c78b3) | | |

## Test plan

- [x] Verify all new unit tests pass (`cargo test -p codex-tui --lib`)
- [x] On Windows Terminal: confirm diffs render with truecolor
backgrounds
- [x] On Windows Terminal with `FORCE_COLOR` set: confirm promotion is
disabled and output follows the forced `supports-color` level
- [x] On macOS/Linux terminals: confirm no behavior change

Fixes https://github.com/openai/codex/issues/12904 
Fixes https://github.com/openai/codex/issues/12890
Fixes https://github.com/openai/codex/issues/12912
Fixes https://github.com/openai/codex/issues/12840
2026-02-27 10:45:59 -07:00

1168 lines
38 KiB
Rust

//! Terminal detection utilities.
//!
//! This module feeds terminal metadata into OpenTelemetry user-agent logging and into
//! terminal-specific configuration choices in the TUI.
use std::sync::OnceLock;
/// Structured terminal identification data.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TerminalInfo {
/// The detected terminal name category.
pub name: TerminalName,
/// The `TERM_PROGRAM` value when provided by the terminal.
pub term_program: Option<String>,
/// The terminal version string when available.
pub version: Option<String>,
/// The `TERM` value when falling back to capability strings.
pub term: Option<String>,
/// Multiplexer metadata when a terminal multiplexer is active.
pub multiplexer: Option<Multiplexer>,
}
/// Known terminal name categories derived from environment variables.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TerminalName {
/// Apple Terminal (Terminal.app).
AppleTerminal,
/// Ghostty terminal emulator.
Ghostty,
/// iTerm2 terminal emulator.
Iterm2,
/// Warp terminal emulator.
WarpTerminal,
/// Visual Studio Code integrated terminal.
VsCode,
/// WezTerm terminal emulator.
WezTerm,
/// kitty terminal emulator.
Kitty,
/// Alacritty terminal emulator.
Alacritty,
/// KDE Konsole terminal emulator.
Konsole,
/// GNOME Terminal emulator.
GnomeTerminal,
/// VTE backend terminal.
Vte,
/// Windows Terminal emulator.
WindowsTerminal,
/// Dumb terminal (TERM=dumb).
Dumb,
/// Unknown or missing terminal identification.
Unknown,
}
/// Detected terminal multiplexer metadata.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Multiplexer {
/// tmux terminal multiplexer.
Tmux {
/// tmux version string when `TERM_PROGRAM=tmux` is available.
///
/// This is derived from `TERM_PROGRAM_VERSION`.
version: Option<String>,
},
/// zellij terminal multiplexer.
Zellij {},
}
/// tmux client terminal identification captured via `tmux display-message`.
///
/// `termtype` corresponds to `#{client_termtype}` and typically reflects the
/// underlying terminal program (for example, `ghostty` or `wezterm`) with an
/// optional version suffix. `termname` comes from `#{client_termname}` and
/// preserves the TERM capability string exposed by the client (for example,
/// `xterm-256color`).
///
/// This information is only available when running under tmux and lets us
/// attribute the session to the underlying terminal rather than to tmux itself.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct TmuxClientInfo {
termtype: Option<String>,
termname: Option<String>,
}
impl TerminalInfo {
/// Creates terminal metadata from detected fields.
fn new(
name: TerminalName,
term_program: Option<String>,
version: Option<String>,
term: Option<String>,
multiplexer: Option<Multiplexer>,
) -> Self {
Self {
name,
term_program,
version,
term,
multiplexer,
}
}
/// Creates terminal metadata from a `TERM_PROGRAM` match.
fn from_term_program(
name: TerminalName,
term_program: String,
version: Option<String>,
multiplexer: Option<Multiplexer>,
) -> Self {
Self::new(name, Some(term_program), version, None, multiplexer)
}
/// Creates terminal metadata from a `TERM_PROGRAM` match plus a `TERM` value.
fn from_term_program_and_term(
name: TerminalName,
term_program: String,
version: Option<String>,
term: Option<String>,
multiplexer: Option<Multiplexer>,
) -> Self {
Self::new(name, Some(term_program), version, term, multiplexer)
}
/// Creates terminal metadata from a known terminal name and optional version.
fn from_name(
name: TerminalName,
version: Option<String>,
multiplexer: Option<Multiplexer>,
) -> Self {
Self::new(name, None, version, None, multiplexer)
}
/// Creates terminal metadata from a `TERM` capability value.
fn from_term(term: String, multiplexer: Option<Multiplexer>) -> Self {
let name = if term == "dumb" {
TerminalName::Dumb
} else {
TerminalName::Unknown
};
Self::new(name, None, None, Some(term), multiplexer)
}
/// Creates terminal metadata for unknown terminals.
fn unknown(multiplexer: Option<Multiplexer>) -> Self {
Self::new(TerminalName::Unknown, None, None, None, multiplexer)
}
/// Formats the terminal info as a User-Agent token.
fn user_agent_token(&self) -> String {
let raw = if let Some(program) = self.term_program.as_ref() {
match self.version.as_ref().filter(|v| !v.is_empty()) {
Some(version) => format!("{program}/{version}"),
None => program.clone(),
}
} else if let Some(term) = self.term.as_ref().filter(|value| !value.is_empty()) {
term.clone()
} else {
match self.name {
TerminalName::AppleTerminal => {
format_terminal_version("Apple_Terminal", &self.version)
}
TerminalName::Ghostty => format_terminal_version("Ghostty", &self.version),
TerminalName::Iterm2 => format_terminal_version("iTerm.app", &self.version),
TerminalName::WarpTerminal => {
format_terminal_version("WarpTerminal", &self.version)
}
TerminalName::VsCode => format_terminal_version("vscode", &self.version),
TerminalName::WezTerm => format_terminal_version("WezTerm", &self.version),
TerminalName::Kitty => "kitty".to_string(),
TerminalName::Alacritty => "Alacritty".to_string(),
TerminalName::Konsole => format_terminal_version("Konsole", &self.version),
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(),
}
};
sanitize_header_value(raw)
}
}
static TERMINAL_INFO: OnceLock<TerminalInfo> = OnceLock::new();
/// Environment variable access used by terminal detection.
///
/// This trait exists to allow faking the environment in tests.
trait Environment {
/// Returns an environment variable when set.
fn var(&self, name: &str) -> Option<String>;
/// Returns whether an environment variable is set.
fn has(&self, name: &str) -> bool {
self.var(name).is_some()
}
/// Returns a non-empty environment variable.
fn var_non_empty(&self, name: &str) -> Option<String> {
self.var(name).and_then(none_if_whitespace)
}
/// Returns whether an environment variable is set and non-empty.
fn has_non_empty(&self, name: &str) -> bool {
self.var_non_empty(name).is_some()
}
/// Returns tmux client details when available.
fn tmux_client_info(&self) -> TmuxClientInfo;
}
/// Reads environment variables from the running process.
struct ProcessEnvironment;
impl Environment for ProcessEnvironment {
fn var(&self, name: &str) -> Option<String> {
match std::env::var(name) {
Ok(value) => Some(value),
Err(std::env::VarError::NotPresent) => None,
Err(std::env::VarError::NotUnicode(_)) => {
tracing::warn!("failed to read env var {name}: value not valid UTF-8");
None
}
}
}
fn tmux_client_info(&self) -> TmuxClientInfo {
tmux_client_info()
}
}
/// Returns a sanitized terminal identifier for User-Agent strings.
pub fn user_agent() -> String {
terminal_info().user_agent_token()
}
/// Returns structured terminal metadata for the current process.
pub fn terminal_info() -> TerminalInfo {
TERMINAL_INFO
.get_or_init(|| detect_terminal_info_from_env(&ProcessEnvironment))
.clone()
}
/// Detects structured terminal metadata from an injectable environment.
///
/// Detection order favors explicit identifiers before falling back to capability strings:
/// - If `TERM_PROGRAM=tmux`, the tmux client term type/name are used instead. The client term
/// type is split on whitespace to extract a program name plus optional version (for example,
/// `ghostty 1.2.3`), while the client term name becomes the `TERM` capability string.
/// - Otherwise, `TERM_PROGRAM` (plus `TERM_PROGRAM_VERSION`) drives the detected terminal name.
/// This means `TERM_PROGRAM` can mask later probes (for example `WT_SESSION`).
/// - Next, terminal-specific variables (WEZTERM, iTerm2, Apple Terminal, kitty, etc.) are checked.
/// - Finally, `TERM` is used as the capability fallback with `TerminalName::Unknown`.
///
/// tmux client term info is only consulted when a tmux multiplexer is detected, and it is
/// derived from `tmux display-message` to surface the underlying terminal program instead of
/// reporting tmux itself.
fn detect_terminal_info_from_env(env: &dyn Environment) -> TerminalInfo {
let multiplexer = detect_multiplexer(env);
if let Some(term_program) = env.var_non_empty("TERM_PROGRAM") {
if is_tmux_term_program(&term_program)
&& matches!(multiplexer, Some(Multiplexer::Tmux { .. }))
&& let Some(terminal) =
terminal_from_tmux_client_info(env.tmux_client_info(), multiplexer.clone())
{
return terminal;
}
let version = env.var_non_empty("TERM_PROGRAM_VERSION");
let name = terminal_name_from_term_program(&term_program).unwrap_or(TerminalName::Unknown);
return TerminalInfo::from_term_program(name, term_program, version, multiplexer);
}
if env.has("WEZTERM_VERSION") {
let version = env.var_non_empty("WEZTERM_VERSION");
return TerminalInfo::from_name(TerminalName::WezTerm, version, multiplexer);
}
if env.has("ITERM_SESSION_ID") || env.has("ITERM_PROFILE") || env.has("ITERM_PROFILE_NAME") {
return TerminalInfo::from_name(TerminalName::Iterm2, None, multiplexer);
}
if env.has("TERM_SESSION_ID") {
return TerminalInfo::from_name(TerminalName::AppleTerminal, None, multiplexer);
}
if env.has("KITTY_WINDOW_ID")
|| env
.var("TERM")
.map(|term| term.contains("kitty"))
.unwrap_or(false)
{
return TerminalInfo::from_name(TerminalName::Kitty, None, multiplexer);
}
if env.has("ALACRITTY_SOCKET")
|| env
.var("TERM")
.map(|term| term == "alacritty")
.unwrap_or(false)
{
return TerminalInfo::from_name(TerminalName::Alacritty, None, multiplexer);
}
if env.has("KONSOLE_VERSION") {
let version = env.var_non_empty("KONSOLE_VERSION");
return TerminalInfo::from_name(TerminalName::Konsole, version, multiplexer);
}
if env.has("GNOME_TERMINAL_SCREEN") {
return TerminalInfo::from_name(TerminalName::GnomeTerminal, None, multiplexer);
}
if env.has("VTE_VERSION") {
let version = env.var_non_empty("VTE_VERSION");
return TerminalInfo::from_name(TerminalName::Vte, version, multiplexer);
}
if env.has("WT_SESSION") {
return TerminalInfo::from_name(TerminalName::WindowsTerminal, None, multiplexer);
}
if let Some(term) = env.var_non_empty("TERM") {
return TerminalInfo::from_term(term, multiplexer);
}
TerminalInfo::unknown(multiplexer)
}
fn detect_multiplexer(env: &dyn Environment) -> Option<Multiplexer> {
if env.has_non_empty("TMUX") || env.has_non_empty("TMUX_PANE") {
return Some(Multiplexer::Tmux {
version: tmux_version_from_env(env),
});
}
if env.has_non_empty("ZELLIJ")
|| env.has_non_empty("ZELLIJ_SESSION_NAME")
|| env.has_non_empty("ZELLIJ_VERSION")
{
return Some(Multiplexer::Zellij {});
}
None
}
fn is_tmux_term_program(value: &str) -> bool {
value.eq_ignore_ascii_case("tmux")
}
fn terminal_from_tmux_client_info(
client_info: TmuxClientInfo,
multiplexer: Option<Multiplexer>,
) -> Option<TerminalInfo> {
let termtype = client_info.termtype.and_then(none_if_whitespace);
let termname = client_info.termname.and_then(none_if_whitespace);
if let Some(termtype) = termtype.as_ref() {
let (program, version) = split_term_program_and_version(termtype);
let name = terminal_name_from_term_program(&program).unwrap_or(TerminalName::Unknown);
return Some(TerminalInfo::from_term_program_and_term(
name,
program,
version,
termname,
multiplexer,
));
}
termname
.as_ref()
.map(|termname| TerminalInfo::from_term(termname.to_string(), multiplexer))
}
fn tmux_version_from_env(env: &dyn Environment) -> Option<String> {
let term_program = env.var("TERM_PROGRAM")?;
if !is_tmux_term_program(&term_program) {
return None;
}
env.var_non_empty("TERM_PROGRAM_VERSION")
}
fn split_term_program_and_version(value: &str) -> (String, Option<String>) {
let mut parts = value.split_whitespace();
let program = parts.next().unwrap_or_default().to_string();
let version = parts.next().map(ToString::to_string);
(program, version)
}
fn tmux_client_info() -> TmuxClientInfo {
let termtype = tmux_display_message("#{client_termtype}");
let termname = tmux_display_message("#{client_termname}");
TmuxClientInfo { termtype, termname }
}
fn tmux_display_message(format: &str) -> Option<String> {
let output = std::process::Command::new("tmux")
.args(["display-message", "-p", format])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8(output.stdout).ok()?;
none_if_whitespace(value.trim().to_string())
}
/// Sanitizes a terminal token for use in User-Agent headers.
///
/// Invalid header characters are replaced with underscores.
fn sanitize_header_value(value: String) -> String {
value.replace(|c| !is_valid_header_value_char(c), "_")
}
/// Returns whether a character is allowed in User-Agent header values.
fn is_valid_header_value_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '/'
}
fn terminal_name_from_term_program(value: &str) -> Option<TerminalName> {
let normalized: String = value
.trim()
.chars()
.filter(|c| !matches!(c, ' ' | '-' | '_' | '.'))
.map(|c| c.to_ascii_lowercase())
.collect();
match normalized.as_str() {
"appleterminal" => Some(TerminalName::AppleTerminal),
"ghostty" => Some(TerminalName::Ghostty),
"iterm" | "iterm2" | "itermapp" => Some(TerminalName::Iterm2),
"warp" | "warpterminal" => Some(TerminalName::WarpTerminal),
"vscode" => Some(TerminalName::VsCode),
"wezterm" => Some(TerminalName::WezTerm),
"kitty" => Some(TerminalName::Kitty),
"alacritty" => Some(TerminalName::Alacritty),
"konsole" => Some(TerminalName::Konsole),
"gnometerminal" => Some(TerminalName::GnomeTerminal),
"vte" => Some(TerminalName::Vte),
"windowsterminal" => Some(TerminalName::WindowsTerminal),
"dumb" => Some(TerminalName::Dumb),
_ => None,
}
}
fn format_terminal_version(name: &str, version: &Option<String>) -> String {
match version.as_ref().filter(|value| !value.is_empty()) {
Some(version) => format!("{name}/{version}"),
None => name.to_string(),
}
}
fn none_if_whitespace(value: String) -> Option<String> {
(!value.trim().is_empty()).then_some(value)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
struct FakeEnvironment {
vars: HashMap<String, String>,
tmux_client_info: TmuxClientInfo,
}
impl FakeEnvironment {
fn new() -> Self {
Self {
vars: HashMap::new(),
tmux_client_info: TmuxClientInfo::default(),
}
}
fn with_var(mut self, key: &str, value: &str) -> Self {
self.vars.insert(key.to_string(), value.to_string());
self
}
fn with_tmux_client_info(mut self, termtype: Option<&str>, termname: Option<&str>) -> Self {
self.tmux_client_info = TmuxClientInfo {
termtype: termtype.map(ToString::to_string),
termname: termname.map(ToString::to_string),
};
self
}
}
impl Environment for FakeEnvironment {
fn var(&self, name: &str) -> Option<String> {
self.vars.get(name).cloned()
}
fn tmux_client_info(&self) -> TmuxClientInfo {
self.tmux_client_info.clone()
}
}
fn terminal_info(
name: TerminalName,
term_program: Option<&str>,
version: Option<&str>,
term: Option<&str>,
multiplexer: Option<Multiplexer>,
) -> TerminalInfo {
TerminalInfo {
name,
term_program: term_program.map(ToString::to_string),
version: version.map(ToString::to_string),
term: term.map(ToString::to_string),
multiplexer,
}
}
#[test]
fn detects_term_program() {
let env = FakeEnvironment::new()
.with_var("TERM_PROGRAM", "iTerm.app")
.with_var("TERM_PROGRAM_VERSION", "3.5.0")
.with_var("WEZTERM_VERSION", "2024.2");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(
TerminalName::Iterm2,
Some("iTerm.app"),
Some("3.5.0"),
None,
None,
),
"term_program_with_version_info"
);
assert_eq!(
terminal.user_agent_token(),
"iTerm.app/3.5.0",
"term_program_with_version_user_agent"
);
let env = FakeEnvironment::new()
.with_var("TERM_PROGRAM", "iTerm.app")
.with_var("TERM_PROGRAM_VERSION", "");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(TerminalName::Iterm2, Some("iTerm.app"), None, None, None),
"term_program_without_version_info"
);
assert_eq!(
terminal.user_agent_token(),
"iTerm.app",
"term_program_without_version_user_agent"
);
let env = FakeEnvironment::new()
.with_var("TERM_PROGRAM", "iTerm.app")
.with_var("WEZTERM_VERSION", "2024.2");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(TerminalName::Iterm2, Some("iTerm.app"), None, None, None),
"term_program_overrides_wezterm_info"
);
assert_eq!(
terminal.user_agent_token(),
"iTerm.app",
"term_program_overrides_wezterm_user_agent"
);
}
#[test]
fn detects_iterm2() {
let env = FakeEnvironment::new().with_var("ITERM_SESSION_ID", "w0t1p0");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(TerminalName::Iterm2, None, None, None, None),
"iterm_session_id_info"
);
assert_eq!(
terminal.user_agent_token(),
"iTerm.app",
"iterm_session_id_user_agent"
);
}
#[test]
fn detects_apple_terminal() {
let env = FakeEnvironment::new().with_var("TERM_PROGRAM", "Apple_Terminal");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(
TerminalName::AppleTerminal,
Some("Apple_Terminal"),
None,
None,
None,
),
"apple_term_program_info"
);
assert_eq!(
terminal.user_agent_token(),
"Apple_Terminal",
"apple_term_program_user_agent"
);
let env = FakeEnvironment::new().with_var("TERM_SESSION_ID", "A1B2C3");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(TerminalName::AppleTerminal, None, None, None, None),
"apple_term_session_id_info"
);
assert_eq!(
terminal.user_agent_token(),
"Apple_Terminal",
"apple_term_session_id_user_agent"
);
}
#[test]
fn detects_ghostty() {
let env = FakeEnvironment::new().with_var("TERM_PROGRAM", "Ghostty");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(TerminalName::Ghostty, Some("Ghostty"), None, None, None),
"ghostty_term_program_info"
);
assert_eq!(
terminal.user_agent_token(),
"Ghostty",
"ghostty_term_program_user_agent"
);
}
#[test]
fn detects_vscode() {
let env = FakeEnvironment::new()
.with_var("TERM_PROGRAM", "vscode")
.with_var("TERM_PROGRAM_VERSION", "1.86.0");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(
TerminalName::VsCode,
Some("vscode"),
Some("1.86.0"),
None,
None
),
"vscode_term_program_info"
);
assert_eq!(
terminal.user_agent_token(),
"vscode/1.86.0",
"vscode_term_program_user_agent"
);
}
#[test]
fn detects_warp_terminal() {
let env = FakeEnvironment::new()
.with_var("TERM_PROGRAM", "WarpTerminal")
.with_var("TERM_PROGRAM_VERSION", "v0.2025.12.10.08.12.stable_03");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(
TerminalName::WarpTerminal,
Some("WarpTerminal"),
Some("v0.2025.12.10.08.12.stable_03"),
None,
None,
),
"warp_term_program_info"
);
assert_eq!(
terminal.user_agent_token(),
"WarpTerminal/v0.2025.12.10.08.12.stable_03",
"warp_term_program_user_agent"
);
}
#[test]
fn detects_tmux_multiplexer() {
let env = FakeEnvironment::new()
.with_var("TMUX", "/tmp/tmux-1000/default,123,0")
.with_var("TERM_PROGRAM", "tmux")
.with_tmux_client_info(Some("xterm-256color"), Some("screen-256color"));
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(
TerminalName::Unknown,
Some("xterm-256color"),
None,
Some("screen-256color"),
Some(Multiplexer::Tmux { version: None }),
),
"tmux_multiplexer_info"
);
assert_eq!(
terminal.user_agent_token(),
"xterm-256color",
"tmux_multiplexer_user_agent"
);
}
#[test]
fn detects_zellij_multiplexer() {
let env = FakeEnvironment::new().with_var("ZELLIJ", "1");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
TerminalInfo {
name: TerminalName::Unknown,
term_program: None,
version: None,
term: None,
multiplexer: Some(Multiplexer::Zellij {}),
},
"zellij_multiplexer"
);
}
#[test]
fn detects_tmux_client_termtype() {
let env = FakeEnvironment::new()
.with_var("TMUX", "/tmp/tmux-1000/default,123,0")
.with_var("TERM_PROGRAM", "tmux")
.with_tmux_client_info(Some("WezTerm"), None);
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(
TerminalName::WezTerm,
Some("WezTerm"),
None,
None,
Some(Multiplexer::Tmux { version: None }),
),
"tmux_client_termtype_info"
);
assert_eq!(
terminal.user_agent_token(),
"WezTerm",
"tmux_client_termtype_user_agent"
);
}
#[test]
fn detects_tmux_client_termname() {
let env = FakeEnvironment::new()
.with_var("TMUX", "/tmp/tmux-1000/default,123,0")
.with_var("TERM_PROGRAM", "tmux")
.with_tmux_client_info(None, Some("xterm-256color"));
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(
TerminalName::Unknown,
None,
None,
Some("xterm-256color"),
Some(Multiplexer::Tmux { version: None })
),
"tmux_client_termname_info"
);
assert_eq!(
terminal.user_agent_token(),
"xterm-256color",
"tmux_client_termname_user_agent"
);
}
#[test]
fn detects_tmux_term_program_uses_client_termtype() {
let env = FakeEnvironment::new()
.with_var("TMUX", "/tmp/tmux-1000/default,123,0")
.with_var("TERM_PROGRAM", "tmux")
.with_var("TERM_PROGRAM_VERSION", "3.6a")
.with_tmux_client_info(Some("ghostty 1.2.3"), Some("xterm-ghostty"));
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(
TerminalName::Ghostty,
Some("ghostty"),
Some("1.2.3"),
Some("xterm-ghostty"),
Some(Multiplexer::Tmux {
version: Some("3.6a".to_string()),
}),
),
"tmux_term_program_client_termtype_info"
);
assert_eq!(
terminal.user_agent_token(),
"ghostty/1.2.3",
"tmux_term_program_client_termtype_user_agent"
);
}
#[test]
fn detects_wezterm() {
let env = FakeEnvironment::new().with_var("WEZTERM_VERSION", "2024.2");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(TerminalName::WezTerm, None, Some("2024.2"), None, None),
"wezterm_version_info"
);
assert_eq!(
terminal.user_agent_token(),
"WezTerm/2024.2",
"wezterm_version_user_agent"
);
let env = FakeEnvironment::new()
.with_var("TERM_PROGRAM", "WezTerm")
.with_var("TERM_PROGRAM_VERSION", "2024.2");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(
TerminalName::WezTerm,
Some("WezTerm"),
Some("2024.2"),
None,
None
),
"wezterm_term_program_info"
);
assert_eq!(
terminal.user_agent_token(),
"WezTerm/2024.2",
"wezterm_term_program_user_agent"
);
let env = FakeEnvironment::new().with_var("WEZTERM_VERSION", "");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(TerminalName::WezTerm, None, None, None, None),
"wezterm_empty_info"
);
assert_eq!(
terminal.user_agent_token(),
"WezTerm",
"wezterm_empty_user_agent"
);
}
#[test]
fn detects_kitty() {
let env = FakeEnvironment::new().with_var("KITTY_WINDOW_ID", "1");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(TerminalName::Kitty, None, None, None, None),
"kitty_window_id_info"
);
assert_eq!(
terminal.user_agent_token(),
"kitty",
"kitty_window_id_user_agent"
);
let env = FakeEnvironment::new()
.with_var("TERM_PROGRAM", "kitty")
.with_var("TERM_PROGRAM_VERSION", "0.30.1");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(
TerminalName::Kitty,
Some("kitty"),
Some("0.30.1"),
None,
None
),
"kitty_term_program_info"
);
assert_eq!(
terminal.user_agent_token(),
"kitty/0.30.1",
"kitty_term_program_user_agent"
);
let env = FakeEnvironment::new()
.with_var("TERM", "xterm-kitty")
.with_var("ALACRITTY_SOCKET", "/tmp/alacritty");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(TerminalName::Kitty, None, None, None, None),
"kitty_term_over_alacritty_info"
);
assert_eq!(
terminal.user_agent_token(),
"kitty",
"kitty_term_over_alacritty_user_agent"
);
}
#[test]
fn detects_alacritty() {
let env = FakeEnvironment::new().with_var("ALACRITTY_SOCKET", "/tmp/alacritty");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(TerminalName::Alacritty, None, None, None, None),
"alacritty_socket_info"
);
assert_eq!(
terminal.user_agent_token(),
"Alacritty",
"alacritty_socket_user_agent"
);
let env = FakeEnvironment::new()
.with_var("TERM_PROGRAM", "Alacritty")
.with_var("TERM_PROGRAM_VERSION", "0.13.2");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(
TerminalName::Alacritty,
Some("Alacritty"),
Some("0.13.2"),
None,
None,
),
"alacritty_term_program_info"
);
assert_eq!(
terminal.user_agent_token(),
"Alacritty/0.13.2",
"alacritty_term_program_user_agent"
);
let env = FakeEnvironment::new().with_var("TERM", "alacritty");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(TerminalName::Alacritty, None, None, None, None),
"alacritty_term_info"
);
assert_eq!(
terminal.user_agent_token(),
"Alacritty",
"alacritty_term_user_agent"
);
}
#[test]
fn detects_konsole() {
let env = FakeEnvironment::new().with_var("KONSOLE_VERSION", "230800");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(TerminalName::Konsole, None, Some("230800"), None, None),
"konsole_version_info"
);
assert_eq!(
terminal.user_agent_token(),
"Konsole/230800",
"konsole_version_user_agent"
);
let env = FakeEnvironment::new()
.with_var("TERM_PROGRAM", "Konsole")
.with_var("TERM_PROGRAM_VERSION", "230800");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(
TerminalName::Konsole,
Some("Konsole"),
Some("230800"),
None,
None
),
"konsole_term_program_info"
);
assert_eq!(
terminal.user_agent_token(),
"Konsole/230800",
"konsole_term_program_user_agent"
);
let env = FakeEnvironment::new().with_var("KONSOLE_VERSION", "");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(TerminalName::Konsole, None, None, None, None),
"konsole_empty_info"
);
assert_eq!(
terminal.user_agent_token(),
"Konsole",
"konsole_empty_user_agent"
);
}
#[test]
fn detects_gnome_terminal() {
let env = FakeEnvironment::new().with_var("GNOME_TERMINAL_SCREEN", "1");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(TerminalName::GnomeTerminal, None, None, None, None),
"gnome_terminal_screen_info"
);
assert_eq!(
terminal.user_agent_token(),
"gnome-terminal",
"gnome_terminal_screen_user_agent"
);
let env = FakeEnvironment::new()
.with_var("TERM_PROGRAM", "gnome-terminal")
.with_var("TERM_PROGRAM_VERSION", "3.50");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(
TerminalName::GnomeTerminal,
Some("gnome-terminal"),
Some("3.50"),
None,
None,
),
"gnome_terminal_term_program_info"
);
assert_eq!(
terminal.user_agent_token(),
"gnome-terminal/3.50",
"gnome_terminal_term_program_user_agent"
);
}
#[test]
fn detects_vte() {
let env = FakeEnvironment::new().with_var("VTE_VERSION", "7000");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(TerminalName::Vte, None, Some("7000"), None, None),
"vte_version_info"
);
assert_eq!(
terminal.user_agent_token(),
"VTE/7000",
"vte_version_user_agent"
);
let env = FakeEnvironment::new()
.with_var("TERM_PROGRAM", "VTE")
.with_var("TERM_PROGRAM_VERSION", "7000");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(TerminalName::Vte, Some("VTE"), Some("7000"), None, None),
"vte_term_program_info"
);
assert_eq!(
terminal.user_agent_token(),
"VTE/7000",
"vte_term_program_user_agent"
);
let env = FakeEnvironment::new().with_var("VTE_VERSION", "");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(TerminalName::Vte, None, None, None, None),
"vte_empty_info"
);
assert_eq!(terminal.user_agent_token(), "VTE", "vte_empty_user_agent");
}
#[test]
fn detects_windows_terminal() {
let env = FakeEnvironment::new().with_var("WT_SESSION", "1");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(TerminalName::WindowsTerminal, None, None, None, None),
"wt_session_info"
);
assert_eq!(
terminal.user_agent_token(),
"WindowsTerminal",
"wt_session_user_agent"
);
let env = FakeEnvironment::new()
.with_var("TERM_PROGRAM", "WindowsTerminal")
.with_var("TERM_PROGRAM_VERSION", "1.21");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(
TerminalName::WindowsTerminal,
Some("WindowsTerminal"),
Some("1.21"),
None,
None,
),
"windows_terminal_term_program_info"
);
assert_eq!(
terminal.user_agent_token(),
"WindowsTerminal/1.21",
"windows_terminal_term_program_user_agent"
);
}
#[test]
fn detects_term_fallbacks() {
let env = FakeEnvironment::new().with_var("TERM", "xterm-256color");
let terminal = detect_terminal_info_from_env(&env);
assert_eq!(
terminal,
terminal_info(
TerminalName::Unknown,
None,
None,
Some("xterm-256color"),
None,
),
"term_fallback_info"
);
assert_eq!(
terminal.user_agent_token(),
"xterm-256color",
"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!(
terminal,
terminal_info(TerminalName::Unknown, None, None, None, None),
"unknown_info"
);
assert_eq!(terminal.user_agent_token(), "unknown", "unknown_user_agent");
}
}