use std::io::IsTerminal;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum TerminalCapability {
OscProgress,
AsciFallback,
Silent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Multiplexer {
None,
Tmux,
Screen,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct DetectOptions {
pub force: bool,
pub disabled: bool,
pub is_tty: Option<bool>,
}
#[must_use]
pub fn detect(opts: &DetectOptions) -> TerminalCapability {
detect_with_env(opts, &EnvReader::REAL)
}
#[must_use]
pub fn detect_with_env(opts: &DetectOptions, env: &dyn EnvLookup) -> TerminalCapability {
if opts.disabled {
let is_tty = opts
.is_tty
.unwrap_or_else(|| std::io::stderr().is_terminal());
return if is_tty {
TerminalCapability::AsciFallback
} else {
TerminalCapability::Silent
};
}
if opts.force {
return TerminalCapability::OscProgress;
}
if env.var("TERMPULSE_FORCE").as_deref() == Some("1") {
return TerminalCapability::OscProgress;
}
if env.var("TERMPULSE_DISABLE").as_deref() == Some("1") {
let is_tty = opts
.is_tty
.unwrap_or_else(|| std::io::stderr().is_terminal());
return if is_tty {
TerminalCapability::AsciFallback
} else {
TerminalCapability::Silent
};
}
if env.var("NO_COLOR").is_some() {
let is_tty = opts
.is_tty
.unwrap_or_else(|| std::io::stderr().is_terminal());
return if is_tty {
TerminalCapability::AsciFallback
} else {
TerminalCapability::Silent
};
}
let is_tty = opts
.is_tty
.unwrap_or_else(|| std::io::stderr().is_terminal());
if !is_tty {
return TerminalCapability::Silent;
}
let term_program = env.var("TERM_PROGRAM").unwrap_or_default().to_lowercase();
if term_program.contains("ghostty") {
return TerminalCapability::OscProgress;
}
if term_program.contains("wezterm") {
return TerminalCapability::OscProgress;
}
if term_program.contains("iterm") {
return TerminalCapability::OscProgress;
}
if term_program.contains("kitty") {
return TerminalCapability::OscProgress;
}
if term_program.contains("vscode") {
return TerminalCapability::OscProgress;
}
if term_program.contains("contour") {
return TerminalCapability::OscProgress;
}
if term_program.contains("rio") {
return TerminalCapability::OscProgress;
}
if env.var("WT_SESSION").is_some() {
return TerminalCapability::OscProgress;
}
if env.var("ConEmuPID").is_some() {
return TerminalCapability::OscProgress;
}
let term = env.var("TERM").unwrap_or_default();
if term.starts_with("foot") {
return TerminalCapability::OscProgress;
}
TerminalCapability::AsciFallback
}
#[must_use]
pub fn detect_multiplexer(env: &dyn EnvLookup) -> Multiplexer {
if env.var("TMUX").is_some() {
Multiplexer::Tmux
} else if env.var("STY").is_some() {
Multiplexer::Screen
} else {
Multiplexer::None
}
}
#[must_use]
pub fn multiplexer_supports_passthrough(mux: &Multiplexer) -> bool {
match mux {
Multiplexer::Tmux => true, Multiplexer::Screen => false, Multiplexer::None => true, }
}
pub trait EnvLookup {
fn var(&self, name: &str) -> Option<String>;
}
#[derive(Debug, Clone, Copy)]
pub struct EnvReader;
impl EnvReader {
pub const REAL: Self = Self;
}
impl EnvLookup for EnvReader {
fn var(&self, name: &str) -> Option<String> {
std::env::var(name).ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
struct MockEnv(HashMap<String, String>);
impl MockEnv {
fn new() -> Self {
Self(HashMap::new())
}
fn set(mut self, key: &str, val: &str) -> Self {
self.0.insert(key.to_string(), val.to_string());
self
}
}
impl EnvLookup for MockEnv {
fn var(&self, name: &str) -> Option<String> {
self.0.get(name).cloned()
}
}
fn opts_tty() -> DetectOptions {
DetectOptions {
is_tty: Some(true),
..Default::default()
}
}
fn opts_no_tty() -> DetectOptions {
DetectOptions {
is_tty: Some(false),
..Default::default()
}
}
#[test]
fn force_override() {
let opts = DetectOptions {
force: true,
is_tty: Some(false),
..Default::default()
};
assert_eq!(
detect_with_env(&opts, &MockEnv::new()),
TerminalCapability::OscProgress
);
}
#[test]
fn disabled_override_tty() {
let opts = DetectOptions {
disabled: true,
is_tty: Some(true),
..Default::default()
};
assert_eq!(
detect_with_env(&opts, &MockEnv::new()),
TerminalCapability::AsciFallback
);
}
#[test]
fn disabled_override_no_tty() {
let opts = DetectOptions {
disabled: true,
is_tty: Some(false),
..Default::default()
};
assert_eq!(
detect_with_env(&opts, &MockEnv::new()),
TerminalCapability::Silent
);
}
#[test]
fn env_force() {
let env = MockEnv::new().set("TERMPULSE_FORCE", "1");
assert_eq!(
detect_with_env(&opts_tty(), &env),
TerminalCapability::OscProgress
);
}
#[test]
fn env_disable() {
let env = MockEnv::new().set("TERMPULSE_DISABLE", "1");
assert_eq!(
detect_with_env(&opts_tty(), &env),
TerminalCapability::AsciFallback
);
}
#[test]
fn no_color_falls_back_to_ascii() {
let env = MockEnv::new()
.set("NO_COLOR", "1")
.set("TERM_PROGRAM", "ghostty");
assert_eq!(
detect_with_env(&opts_tty(), &env),
TerminalCapability::AsciFallback
);
}
#[test]
fn no_color_empty_value_still_triggers() {
let env = MockEnv::new().set("NO_COLOR", "");
assert_eq!(
detect_with_env(&opts_tty(), &env),
TerminalCapability::AsciFallback
);
}
#[test]
fn force_overrides_no_color() {
let env = MockEnv::new()
.set("NO_COLOR", "1")
.set("TERMPULSE_FORCE", "1");
assert_eq!(
detect_with_env(&opts_tty(), &env),
TerminalCapability::OscProgress
);
}
#[test]
fn not_tty_is_silent() {
assert_eq!(
detect_with_env(&opts_no_tty(), &MockEnv::new()),
TerminalCapability::Silent
);
}
#[test]
fn ghostty_detected() {
let env = MockEnv::new().set("TERM_PROGRAM", "ghostty");
assert_eq!(
detect_with_env(&opts_tty(), &env),
TerminalCapability::OscProgress
);
}
#[test]
fn wezterm_detected() {
let env = MockEnv::new().set("TERM_PROGRAM", "WezTerm");
assert_eq!(
detect_with_env(&opts_tty(), &env),
TerminalCapability::OscProgress
);
}
#[test]
fn windows_terminal_detected() {
let env = MockEnv::new().set("WT_SESSION", "some-guid");
assert_eq!(
detect_with_env(&opts_tty(), &env),
TerminalCapability::OscProgress
);
}
#[test]
fn iterm2_detected() {
let env = MockEnv::new().set("TERM_PROGRAM", "iTerm.app");
assert_eq!(
detect_with_env(&opts_tty(), &env),
TerminalCapability::OscProgress
);
}
#[test]
fn kitty_detected() {
let env = MockEnv::new().set("TERM_PROGRAM", "kitty");
assert_eq!(
detect_with_env(&opts_tty(), &env),
TerminalCapability::OscProgress
);
}
#[test]
fn conemu_detected() {
let env = MockEnv::new().set("ConEmuPID", "1234");
assert_eq!(
detect_with_env(&opts_tty(), &env),
TerminalCapability::OscProgress
);
}
#[test]
fn foot_detected() {
let env = MockEnv::new().set("TERM", "foot-extra");
assert_eq!(
detect_with_env(&opts_tty(), &env),
TerminalCapability::OscProgress
);
}
#[test]
fn unknown_tty_is_ascii_fallback() {
let env = MockEnv::new().set("TERM_PROGRAM", "some-unknown-terminal");
assert_eq!(
detect_with_env(&opts_tty(), &env),
TerminalCapability::AsciFallback
);
}
#[test]
fn detect_tmux() {
let env = MockEnv::new().set("TMUX", "/tmp/tmux-1000/default,1234,0");
assert_eq!(detect_multiplexer(&env), Multiplexer::Tmux);
}
#[test]
fn detect_screen() {
let env = MockEnv::new().set("STY", "1234.pts-0.hostname");
assert_eq!(detect_multiplexer(&env), Multiplexer::Screen);
}
#[test]
fn detect_no_multiplexer() {
assert_eq!(detect_multiplexer(&MockEnv::new()), Multiplexer::None);
}
#[test]
fn tmux_passthrough_supported() {
assert!(multiplexer_supports_passthrough(&Multiplexer::Tmux));
assert!(multiplexer_supports_passthrough(&Multiplexer::None));
assert!(!multiplexer_supports_passthrough(&Multiplexer::Screen));
}
}