Skip to main content

mdcat/terminal/
detect.rs

1// Copyright 2018-2020 Sebastian Wiesner <sebastian@swsnr.de>
2
3// This Source Code Form is subject to the terms of the Mozilla Public
4// License, v. 2.0. If a copy of the MPL was not distributed with this
5// file, You can obtain one at http://mozilla.org/MPL/2.0/.
6
7//! Detect the terminal application mdcat is running on.
8
9use crate::terminal::capabilities::iterm2::ITerm2Protocol;
10use crate::terminal::capabilities::*;
11use std::fmt::{Display, Formatter};
12
13/// A terminal application.
14#[derive(Debug, Copy, Clone, Eq, PartialEq)]
15pub enum TerminalProgram {
16    /// A dumb terminal which does not support any formatting.
17    Dumb,
18    /// A plain ANSI terminal which supports only standard ANSI formatting.
19    Ansi,
20    /// iTerm2 — <https://www.iterm2.com>.
21    ITerm2,
22    /// Terminology — <http://terminolo.gy>.
23    Terminology,
24    /// Kitty — <https://sw.kovidgoyal.net/kitty/>.
25    Kitty,
26    /// WezTerm — <https://wezfurlong.org/wezterm/>.
27    WezTerm,
28    /// The built-in terminal in VSCode (since 1.80, iTerm2 image protocol).
29    VSCode,
30    /// Ghostty — <https://mitchellh.com/ghostty>.
31    Ghostty,
32    /// Alacritty — ANSI + OSC 8 hyperlinks.
33    Alacritty,
34    /// Foot, Wayland terminal — ANSI + OSC 8 + Sixel (when the feature lands).
35    Foot,
36    /// KDE Konsole — ANSI + OSC 8.
37    Konsole,
38    /// Apple's Terminal.app — ANSI only on older macOS; OSC 8 on macOS 15+.
39    AppleTerminal,
40    /// Warp — <https://warp.dev>.
41    Warp,
42    /// Rio — <https://raphamorim.io/rio/>. Supports Kitty graphics.
43    Rio,
44    /// Hyper (Electron-based) — ANSI only.
45    Hyper,
46    /// Contour — ANSI + OSC 8 (Sixel when the feature lands).
47    Contour,
48    /// mlterm — ANSI + Sixel (when the feature lands).
49    Mlterm,
50    /// Windows Terminal — ANSI + OSC 8 (Sixel since 1.22 beta).
51    WindowsTerminal,
52}
53
54impl Display for TerminalProgram {
55    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
56        let name = match *self {
57            TerminalProgram::Dumb => "dumb",
58            TerminalProgram::Ansi => "ansi",
59            TerminalProgram::ITerm2 => "iTerm2",
60            TerminalProgram::Terminology => "Terminology",
61            TerminalProgram::Kitty => "kitty",
62            TerminalProgram::WezTerm => "WezTerm",
63            TerminalProgram::VSCode => "vscode",
64            TerminalProgram::Ghostty => "ghostty",
65            TerminalProgram::Alacritty => "Alacritty",
66            TerminalProgram::Foot => "foot",
67            TerminalProgram::Konsole => "Konsole",
68            TerminalProgram::AppleTerminal => "Apple Terminal",
69            TerminalProgram::Warp => "Warp",
70            TerminalProgram::Rio => "Rio",
71            TerminalProgram::Hyper => "Hyper",
72            TerminalProgram::Contour => "Contour",
73            TerminalProgram::Mlterm => "mlterm",
74            TerminalProgram::WindowsTerminal => "Windows Terminal",
75        };
76        write!(f, "{name}")
77    }
78}
79
80/// Extract major and minor version from `$TERM_PROGRAM_VERSION`.
81///
82/// Return `None` if the variable doesn't exist, or has invalid contents, such as
83/// non-numeric parts, insufficient parts for a major.minor version, etc.
84fn get_term_program_major_minor_version() -> Option<(u16, u16)> {
85    let value = std::env::var("TERM_PROGRAM_VERSION").ok()?;
86    let mut parts = value.split('.').take(2);
87    let major = parts.next()?.parse().ok()?;
88    let minor = parts.next()?.parse().ok()?;
89    Some((major, minor))
90}
91
92impl TerminalProgram {
93    fn detect_term() -> Option<Self> {
94        let term = std::env::var("TERM").ok();
95        let t = term.as_deref()?;
96        match t {
97            "wezterm" => Some(Self::WezTerm),
98            "xterm-kitty" => Some(Self::Kitty),
99            "xterm-ghostty" => Some(Self::Ghostty),
100            "alacritty" | "xterm-alacritty" => Some(Self::Alacritty),
101            "foot" | "foot-extra" | "xterm-foot" => Some(Self::Foot),
102            "rio" | "xterm-rio" => Some(Self::Rio),
103            _ if t.starts_with("mlterm") => Some(Self::Mlterm),
104            _ => None,
105        }
106    }
107
108    fn detect_term_program() -> Option<Self> {
109        match std::env::var("TERM_PROGRAM").ok().as_deref() {
110            Some("WezTerm") => Some(Self::WezTerm),
111            Some("iTerm.app") => Some(Self::ITerm2),
112            Some("ghostty") => Some(Self::Ghostty),
113            Some("Apple_Terminal") => Some(Self::AppleTerminal),
114            Some("WarpTerminal") => Some(Self::Warp),
115            Some("Hyper") => Some(Self::Hyper),
116            Some("alacritty") => Some(Self::Alacritty),
117            Some("rio") => Some(Self::Rio),
118            Some("vscode")
119                if get_term_program_major_minor_version()
120                    .is_some_and(|version| (1, 80) <= version) =>
121            {
122                Some(Self::VSCode)
123            }
124            _ => None,
125        }
126    }
127
128    /// Look at less-common environment variables terminals set to announce
129    /// themselves. Third-tier after `$TERM` and `$TERM_PROGRAM`.
130    fn detect_secondary_env() -> Option<Self> {
131        if std::env::var_os("WT_SESSION").is_some() {
132            return Some(Self::WindowsTerminal);
133        }
134        if std::env::var_os("KONSOLE_VERSION").is_some() {
135            return Some(Self::Konsole);
136        }
137        if let Ok(value) = std::env::var("TERMINAL_EMULATOR") {
138            if value.eq_ignore_ascii_case("contour") {
139                return Some(Self::Contour);
140            }
141        }
142        if matches!(std::env::var("TERMINOLOGY").ok().as_deref(), Some("1")) {
143            return Some(Self::Terminology);
144        }
145        None
146    }
147
148    /// Attempt to detect the terminal program mdcat is running on.
149    ///
150    /// Environment variables are consulted in the following priority order:
151    ///
152    /// 1. `$TERM` (most reliable — it propagates across `sudo`/`ssh`)
153    /// 2. `$TERM_PROGRAM`
154    /// 3. Terminal-specific markers: `$WT_SESSION` (Windows Terminal),
155    ///    `$KONSOLE_VERSION`, `$TERMINAL_EMULATOR` (Contour),
156    ///    `$TERMINOLOGY`.
157    ///
158    /// Falls back to [`TerminalProgram::Ansi`] when no signal is found.
159    pub fn detect() -> Self {
160        Self::detect_term()
161            .or_else(Self::detect_term_program)
162            .or_else(Self::detect_secondary_env)
163            .unwrap_or(Self::Ansi)
164    }
165
166    /// Get the capabilities of this terminal emulator.
167    pub fn capabilities(self) -> TerminalCapabilities {
168        let ansi = TerminalCapabilities {
169            style: Some(StyleCapability::Ansi),
170            image: None,
171            marks: None,
172        };
173        let kitty = || ImageCapability::Kitty(self::kitty::KittyGraphicsProtocol);
174        let iterm2 = || ImageCapability::ITerm2(ITerm2Protocol);
175        #[cfg(feature = "sixel")]
176        let sixel = || ImageCapability::Sixel(self::sixel::SixelProtocol);
177        match self {
178            TerminalProgram::Dumb => TerminalCapabilities::default(),
179            TerminalProgram::Ansi
180            | TerminalProgram::Alacritty
181            | TerminalProgram::Konsole
182            | TerminalProgram::AppleTerminal
183            | TerminalProgram::Warp
184            | TerminalProgram::Hyper => ansi,
185            TerminalProgram::ITerm2 => ansi
186                .with_mark_capability(MarkCapability::ITerm2(ITerm2Protocol))
187                .with_image_capability(iterm2()),
188            TerminalProgram::VSCode => ansi.with_image_capability(iterm2()),
189            TerminalProgram::Terminology => {
190                ansi.with_image_capability(ImageCapability::Terminology(terminology::Terminology))
191            }
192            TerminalProgram::Kitty
193            | TerminalProgram::WezTerm
194            | TerminalProgram::Ghostty
195            | TerminalProgram::Rio => ansi.with_image_capability(kitty()),
196            // Sixel-capable terminals: get the Sixel protocol when the feature
197            // is enabled, otherwise fall back to plain ANSI.
198            TerminalProgram::Foot
199            | TerminalProgram::Contour
200            | TerminalProgram::Mlterm
201            | TerminalProgram::WindowsTerminal => {
202                #[cfg(feature = "sixel")]
203                {
204                    ansi.with_image_capability(sixel())
205                }
206                #[cfg(not(feature = "sixel"))]
207                {
208                    ansi
209                }
210            }
211        }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use crate::terminal::TerminalProgram;
218
219    use temp_env::with_vars;
220
221    #[test]
222    pub fn detect_term_kitty() {
223        with_vars(vec![("TERM", Some("xterm-kitty"))], || {
224            assert_eq!(TerminalProgram::detect(), TerminalProgram::Kitty)
225        })
226    }
227
228    #[test]
229    pub fn detect_term_wezterm() {
230        with_vars(vec![("TERM", Some("wezterm"))], || {
231            assert_eq!(TerminalProgram::detect(), TerminalProgram::WezTerm)
232        })
233    }
234
235    #[test]
236    pub fn detect_term_program_wezterm() {
237        with_vars(
238            vec![
239                ("TERM", Some("xterm-256color")),
240                ("TERM_PROGRAM", Some("WezTerm")),
241            ],
242            || assert_eq!(TerminalProgram::detect(), TerminalProgram::WezTerm),
243        )
244    }
245
246    #[test]
247    pub fn detect_term_program_iterm2() {
248        with_vars(
249            vec![
250                ("TERM", Some("xterm-256color")),
251                ("TERM_PROGRAM", Some("iTerm.app")),
252            ],
253            || assert_eq!(TerminalProgram::detect(), TerminalProgram::ITerm2),
254        )
255    }
256
257    #[test]
258    pub fn detect_terminology() {
259        with_vars(
260            vec![
261                ("TERM", Some("xterm-256color")),
262                ("TERM_PROGRAM", None),
263                ("TERMINOLOGY", Some("1")),
264            ],
265            || assert_eq!(TerminalProgram::detect(), TerminalProgram::Terminology),
266        );
267        with_vars(
268            vec![
269                ("TERM", Some("xterm-256color")),
270                ("TERM_PROGRAM", None),
271                ("TERMINOLOGY", Some("0")),
272            ],
273            || assert_eq!(TerminalProgram::detect(), TerminalProgram::Ansi),
274        );
275    }
276
277    #[test]
278    pub fn detect_term_ghostty() {
279        with_vars(vec![("TERM", Some("xterm-ghostty"))], || {
280            assert_eq!(TerminalProgram::detect(), TerminalProgram::Ghostty)
281        })
282    }
283
284    #[test]
285    pub fn detect_term_program_ghostty() {
286        with_vars(
287            vec![
288                ("TERM", Some("xterm-256color")),
289                ("TERM_PROGRAM", Some("ghostty")),
290            ],
291            || assert_eq!(TerminalProgram::detect(), TerminalProgram::Ghostty),
292        )
293    }
294
295    #[test]
296    pub fn detect_ansi() {
297        with_vars(
298            vec![
299                ("TERM", Some("xterm-256color")),
300                ("TERM_PROGRAM", None),
301                ("TERMINOLOGY", None),
302            ],
303            || assert_eq!(TerminalProgram::detect(), TerminalProgram::Ansi),
304        )
305    }
306
307    /// Regression test for <https://github.com/swsnr/mdcat/issues/230>
308    #[test]
309    #[allow(non_snake_case)]
310    pub fn GH_230_detect_nested_kitty_from_iterm2() {
311        with_vars(
312            vec![
313                ("TERM_PROGRAM", Some("iTerm.app")),
314                ("TERM", Some("xterm-kitty")),
315            ],
316            || assert_eq!(TerminalProgram::detect(), TerminalProgram::Kitty),
317        )
318    }
319
320    // ─── terminals added in 3.0 ────────────────────────────────────────────
321
322    fn assert_detects(env: Vec<(&str, Option<&str>)>, expected: TerminalProgram) {
323        with_vars(env, || assert_eq!(TerminalProgram::detect(), expected));
324    }
325
326    #[test]
327    fn detect_alacritty_via_term() {
328        assert_detects(
329            vec![("TERM", Some("alacritty"))],
330            TerminalProgram::Alacritty,
331        );
332    }
333
334    #[test]
335    fn detect_alacritty_via_term_program() {
336        assert_detects(
337            vec![
338                ("TERM", Some("xterm-256color")),
339                ("TERM_PROGRAM", Some("alacritty")),
340            ],
341            TerminalProgram::Alacritty,
342        );
343    }
344
345    #[test]
346    fn detect_foot() {
347        assert_detects(vec![("TERM", Some("foot"))], TerminalProgram::Foot);
348    }
349
350    #[test]
351    fn detect_rio_via_term() {
352        assert_detects(vec![("TERM", Some("rio"))], TerminalProgram::Rio);
353    }
354
355    #[test]
356    fn detect_rio_via_term_program() {
357        assert_detects(
358            vec![
359                ("TERM", Some("xterm-256color")),
360                ("TERM_PROGRAM", Some("rio")),
361            ],
362            TerminalProgram::Rio,
363        );
364    }
365
366    #[test]
367    fn detect_mlterm() {
368        assert_detects(vec![("TERM", Some("mlterm"))], TerminalProgram::Mlterm);
369    }
370
371    #[test]
372    fn detect_warp() {
373        assert_detects(
374            vec![
375                ("TERM", Some("xterm-256color")),
376                ("TERM_PROGRAM", Some("WarpTerminal")),
377            ],
378            TerminalProgram::Warp,
379        );
380    }
381
382    #[test]
383    fn detect_hyper() {
384        assert_detects(
385            vec![
386                ("TERM", Some("xterm-256color")),
387                ("TERM_PROGRAM", Some("Hyper")),
388            ],
389            TerminalProgram::Hyper,
390        );
391    }
392
393    #[test]
394    fn detect_apple_terminal() {
395        assert_detects(
396            vec![
397                ("TERM", Some("xterm-256color")),
398                ("TERM_PROGRAM", Some("Apple_Terminal")),
399            ],
400            TerminalProgram::AppleTerminal,
401        );
402    }
403
404    #[test]
405    fn detect_windows_terminal() {
406        assert_detects(
407            vec![
408                ("TERM", Some("xterm-256color")),
409                ("TERM_PROGRAM", None),
410                ("WT_SESSION", Some("abc-123")),
411            ],
412            TerminalProgram::WindowsTerminal,
413        );
414    }
415
416    #[test]
417    fn detect_konsole() {
418        assert_detects(
419            vec![
420                ("TERM", Some("xterm-256color")),
421                ("TERM_PROGRAM", None),
422                ("KONSOLE_VERSION", Some("240100")),
423            ],
424            TerminalProgram::Konsole,
425        );
426    }
427
428    #[test]
429    fn detect_contour() {
430        assert_detects(
431            vec![
432                ("TERM", Some("xterm-256color")),
433                ("TERM_PROGRAM", None),
434                ("TERMINAL_EMULATOR", Some("contour")),
435            ],
436            TerminalProgram::Contour,
437        );
438    }
439}