Skip to main content

a3s_tui/
terminal_profile.rs

1//! Conservative terminal capability detection.
2//!
3//! Terminal applications should not infer support from one environment
4//! variable or assume every terminal and multiplexer forwards the same control
5//! sequences. This module turns the small set of process-local facts that are
6//! safe to inspect into a typed profile. Unknown capabilities stay unknown so
7//! callers can choose a documented fallback instead of emitting an unsafe
8//! sequence.
9
10use std::fmt;
11use std::io::IsTerminal;
12
13const MAX_ENV_VALUE_CHARS: usize = 128;
14
15/// Terminal emulator family detected from well-known, non-secret variables.
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17pub enum TerminalFamily {
18    Alacritty,
19    AppleTerminal,
20    Dumb,
21    Ghostty,
22    Iterm2,
23    Kitty,
24    Konsole,
25    Vscode,
26    Vte,
27    WezTerm,
28    WindowsTerminal,
29    Xterm,
30    #[default]
31    Unknown,
32}
33
34impl fmt::Display for TerminalFamily {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        f.write_str(match self {
37            Self::Alacritty => "Alacritty",
38            Self::AppleTerminal => "Apple Terminal",
39            Self::Dumb => "dumb",
40            Self::Ghostty => "Ghostty",
41            Self::Iterm2 => "iTerm2",
42            Self::Kitty => "Kitty",
43            Self::Konsole => "Konsole",
44            Self::Vscode => "VS Code",
45            Self::Vte => "VTE",
46            Self::WezTerm => "WezTerm",
47            Self::WindowsTerminal => "Windows Terminal",
48            Self::Xterm => "xterm",
49            Self::Unknown => "unknown",
50        })
51    }
52}
53
54/// Terminal multiplexer between the application and emulator.
55#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
56pub enum TerminalMultiplexer {
57    Screen,
58    Tmux,
59    Zellij,
60    #[default]
61    None,
62}
63
64impl fmt::Display for TerminalMultiplexer {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.write_str(match self {
67            Self::Screen => "GNU Screen",
68            Self::Tmux => "tmux",
69            Self::Zellij => "Zellij",
70            Self::None => "none",
71        })
72    }
73}
74
75/// Effective color depth inferred from the terminal environment.
76#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
77pub enum TerminalColorLevel {
78    None,
79    Ansi16,
80    Ansi256,
81    TrueColor,
82    #[default]
83    Unknown,
84}
85
86impl fmt::Display for TerminalColorLevel {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        f.write_str(match self {
89            Self::None => "none",
90            Self::Ansi16 => "16 colors",
91            Self::Ansi256 => "256 colors",
92            Self::TrueColor => "truecolor",
93            Self::Unknown => "unknown",
94        })
95    }
96}
97
98/// Confidence-aware support result for one terminal capability.
99#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
100pub enum TerminalSupport {
101    Supported,
102    Unsupported,
103    RequiresPassthrough,
104    #[default]
105    Unknown,
106}
107
108impl TerminalSupport {
109    pub fn is_supported(self) -> bool {
110        matches!(self, Self::Supported)
111    }
112}
113
114impl fmt::Display for TerminalSupport {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        f.write_str(match self {
117            Self::Supported => "supported",
118            Self::Unsupported => "unsupported",
119            Self::RequiresPassthrough => "multiplexer passthrough required",
120            Self::Unknown => "unknown",
121        })
122    }
123}
124
125/// Presentation mode that is safe for the detected I/O boundary.
126#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
127pub enum TerminalDisplayMode {
128    Fullscreen,
129    #[default]
130    Inline,
131}
132
133impl fmt::Display for TerminalDisplayMode {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        f.write_str(match self {
136            Self::Fullscreen => "fullscreen",
137            Self::Inline => "inline fallback",
138        })
139    }
140}
141
142/// Typed terminal facts and conservative capability decisions.
143#[derive(Clone, Debug, PartialEq, Eq)]
144pub struct TerminalProfile {
145    family: TerminalFamily,
146    multiplexer: TerminalMultiplexer,
147    term: Option<String>,
148    term_program: Option<String>,
149    stdin_tty: bool,
150    stdout_tty: bool,
151    stderr_tty: bool,
152    color_level: TerminalColorLevel,
153    display_mode: TerminalDisplayMode,
154    alternate_screen: TerminalSupport,
155    bracketed_paste: TerminalSupport,
156    mouse_capture: TerminalSupport,
157    enhanced_keyboard: TerminalSupport,
158    hyperlinks: TerminalSupport,
159    clipboard: TerminalSupport,
160}
161
162impl TerminalProfile {
163    /// Detect a profile from the current process without reading arbitrary
164    /// environment values.
165    pub fn detect() -> Self {
166        let io = TerminalIo {
167            stdin: std::io::stdin().is_terminal(),
168            stdout: std::io::stdout().is_terminal(),
169            stderr: std::io::stderr().is_terminal(),
170        };
171        let enhanced_keyboard = match crossterm::terminal::supports_keyboard_enhancement() {
172            Ok(true) => TerminalSupport::Supported,
173            Ok(false) => TerminalSupport::Unsupported,
174            Err(_) => TerminalSupport::Unknown,
175        };
176        Self::detect_with(|name| std::env::var(name).ok(), io, enhanced_keyboard)
177    }
178
179    pub fn family(&self) -> TerminalFamily {
180        self.family
181    }
182
183    pub fn multiplexer(&self) -> TerminalMultiplexer {
184        self.multiplexer
185    }
186
187    pub fn term(&self) -> Option<&str> {
188        self.term.as_deref()
189    }
190
191    pub fn term_program(&self) -> Option<&str> {
192        self.term_program.as_deref()
193    }
194
195    pub fn stdin_is_terminal(&self) -> bool {
196        self.stdin_tty
197    }
198
199    pub fn stdout_is_terminal(&self) -> bool {
200        self.stdout_tty
201    }
202
203    pub fn stderr_is_terminal(&self) -> bool {
204        self.stderr_tty
205    }
206
207    pub fn color_level(&self) -> TerminalColorLevel {
208        self.color_level
209    }
210
211    pub fn display_mode(&self) -> TerminalDisplayMode {
212        self.display_mode
213    }
214
215    pub fn alternate_screen(&self) -> TerminalSupport {
216        self.alternate_screen
217    }
218
219    pub fn bracketed_paste(&self) -> TerminalSupport {
220        self.bracketed_paste
221    }
222
223    pub fn mouse_capture(&self) -> TerminalSupport {
224        self.mouse_capture
225    }
226
227    pub fn enhanced_keyboard(&self) -> TerminalSupport {
228        self.enhanced_keyboard
229    }
230
231    pub fn hyperlinks(&self) -> TerminalSupport {
232        self.hyperlinks
233    }
234
235    pub fn clipboard(&self) -> TerminalSupport {
236        self.clipboard
237    }
238
239    /// Actionable, bounded warnings suitable for a diagnostics screen.
240    pub fn warnings(&self) -> Vec<&'static str> {
241        let mut warnings = Vec::new();
242        if self.display_mode == TerminalDisplayMode::Inline {
243            warnings.push("fullscreen mode is unsafe because stdin/stdout are not interactive");
244        }
245        if self.family == TerminalFamily::Dumb {
246            warnings.push("TERM=dumb disables interactive terminal features");
247        } else if self.family == TerminalFamily::Unknown {
248            warnings.push("terminal family is unknown; optional escape sequences use fallbacks");
249        }
250        if self.multiplexer != TerminalMultiplexer::None {
251            if self.hyperlinks == TerminalSupport::RequiresPassthrough {
252                warnings.push("hyperlinks depend on multiplexer escape-sequence passthrough");
253            }
254            if self.clipboard == TerminalSupport::RequiresPassthrough {
255                warnings.push("clipboard copy depends on multiplexer OSC 52 passthrough");
256            }
257        }
258        if !self.enhanced_keyboard.is_supported() {
259            warnings.push("enhanced key reporting is unavailable; modified-key fallbacks apply");
260        }
261        warnings
262    }
263
264    fn detect_with(
265        lookup: impl Fn(&str) -> Option<String>,
266        io: TerminalIo,
267        enhanced_keyboard: TerminalSupport,
268    ) -> Self {
269        let term = env_value(&lookup, "TERM");
270        let term_program = env_value(&lookup, "TERM_PROGRAM");
271        let multiplexer = detect_multiplexer(&lookup, term.as_deref());
272        let family = detect_family(&lookup, term.as_deref(), term_program.as_deref());
273        let interactive = io.stdin && io.stdout && family != TerminalFamily::Dumb;
274        let baseline = if interactive {
275            TerminalSupport::Supported
276        } else {
277            TerminalSupport::Unsupported
278        };
279        let display_mode = if interactive {
280            TerminalDisplayMode::Fullscreen
281        } else {
282            TerminalDisplayMode::Inline
283        };
284        let enhanced_keyboard = if interactive {
285            enhanced_keyboard
286        } else {
287            TerminalSupport::Unsupported
288        };
289        let color_level = detect_color_level(&lookup, term.as_deref(), family, interactive);
290        let (hyperlinks, clipboard) =
291            detect_escape_capabilities(&lookup, family, multiplexer, interactive);
292
293        Self {
294            family,
295            multiplexer,
296            term,
297            term_program,
298            stdin_tty: io.stdin,
299            stdout_tty: io.stdout,
300            stderr_tty: io.stderr,
301            color_level,
302            display_mode,
303            alternate_screen: baseline,
304            bracketed_paste: baseline,
305            mouse_capture: baseline,
306            enhanced_keyboard,
307            hyperlinks,
308            clipboard,
309        }
310    }
311}
312
313#[derive(Clone, Copy)]
314struct TerminalIo {
315    stdin: bool,
316    stdout: bool,
317    stderr: bool,
318}
319
320fn env_value(lookup: &impl Fn(&str) -> Option<String>, name: &str) -> Option<String> {
321    lookup(name).and_then(|value| sanitize_env_value(&value))
322}
323
324fn env_present(lookup: &impl Fn(&str) -> Option<String>, name: &str) -> bool {
325    lookup(name).is_some_and(|value| !value.is_empty())
326}
327
328fn sanitize_env_value(value: &str) -> Option<String> {
329    let value = value
330        .chars()
331        .filter(|character| !character.is_control())
332        .take(MAX_ENV_VALUE_CHARS)
333        .collect::<String>();
334    (!value.is_empty()).then_some(value)
335}
336
337fn detect_multiplexer(
338    lookup: &impl Fn(&str) -> Option<String>,
339    term: Option<&str>,
340) -> TerminalMultiplexer {
341    if env_present(lookup, "TMUX") {
342        return TerminalMultiplexer::Tmux;
343    }
344    if env_present(lookup, "ZELLIJ") || env_present(lookup, "ZELLIJ_SESSION_NAME") {
345        return TerminalMultiplexer::Zellij;
346    }
347    if env_present(lookup, "STY") || term.is_some_and(|term| term.starts_with("screen")) {
348        return TerminalMultiplexer::Screen;
349    }
350    TerminalMultiplexer::None
351}
352
353fn detect_family(
354    lookup: &impl Fn(&str) -> Option<String>,
355    term: Option<&str>,
356    term_program: Option<&str>,
357) -> TerminalFamily {
358    if term.is_some_and(|term| term.eq_ignore_ascii_case("dumb")) {
359        return TerminalFamily::Dumb;
360    }
361
362    let program = term_program.unwrap_or_default().to_ascii_lowercase();
363    if program.contains("ghostty") || env_present(lookup, "GHOSTTY_RESOURCES_DIR") {
364        return TerminalFamily::Ghostty;
365    }
366    if program.contains("wezterm") || env_present(lookup, "WEZTERM_PANE") {
367        return TerminalFamily::WezTerm;
368    }
369    if program.contains("iterm") || env_present(lookup, "ITERM_SESSION_ID") {
370        return TerminalFamily::Iterm2;
371    }
372    if program.contains("apple_terminal") {
373        return TerminalFamily::AppleTerminal;
374    }
375    if program.contains("vscode") || env_present(lookup, "VSCODE_INJECTION") {
376        return TerminalFamily::Vscode;
377    }
378    if env_present(lookup, "WT_SESSION") {
379        return TerminalFamily::WindowsTerminal;
380    }
381    if program.contains("kitty")
382        || env_present(lookup, "KITTY_WINDOW_ID")
383        || term.is_some_and(|term| term.contains("kitty"))
384    {
385        return TerminalFamily::Kitty;
386    }
387    if env_present(lookup, "KONSOLE_VERSION") {
388        return TerminalFamily::Konsole;
389    }
390    if program.contains("alacritty") || term.is_some_and(|term| term.contains("alacritty")) {
391        return TerminalFamily::Alacritty;
392    }
393    if env_present(lookup, "VTE_VERSION") {
394        return TerminalFamily::Vte;
395    }
396    if term.is_some_and(|term| term.contains("xterm")) {
397        return TerminalFamily::Xterm;
398    }
399    TerminalFamily::Unknown
400}
401
402fn detect_color_level(
403    lookup: &impl Fn(&str) -> Option<String>,
404    term: Option<&str>,
405    family: TerminalFamily,
406    interactive: bool,
407) -> TerminalColorLevel {
408    if !interactive {
409        return TerminalColorLevel::None;
410    }
411    let color_term = env_value(lookup, "COLORTERM")
412        .unwrap_or_default()
413        .to_ascii_lowercase();
414    if matches!(color_term.as_str(), "truecolor" | "24bit") {
415        return TerminalColorLevel::TrueColor;
416    }
417    if term.is_some_and(|term| term.contains("256color")) {
418        return TerminalColorLevel::Ansi256;
419    }
420    if matches!(
421        family,
422        TerminalFamily::Ghostty
423            | TerminalFamily::Iterm2
424            | TerminalFamily::Kitty
425            | TerminalFamily::Vscode
426            | TerminalFamily::WezTerm
427            | TerminalFamily::WindowsTerminal
428    ) {
429        return TerminalColorLevel::TrueColor;
430    }
431    TerminalColorLevel::Ansi16
432}
433
434fn detect_escape_capabilities(
435    lookup: &impl Fn(&str) -> Option<String>,
436    family: TerminalFamily,
437    multiplexer: TerminalMultiplexer,
438    interactive: bool,
439) -> (TerminalSupport, TerminalSupport) {
440    if !interactive {
441        return (TerminalSupport::Unsupported, TerminalSupport::Unsupported);
442    }
443    if multiplexer != TerminalMultiplexer::None {
444        return (
445            TerminalSupport::RequiresPassthrough,
446            TerminalSupport::RequiresPassthrough,
447        );
448    }
449
450    let hyperlinks = match family {
451        TerminalFamily::Ghostty
452        | TerminalFamily::Iterm2
453        | TerminalFamily::Kitty
454        | TerminalFamily::Konsole
455        | TerminalFamily::Vscode
456        | TerminalFamily::WezTerm
457        | TerminalFamily::WindowsTerminal => TerminalSupport::Supported,
458        TerminalFamily::Vte if vte_supports_hyperlinks(lookup) => TerminalSupport::Supported,
459        TerminalFamily::Dumb => TerminalSupport::Unsupported,
460        _ => TerminalSupport::Unknown,
461    };
462    let clipboard = match family {
463        TerminalFamily::Ghostty
464        | TerminalFamily::Iterm2
465        | TerminalFamily::Kitty
466        | TerminalFamily::Vscode
467        | TerminalFamily::WezTerm
468        | TerminalFamily::WindowsTerminal => TerminalSupport::Supported,
469        TerminalFamily::Dumb => TerminalSupport::Unsupported,
470        _ => TerminalSupport::Unknown,
471    };
472    (hyperlinks, clipboard)
473}
474
475fn vte_supports_hyperlinks(lookup: &impl Fn(&str) -> Option<String>) -> bool {
476    env_value(lookup, "VTE_VERSION")
477        .and_then(|version| version.parse::<u32>().ok())
478        .is_some_and(|version| version >= 5_000)
479}
480
481#[cfg(test)]
482mod tests {
483    use std::collections::HashMap;
484
485    use super::*;
486
487    fn profile(
488        values: &[(&str, &str)],
489        io: TerminalIo,
490        keyboard: TerminalSupport,
491    ) -> TerminalProfile {
492        let values = values
493            .iter()
494            .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
495            .collect::<HashMap<_, _>>();
496        TerminalProfile::detect_with(|name| values.get(name).cloned(), io, keyboard)
497    }
498
499    fn interactive_io() -> TerminalIo {
500        TerminalIo {
501            stdin: true,
502            stdout: true,
503            stderr: true,
504        }
505    }
506
507    #[test]
508    fn kitty_inside_tmux_requires_explicit_escape_passthrough() {
509        let profile = profile(
510            &[
511                ("TERM", "screen-256color"),
512                ("TERM_PROGRAM", "kitty"),
513                ("TMUX", "/tmp/tmux-501/default,123,0"),
514                ("COLORTERM", "truecolor"),
515            ],
516            interactive_io(),
517            TerminalSupport::Supported,
518        );
519
520        assert_eq!(profile.family(), TerminalFamily::Kitty);
521        assert_eq!(profile.multiplexer(), TerminalMultiplexer::Tmux);
522        assert_eq!(profile.color_level(), TerminalColorLevel::TrueColor);
523        assert_eq!(profile.display_mode(), TerminalDisplayMode::Fullscreen);
524        assert_eq!(profile.hyperlinks(), TerminalSupport::RequiresPassthrough);
525        assert_eq!(profile.clipboard(), TerminalSupport::RequiresPassthrough);
526        assert!(profile
527            .warnings()
528            .iter()
529            .any(|warning| warning.contains("OSC 52")));
530    }
531
532    #[test]
533    fn dumb_or_noninteractive_term_uses_inline_fallbacks() {
534        let profile = profile(
535            &[("TERM", "dumb")],
536            TerminalIo {
537                stdin: false,
538                stdout: false,
539                stderr: true,
540            },
541            TerminalSupport::Supported,
542        );
543
544        assert_eq!(profile.family(), TerminalFamily::Dumb);
545        assert_eq!(profile.color_level(), TerminalColorLevel::None);
546        assert_eq!(profile.display_mode(), TerminalDisplayMode::Inline);
547        assert_eq!(profile.enhanced_keyboard(), TerminalSupport::Unsupported);
548        assert_eq!(profile.mouse_capture(), TerminalSupport::Unsupported);
549        assert_eq!(profile.bracketed_paste(), TerminalSupport::Unsupported);
550    }
551
552    #[test]
553    fn modern_emulators_expose_known_native_capabilities() {
554        let profile = profile(
555            &[("TERM", "xterm-256color"), ("TERM_PROGRAM", "WezTerm")],
556            interactive_io(),
557            TerminalSupport::Supported,
558        );
559
560        assert_eq!(profile.family(), TerminalFamily::WezTerm);
561        assert_eq!(profile.multiplexer(), TerminalMultiplexer::None);
562        assert_eq!(profile.color_level(), TerminalColorLevel::Ansi256);
563        assert_eq!(profile.hyperlinks(), TerminalSupport::Supported);
564        assert_eq!(profile.clipboard(), TerminalSupport::Supported);
565        assert_eq!(profile.alternate_screen(), TerminalSupport::Supported);
566    }
567
568    #[test]
569    fn recent_vte_reports_hyperlink_support_without_guessing_clipboard() {
570        let profile = profile(
571            &[("TERM", "xterm-256color"), ("VTE_VERSION", "7200")],
572            interactive_io(),
573            TerminalSupport::Unknown,
574        );
575
576        assert_eq!(profile.family(), TerminalFamily::Vte);
577        assert_eq!(profile.hyperlinks(), TerminalSupport::Supported);
578        assert_eq!(profile.clipboard(), TerminalSupport::Unknown);
579    }
580
581    #[test]
582    fn displayed_environment_values_are_bounded_and_control_free() {
583        let hostile = format!("xterm\u{1b}[31m{}", "x".repeat(300));
584        let profile = profile(
585            &[("TERM", &hostile)],
586            interactive_io(),
587            TerminalSupport::Unknown,
588        );
589        let term = profile.term().expect("TERM should be retained safely");
590
591        assert!(!term.contains('\u{1b}'));
592        assert_eq!(term.chars().count(), MAX_ENV_VALUE_CHARS);
593    }
594}