Skip to main content

vtcode_ui/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>>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
15
16    pub(super) fn get(key: &str) -> Option<Option<String>> {
17        OVERRIDES.lock().unwrap_or_else(|e| e.into_inner()).get(key).cloned()
18    }
19
20    pub(super) fn set(key: &str, value: Option<&str>) {
21        if let Ok(mut map) = OVERRIDES.lock() {
22            map.insert(key.to_string(), value.map(ToString::to_string));
23        }
24    }
25
26    pub(super) fn clear(key: &str) {
27        if let Ok(mut map) = OVERRIDES.lock() {
28            map.remove(key);
29        }
30    }
31}
32
33fn read_env_var(key: &str) -> Option<String> {
34    #[cfg(test)]
35    if let Some(override_value) = test_env_overrides::get(key) {
36        return override_value;
37    }
38
39    env::var(key).ok()
40}
41
42#[cfg(test)]
43pub(crate) fn set_test_env_override(key: &str, value: Option<&str>) {
44    test_env_overrides::set(key, value);
45}
46
47#[cfg(test)]
48pub(crate) fn clear_test_env_override(key: &str) {
49    test_env_overrides::clear(key);
50}
51
52/// Detects if the current terminal supports Unicode box drawing characters
53///
54/// This function checks various environment variables and terminal settings
55/// to determine if Unicode characters can be safely displayed without
56/// appearing as broken ANSI sequences.
57fn supports_unicode_box_drawing() -> bool {
58    // Check if explicitly disabled via environment variable
59    if read_env_var("VTCODE_NO_UNICODE").is_some() {
60        return false;
61    }
62
63    // Check terminal type - many terminals support Unicode
64    if let Some(term) = read_env_var("TERM") {
65        let term_lower = term.to_lowercase();
66
67        // Modern terminals that definitely support Unicode
68        if term_lower.contains("unicode")
69            || term_lower.contains("utf")
70            || term_lower.contains("xterm-256color")
71            || term_lower.contains("screen-256color")
72            || term_lower.contains("tmux-256color")
73            || term_lower.contains("alacritty")
74            || term_lower.contains("wezterm")
75            || term_lower.contains("kitty")
76            || term_lower.contains("iterm")
77            || term_lower.contains("hyper")
78        {
79            return true;
80        }
81
82        // Older or basic terminal types that likely don't support Unicode well
83        if term_lower.contains("dumb")
84            || term_lower.contains("basic")
85            || term_lower == "xterm"
86            || term_lower == "screen"
87        {
88            return false;
89        }
90    }
91
92    // Check LANG environment variable for UTF-8 locale
93    if let Some(lang) = read_env_var("LANG")
94        && (lang.to_lowercase().contains("utf-8") || lang.to_lowercase().contains("utf8"))
95    {
96        return true;
97    }
98
99    // Check LC_ALL and LC_CTYPE for UTF-8
100    for var in &["LC_ALL", "LC_CTYPE"] {
101        if let Some(locale) = read_env_var(var)
102            && (locale.to_lowercase().contains("utf-8") || locale.to_lowercase().contains("utf8"))
103        {
104            return true;
105        }
106    }
107
108    // Default to plain ASCII for safety - prevents broken Unicode display
109    false
110}
111
112/// Detects if the terminal supports rich Unicode box drawing characters
113///
114/// This includes dashed border variants (╌, ╍, ┄, ┅, etc.) used by
115/// `BorderType::HeavyDoubleDashed` and similar types from ratatui 0.30.0.
116/// Only terminals known to render these correctly are opted in. Unknown
117/// Unicode-capable terminals receive `Rounded` borders instead.
118fn supports_rich_unicode_box_drawing() -> bool {
119    // Check if explicitly disabled via environment variable
120    if read_env_var("VTCODE_NO_RICH_UNICODE").is_some() {
121        return false;
122    }
123
124    // Rich Unicode requires basic Unicode support first
125    if !supports_unicode_box_drawing() {
126        return false;
127    }
128
129    // Only opt in for terminals known to render dashed box drawing correctly
130    if let Some(term) = read_env_var("TERM") {
131        let term_lower = term.to_lowercase();
132        return term_lower.contains("alacritty")
133            || term_lower.contains("wezterm")
134            || term_lower.contains("kitty")
135            || term_lower.contains("iterm")
136            || term_lower.contains("hyper")
137            || term_lower.contains("ghostty");
138    }
139
140    false
141}
142
143/// Gets the appropriate border type based on terminal capabilities
144///
145/// Returns `BorderType::HeavyDoubleDashed` for terminals with rich Unicode support,
146/// `BorderType::Rounded` for terminals with basic Unicode support, or
147/// `BorderType::Plain` for maximum compatibility.
148pub(crate) fn get_border_type() -> ratatui::widgets::BorderType {
149    if supports_rich_unicode_box_drawing() {
150        ratatui::widgets::BorderType::HeavyDoubleDashed
151    } else if supports_unicode_box_drawing() {
152        ratatui::widgets::BorderType::Rounded
153    } else {
154        ratatui::widgets::BorderType::Plain
155    }
156}
157
158pub(crate) fn queued_input_edit_uses_shift_left() -> bool {
159    if read_env_var("TMUX").is_some() {
160        return true;
161    }
162
163    read_env_var("TERM")
164        .map(|term| term.to_lowercase().contains("tmux"))
165        .unwrap_or(false)
166}
167
168pub(crate) fn queued_input_edit_hint() -> &'static str {
169    if queued_input_edit_uses_shift_left() {
170        if cfg!(target_os = "macos") {
171            "⇧ + ← edit"
172        } else {
173            "Shift + ← edit"
174        }
175    } else if cfg!(target_os = "macos") {
176        "⌥ + ↑ edit"
177    } else {
178        "Alt + ↑ edit"
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use std::sync::{LazyLock, Mutex};
186
187    static TERMINAL_ENV_TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
188
189    #[inline]
190    fn set_var(key: &str, value: &str) {
191        test_env_overrides::set(key, Some(value));
192    }
193    #[inline]
194    fn remove_var(key: &str) {
195        test_env_overrides::set(key, None);
196    }
197    #[inline]
198    fn clear_var(key: &str) {
199        test_env_overrides::clear(key);
200    }
201
202    #[test]
203    fn test_supports_unicode_box_drawing() {
204        let _guard = TERMINAL_ENV_TEST_LOCK.lock().expect("terminal env test lock");
205        for key in ["TERM", "LANG", "LC_ALL", "LC_CTYPE", "VTCODE_NO_UNICODE"] {
206            clear_var(key);
207        }
208
209        // Test with VTCODE_NO_UNICODE set (should disable Unicode)
210        set_var("VTCODE_NO_UNICODE", "1");
211        assert!(!supports_unicode_box_drawing());
212        remove_var("VTCODE_NO_UNICODE");
213
214        // Test with modern terminal
215        set_var("TERM", "xterm-256color");
216        assert!(supports_unicode_box_drawing());
217
218        // Test with UTF-8 locale
219        set_var("LANG", "en_US.UTF-8");
220        assert!(supports_unicode_box_drawing());
221
222        // Test with basic terminal
223        set_var("TERM", "dumb");
224        assert!(!supports_unicode_box_drawing());
225
226        // Test with no locale info (should default to false for safety)
227        remove_var("TERM");
228        remove_var("LANG");
229        remove_var("LC_ALL");
230        remove_var("LC_CTYPE");
231        assert!(!supports_unicode_box_drawing());
232
233        for key in ["TERM", "LANG", "LC_ALL", "LC_CTYPE", "VTCODE_NO_UNICODE"] {
234            clear_var(key);
235        }
236    }
237
238    #[test]
239    fn test_get_border_type() {
240        let _guard = TERMINAL_ENV_TEST_LOCK.lock().expect("terminal env test lock");
241        clear_var("TERM");
242        clear_var("VTCODE_NO_RICH_UNICODE");
243
244        // Test with rich Unicode terminal (Alacritty)
245        set_var("TERM", "alacritty");
246        let border_type = get_border_type();
247        assert!(matches!(border_type, ratatui::widgets::BorderType::HeavyDoubleDashed));
248
249        // Test with Unicode-supporting terminal (xterm-256color) - not in known list
250        set_var("TERM", "xterm-256color");
251        clear_var("VTCODE_NO_RICH_UNICODE");
252        let border_type = get_border_type();
253        assert!(matches!(border_type, ratatui::widgets::BorderType::Rounded));
254
255        // Test with basic terminal
256        set_var("TERM", "dumb");
257        let border_type = get_border_type();
258        assert!(matches!(border_type, ratatui::widgets::BorderType::Plain));
259
260        // Test with rich Unicode disabled
261        set_var("TERM", "alacritty");
262        set_var("VTCODE_NO_RICH_UNICODE", "1");
263        let border_type = get_border_type();
264        assert!(matches!(border_type, ratatui::widgets::BorderType::Rounded));
265
266        clear_var("TERM");
267        clear_var("VTCODE_NO_RICH_UNICODE");
268    }
269
270    #[test]
271    fn queued_input_edit_binding_switches_for_tmux() {
272        let _guard = TERMINAL_ENV_TEST_LOCK.lock().expect("terminal env test lock");
273        // Shadow instead of clearing: clearing falls back to the host environment,
274        // which leaks when the suite itself runs inside tmux.
275        remove_var("TMUX");
276        remove_var("TERM");
277        set_var("TERM", "xterm-256color");
278        assert!(!queued_input_edit_uses_shift_left());
279
280        set_var("TMUX", "/tmp/tmux-1000/default,123,0");
281        assert!(queued_input_edit_uses_shift_left());
282
283        remove_var("TMUX");
284        set_var("TERM", "tmux-256color");
285        assert!(queued_input_edit_uses_shift_left());
286
287        clear_var("TMUX");
288        clear_var("TERM");
289    }
290}