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