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")]
16/// Terminal graphics protocols recognized by capability detection.
17pub enum GraphicsProtocol {
18    /// Sixel bitmap graphics escape sequences.
19    Sixel,
20    /// Kitty graphics protocol escape sequences.
21    Kitty,
22    /// iTerm2 inline file protocol escape sequences.
23    Iterm2File,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "kebab-case")]
28/// Detection result for a terminal graphics protocol.
29pub enum CapabilityStatus {
30    /// The protocol is available for use.
31    Supported,
32    /// The protocol is known to be unavailable.
33    Unsupported,
34    /// Available evidence is not strong enough to decide.
35    Unknown,
36    /// The current terminal/session prevents using the protocol safely.
37    Blocked,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "kebab-case")]
42/// Confidence level for graphics capability evidence.
43pub enum EvidenceStrength {
44    /// A live terminal probe confirmed the capability.
45    Probe,
46    /// A strong terminal-host signal identified expected support.
47    StrongHostSignal,
48    /// Terminfo advertised the capability.
49    Terminfo,
50    /// Environment identity provides only weak evidence.
51    WeakEnv,
52    /// A user setting supplied the evidence.
53    UserOverride,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57/// Detection result for one terminal graphics protocol.
58pub struct GraphicsCapability {
59    /// Protocol described by this result.
60    pub protocol: GraphicsProtocol,
61    /// Whether the protocol is usable, unavailable, unknown, or blocked.
62    pub status: CapabilityStatus,
63    /// Strength of the evidence behind `status`.
64    pub evidence: EvidenceStrength,
65    /// Human-readable source of the evidence.
66    pub source: String,
67    /// Context markers that may affect rendering reliability.
68    pub risks: Vec<String>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72/// Graphics capability set detected for a terminal.
73pub struct TerminalGraphicsCapabilities {
74    /// Per-protocol capability results.
75    pub protocols: Vec<GraphicsCapability>,
76    /// Preferred protocol when one is supported.
77    pub preferred: Option<GraphicsProtocol>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81/// Terminal capability snapshot used by clients and diagnostics.
82pub struct TerminalCapabilities {
83    /// Whether standard input and output are attached to a terminal.
84    pub is_tty: bool,
85    /// Value of the `TERM` environment variable, when present.
86    pub term: Option<String>,
87    /// Value of the `TERM_PROGRAM` environment variable, when present.
88    pub terminal_program: Option<String>,
89    /// Detected terminal graphics capabilities.
90    pub graphics: TerminalGraphicsCapabilities,
91}
92
93#[derive(Debug, Clone, Default, PartialEq, Eq)]
94/// Raw terminal replies collected during active graphics probing.
95pub struct TerminalProbeEvidence {
96    /// Reply to the Sixel XTSMGRAPHICS query.
97    pub sixel_xtsmgraphics: Option<String>,
98    /// Primary device attributes reply used for Sixel detection.
99    pub sixel_da1: Option<String>,
100    /// Reply to the Kitty graphics query.
101    pub kitty_graphics: Option<String>,
102    /// Reply to the iTerm2 capabilities query.
103    pub iterm2_capabilities: Option<String>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
107/// Inputs used to detect terminal capabilities.
108pub struct TerminalCapabilityInput {
109    /// Whether the inspected streams are attached to a terminal.
110    pub is_tty: bool,
111    /// Environment variables used as host and session hints.
112    pub env: BTreeMap<String, String>,
113    /// Active probe replies to incorporate into detection.
114    pub probe: TerminalProbeEvidence,
115}
116
117impl TerminalCapabilityInput {
118    /// Builds detection input from the current process environment.
119    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    /// Returns this input with active probe evidence attached.
128    pub fn with_probe(mut self, probe: TerminalProbeEvidence) -> Self {
129        self.probe = probe;
130        self
131    }
132}
133
134impl TerminalGraphicsCapabilities {
135    /// Returns an all-protocol unknown capability set.
136    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    /// Finds the capability result for `protocol`.
166    pub fn by_protocol(&self, protocol: GraphicsProtocol) -> Option<&GraphicsCapability> {
167        self.protocols.iter().find(|c| c.protocol == protocol)
168    }
169}
170
171/// Detects capabilities for the current terminal with the default probe timeout.
172pub fn current_terminal_capabilities() -> TerminalCapabilities {
173    current_terminal_capabilities_with_timeout(Duration::from_millis(80))
174}
175
176/// Detects capabilities for the current terminal using `timeout` for active probes.
177pub 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
187/// Detects terminal capabilities from explicit environment and probe evidence.
188pub 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
513/// Returns whether a primary device attributes reply advertises Sixel support.
514pub 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
523/// Returns whether an XTSMGRAPHICS reply indicates Sixel graphics support.
524pub 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::active_graphics_probe(timeout);
531    TerminalProbeEvidence {
532        sixel_xtsmgraphics: reply.sixel_xtsmgraphics,
533        sixel_da1: reply.sixel_da1,
534        kitty_graphics: reply.kitty_graphics,
535        iterm2_capabilities: reply.iterm2_capabilities,
536    }
537}
538
539#[cfg(feature = "client")]
540/// Converts terminal graphics capabilities into the daemon protobuf form.
541pub fn terminal_graphics_capabilities_to_proto(
542    caps: &TerminalGraphicsCapabilities,
543) -> crate::proto::daemon::TerminalGraphicsCapabilities {
544    crate::proto::daemon::TerminalGraphicsCapabilities {
545        protocols: caps
546            .protocols
547            .iter()
548            .map(graphics_capability_to_proto)
549            .collect(),
550        preferred: caps
551            .preferred
552            .map(proto_graphics_protocol)
553            .unwrap_or(crate::proto::daemon::GraphicsProtocol::Unspecified)
554            as i32,
555    }
556}
557
558#[cfg(feature = "client")]
559/// Converts daemon protobuf terminal graphics capabilities into the Rust form.
560pub fn terminal_graphics_capabilities_from_proto(
561    caps: &crate::proto::daemon::TerminalGraphicsCapabilities,
562) -> TerminalGraphicsCapabilities {
563    let protocols = caps
564        .protocols
565        .iter()
566        .map(graphics_capability_from_proto)
567        .collect();
568    TerminalGraphicsCapabilities {
569        protocols,
570        preferred: graphics_protocol_from_i32(caps.preferred),
571    }
572}
573
574#[cfg(feature = "client")]
575fn graphics_capability_to_proto(
576    capability: &GraphicsCapability,
577) -> crate::proto::daemon::TerminalGraphicsCapability {
578    crate::proto::daemon::TerminalGraphicsCapability {
579        protocol: proto_graphics_protocol(capability.protocol) as i32,
580        status: proto_capability_status(capability.status) as i32,
581        evidence: proto_evidence_strength(capability.evidence) as i32,
582        source: capability.source.clone(),
583        risks: capability.risks.clone(),
584    }
585}
586
587#[cfg(feature = "client")]
588fn graphics_capability_from_proto(
589    capability: &crate::proto::daemon::TerminalGraphicsCapability,
590) -> GraphicsCapability {
591    GraphicsCapability {
592        protocol: graphics_protocol_from_i32(capability.protocol)
593            .unwrap_or(GraphicsProtocol::Sixel),
594        status: capability_status_from_i32(capability.status),
595        evidence: evidence_strength_from_i32(capability.evidence),
596        source: capability.source.clone(),
597        risks: capability.risks.clone(),
598    }
599}
600
601#[cfg(feature = "client")]
602fn proto_graphics_protocol(protocol: GraphicsProtocol) -> crate::proto::daemon::GraphicsProtocol {
603    match protocol {
604        GraphicsProtocol::Sixel => crate::proto::daemon::GraphicsProtocol::Sixel,
605        GraphicsProtocol::Kitty => crate::proto::daemon::GraphicsProtocol::Kitty,
606        GraphicsProtocol::Iterm2File => crate::proto::daemon::GraphicsProtocol::Iterm2File,
607    }
608}
609
610#[cfg(feature = "client")]
611fn graphics_protocol_from_i32(protocol: i32) -> Option<GraphicsProtocol> {
612    match crate::proto::daemon::GraphicsProtocol::try_from(protocol).ok()? {
613        crate::proto::daemon::GraphicsProtocol::Sixel => Some(GraphicsProtocol::Sixel),
614        crate::proto::daemon::GraphicsProtocol::Kitty => Some(GraphicsProtocol::Kitty),
615        crate::proto::daemon::GraphicsProtocol::Iterm2File => Some(GraphicsProtocol::Iterm2File),
616        crate::proto::daemon::GraphicsProtocol::Unspecified => None,
617    }
618}
619
620#[cfg(feature = "client")]
621fn proto_capability_status(status: CapabilityStatus) -> crate::proto::daemon::CapabilityStatus {
622    match status {
623        CapabilityStatus::Supported => crate::proto::daemon::CapabilityStatus::Supported,
624        CapabilityStatus::Unsupported => crate::proto::daemon::CapabilityStatus::Unsupported,
625        CapabilityStatus::Unknown => crate::proto::daemon::CapabilityStatus::Unknown,
626        CapabilityStatus::Blocked => crate::proto::daemon::CapabilityStatus::Blocked,
627    }
628}
629
630#[cfg(feature = "client")]
631fn capability_status_from_i32(status: i32) -> CapabilityStatus {
632    match crate::proto::daemon::CapabilityStatus::try_from(status)
633        .unwrap_or(crate::proto::daemon::CapabilityStatus::Unknown)
634    {
635        crate::proto::daemon::CapabilityStatus::Supported => CapabilityStatus::Supported,
636        crate::proto::daemon::CapabilityStatus::Unsupported => CapabilityStatus::Unsupported,
637        crate::proto::daemon::CapabilityStatus::Unknown
638        | crate::proto::daemon::CapabilityStatus::Unspecified => CapabilityStatus::Unknown,
639        crate::proto::daemon::CapabilityStatus::Blocked => CapabilityStatus::Blocked,
640    }
641}
642
643#[cfg(feature = "client")]
644fn proto_evidence_strength(evidence: EvidenceStrength) -> crate::proto::daemon::EvidenceStrength {
645    match evidence {
646        EvidenceStrength::Probe => crate::proto::daemon::EvidenceStrength::Probe,
647        EvidenceStrength::StrongHostSignal => {
648            crate::proto::daemon::EvidenceStrength::StrongHostSignal
649        }
650        EvidenceStrength::Terminfo => crate::proto::daemon::EvidenceStrength::Terminfo,
651        EvidenceStrength::WeakEnv => crate::proto::daemon::EvidenceStrength::WeakEnv,
652        EvidenceStrength::UserOverride => crate::proto::daemon::EvidenceStrength::UserOverride,
653    }
654}
655
656#[cfg(feature = "client")]
657fn evidence_strength_from_i32(evidence: i32) -> EvidenceStrength {
658    match crate::proto::daemon::EvidenceStrength::try_from(evidence)
659        .unwrap_or(crate::proto::daemon::EvidenceStrength::WeakEnv)
660    {
661        crate::proto::daemon::EvidenceStrength::Probe => EvidenceStrength::Probe,
662        crate::proto::daemon::EvidenceStrength::StrongHostSignal => {
663            EvidenceStrength::StrongHostSignal
664        }
665        crate::proto::daemon::EvidenceStrength::Terminfo => EvidenceStrength::Terminfo,
666        crate::proto::daemon::EvidenceStrength::WeakEnv
667        | crate::proto::daemon::EvidenceStrength::Unspecified => EvidenceStrength::WeakEnv,
668        crate::proto::daemon::EvidenceStrength::UserOverride => EvidenceStrength::UserOverride,
669    }
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675
676    fn input(term: &str, pairs: &[(&str, &str)]) -> TerminalCapabilityInput {
677        let mut env = BTreeMap::new();
678        if !term.is_empty() {
679            env.insert("TERM".into(), term.into());
680        }
681        for (k, v) in pairs {
682            env.insert((*k).into(), (*v).into());
683        }
684        TerminalCapabilityInput {
685            is_tty: true,
686            env,
687            probe: TerminalProbeEvidence::default(),
688        }
689    }
690
691    #[test]
692    fn weak_xterm_does_not_confirm_sixel() {
693        let caps = detect_terminal_capabilities(input("xterm-256color", &[]));
694        let sixel = caps.graphics.by_protocol(GraphicsProtocol::Sixel).unwrap();
695        assert_eq!(sixel.status, CapabilityStatus::Unknown);
696        assert_eq!(sixel.evidence, EvidenceStrength::WeakEnv);
697        assert_eq!(caps.graphics.preferred, None);
698    }
699
700    #[test]
701    fn da1_probe_confirms_sixel() {
702        let mut case = input("xterm-256color", &[]);
703        case.probe.sixel_da1 = Some("\x1b[?62;4;22c".into());
704        let caps = detect_terminal_capabilities(case);
705        let sixel = caps.graphics.by_protocol(GraphicsProtocol::Sixel).unwrap();
706        assert_eq!(sixel.status, CapabilityStatus::Supported);
707        assert_eq!(sixel.evidence, EvidenceStrength::Probe);
708        assert_eq!(caps.graphics.preferred, Some(GraphicsProtocol::Sixel));
709    }
710
711    #[test]
712    fn vt100_da_does_not_confirm_sixel() {
713        assert!(!primary_da_reports_sixel("\x1b[?1;2c"));
714        assert!(!primary_da_reports_sixel("\x1b[?62;22c"));
715    }
716}
717
718#[cfg(test)]
719#[path = "tests/terminal_graphics_coverage.rs"]
720mod coverage_tests;