Skip to main content

vtcode_commons/
ansi_capabilities.rs

1//! ANSI terminal capabilities detection and feature support
2
3use crate::color_policy::no_color_env_active;
4use once_cell::sync::Lazy;
5use std::io::IsTerminal;
6use std::sync::atomic::{AtomicU8, Ordering};
7
8/// Check if `CLICOLOR` environment variable is set to a non-zero value.
9fn clicolor() -> Option<bool> {
10    match std::env::var("CLICOLOR") {
11        Ok(val) => Some(!val.is_empty() && val != "0"),
12        Err(_) => None,
13    }
14}
15
16/// Check if `CLICOLOR_FORCE` environment variable is set to a non-zero value.
17fn clicolor_force() -> bool {
18    std::env::var("CLICOLOR_FORCE").is_ok_and(|val| !val.is_empty() && val != "0")
19}
20
21/// Check if the terminal supports ANSI color output.
22fn term_supports_color() -> bool {
23    if !std::io::stdout().is_terminal() {
24        return false;
25    }
26    if let Ok(term) = std::env::var("TERM") {
27        if term == "dumb" || term == "emacs" {
28            return false;
29        }
30    }
31    true
32}
33
34/// Color depth support level detected for the terminal
35#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
36pub enum ColorDepth {
37    /// No color support
38    None = 0,
39    /// 16 colors (basic ANSI)
40    Basic16 = 1,
41    /// 256 colors
42    Color256 = 2,
43    /// True color (24-bit RGB)
44    TrueColor = 3,
45}
46
47impl ColorDepth {
48    /// Get a human-readable name for this color depth
49    pub fn name(self) -> &'static str {
50        match self {
51            ColorDepth::None => "none",
52            ColorDepth::Basic16 => "16-color",
53            ColorDepth::Color256 => "256-color",
54            ColorDepth::TrueColor => "true-color",
55        }
56    }
57
58    /// Check if this depth supports color
59    pub fn supports_color(self) -> bool {
60        self != ColorDepth::None
61    }
62
63    /// Check if this depth is at least 256 colors
64    pub fn supports_256(self) -> bool {
65        self >= ColorDepth::Color256
66    }
67
68    /// Check if this depth supports true color
69    pub fn supports_true_color(self) -> bool {
70        self == ColorDepth::TrueColor
71    }
72}
73
74/// ANSI terminal feature capabilities
75#[derive(Clone, Copy, Debug)]
76pub struct AnsiCapabilities {
77    /// Detected color depth
78    pub color_depth: ColorDepth,
79    /// Whether unicode is supported
80    pub unicode_support: bool,
81    /// Whether to force color output
82    pub force_color: bool,
83    /// Whether color is explicitly disabled
84    pub no_color: bool,
85}
86
87impl AnsiCapabilities {
88    /// Detect terminal capabilities
89    pub fn detect() -> Self {
90        Self {
91            color_depth: detect_color_depth(),
92            unicode_support: detect_unicode_support(),
93            force_color: clicolor_force(),
94            no_color: no_color_env_active(),
95        }
96    }
97
98    /// Check if color output is supported
99    pub fn supports_color(&self) -> bool {
100        !self.no_color && (self.force_color || self.color_depth.supports_color())
101    }
102
103    /// Check if 256-color output is supported
104    pub fn supports_256_colors(&self) -> bool {
105        self.supports_color() && self.color_depth.supports_256()
106    }
107
108    /// Check if true color (24-bit) is supported
109    pub fn supports_true_color(&self) -> bool {
110        self.supports_color() && self.color_depth.supports_true_color()
111    }
112
113    /// Check if advanced formatting (tables, boxes) should use unicode
114    pub fn should_use_unicode_boxes(&self) -> bool {
115        self.unicode_support && self.supports_color()
116    }
117}
118
119/// Detected terminal color scheme (light or dark background)
120#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
121pub enum ColorScheme {
122    /// Light background (dark text preferred)
123    Light,
124    /// Dark background (light text preferred)
125    #[default]
126    Dark,
127    /// Unable to detect, assume dark
128    Unknown,
129}
130
131impl ColorScheme {
132    /// Check if this is a light color scheme
133    pub fn is_light(self) -> bool {
134        matches!(self, ColorScheme::Light)
135    }
136
137    /// Check if this is a dark color scheme
138    pub fn is_dark(self) -> bool {
139        matches!(self, ColorScheme::Dark | ColorScheme::Unknown)
140    }
141
142    /// Get a human-readable name
143    pub fn name(self) -> &'static str {
144        match self {
145            ColorScheme::Light => "light",
146            ColorScheme::Dark => "dark",
147            ColorScheme::Unknown => "unknown",
148        }
149    }
150}
151
152const COLOR_SCHEME_LIGHT: u8 = 0;
153const COLOR_SCHEME_DARK: u8 = 1;
154const COLOR_SCHEME_UNKNOWN: u8 = 2;
155const COLOR_SCHEME_UNSET: u8 = 255;
156
157static COLOR_SCHEME_RUNTIME_OVERRIDE: AtomicU8 = AtomicU8::new(COLOR_SCHEME_UNSET);
158
159/// Detect terminal color scheme from environment.
160pub fn detect_color_scheme() -> ColorScheme {
161    if let Some(override_scheme) = color_scheme_runtime_override() {
162        return override_scheme;
163    }
164
165    // Check cached value first
166    static CACHED: Lazy<ColorScheme> = Lazy::new(detect_color_scheme_uncached);
167    *CACHED
168}
169
170fn color_scheme_runtime_override() -> Option<ColorScheme> {
171    match COLOR_SCHEME_RUNTIME_OVERRIDE.load(Ordering::Relaxed) {
172        COLOR_SCHEME_LIGHT => Some(ColorScheme::Light),
173        COLOR_SCHEME_DARK => Some(ColorScheme::Dark),
174        COLOR_SCHEME_UNKNOWN => Some(ColorScheme::Unknown),
175        _ => None,
176    }
177}
178
179/// Store a runtime color scheme override.
180///
181/// This is intended to be populated once at startup by terminal OSC probing.
182/// Set `None` to clear the runtime override.
183pub fn set_color_scheme_override(value: Option<ColorScheme>) {
184    let encoded = match value {
185        Some(ColorScheme::Light) => COLOR_SCHEME_LIGHT,
186        Some(ColorScheme::Dark) => COLOR_SCHEME_DARK,
187        Some(ColorScheme::Unknown) => COLOR_SCHEME_UNKNOWN,
188        None => COLOR_SCHEME_UNSET,
189    };
190    COLOR_SCHEME_RUNTIME_OVERRIDE.store(encoded, Ordering::Relaxed);
191}
192
193fn detect_color_scheme_uncached() -> ColorScheme {
194    if let Ok(colorfgbg) = std::env::var("COLORFGBG") {
195        let parts: Vec<&str> = colorfgbg.split(';').collect();
196        if let Some(bg_str) = parts.last()
197            && let Ok(bg) = bg_str.parse::<u8>()
198        {
199            return if bg == 7 || bg == 15 {
200                ColorScheme::Light
201            } else if bg == 0 || bg == 8 {
202                ColorScheme::Dark
203            } else if bg > 230 {
204                ColorScheme::Light
205            } else {
206                ColorScheme::Dark
207            };
208        }
209    }
210
211    if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
212        let term_lower = term_program.to_lowercase();
213        if term_lower.contains("iterm")
214            || term_lower.contains("ghostty")
215            || term_lower.contains("warp")
216            || term_lower.contains("alacritty")
217        {
218            return ColorScheme::Dark;
219        }
220    }
221
222    if cfg!(target_os = "macos")
223        && let Ok(term_program) = std::env::var("TERM_PROGRAM")
224        && term_program == "Apple_Terminal"
225    {
226        return ColorScheme::Light;
227    }
228
229    ColorScheme::Unknown
230}
231
232// Cache detection results to avoid repeated system calls
233static COLOR_DEPTH_CACHE: AtomicU8 = AtomicU8::new(255); // 255 = not cached yet
234
235/// Detect the terminal's color depth
236fn detect_color_depth() -> ColorDepth {
237    let cached = COLOR_DEPTH_CACHE.load(Ordering::Relaxed);
238    if cached != 255 {
239        return match cached {
240            0 => ColorDepth::None,
241            1 => ColorDepth::Basic16,
242            2 => ColorDepth::Color256,
243            3 => ColorDepth::TrueColor,
244            _ => ColorDepth::None,
245        };
246    }
247
248    let depth = if no_color_env_active() {
249        ColorDepth::None
250    } else if clicolor_force() {
251        ColorDepth::TrueColor
252    } else if !clicolor().unwrap_or_else(term_supports_color) {
253        ColorDepth::None
254    } else {
255        std::env::var("COLORTERM")
256            .ok()
257            .and_then(|val| {
258                let lower = val.to_lowercase();
259                if lower.contains("truecolor") || lower.contains("24bit") {
260                    Some(ColorDepth::TrueColor)
261                } else {
262                    None
263                }
264            })
265            .unwrap_or(ColorDepth::Color256)
266    };
267
268    COLOR_DEPTH_CACHE.store(
269        match depth {
270            ColorDepth::None => 0,
271            ColorDepth::Basic16 => 1,
272            ColorDepth::Color256 => 2,
273            ColorDepth::TrueColor => 3,
274        },
275        Ordering::Relaxed,
276    );
277
278    depth
279}
280
281/// Detect if unicode is supported by the terminal
282fn detect_unicode_support() -> bool {
283    std::env::var("LANG")
284        .ok()
285        .map(|lang| lang.to_lowercase().contains("utf"))
286        .or_else(|| std::env::var("LC_ALL").ok().map(|lc| lc.to_lowercase().contains("utf")))
287        .unwrap_or(true)
288}
289
290/// Global capabilities instance (cached)
291pub static CAPABILITIES: Lazy<AnsiCapabilities> = Lazy::new(AnsiCapabilities::detect);
292
293/// Check if NO_COLOR environment variable is set
294pub fn is_no_color() -> bool {
295    no_color_env_active()
296}
297
298/// Check if CLICOLOR_FORCE is set
299pub fn is_clicolor_force() -> bool {
300    clicolor_force()
301}