Skip to main content

vtcode_commons/
terminal_capabilities.rs

1//! Canonical terminal capability probe.
2//!
3//! VT Code paints box-drawing chrome in more than one surface (the CLI
4//! transcript, the TUI panels, the modal lists) and each surface used to
5//! answer "can this terminal draw Unicode?" on its own. Those answers
6//! disagreed, so the same machine could render Unicode chrome in one surface
7//! and ASCII chrome in another.
8//!
9//! This module owns the single answer: one probe, one verdict, evaluated once
10//! per process. The verdict is a deterministic fallback ladder:
11//!
12//! 1. `VTCODE_NO_UNICODE` set to an affirmative value drops straight to ASCII.
13//! 2. A terminal that advertises Unicode (`TERM`), or a UTF-8 locale, earns
14//!    Unicode box drawing.
15//! 3. A terminal known to render the dashed box-drawing variants correctly
16//!    earns the rich tier.
17//! 4. Everything else stays on ASCII, which never renders as mojibake.
18//!
19//! The probe never guesses from the terminal *name* alone: iTerm2, WezTerm,
20//! and Hyper all advertise `TERM=xterm-256color`, so `TERM_PROGRAM` is the
21//! signal that identifies them.
22
23use std::sync::LazyLock;
24
25use crate::utils::env_flag_value_enabled;
26
27/// Richness of the box-drawing chrome the terminal can render.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
29pub enum BoxGlyphTier {
30    /// ASCII-only chrome (`+`, `-`, `|`).
31    #[default]
32    Ascii,
33    /// Unicode box drawing (`╭ ─ ╮ │`).
34    Unicode,
35    /// Unicode plus the dashed variants only some terminals render correctly.
36    RichUnicode,
37}
38
39impl BoxGlyphTier {
40    /// Whether this tier can draw Unicode box-drawing glyphs.
41    #[must_use]
42    pub fn supports_unicode(self) -> bool {
43        !matches!(self, Self::Ascii)
44    }
45}
46
47/// Terminal environment inputs the capability probe reads.
48///
49/// The verdict is a pure function of this snapshot, so callers that need to
50/// evaluate a hypothetical environment can do so without mutating the
51/// process environment.
52#[derive(Debug, Clone, Default, PartialEq, Eq)]
53pub struct TerminalEnv {
54    /// `TERM` — the terminal's generic capability string.
55    pub term: Option<String>,
56    /// `TERM_PROGRAM` — the terminal program's own name.
57    pub term_program: Option<String>,
58    /// `LANG` — the primary locale.
59    pub lang: Option<String>,
60    /// `LC_ALL` — the locale override.
61    pub lc_all: Option<String>,
62    /// `LC_CTYPE` — the character-encoding locale.
63    pub lc_ctype: Option<String>,
64    /// `VTCODE_NO_UNICODE` — opt out of Unicode chrome.
65    pub no_unicode: Option<String>,
66    /// `VTCODE_NO_RICH_UNICODE` — opt out of the dashed box-drawing variants.
67    pub no_rich_unicode: Option<String>,
68}
69
70impl TerminalEnv {
71    /// Snapshot the current process environment.
72    #[must_use]
73    pub fn capture() -> Self {
74        Self {
75            term: std::env::var("TERM").ok(),
76            term_program: std::env::var("TERM_PROGRAM").ok(),
77            lang: std::env::var("LANG").ok(),
78            lc_all: std::env::var("LC_ALL").ok(),
79            lc_ctype: std::env::var("LC_CTYPE").ok(),
80            no_unicode: std::env::var("VTCODE_NO_UNICODE").ok(),
81            no_rich_unicode: std::env::var("VTCODE_NO_RICH_UNICODE").ok(),
82        }
83    }
84
85    /// Whether the user asked for ASCII-only chrome.
86    ///
87    /// Uses the repository's canonical env-flag semantics: only an affirmative
88    /// value (`1`, `true`, `yes`, `on`, `debug`) disables Unicode, so
89    /// `VTCODE_NO_UNICODE=0` leaves Unicode chrome enabled.
90    #[must_use]
91    pub fn no_unicode_requested(&self) -> bool {
92        env_flag_value_enabled(self.no_unicode.as_deref())
93    }
94
95    /// Whether the user asked to skip the dashed box-drawing variants.
96    #[must_use]
97    pub fn no_rich_unicode_requested(&self) -> bool {
98        env_flag_value_enabled(self.no_rich_unicode.as_deref())
99    }
100
101    /// Whether the terminal can draw Unicode box-drawing glyphs.
102    ///
103    /// `TERM` is authoritative because it describes the terminal program; the
104    /// locale only decides the question when `TERM` says nothing. A UTF-8
105    /// locale is the only positive evidence left, so without one the answer
106    /// stays at "no" and the terminal keeps ASCII chrome.
107    #[must_use]
108    pub fn supports_unicode_box_drawing(&self) -> bool {
109        if let Some(term) = self.term.as_deref() {
110            if term_supports_unicode(term) {
111                return true;
112            }
113            if term_lacks_unicode(term) {
114                return false;
115            }
116        }
117
118        [self.lang.as_deref(), self.lc_all.as_deref(), self.lc_ctype.as_deref()]
119            .iter()
120            .flatten()
121            .any(|locale| locale_is_utf8(locale))
122    }
123
124    /// Whether the terminal renders the dashed box-drawing variants correctly.
125    ///
126    /// `TERM_PROGRAM` identifies the terminal program; `TERM` only names it
127    /// for the terminals that put their name in `TERM` as well.
128    #[must_use]
129    pub fn supports_rich_box_drawing(&self) -> bool {
130        const RICH_TERMINALS: [&str; 6] = ["ghostty", "wezterm", "iterm", "hyper", "kitty", "alacritty"];
131
132        let term_program = self.term_program.as_deref().unwrap_or_default();
133        let term = self.term.as_deref().unwrap_or_default();
134        RICH_TERMINALS.iter().any(|name| {
135            crate::formatting::contains_ignore_ascii_case(term_program, name)
136                || crate::formatting::contains_ignore_ascii_case(term, name)
137        })
138    }
139}
140
141/// Whether the environment advertises Unicode box-drawing support.
142#[must_use]
143pub fn box_glyph_tier() -> BoxGlyphTier {
144    *BOX_GLYPH_TIER
145}
146
147/// Evaluate the capability ladder against a captured environment.
148#[must_use]
149pub fn box_glyph_tier_for(env: &TerminalEnv) -> BoxGlyphTier {
150    if env.no_unicode_requested() {
151        return BoxGlyphTier::Ascii;
152    }
153    if !env.supports_unicode_box_drawing() {
154        return BoxGlyphTier::Ascii;
155    }
156    if env.no_rich_unicode_requested() {
157        return BoxGlyphTier::Unicode;
158    }
159    if env.supports_rich_box_drawing() {
160        return BoxGlyphTier::RichUnicode;
161    }
162    BoxGlyphTier::Unicode
163}
164
165/// The cached verdict; the environment does not change while VT Code runs.
166static BOX_GLYPH_TIER: LazyLock<BoxGlyphTier> = LazyLock::new(|| box_glyph_tier_for(&TerminalEnv::capture()));
167
168/// Terminals whose `TERM` value advertises Unicode box-drawing support.
169fn term_supports_unicode(term: &str) -> bool {
170    let term = term.to_ascii_lowercase();
171    term.contains("utf")
172        || term.contains("unicode")
173        || term.contains("256color")
174        || term.contains("truecolor")
175        || term.contains("direct")
176        || term.contains("alacritty")
177        || term.contains("wezterm")
178        || term.contains("kitty")
179        || term.contains("iterm")
180        || term.contains("hyper")
181        || term.contains("ghostty")
182        || term.contains("konsole")
183        || term.contains("contour")
184        || term.contains("foot")
185}
186
187/// Terminals that cannot render Unicode box-drawing glyphs at all.
188fn term_lacks_unicode(term: &str) -> bool {
189    let term = term.to_ascii_lowercase();
190    term == "dumb" || term == "emacs" || term == "ansi" || term == "cons25" || term == "basic" || term.starts_with("vt")
191}
192
193/// Whether a locale value declares a UTF-8 encoding.
194fn locale_is_utf8(locale: &str) -> bool {
195    locale.to_ascii_lowercase().contains("utf")
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    fn env(term: Option<&str>, term_program: Option<&str>, lang: Option<&str>) -> TerminalEnv {
203        TerminalEnv {
204            term: term.map(str::to_owned),
205            term_program: term_program.map(str::to_owned),
206            lang: lang.map(str::to_owned),
207            ..TerminalEnv::default()
208        }
209    }
210
211    #[test]
212    fn ascii_is_the_fallback_without_evidence() {
213        assert_eq!(box_glyph_tier_for(&TerminalEnv::default()), BoxGlyphTier::Ascii);
214        assert_eq!(box_glyph_tier_for(&env(None, None, None)), BoxGlyphTier::Ascii);
215    }
216
217    #[test]
218    fn term_advertisement_earns_unicode_even_with_a_c_locale() {
219        let tier = box_glyph_tier_for(&env(Some("xterm-256color"), None, Some("C")));
220        assert_eq!(tier, BoxGlyphTier::Unicode);
221    }
222
223    #[test]
224    fn utf8_locale_earns_unicode_when_term_says_nothing() {
225        assert_eq!(box_glyph_tier_for(&env(None, None, Some("en_US.UTF-8"))), BoxGlyphTier::Unicode);
226        assert_eq!(
227            box_glyph_tier_for(&TerminalEnv {
228                lc_ctype: Some("en_US.UTF-8".to_owned()),
229                ..TerminalEnv::default()
230            }),
231            BoxGlyphTier::Unicode
232        );
233    }
234
235    #[test]
236    fn explicitly_non_utf8_locale_stays_ascii() {
237        assert_eq!(box_glyph_tier_for(&env(None, None, Some("C"))), BoxGlyphTier::Ascii);
238    }
239
240    #[test]
241    fn dumb_terminal_stays_ascii_even_with_a_utf8_locale() {
242        assert_eq!(box_glyph_tier_for(&env(Some("dumb"), None, Some("en_US.UTF-8"))), BoxGlyphTier::Ascii);
243    }
244
245    #[test]
246    fn term_program_identifies_rich_terminals_that_share_a_generic_term() {
247        for (program, term) in [
248            ("iTerm.app", "xterm-256color"),
249            ("WezTerm", "xterm-256color"),
250            ("Hyper.app", "xterm-256color"),
251            ("kitty", "xterm-kitty"),
252            ("ghostty", "xterm-ghostty"),
253            ("Alacritty", "alacritty"),
254        ] {
255            let tier = box_glyph_tier_for(&env(Some(term), Some(program), Some("en_US.UTF-8")));
256            assert_eq!(tier, BoxGlyphTier::RichUnicode, "{program} should reach the rich tier");
257        }
258    }
259
260    #[test]
261    fn plain_terminals_stay_on_the_unicode_tier() {
262        assert_eq!(box_glyph_tier_for(&env(Some("xterm-256color"), None, Some("en_US.UTF-8"))), BoxGlyphTier::Unicode);
263        assert_eq!(
264            box_glyph_tier_for(&env(Some("xterm-256color"), Some("Apple_Terminal"), Some("en_US.UTF-8"))),
265            BoxGlyphTier::Unicode
266        );
267    }
268
269    #[test]
270    fn opt_out_flags_use_canonical_flag_semantics() {
271        let base = env(Some("xterm-256color"), Some("WezTerm"), Some("en_US.UTF-8"));
272        assert_eq!(box_glyph_tier_for(&base), BoxGlyphTier::RichUnicode);
273
274        let no_unicode = TerminalEnv { no_unicode: Some("1".to_owned()), ..base.clone() };
275        assert_eq!(box_glyph_tier_for(&no_unicode), BoxGlyphTier::Ascii);
276
277        let no_rich = TerminalEnv {
278            no_rich_unicode: Some("1".to_owned()),
279            ..base.clone()
280        };
281        assert_eq!(box_glyph_tier_for(&no_rich), BoxGlyphTier::Unicode);
282    }
283
284    #[test]
285    fn falsy_opt_out_values_do_not_disable_unicode() {
286        for value in ["0", "false", "off", "no", ""] {
287            let disabled = TerminalEnv {
288                no_unicode: Some(value.to_owned()),
289                no_rich_unicode: Some(value.to_owned()),
290                ..env(Some("xterm-256color"), Some("WezTerm"), Some("en_US.UTF-8"))
291            };
292            assert_eq!(
293                box_glyph_tier_for(&disabled),
294                BoxGlyphTier::RichUnicode,
295                "VTCODE_NO_UNICODE={value:?} must not disable Unicode"
296            );
297        }
298    }
299
300    #[test]
301    fn tier_supports_unicode_matches_the_ladder() {
302        assert!(!BoxGlyphTier::Ascii.supports_unicode());
303        assert!(BoxGlyphTier::Unicode.supports_unicode());
304        assert!(BoxGlyphTier::RichUnicode.supports_unicode());
305    }
306}