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