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_DYNAMIC_PROFILE_FILENAME: &str = "vtcode.json";
14
15pub const ITERM2_ICON_MODE_CUSTOM: u8 = 2;
22
23pub fn iterm2_dynamic_profiles_dir(home: &Path) -> PathBuf {
28 home.join("Library")
29 .join("Application Support")
30 .join("iTerm2")
31 .join("DynamicProfiles")
32}
33
34pub fn installed_iterm2_profile_path(home: &Path) -> PathBuf {
36 iterm2_dynamic_profiles_dir(home).join(ITERM2_DYNAMIC_PROFILE_FILENAME)
37}
38
39pub fn should_apply_iterm2_profile(iterm_session: bool, tmux_session: bool, profile_installed: bool) -> bool {
45 iterm_session && !tmux_session && profile_installed
46}
47
48pub 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum TerminalFeature {
85 Multiline,
86 CopyPaste,
87 ShellIntegration,
88 ThemeSync,
89 Notifications,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum TerminalSetupAvailability {
95 NativeSupport,
96 Offered,
97 GuidanceOnly,
98}
99
100impl TerminalType {
101 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 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 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 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 pub fn should_offer_terminal_setup(&self) -> bool {
220 matches!(self.terminal_setup_availability(), TerminalSetupAvailability::Offered)
221 }
222
223 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 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 fn requires_manual_setup(&self) -> bool {
362 self.should_offer_terminal_setup()
363 }
364}
365
366impl TerminalFeature {
367 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
379pub 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}