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::{Path, PathBuf};
6
7/// Name of the VT Code iTerm2 profile shipped for tab-icon support.
8/// Distinctive so the one-shot profile switch never hijacks an unrelated
9/// user profile with a generic name.
10pub const ITERM2_PROFILE_NAME: &str = "VT Code";
11
12/// Filename of the shipped iTerm2 dynamic profile.
13pub const ITERM2_DYNAMIC_PROFILE_FILENAME: &str = "vtcode.json";
14
15/// Profile `Icon` mode selecting a custom image.
16///
17/// Best-effort mapping from observed behavior: a default profile stores
18/// `Icon = 1` and renders the built-in foreground-app glyph, while the
19/// preferences offer custom / built-in / none. If a future iTerm2 maps the
20/// custom mode elsewhere, only this constant changes.
21pub const ITERM2_ICON_MODE_CUSTOM: u8 = 2;
22
23/// iTerm2 DynamicProfiles directory under `home`.
24///
25/// iTerm2 loads JSON profiles from this directory live with no restart.
26/// The directory may not exist yet; callers create it on install.
27pub fn iterm2_dynamic_profiles_dir(home: &Path) -> PathBuf {
28    home.join("Library")
29        .join("Application Support")
30        .join("iTerm2")
31        .join("DynamicProfiles")
32}
33
34/// Installed dynamic-profile path under `home`.
35pub fn installed_iterm2_profile_path(home: &Path) -> PathBuf {
36    iterm2_dynamic_profiles_dir(home).join(ITERM2_DYNAMIC_PROFILE_FILENAME)
37}
38
39/// Pure gate for the one-shot iTerm2 profile switch.
40///
41/// Switch only for a real iTerm2 session outside tmux (proprietary
42/// sequences do not pass through multiplexers) with the shipped profile
43/// installed, so removing the profile file disables the switch.
44pub fn should_apply_iterm2_profile(iterm_session: bool, tmux_session: bool, profile_installed: bool) -> bool {
45    iterm_session && !tmux_session && profile_installed
46}
47
48/// Environment plus install gate evaluated against the live process.
49///
50/// Combines [`should_apply_iterm2_profile`] with `ITERM_SESSION_ID` /
51/// `TMUX` detection and the installed-profile check, so all callers share
52/// one gating decision.
53pub fn should_apply_iterm2_profile_now() -> bool {
54    let iterm_session = env::var("ITERM_SESSION_ID").is_ok();
55    let tmux_session = env::var("TMUX").is_ok();
56    let installed = dirs::home_dir()
57        .map(|home| installed_iterm2_profile_path(&home))
58        .map(|path| path.exists())
59        .unwrap_or(false);
60    should_apply_iterm2_profile(iterm_session, tmux_session, installed)
61}
62
63/// Supported terminal emulators.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum TerminalType {
66    Ghostty,
67    Kitty,
68    Alacritty,
69    WezTerm,
70    TerminalApp,
71    Xterm,
72    Zed,
73    Warp,
74    ITerm2,
75    VSCode,
76    WindowsTerminal,
77    Hyper,
78    Tabby,
79    Unknown,
80}
81
82/// Terminal features that can be configured.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum TerminalFeature {
85    Multiline,
86    CopyPaste,
87    ShellIntegration,
88    ThemeSync,
89    Notifications,
90}
91
92/// How VT Code should present `/terminal-setup` for a terminal.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum TerminalSetupAvailability {
95    NativeSupport,
96    Offered,
97    GuidanceOnly,
98}
99
100impl TerminalType {
101    /// Detect the current terminal emulator from environment variables.
102    pub fn detect() -> Result<Self> {
103        if let Ok(term_program) = env::var("TERM_PROGRAM") {
104            let term_lower = term_program.to_lowercase();
105
106            if term_lower.contains("ghostty") {
107                return Ok(TerminalType::Ghostty);
108            } else if term_lower.contains("wezterm") {
109                return Ok(TerminalType::WezTerm);
110            } else if term_lower.contains("apple_terminal") {
111                return Ok(TerminalType::TerminalApp);
112            } else if term_lower.contains("iterm") {
113                return Ok(TerminalType::ITerm2);
114            } else if term_lower.contains("vscode") {
115                return Ok(TerminalType::VSCode);
116            } else if term_lower.contains("warp") {
117                return Ok(TerminalType::Warp);
118            } else if term_lower.contains("hyper") {
119                return Ok(TerminalType::Hyper);
120            } else if term_lower.contains("tabby") {
121                return Ok(TerminalType::Tabby);
122            }
123        }
124
125        if env::var("KITTY_WINDOW_ID").is_ok() || env::var("KITTY_PID").is_ok() {
126            return Ok(TerminalType::Kitty);
127        }
128
129        if env::var("ALACRITTY_SOCKET").is_ok() || env::var("ALACRITTY_LOG").is_ok() {
130            return Ok(TerminalType::Alacritty);
131        }
132
133        if env::var("ZED_TERMINAL").is_ok() {
134            return Ok(TerminalType::Zed);
135        }
136
137        if env::var("WT_SESSION").is_ok() || env::var("WT_PROFILE_ID").is_ok() {
138            return Ok(TerminalType::WindowsTerminal);
139        }
140
141        if let Ok(term) = env::var("TERM") {
142            let term_lower = term.to_lowercase();
143
144            if term_lower.contains("kitty") {
145                return Ok(TerminalType::Kitty);
146            } else if term_lower.contains("alacritty") {
147                return Ok(TerminalType::Alacritty);
148            } else if term_lower.contains("xterm") {
149                return Ok(TerminalType::Xterm);
150            }
151        }
152
153        Ok(TerminalType::Unknown)
154    }
155
156    /// Check if terminal supports a specific feature.
157    pub fn supports_feature(&self, feature: TerminalFeature) -> bool {
158        match (self, feature) {
159            (TerminalType::Ghostty, _) => true,
160            (TerminalType::Kitty, _) => true,
161            (TerminalType::Alacritty, _) => true,
162            (TerminalType::WezTerm, _) => true,
163            (TerminalType::TerminalApp, TerminalFeature::Multiline) => true,
164            (TerminalType::TerminalApp, TerminalFeature::ShellIntegration) => true,
165            (TerminalType::TerminalApp, TerminalFeature::Notifications) => true,
166            (TerminalType::TerminalApp, _) => false,
167            (TerminalType::Xterm, TerminalFeature::Multiline) => true,
168            (TerminalType::Xterm, TerminalFeature::Notifications) => true,
169            (TerminalType::Xterm, _) => false,
170            (TerminalType::Zed, TerminalFeature::Multiline) => true,
171            (TerminalType::Zed, TerminalFeature::ThemeSync) => true,
172            (TerminalType::Zed, TerminalFeature::Notifications) => true,
173            (TerminalType::Zed, _) => false,
174            (TerminalType::Warp, TerminalFeature::Multiline) => true,
175            (TerminalType::Warp, TerminalFeature::Notifications) => true,
176            (TerminalType::Warp, _) => false,
177            (TerminalType::ITerm2, _) => true,
178            (TerminalType::VSCode, TerminalFeature::Multiline) => true,
179            (TerminalType::VSCode, TerminalFeature::Notifications) => true,
180            (TerminalType::VSCode, _) => false,
181            (TerminalType::WindowsTerminal, _) => true,
182            (TerminalType::Hyper, _) => true,
183            (TerminalType::Tabby, _) => true,
184            (TerminalType::Unknown, _) => false,
185        }
186    }
187
188    /// Whether multiline input works without VT Code modifying terminal config.
189    pub fn has_native_multiline_support(&self) -> bool {
190        matches!(
191            self,
192            TerminalType::Ghostty
193                | TerminalType::Kitty
194                | TerminalType::WezTerm
195                | TerminalType::ITerm2
196                | TerminalType::Warp
197        )
198    }
199
200    /// How VT Code should present `/terminal-setup` for this terminal.
201    pub fn terminal_setup_availability(&self) -> TerminalSetupAvailability {
202        match self {
203            TerminalType::Ghostty
204            | TerminalType::Kitty
205            | TerminalType::WezTerm
206            | TerminalType::ITerm2
207            | TerminalType::Warp => TerminalSetupAvailability::NativeSupport,
208            TerminalType::Alacritty | TerminalType::Zed | TerminalType::VSCode => TerminalSetupAvailability::Offered,
209            TerminalType::TerminalApp
210            | TerminalType::Xterm
211            | TerminalType::WindowsTerminal
212            | TerminalType::Hyper
213            | TerminalType::Tabby
214            | TerminalType::Unknown => TerminalSetupAvailability::GuidanceOnly,
215        }
216    }
217
218    /// Whether `/terminal-setup` should appear in slash discovery surfaces.
219    pub fn should_offer_terminal_setup(&self) -> bool {
220        matches!(self.terminal_setup_availability(), TerminalSetupAvailability::Offered)
221    }
222
223    /// Get the configuration file path for this terminal.
224    pub fn config_path(&self) -> Result<PathBuf> {
225        let home_dir = dirs::home_dir().context("Failed to determine home directory")?;
226
227        let path = match self {
228            TerminalType::Ghostty => {
229                if cfg!(target_os = "windows") {
230                    let appdata = env::var("APPDATA").context("APPDATA environment variable not set")?;
231                    PathBuf::from(appdata).join("ghostty").join("config")
232                } else {
233                    home_dir.join(".config").join("ghostty").join("config")
234                }
235            }
236            TerminalType::Kitty => {
237                if cfg!(target_os = "windows") {
238                    let appdata = env::var("APPDATA").context("APPDATA environment variable not set")?;
239                    PathBuf::from(appdata).join("kitty").join("kitty.conf")
240                } else {
241                    home_dir.join(".config").join("kitty").join("kitty.conf")
242                }
243            }
244            TerminalType::Alacritty => {
245                if cfg!(target_os = "windows") {
246                    let appdata = env::var("APPDATA").context("APPDATA environment variable not set")?;
247                    PathBuf::from(appdata).join("alacritty").join("alacritty.toml")
248                } else {
249                    home_dir.join(".config").join("alacritty").join("alacritty.toml")
250                }
251            }
252            TerminalType::WezTerm => home_dir.join(".wezterm.lua"),
253            TerminalType::TerminalApp => {
254                if cfg!(target_os = "macos") {
255                    home_dir.join("Library").join("Preferences").join("com.apple.Terminal.plist")
256                } else {
257                    anyhow::bail!("Terminal.app is only available on macOS")
258                }
259            }
260            TerminalType::Xterm => home_dir.join(".Xresources"),
261            TerminalType::Zed => {
262                if cfg!(target_os = "windows") {
263                    let appdata = env::var("APPDATA").context("APPDATA environment variable not set")?;
264                    PathBuf::from(appdata).join("Zed").join("settings.json")
265                } else if cfg!(target_os = "macos") {
266                    home_dir
267                        .join("Library")
268                        .join("Application Support")
269                        .join("Zed")
270                        .join("settings.json")
271                } else {
272                    home_dir.join(".config").join("zed").join("settings.json")
273                }
274            }
275            TerminalType::Warp => {
276                if cfg!(target_os = "macos") {
277                    home_dir.join(".warp")
278                } else {
279                    home_dir.join(".config").join("warp")
280                }
281            }
282            TerminalType::ITerm2 => {
283                if cfg!(target_os = "macos") {
284                    home_dir.join("Library").join("Preferences").join("com.googlecode.iterm2.plist")
285                } else {
286                    anyhow::bail!("iTerm2 is only available on macOS")
287                }
288            }
289            TerminalType::VSCode => {
290                if cfg!(target_os = "windows") {
291                    let appdata = env::var("APPDATA").context("APPDATA environment variable not set")?;
292                    PathBuf::from(appdata).join("Code").join("User").join("settings.json")
293                } else if cfg!(target_os = "macos") {
294                    home_dir
295                        .join("Library")
296                        .join("Application Support")
297                        .join("Code")
298                        .join("User")
299                        .join("settings.json")
300                } else {
301                    home_dir.join(".config").join("Code").join("User").join("settings.json")
302                }
303            }
304            TerminalType::WindowsTerminal => {
305                if cfg!(target_os = "windows") {
306                    let local_appdata =
307                        env::var("LOCALAPPDATA").context("LOCALAPPDATA environment variable not set")?;
308                    PathBuf::from(local_appdata)
309                        .join("Packages")
310                        .join("Microsoft.WindowsTerminal_8wekyb3d8bbwe")
311                        .join("LocalState")
312                        .join("settings.json")
313                } else {
314                    anyhow::bail!("Windows Terminal is only available on Windows")
315                }
316            }
317            TerminalType::Hyper => home_dir.join(".hyper.js"),
318            TerminalType::Tabby => {
319                if cfg!(target_os = "windows") {
320                    let appdata = env::var("APPDATA").context("APPDATA environment variable not set")?;
321                    PathBuf::from(appdata).join("tabby").join("config.yaml")
322                } else if cfg!(target_os = "macos") {
323                    home_dir
324                        .join("Library")
325                        .join("Application Support")
326                        .join("tabby")
327                        .join("config.yaml")
328                } else {
329                    home_dir.join(".config").join("tabby").join("config.yaml")
330                }
331            }
332            TerminalType::Unknown => {
333                anyhow::bail!("Cannot determine config path for unknown terminal")
334            }
335        };
336
337        Ok(path)
338    }
339
340    /// Get a human-readable name for this terminal.
341    pub fn name(&self) -> &'static str {
342        match self {
343            TerminalType::Ghostty => "Ghostty",
344            TerminalType::Kitty => "Kitty",
345            TerminalType::Alacritty => "Alacritty",
346            TerminalType::WezTerm => "WezTerm",
347            TerminalType::TerminalApp => "Terminal.app",
348            TerminalType::Xterm => "xterm",
349            TerminalType::Zed => "Zed",
350            TerminalType::Warp => "Warp",
351            TerminalType::ITerm2 => "iTerm2",
352            TerminalType::VSCode => "VS Code",
353            TerminalType::WindowsTerminal => "Windows Terminal",
354            TerminalType::Hyper => "Hyper",
355            TerminalType::Tabby => "Tabby",
356            TerminalType::Unknown => "Unknown",
357        }
358    }
359
360    /// Check if terminal requires manual setup (vs automatic config).
361    fn requires_manual_setup(&self) -> bool {
362        self.should_offer_terminal_setup()
363    }
364}
365
366impl TerminalFeature {
367    /// Get a human-readable name for this feature.
368    pub fn name(&self) -> &'static str {
369        match self {
370            TerminalFeature::Multiline => "Shift+Enter Multiline Input",
371            TerminalFeature::CopyPaste => "Enhanced Copy/Paste",
372            TerminalFeature::ShellIntegration => "Shell Integration",
373            TerminalFeature::ThemeSync => "Theme Synchronization",
374            TerminalFeature::Notifications => "System Notifications",
375        }
376    }
377}
378
379/// Returns whether the terminal identifiers point to Ghostty.
380pub fn is_ghostty_terminal(term_program: Option<&str>, term: Option<&str>) -> bool {
381    terminal_name_contains(term_program, "ghostty") || terminal_name_contains(term, "ghostty")
382}
383
384fn terminal_name_contains(value: Option<&str>, needle: &str) -> bool {
385    value.map(|value| value.to_ascii_lowercase().contains(needle)).unwrap_or(false)
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn terminal_feature_support_matches_expectations() {
394        assert!(TerminalType::Ghostty.supports_feature(TerminalFeature::Multiline));
395        assert!(TerminalType::Ghostty.supports_feature(TerminalFeature::CopyPaste));
396        assert!(TerminalType::Ghostty.supports_feature(TerminalFeature::ShellIntegration));
397        assert!(TerminalType::Ghostty.supports_feature(TerminalFeature::ThemeSync));
398        assert!(TerminalType::Ghostty.supports_feature(TerminalFeature::Notifications));
399
400        assert!(TerminalType::VSCode.supports_feature(TerminalFeature::Multiline));
401        assert!(TerminalType::VSCode.supports_feature(TerminalFeature::Notifications));
402        assert!(!TerminalType::VSCode.supports_feature(TerminalFeature::CopyPaste));
403
404        assert!(TerminalType::Zed.supports_feature(TerminalFeature::Multiline));
405        assert!(TerminalType::Zed.supports_feature(TerminalFeature::ThemeSync));
406        assert!(TerminalType::Zed.supports_feature(TerminalFeature::Notifications));
407
408        assert!(TerminalType::Warp.supports_feature(TerminalFeature::Notifications));
409
410        assert!(!TerminalType::Unknown.supports_feature(TerminalFeature::Multiline));
411        assert!(!TerminalType::Unknown.supports_feature(TerminalFeature::Notifications));
412    }
413
414    #[test]
415    fn terminal_names_match_current_labels() {
416        assert_eq!(TerminalType::Kitty.name(), "Kitty");
417        assert_eq!(TerminalType::Alacritty.name(), "Alacritty");
418        assert_eq!(TerminalType::VSCode.name(), "VS Code");
419    }
420
421    #[test]
422    fn manual_setup_detection_matches_offer_state() {
423        assert!(TerminalType::VSCode.requires_manual_setup());
424        assert!(!TerminalType::ITerm2.requires_manual_setup());
425        assert!(!TerminalType::Kitty.requires_manual_setup());
426    }
427
428    #[test]
429    fn native_multiline_terminals_are_not_offered_setup() {
430        assert!(TerminalType::WezTerm.has_native_multiline_support());
431        assert!(!TerminalType::WezTerm.should_offer_terminal_setup());
432        assert!(TerminalType::ITerm2.has_native_multiline_support());
433        assert!(!TerminalType::ITerm2.should_offer_terminal_setup());
434        assert!(TerminalType::Warp.has_native_multiline_support());
435        assert!(!TerminalType::Warp.should_offer_terminal_setup());
436    }
437
438    #[test]
439    fn supported_setup_terminals_are_offered_setup() {
440        assert!(TerminalType::VSCode.should_offer_terminal_setup());
441        assert!(TerminalType::Alacritty.should_offer_terminal_setup());
442        assert!(TerminalType::Zed.should_offer_terminal_setup());
443        assert!(!TerminalType::WindowsTerminal.should_offer_terminal_setup());
444        assert!(!TerminalType::Hyper.should_offer_terminal_setup());
445        assert!(!TerminalType::Tabby.should_offer_terminal_setup());
446    }
447
448    #[test]
449    fn ghostty_helper_matches_term_program_or_term() {
450        assert!(is_ghostty_terminal(Some("Ghostty"), None));
451        assert!(is_ghostty_terminal(None, Some("xterm-ghostty")));
452        assert!(!is_ghostty_terminal(Some("WezTerm"), Some("xterm-256color")));
453    }
454
455    #[test]
456    fn iterm2_profile_paths_stay_under_dynamic_profiles() {
457        let first = installed_iterm2_profile_path(Path::new("/Users/demo"));
458        let second = installed_iterm2_profile_path(Path::new("/home/other"));
459
460        assert_eq!(first, PathBuf::from("/Users/demo/Library/Application Support/iTerm2/DynamicProfiles/vtcode.json"));
461        assert_eq!(second, PathBuf::from("/home/other/Library/Application Support/iTerm2/DynamicProfiles/vtcode.json"));
462        assert!(!ITERM2_PROFILE_NAME.is_empty());
463        assert!(ITERM2_DYNAMIC_PROFILE_FILENAME.ends_with(".json"));
464    }
465
466    #[test]
467    fn iterm2_profile_switch_requires_session_without_tmux_and_installed_profile() {
468        assert!(should_apply_iterm2_profile(true, false, true));
469        assert!(!should_apply_iterm2_profile(false, false, true));
470        assert!(!should_apply_iterm2_profile(true, true, true));
471        assert!(!should_apply_iterm2_profile(true, false, false));
472        assert!(!should_apply_iterm2_profile(false, false, false));
473    }
474}