mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Why Users and support need a single command that captures the local Codex runtime, configuration, auth, terminal, network, and state shape without asking the user to know which diagnostic depth to choose first. `codex doctor` now runs the useful checks by default and makes the detailed human output the default because the command is usually run when someone already needs context. The command also targets concrete support failure modes we have seen while iterating on the design: - update-target mismatches like #21956, where the installed package manager target can differ from the running executable - terminal and multiplexer issues that depend on `TERM`, tmux/zellij state, color handling, and TTY metadata - provider-specific HTTP/WebSocket connectivity, including ChatGPT WebSocket handshakes and API-key/provider endpoint reachability - local state/log SQLite integrity problems and large rollout directories - feedback reports that need an attached, redacted diagnostic snapshot without asking the user to run a second command ## What Changed - Adds `codex doctor` as a grouped CLI diagnostic report with default detailed output and `--summary` for the compact view. - Adds stable report sections for Environment, Configuration, Updates, Connectivity, and Background Server, plus a top Notes block that promotes anomalies such as available updates, large rollout directories, optional MCP issues, and mixed auth signals. - Adds runtime provenance, install consistency, bundled/system search readiness, terminal/multiplexer metadata, `config.toml` parse status, auth mode details, sandbox details, feature flag summaries, update cache/latest-version state, app-server daemon state, SQLite integrity checks, rollout statistics, and provider-aware network diagnostics. - Adds ChatGPT WebSocket diagnostics that report the negotiated HTTP upgrade as `HTTP 101 Switching Protocols` and include timeout, DNS, auth, and provider context in detailed output. - Makes reachability provider-aware: API-key OpenAI setups check the API endpoint, ChatGPT auth checks the ChatGPT path, and custom/AWS/local providers check configured HTTP endpoints when available. - Adds structured, redacted JSON output where `checks` is keyed by check id and `details` is a key/value object for support tooling. - Integrates doctor with feedback uploads by attaching a best-effort `codex-doctor-report.json` report and adding derived Sentry tags for overall status and failing/warning checks. - Updates the TUI feedback consent copy so users can see that the doctor report is included when logs/diagnostics are uploaded. - Updates the CLI bug issue template to ask reporters for `codex doctor --json` and render pasted reports as JSON. ## Example Output The examples below are sanitized from local smoke runs with `--no-color` so the structure is reviewable in plain text. ### `codex doctor` ```text Codex Doctor v0.0.0 · macos-aarch64 Notes ↑ updates 0.130.0 available (current 0.0.0, dismissed 0.128.0) ⚠ rollouts 1,526 active files · 2.53 GB on disk ⚠ mcp MCP configuration has optional issues ⚠ auth mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode ───────────────────────────────────────────────────────────── Environment ✓ runtime local debug build version 0.0.0 install method other commit unknown executable ~/code/codex.fcoury-doct…x-rs/target/debug/codex ✓ install consistent context other managed by npm: no · bun: no · package root — PATH entries (2) ~/.local/share/mise/installs/node/24/bin/codex ~/.local/share/mise/shims/codex ✓ search ripgrep 15.1.0 (system, `rg`) ✓ terminal Ghostty 1.3.2-main-+b0f827665 · tmux 3.6a · TERM=xterm-256color terminal Ghostty TERM_PROGRAM ghostty terminal version 1.3.2-main-+b0f827665 TERM xterm-256color multiplexer tmux 3.6a tmux extended-keys on tmux allow-passthrough on tmux set-clipboard on ✓ state databases healthy CODEX_HOME ~/.codex (dir) state DB ~/.codex/state_5.sqlite (file) · integrity ok log DB ~/.codex/logs_2.sqlite (file) · integrity ok active rollouts 1,526 files · 2.53 GB (avg 1.70 MB) archived rollouts 8 files · 3.84 MB (avg 491.11 KB) Configuration ✓ config loaded model gpt-5.5 · openai cwd ~/code/codex.fcoury-doctor/codex-rs config.toml ~/.codex/config.toml config.toml parse ok MCP servers 1 feature flags 36 enabled · 7 overridden (full list with --all) overrides code_mode, code_mode_only, memories, chronicle, goals, remote_control, prevent_idle_sleep ✓ auth auth is configured auth storage mode File auth file ~/.codex/auth.json auth env vars present OPENAI_API_KEY stored auth mode chatgpt stored API key false stored ChatGPT tokens true stored agent identity false ⚠ mcp MCP configuration has optional issues — Set the missing MCP env vars or disable the affected server. configured servers 1 disabled servers 0 streamable_http servers 1 optional reachability openaiDeveloperDocs: https://developers.openai.com/mcp (HEAD connect failed; GET connect failed) ✓ sandbox restricted fs + restricted network · approval OnRequest approval policy OnRequest filesystem sandbox restricted network sandbox restricted Connectivity ✓ network network-related environment looks readable ✓ websocket connected (HTTP 101 Switching Protocols) · 15s timeout model provider openai provider name OpenAI wire API responses supports websockets true connect timeout 15000 ms auth mode chatgpt endpoint wss://chatgpt.com/backend-api/<redacted> DNS 2 IPv4, 2 IPv6, first IPv6 handshake result HTTP 101 Switching Protocols ✗ reachability one or more required provider endpoints are unreachable over HTTP — Check proxy, VPN, firewall, DNS, and custom CA configuration. reachability mode API key auth openai API https://api.openai.com/v1 connect failed (required) Background Server ○ app-server not running (ephemeral mode) ───────────────────────────────────────────────────────────── 11 ok · 1 idle · 4 notes · 1 warn · 1 fail failed --summary compact output --all expand truncated lists --json redacted report ``` ### `codex doctor --summary` ```text Codex Doctor v0.0.0 · macos-aarch64 Notes ↑ updates 0.130.0 available (current 0.0.0, dismissed 0.128.0) ⚠ rollouts 1,526 active files · 2.53 GB on disk ⚠ mcp MCP configuration has optional issues ⚠ auth mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode ───────────────────────────────────────────────────────────── Environment ✓ runtime local debug build ✓ install consistent ✓ search ripgrep 15.1.0 (system, `rg`) ✓ terminal Ghostty 1.3.2-main-+b0f827665 · tmux 3.6a · TERM=xterm-256color ✓ state databases healthy Configuration ✓ config loaded ✓ auth auth is configured ⚠ mcp MCP configuration has optional issues — Set the missing MCP env vars or disable the affected server. ✓ sandbox restricted fs + restricted network · approval OnRequest Updates ✓ updates update configuration is locally consistent Connectivity ✓ network network-related environment looks readable ✓ websocket connected (HTTP 101 Switching Protocols) · 15s timeout ✗ reachability one or more required provider endpoints are unreachable over HTTP — Check proxy, VPN, firewall, DNS, and custom CA configuration. Background Server ○ app-server not running (ephemeral mode) ───────────────────────────────────────────────────────────── 11 ok · 1 idle · 4 notes · 1 warn · 1 fail failed Run codex doctor without --summary for detailed diagnostics. --all expand truncated lists --json redacted report ``` ### `codex doctor --json` shape ```json { "schema_version": 1, "overall_status": "fail", "checks": { "runtime.provenance": { "id": "runtime.provenance", "category": "Environment", "status": "ok", "summary": "local debug build", "details": { "version": "0.0.0", "install method": "other", "commit": "unknown" } }, "sandbox.helpers": { "id": "sandbox.helpers", "category": "Configuration", "status": "ok", "summary": "restricted fs + restricted network · approval OnRequest", "details": { "approval policy": "OnRequest", "filesystem sandbox": "restricted", "network sandbox": "restricted" } } } } ``` ### `/feedback` new sentry attachment <img width="938" height="798" alt="CleanShot 2026-05-13 at 15 36 14" src="https://github.com/user-attachments/assets/715e62e0-d7b4-4fea-a35a-fd5d5d33c4c0" /> ### New section in CLI issue template <img width="1164" height="435" alt="CleanShot 2026-05-13 at 15 47 24" src="https://github.com/user-attachments/assets/9081dc25-a28c-4afa-8ba1-e299c2b4031d" /> ## How to Test 1. Run `cargo run --bin codex -- doctor --no-color`. 2. Confirm the detailed report is the default and includes promoted Notes, grouped sections, terminal details, state DB integrity, rollout stats, provider reachability, WebSocket diagnostics, and app-server status. 3. Run `cargo run --bin codex -- doctor --summary --no-color`. 4. Confirm the compact view keeps the same sections and summary counts but omits detailed key/value rows. 5. Run `cargo run --bin codex -- doctor --json`. 6. Confirm the output is redacted JSON, `checks` is an object keyed by check id, and each check's `details` is a key/value object. 7. Preview the CLI bug issue template and confirm the `Codex doctor report` field appears after the terminal field, asks for `codex doctor --json`, and renders pasted output as JSON. 8. Start a feedback flow that includes logs. 9. Confirm the upload consent copy lists `codex-doctor-report.json` alongside the log attachments. Targeted tests: - `cargo test -p codex-cli doctor` - `cargo test -p codex-app-server doctor_report_tags_summarize_status_counts` - `cargo test -p codex-feedback` - `cargo test -p codex-tui feedback_view` - `just argument-comment-lint` - `git diff --check`
552 lines
18 KiB
Rust
552 lines
18 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 {
|
|
/// Zellij version string when ZELLIJ_VERSION is available.
|
|
version: Option<String>,
|
|
},
|
|
}
|
|
|
|
/// 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,
|
|
/*term*/ 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,
|
|
/*term_program*/ None,
|
|
version,
|
|
/*term*/ None,
|
|
multiplexer,
|
|
)
|
|
}
|
|
|
|
/// Creates terminal metadata from a `TERM` capability value.
|
|
fn from_term(term: String, multiplexer: Option<Multiplexer>) -> Self {
|
|
let name = match term.as_str() {
|
|
"dumb" => TerminalName::Dumb,
|
|
"wezterm" | "wezterm-mux" => TerminalName::WezTerm,
|
|
_ => TerminalName::Unknown,
|
|
};
|
|
Self::new(
|
|
name,
|
|
/*term_program*/ None,
|
|
/*version*/ None,
|
|
Some(term),
|
|
multiplexer,
|
|
)
|
|
}
|
|
|
|
/// Creates terminal metadata for unknown terminals.
|
|
fn unknown(multiplexer: Option<Multiplexer>) -> Self {
|
|
Self::new(
|
|
TerminalName::Unknown,
|
|
/*term_program*/ None,
|
|
/*version*/ None,
|
|
/*term*/ 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)
|
|
}
|
|
|
|
/// Returns whether the active terminal multiplexer is Zellij.
|
|
pub fn is_zellij(&self) -> bool {
|
|
matches!(self.multiplexer, Some(Multiplexer::Zellij { .. }))
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
/// Returns Zellij version details when available.
|
|
fn zellij_version(&self) -> Option<String> {
|
|
self.var_non_empty("ZELLIJ_VERSION")
|
|
}
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
fn zellij_version(&self) -> Option<String> {
|
|
self.var_non_empty("ZELLIJ_VERSION")
|
|
.or_else(zellij_version_from_command)
|
|
}
|
|
}
|
|
|
|
/// 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, /*version*/ None, multiplexer);
|
|
}
|
|
|
|
if env.has("TERM_SESSION_ID") {
|
|
return TerminalInfo::from_name(
|
|
TerminalName::AppleTerminal,
|
|
/*version*/ 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, /*version*/ None, multiplexer);
|
|
}
|
|
|
|
if env.has("ALACRITTY_SOCKET")
|
|
|| env
|
|
.var("TERM")
|
|
.map(|term| term == "alacritty")
|
|
.unwrap_or(false)
|
|
{
|
|
return TerminalInfo::from_name(
|
|
TerminalName::Alacritty,
|
|
/*version*/ 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,
|
|
/*version*/ 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,
|
|
/*version*/ 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 {
|
|
version: env.zellij_version(),
|
|
});
|
|
}
|
|
|
|
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())
|
|
}
|
|
|
|
fn zellij_version_from_command() -> Option<String> {
|
|
// Best-effort fallback: missing or broken zellij binaries should not affect
|
|
// terminal detection.
|
|
let output = std::process::Command::new("zellij")
|
|
.arg("--version")
|
|
.output()
|
|
.ok()?;
|
|
if !output.status.success() {
|
|
return None;
|
|
}
|
|
|
|
let stdout = String::from_utf8(output.stdout).ok()?;
|
|
parse_zellij_version(stdout.trim())
|
|
}
|
|
|
|
fn parse_zellij_version(value: &str) -> Option<String> {
|
|
let value = none_if_whitespace(value.to_string())?;
|
|
let mut parts = value.split_whitespace();
|
|
match (parts.next(), parts.next()) {
|
|
(Some(command), Some(version)) if command.eq_ignore_ascii_case("zellij") => {
|
|
Some(version.to_string())
|
|
}
|
|
_ => Some(value),
|
|
}
|
|
}
|
|
|
|
/// 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)]
|
|
#[path = "terminal_tests.rs"]
|
|
mod tests;
|