1use anyhow::{Context, Result};
4use std::env;
5use std::path::{Path, PathBuf};
6
7pub const ITERM2_PROFILE_NAME: &str = "VT Code";
11
12pub const ITERM2_DEFAULT_PROFILE_NAME: &str = "Default";
15
16pub const ITERM2_DYNAMIC_PROFILE_FILENAME: &str = "vtcode.json";
18
19pub const ITERM2_ICON_MODE_CUSTOM: u8 = 2;
26
27pub fn iterm2_dynamic_profiles_dir(home: &Path) -> PathBuf {
32 home.join("Library")
33 .join("Application Support")
34 .join("iTerm2")
35 .join("DynamicProfiles")
36}
37
38pub fn installed_iterm2_profile_path(home: &Path) -> PathBuf {
40 iterm2_dynamic_profiles_dir(home).join(ITERM2_DYNAMIC_PROFILE_FILENAME)
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub struct ITerm2ProfileConditions {
50 pub iterm_session: bool,
52 pub tmux_session: bool,
54 pub profile_installed: bool,
56}
57
58impl ITerm2ProfileConditions {
59 pub fn should_apply(&self) -> bool {
61 self.iterm_session && !self.tmux_session && self.profile_installed
62 }
63}
64
65pub 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
74pub 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
94pub 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum TerminalFeature {
133 Multiline,
134 CopyPaste,
135 ShellIntegration,
136 ThemeSync,
137 Notifications,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum TerminalSetupAvailability {
143 NativeSupport,
144 Offered,
145 GuidanceOnly,
146}
147
148impl TerminalType {
149 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 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 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 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 pub fn should_offer_terminal_setup(&self) -> bool {
268 matches!(self.terminal_setup_availability(), TerminalSetupAvailability::Offered)
269 }
270
271 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 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 fn requires_manual_setup(&self) -> bool {
410 self.should_offer_terminal_setup()
411 }
412}
413
414impl TerminalFeature {
415 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
427pub 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}