1use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::io::IsTerminal;
12use std::time::Duration;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum GraphicsProtocol {
18 Sixel,
20 Kitty,
22 Iterm2File,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "kebab-case")]
28pub enum CapabilityStatus {
30 Supported,
32 Unsupported,
34 Unknown,
36 Blocked,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "kebab-case")]
42pub enum EvidenceStrength {
44 Probe,
46 StrongHostSignal,
48 Terminfo,
50 WeakEnv,
52 UserOverride,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct GraphicsCapability {
59 pub protocol: GraphicsProtocol,
61 pub status: CapabilityStatus,
63 pub evidence: EvidenceStrength,
65 pub source: String,
67 pub risks: Vec<String>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct TerminalGraphicsCapabilities {
74 pub protocols: Vec<GraphicsCapability>,
76 pub preferred: Option<GraphicsProtocol>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct TerminalCapabilities {
83 pub is_tty: bool,
85 pub term: Option<String>,
87 pub terminal_program: Option<String>,
89 pub graphics: TerminalGraphicsCapabilities,
91}
92
93#[derive(Debug, Clone, Default, PartialEq, Eq)]
94pub struct TerminalProbeEvidence {
96 pub sixel_xtsmgraphics: Option<String>,
98 pub sixel_da1: Option<String>,
100 pub kitty_graphics: Option<String>,
102 pub iterm2_capabilities: Option<String>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct TerminalCapabilityInput {
109 pub is_tty: bool,
111 pub env: BTreeMap<String, String>,
113 pub probe: TerminalProbeEvidence,
115}
116
117impl TerminalCapabilityInput {
118 pub fn from_env(is_tty: bool) -> Self {
120 Self {
121 is_tty,
122 env: std::env::vars().collect(),
123 probe: TerminalProbeEvidence::default(),
124 }
125 }
126
127 pub fn with_probe(mut self, probe: TerminalProbeEvidence) -> Self {
129 self.probe = probe;
130 self
131 }
132}
133
134impl TerminalGraphicsCapabilities {
135 pub fn unknown() -> Self {
137 Self {
138 protocols: vec![
139 capability(
140 GraphicsProtocol::Sixel,
141 CapabilityStatus::Unknown,
142 EvidenceStrength::WeakEnv,
143 "missing",
144 Vec::<String>::new(),
145 ),
146 capability(
147 GraphicsProtocol::Kitty,
148 CapabilityStatus::Unknown,
149 EvidenceStrength::WeakEnv,
150 "missing",
151 Vec::<String>::new(),
152 ),
153 capability(
154 GraphicsProtocol::Iterm2File,
155 CapabilityStatus::Unknown,
156 EvidenceStrength::WeakEnv,
157 "missing",
158 Vec::<String>::new(),
159 ),
160 ],
161 preferred: None,
162 }
163 }
164
165 pub fn by_protocol(&self, protocol: GraphicsProtocol) -> Option<&GraphicsCapability> {
167 self.protocols.iter().find(|c| c.protocol == protocol)
168 }
169}
170
171pub fn current_terminal_capabilities() -> TerminalCapabilities {
173 current_terminal_capabilities_with_timeout(Duration::from_millis(80))
174}
175
176pub fn current_terminal_capabilities_with_timeout(timeout: Duration) -> TerminalCapabilities {
178 let is_tty = std::io::stdout().is_terminal() && std::io::stdin().is_terminal();
179 let probe = if is_tty {
180 active_probe(timeout)
181 } else {
182 TerminalProbeEvidence::default()
183 };
184 detect_terminal_capabilities(TerminalCapabilityInput::from_env(is_tty).with_probe(probe))
185}
186
187pub fn detect_terminal_capabilities(input: TerminalCapabilityInput) -> TerminalCapabilities {
189 let term = env_value(&input.env, "TERM");
190 let terminal_program = env_value(&input.env, "TERM_PROGRAM");
191 let risks = base_risks(&input);
192
193 let graphics = if !input.is_tty {
194 blocked_all("non_tty", ["non_tty"])
195 } else if is_linux_console(term.as_deref()) {
196 blocked_all("TERM=linux", ["linux_console"])
197 } else if is_screen(term.as_deref()) {
198 blocked_all("TERM=screen", ["screen"])
199 } else {
200 let sixel = detect_sixel(&input, &risks);
201 let kitty = detect_kitty(&input, &risks);
202 let iterm2 = detect_iterm2(&input, &risks);
203 let preferred = choose_preferred(&[sixel.clone(), kitty.clone(), iterm2.clone()]);
204 TerminalGraphicsCapabilities {
205 protocols: vec![sixel, kitty, iterm2],
206 preferred,
207 }
208 };
209
210 TerminalCapabilities {
211 is_tty: input.is_tty,
212 term,
213 terminal_program,
214 graphics,
215 }
216}
217
218fn detect_sixel(input: &TerminalCapabilityInput, risks: &[String]) -> GraphicsCapability {
219 if let Some(reply) = input.probe.sixel_xtsmgraphics.as_deref() {
220 if xtsmgraphics_reports_sixel(reply) {
221 return capability(
222 GraphicsProtocol::Sixel,
223 CapabilityStatus::Supported,
224 EvidenceStrength::Probe,
225 "XTSMGRAPHICS",
226 risks.to_vec(),
227 );
228 }
229 }
230 if let Some(reply) = input.probe.sixel_da1.as_deref() {
231 if primary_da_reports_sixel(reply) {
232 return capability(
233 GraphicsProtocol::Sixel,
234 CapabilityStatus::Supported,
235 EvidenceStrength::Probe,
236 "DA1",
237 risks.to_vec(),
238 );
239 }
240 }
241
242 let term = env_value(&input.env, "TERM").unwrap_or_default();
243 let program = env_value(&input.env, "TERM_PROGRAM").unwrap_or_default();
244 if contains_any(&term, &["alacritty", "kitty", "ghostty"])
245 || contains_any(&program, &["Alacritty", "kitty", "Ghostty"])
246 || env_value(&input.env, "VTE_VERSION").is_some()
247 || contains_any(&program, &["gnome-terminal"])
248 || contains_any(&term, &["vte"])
249 {
250 return capability(
251 GraphicsProtocol::Sixel,
252 CapabilityStatus::Blocked,
253 EvidenceStrength::StrongHostSignal,
254 first_source(&[
255 ("TERM", &term),
256 ("TERM_PROGRAM", &program),
257 (
258 "VTE_VERSION",
259 &env_value(&input.env, "VTE_VERSION").unwrap_or_default(),
260 ),
261 ]),
262 risks.to_vec(),
263 );
264 }
265
266 if env_value(&input.env, "WT_SESSION").is_some() {
267 let mut local_risks = risks.to_vec();
268 local_risks.push("requires_windows_terminal_1_22".into());
269 return capability(
270 GraphicsProtocol::Sixel,
271 CapabilityStatus::Supported,
272 EvidenceStrength::StrongHostSignal,
273 "WT_SESSION",
274 local_risks,
275 );
276 }
277 if term == "foot"
278 || env_value(&input.env, "KONSOLE_VERSION").is_some()
279 || contains_any(&program, &["WezTerm", "mintty"])
280 || env_value(&input.env, "WEZTERM_PANE").is_some()
281 {
282 return capability(
283 GraphicsProtocol::Sixel,
284 CapabilityStatus::Supported,
285 EvidenceStrength::StrongHostSignal,
286 first_source(&[
287 ("TERM", &term),
288 ("TERM_PROGRAM", &program),
289 (
290 "KONSOLE_VERSION",
291 &env_value(&input.env, "KONSOLE_VERSION").unwrap_or_default(),
292 ),
293 (
294 "WEZTERM_PANE",
295 &env_value(&input.env, "WEZTERM_PANE").unwrap_or_default(),
296 ),
297 ]),
298 risks.to_vec(),
299 );
300 }
301
302 if contains_any(&term, &["xterm"]) {
303 return capability(
304 GraphicsProtocol::Sixel,
305 CapabilityStatus::Unknown,
306 EvidenceStrength::WeakEnv,
307 format!("TERM={term}"),
308 risks.to_vec(),
309 );
310 }
311
312 capability(
313 GraphicsProtocol::Sixel,
314 CapabilityStatus::Unknown,
315 EvidenceStrength::WeakEnv,
316 if term.is_empty() {
317 "TERM missing".to_string()
318 } else {
319 format!("TERM={term}")
320 },
321 risks.to_vec(),
322 )
323}
324
325fn detect_kitty(input: &TerminalCapabilityInput, risks: &[String]) -> GraphicsCapability {
326 if let Some(reply) = input.probe.kitty_graphics.as_deref() {
327 if reply.contains("_G") || reply.contains("OK") {
328 return capability(
329 GraphicsProtocol::Kitty,
330 CapabilityStatus::Supported,
331 EvidenceStrength::Probe,
332 "kitty-query",
333 risks.to_vec(),
334 );
335 }
336 }
337 let term = env_value(&input.env, "TERM").unwrap_or_default();
338 let program = env_value(&input.env, "TERM_PROGRAM").unwrap_or_default();
339 if contains_any(&term, &["kitty", "ghostty"])
340 || contains_any(&program, &["kitty", "Ghostty", "WezTerm"])
341 || env_value(&input.env, "WEZTERM_PANE").is_some()
342 {
343 return capability(
344 GraphicsProtocol::Kitty,
345 CapabilityStatus::Supported,
346 EvidenceStrength::StrongHostSignal,
347 first_source(&[
348 ("TERM", &term),
349 ("TERM_PROGRAM", &program),
350 (
351 "WEZTERM_PANE",
352 &env_value(&input.env, "WEZTERM_PANE").unwrap_or_default(),
353 ),
354 ]),
355 risks.to_vec(),
356 );
357 }
358 capability(
359 GraphicsProtocol::Kitty,
360 CapabilityStatus::Unknown,
361 EvidenceStrength::WeakEnv,
362 if term.is_empty() {
363 "TERM missing".to_string()
364 } else {
365 format!("TERM={term}")
366 },
367 risks.to_vec(),
368 )
369}
370
371fn detect_iterm2(input: &TerminalCapabilityInput, risks: &[String]) -> GraphicsCapability {
372 if let Some(reply) = input.probe.iterm2_capabilities.as_deref() {
373 if reply.contains("Capabilities=") || reply.contains("File=") {
374 return capability(
375 GraphicsProtocol::Iterm2File,
376 CapabilityStatus::Supported,
377 EvidenceStrength::Probe,
378 "OSC 1337;Capabilities",
379 risks.to_vec(),
380 );
381 }
382 }
383 let program = env_value(&input.env, "TERM_PROGRAM").unwrap_or_default();
384 if contains_any(&program, &["iTerm.app", "WezTerm", "mintty"]) {
385 return capability(
386 GraphicsProtocol::Iterm2File,
387 CapabilityStatus::Supported,
388 EvidenceStrength::StrongHostSignal,
389 format!("TERM_PROGRAM={program}"),
390 risks.to_vec(),
391 );
392 }
393 capability(
394 GraphicsProtocol::Iterm2File,
395 CapabilityStatus::Unknown,
396 EvidenceStrength::WeakEnv,
397 if program.is_empty() {
398 "TERM_PROGRAM missing".to_string()
399 } else {
400 format!("TERM_PROGRAM={program}")
401 },
402 risks.to_vec(),
403 )
404}
405
406fn choose_preferred(capabilities: &[GraphicsCapability]) -> Option<GraphicsProtocol> {
407 capabilities
408 .iter()
409 .find(|c| c.status == CapabilityStatus::Supported && c.evidence == EvidenceStrength::Probe)
410 .or_else(|| {
411 capabilities
412 .iter()
413 .find(|c| c.status == CapabilityStatus::Supported)
414 })
415 .map(|c| c.protocol)
416}
417
418fn blocked_all(
419 source: &str,
420 risks: impl IntoIterator<Item = impl Into<String>>,
421) -> TerminalGraphicsCapabilities {
422 let risks: Vec<String> = risks.into_iter().map(Into::into).collect();
423 TerminalGraphicsCapabilities {
424 protocols: vec![
425 capability(
426 GraphicsProtocol::Sixel,
427 CapabilityStatus::Blocked,
428 EvidenceStrength::StrongHostSignal,
429 source,
430 risks.clone(),
431 ),
432 capability(
433 GraphicsProtocol::Kitty,
434 CapabilityStatus::Blocked,
435 EvidenceStrength::StrongHostSignal,
436 source,
437 risks.clone(),
438 ),
439 capability(
440 GraphicsProtocol::Iterm2File,
441 CapabilityStatus::Blocked,
442 EvidenceStrength::StrongHostSignal,
443 source,
444 risks,
445 ),
446 ],
447 preferred: None,
448 }
449}
450
451fn base_risks(input: &TerminalCapabilityInput) -> Vec<String> {
452 let mut risks = Vec::new();
453 if env_value(&input.env, "TMUX").is_some() || is_tmux(env_value(&input.env, "TERM").as_deref())
454 {
455 risks.push("tmux".into());
456 }
457 if env_value(&input.env, "SSH_CONNECTION").is_some()
458 || env_value(&input.env, "SSH_TTY").is_some()
459 {
460 risks.push("ssh".into());
461 }
462 risks
463}
464
465fn capability(
466 protocol: GraphicsProtocol,
467 status: CapabilityStatus,
468 evidence: EvidenceStrength,
469 source: impl Into<String>,
470 risks: impl IntoIterator<Item = impl Into<String>>,
471) -> GraphicsCapability {
472 GraphicsCapability {
473 protocol,
474 status,
475 evidence,
476 source: source.into(),
477 risks: risks.into_iter().map(Into::into).collect(),
478 }
479}
480
481fn env_value(env: &BTreeMap<String, String>, key: &str) -> Option<String> {
482 env.get(key).filter(|v| !v.is_empty()).cloned()
483}
484
485fn contains_any(value: &str, needles: &[&str]) -> bool {
486 let lower = value.to_ascii_lowercase();
487 needles
488 .iter()
489 .any(|needle| lower.contains(&needle.to_ascii_lowercase()))
490}
491
492fn is_linux_console(term: Option<&str>) -> bool {
493 matches!(term, Some("linux"))
494}
495
496fn is_screen(term: Option<&str>) -> bool {
497 term.is_some_and(|t| t.starts_with("screen") || t == "screen")
498}
499
500fn is_tmux(term: Option<&str>) -> bool {
501 term.is_some_and(|t| t.starts_with("tmux") || t.contains("tmux"))
502}
503
504fn first_source(candidates: &[(&str, &str)]) -> String {
505 for (key, value) in candidates {
506 if !value.is_empty() {
507 return format!("{key}={value}");
508 }
509 }
510 "unknown".into()
511}
512
513pub fn primary_da_reports_sixel(reply: &str) -> bool {
515 reply
516 .split('\x1b')
517 .filter_map(|part| part.strip_prefix("[?"))
518 .filter_map(|part| part.split('c').next())
519 .flat_map(|params| params.split(';'))
520 .any(|param| param == "4")
521}
522
523pub fn xtsmgraphics_reports_sixel(reply: &str) -> bool {
525 reply.contains("\x1b[?") && reply.contains('S')
526}
527
528fn active_probe(timeout: Duration) -> TerminalProbeEvidence {
529 let reply =
530 running_process_platform_internal::platform::terminal_graphics::active_graphics_probe(
531 timeout,
532 );
533 TerminalProbeEvidence {
534 sixel_xtsmgraphics: reply.sixel_xtsmgraphics,
535 sixel_da1: reply.sixel_da1,
536 kitty_graphics: reply.kitty_graphics,
537 iterm2_capabilities: reply.iterm2_capabilities,
538 }
539}
540
541#[cfg(feature = "client")]
542pub fn terminal_graphics_capabilities_to_proto(
544 caps: &TerminalGraphicsCapabilities,
545) -> crate::proto::daemon::TerminalGraphicsCapabilities {
546 crate::proto::daemon::TerminalGraphicsCapabilities {
547 protocols: caps
548 .protocols
549 .iter()
550 .map(graphics_capability_to_proto)
551 .collect(),
552 preferred: caps
553 .preferred
554 .map(proto_graphics_protocol)
555 .unwrap_or(crate::proto::daemon::GraphicsProtocol::Unspecified)
556 as i32,
557 }
558}
559
560#[cfg(feature = "client")]
561pub fn terminal_graphics_capabilities_from_proto(
563 caps: &crate::proto::daemon::TerminalGraphicsCapabilities,
564) -> TerminalGraphicsCapabilities {
565 let protocols = caps
566 .protocols
567 .iter()
568 .map(graphics_capability_from_proto)
569 .collect();
570 TerminalGraphicsCapabilities {
571 protocols,
572 preferred: graphics_protocol_from_i32(caps.preferred),
573 }
574}
575
576#[cfg(feature = "client")]
577fn graphics_capability_to_proto(
578 capability: &GraphicsCapability,
579) -> crate::proto::daemon::TerminalGraphicsCapability {
580 crate::proto::daemon::TerminalGraphicsCapability {
581 protocol: proto_graphics_protocol(capability.protocol) as i32,
582 status: proto_capability_status(capability.status) as i32,
583 evidence: proto_evidence_strength(capability.evidence) as i32,
584 source: capability.source.clone(),
585 risks: capability.risks.clone(),
586 }
587}
588
589#[cfg(feature = "client")]
590fn graphics_capability_from_proto(
591 capability: &crate::proto::daemon::TerminalGraphicsCapability,
592) -> GraphicsCapability {
593 GraphicsCapability {
594 protocol: graphics_protocol_from_i32(capability.protocol)
595 .unwrap_or(GraphicsProtocol::Sixel),
596 status: capability_status_from_i32(capability.status),
597 evidence: evidence_strength_from_i32(capability.evidence),
598 source: capability.source.clone(),
599 risks: capability.risks.clone(),
600 }
601}
602
603#[cfg(feature = "client")]
604fn proto_graphics_protocol(protocol: GraphicsProtocol) -> crate::proto::daemon::GraphicsProtocol {
605 match protocol {
606 GraphicsProtocol::Sixel => crate::proto::daemon::GraphicsProtocol::Sixel,
607 GraphicsProtocol::Kitty => crate::proto::daemon::GraphicsProtocol::Kitty,
608 GraphicsProtocol::Iterm2File => crate::proto::daemon::GraphicsProtocol::Iterm2File,
609 }
610}
611
612#[cfg(feature = "client")]
613fn graphics_protocol_from_i32(protocol: i32) -> Option<GraphicsProtocol> {
614 match crate::proto::daemon::GraphicsProtocol::try_from(protocol).ok()? {
615 crate::proto::daemon::GraphicsProtocol::Sixel => Some(GraphicsProtocol::Sixel),
616 crate::proto::daemon::GraphicsProtocol::Kitty => Some(GraphicsProtocol::Kitty),
617 crate::proto::daemon::GraphicsProtocol::Iterm2File => Some(GraphicsProtocol::Iterm2File),
618 crate::proto::daemon::GraphicsProtocol::Unspecified => None,
619 }
620}
621
622#[cfg(feature = "client")]
623fn proto_capability_status(status: CapabilityStatus) -> crate::proto::daemon::CapabilityStatus {
624 match status {
625 CapabilityStatus::Supported => crate::proto::daemon::CapabilityStatus::Supported,
626 CapabilityStatus::Unsupported => crate::proto::daemon::CapabilityStatus::Unsupported,
627 CapabilityStatus::Unknown => crate::proto::daemon::CapabilityStatus::Unknown,
628 CapabilityStatus::Blocked => crate::proto::daemon::CapabilityStatus::Blocked,
629 }
630}
631
632#[cfg(feature = "client")]
633fn capability_status_from_i32(status: i32) -> CapabilityStatus {
634 match crate::proto::daemon::CapabilityStatus::try_from(status)
635 .unwrap_or(crate::proto::daemon::CapabilityStatus::Unknown)
636 {
637 crate::proto::daemon::CapabilityStatus::Supported => CapabilityStatus::Supported,
638 crate::proto::daemon::CapabilityStatus::Unsupported => CapabilityStatus::Unsupported,
639 crate::proto::daemon::CapabilityStatus::Unknown
640 | crate::proto::daemon::CapabilityStatus::Unspecified => CapabilityStatus::Unknown,
641 crate::proto::daemon::CapabilityStatus::Blocked => CapabilityStatus::Blocked,
642 }
643}
644
645#[cfg(feature = "client")]
646fn proto_evidence_strength(evidence: EvidenceStrength) -> crate::proto::daemon::EvidenceStrength {
647 match evidence {
648 EvidenceStrength::Probe => crate::proto::daemon::EvidenceStrength::Probe,
649 EvidenceStrength::StrongHostSignal => {
650 crate::proto::daemon::EvidenceStrength::StrongHostSignal
651 }
652 EvidenceStrength::Terminfo => crate::proto::daemon::EvidenceStrength::Terminfo,
653 EvidenceStrength::WeakEnv => crate::proto::daemon::EvidenceStrength::WeakEnv,
654 EvidenceStrength::UserOverride => crate::proto::daemon::EvidenceStrength::UserOverride,
655 }
656}
657
658#[cfg(feature = "client")]
659fn evidence_strength_from_i32(evidence: i32) -> EvidenceStrength {
660 match crate::proto::daemon::EvidenceStrength::try_from(evidence)
661 .unwrap_or(crate::proto::daemon::EvidenceStrength::WeakEnv)
662 {
663 crate::proto::daemon::EvidenceStrength::Probe => EvidenceStrength::Probe,
664 crate::proto::daemon::EvidenceStrength::StrongHostSignal => {
665 EvidenceStrength::StrongHostSignal
666 }
667 crate::proto::daemon::EvidenceStrength::Terminfo => EvidenceStrength::Terminfo,
668 crate::proto::daemon::EvidenceStrength::WeakEnv
669 | crate::proto::daemon::EvidenceStrength::Unspecified => EvidenceStrength::WeakEnv,
670 crate::proto::daemon::EvidenceStrength::UserOverride => EvidenceStrength::UserOverride,
671 }
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677
678 fn input(term: &str, pairs: &[(&str, &str)]) -> TerminalCapabilityInput {
679 let mut env = BTreeMap::new();
680 if !term.is_empty() {
681 env.insert("TERM".into(), term.into());
682 }
683 for (k, v) in pairs {
684 env.insert((*k).into(), (*v).into());
685 }
686 TerminalCapabilityInput {
687 is_tty: true,
688 env,
689 probe: TerminalProbeEvidence::default(),
690 }
691 }
692
693 #[test]
694 fn weak_xterm_does_not_confirm_sixel() {
695 let caps = detect_terminal_capabilities(input("xterm-256color", &[]));
696 let sixel = caps.graphics.by_protocol(GraphicsProtocol::Sixel).unwrap();
697 assert_eq!(sixel.status, CapabilityStatus::Unknown);
698 assert_eq!(sixel.evidence, EvidenceStrength::WeakEnv);
699 assert_eq!(caps.graphics.preferred, None);
700 }
701
702 #[test]
703 fn da1_probe_confirms_sixel() {
704 let mut case = input("xterm-256color", &[]);
705 case.probe.sixel_da1 = Some("\x1b[?62;4;22c".into());
706 let caps = detect_terminal_capabilities(case);
707 let sixel = caps.graphics.by_protocol(GraphicsProtocol::Sixel).unwrap();
708 assert_eq!(sixel.status, CapabilityStatus::Supported);
709 assert_eq!(sixel.evidence, EvidenceStrength::Probe);
710 assert_eq!(caps.graphics.preferred, Some(GraphicsProtocol::Sixel));
711 }
712
713 #[test]
714 fn vt100_da_does_not_confirm_sixel() {
715 assert!(!primary_da_reports_sixel("\x1b[?1;2c"));
716 assert!(!primary_da_reports_sixel("\x1b[?62;22c"));
717 }
718}
719
720#[cfg(test)]
721#[path = "tests/terminal_graphics_coverage.rs"]
722mod coverage_tests;