Skip to main content

running_process/
terminal_graphics.rs

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