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
528#[cfg(unix)]
529fn active_probe(timeout: Duration) -> TerminalProbeEvidence {
530    use std::fs::OpenOptions;
531    use std::io::{Read, Write};
532    use std::os::fd::AsRawFd;
533    use std::time::Instant;
534
535    let Ok(mut tty) = OpenOptions::new().read(true).write(true).open("/dev/tty") else {
536        return TerminalProbeEvidence::default();
537    };
538    let fd = tty.as_raw_fd();
539    let mut old_termios = std::mem::MaybeUninit::<libc::termios>::uninit();
540    let have_termios = unsafe { libc::tcgetattr(fd, old_termios.as_mut_ptr()) == 0 };
541    let old_termios = if have_termios {
542        Some(unsafe { old_termios.assume_init() })
543    } else {
544        None
545    };
546    if let Some(mut raw) = old_termios {
547        raw.c_lflag &= !(libc::ICANON | libc::ECHO);
548        raw.c_cc[libc::VMIN] = 0;
549        raw.c_cc[libc::VTIME] = 0;
550        let _ = unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) };
551    }
552    let old_flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
553    if old_flags >= 0 {
554        let _ = unsafe { libc::fcntl(fd, libc::F_SETFL, old_flags | libc::O_NONBLOCK) };
555    }
556
557    let _ = tty.write_all(
558        b"\x1b[c\x1b[?2;1;0S\x1b_Gi=running-process-probe,a=q;\x1b\\\x1b]1337;Capabilities\x07",
559    );
560    let _ = tty.flush();
561
562    let deadline = Instant::now() + timeout;
563    let mut buf = Vec::new();
564    while Instant::now() < deadline {
565        let mut chunk = [0_u8; 512];
566        match tty.read(&mut chunk) {
567            Ok(0) => std::thread::sleep(Duration::from_millis(5)),
568            Ok(n) => buf.extend_from_slice(&chunk[..n]),
569            Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
570                std::thread::sleep(Duration::from_millis(5));
571            }
572            Err(_) => break,
573        }
574    }
575
576    if old_flags >= 0 {
577        let _ = unsafe { libc::fcntl(fd, libc::F_SETFL, old_flags) };
578    }
579    if let Some(old) = old_termios {
580        let _ = unsafe { libc::tcsetattr(fd, libc::TCSANOW, &old) };
581    }
582
583    let reply = String::from_utf8_lossy(&buf).into_owned();
584    TerminalProbeEvidence {
585        sixel_xtsmgraphics: reply.contains('S').then(|| reply.clone()),
586        sixel_da1: reply.contains("[?").then(|| reply.clone()),
587        kitty_graphics: reply.contains("_G").then(|| reply.clone()),
588        iterm2_capabilities: reply.contains("Capabilities=").then_some(reply),
589    }
590}
591
592#[cfg(not(unix))]
593fn active_probe(_timeout: Duration) -> TerminalProbeEvidence {
594    TerminalProbeEvidence::default()
595}
596
597#[cfg(feature = "client")]
598/// Converts terminal graphics capabilities into the daemon protobuf form.
599pub fn terminal_graphics_capabilities_to_proto(
600    caps: &TerminalGraphicsCapabilities,
601) -> crate::proto::daemon::TerminalGraphicsCapabilities {
602    crate::proto::daemon::TerminalGraphicsCapabilities {
603        protocols: caps
604            .protocols
605            .iter()
606            .map(graphics_capability_to_proto)
607            .collect(),
608        preferred: caps
609            .preferred
610            .map(proto_graphics_protocol)
611            .unwrap_or(crate::proto::daemon::GraphicsProtocol::Unspecified)
612            as i32,
613    }
614}
615
616#[cfg(feature = "client")]
617/// Converts daemon protobuf terminal graphics capabilities into the Rust form.
618pub fn terminal_graphics_capabilities_from_proto(
619    caps: &crate::proto::daemon::TerminalGraphicsCapabilities,
620) -> TerminalGraphicsCapabilities {
621    let protocols = caps
622        .protocols
623        .iter()
624        .map(graphics_capability_from_proto)
625        .collect();
626    TerminalGraphicsCapabilities {
627        protocols,
628        preferred: graphics_protocol_from_i32(caps.preferred),
629    }
630}
631
632#[cfg(feature = "client")]
633fn graphics_capability_to_proto(
634    capability: &GraphicsCapability,
635) -> crate::proto::daemon::TerminalGraphicsCapability {
636    crate::proto::daemon::TerminalGraphicsCapability {
637        protocol: proto_graphics_protocol(capability.protocol) as i32,
638        status: proto_capability_status(capability.status) as i32,
639        evidence: proto_evidence_strength(capability.evidence) as i32,
640        source: capability.source.clone(),
641        risks: capability.risks.clone(),
642    }
643}
644
645#[cfg(feature = "client")]
646fn graphics_capability_from_proto(
647    capability: &crate::proto::daemon::TerminalGraphicsCapability,
648) -> GraphicsCapability {
649    GraphicsCapability {
650        protocol: graphics_protocol_from_i32(capability.protocol)
651            .unwrap_or(GraphicsProtocol::Sixel),
652        status: capability_status_from_i32(capability.status),
653        evidence: evidence_strength_from_i32(capability.evidence),
654        source: capability.source.clone(),
655        risks: capability.risks.clone(),
656    }
657}
658
659#[cfg(feature = "client")]
660fn proto_graphics_protocol(protocol: GraphicsProtocol) -> crate::proto::daemon::GraphicsProtocol {
661    match protocol {
662        GraphicsProtocol::Sixel => crate::proto::daemon::GraphicsProtocol::Sixel,
663        GraphicsProtocol::Kitty => crate::proto::daemon::GraphicsProtocol::Kitty,
664        GraphicsProtocol::Iterm2File => crate::proto::daemon::GraphicsProtocol::Iterm2File,
665    }
666}
667
668#[cfg(feature = "client")]
669fn graphics_protocol_from_i32(protocol: i32) -> Option<GraphicsProtocol> {
670    match crate::proto::daemon::GraphicsProtocol::try_from(protocol).ok()? {
671        crate::proto::daemon::GraphicsProtocol::Sixel => Some(GraphicsProtocol::Sixel),
672        crate::proto::daemon::GraphicsProtocol::Kitty => Some(GraphicsProtocol::Kitty),
673        crate::proto::daemon::GraphicsProtocol::Iterm2File => Some(GraphicsProtocol::Iterm2File),
674        crate::proto::daemon::GraphicsProtocol::Unspecified => None,
675    }
676}
677
678#[cfg(feature = "client")]
679fn proto_capability_status(status: CapabilityStatus) -> crate::proto::daemon::CapabilityStatus {
680    match status {
681        CapabilityStatus::Supported => crate::proto::daemon::CapabilityStatus::Supported,
682        CapabilityStatus::Unsupported => crate::proto::daemon::CapabilityStatus::Unsupported,
683        CapabilityStatus::Unknown => crate::proto::daemon::CapabilityStatus::Unknown,
684        CapabilityStatus::Blocked => crate::proto::daemon::CapabilityStatus::Blocked,
685    }
686}
687
688#[cfg(feature = "client")]
689fn capability_status_from_i32(status: i32) -> CapabilityStatus {
690    match crate::proto::daemon::CapabilityStatus::try_from(status)
691        .unwrap_or(crate::proto::daemon::CapabilityStatus::Unknown)
692    {
693        crate::proto::daemon::CapabilityStatus::Supported => CapabilityStatus::Supported,
694        crate::proto::daemon::CapabilityStatus::Unsupported => CapabilityStatus::Unsupported,
695        crate::proto::daemon::CapabilityStatus::Unknown
696        | crate::proto::daemon::CapabilityStatus::Unspecified => CapabilityStatus::Unknown,
697        crate::proto::daemon::CapabilityStatus::Blocked => CapabilityStatus::Blocked,
698    }
699}
700
701#[cfg(feature = "client")]
702fn proto_evidence_strength(evidence: EvidenceStrength) -> crate::proto::daemon::EvidenceStrength {
703    match evidence {
704        EvidenceStrength::Probe => crate::proto::daemon::EvidenceStrength::Probe,
705        EvidenceStrength::StrongHostSignal => {
706            crate::proto::daemon::EvidenceStrength::StrongHostSignal
707        }
708        EvidenceStrength::Terminfo => crate::proto::daemon::EvidenceStrength::Terminfo,
709        EvidenceStrength::WeakEnv => crate::proto::daemon::EvidenceStrength::WeakEnv,
710        EvidenceStrength::UserOverride => crate::proto::daemon::EvidenceStrength::UserOverride,
711    }
712}
713
714#[cfg(feature = "client")]
715fn evidence_strength_from_i32(evidence: i32) -> EvidenceStrength {
716    match crate::proto::daemon::EvidenceStrength::try_from(evidence)
717        .unwrap_or(crate::proto::daemon::EvidenceStrength::WeakEnv)
718    {
719        crate::proto::daemon::EvidenceStrength::Probe => EvidenceStrength::Probe,
720        crate::proto::daemon::EvidenceStrength::StrongHostSignal => {
721            EvidenceStrength::StrongHostSignal
722        }
723        crate::proto::daemon::EvidenceStrength::Terminfo => EvidenceStrength::Terminfo,
724        crate::proto::daemon::EvidenceStrength::WeakEnv
725        | crate::proto::daemon::EvidenceStrength::Unspecified => EvidenceStrength::WeakEnv,
726        crate::proto::daemon::EvidenceStrength::UserOverride => EvidenceStrength::UserOverride,
727    }
728}
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733
734    fn input(term: &str, pairs: &[(&str, &str)]) -> TerminalCapabilityInput {
735        let mut env = BTreeMap::new();
736        if !term.is_empty() {
737            env.insert("TERM".into(), term.into());
738        }
739        for (k, v) in pairs {
740            env.insert((*k).into(), (*v).into());
741        }
742        TerminalCapabilityInput {
743            is_tty: true,
744            env,
745            probe: TerminalProbeEvidence::default(),
746        }
747    }
748
749    #[test]
750    fn weak_xterm_does_not_confirm_sixel() {
751        let caps = detect_terminal_capabilities(input("xterm-256color", &[]));
752        let sixel = caps.graphics.by_protocol(GraphicsProtocol::Sixel).unwrap();
753        assert_eq!(sixel.status, CapabilityStatus::Unknown);
754        assert_eq!(sixel.evidence, EvidenceStrength::WeakEnv);
755        assert_eq!(caps.graphics.preferred, None);
756    }
757
758    #[test]
759    fn da1_probe_confirms_sixel() {
760        let mut case = input("xterm-256color", &[]);
761        case.probe.sixel_da1 = Some("\x1b[?62;4;22c".into());
762        let caps = detect_terminal_capabilities(case);
763        let sixel = caps.graphics.by_protocol(GraphicsProtocol::Sixel).unwrap();
764        assert_eq!(sixel.status, CapabilityStatus::Supported);
765        assert_eq!(sixel.evidence, EvidenceStrength::Probe);
766        assert_eq!(caps.graphics.preferred, Some(GraphicsProtocol::Sixel));
767    }
768
769    #[test]
770    fn vt100_da_does_not_confirm_sixel() {
771        assert!(!primary_da_reports_sixel("\x1b[?1;2c"));
772        assert!(!primary_da_reports_sixel("\x1b[?62;22c"));
773    }
774}