Skip to main content

vtcode_tui/core_tui/session/
terminal_capabilities.rs

1//! Terminal capability detection for optimal rendering
2//!
3//! This module provides utilities to detect terminal capabilities such as
4//! Unicode support, color support, and other features to ensure optimal
5//! rendering across different terminal environments.
6
7use std::env;
8
9#[cfg(test)]
10mod test_env_overrides {
11    use hashbrown::HashMap;
12    use std::sync::{LazyLock, Mutex};
13
14    static OVERRIDES: LazyLock<Mutex<HashMap<String, Option<String>>>> =
15        LazyLock::new(|| Mutex::new(HashMap::new()));
16
17    pub(super) fn get(key: &str) -> Option<Option<String>> {
18        OVERRIDES.lock().ok().and_then(|map| map.get(key).cloned())
19    }
20
21    pub(super) fn set(key: &str, value: Option<&str>) {
22        if let Ok(mut map) = OVERRIDES.lock() {
23            map.insert(key.to_string(), value.map(ToString::to_string));
24        }
25    }
26
27    pub(super) fn clear(key: &str) {
28        if let Ok(mut map) = OVERRIDES.lock() {
29            map.remove(key);
30        }
31    }
32}
33
34fn read_env_var(key: &str) -> Option<String> {
35    #[cfg(test)]
36    if let Some(override_value) = test_env_overrides::get(key) {
37        return override_value;
38    }
39
40    env::var(key).ok()
41}
42
43#[cfg(test)]
44pub(crate) fn set_test_env_override(key: &str, value: Option<&str>) {
45    test_env_overrides::set(key, value);
46}
47
48#[cfg(test)]
49pub(crate) fn clear_test_env_override(key: &str) {
50    test_env_overrides::clear(key);
51}
52
53/// Detects if the current terminal supports Unicode box drawing characters
54///
55/// This function checks various environment variables and terminal settings
56/// to determine if Unicode characters can be safely displayed without
57/// appearing as broken ANSI sequences.
58pub fn supports_unicode_box_drawing() -> bool {
59    // Check if explicitly disabled via environment variable
60    if read_env_var("VTCODE_NO_UNICODE").is_some() {
61        return false;
62    }
63
64    // Check terminal type - many terminals support Unicode
65    if let Some(term) = read_env_var("TERM") {
66        let term_lower = term.to_lowercase();
67
68        // Modern terminals that definitely support Unicode
69        if term_lower.contains("unicode")
70            || term_lower.contains("utf")
71            || term_lower.contains("xterm-256color")
72            || term_lower.contains("screen-256color")
73            || term_lower.contains("tmux-256color")
74            || term_lower.contains("alacritty")
75            || term_lower.contains("wezterm")
76            || term_lower.contains("kitty")
77            || term_lower.contains("iterm")
78            || term_lower.contains("hyper")
79        {
80            return true;
81        }
82
83        // Older or basic terminal types that likely don't support Unicode well
84        if term_lower.contains("dumb")
85            || term_lower.contains("basic")
86            || term_lower == "xterm"
87            || term_lower == "screen"
88        {
89            return false;
90        }
91    }
92
93    // Check LANG environment variable for UTF-8 locale
94    if let Some(lang) = read_env_var("LANG")
95        && (lang.to_lowercase().contains("utf-8") || lang.to_lowercase().contains("utf8"))
96    {
97        return true;
98    }
99
100    // Check LC_ALL and LC_CTYPE for UTF-8
101    for var in &["LC_ALL", "LC_CTYPE"] {
102        if let Some(locale) = read_env_var(var)
103            && (locale.to_lowercase().contains("utf-8") || locale.to_lowercase().contains("utf8"))
104        {
105            return true;
106        }
107    }
108
109    // Default to plain ASCII for safety - prevents broken Unicode display
110    false
111}
112
113/// Gets the appropriate border type based on terminal capabilities
114///
115/// Returns `BorderType::Rounded` if Unicode is supported, otherwise
116/// returns `BorderType::Plain` for maximum compatibility.
117pub fn get_border_type() -> ratatui::widgets::BorderType {
118    if supports_unicode_box_drawing() {
119        ratatui::widgets::BorderType::Rounded
120    } else {
121        ratatui::widgets::BorderType::Plain
122    }
123}
124
125pub(crate) fn queued_input_edit_uses_shift_left() -> bool {
126    if read_env_var("TMUX").is_some() {
127        return true;
128    }
129
130    read_env_var("TERM")
131        .map(|term| term.to_lowercase().contains("tmux"))
132        .unwrap_or(false)
133}
134
135pub(crate) fn queued_input_edit_hint() -> &'static str {
136    if queued_input_edit_uses_shift_left() {
137        if cfg!(target_os = "macos") {
138            "⇧ + ← edit"
139        } else {
140            "Shift + ← edit"
141        }
142    } else if cfg!(target_os = "macos") {
143        "⌥ + ↑ edit"
144    } else {
145        "Alt + ↑ edit"
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    #[inline]
153    fn set_var(key: &str, value: &str) {
154        test_env_overrides::set(key, Some(value));
155    }
156    #[inline]
157    fn remove_var(key: &str) {
158        test_env_overrides::set(key, None);
159    }
160    #[inline]
161    fn clear_var(key: &str) {
162        test_env_overrides::clear(key);
163    }
164
165    #[test]
166    fn test_supports_unicode_box_drawing() {
167        // Test with different environment variable combinations
168
169        // Save original values
170        let original_term = env::var("TERM").ok();
171        let original_lang = env::var("LANG").ok();
172        let original_lc_all = env::var("LC_ALL").ok();
173        let original_lc_ctype = env::var("LC_CTYPE").ok();
174        let original_no_unicode = env::var("VTCODE_NO_UNICODE").ok();
175
176        // Test with VTCODE_NO_UNICODE set (should disable Unicode)
177        set_var("VTCODE_NO_UNICODE", "1");
178        assert!(!supports_unicode_box_drawing());
179        remove_var("VTCODE_NO_UNICODE");
180
181        // Test with modern terminal
182        set_var("TERM", "xterm-256color");
183        assert!(supports_unicode_box_drawing());
184
185        // Test with UTF-8 locale
186        set_var("LANG", "en_US.UTF-8");
187        assert!(supports_unicode_box_drawing());
188
189        // Test with basic terminal
190        set_var("TERM", "dumb");
191        assert!(!supports_unicode_box_drawing());
192
193        // Test with no locale info (should default to false for safety)
194        remove_var("TERM");
195        remove_var("LANG");
196        remove_var("LC_ALL");
197        remove_var("LC_CTYPE");
198        assert!(!supports_unicode_box_drawing());
199
200        // Restore original values
201        match original_term {
202            Some(val) => set_var("TERM", &val),
203            None => clear_var("TERM"),
204        }
205        match original_lang {
206            Some(val) => set_var("LANG", &val),
207            None => clear_var("LANG"),
208        }
209        match original_lc_all {
210            Some(val) => set_var("LC_ALL", &val),
211            None => clear_var("LC_ALL"),
212        }
213        match original_lc_ctype {
214            Some(val) => set_var("LC_CTYPE", &val),
215            None => clear_var("LC_CTYPE"),
216        }
217        match original_no_unicode {
218            Some(val) => set_var("VTCODE_NO_UNICODE", &val),
219            None => clear_var("VTCODE_NO_UNICODE"),
220        }
221    }
222
223    #[test]
224    fn test_get_border_type() {
225        // Save original TERM
226        let original_term = env::var("TERM").ok();
227
228        // Test with Unicode-supporting terminal
229        set_var("TERM", "xterm-256color");
230        let border_type = get_border_type();
231        assert!(matches!(border_type, ratatui::widgets::BorderType::Rounded));
232
233        // Test with basic terminal
234        set_var("TERM", "dumb");
235        let border_type = get_border_type();
236        assert!(matches!(border_type, ratatui::widgets::BorderType::Plain));
237
238        // Restore original TERM
239        match original_term {
240            Some(val) => set_var("TERM", &val),
241            None => clear_var("TERM"),
242        }
243    }
244
245    #[test]
246    fn queued_input_edit_binding_switches_for_tmux() {
247        let original_tmux = env::var("TMUX").ok();
248        let original_term = env::var("TERM").ok();
249
250        remove_var("TMUX");
251        set_var("TERM", "xterm-256color");
252        assert!(!queued_input_edit_uses_shift_left());
253
254        set_var("TMUX", "/tmp/tmux-1000/default,123,0");
255        assert!(queued_input_edit_uses_shift_left());
256
257        remove_var("TMUX");
258        set_var("TERM", "tmux-256color");
259        assert!(queued_input_edit_uses_shift_left());
260
261        match original_tmux {
262            Some(val) => set_var("TMUX", &val),
263            None => clear_var("TMUX"),
264        }
265        match original_term {
266            Some(val) => set_var("TERM", &val),
267            None => clear_var("TERM"),
268        }
269    }
270}