Skip to main content

vtcode_terminal_detection/
lib.rs

1//! Terminal detection primitives shared across VT Code crates.
2
3use anyhow::{Context, Result};
4use std::env;
5use std::path::PathBuf;
6
7/// Supported terminal emulators.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum TerminalType {
10    Ghostty,
11    Kitty,
12    Alacritty,
13    WezTerm,
14    TerminalApp,
15    Xterm,
16    Zed,
17    Warp,
18    ITerm2,
19    VSCode,
20    WindowsTerminal,
21    Hyper,
22    Tabby,
23    Unknown,
24}
25
26/// Terminal features that can be configured.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum TerminalFeature {
29    Multiline,
30    CopyPaste,
31    ShellIntegration,
32    ThemeSync,
33    Notifications,
34}
35
36/// How VT Code should present `/terminal-setup` for a terminal.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum TerminalSetupAvailability {
39    NativeSupport,
40    Offered,
41    GuidanceOnly,
42}
43
44impl TerminalType {
45    /// Detect the current terminal emulator from environment variables.
46    pub fn detect() -> Result<Self> {
47        if let Ok(term_program) = env::var("TERM_PROGRAM") {
48            let term_lower = term_program.to_lowercase();
49
50            if term_lower.contains("ghostty") {
51                return Ok(TerminalType::Ghostty);
52            } else if term_lower.contains("wezterm") {
53                return Ok(TerminalType::WezTerm);
54            } else if term_lower.contains("apple_terminal") {
55                return Ok(TerminalType::TerminalApp);
56            } else if term_lower.contains("iterm") {
57                return Ok(TerminalType::ITerm2);
58            } else if term_lower.contains("vscode") {
59                return Ok(TerminalType::VSCode);
60            } else if term_lower.contains("warp") {
61                return Ok(TerminalType::Warp);
62            } else if term_lower.contains("hyper") {
63                return Ok(TerminalType::Hyper);
64            } else if term_lower.contains("tabby") {
65                return Ok(TerminalType::Tabby);
66            }
67        }
68
69        if env::var("KITTY_WINDOW_ID").is_ok() || env::var("KITTY_PID").is_ok() {
70            return Ok(TerminalType::Kitty);
71        }
72
73        if env::var("ALACRITTY_SOCKET").is_ok() || env::var("ALACRITTY_LOG").is_ok() {
74            return Ok(TerminalType::Alacritty);
75        }
76
77        if env::var("ZED_TERMINAL").is_ok() {
78            return Ok(TerminalType::Zed);
79        }
80
81        if env::var("WT_SESSION").is_ok() || env::var("WT_PROFILE_ID").is_ok() {
82            return Ok(TerminalType::WindowsTerminal);
83        }
84
85        if let Ok(term) = env::var("TERM") {
86            let term_lower = term.to_lowercase();
87
88            if term_lower.contains("kitty") {
89                return Ok(TerminalType::Kitty);
90            } else if term_lower.contains("alacritty") {
91                return Ok(TerminalType::Alacritty);
92            } else if term_lower.contains("xterm") {
93                return Ok(TerminalType::Xterm);
94            }
95        }
96
97        Ok(TerminalType::Unknown)
98    }
99
100    /// Check if terminal supports a specific feature.
101    pub fn supports_feature(&self, feature: TerminalFeature) -> bool {
102        match (self, feature) {
103            (TerminalType::Ghostty, _) => true,
104            (TerminalType::Kitty, _) => true,
105            (TerminalType::Alacritty, _) => true,
106            (TerminalType::WezTerm, _) => true,
107            (TerminalType::TerminalApp, TerminalFeature::Multiline) => true,
108            (TerminalType::TerminalApp, TerminalFeature::ShellIntegration) => true,
109            (TerminalType::TerminalApp, TerminalFeature::Notifications) => true,
110            (TerminalType::TerminalApp, _) => false,
111            (TerminalType::Xterm, TerminalFeature::Multiline) => true,
112            (TerminalType::Xterm, TerminalFeature::Notifications) => true,
113            (TerminalType::Xterm, _) => false,
114            (TerminalType::Zed, TerminalFeature::Multiline) => true,
115            (TerminalType::Zed, TerminalFeature::ThemeSync) => true,
116            (TerminalType::Zed, TerminalFeature::Notifications) => true,
117            (TerminalType::Zed, _) => false,
118            (TerminalType::Warp, TerminalFeature::Multiline) => true,
119            (TerminalType::Warp, TerminalFeature::Notifications) => true,
120            (TerminalType::Warp, _) => false,
121            (TerminalType::ITerm2, _) => true,
122            (TerminalType::VSCode, TerminalFeature::Multiline) => true,
123            (TerminalType::VSCode, TerminalFeature::Notifications) => true,
124            (TerminalType::VSCode, _) => false,
125            (TerminalType::WindowsTerminal, _) => true,
126            (TerminalType::Hyper, _) => true,
127            (TerminalType::Tabby, _) => true,
128            (TerminalType::Unknown, _) => false,
129        }
130    }
131
132    /// Whether multiline input works without VT Code modifying terminal config.
133    pub fn has_native_multiline_support(&self) -> bool {
134        matches!(
135            self,
136            TerminalType::Ghostty
137                | TerminalType::Kitty
138                | TerminalType::WezTerm
139                | TerminalType::ITerm2
140                | TerminalType::Warp
141        )
142    }
143
144    /// How VT Code should present `/terminal-setup` for this terminal.
145    pub fn terminal_setup_availability(&self) -> TerminalSetupAvailability {
146        match self {
147            TerminalType::Ghostty
148            | TerminalType::Kitty
149            | TerminalType::WezTerm
150            | TerminalType::ITerm2
151            | TerminalType::Warp => TerminalSetupAvailability::NativeSupport,
152            TerminalType::Alacritty | TerminalType::Zed | TerminalType::VSCode => {
153                TerminalSetupAvailability::Offered
154            }
155            TerminalType::TerminalApp
156            | TerminalType::Xterm
157            | TerminalType::WindowsTerminal
158            | TerminalType::Hyper
159            | TerminalType::Tabby
160            | TerminalType::Unknown => TerminalSetupAvailability::GuidanceOnly,
161        }
162    }
163
164    /// Whether `/terminal-setup` should appear in slash discovery surfaces.
165    pub fn should_offer_terminal_setup(&self) -> bool {
166        matches!(
167            self.terminal_setup_availability(),
168            TerminalSetupAvailability::Offered
169        )
170    }
171
172    /// Get the configuration file path for this terminal.
173    pub fn config_path(&self) -> Result<PathBuf> {
174        let home_dir = dirs::home_dir().context("Failed to determine home directory")?;
175
176        let path = match self {
177            TerminalType::Ghostty => {
178                if cfg!(target_os = "windows") {
179                    let appdata =
180                        env::var("APPDATA").context("APPDATA environment variable not set")?;
181                    PathBuf::from(appdata).join("ghostty").join("config")
182                } else {
183                    home_dir.join(".config").join("ghostty").join("config")
184                }
185            }
186            TerminalType::Kitty => {
187                if cfg!(target_os = "windows") {
188                    let appdata =
189                        env::var("APPDATA").context("APPDATA environment variable not set")?;
190                    PathBuf::from(appdata).join("kitty").join("kitty.conf")
191                } else {
192                    home_dir.join(".config").join("kitty").join("kitty.conf")
193                }
194            }
195            TerminalType::Alacritty => {
196                if cfg!(target_os = "windows") {
197                    let appdata =
198                        env::var("APPDATA").context("APPDATA environment variable not set")?;
199                    PathBuf::from(appdata)
200                        .join("alacritty")
201                        .join("alacritty.toml")
202                } else {
203                    home_dir
204                        .join(".config")
205                        .join("alacritty")
206                        .join("alacritty.toml")
207                }
208            }
209            TerminalType::WezTerm => home_dir.join(".wezterm.lua"),
210            TerminalType::TerminalApp => {
211                if cfg!(target_os = "macos") {
212                    home_dir
213                        .join("Library")
214                        .join("Preferences")
215                        .join("com.apple.Terminal.plist")
216                } else {
217                    anyhow::bail!("Terminal.app is only available on macOS")
218                }
219            }
220            TerminalType::Xterm => home_dir.join(".Xresources"),
221            TerminalType::Zed => {
222                if cfg!(target_os = "windows") {
223                    let appdata =
224                        env::var("APPDATA").context("APPDATA environment variable not set")?;
225                    PathBuf::from(appdata).join("Zed").join("settings.json")
226                } else if cfg!(target_os = "macos") {
227                    home_dir
228                        .join("Library")
229                        .join("Application Support")
230                        .join("Zed")
231                        .join("settings.json")
232                } else {
233                    home_dir.join(".config").join("zed").join("settings.json")
234                }
235            }
236            TerminalType::Warp => {
237                if cfg!(target_os = "macos") {
238                    home_dir.join(".warp")
239                } else {
240                    home_dir.join(".config").join("warp")
241                }
242            }
243            TerminalType::ITerm2 => {
244                if cfg!(target_os = "macos") {
245                    home_dir
246                        .join("Library")
247                        .join("Preferences")
248                        .join("com.googlecode.iterm2.plist")
249                } else {
250                    anyhow::bail!("iTerm2 is only available on macOS")
251                }
252            }
253            TerminalType::VSCode => {
254                if cfg!(target_os = "windows") {
255                    let appdata =
256                        env::var("APPDATA").context("APPDATA environment variable not set")?;
257                    PathBuf::from(appdata)
258                        .join("Code")
259                        .join("User")
260                        .join("settings.json")
261                } else if cfg!(target_os = "macos") {
262                    home_dir
263                        .join("Library")
264                        .join("Application Support")
265                        .join("Code")
266                        .join("User")
267                        .join("settings.json")
268                } else {
269                    home_dir
270                        .join(".config")
271                        .join("Code")
272                        .join("User")
273                        .join("settings.json")
274                }
275            }
276            TerminalType::WindowsTerminal => {
277                if cfg!(target_os = "windows") {
278                    let local_appdata = env::var("LOCALAPPDATA")
279                        .context("LOCALAPPDATA environment variable not set")?;
280                    PathBuf::from(local_appdata)
281                        .join("Packages")
282                        .join("Microsoft.WindowsTerminal_8wekyb3d8bbwe")
283                        .join("LocalState")
284                        .join("settings.json")
285                } else {
286                    anyhow::bail!("Windows Terminal is only available on Windows")
287                }
288            }
289            TerminalType::Hyper => home_dir.join(".hyper.js"),
290            TerminalType::Tabby => {
291                if cfg!(target_os = "windows") {
292                    let appdata =
293                        env::var("APPDATA").context("APPDATA environment variable not set")?;
294                    PathBuf::from(appdata).join("tabby").join("config.yaml")
295                } else if cfg!(target_os = "macos") {
296                    home_dir
297                        .join("Library")
298                        .join("Application Support")
299                        .join("tabby")
300                        .join("config.yaml")
301                } else {
302                    home_dir.join(".config").join("tabby").join("config.yaml")
303                }
304            }
305            TerminalType::Unknown => {
306                anyhow::bail!("Cannot determine config path for unknown terminal")
307            }
308        };
309
310        Ok(path)
311    }
312
313    /// Get a human-readable name for this terminal.
314    pub fn name(&self) -> &'static str {
315        match self {
316            TerminalType::Ghostty => "Ghostty",
317            TerminalType::Kitty => "Kitty",
318            TerminalType::Alacritty => "Alacritty",
319            TerminalType::WezTerm => "WezTerm",
320            TerminalType::TerminalApp => "Terminal.app",
321            TerminalType::Xterm => "xterm",
322            TerminalType::Zed => "Zed",
323            TerminalType::Warp => "Warp",
324            TerminalType::ITerm2 => "iTerm2",
325            TerminalType::VSCode => "VS Code",
326            TerminalType::WindowsTerminal => "Windows Terminal",
327            TerminalType::Hyper => "Hyper",
328            TerminalType::Tabby => "Tabby",
329            TerminalType::Unknown => "Unknown",
330        }
331    }
332
333    /// Check if terminal requires manual setup (vs automatic config).
334    pub fn requires_manual_setup(&self) -> bool {
335        self.should_offer_terminal_setup()
336    }
337}
338
339impl TerminalFeature {
340    /// Get a human-readable name for this feature.
341    pub fn name(&self) -> &'static str {
342        match self {
343            TerminalFeature::Multiline => "Shift+Enter Multiline Input",
344            TerminalFeature::CopyPaste => "Enhanced Copy/Paste",
345            TerminalFeature::ShellIntegration => "Shell Integration",
346            TerminalFeature::ThemeSync => "Theme Synchronization",
347            TerminalFeature::Notifications => "System Notifications",
348        }
349    }
350}
351
352/// Returns whether the terminal identifiers point to Ghostty.
353pub fn is_ghostty_terminal(term_program: Option<&str>, term: Option<&str>) -> bool {
354    terminal_name_contains(term_program, "ghostty") || terminal_name_contains(term, "ghostty")
355}
356
357fn terminal_name_contains(value: Option<&str>, needle: &str) -> bool {
358    value
359        .map(|value| value.to_ascii_lowercase().contains(needle))
360        .unwrap_or(false)
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn terminal_feature_support_matches_expectations() {
369        assert!(TerminalType::Ghostty.supports_feature(TerminalFeature::Multiline));
370        assert!(TerminalType::Ghostty.supports_feature(TerminalFeature::CopyPaste));
371        assert!(TerminalType::Ghostty.supports_feature(TerminalFeature::ShellIntegration));
372        assert!(TerminalType::Ghostty.supports_feature(TerminalFeature::ThemeSync));
373        assert!(TerminalType::Ghostty.supports_feature(TerminalFeature::Notifications));
374
375        assert!(TerminalType::VSCode.supports_feature(TerminalFeature::Multiline));
376        assert!(TerminalType::VSCode.supports_feature(TerminalFeature::Notifications));
377        assert!(!TerminalType::VSCode.supports_feature(TerminalFeature::CopyPaste));
378
379        assert!(TerminalType::Zed.supports_feature(TerminalFeature::Multiline));
380        assert!(TerminalType::Zed.supports_feature(TerminalFeature::ThemeSync));
381        assert!(TerminalType::Zed.supports_feature(TerminalFeature::Notifications));
382
383        assert!(TerminalType::Warp.supports_feature(TerminalFeature::Notifications));
384
385        assert!(!TerminalType::Unknown.supports_feature(TerminalFeature::Multiline));
386        assert!(!TerminalType::Unknown.supports_feature(TerminalFeature::Notifications));
387    }
388
389    #[test]
390    fn terminal_names_match_current_labels() {
391        assert_eq!(TerminalType::Kitty.name(), "Kitty");
392        assert_eq!(TerminalType::Alacritty.name(), "Alacritty");
393        assert_eq!(TerminalType::VSCode.name(), "VS Code");
394    }
395
396    #[test]
397    fn manual_setup_detection_matches_offer_state() {
398        assert!(TerminalType::VSCode.requires_manual_setup());
399        assert!(!TerminalType::ITerm2.requires_manual_setup());
400        assert!(!TerminalType::Kitty.requires_manual_setup());
401    }
402
403    #[test]
404    fn native_multiline_terminals_are_not_offered_setup() {
405        assert!(TerminalType::WezTerm.has_native_multiline_support());
406        assert!(!TerminalType::WezTerm.should_offer_terminal_setup());
407        assert!(TerminalType::ITerm2.has_native_multiline_support());
408        assert!(!TerminalType::ITerm2.should_offer_terminal_setup());
409        assert!(TerminalType::Warp.has_native_multiline_support());
410        assert!(!TerminalType::Warp.should_offer_terminal_setup());
411    }
412
413    #[test]
414    fn supported_setup_terminals_are_offered_setup() {
415        assert!(TerminalType::VSCode.should_offer_terminal_setup());
416        assert!(TerminalType::Alacritty.should_offer_terminal_setup());
417        assert!(TerminalType::Zed.should_offer_terminal_setup());
418        assert!(!TerminalType::WindowsTerminal.should_offer_terminal_setup());
419        assert!(!TerminalType::Hyper.should_offer_terminal_setup());
420        assert!(!TerminalType::Tabby.should_offer_terminal_setup());
421    }
422
423    #[test]
424    fn ghostty_helper_matches_term_program_or_term() {
425        assert!(is_ghostty_terminal(Some("Ghostty"), None));
426        assert!(is_ghostty_terminal(None, Some("xterm-ghostty")));
427        assert!(!is_ghostty_terminal(
428            Some("WezTerm"),
429            Some("xterm-256color")
430        ));
431    }
432}