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 std::io::IsTerminal;
5use std::sync::LazyLock;
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    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    color_depth: ColorDepth,
79    /// Whether the terminal can render Unicode box-drawing chrome.
80    ///
81    /// Sourced from the canonical capability probe in
82    /// [`crate::terminal_capabilities`].
83    pub unicode_support: bool,
84    /// Whether to force color output
85    pub force_color: bool,
86    /// Whether color is explicitly disabled
87    pub no_color: bool,
88}
89
90impl AnsiCapabilities {
91    /// Detect terminal capabilities
92    pub fn detect() -> Self {
93        Self {
94            color_depth: detect_color_depth(),
95            unicode_support: detect_unicode_support(),
96            force_color: clicolor_force(),
97            no_color: no_color_env_active(),
98        }
99    }
100
101    /// Check if color output is supported
102    pub fn supports_color(&self) -> bool {
103        !self.no_color && (self.force_color || self.color_depth.supports_color())
104    }
105
106    /// Check if 256-color output is supported
107    pub fn supports_256_colors(&self) -> bool {
108        self.supports_color() && self.color_depth.supports_256()
109    }
110
111    /// Check if true color (24-bit) is supported
112    pub fn supports_true_color(&self) -> bool {
113        self.supports_color() && self.color_depth.supports_true_color()
114    }
115}
116
117/// Detected terminal color scheme (light or dark background)
118#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
119pub enum ColorScheme {
120    /// Light background (dark text preferred)
121    Light,
122    /// Dark background (light text preferred)
123    #[default]
124    Dark,
125    /// Unable to detect, assume dark
126    Unknown,
127}
128
129impl ColorScheme {
130    /// Check if this is a light color scheme
131    pub fn is_light(self) -> bool {
132        matches!(self, ColorScheme::Light)
133    }
134
135    /// Check if this is a dark color scheme
136    pub fn is_dark(self) -> bool {
137        matches!(self, ColorScheme::Dark | ColorScheme::Unknown)
138    }
139
140    /// Get a human-readable name
141    pub fn name(self) -> &'static str {
142        match self {
143            ColorScheme::Light => "light",
144            ColorScheme::Dark => "dark",
145            ColorScheme::Unknown => "unknown",
146        }
147    }
148}
149
150const COLOR_SCHEME_LIGHT: u8 = 0;
151const COLOR_SCHEME_DARK: u8 = 1;
152const COLOR_SCHEME_UNKNOWN: u8 = 2;
153const COLOR_SCHEME_UNSET: u8 = 255;
154
155static COLOR_SCHEME_RUNTIME_OVERRIDE: AtomicU8 = AtomicU8::new(COLOR_SCHEME_UNSET);
156
157/// Detect terminal color scheme from environment.
158pub fn detect_color_scheme() -> ColorScheme {
159    if let Some(override_scheme) = color_scheme_runtime_override() {
160        return override_scheme;
161    }
162
163    // Check cached value first
164    static CACHED: LazyLock<ColorScheme> = LazyLock::new(detect_color_scheme_uncached);
165    *CACHED
166}
167
168fn color_scheme_runtime_override() -> Option<ColorScheme> {
169    match COLOR_SCHEME_RUNTIME_OVERRIDE.load(Ordering::Relaxed) {
170        COLOR_SCHEME_LIGHT => Some(ColorScheme::Light),
171        COLOR_SCHEME_DARK => Some(ColorScheme::Dark),
172        COLOR_SCHEME_UNKNOWN => Some(ColorScheme::Unknown),
173        _ => None,
174    }
175}
176
177/// Store a runtime color scheme override.
178///
179/// This is intended to be populated once at startup by terminal OSC probing.
180/// Set `None` to clear the runtime override.
181pub fn set_color_scheme_override(value: Option<ColorScheme>) {
182    let encoded = match value {
183        Some(ColorScheme::Light) => COLOR_SCHEME_LIGHT,
184        Some(ColorScheme::Dark) => COLOR_SCHEME_DARK,
185        Some(ColorScheme::Unknown) => COLOR_SCHEME_UNKNOWN,
186        None => COLOR_SCHEME_UNSET,
187    };
188    COLOR_SCHEME_RUNTIME_OVERRIDE.store(encoded, Ordering::Relaxed);
189}
190
191fn detect_color_scheme_uncached() -> ColorScheme {
192    if let Ok(colorfgbg) = std::env::var("COLORFGBG") {
193        let parts: Vec<&str> = colorfgbg.split(';').collect();
194        if let Some(bg_str) = parts.last()
195            && let Ok(bg) = bg_str.parse::<u8>()
196        {
197            return if bg == 7 || bg == 15 {
198                ColorScheme::Light
199            } else if bg == 0 || bg == 8 {
200                ColorScheme::Dark
201            } else if bg > 230 {
202                ColorScheme::Light
203            } else {
204                ColorScheme::Dark
205            };
206        }
207    }
208
209    if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
210        let term_lower = term_program.to_lowercase();
211        if term_lower.contains("iterm")
212            || term_lower.contains("ghostty")
213            || term_lower.contains("warp")
214            || term_lower.contains("alacritty")
215        {
216            return ColorScheme::Dark;
217        }
218    }
219
220    if cfg!(target_os = "macos")
221        && let Ok(term_program) = std::env::var("TERM_PROGRAM")
222        && term_program == "Apple_Terminal"
223    {
224        return ColorScheme::Light;
225    }
226
227    ColorScheme::Unknown
228}
229
230// Cache detection results to avoid repeated system calls
231static COLOR_DEPTH_CACHE: AtomicU8 = AtomicU8::new(255); // 255 = not cached yet
232
233/// Detect the terminal's color depth
234fn detect_color_depth() -> ColorDepth {
235    let cached = COLOR_DEPTH_CACHE.load(Ordering::Relaxed);
236    if cached != 255 {
237        return match cached {
238            0 => ColorDepth::None,
239            1 => ColorDepth::Basic16,
240            2 => ColorDepth::Color256,
241            3 => ColorDepth::TrueColor,
242            _ => ColorDepth::None,
243        };
244    }
245
246    let depth = if no_color_env_active() {
247        ColorDepth::None
248    } else if clicolor_force() {
249        ColorDepth::TrueColor
250    } else if !clicolor().unwrap_or_else(term_supports_color) {
251        ColorDepth::None
252    } else {
253        std::env::var("COLORTERM")
254            .ok()
255            .and_then(|val| {
256                let lower = val.to_lowercase();
257                if lower.contains("truecolor") || lower.contains("24bit") {
258                    Some(ColorDepth::TrueColor)
259                } else {
260                    None
261                }
262            })
263            .unwrap_or(ColorDepth::Color256)
264    };
265
266    COLOR_DEPTH_CACHE.store(
267        match depth {
268            ColorDepth::None => 0,
269            ColorDepth::Basic16 => 1,
270            ColorDepth::Color256 => 2,
271            ColorDepth::TrueColor => 3,
272        },
273        Ordering::Relaxed,
274    );
275
276    depth
277}
278
279/// Detect if unicode is supported by the terminal.
280///
281/// Delegates to the canonical capability probe so the CLI and the TUI always
282/// reach the same verdict for the same environment.
283fn detect_unicode_support() -> bool {
284    crate::terminal_capabilities::box_glyph_tier().supports_unicode()
285}
286
287/// Global capabilities instance (cached)
288pub static CAPABILITIES: LazyLock<AnsiCapabilities> = LazyLock::new(AnsiCapabilities::detect);
289
290/// Check if NO_COLOR environment variable is set
291pub fn is_no_color() -> bool {
292    no_color_env_active()
293}
294
295/// Check if CLICOLOR_FORCE is set
296pub fn is_clicolor_force() -> bool {
297    clicolor_force()
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn unicode_support_follows_the_canonical_capability_probe() {
306        // The CLI and the TUI must never disagree about the same machine, so
307        // the capability struct has to follow the canonical probe verbatim.
308        let tier = crate::terminal_capabilities::box_glyph_tier();
309        assert_eq!(AnsiCapabilities::detect().unicode_support, tier.supports_unicode());
310        assert_eq!(CAPABILITIES.unicode_support, tier.supports_unicode());
311    }
312}