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