1use std::fmt;
11use std::io::IsTerminal;
12
13const MAX_ENV_VALUE_CHARS: usize = 128;
14
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17pub enum TerminalFamily {
18 Alacritty,
19 AppleTerminal,
20 Dumb,
21 Ghostty,
22 Iterm2,
23 Kitty,
24 Konsole,
25 Vscode,
26 Vte,
27 WezTerm,
28 WindowsTerminal,
29 Xterm,
30 #[default]
31 Unknown,
32}
33
34impl fmt::Display for TerminalFamily {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 f.write_str(match self {
37 Self::Alacritty => "Alacritty",
38 Self::AppleTerminal => "Apple Terminal",
39 Self::Dumb => "dumb",
40 Self::Ghostty => "Ghostty",
41 Self::Iterm2 => "iTerm2",
42 Self::Kitty => "Kitty",
43 Self::Konsole => "Konsole",
44 Self::Vscode => "VS Code",
45 Self::Vte => "VTE",
46 Self::WezTerm => "WezTerm",
47 Self::WindowsTerminal => "Windows Terminal",
48 Self::Xterm => "xterm",
49 Self::Unknown => "unknown",
50 })
51 }
52}
53
54#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
56pub enum TerminalMultiplexer {
57 Screen,
58 Tmux,
59 Zellij,
60 #[default]
61 None,
62}
63
64impl fmt::Display for TerminalMultiplexer {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66 f.write_str(match self {
67 Self::Screen => "GNU Screen",
68 Self::Tmux => "tmux",
69 Self::Zellij => "Zellij",
70 Self::None => "none",
71 })
72 }
73}
74
75#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
77pub enum TerminalColorLevel {
78 None,
79 Ansi16,
80 Ansi256,
81 TrueColor,
82 #[default]
83 Unknown,
84}
85
86impl fmt::Display for TerminalColorLevel {
87 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88 f.write_str(match self {
89 Self::None => "none",
90 Self::Ansi16 => "16 colors",
91 Self::Ansi256 => "256 colors",
92 Self::TrueColor => "truecolor",
93 Self::Unknown => "unknown",
94 })
95 }
96}
97
98#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
100pub enum TerminalSupport {
101 Supported,
102 Unsupported,
103 RequiresPassthrough,
104 #[default]
105 Unknown,
106}
107
108impl TerminalSupport {
109 pub fn is_supported(self) -> bool {
110 matches!(self, Self::Supported)
111 }
112}
113
114impl fmt::Display for TerminalSupport {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 f.write_str(match self {
117 Self::Supported => "supported",
118 Self::Unsupported => "unsupported",
119 Self::RequiresPassthrough => "multiplexer passthrough required",
120 Self::Unknown => "unknown",
121 })
122 }
123}
124
125#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
127pub enum TerminalDisplayMode {
128 Fullscreen,
129 #[default]
130 Inline,
131}
132
133impl fmt::Display for TerminalDisplayMode {
134 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135 f.write_str(match self {
136 Self::Fullscreen => "fullscreen",
137 Self::Inline => "inline fallback",
138 })
139 }
140}
141
142#[derive(Clone, Debug, PartialEq, Eq)]
144pub struct TerminalProfile {
145 family: TerminalFamily,
146 multiplexer: TerminalMultiplexer,
147 term: Option<String>,
148 term_program: Option<String>,
149 stdin_tty: bool,
150 stdout_tty: bool,
151 stderr_tty: bool,
152 color_level: TerminalColorLevel,
153 display_mode: TerminalDisplayMode,
154 alternate_screen: TerminalSupport,
155 bracketed_paste: TerminalSupport,
156 mouse_capture: TerminalSupport,
157 enhanced_keyboard: TerminalSupport,
158 hyperlinks: TerminalSupport,
159 clipboard: TerminalSupport,
160}
161
162impl TerminalProfile {
163 pub fn detect() -> Self {
166 let io = TerminalIo {
167 stdin: std::io::stdin().is_terminal(),
168 stdout: std::io::stdout().is_terminal(),
169 stderr: std::io::stderr().is_terminal(),
170 };
171 let enhanced_keyboard = match crossterm::terminal::supports_keyboard_enhancement() {
172 Ok(true) => TerminalSupport::Supported,
173 Ok(false) => TerminalSupport::Unsupported,
174 Err(_) => TerminalSupport::Unknown,
175 };
176 Self::detect_with(|name| std::env::var(name).ok(), io, enhanced_keyboard)
177 }
178
179 pub fn family(&self) -> TerminalFamily {
180 self.family
181 }
182
183 pub fn multiplexer(&self) -> TerminalMultiplexer {
184 self.multiplexer
185 }
186
187 pub fn term(&self) -> Option<&str> {
188 self.term.as_deref()
189 }
190
191 pub fn term_program(&self) -> Option<&str> {
192 self.term_program.as_deref()
193 }
194
195 pub fn stdin_is_terminal(&self) -> bool {
196 self.stdin_tty
197 }
198
199 pub fn stdout_is_terminal(&self) -> bool {
200 self.stdout_tty
201 }
202
203 pub fn stderr_is_terminal(&self) -> bool {
204 self.stderr_tty
205 }
206
207 pub fn color_level(&self) -> TerminalColorLevel {
208 self.color_level
209 }
210
211 pub fn display_mode(&self) -> TerminalDisplayMode {
212 self.display_mode
213 }
214
215 pub fn alternate_screen(&self) -> TerminalSupport {
216 self.alternate_screen
217 }
218
219 pub fn bracketed_paste(&self) -> TerminalSupport {
220 self.bracketed_paste
221 }
222
223 pub fn mouse_capture(&self) -> TerminalSupport {
224 self.mouse_capture
225 }
226
227 pub fn enhanced_keyboard(&self) -> TerminalSupport {
228 self.enhanced_keyboard
229 }
230
231 pub fn hyperlinks(&self) -> TerminalSupport {
232 self.hyperlinks
233 }
234
235 pub fn clipboard(&self) -> TerminalSupport {
236 self.clipboard
237 }
238
239 pub fn warnings(&self) -> Vec<&'static str> {
241 let mut warnings = Vec::new();
242 if self.display_mode == TerminalDisplayMode::Inline {
243 warnings.push("fullscreen mode is unsafe because stdin/stdout are not interactive");
244 }
245 if self.family == TerminalFamily::Dumb {
246 warnings.push("TERM=dumb disables interactive terminal features");
247 } else if self.family == TerminalFamily::Unknown {
248 warnings.push("terminal family is unknown; optional escape sequences use fallbacks");
249 }
250 if self.multiplexer != TerminalMultiplexer::None {
251 if self.hyperlinks == TerminalSupport::RequiresPassthrough {
252 warnings.push("hyperlinks depend on multiplexer escape-sequence passthrough");
253 }
254 if self.clipboard == TerminalSupport::RequiresPassthrough {
255 warnings.push("clipboard copy depends on multiplexer OSC 52 passthrough");
256 }
257 }
258 if !self.enhanced_keyboard.is_supported() {
259 warnings.push("enhanced key reporting is unavailable; modified-key fallbacks apply");
260 }
261 warnings
262 }
263
264 fn detect_with(
265 lookup: impl Fn(&str) -> Option<String>,
266 io: TerminalIo,
267 enhanced_keyboard: TerminalSupport,
268 ) -> Self {
269 let term = env_value(&lookup, "TERM");
270 let term_program = env_value(&lookup, "TERM_PROGRAM");
271 let multiplexer = detect_multiplexer(&lookup, term.as_deref());
272 let family = detect_family(&lookup, term.as_deref(), term_program.as_deref());
273 let interactive = io.stdin && io.stdout && family != TerminalFamily::Dumb;
274 let baseline = if interactive {
275 TerminalSupport::Supported
276 } else {
277 TerminalSupport::Unsupported
278 };
279 let display_mode = if interactive {
280 TerminalDisplayMode::Fullscreen
281 } else {
282 TerminalDisplayMode::Inline
283 };
284 let enhanced_keyboard = if interactive {
285 enhanced_keyboard
286 } else {
287 TerminalSupport::Unsupported
288 };
289 let color_level = detect_color_level(&lookup, term.as_deref(), family, interactive);
290 let (hyperlinks, clipboard) =
291 detect_escape_capabilities(&lookup, family, multiplexer, interactive);
292
293 Self {
294 family,
295 multiplexer,
296 term,
297 term_program,
298 stdin_tty: io.stdin,
299 stdout_tty: io.stdout,
300 stderr_tty: io.stderr,
301 color_level,
302 display_mode,
303 alternate_screen: baseline,
304 bracketed_paste: baseline,
305 mouse_capture: baseline,
306 enhanced_keyboard,
307 hyperlinks,
308 clipboard,
309 }
310 }
311}
312
313#[derive(Clone, Copy)]
314struct TerminalIo {
315 stdin: bool,
316 stdout: bool,
317 stderr: bool,
318}
319
320fn env_value(lookup: &impl Fn(&str) -> Option<String>, name: &str) -> Option<String> {
321 lookup(name).and_then(|value| sanitize_env_value(&value))
322}
323
324fn env_present(lookup: &impl Fn(&str) -> Option<String>, name: &str) -> bool {
325 lookup(name).is_some_and(|value| !value.is_empty())
326}
327
328fn sanitize_env_value(value: &str) -> Option<String> {
329 let value = value
330 .chars()
331 .filter(|character| !character.is_control())
332 .take(MAX_ENV_VALUE_CHARS)
333 .collect::<String>();
334 (!value.is_empty()).then_some(value)
335}
336
337fn detect_multiplexer(
338 lookup: &impl Fn(&str) -> Option<String>,
339 term: Option<&str>,
340) -> TerminalMultiplexer {
341 if env_present(lookup, "TMUX") {
342 return TerminalMultiplexer::Tmux;
343 }
344 if env_present(lookup, "ZELLIJ") || env_present(lookup, "ZELLIJ_SESSION_NAME") {
345 return TerminalMultiplexer::Zellij;
346 }
347 if env_present(lookup, "STY") || term.is_some_and(|term| term.starts_with("screen")) {
348 return TerminalMultiplexer::Screen;
349 }
350 TerminalMultiplexer::None
351}
352
353fn detect_family(
354 lookup: &impl Fn(&str) -> Option<String>,
355 term: Option<&str>,
356 term_program: Option<&str>,
357) -> TerminalFamily {
358 if term.is_some_and(|term| term.eq_ignore_ascii_case("dumb")) {
359 return TerminalFamily::Dumb;
360 }
361
362 let program = term_program.unwrap_or_default().to_ascii_lowercase();
363 if program.contains("ghostty") || env_present(lookup, "GHOSTTY_RESOURCES_DIR") {
364 return TerminalFamily::Ghostty;
365 }
366 if program.contains("wezterm") || env_present(lookup, "WEZTERM_PANE") {
367 return TerminalFamily::WezTerm;
368 }
369 if program.contains("iterm") || env_present(lookup, "ITERM_SESSION_ID") {
370 return TerminalFamily::Iterm2;
371 }
372 if program.contains("apple_terminal") {
373 return TerminalFamily::AppleTerminal;
374 }
375 if program.contains("vscode") || env_present(lookup, "VSCODE_INJECTION") {
376 return TerminalFamily::Vscode;
377 }
378 if env_present(lookup, "WT_SESSION") {
379 return TerminalFamily::WindowsTerminal;
380 }
381 if program.contains("kitty")
382 || env_present(lookup, "KITTY_WINDOW_ID")
383 || term.is_some_and(|term| term.contains("kitty"))
384 {
385 return TerminalFamily::Kitty;
386 }
387 if env_present(lookup, "KONSOLE_VERSION") {
388 return TerminalFamily::Konsole;
389 }
390 if program.contains("alacritty") || term.is_some_and(|term| term.contains("alacritty")) {
391 return TerminalFamily::Alacritty;
392 }
393 if env_present(lookup, "VTE_VERSION") {
394 return TerminalFamily::Vte;
395 }
396 if term.is_some_and(|term| term.contains("xterm")) {
397 return TerminalFamily::Xterm;
398 }
399 TerminalFamily::Unknown
400}
401
402fn detect_color_level(
403 lookup: &impl Fn(&str) -> Option<String>,
404 term: Option<&str>,
405 family: TerminalFamily,
406 interactive: bool,
407) -> TerminalColorLevel {
408 if !interactive {
409 return TerminalColorLevel::None;
410 }
411 let color_term = env_value(lookup, "COLORTERM")
412 .unwrap_or_default()
413 .to_ascii_lowercase();
414 if matches!(color_term.as_str(), "truecolor" | "24bit") {
415 return TerminalColorLevel::TrueColor;
416 }
417 if term.is_some_and(|term| term.contains("256color")) {
418 return TerminalColorLevel::Ansi256;
419 }
420 if matches!(
421 family,
422 TerminalFamily::Ghostty
423 | TerminalFamily::Iterm2
424 | TerminalFamily::Kitty
425 | TerminalFamily::Vscode
426 | TerminalFamily::WezTerm
427 | TerminalFamily::WindowsTerminal
428 ) {
429 return TerminalColorLevel::TrueColor;
430 }
431 TerminalColorLevel::Ansi16
432}
433
434fn detect_escape_capabilities(
435 lookup: &impl Fn(&str) -> Option<String>,
436 family: TerminalFamily,
437 multiplexer: TerminalMultiplexer,
438 interactive: bool,
439) -> (TerminalSupport, TerminalSupport) {
440 if !interactive {
441 return (TerminalSupport::Unsupported, TerminalSupport::Unsupported);
442 }
443 if multiplexer != TerminalMultiplexer::None {
444 return (
445 TerminalSupport::RequiresPassthrough,
446 TerminalSupport::RequiresPassthrough,
447 );
448 }
449
450 let hyperlinks = match family {
451 TerminalFamily::Ghostty
452 | TerminalFamily::Iterm2
453 | TerminalFamily::Kitty
454 | TerminalFamily::Konsole
455 | TerminalFamily::Vscode
456 | TerminalFamily::WezTerm
457 | TerminalFamily::WindowsTerminal => TerminalSupport::Supported,
458 TerminalFamily::Vte if vte_supports_hyperlinks(lookup) => TerminalSupport::Supported,
459 TerminalFamily::Dumb => TerminalSupport::Unsupported,
460 _ => TerminalSupport::Unknown,
461 };
462 let clipboard = match family {
463 TerminalFamily::Ghostty
464 | TerminalFamily::Iterm2
465 | TerminalFamily::Kitty
466 | TerminalFamily::Vscode
467 | TerminalFamily::WezTerm
468 | TerminalFamily::WindowsTerminal => TerminalSupport::Supported,
469 TerminalFamily::Dumb => TerminalSupport::Unsupported,
470 _ => TerminalSupport::Unknown,
471 };
472 (hyperlinks, clipboard)
473}
474
475fn vte_supports_hyperlinks(lookup: &impl Fn(&str) -> Option<String>) -> bool {
476 env_value(lookup, "VTE_VERSION")
477 .and_then(|version| version.parse::<u32>().ok())
478 .is_some_and(|version| version >= 5_000)
479}
480
481#[cfg(test)]
482mod tests {
483 use std::collections::HashMap;
484
485 use super::*;
486
487 fn profile(
488 values: &[(&str, &str)],
489 io: TerminalIo,
490 keyboard: TerminalSupport,
491 ) -> TerminalProfile {
492 let values = values
493 .iter()
494 .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
495 .collect::<HashMap<_, _>>();
496 TerminalProfile::detect_with(|name| values.get(name).cloned(), io, keyboard)
497 }
498
499 fn interactive_io() -> TerminalIo {
500 TerminalIo {
501 stdin: true,
502 stdout: true,
503 stderr: true,
504 }
505 }
506
507 #[test]
508 fn kitty_inside_tmux_requires_explicit_escape_passthrough() {
509 let profile = profile(
510 &[
511 ("TERM", "screen-256color"),
512 ("TERM_PROGRAM", "kitty"),
513 ("TMUX", "/tmp/tmux-501/default,123,0"),
514 ("COLORTERM", "truecolor"),
515 ],
516 interactive_io(),
517 TerminalSupport::Supported,
518 );
519
520 assert_eq!(profile.family(), TerminalFamily::Kitty);
521 assert_eq!(profile.multiplexer(), TerminalMultiplexer::Tmux);
522 assert_eq!(profile.color_level(), TerminalColorLevel::TrueColor);
523 assert_eq!(profile.display_mode(), TerminalDisplayMode::Fullscreen);
524 assert_eq!(profile.hyperlinks(), TerminalSupport::RequiresPassthrough);
525 assert_eq!(profile.clipboard(), TerminalSupport::RequiresPassthrough);
526 assert!(profile
527 .warnings()
528 .iter()
529 .any(|warning| warning.contains("OSC 52")));
530 }
531
532 #[test]
533 fn dumb_or_noninteractive_term_uses_inline_fallbacks() {
534 let profile = profile(
535 &[("TERM", "dumb")],
536 TerminalIo {
537 stdin: false,
538 stdout: false,
539 stderr: true,
540 },
541 TerminalSupport::Supported,
542 );
543
544 assert_eq!(profile.family(), TerminalFamily::Dumb);
545 assert_eq!(profile.color_level(), TerminalColorLevel::None);
546 assert_eq!(profile.display_mode(), TerminalDisplayMode::Inline);
547 assert_eq!(profile.enhanced_keyboard(), TerminalSupport::Unsupported);
548 assert_eq!(profile.mouse_capture(), TerminalSupport::Unsupported);
549 assert_eq!(profile.bracketed_paste(), TerminalSupport::Unsupported);
550 }
551
552 #[test]
553 fn modern_emulators_expose_known_native_capabilities() {
554 let profile = profile(
555 &[("TERM", "xterm-256color"), ("TERM_PROGRAM", "WezTerm")],
556 interactive_io(),
557 TerminalSupport::Supported,
558 );
559
560 assert_eq!(profile.family(), TerminalFamily::WezTerm);
561 assert_eq!(profile.multiplexer(), TerminalMultiplexer::None);
562 assert_eq!(profile.color_level(), TerminalColorLevel::Ansi256);
563 assert_eq!(profile.hyperlinks(), TerminalSupport::Supported);
564 assert_eq!(profile.clipboard(), TerminalSupport::Supported);
565 assert_eq!(profile.alternate_screen(), TerminalSupport::Supported);
566 }
567
568 #[test]
569 fn recent_vte_reports_hyperlink_support_without_guessing_clipboard() {
570 let profile = profile(
571 &[("TERM", "xterm-256color"), ("VTE_VERSION", "7200")],
572 interactive_io(),
573 TerminalSupport::Unknown,
574 );
575
576 assert_eq!(profile.family(), TerminalFamily::Vte);
577 assert_eq!(profile.hyperlinks(), TerminalSupport::Supported);
578 assert_eq!(profile.clipboard(), TerminalSupport::Unknown);
579 }
580
581 #[test]
582 fn displayed_environment_values_are_bounded_and_control_free() {
583 let hostile = format!("xterm\u{1b}[31m{}", "x".repeat(300));
584 let profile = profile(
585 &[("TERM", &hostile)],
586 interactive_io(),
587 TerminalSupport::Unknown,
588 );
589 let term = profile.term().expect("TERM should be retained safely");
590
591 assert!(!term.contains('\u{1b}'));
592 assert_eq!(term.chars().count(), MAX_ENV_VALUE_CHARS);
593 }
594}