Skip to main content

vtcode_commons/
terminal_detection.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!(self.terminal_setup_availability(), TerminalSetupAvailability::Offered)
167    }
168
169    /// Get the configuration file path for this terminal.
170    pub fn config_path(&self) -> Result<PathBuf> {
171        let home_dir = dirs::home_dir().context("Failed to determine home directory")?;
172
173        let path = match self {
174            TerminalType::Ghostty => {
175                if cfg!(target_os = "windows") {
176                    let appdata =
177                        env::var("APPDATA").context("APPDATA environment variable not set")?;
178                    PathBuf::from(appdata).join("ghostty").join("config")
179                } else {
180                    home_dir.join(".config").join("ghostty").join("config")
181                }
182            }
183            TerminalType::Kitty => {
184                if cfg!(target_os = "windows") {
185                    let appdata =
186                        env::var("APPDATA").context("APPDATA environment variable not set")?;
187                    PathBuf::from(appdata).join("kitty").join("kitty.conf")
188                } else {
189                    home_dir.join(".config").join("kitty").join("kitty.conf")
190                }
191            }
192            TerminalType::Alacritty => {
193                if cfg!(target_os = "windows") {
194                    let appdata =
195                        env::var("APPDATA").context("APPDATA environment variable not set")?;
196                    PathBuf::from(appdata).join("alacritty").join("alacritty.toml")
197                } else {
198                    home_dir.join(".config").join("alacritty").join("alacritty.toml")
199                }
200            }
201            TerminalType::WezTerm => home_dir.join(".wezterm.lua"),
202            TerminalType::TerminalApp => {
203                if cfg!(target_os = "macos") {
204                    home_dir.join("Library").join("Preferences").join("com.apple.Terminal.plist")
205                } else {
206                    anyhow::bail!("Terminal.app is only available on macOS")
207                }
208            }
209            TerminalType::Xterm => home_dir.join(".Xresources"),
210            TerminalType::Zed => {
211                if cfg!(target_os = "windows") {
212                    let appdata =
213                        env::var("APPDATA").context("APPDATA environment variable not set")?;
214                    PathBuf::from(appdata).join("Zed").join("settings.json")
215                } else if cfg!(target_os = "macos") {
216                    home_dir
217                        .join("Library")
218                        .join("Application Support")
219                        .join("Zed")
220                        .join("settings.json")
221                } else {
222                    home_dir.join(".config").join("zed").join("settings.json")
223                }
224            }
225            TerminalType::Warp => {
226                if cfg!(target_os = "macos") {
227                    home_dir.join(".warp")
228                } else {
229                    home_dir.join(".config").join("warp")
230                }
231            }
232            TerminalType::ITerm2 => {
233                if cfg!(target_os = "macos") {
234                    home_dir.join("Library").join("Preferences").join("com.googlecode.iterm2.plist")
235                } else {
236                    anyhow::bail!("iTerm2 is only available on macOS")
237                }
238            }
239            TerminalType::VSCode => {
240                if cfg!(target_os = "windows") {
241                    let appdata =
242                        env::var("APPDATA").context("APPDATA environment variable not set")?;
243                    PathBuf::from(appdata).join("Code").join("User").join("settings.json")
244                } else if cfg!(target_os = "macos") {
245                    home_dir
246                        .join("Library")
247                        .join("Application Support")
248                        .join("Code")
249                        .join("User")
250                        .join("settings.json")
251                } else {
252                    home_dir.join(".config").join("Code").join("User").join("settings.json")
253                }
254            }
255            TerminalType::WindowsTerminal => {
256                if cfg!(target_os = "windows") {
257                    let local_appdata = env::var("LOCALAPPDATA")
258                        .context("LOCALAPPDATA environment variable not set")?;
259                    PathBuf::from(local_appdata)
260                        .join("Packages")
261                        .join("Microsoft.WindowsTerminal_8wekyb3d8bbwe")
262                        .join("LocalState")
263                        .join("settings.json")
264                } else {
265                    anyhow::bail!("Windows Terminal is only available on Windows")
266                }
267            }
268            TerminalType::Hyper => home_dir.join(".hyper.js"),
269            TerminalType::Tabby => {
270                if cfg!(target_os = "windows") {
271                    let appdata =
272                        env::var("APPDATA").context("APPDATA environment variable not set")?;
273                    PathBuf::from(appdata).join("tabby").join("config.yaml")
274                } else if cfg!(target_os = "macos") {
275                    home_dir
276                        .join("Library")
277                        .join("Application Support")
278                        .join("tabby")
279                        .join("config.yaml")
280                } else {
281                    home_dir.join(".config").join("tabby").join("config.yaml")
282                }
283            }
284            TerminalType::Unknown => {
285                anyhow::bail!("Cannot determine config path for unknown terminal")
286            }
287        };
288
289        Ok(path)
290    }
291
292    /// Get a human-readable name for this terminal.
293    pub fn name(&self) -> &'static str {
294        match self {
295            TerminalType::Ghostty => "Ghostty",
296            TerminalType::Kitty => "Kitty",
297            TerminalType::Alacritty => "Alacritty",
298            TerminalType::WezTerm => "WezTerm",
299            TerminalType::TerminalApp => "Terminal.app",
300            TerminalType::Xterm => "xterm",
301            TerminalType::Zed => "Zed",
302            TerminalType::Warp => "Warp",
303            TerminalType::ITerm2 => "iTerm2",
304            TerminalType::VSCode => "VS Code",
305            TerminalType::WindowsTerminal => "Windows Terminal",
306            TerminalType::Hyper => "Hyper",
307            TerminalType::Tabby => "Tabby",
308            TerminalType::Unknown => "Unknown",
309        }
310    }
311
312    /// Check if terminal requires manual setup (vs automatic config).
313    pub fn requires_manual_setup(&self) -> bool {
314        self.should_offer_terminal_setup()
315    }
316}
317
318impl TerminalFeature {
319    /// Get a human-readable name for this feature.
320    pub fn name(&self) -> &'static str {
321        match self {
322            TerminalFeature::Multiline => "Shift+Enter Multiline Input",
323            TerminalFeature::CopyPaste => "Enhanced Copy/Paste",
324            TerminalFeature::ShellIntegration => "Shell Integration",
325            TerminalFeature::ThemeSync => "Theme Synchronization",
326            TerminalFeature::Notifications => "System Notifications",
327        }
328    }
329}
330
331/// Returns whether the terminal identifiers point to Ghostty.
332pub fn is_ghostty_terminal(term_program: Option<&str>, term: Option<&str>) -> bool {
333    terminal_name_contains(term_program, "ghostty") || terminal_name_contains(term, "ghostty")
334}
335
336fn terminal_name_contains(value: Option<&str>, needle: &str) -> bool {
337    value.map(|value| value.to_ascii_lowercase().contains(needle)).unwrap_or(false)
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn terminal_feature_support_matches_expectations() {
346        assert!(TerminalType::Ghostty.supports_feature(TerminalFeature::Multiline));
347        assert!(TerminalType::Ghostty.supports_feature(TerminalFeature::CopyPaste));
348        assert!(TerminalType::Ghostty.supports_feature(TerminalFeature::ShellIntegration));
349        assert!(TerminalType::Ghostty.supports_feature(TerminalFeature::ThemeSync));
350        assert!(TerminalType::Ghostty.supports_feature(TerminalFeature::Notifications));
351
352        assert!(TerminalType::VSCode.supports_feature(TerminalFeature::Multiline));
353        assert!(TerminalType::VSCode.supports_feature(TerminalFeature::Notifications));
354        assert!(!TerminalType::VSCode.supports_feature(TerminalFeature::CopyPaste));
355
356        assert!(TerminalType::Zed.supports_feature(TerminalFeature::Multiline));
357        assert!(TerminalType::Zed.supports_feature(TerminalFeature::ThemeSync));
358        assert!(TerminalType::Zed.supports_feature(TerminalFeature::Notifications));
359
360        assert!(TerminalType::Warp.supports_feature(TerminalFeature::Notifications));
361
362        assert!(!TerminalType::Unknown.supports_feature(TerminalFeature::Multiline));
363        assert!(!TerminalType::Unknown.supports_feature(TerminalFeature::Notifications));
364    }
365
366    #[test]
367    fn terminal_names_match_current_labels() {
368        assert_eq!(TerminalType::Kitty.name(), "Kitty");
369        assert_eq!(TerminalType::Alacritty.name(), "Alacritty");
370        assert_eq!(TerminalType::VSCode.name(), "VS Code");
371    }
372
373    #[test]
374    fn manual_setup_detection_matches_offer_state() {
375        assert!(TerminalType::VSCode.requires_manual_setup());
376        assert!(!TerminalType::ITerm2.requires_manual_setup());
377        assert!(!TerminalType::Kitty.requires_manual_setup());
378    }
379
380    #[test]
381    fn native_multiline_terminals_are_not_offered_setup() {
382        assert!(TerminalType::WezTerm.has_native_multiline_support());
383        assert!(!TerminalType::WezTerm.should_offer_terminal_setup());
384        assert!(TerminalType::ITerm2.has_native_multiline_support());
385        assert!(!TerminalType::ITerm2.should_offer_terminal_setup());
386        assert!(TerminalType::Warp.has_native_multiline_support());
387        assert!(!TerminalType::Warp.should_offer_terminal_setup());
388    }
389
390    #[test]
391    fn supported_setup_terminals_are_offered_setup() {
392        assert!(TerminalType::VSCode.should_offer_terminal_setup());
393        assert!(TerminalType::Alacritty.should_offer_terminal_setup());
394        assert!(TerminalType::Zed.should_offer_terminal_setup());
395        assert!(!TerminalType::WindowsTerminal.should_offer_terminal_setup());
396        assert!(!TerminalType::Hyper.should_offer_terminal_setup());
397        assert!(!TerminalType::Tabby.should_offer_terminal_setup());
398    }
399
400    #[test]
401    fn ghostty_helper_matches_term_program_or_term() {
402        assert!(is_ghostty_terminal(Some("Ghostty"), None));
403        assert!(is_ghostty_terminal(None, Some("xterm-ghostty")));
404        assert!(!is_ghostty_terminal(Some("WezTerm"), Some("xterm-256color")));
405    }
406}