Skip to main content

ftui_core/
terminal_capabilities.rs

1#![forbid(unsafe_code)]
2
3//! Terminal capability detection model with tear-free output strategies.
4//!
5//! This module provides detection of terminal capabilities to inform how ftui
6//! behaves on different terminals. Detection is based on environment variables
7//! and known terminal program identification.
8//!
9//! # Capability Profiles (bd-k4lj.2)
10//!
11//! In addition to runtime detection, this module provides predefined terminal
12//! profiles for testing and simulation. Each profile represents a known terminal
13//! configuration with its expected capabilities.
14//!
15//! ## Predefined Profiles
16//!
17//! | Profile | Description |
18//! |---------|-------------|
19//! | `xterm_256color()` | Standard xterm with 256-color support |
20//! | `xterm()` | Basic xterm with 16 colors |
21//! | `vt100()` | VT100 terminal (minimal features) |
22//! | `dumb()` | Dumb terminal (no capabilities) |
23//! | `screen()` | GNU Screen multiplexer |
24//! | `tmux()` | tmux multiplexer |
25//! | `windows_console()` | Windows Console Host |
26//! | `modern()` | Modern terminal with all features |
27//!
28//! ## Profile Builder
29//!
30//! For custom configurations, use [`CapabilityProfileBuilder`]:
31//!
32//! ```
33//! use ftui_core::terminal_capabilities::{CapabilityProfileBuilder, ColorDepth};
34//!
35//! let custom = CapabilityProfileBuilder::new()
36//!     .color_depth(ColorDepth::TrueColor)
37//!     .mouse_sgr(true)
38//!     .build();
39//! ```
40//!
41//! ## Profile Switching
42//!
43//! Profiles can be identified by name for dynamic switching in tests:
44//!
45//! ```
46//! use ftui_core::terminal_capabilities::TerminalCapabilities;
47//!
48//! let profile = TerminalCapabilities::xterm_256color();
49//! assert_eq!(profile.profile_name(), Some("xterm-256color"));
50//! ```
51//!
52//! Override detection in tests by setting `FTUI_TEST_PROFILE` to a known
53//! profile name (for example: `dumb`, `screen`, `tmux`, `windows-console`).
54//!
55//! # Detection Strategy
56//!
57//! We detect capabilities using:
58//! - `COLORTERM`: truecolor/24bit support
59//! - `TERM`: terminal type (kitty, xterm-256color, etc.)
60//! - `TERM_PROGRAM`: specific terminal (iTerm.app, WezTerm, Alacritty, Ghostty)
61//! - `NO_COLOR`: de-facto standard for disabling color
62//! - `TMUX`, `STY`, `ZELLIJ`, `WEZTERM_UNIX_SOCKET`, `WEZTERM_PANE`: multiplexer detection
63//! - `KITTY_WINDOW_ID`: Kitty terminal detection
64//!
65//! # Invariants (bd-1rz0.6)
66//!
67//! 1. **Sync-output safety**: `use_sync_output()` returns `false` for any
68//!    multiplexer environment (tmux, screen, zellij, wezterm mux) because CSI ?2026 h/l
69//!    sequences are unreliable through passthrough. Detection also hard-disables
70//!    synchronized output in WezTerm sessions as a safety fallback.
71//!
72//! 2. **Scroll region safety**: `use_scroll_region()` returns `false` in
73//!    multiplexers because DECSTBM behavior varies across versions.
74//!
75//! 3. **Capability monotonicity**: Once a capability is detected as absent,
76//!    it remains absent for the session. We never upgrade capabilities.
77//!
78//! 4. **Fallback ordering**: Capabilities degrade in this order:
79//!    `sync_output` → `scroll_region` → `overlay_redraw`
80//!
81//! 5. **Detection determinism**: Given the same environment variables,
82//!    `TerminalCapabilities::detect()` always produces the same result.
83//!
84//! # Failure Modes
85//!
86//! | Mode | Condition | Fallback Behavior |
87//! |------|-----------|-------------------|
88//! | Dumb terminal | `TERM=dumb` or empty | All advanced features disabled |
89//! | Unknown mux | Nested or chained mux | Conservative: disable sync/scroll |
90//! | False positive mux | Non-mux with `TMUX` env | Unnecessary fallback (safe) |
91//! | Missing env vars | Env cleared by parent | Conservative defaults |
92//! | Conflicting signals | e.g., modern term inside screen | Mux detection wins |
93//!
94//! # Decision Rules
95//!
96//! The policy methods (`use_sync_output()`, `use_scroll_region()`, etc.)
97//! implement an evidence-based decision rule:
98//!
99//! ```text
100//! IF in_any_mux() THEN disable_advanced_features
101//! ELSE IF capability_detected THEN enable_feature
102//! ELSE use_conservative_default
103//! ```
104//!
105//! This fail-safe approach means false negatives (disabling a feature that
106//! would work) are preferred over false positives (enabling a feature that
107//! corrupts output).
108//!
109//! # Future: Runtime Probing
110//!
111//! Optional feature-gated probing may be added for:
112//! - Device attribute queries (DA)
113//! - OSC queries for capabilities
114//! - Must be bounded with timeouts
115
116use std::env;
117use std::ffi::OsString;
118use std::str::FromStr;
119
120fn normalize_terminal_env_value(value: &str) -> String {
121    value.trim().to_ascii_lowercase()
122}
123
124fn is_dumb_terminal(term: &str, windows_terminal: bool) -> bool {
125    term == "dumb" || (term.is_empty() && !windows_terminal)
126}
127
128fn colorterm_declares_true_color(colorterm: &str) -> bool {
129    matches!(colorterm, "truecolor" | "24bit")
130}
131
132fn term_declares_true_color(term: &str) -> bool {
133    let direct_color = term.rsplit_once("-direct").is_some_and(|(base, suffix)| {
134        !base.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit())
135    });
136
137    direct_color || term.ends_with("-truecolor") || term == "24bit" || term.ends_with("-24bit")
138}
139
140/// Maximum color fidelity the terminal may receive.
141///
142/// This is a single ordered capability rather than independent booleans so
143/// impossible states such as "truecolor but not 256-color" cannot exist.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
145#[repr(u8)]
146pub enum ColorDepth {
147    /// Do not emit foreground or background color sequences.
148    #[default]
149    Mono,
150    /// Standard 16-color ANSI palette.
151    Ansi16,
152    /// Extended 256-color ANSI palette.
153    Ansi256,
154    /// Full 24-bit RGB color.
155    TrueColor,
156}
157
158impl ColorDepth {
159    /// Detect color depth using the canonical terminal capability detector.
160    #[must_use]
161    pub fn detect() -> Self {
162        TerminalCapabilities::detect().color_depth
163    }
164
165    /// Detect color depth from explicit environment values.
166    ///
167    /// `NO_COLOR` presence wins over every terminal signal. `TERM=dumb`,
168    /// `TERM=vt100`, and a missing/empty `TERM` are monochrome. Direct-color
169    /// TERM variants (`*-direct[digits]`, `*-truecolor`, and `*-24bit`) receive
170    /// truecolor. Plain xterm and other non-dumb terminals conservatively
171    /// receive ANSI16.
172    #[must_use]
173    pub fn detect_from_env(
174        no_color: Option<&str>,
175        colorterm: Option<&str>,
176        term: Option<&str>,
177    ) -> Self {
178        let term = normalize_terminal_env_value(term.unwrap_or_default());
179        let colorterm = normalize_terminal_env_value(colorterm.unwrap_or_default());
180        Self::detect_normalized(
181            no_color.is_some(),
182            is_dumb_terminal(&term, false),
183            &colorterm,
184            &term,
185            false,
186        )
187    }
188
189    fn detect_normalized(
190        no_color: bool,
191        is_dumb: bool,
192        colorterm: &str,
193        term: &str,
194        inferred_true_color: bool,
195    ) -> Self {
196        if no_color || is_dumb || term == "vt100" {
197            Self::Mono
198        } else if term == "linux" {
199            Self::Ansi16
200        } else if colorterm_declares_true_color(colorterm)
201            || term_declares_true_color(term)
202            || inferred_true_color
203        {
204            Self::TrueColor
205        } else if term.contains("256color") || term.contains("256") {
206            Self::Ansi256
207        } else {
208            Self::Ansi16
209        }
210    }
211
212    /// Stable identifier used in diagnostics and evidence logs.
213    #[must_use]
214    pub const fn as_str(self) -> &'static str {
215        match self {
216            Self::Mono => "mono",
217            Self::Ansi16 => "ansi16",
218            Self::Ansi256 => "ansi256",
219            Self::TrueColor => "truecolor",
220        }
221    }
222
223    /// Whether any color sequences may be emitted.
224    #[must_use]
225    pub const fn supports_color(self) -> bool {
226        !matches!(self, Self::Mono)
227    }
228
229    /// Whether the terminal supports at least the 256-color palette.
230    #[must_use]
231    pub const fn supports_256_colors(self) -> bool {
232        matches!(self, Self::Ansi256 | Self::TrueColor)
233    }
234
235    /// Whether the terminal supports 24-bit RGB color.
236    #[must_use]
237    pub const fn supports_true_color(self) -> bool {
238        matches!(self, Self::TrueColor)
239    }
240}
241
242impl std::fmt::Display for ColorDepth {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        f.write_str(self.as_str())
245    }
246}
247
248#[derive(Debug, Clone)]
249struct DetectInputs {
250    no_color: bool,
251    term: String,
252    term_program: String,
253    colorterm: String,
254    in_tmux: bool,
255    in_screen: bool,
256    in_zellij: bool,
257    wezterm_unix_socket: bool,
258    wezterm_pane: bool,
259    wezterm_executable: bool,
260    kitty_window_id: bool,
261    wt_session: bool,
262}
263
264impl DetectInputs {
265    fn from_env() -> Self {
266        Self::from_env_with(|key| env::var_os(key))
267    }
268
269    fn from_env_with<F>(get_env: F) -> Self
270    where
271        F: Fn(&str) -> Option<OsString>,
272    {
273        let text = |key| {
274            get_env(key)
275                .and_then(|value| value.into_string().ok())
276                .unwrap_or_default()
277        };
278        let utf8_present = |key| get_env(key).is_some_and(|value| value.into_string().is_ok());
279
280        Self {
281            no_color: get_env("NO_COLOR").is_some(),
282            term: text("TERM"),
283            term_program: text("TERM_PROGRAM"),
284            colorterm: text("COLORTERM"),
285            in_tmux: utf8_present("TMUX"),
286            in_screen: utf8_present("STY"),
287            in_zellij: utf8_present("ZELLIJ"),
288            wezterm_unix_socket: utf8_present("WEZTERM_UNIX_SOCKET"),
289            wezterm_pane: utf8_present("WEZTERM_PANE"),
290            wezterm_executable: utf8_present("WEZTERM_EXECUTABLE"),
291            kitty_window_id: utf8_present("KITTY_WINDOW_ID"),
292            wt_session: utf8_present("WT_SESSION"),
293        }
294    }
295}
296
297/// Known modern terminal programs that support advanced features.
298const MODERN_TERMINALS: &[&str] = &[
299    "iTerm.app",
300    "WezTerm",
301    "Alacritty",
302    "Ghostty",
303    "kitty",
304    "Rio",
305    "Hyper",
306    "Contour",
307    "vscode",
308    "Black Box",
309];
310
311/// Terminals known to implement the Kitty keyboard protocol.
312const KITTY_KEYBOARD_TERMINALS: &[&str] = &[
313    "iTerm.app",
314    "WezTerm",
315    "Alacritty",
316    "Ghostty",
317    "Rio",
318    "kitty",
319    "foot",
320    "Black Box",
321];
322
323/// Terminal programs that support synchronized output (DEC 2026).
324///
325/// NOTE: WezTerm is intentionally excluded as a safety fallback due to observed
326/// mux/terminal instability around DEC ?2026 h/l in real-world setups.
327const SYNC_OUTPUT_TERMINALS: &[&str] = &["Alacritty", "Ghostty", "kitty", "Contour"];
328
329/// Known terminal profile identifiers.
330///
331/// These names correspond to predefined capability configurations.
332#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
333pub enum TerminalProfile {
334    /// Modern terminal with all features (WezTerm, Alacritty, Ghostty, etc.)
335    Modern,
336    /// xterm with 256-color support
337    Xterm256Color,
338    /// Basic xterm with 16 colors
339    Xterm,
340    /// VT100 terminal (minimal)
341    Vt100,
342    /// Dumb terminal (no capabilities)
343    Dumb,
344    /// GNU Screen multiplexer
345    Screen,
346    /// tmux multiplexer
347    Tmux,
348    /// Zellij multiplexer
349    Zellij,
350    /// Windows Console Host
351    WindowsConsole,
352    /// Kitty terminal
353    Kitty,
354    /// Linux console (16 colors, basic features)
355    LinuxConsole,
356    /// Custom profile (user-defined)
357    Custom,
358    /// Auto-detected from environment
359    Detected,
360}
361
362impl TerminalProfile {
363    /// Get the profile name as a string.
364    #[must_use]
365    pub const fn as_str(&self) -> &'static str {
366        match self {
367            Self::Modern => "modern",
368            Self::Xterm256Color => "xterm-256color",
369            Self::Xterm => "xterm",
370            Self::Vt100 => "vt100",
371            Self::Dumb => "dumb",
372            Self::Screen => "screen",
373            Self::Tmux => "tmux",
374            Self::Zellij => "zellij",
375            Self::WindowsConsole => "windows-console",
376            Self::Kitty => "kitty",
377            Self::LinuxConsole => "linux",
378            Self::Custom => "custom",
379            Self::Detected => "detected",
380        }
381    }
382
383    /// Get all known profile identifiers (excluding Custom and Detected).
384    #[must_use]
385    pub const fn all_predefined() -> &'static [Self] {
386        &[
387            Self::Modern,
388            Self::Xterm256Color,
389            Self::Xterm,
390            Self::Vt100,
391            Self::Dumb,
392            Self::Screen,
393            Self::Tmux,
394            Self::Zellij,
395            Self::WindowsConsole,
396            Self::Kitty,
397            Self::LinuxConsole,
398        ]
399    }
400}
401
402impl std::str::FromStr for TerminalProfile {
403    type Err = ();
404
405    fn from_str(s: &str) -> Result<Self, Self::Err> {
406        match s.to_lowercase().as_str() {
407            "modern" => Ok(Self::Modern),
408            "xterm-256color" | "xterm256color" | "xterm-256" => Ok(Self::Xterm256Color),
409            "xterm" => Ok(Self::Xterm),
410            "vt100" => Ok(Self::Vt100),
411            "dumb" => Ok(Self::Dumb),
412            "screen" | "screen-256color" => Ok(Self::Screen),
413            "tmux" | "tmux-256color" => Ok(Self::Tmux),
414            "zellij" => Ok(Self::Zellij),
415            "windows-console" | "windows" | "conhost" => Ok(Self::WindowsConsole),
416            "kitty" | "xterm-kitty" => Ok(Self::Kitty),
417            "linux" | "linux-console" => Ok(Self::LinuxConsole),
418            "custom" => Ok(Self::Custom),
419            "detected" | "auto" => Ok(Self::Detected),
420            _ => Err(()),
421        }
422    }
423}
424
425impl std::fmt::Display for TerminalProfile {
426    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
427        write!(f, "{}", self.as_str())
428    }
429}
430
431/// Terminal capability model.
432///
433/// This struct describes what features a terminal supports. Use [`detect`](Self::detect)
434/// to auto-detect from the environment, or [`basic`](Self::basic) for a minimal fallback.
435///
436/// # Predefined Profiles
437///
438/// For testing and simulation, use predefined profiles:
439/// - [`modern()`](Self::modern) - Full-featured modern terminal
440/// - [`xterm_256color()`](Self::xterm_256color) - Standard xterm with 256 colors
441/// - [`xterm()`](Self::xterm) - Basic xterm with 16 colors
442/// - [`vt100()`](Self::vt100) - VT100 terminal (minimal)
443/// - [`dumb()`](Self::dumb) - No capabilities
444/// - [`screen()`](Self::screen) - GNU Screen
445/// - [`tmux()`](Self::tmux) - tmux multiplexer
446/// - [`kitty()`](Self::kitty) - Kitty terminal
447#[derive(Debug, Clone, Copy, PartialEq, Eq)]
448pub struct TerminalCapabilities {
449    // Profile identification
450    profile: TerminalProfile,
451
452    // Color support
453    /// Maximum supported color fidelity.
454    pub color_depth: ColorDepth,
455
456    // Glyph support
457    /// Unicode box-drawing support.
458    pub unicode_box_drawing: bool,
459    /// Emoji glyph support.
460    pub unicode_emoji: bool,
461    /// Double-width glyph support (CJK/emoji).
462    pub double_width: bool,
463
464    // Advanced features
465    /// Synchronized output (DEC mode 2026) to reduce flicker.
466    pub sync_output: bool,
467    /// OSC 8 hyperlinks support.
468    pub osc8_hyperlinks: bool,
469    /// Scroll region support (DECSTBM).
470    pub scroll_region: bool,
471
472    // Multiplexer detection
473    /// Running inside tmux.
474    pub in_tmux: bool,
475    /// Running inside GNU screen.
476    pub in_screen: bool,
477    /// Running inside Zellij.
478    pub in_zellij: bool,
479    /// Running inside a WezTerm mux-served session (or any WezTerm window).
480    ///
481    /// Detected via `WEZTERM_UNIX_SOCKET` / `WEZTERM_PANE` /
482    /// `WEZTERM_EXECUTABLE`, and — conservatively — via WezTerm identity in
483    /// `TERM_PROGRAM` or `TERM`, because mux sessions do not always preserve
484    /// the `WEZTERM_*` markers across shell launch paths. This deliberately
485    /// fires for every WezTerm window, mux-served or not (fail-safe).
486    pub in_wezterm_mux: bool,
487
488    // Input features
489    /// Kitty keyboard protocol support.
490    pub kitty_keyboard: bool,
491    /// Focus event reporting support.
492    pub focus_events: bool,
493    /// Bracketed paste mode support.
494    pub bracketed_paste: bool,
495    /// SGR mouse protocol support.
496    pub mouse_sgr: bool,
497
498    // Optional features
499    /// OSC 52 clipboard support (best-effort, security restricted in some terminals).
500    pub osc52_clipboard: bool,
501}
502
503impl Default for TerminalCapabilities {
504    fn default() -> Self {
505        Self::basic()
506    }
507}
508
509// ============================================================================
510// Predefined Capability Profiles (bd-k4lj.2)
511// ============================================================================
512
513impl TerminalCapabilities {
514    // ── Profile Identification ─────────────────────────────────────────
515
516    /// Get the profile identifier for this capability set.
517    #[must_use]
518    pub const fn profile(&self) -> TerminalProfile {
519        self.profile
520    }
521
522    /// Get the profile name as a string.
523    ///
524    /// Returns `None` for detected capabilities (use [`profile()`](Self::profile)
525    /// to distinguish between profiles).
526    #[must_use]
527    pub fn profile_name(&self) -> Option<&'static str> {
528        match self.profile {
529            TerminalProfile::Detected => None,
530            p => Some(p.as_str()),
531        }
532    }
533
534    /// Create capabilities from a profile identifier.
535    #[must_use]
536    pub fn from_profile(profile: TerminalProfile) -> Self {
537        match profile {
538            TerminalProfile::Modern => Self::modern(),
539            TerminalProfile::Xterm256Color => Self::xterm_256color(),
540            TerminalProfile::Xterm => Self::xterm(),
541            TerminalProfile::Vt100 => Self::vt100(),
542            TerminalProfile::Dumb => Self::dumb(),
543            TerminalProfile::Screen => Self::screen(),
544            TerminalProfile::Tmux => Self::tmux(),
545            TerminalProfile::Zellij => Self::zellij(),
546            TerminalProfile::WindowsConsole => Self::windows_console(),
547            TerminalProfile::Kitty => Self::kitty(),
548            TerminalProfile::LinuxConsole => Self::linux_console(),
549            TerminalProfile::Custom => {
550                // Same conservative capability set as basic(), but stamped
551                // with the requested profile so from_profile round-trips.
552                let mut caps = Self::basic();
553                caps.profile = TerminalProfile::Custom;
554                caps
555            }
556            TerminalProfile::Detected => Self::detect(),
557        }
558    }
559
560    // ── Predefined Profiles ────────────────────────────────────────────
561
562    /// Modern terminal with all features enabled.
563    ///
564    /// Represents terminals like WezTerm, Alacritty, Ghostty, Kitty, iTerm2.
565    /// All advanced features are enabled.
566    #[must_use]
567    pub const fn modern() -> Self {
568        Self {
569            profile: TerminalProfile::Modern,
570            color_depth: ColorDepth::TrueColor,
571            unicode_box_drawing: true,
572            unicode_emoji: true,
573            double_width: true,
574            sync_output: true,
575            osc8_hyperlinks: true,
576            scroll_region: true,
577            in_tmux: false,
578            in_screen: false,
579            in_zellij: false,
580            in_wezterm_mux: false,
581            kitty_keyboard: true,
582            focus_events: true,
583            bracketed_paste: true,
584            mouse_sgr: true,
585            osc52_clipboard: true,
586        }
587    }
588
589    /// xterm with 256-color support.
590    ///
591    /// Standard xterm-256color profile with common features.
592    /// No true color, no sync output, no hyperlinks.
593    #[must_use]
594    pub const fn xterm_256color() -> Self {
595        Self {
596            profile: TerminalProfile::Xterm256Color,
597            color_depth: ColorDepth::Ansi256,
598            unicode_box_drawing: true,
599            unicode_emoji: true,
600            double_width: true,
601            sync_output: false,
602            osc8_hyperlinks: false,
603            scroll_region: true,
604            in_tmux: false,
605            in_screen: false,
606            in_zellij: false,
607            in_wezterm_mux: false,
608            kitty_keyboard: false,
609            focus_events: false,
610            bracketed_paste: true,
611            mouse_sgr: true,
612            osc52_clipboard: false,
613        }
614    }
615
616    /// Basic xterm with 16 colors only.
617    ///
618    /// Minimal xterm without 256-color or advanced features.
619    #[must_use]
620    pub const fn xterm() -> Self {
621        Self {
622            profile: TerminalProfile::Xterm,
623            color_depth: ColorDepth::Ansi16,
624            unicode_box_drawing: true,
625            unicode_emoji: false,
626            double_width: true,
627            sync_output: false,
628            osc8_hyperlinks: false,
629            scroll_region: true,
630            in_tmux: false,
631            in_screen: false,
632            in_zellij: false,
633            in_wezterm_mux: false,
634            kitty_keyboard: false,
635            focus_events: false,
636            bracketed_paste: true,
637            mouse_sgr: true,
638            osc52_clipboard: false,
639        }
640    }
641
642    /// VT100 terminal (minimal capabilities).
643    ///
644    /// Classic VT100 with basic cursor control, no colors.
645    #[must_use]
646    pub const fn vt100() -> Self {
647        Self {
648            profile: TerminalProfile::Vt100,
649            color_depth: ColorDepth::Mono,
650            unicode_box_drawing: false,
651            unicode_emoji: false,
652            double_width: false,
653            sync_output: false,
654            osc8_hyperlinks: false,
655            scroll_region: true,
656            in_tmux: false,
657            in_screen: false,
658            in_zellij: false,
659            in_wezterm_mux: false,
660            kitty_keyboard: false,
661            focus_events: false,
662            bracketed_paste: false,
663            mouse_sgr: false,
664            osc52_clipboard: false,
665        }
666    }
667
668    /// Dumb terminal with no capabilities.
669    ///
670    /// Alias for [`basic()`](Self::basic) with the Dumb profile identifier.
671    #[must_use]
672    pub const fn dumb() -> Self {
673        Self {
674            profile: TerminalProfile::Dumb,
675            color_depth: ColorDepth::Mono,
676            unicode_box_drawing: false,
677            unicode_emoji: false,
678            double_width: false,
679            sync_output: false,
680            osc8_hyperlinks: false,
681            scroll_region: false,
682            in_tmux: false,
683            in_screen: false,
684            in_zellij: false,
685            in_wezterm_mux: false,
686            kitty_keyboard: false,
687            focus_events: false,
688            bracketed_paste: false,
689            mouse_sgr: false,
690            osc52_clipboard: false,
691        }
692    }
693
694    /// GNU Screen multiplexer.
695    ///
696    /// Screen with 256 colors but multiplexer-safe settings.
697    /// Sync output and scroll region disabled for passthrough safety.
698    #[must_use]
699    pub const fn screen() -> Self {
700        Self {
701            profile: TerminalProfile::Screen,
702            color_depth: ColorDepth::Ansi256,
703            unicode_box_drawing: true,
704            unicode_emoji: true,
705            double_width: true,
706            sync_output: false,
707            osc8_hyperlinks: false,
708            scroll_region: true,
709            in_tmux: false,
710            in_screen: true,
711            in_zellij: false,
712            in_wezterm_mux: false,
713            kitty_keyboard: false,
714            focus_events: false,
715            bracketed_paste: true,
716            mouse_sgr: true,
717            osc52_clipboard: false,
718        }
719    }
720
721    /// tmux multiplexer.
722    ///
723    /// tmux with 256 colors and multiplexer detection.
724    /// Advanced features disabled for passthrough safety.
725    #[must_use]
726    pub const fn tmux() -> Self {
727        Self {
728            profile: TerminalProfile::Tmux,
729            color_depth: ColorDepth::Ansi256,
730            unicode_box_drawing: true,
731            unicode_emoji: true,
732            double_width: true,
733            sync_output: false,
734            osc8_hyperlinks: false,
735            scroll_region: true,
736            in_tmux: true,
737            in_screen: false,
738            in_zellij: false,
739            in_wezterm_mux: false,
740            kitty_keyboard: false,
741            focus_events: false,
742            bracketed_paste: true,
743            mouse_sgr: true,
744            osc52_clipboard: false,
745        }
746    }
747
748    /// Zellij multiplexer.
749    ///
750    /// Zellij with true color (it has better passthrough than tmux/screen).
751    #[must_use]
752    pub const fn zellij() -> Self {
753        Self {
754            profile: TerminalProfile::Zellij,
755            color_depth: ColorDepth::TrueColor,
756            unicode_box_drawing: true,
757            unicode_emoji: true,
758            double_width: true,
759            sync_output: false,
760            osc8_hyperlinks: false,
761            scroll_region: true,
762            in_tmux: false,
763            in_screen: false,
764            in_zellij: true,
765            in_wezterm_mux: false,
766            kitty_keyboard: false,
767            focus_events: true,
768            bracketed_paste: true,
769            mouse_sgr: true,
770            osc52_clipboard: false,
771        }
772    }
773
774    /// Windows Console Host.
775    ///
776    /// Windows Terminal with good color support but some quirks.
777    #[must_use]
778    pub const fn windows_console() -> Self {
779        Self {
780            profile: TerminalProfile::WindowsConsole,
781            color_depth: ColorDepth::TrueColor,
782            unicode_box_drawing: true,
783            unicode_emoji: true,
784            double_width: true,
785            sync_output: false,
786            osc8_hyperlinks: true,
787            scroll_region: true,
788            in_tmux: false,
789            in_screen: false,
790            in_zellij: false,
791            in_wezterm_mux: false,
792            kitty_keyboard: false,
793            focus_events: true,
794            bracketed_paste: true,
795            mouse_sgr: true,
796            osc52_clipboard: true,
797        }
798    }
799
800    /// Kitty terminal.
801    ///
802    /// Kitty with full feature set including keyboard protocol.
803    #[must_use]
804    pub const fn kitty() -> Self {
805        Self {
806            profile: TerminalProfile::Kitty,
807            color_depth: ColorDepth::TrueColor,
808            unicode_box_drawing: true,
809            unicode_emoji: true,
810            double_width: true,
811            sync_output: true,
812            osc8_hyperlinks: true,
813            scroll_region: true,
814            in_tmux: false,
815            in_screen: false,
816            in_zellij: false,
817            in_wezterm_mux: false,
818            kitty_keyboard: true,
819            focus_events: true,
820            bracketed_paste: true,
821            mouse_sgr: true,
822            osc52_clipboard: true,
823        }
824    }
825
826    /// Linux console (framebuffer console).
827    ///
828    /// Linux console with ANSI 16-color and basic single-width glyph support.
829    #[must_use]
830    pub const fn linux_console() -> Self {
831        Self {
832            profile: TerminalProfile::LinuxConsole,
833            color_depth: ColorDepth::Ansi16,
834            unicode_box_drawing: true,
835            unicode_emoji: false,
836            double_width: false,
837            sync_output: false,
838            osc8_hyperlinks: false,
839            scroll_region: true,
840            in_tmux: false,
841            in_screen: false,
842            in_zellij: false,
843            in_wezterm_mux: false,
844            kitty_keyboard: false,
845            focus_events: false,
846            bracketed_paste: true,
847            mouse_sgr: true,
848            osc52_clipboard: false,
849        }
850    }
851
852    /// Create a builder for custom capability profiles.
853    ///
854    /// Start with all capabilities disabled and enable what you need.
855    pub fn builder() -> CapabilityProfileBuilder {
856        CapabilityProfileBuilder::new()
857    }
858}
859
860// ============================================================================
861// Capability Profile Builder (bd-k4lj.2)
862// ============================================================================
863
864/// Builder for custom terminal capability profiles.
865///
866/// Enables fine-grained control over capability configuration for testing
867/// and simulation purposes.
868///
869/// # Example
870///
871/// ```
872/// use ftui_core::terminal_capabilities::{CapabilityProfileBuilder, ColorDepth};
873///
874/// let profile = CapabilityProfileBuilder::new()
875///     .color_depth(ColorDepth::TrueColor)
876///     .mouse_sgr(true)
877///     .bracketed_paste(true)
878///     .build();
879///
880/// assert_eq!(profile.color_depth, ColorDepth::TrueColor);
881/// ```
882#[derive(Debug, Clone)]
883#[must_use]
884pub struct CapabilityProfileBuilder {
885    caps: TerminalCapabilities,
886}
887
888impl Default for CapabilityProfileBuilder {
889    fn default() -> Self {
890        Self::new()
891    }
892}
893
894impl CapabilityProfileBuilder {
895    /// Create a new builder with all capabilities disabled.
896    pub fn new() -> Self {
897        Self {
898            caps: TerminalCapabilities {
899                profile: TerminalProfile::Custom,
900                color_depth: ColorDepth::Mono,
901                unicode_box_drawing: false,
902                unicode_emoji: false,
903                double_width: false,
904                sync_output: false,
905                osc8_hyperlinks: false,
906                scroll_region: false,
907                in_tmux: false,
908                in_screen: false,
909                in_zellij: false,
910                in_wezterm_mux: false,
911                kitty_keyboard: false,
912                focus_events: false,
913                bracketed_paste: false,
914                mouse_sgr: false,
915                osc52_clipboard: false,
916            },
917        }
918    }
919
920    /// Start from an existing profile.
921    pub fn from_profile(profile: TerminalProfile) -> Self {
922        let mut caps = TerminalCapabilities::from_profile(profile);
923        caps.profile = TerminalProfile::Custom;
924        Self { caps }
925    }
926
927    /// Build the final capability set.
928    #[must_use]
929    pub fn build(self) -> TerminalCapabilities {
930        self.caps
931    }
932
933    // ── Color Capabilities ─────────────────────────────────────────────
934
935    /// Set maximum color fidelity.
936    pub const fn color_depth(mut self, depth: ColorDepth) -> Self {
937        self.caps.color_depth = depth;
938        self
939    }
940
941    // ── Advanced Features ──────────────────────────────────────────────
942
943    /// Set synchronized output (DEC mode 2026) support.
944    pub const fn sync_output(mut self, enabled: bool) -> Self {
945        self.caps.sync_output = enabled;
946        self
947    }
948
949    /// Set OSC 8 hyperlinks support.
950    pub const fn osc8_hyperlinks(mut self, enabled: bool) -> Self {
951        self.caps.osc8_hyperlinks = enabled;
952        self
953    }
954
955    /// Set scroll region (DECSTBM) support.
956    pub const fn scroll_region(mut self, enabled: bool) -> Self {
957        self.caps.scroll_region = enabled;
958        self
959    }
960
961    // ── Multiplexer Flags ──────────────────────────────────────────────
962
963    /// Set whether running inside tmux.
964    pub const fn in_tmux(mut self, enabled: bool) -> Self {
965        self.caps.in_tmux = enabled;
966        self
967    }
968
969    /// Set whether running inside GNU screen.
970    pub const fn in_screen(mut self, enabled: bool) -> Self {
971        self.caps.in_screen = enabled;
972        self
973    }
974
975    /// Set whether running inside Zellij.
976    pub const fn in_zellij(mut self, enabled: bool) -> Self {
977        self.caps.in_zellij = enabled;
978        self
979    }
980
981    /// Set whether running inside a WezTerm mux-served session.
982    pub const fn in_wezterm_mux(mut self, enabled: bool) -> Self {
983        self.caps.in_wezterm_mux = enabled;
984        self
985    }
986
987    // ── Input Features ─────────────────────────────────────────────────
988
989    /// Set Kitty keyboard protocol support.
990    pub const fn kitty_keyboard(mut self, enabled: bool) -> Self {
991        self.caps.kitty_keyboard = enabled;
992        self
993    }
994
995    /// Set focus event reporting support.
996    pub const fn focus_events(mut self, enabled: bool) -> Self {
997        self.caps.focus_events = enabled;
998        self
999    }
1000
1001    /// Set bracketed paste mode support.
1002    pub const fn bracketed_paste(mut self, enabled: bool) -> Self {
1003        self.caps.bracketed_paste = enabled;
1004        self
1005    }
1006
1007    /// Set SGR mouse protocol support.
1008    pub const fn mouse_sgr(mut self, enabled: bool) -> Self {
1009        self.caps.mouse_sgr = enabled;
1010        self
1011    }
1012
1013    // ── Optional Features ──────────────────────────────────────────────
1014
1015    /// Set OSC 52 clipboard support.
1016    pub const fn osc52_clipboard(mut self, enabled: bool) -> Self {
1017        self.caps.osc52_clipboard = enabled;
1018        self
1019    }
1020}
1021
1022impl TerminalCapabilities {
1023    /// Detect terminal capabilities from the environment.
1024    ///
1025    /// This examines environment variables to determine what features the
1026    /// current terminal supports. When in doubt, capabilities are disabled
1027    /// for safety.
1028    #[must_use]
1029    pub fn detect() -> Self {
1030        let value = env::var("FTUI_TEST_PROFILE").ok();
1031        Self::detect_with_test_profile_override(value.as_deref())
1032    }
1033
1034    fn detect_with_test_profile_override(value: Option<&str>) -> Self {
1035        if let Some(value) = value
1036            && let Ok(profile) = TerminalProfile::from_str(value.trim())
1037            && profile != TerminalProfile::Detected
1038        {
1039            return Self::from_profile(profile);
1040        }
1041        let env = DetectInputs::from_env();
1042        Self::detect_from_inputs(&env)
1043    }
1044
1045    fn detect_from_inputs(env: &DetectInputs) -> Self {
1046        let term = normalize_terminal_env_value(&env.term);
1047        let term_program = normalize_terminal_env_value(&env.term_program);
1048        let colorterm = normalize_terminal_env_value(&env.colorterm);
1049
1050        // Multiplexer detection. The $TMUX/$STY env vars do not survive
1051        // ssh/sudo/container boundaries, but the mux's TERM value does
1052        // (TERM=tmux-256color / screen-256color). Treat TERM identity as
1053        // conservative mux evidence — the same fail-safe reasoning applied
1054        // to WezTerm below — so use_scroll_region() etc. stay disabled
1055        // inside a mux pane reached over ssh (doc invariant: mux wins).
1056        let in_tmux = env.in_tmux || term.starts_with("tmux");
1057        let in_screen = env.in_screen || term.starts_with("screen");
1058        let in_zellij = env.in_zellij;
1059
1060        // WezTerm mux sessions may not always preserve WEZTERM_* env markers
1061        // across shell launch paths. Treat explicit WezTerm identity itself as
1062        // conservative mux evidence so policy remains fail-safe.
1063        let term_program_is_wezterm = term_program.contains("wezterm");
1064        let term_is_wezterm = term.contains("wezterm");
1065        let in_wezterm_mux = term_program_is_wezterm
1066            || term_is_wezterm
1067            || env.wezterm_unix_socket
1068            || env.wezterm_pane
1069            || env.wezterm_executable;
1070        let in_any_mux = in_tmux || in_screen || in_zellij || in_wezterm_mux;
1071
1072        // Windows Terminal detection
1073        let is_windows_terminal = env.wt_session;
1074
1075        // Check for dumb terminal
1076        //
1077        // NOTE: Windows Terminal often omits TERM; treat it as non-dumb when
1078        // WT_SESSION is present so we don't incorrectly disable features.
1079        let is_dumb = is_dumb_terminal(&term, is_windows_terminal);
1080
1081        // Kitty detection
1082        let is_kitty = env.kitty_window_id || term.contains("kitty");
1083
1084        // Check if running in a modern terminal
1085        let is_modern_terminal = MODERN_TERMINALS.iter().any(|t| {
1086            let t_lower = t.to_ascii_lowercase();
1087            term_program.contains(&t_lower) || term.contains(&t_lower)
1088        }) || is_windows_terminal;
1089
1090        let color_depth = ColorDepth::detect_normalized(
1091            env.no_color,
1092            is_dumb,
1093            &colorterm,
1094            &term,
1095            is_modern_terminal || is_kitty,
1096        );
1097
1098        // Keep WezTerm inference conservative: any explicit WezTerm marker
1099        // should disable risky capabilities even if terminal identity is mixed.
1100        let is_wezterm = term_program_is_wezterm || term_is_wezterm || env.wezterm_executable;
1101
1102        // Synchronized output detection
1103        let sync_output = !is_dumb
1104            && !is_wezterm
1105            && (is_kitty
1106                || SYNC_OUTPUT_TERMINALS.iter().any(|t| {
1107                    let t_lower = t.to_ascii_lowercase();
1108                    term_program.contains(&t_lower)
1109                }));
1110
1111        // OSC 8 hyperlinks detection
1112        let osc8_hyperlinks = !env.no_color && !is_dumb && is_modern_terminal;
1113
1114        // Scroll region support (broadly available except dumb)
1115        let scroll_region = !is_dumb;
1116
1117        // Kitty keyboard protocol (kitty + other compatible terminals).
1118        // Gated on !is_dumb like every other capability: TERM=dumb with an
1119        // inherited TERM_PROGRAM (e.g. Emacs shell inside kitty/iTerm2) must
1120        // not receive CSI > u progressive-enhancement sequences.
1121        let kitty_keyboard = !is_dumb
1122            && (is_kitty
1123                || KITTY_KEYBOARD_TERMINALS.iter().any(|t| {
1124                    let t_lower = t.to_ascii_lowercase();
1125                    term_program.contains(&t_lower) || term.contains(&t_lower)
1126                }));
1127
1128        // Focus events (available in most modern terminals)
1129        let focus_events = !is_dumb && (is_modern_terminal || is_kitty);
1130
1131        // Bracketed paste (broadly available except dumb)
1132        let bracketed_paste = !is_dumb;
1133
1134        // SGR mouse (broadly available except dumb)
1135        let mouse_sgr = !is_dumb;
1136
1137        // OSC 52 clipboard (security restricted in multiplexers by default)
1138        let osc52_clipboard = !is_dumb && !in_any_mux && (is_modern_terminal || is_kitty);
1139
1140        // Unicode glyph support (assume available in modern terminals)
1141        let unicode_box_drawing = !is_dumb;
1142        let unicode_emoji = !is_dumb && (is_modern_terminal || is_kitty);
1143        let double_width = !is_dumb && term != "linux";
1144
1145        Self {
1146            profile: TerminalProfile::Detected,
1147            color_depth,
1148            unicode_box_drawing,
1149            unicode_emoji,
1150            double_width,
1151            sync_output,
1152            osc8_hyperlinks,
1153            scroll_region,
1154            in_tmux,
1155            in_screen,
1156            in_zellij,
1157            in_wezterm_mux,
1158            kitty_keyboard,
1159            focus_events,
1160            bracketed_paste,
1161            mouse_sgr,
1162            osc52_clipboard,
1163        }
1164    }
1165
1166    /// Create a minimal fallback capability set.
1167    ///
1168    /// This is safe to use on any terminal, including dumb terminals.
1169    /// All advanced features are disabled.
1170    #[must_use]
1171    pub const fn basic() -> Self {
1172        Self {
1173            profile: TerminalProfile::Dumb,
1174            color_depth: ColorDepth::Mono,
1175            unicode_box_drawing: false,
1176            unicode_emoji: false,
1177            double_width: false,
1178            sync_output: false,
1179            osc8_hyperlinks: false,
1180            scroll_region: false,
1181            in_tmux: false,
1182            in_screen: false,
1183            in_zellij: false,
1184            in_wezterm_mux: false,
1185            kitty_keyboard: false,
1186            focus_events: false,
1187            bracketed_paste: false,
1188            mouse_sgr: false,
1189            osc52_clipboard: false,
1190        }
1191    }
1192
1193    /// Check if running inside any terminal multiplexer.
1194    ///
1195    /// This includes tmux, GNU screen, Zellij, and WezTerm mux.
1196    #[must_use]
1197    #[inline]
1198    pub const fn in_any_mux(&self) -> bool {
1199        self.in_tmux || self.in_screen || self.in_zellij || self.in_wezterm_mux
1200    }
1201
1202    /// Check if any color support is available.
1203    #[must_use]
1204    #[inline]
1205    pub const fn has_color(&self) -> bool {
1206        self.color_depth.supports_color()
1207    }
1208
1209    /// Whether the terminal supports at least the 256-color palette.
1210    #[must_use]
1211    pub const fn supports_256_colors(&self) -> bool {
1212        self.color_depth.supports_256_colors()
1213    }
1214
1215    /// Whether the terminal supports 24-bit RGB color.
1216    #[must_use]
1217    pub const fn supports_true_color(&self) -> bool {
1218        self.color_depth.supports_true_color()
1219    }
1220
1221    // --- Mux-aware feature policies ---
1222    //
1223    // These methods apply conservative defaults when running inside a
1224    // multiplexer to avoid quirks with sequence passthrough.
1225
1226    /// Whether synchronized output (DEC 2026) should be used.
1227    ///
1228    /// Disabled in multiplexers because passthrough is unreliable
1229    /// for mode-setting sequences. Also disabled for all WezTerm sessions as
1230    /// a safety fallback due observed DEC 2026 instability in mux workflows.
1231    #[must_use]
1232    #[inline]
1233    pub const fn use_sync_output(&self) -> bool {
1234        if self.in_tmux || self.in_screen || self.in_zellij || self.in_wezterm_mux {
1235            return false;
1236        }
1237        self.sync_output
1238    }
1239
1240    /// Whether scroll-region optimization (DECSTBM) is safe to use.
1241    ///
1242    /// Disabled in multiplexers due to inconsistent scroll margin
1243    /// handling across tmux/screen/zellij and WezTerm mux sessions.
1244    #[must_use]
1245    #[inline]
1246    pub const fn use_scroll_region(&self) -> bool {
1247        if self.in_any_mux() {
1248            return false;
1249        }
1250        self.scroll_region
1251    }
1252
1253    /// Whether OSC 8 hyperlinks should be emitted.
1254    ///
1255    /// Disabled in mux environments because passthrough for OSC
1256    /// sequences is fragile and behavior varies by mux implementation.
1257    #[must_use]
1258    #[inline]
1259    pub const fn use_hyperlinks(&self) -> bool {
1260        if self.in_any_mux() {
1261            return false;
1262        }
1263        self.osc8_hyperlinks
1264    }
1265
1266    /// Whether OSC 52 clipboard access should be used.
1267    ///
1268    /// Gated by mux detection in `detect()`, and re-checked here to keep
1269    /// policy behavior consistent for overridden/custom capability sets.
1270    #[must_use]
1271    #[inline]
1272    pub const fn use_clipboard(&self) -> bool {
1273        if self.in_any_mux() {
1274            return false;
1275        }
1276        self.osc52_clipboard
1277    }
1278
1279    /// Whether the passthrough wrapping is needed for this environment.
1280    ///
1281    /// Returns `true` if running in tmux or screen, which require
1282    /// DCS passthrough for escape sequences to reach the inner terminal.
1283    /// Zellij handles passthrough natively and doesn't need wrapping.
1284    #[must_use]
1285    #[inline]
1286    pub const fn needs_passthrough_wrap(&self) -> bool {
1287        self.in_tmux || self.in_screen
1288    }
1289}
1290
1291// ============================================================================
1292// SharedCapabilities — ArcSwap-backed concurrent access (bd-3l9qr.2)
1293// ============================================================================
1294
1295/// Wait-free shared terminal capabilities for concurrent read/write.
1296///
1297/// Wraps [`TerminalCapabilities`] in an `ArcSwapStore` so that the render
1298/// thread can read capabilities without locking while the main thread updates
1299/// them on terminal reconfiguration or resize.
1300///
1301/// # Example
1302///
1303/// ```
1304/// use ftui_core::terminal_capabilities::{TerminalCapabilities, SharedCapabilities};
1305///
1306/// let shared = SharedCapabilities::new(TerminalCapabilities::modern());
1307/// assert!(shared.load().supports_true_color());
1308///
1309/// // Update from main thread (e.g., after re-detection).
1310/// shared.store(TerminalCapabilities::dumb());
1311/// assert!(!shared.load().supports_true_color());
1312/// ```
1313pub struct SharedCapabilities {
1314    inner: crate::read_optimized::ArcSwapStore<TerminalCapabilities>,
1315}
1316
1317impl SharedCapabilities {
1318    /// Create shared capabilities from an initial detection.
1319    pub fn new(caps: TerminalCapabilities) -> Self {
1320        Self {
1321            inner: crate::read_optimized::ArcSwapStore::new(caps),
1322        }
1323    }
1324
1325    /// Detect capabilities from the current environment and wrap them.
1326    pub fn detect() -> Self {
1327        Self::new(TerminalCapabilities::detect())
1328    }
1329
1330    /// Wait-free read of current capabilities.
1331    #[inline]
1332    pub fn load(&self) -> TerminalCapabilities {
1333        crate::read_optimized::ReadOptimized::load(&self.inner)
1334    }
1335
1336    /// Atomically replace capabilities (e.g., after re-detection).
1337    #[inline]
1338    pub fn store(&self, caps: TerminalCapabilities) {
1339        crate::read_optimized::ReadOptimized::store(&self.inner, caps);
1340    }
1341}
1342
1343#[cfg(test)]
1344mod tests {
1345    use super::*;
1346
1347    fn detect_with_override(value: Option<&str>) -> TerminalCapabilities {
1348        TerminalCapabilities::detect_with_test_profile_override(value)
1349    }
1350
1351    #[test]
1352    fn basic_is_minimal() {
1353        let caps = TerminalCapabilities::basic();
1354        assert_eq!(caps.color_depth, ColorDepth::Mono);
1355        assert!(!caps.sync_output);
1356        assert!(!caps.osc8_hyperlinks);
1357        assert!(!caps.scroll_region);
1358        assert!(!caps.in_tmux);
1359        assert!(!caps.in_screen);
1360        assert!(!caps.in_zellij);
1361        assert!(!caps.kitty_keyboard);
1362        assert!(!caps.focus_events);
1363        assert!(!caps.bracketed_paste);
1364        assert!(!caps.mouse_sgr);
1365        assert!(!caps.osc52_clipboard);
1366    }
1367
1368    #[test]
1369    fn basic_is_default() {
1370        let basic = TerminalCapabilities::basic();
1371        let default = TerminalCapabilities::default();
1372        assert_eq!(basic, default);
1373    }
1374
1375    #[test]
1376    fn in_any_mux_logic() {
1377        let mut caps = TerminalCapabilities::basic();
1378        assert!(!caps.in_any_mux());
1379
1380        caps.in_tmux = true;
1381        assert!(caps.in_any_mux());
1382
1383        caps.in_tmux = false;
1384        caps.in_screen = true;
1385        assert!(caps.in_any_mux());
1386
1387        caps.in_screen = false;
1388        caps.in_zellij = true;
1389        assert!(caps.in_any_mux());
1390
1391        caps.in_zellij = false;
1392        caps.in_wezterm_mux = true;
1393        assert!(caps.in_any_mux());
1394    }
1395
1396    #[test]
1397    fn has_color_logic() {
1398        let mut caps = TerminalCapabilities::basic();
1399        assert!(!caps.has_color());
1400
1401        caps.color_depth = ColorDepth::Ansi16;
1402        assert!(caps.has_color());
1403
1404        caps.color_depth = ColorDepth::TrueColor;
1405        assert!(caps.has_color());
1406    }
1407
1408    #[test]
1409    fn color_depth_identifiers() {
1410        let mut caps = TerminalCapabilities::basic();
1411        assert_eq!(caps.color_depth.as_str(), "mono");
1412
1413        caps.color_depth = ColorDepth::Ansi16;
1414        assert_eq!(caps.color_depth.as_str(), "ansi16");
1415
1416        caps.color_depth = ColorDepth::Ansi256;
1417        assert_eq!(caps.color_depth.as_str(), "ansi256");
1418
1419        caps.color_depth = ColorDepth::TrueColor;
1420        assert_eq!(caps.color_depth.as_str(), "truecolor");
1421    }
1422
1423    #[test]
1424    fn explicit_color_depth_detection_contract() {
1425        let cases = [
1426            (Some(""), Some("truecolor"), Some("xterm"), ColorDepth::Mono),
1427            (None, Some("truecolor"), Some("dumb"), ColorDepth::Mono),
1428            (None, Some("truecolor"), Some(" DUMB "), ColorDepth::Mono),
1429            (None, Some("truecolor"), Some("vt100"), ColorDepth::Mono),
1430            (None, None, Some("xterm"), ColorDepth::Ansi16),
1431            (None, None, Some("xterm-256color"), ColorDepth::Ansi256),
1432            (None, Some("24bit"), Some("xterm"), ColorDepth::TrueColor),
1433            (None, None, Some(" XTERM-DIRECT "), ColorDepth::TrueColor),
1434            (None, None, Some("xterm-direct2"), ColorDepth::TrueColor),
1435            (None, None, Some("xterm-direct16"), ColorDepth::TrueColor),
1436            (None, None, Some("xterm-direct256"), ColorDepth::TrueColor),
1437            (None, None, Some("xterm-truecolor"), ColorDepth::TrueColor),
1438            (None, None, Some("xterm-24bit"), ColorDepth::TrueColor),
1439            (None, None, Some("xterm-no24bit"), ColorDepth::Ansi16),
1440            (
1441                None,
1442                Some("nottruecolor"),
1443                Some("xterm"),
1444                ColorDepth::Ansi16,
1445            ),
1446            (None, Some("no24bit"), Some("xterm"), ColorDepth::Ansi16),
1447            (None, Some("truecolor"), Some("linux"), ColorDepth::Ansi16),
1448        ];
1449
1450        for (no_color, colorterm, term, expected) in cases {
1451            assert_eq!(
1452                ColorDepth::detect_from_env(no_color, colorterm, term),
1453                expected,
1454                "NO_COLOR={no_color:?} COLORTERM={colorterm:?} TERM={term:?}"
1455            );
1456        }
1457    }
1458
1459    #[test]
1460    fn detect_does_not_panic() {
1461        // detect() should never panic, even with unusual environment
1462        let _caps = TerminalCapabilities::detect();
1463    }
1464
1465    #[test]
1466    fn windows_terminal_not_dumb_when_term_missing() {
1467        let env = DetectInputs {
1468            no_color: false,
1469            term: String::new(),
1470            term_program: String::new(),
1471            colorterm: String::new(),
1472            in_tmux: false,
1473            in_screen: false,
1474            in_zellij: false,
1475            wezterm_unix_socket: false,
1476            wezterm_pane: false,
1477            wezterm_executable: false,
1478            kitty_window_id: false,
1479            wt_session: true,
1480        };
1481
1482        let caps = TerminalCapabilities::detect_from_inputs(&env);
1483        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
1484        assert!(
1485            caps.osc8_hyperlinks,
1486            "WT_SESSION implies OSC 8 hyperlink support by default"
1487        );
1488        assert!(
1489            caps.bracketed_paste,
1490            "WT_SESSION should not be treated as dumb"
1491        );
1492        assert!(caps.mouse_sgr, "WT_SESSION should not be treated as dumb");
1493    }
1494
1495    #[test]
1496    #[cfg(target_os = "windows")]
1497    fn detect_windows_terminal_from_wt_session() {
1498        let mut env = make_env("", "", "");
1499        env.wt_session = true;
1500        let caps = TerminalCapabilities::detect_from_inputs(&env);
1501        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
1502        assert!(caps.osc8_hyperlinks, "WT_SESSION implies OSC 8 support");
1503    }
1504
1505    #[test]
1506    fn no_color_disables_color_and_links() {
1507        let env = DetectInputs {
1508            no_color: true,
1509            term: "xterm-256color".to_string(),
1510            term_program: "WezTerm".to_string(),
1511            colorterm: "truecolor".to_string(),
1512            in_tmux: false,
1513            in_screen: false,
1514            in_zellij: false,
1515            wezterm_unix_socket: false,
1516            wezterm_pane: false,
1517            wezterm_executable: false,
1518            kitty_window_id: false,
1519            wt_session: false,
1520        };
1521
1522        let caps = TerminalCapabilities::detect_from_inputs(&env);
1523        assert_eq!(caps.color_depth, ColorDepth::Mono);
1524        assert!(
1525            !caps.osc8_hyperlinks,
1526            "NO_COLOR must disable OSC 8 hyperlinks"
1527        );
1528    }
1529
1530    #[cfg(unix)]
1531    #[test]
1532    fn non_utf8_no_color_presence_disables_color_without_global_env_mutation() {
1533        use std::os::unix::ffi::OsStringExt as _;
1534
1535        let non_utf8 = OsString::from_vec(vec![0xff]);
1536        let env = DetectInputs::from_env_with(|key| match key {
1537            "NO_COLOR" => Some(non_utf8.clone()),
1538            "TERM" => Some(OsString::from("xterm-direct")),
1539            "TERM_PROGRAM" => Some(OsString::from("WezTerm")),
1540            "COLORTERM" => Some(OsString::from("truecolor")),
1541            _ => None,
1542        });
1543
1544        assert!(env.no_color, "NO_COLOR is a presence-only contract");
1545        let caps = TerminalCapabilities::detect_from_inputs(&env);
1546        assert_eq!(caps.color_depth, ColorDepth::Mono);
1547        assert!(!caps.osc8_hyperlinks);
1548    }
1549
1550    // --- Mux-aware policy tests ---
1551
1552    #[test]
1553    fn use_sync_output_disabled_in_tmux() {
1554        let mut caps = TerminalCapabilities::basic();
1555        caps.sync_output = true;
1556        assert!(caps.use_sync_output());
1557
1558        caps.in_tmux = true;
1559        assert!(!caps.use_sync_output());
1560    }
1561
1562    #[test]
1563    fn use_sync_output_disabled_in_screen() {
1564        let mut caps = TerminalCapabilities::basic();
1565        caps.sync_output = true;
1566        caps.in_screen = true;
1567        assert!(!caps.use_sync_output());
1568    }
1569
1570    #[test]
1571    fn use_sync_output_disabled_in_zellij() {
1572        let mut caps = TerminalCapabilities::basic();
1573        caps.sync_output = true;
1574        caps.in_zellij = true;
1575        assert!(!caps.use_sync_output());
1576    }
1577
1578    #[test]
1579    fn use_sync_output_disabled_in_wezterm_mux() {
1580        let mut caps = TerminalCapabilities::basic();
1581        caps.sync_output = true;
1582        caps.in_wezterm_mux = true;
1583        assert!(!caps.use_sync_output());
1584    }
1585
1586    #[test]
1587    fn use_scroll_region_disabled_in_mux() {
1588        let mut caps = TerminalCapabilities::basic();
1589        caps.scroll_region = true;
1590        assert!(caps.use_scroll_region());
1591
1592        caps.in_tmux = true;
1593        assert!(!caps.use_scroll_region());
1594
1595        caps.in_tmux = false;
1596        caps.in_screen = true;
1597        assert!(!caps.use_scroll_region());
1598
1599        caps.in_screen = false;
1600        caps.in_zellij = true;
1601        assert!(!caps.use_scroll_region());
1602
1603        caps.in_zellij = false;
1604        caps.in_wezterm_mux = true;
1605        assert!(!caps.use_scroll_region());
1606    }
1607
1608    #[test]
1609    fn use_hyperlinks_disabled_in_mux() {
1610        let mut caps = TerminalCapabilities::basic();
1611        caps.osc8_hyperlinks = true;
1612        assert!(caps.use_hyperlinks());
1613
1614        caps.in_tmux = true;
1615        assert!(!caps.use_hyperlinks());
1616
1617        caps.in_tmux = false;
1618        caps.in_wezterm_mux = true;
1619        assert!(!caps.use_hyperlinks());
1620    }
1621
1622    #[test]
1623    fn use_clipboard_disabled_in_mux() {
1624        let mut caps = TerminalCapabilities::basic();
1625        caps.osc52_clipboard = true;
1626        assert!(caps.use_clipboard());
1627
1628        caps.in_screen = true;
1629        assert!(!caps.use_clipboard());
1630
1631        caps.in_screen = false;
1632        caps.in_wezterm_mux = true;
1633        assert!(!caps.use_clipboard());
1634    }
1635
1636    #[test]
1637    fn needs_passthrough_wrap_only_for_tmux_screen() {
1638        let mut caps = TerminalCapabilities::basic();
1639        assert!(!caps.needs_passthrough_wrap());
1640
1641        caps.in_tmux = true;
1642        assert!(caps.needs_passthrough_wrap());
1643
1644        caps.in_tmux = false;
1645        caps.in_screen = true;
1646        assert!(caps.needs_passthrough_wrap());
1647
1648        // Zellij doesn't need wrapping
1649        caps.in_screen = false;
1650        caps.in_zellij = true;
1651        assert!(!caps.needs_passthrough_wrap());
1652    }
1653
1654    #[test]
1655    fn policies_return_false_when_capability_absent() {
1656        // Even without mux, policies return false when capability is off
1657        let caps = TerminalCapabilities::basic();
1658        assert!(!caps.use_sync_output());
1659        assert!(!caps.use_scroll_region());
1660        assert!(!caps.use_hyperlinks());
1661        assert!(!caps.use_clipboard());
1662    }
1663
1664    // ====== Specific terminal detection ======
1665
1666    fn make_env(term: &str, term_program: &str, colorterm: &str) -> DetectInputs {
1667        DetectInputs {
1668            no_color: false,
1669            term: term.to_string(),
1670            term_program: term_program.to_string(),
1671            colorterm: colorterm.to_string(),
1672            in_tmux: false,
1673            in_screen: false,
1674            in_zellij: false,
1675            wezterm_unix_socket: false,
1676            wezterm_pane: false,
1677            wezterm_executable: false,
1678            kitty_window_id: false,
1679            wt_session: false,
1680        }
1681    }
1682
1683    #[test]
1684    fn detect_dumb_terminal() {
1685        for term in ["dumb", "DUMB", " dumb "] {
1686            let env = make_env(term, " WezTerm ", " TRUECOLOR ");
1687            let caps = TerminalCapabilities::detect_from_inputs(&env);
1688            assert_eq!(caps.color_depth, ColorDepth::Mono, "TERM={term:?}");
1689            assert!(!caps.sync_output, "TERM={term:?}");
1690            assert!(!caps.osc8_hyperlinks, "TERM={term:?}");
1691            assert!(!caps.scroll_region, "TERM={term:?}");
1692            assert!(!caps.focus_events, "TERM={term:?}");
1693            assert!(!caps.bracketed_paste, "TERM={term:?}");
1694            assert!(!caps.mouse_sgr, "TERM={term:?}");
1695        }
1696    }
1697
1698    #[test]
1699    fn detect_dumb_overrides_truecolor_env() {
1700        let env = make_env("dumb", "WezTerm", "truecolor");
1701        let caps = TerminalCapabilities::detect_from_inputs(&env);
1702        assert_eq!(caps.color_depth, ColorDepth::Mono);
1703        assert!(!caps.bracketed_paste);
1704        assert!(!caps.mouse_sgr);
1705        assert!(!caps.osc8_hyperlinks);
1706    }
1707
1708    #[test]
1709    fn detect_dumb_disables_kitty_keyboard() {
1710        // Regression: kitty_keyboard was the only capability not gated on
1711        // is_dumb. TERM=dumb with an inherited TERM_PROGRAM (Emacs shell
1712        // inside kitty/Ghostty/iTerm2) must not enable CSI > u sequences.
1713        for term_program in ["kitty", "ghostty", "iTerm.app", "WezTerm"] {
1714            let env = make_env("dumb", term_program, "truecolor");
1715            let caps = TerminalCapabilities::detect_from_inputs(&env);
1716            assert!(
1717                !caps.kitty_keyboard,
1718                "TERM=dumb + TERM_PROGRAM={term_program} must disable kitty keyboard"
1719            );
1720        }
1721        // Sanity: without dumb, the same identity enables it.
1722        let env = make_env("xterm-kitty", "kitty", "truecolor");
1723        let caps = TerminalCapabilities::detect_from_inputs(&env);
1724        assert!(caps.kitty_keyboard);
1725    }
1726
1727    #[test]
1728    fn detect_term_identity_counts_as_mux_evidence() {
1729        // Regression: $TMUX/$STY do not survive ssh/sudo boundaries but the
1730        // mux TERM value does. TERM=tmux-*/screen-* must count as mux
1731        // evidence so use_scroll_region() stays disabled inside the pane
1732        // (doc invariant: mux detection wins).
1733        let env = make_env("tmux-256color", "", "");
1734        let caps = TerminalCapabilities::detect_from_inputs(&env);
1735        assert!(caps.in_tmux, "TERM=tmux-256color implies tmux");
1736        assert!(caps.in_any_mux());
1737        assert!(!caps.use_scroll_region());
1738
1739        let env = make_env("screen-256color", "", "");
1740        let caps = TerminalCapabilities::detect_from_inputs(&env);
1741        assert!(caps.in_screen, "TERM=screen-256color implies screen");
1742        assert!(caps.in_any_mux());
1743        assert!(!caps.use_scroll_region());
1744
1745        // Plain xterm stays non-mux.
1746        let env = make_env("xterm-256color", "", "");
1747        let caps = TerminalCapabilities::detect_from_inputs(&env);
1748        assert!(!caps.in_tmux);
1749        assert!(!caps.in_screen);
1750    }
1751
1752    #[test]
1753    fn from_profile_custom_roundtrips() {
1754        let caps = TerminalCapabilities::from_profile(TerminalProfile::Custom);
1755        assert_eq!(caps.profile(), TerminalProfile::Custom);
1756        // Capability set matches the conservative basic() profile.
1757        let basic = TerminalCapabilities::basic();
1758        assert_eq!(caps.color_depth, basic.color_depth);
1759        assert_eq!(caps.scroll_region, basic.scroll_region);
1760    }
1761
1762    #[test]
1763    fn detect_empty_term_is_dumb() {
1764        let env = make_env("", "", "");
1765        let caps = TerminalCapabilities::detect_from_inputs(&env);
1766        assert_eq!(caps.color_depth, ColorDepth::Mono);
1767        assert!(!caps.bracketed_paste);
1768    }
1769
1770    #[test]
1771    fn detect_xterm_256color() {
1772        let env = make_env("xterm-256color", "", "");
1773        let caps = TerminalCapabilities::detect_from_inputs(&env);
1774        assert_eq!(caps.color_depth, ColorDepth::Ansi256);
1775        assert!(caps.bracketed_paste);
1776        assert!(caps.mouse_sgr);
1777        assert!(caps.scroll_region);
1778    }
1779
1780    #[test]
1781    fn detect_colorterm_truecolor() {
1782        let env = make_env("xterm-256color", "", "truecolor");
1783        let caps = TerminalCapabilities::detect_from_inputs(&env);
1784        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
1785    }
1786
1787    #[test]
1788    fn detect_colorterm_24bit() {
1789        let env = make_env("xterm-256color", "", "24bit");
1790        let caps = TerminalCapabilities::detect_from_inputs(&env);
1791        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
1792    }
1793
1794    #[test]
1795    fn detect_kitty_by_window_id() {
1796        let mut env = make_env("xterm-kitty", "", "");
1797        env.kitty_window_id = true;
1798        let caps = TerminalCapabilities::detect_from_inputs(&env);
1799        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
1800        assert!(
1801            caps.kitty_keyboard,
1802            "Kitty supports kitty keyboard protocol"
1803        );
1804        assert!(caps.sync_output, "Kitty supports sync output");
1805    }
1806
1807    #[test]
1808    fn detect_kitty_by_term() {
1809        let env = make_env("xterm-kitty", "", "");
1810        let caps = TerminalCapabilities::detect_from_inputs(&env);
1811        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
1812        assert!(caps.kitty_keyboard);
1813    }
1814
1815    #[test]
1816    fn detect_wezterm() {
1817        let env = make_env("xterm-256color", "WezTerm", "truecolor");
1818        let caps = TerminalCapabilities::detect_from_inputs(&env);
1819        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
1820        assert!(
1821            caps.in_wezterm_mux,
1822            "WezTerm identity is treated as conservative mux evidence"
1823        );
1824        assert!(caps.in_any_mux());
1825        assert!(
1826            !caps.sync_output,
1827            "WezTerm sync output is hard-disabled as a safety fallback"
1828        );
1829        assert!(caps.osc8_hyperlinks, "WezTerm supports hyperlinks");
1830        assert!(caps.kitty_keyboard, "WezTerm supports kitty keyboard");
1831        assert!(caps.focus_events);
1832        assert!(
1833            !caps.osc52_clipboard,
1834            "conservative mux policy should disable raw OSC52 detection"
1835        );
1836        assert!(!caps.use_scroll_region());
1837        assert!(!caps.use_hyperlinks());
1838        assert!(!caps.use_clipboard());
1839    }
1840
1841    #[test]
1842    fn detect_wezterm_mux_socket_disables_sync_policy() {
1843        let mut env = make_env("xterm-256color", "WezTerm", "truecolor");
1844        env.wezterm_unix_socket = true;
1845        let caps = TerminalCapabilities::detect_from_inputs(&env);
1846        assert!(!caps.sync_output);
1847        assert!(caps.in_wezterm_mux, "wezterm mux marker should be detected");
1848        assert!(
1849            caps.in_any_mux(),
1850            "wezterm mux must participate in in_any_mux()"
1851        );
1852        assert!(
1853            !caps.use_sync_output(),
1854            "policy must suppress sync output in wezterm mux sessions"
1855        );
1856        assert!(
1857            !caps.use_scroll_region(),
1858            "policy must suppress scroll region in wezterm mux sessions"
1859        );
1860        assert!(
1861            !caps.use_hyperlinks(),
1862            "policy must suppress hyperlinks in wezterm mux sessions"
1863        );
1864        assert!(
1865            !caps.use_clipboard(),
1866            "policy must suppress clipboard in wezterm mux sessions"
1867        );
1868    }
1869
1870    #[test]
1871    fn detect_wezterm_mux_socket_without_term_program_disables_sync_policy() {
1872        let mut env = make_env("xterm-256color", "", "truecolor");
1873        env.wezterm_unix_socket = true;
1874        let caps = TerminalCapabilities::detect_from_inputs(&env);
1875        assert!(
1876            caps.in_wezterm_mux,
1877            "socket marker alone must detect wezterm mux"
1878        );
1879        assert!(
1880            caps.in_any_mux(),
1881            "wezterm mux must participate in in_any_mux()"
1882        );
1883        assert!(
1884            !caps.use_sync_output(),
1885            "policy must suppress sync output when wezterm mux socket is present"
1886        );
1887    }
1888
1889    #[test]
1890    fn detect_wezterm_mux_pane_disables_sync_policy() {
1891        let mut env = make_env("xterm-256color", "WezTerm", "truecolor");
1892        env.wezterm_pane = true;
1893        let caps = TerminalCapabilities::detect_from_inputs(&env);
1894        assert!(!caps.sync_output);
1895        assert!(
1896            caps.in_wezterm_mux,
1897            "wezterm pane marker should be detected"
1898        );
1899        assert!(
1900            caps.in_any_mux(),
1901            "wezterm mux must participate in in_any_mux()"
1902        );
1903        assert!(
1904            !caps.use_sync_output(),
1905            "policy must suppress sync output when wezterm pane marker is present"
1906        );
1907    }
1908
1909    #[test]
1910    fn detect_wezterm_mux_pane_without_term_program_disables_sync_policy() {
1911        let mut env = make_env("xterm-256color", "", "truecolor");
1912        env.wezterm_pane = true;
1913        let caps = TerminalCapabilities::detect_from_inputs(&env);
1914        assert!(
1915            caps.in_wezterm_mux,
1916            "pane marker alone must detect wezterm mux"
1917        );
1918        assert!(
1919            caps.in_any_mux(),
1920            "wezterm mux must participate in in_any_mux()"
1921        );
1922        assert!(
1923            !caps.use_sync_output(),
1924            "policy must suppress sync output when wezterm pane marker is present"
1925        );
1926    }
1927
1928    #[test]
1929    fn detect_wezterm_executable_without_term_program_is_conservative_mux() {
1930        let mut env = make_env("xterm-256color", "", "truecolor");
1931        env.wezterm_executable = true;
1932        let caps = TerminalCapabilities::detect_from_inputs(&env);
1933        assert!(
1934            caps.in_wezterm_mux,
1935            "WEZTERM_EXECUTABLE fallback should conservatively mark mux context"
1936        );
1937        assert!(
1938            !caps.use_sync_output(),
1939            "fallback mux detection must suppress sync output policy"
1940        );
1941    }
1942
1943    #[test]
1944    fn detect_wezterm_executable_overrides_explicit_non_wezterm_program() {
1945        let mut env = make_env("xterm-ghostty", "Ghostty", "truecolor");
1946        env.wezterm_executable = true;
1947        let caps = TerminalCapabilities::detect_from_inputs(&env);
1948        assert!(
1949            caps.in_wezterm_mux,
1950            "WEZTERM_EXECUTABLE should conservatively force wezterm mux policy"
1951        );
1952        assert!(
1953            !caps.sync_output,
1954            "raw sync_output capability should be disabled under conservative wezterm marker handling"
1955        );
1956        assert!(
1957            !caps.use_sync_output(),
1958            "mux policy should disable sync output under WEZTERM_EXECUTABLE marker"
1959        );
1960    }
1961
1962    #[test]
1963    fn detect_wezterm_socket_overrides_explicit_non_wezterm_program() {
1964        let mut env = make_env("xterm-ghostty", "Ghostty", "truecolor");
1965        env.wezterm_unix_socket = true;
1966        let caps = TerminalCapabilities::detect_from_inputs(&env);
1967        assert!(
1968            caps.in_wezterm_mux,
1969            "WEZTERM_UNIX_SOCKET should conservatively force wezterm mux policy"
1970        );
1971        assert!(
1972            !caps.use_sync_output(),
1973            "mux policy should disable sync output with socket marker"
1974        );
1975    }
1976
1977    #[test]
1978    fn detect_wezterm_pane_overrides_explicit_non_wezterm_program() {
1979        let mut env = make_env("xterm-ghostty", "Ghostty", "truecolor");
1980        env.wezterm_pane = true;
1981        let caps = TerminalCapabilities::detect_from_inputs(&env);
1982        assert!(
1983            caps.in_wezterm_mux,
1984            "WEZTERM_PANE should conservatively force wezterm mux policy"
1985        );
1986        assert!(
1987            !caps.use_sync_output(),
1988            "mux policy should disable sync output with pane marker"
1989        );
1990    }
1991
1992    #[test]
1993    fn detect_wezterm_socket_overrides_explicit_non_wezterm_term_identity() {
1994        let mut env = make_env("xterm-ghostty", "", "truecolor");
1995        env.wezterm_unix_socket = true;
1996        let caps = TerminalCapabilities::detect_from_inputs(&env);
1997        assert!(
1998            caps.in_wezterm_mux,
1999            "WEZTERM_UNIX_SOCKET should conservatively force wezterm mux policy"
2000        );
2001        assert!(
2002            !caps.use_sync_output(),
2003            "mux policy should disable sync output with socket marker"
2004        );
2005    }
2006
2007    #[test]
2008    #[cfg(target_os = "macos")]
2009    fn detect_iterm2_from_term_program() {
2010        let env = make_env("xterm-256color", "iTerm.app", "truecolor");
2011        let caps = TerminalCapabilities::detect_from_inputs(&env);
2012        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
2013        assert!(caps.osc8_hyperlinks, "iTerm2 supports OSC 8 hyperlinks");
2014    }
2015
2016    #[test]
2017    fn detect_alacritty() {
2018        let env = make_env("alacritty", "Alacritty", "truecolor");
2019        let caps = TerminalCapabilities::detect_from_inputs(&env);
2020        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
2021        assert!(caps.sync_output);
2022        assert!(caps.osc8_hyperlinks);
2023        assert!(caps.kitty_keyboard);
2024        assert!(caps.focus_events);
2025    }
2026
2027    #[test]
2028    fn detect_ghostty() {
2029        let env = make_env("xterm-ghostty", "Ghostty", "truecolor");
2030        let caps = TerminalCapabilities::detect_from_inputs(&env);
2031        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
2032        assert!(caps.sync_output);
2033        assert!(caps.osc8_hyperlinks);
2034        assert!(caps.kitty_keyboard);
2035        assert!(caps.focus_events);
2036    }
2037
2038    #[test]
2039    fn detect_iterm() {
2040        let env = make_env("xterm-256color", "iTerm.app", "truecolor");
2041        let caps = TerminalCapabilities::detect_from_inputs(&env);
2042        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
2043        assert!(caps.osc8_hyperlinks);
2044        assert!(caps.kitty_keyboard);
2045        assert!(caps.focus_events);
2046    }
2047
2048    #[test]
2049    fn detect_vscode_terminal() {
2050        let env = make_env("xterm-256color", "vscode", "truecolor");
2051        let caps = TerminalCapabilities::detect_from_inputs(&env);
2052        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
2053        assert!(caps.osc8_hyperlinks);
2054        assert!(caps.focus_events);
2055    }
2056
2057    // ====== Multiplexer detection ======
2058
2059    #[test]
2060    fn detect_in_tmux() {
2061        let mut env = make_env("screen-256color", "", "");
2062        env.in_tmux = true;
2063        let caps = TerminalCapabilities::detect_from_inputs(&env);
2064        assert!(caps.in_tmux);
2065        assert!(caps.in_any_mux());
2066        assert_eq!(caps.color_depth, ColorDepth::Ansi256);
2067        assert!(!caps.osc52_clipboard, "clipboard disabled in tmux");
2068    }
2069
2070    #[test]
2071    fn detect_in_screen() {
2072        let mut env = make_env("screen", "", "");
2073        env.in_screen = true;
2074        let caps = TerminalCapabilities::detect_from_inputs(&env);
2075        assert!(caps.in_screen);
2076        assert!(caps.in_any_mux());
2077        assert!(caps.needs_passthrough_wrap());
2078    }
2079
2080    #[test]
2081    fn detect_in_zellij() {
2082        let mut env = make_env("xterm-256color", "", "truecolor");
2083        env.in_zellij = true;
2084        let caps = TerminalCapabilities::detect_from_inputs(&env);
2085        assert!(caps.in_zellij);
2086        assert!(caps.in_any_mux());
2087        assert!(
2088            !caps.needs_passthrough_wrap(),
2089            "Zellij handles passthrough natively"
2090        );
2091        assert!(!caps.osc52_clipboard, "clipboard disabled in mux");
2092    }
2093
2094    #[test]
2095    fn detect_modern_terminal_in_tmux() {
2096        let mut env = make_env("screen-256color", "WezTerm", "truecolor");
2097        env.in_tmux = true;
2098        let caps = TerminalCapabilities::detect_from_inputs(&env);
2099        // Feature detection still works
2100        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
2101        assert!(!caps.sync_output);
2102        // But policies disable features in mux
2103        assert!(!caps.use_sync_output());
2104        assert!(!caps.use_hyperlinks());
2105        assert!(!caps.use_scroll_region());
2106    }
2107
2108    // ====== NO_COLOR interaction with mux ======
2109
2110    #[test]
2111    fn no_color_overrides_everything() {
2112        let mut env = make_env("xterm-256color", "WezTerm", "truecolor");
2113        env.no_color = true;
2114        let caps = TerminalCapabilities::detect_from_inputs(&env);
2115        assert_eq!(caps.color_depth, ColorDepth::Mono);
2116        assert!(!caps.osc8_hyperlinks);
2117        // But non-color features still work
2118        assert!(!caps.sync_output);
2119        assert!(caps.bracketed_paste);
2120        assert!(caps.mouse_sgr);
2121    }
2122
2123    // ====== Edge cases ======
2124
2125    #[test]
2126    fn unknown_term_program() {
2127        let env = make_env("xterm", "SomeUnknownTerminal", "");
2128        let caps = TerminalCapabilities::detect_from_inputs(&env);
2129        assert!(
2130            !caps.supports_true_color(),
2131            "unknown terminal should not assume truecolor"
2132        );
2133        assert_eq!(caps.color_depth, ColorDepth::Ansi16);
2134        assert!(!caps.osc8_hyperlinks);
2135        // But basic features still work
2136        assert!(caps.bracketed_paste);
2137        assert!(caps.mouse_sgr);
2138        assert!(caps.scroll_region);
2139    }
2140
2141    #[test]
2142    fn all_mux_flags_simultaneous() {
2143        let mut env = make_env("screen", "", "");
2144        env.in_tmux = true;
2145        env.in_screen = true;
2146        env.in_zellij = true;
2147        let caps = TerminalCapabilities::detect_from_inputs(&env);
2148        assert!(caps.in_any_mux());
2149        assert!(caps.needs_passthrough_wrap());
2150        assert!(!caps.use_sync_output());
2151        assert!(!caps.use_hyperlinks());
2152        assert!(!caps.use_clipboard());
2153    }
2154
2155    // ====== Additional terminal detection (coverage gaps) ======
2156
2157    #[test]
2158    fn detect_rio() {
2159        let env = make_env("xterm-256color", "Rio", "truecolor");
2160        let caps = TerminalCapabilities::detect_from_inputs(&env);
2161        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
2162        assert!(caps.osc8_hyperlinks);
2163        assert!(caps.kitty_keyboard);
2164        assert!(caps.focus_events);
2165    }
2166
2167    #[test]
2168    fn detect_contour() {
2169        let env = make_env("xterm-256color", "Contour", "truecolor");
2170        let caps = TerminalCapabilities::detect_from_inputs(&env);
2171        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
2172        assert!(caps.sync_output);
2173        assert!(caps.osc8_hyperlinks);
2174        assert!(caps.focus_events);
2175    }
2176
2177    #[test]
2178    fn detect_foot() {
2179        let env = make_env("foot", "foot", "truecolor");
2180        let caps = TerminalCapabilities::detect_from_inputs(&env);
2181        assert!(caps.kitty_keyboard, "foot supports kitty keyboard");
2182    }
2183
2184    #[test]
2185    fn detect_hyper() {
2186        let env = make_env("xterm-256color", "Hyper", "truecolor");
2187        let caps = TerminalCapabilities::detect_from_inputs(&env);
2188        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
2189        assert!(caps.osc8_hyperlinks);
2190        assert!(caps.focus_events);
2191    }
2192
2193    #[test]
2194    fn detect_linux_console() {
2195        let env = make_env("linux", "", "");
2196        let detected = TerminalCapabilities::detect_from_inputs(&env);
2197        let mut expected = TerminalCapabilities::linux_console();
2198        expected.profile = TerminalProfile::Detected;
2199
2200        assert_eq!(
2201            detected, expected,
2202            "the predefined Linux console profile must match TERM=linux detection"
2203        );
2204
2205        let policy = crate::glyph_policy::GlyphPolicy::from_env_with(
2206            |key| (key == "TERM").then(|| "linux".to_string()),
2207            &detected,
2208        );
2209        assert_eq!(policy.mode, crate::glyph_policy::GlyphMode::Ascii);
2210        assert!(!policy.double_width);
2211    }
2212
2213    #[test]
2214    fn detect_linux_console_caps_color_despite_conflicting_positive_signals() {
2215        let expected = TerminalCapabilities::linux_console().color_depth;
2216
2217        for (term_program, colorterm) in [
2218            ("WezTerm", "truecolor"),
2219            ("kitty", "24bit"),
2220            ("Ghostty", ""),
2221        ] {
2222            let env = make_env(" linux ", term_program, colorterm);
2223            let detected = TerminalCapabilities::detect_from_inputs(&env).color_depth;
2224            let explicit = ColorDepth::detect_from_env(None, Some(colorterm), Some(" linux "));
2225
2226            assert_eq!(detected, expected, "TERM_PROGRAM={term_program:?}");
2227            assert_eq!(explicit, expected, "COLORTERM={colorterm:?}");
2228        }
2229    }
2230
2231    #[test]
2232    fn detect_direct_color_term_variants() {
2233        for term in [
2234            "xterm-direct",
2235            "xterm-direct2",
2236            "xterm-direct16",
2237            "xterm-direct256",
2238            "xterm-truecolor",
2239            "xterm-24bit",
2240            " XTERM-DIRECT ",
2241        ] {
2242            let env = make_env(term, "", "");
2243            let caps = TerminalCapabilities::detect_from_inputs(&env);
2244            assert_eq!(caps.color_depth, ColorDepth::TrueColor, "TERM={term:?}");
2245            assert!(caps.bracketed_paste, "TERM={term:?}");
2246            assert!(caps.mouse_sgr, "TERM={term:?}");
2247        }
2248    }
2249
2250    #[test]
2251    fn direct_color_detection_rejects_negated_and_partial_tokens() {
2252        for term in [
2253            "xterm-indirect",
2254            "xterm-direct-color",
2255            "xterm-no24bit",
2256            "xterm-24bit-disabled",
2257        ] {
2258            let env = make_env(term, "", "");
2259            let detected = TerminalCapabilities::detect_from_inputs(&env).color_depth;
2260            let explicit = ColorDepth::detect_from_env(None, None, Some(term));
2261
2262            assert_eq!(detected, ColorDepth::Ansi16, "TERM={term:?}");
2263            assert_eq!(explicit, detected, "TERM={term:?}");
2264        }
2265
2266        for colorterm in [
2267            "nottruecolor",
2268            "truecolor-disabled",
2269            "no24bit",
2270            "24bit-disabled",
2271        ] {
2272            let env = make_env("xterm", "", colorterm);
2273            let detected = TerminalCapabilities::detect_from_inputs(&env).color_depth;
2274            let explicit = ColorDepth::detect_from_env(None, Some(colorterm), Some("xterm"));
2275
2276            assert_eq!(detected, ColorDepth::Ansi16, "COLORTERM={colorterm:?}");
2277            assert_eq!(explicit, detected, "COLORTERM={colorterm:?}");
2278        }
2279    }
2280
2281    #[test]
2282    fn detect_plain_xterm_is_ansi16() {
2283        let env = make_env("xterm", "", "");
2284        let caps = TerminalCapabilities::detect_from_inputs(&env);
2285        assert_eq!(caps.color_depth, ColorDepth::Ansi16);
2286        assert!(caps.bracketed_paste);
2287        assert!(caps.mouse_sgr);
2288    }
2289
2290    #[test]
2291    fn explicit_and_full_color_detection_agree() {
2292        for (no_color, colorterm, term) in [
2293            (false, "", "xterm"),
2294            (false, "", "xterm-256color"),
2295            (false, "", " XTERM-DIRECT "),
2296            (false, "", "xterm-direct2"),
2297            (false, "", "xterm-direct16"),
2298            (false, "", "xterm-direct256"),
2299            (false, "24BIT", "xterm"),
2300            (false, "no24bit", "xterm"),
2301            (false, "truecolor", "linux"),
2302            (false, "truecolor", " VT100 "),
2303            (false, "truecolor", " DUMB "),
2304            (true, "truecolor", "xterm-direct"),
2305        ] {
2306            let mut env = make_env(term, "", colorterm);
2307            env.no_color = no_color;
2308            let detected = TerminalCapabilities::detect_from_inputs(&env).color_depth;
2309            let explicit =
2310                ColorDepth::detect_from_env(no_color.then_some(""), Some(colorterm), Some(term));
2311            assert_eq!(
2312                detected, explicit,
2313                "NO_COLOR={no_color} COLORTERM={colorterm:?} TERM={term:?}"
2314            );
2315        }
2316    }
2317
2318    #[test]
2319    fn detect_vt100_is_monochrome() {
2320        let env = make_env("vt100", "", "");
2321        let caps = TerminalCapabilities::detect_from_inputs(&env);
2322        assert_eq!(caps.color_depth, ColorDepth::Mono);
2323    }
2324
2325    #[test]
2326    fn detect_screen_256color() {
2327        let env = make_env("screen-256color", "", "");
2328        let caps = TerminalCapabilities::detect_from_inputs(&env);
2329        assert_eq!(caps.color_depth, ColorDepth::Ansi256);
2330    }
2331
2332    // ====== Only TERM_PROGRAM without COLORTERM ======
2333
2334    #[test]
2335    fn wezterm_without_colorterm() {
2336        let env = make_env("xterm-256color", "WezTerm", "");
2337        let caps = TerminalCapabilities::detect_from_inputs(&env);
2338        // Modern terminal detection still works via TERM_PROGRAM
2339        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
2340        assert!(!caps.sync_output);
2341        assert!(caps.osc8_hyperlinks);
2342    }
2343
2344    #[test]
2345    fn alacritty_via_term_only() {
2346        // Alacritty sets TERM=alacritty
2347        let env = make_env("alacritty", "", "");
2348        let caps = TerminalCapabilities::detect_from_inputs(&env);
2349        // TERM contains "alacritty" which matches lowercase of MODERN_TERMINALS
2350        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
2351        assert!(caps.osc8_hyperlinks);
2352    }
2353
2354    // ====== Kitty detection edge cases ======
2355
2356    #[test]
2357    fn kitty_via_term_without_window_id() {
2358        let env = make_env("xterm-kitty", "", "");
2359        let caps = TerminalCapabilities::detect_from_inputs(&env);
2360        assert!(caps.kitty_keyboard);
2361        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
2362        assert!(caps.sync_output);
2363    }
2364
2365    #[test]
2366    fn kitty_window_id_with_generic_term() {
2367        let mut env = make_env("xterm-256color", "", "");
2368        env.kitty_window_id = true;
2369        let caps = TerminalCapabilities::detect_from_inputs(&env);
2370        assert!(caps.kitty_keyboard);
2371        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
2372    }
2373
2374    // ====== Policy edge cases ======
2375
2376    #[test]
2377    fn use_clipboard_enabled_when_no_mux_and_modern() {
2378        let env = make_env("xterm-256color", "Alacritty", "truecolor");
2379        let caps = TerminalCapabilities::detect_from_inputs(&env);
2380        assert!(caps.osc52_clipboard);
2381        assert!(caps.use_clipboard());
2382    }
2383
2384    #[test]
2385    fn use_clipboard_disabled_in_tmux_even_if_detected() {
2386        let mut env = make_env("xterm-256color", "WezTerm", "truecolor");
2387        env.in_tmux = true;
2388        let caps = TerminalCapabilities::detect_from_inputs(&env);
2389        // osc52_clipboard is already false due to mux detection in detect_from_inputs
2390        assert!(!caps.osc52_clipboard);
2391        assert!(!caps.use_clipboard());
2392    }
2393
2394    #[test]
2395    fn scroll_region_enabled_for_basic_xterm() {
2396        let env = make_env("xterm", "", "");
2397        let caps = TerminalCapabilities::detect_from_inputs(&env);
2398        assert!(caps.scroll_region);
2399        assert!(caps.use_scroll_region());
2400    }
2401
2402    #[test]
2403    fn no_color_preserves_non_visual_features() {
2404        let mut env = make_env("xterm-256color", "WezTerm", "truecolor");
2405        env.no_color = true;
2406        let caps = TerminalCapabilities::detect_from_inputs(&env);
2407        // Visual features disabled
2408        assert_eq!(caps.color_depth, ColorDepth::Mono);
2409        assert!(!caps.osc8_hyperlinks);
2410        // Non-visual features preserved
2411        assert!(!caps.sync_output);
2412        assert!(caps.kitty_keyboard);
2413        assert!(caps.focus_events);
2414        assert!(caps.bracketed_paste);
2415        assert!(caps.mouse_sgr);
2416    }
2417
2418    // ====== COLORTERM variations ======
2419
2420    #[test]
2421    fn colorterm_yes_not_truecolor() {
2422        let env = make_env("xterm-256color", "", "yes");
2423        let caps = TerminalCapabilities::detect_from_inputs(&env);
2424        assert_eq!(caps.color_depth, ColorDepth::Ansi256);
2425    }
2426
2427    // ====== Capability Profiles (bd-k4lj.2) ======
2428
2429    #[test]
2430    fn profile_enum_as_str() {
2431        assert_eq!(TerminalProfile::Modern.as_str(), "modern");
2432        assert_eq!(TerminalProfile::Xterm256Color.as_str(), "xterm-256color");
2433        assert_eq!(TerminalProfile::Vt100.as_str(), "vt100");
2434        assert_eq!(TerminalProfile::Dumb.as_str(), "dumb");
2435        assert_eq!(TerminalProfile::Tmux.as_str(), "tmux");
2436        assert_eq!(TerminalProfile::Screen.as_str(), "screen");
2437        assert_eq!(TerminalProfile::Kitty.as_str(), "kitty");
2438    }
2439
2440    #[test]
2441    fn profile_enum_from_str() {
2442        use std::str::FromStr;
2443        assert_eq!(
2444            TerminalProfile::from_str("modern"),
2445            Ok(TerminalProfile::Modern)
2446        );
2447        assert_eq!(
2448            TerminalProfile::from_str("xterm-256color"),
2449            Ok(TerminalProfile::Xterm256Color)
2450        );
2451        assert_eq!(
2452            TerminalProfile::from_str("xterm256color"),
2453            Ok(TerminalProfile::Xterm256Color)
2454        );
2455        assert_eq!(TerminalProfile::from_str("DUMB"), Ok(TerminalProfile::Dumb));
2456        assert!(TerminalProfile::from_str("unknown").is_err());
2457    }
2458
2459    #[test]
2460    fn profile_all_predefined() {
2461        let all = TerminalProfile::all_predefined();
2462        assert!(all.len() >= 10);
2463        assert!(all.contains(&TerminalProfile::Modern));
2464        assert!(all.contains(&TerminalProfile::Dumb));
2465        assert!(!all.contains(&TerminalProfile::Custom));
2466        assert!(!all.contains(&TerminalProfile::Detected));
2467    }
2468
2469    #[test]
2470    fn profile_modern_has_all_features() {
2471        let caps = TerminalCapabilities::modern();
2472        assert_eq!(caps.profile(), TerminalProfile::Modern);
2473        assert_eq!(caps.profile_name(), Some("modern"));
2474        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
2475        assert!(caps.sync_output);
2476        assert!(caps.osc8_hyperlinks);
2477        assert!(caps.scroll_region);
2478        assert!(caps.kitty_keyboard);
2479        assert!(caps.focus_events);
2480        assert!(caps.bracketed_paste);
2481        assert!(caps.mouse_sgr);
2482        assert!(caps.osc52_clipboard);
2483        assert!(!caps.in_any_mux());
2484    }
2485
2486    #[test]
2487    fn profile_xterm_256color() {
2488        let caps = TerminalCapabilities::xterm_256color();
2489        assert_eq!(caps.profile(), TerminalProfile::Xterm256Color);
2490        assert_eq!(caps.color_depth, ColorDepth::Ansi256);
2491        assert!(!caps.sync_output);
2492        assert!(!caps.osc8_hyperlinks);
2493        assert!(caps.scroll_region);
2494        assert!(caps.bracketed_paste);
2495        assert!(caps.mouse_sgr);
2496    }
2497
2498    #[test]
2499    fn profile_xterm_basic() {
2500        let caps = TerminalCapabilities::xterm();
2501        assert_eq!(caps.profile(), TerminalProfile::Xterm);
2502        assert_eq!(caps.color_depth, ColorDepth::Ansi16);
2503        assert!(caps.scroll_region);
2504    }
2505
2506    #[test]
2507    fn profile_vt100_minimal() {
2508        let caps = TerminalCapabilities::vt100();
2509        assert_eq!(caps.profile(), TerminalProfile::Vt100);
2510        assert_eq!(caps.color_depth, ColorDepth::Mono);
2511        assert!(caps.scroll_region);
2512        assert!(!caps.bracketed_paste);
2513        assert!(!caps.mouse_sgr);
2514    }
2515
2516    #[test]
2517    fn profile_dumb_no_features() {
2518        let caps = TerminalCapabilities::dumb();
2519        assert_eq!(caps.profile(), TerminalProfile::Dumb);
2520        assert_eq!(caps.color_depth, ColorDepth::Mono);
2521        assert!(!caps.scroll_region);
2522        assert!(!caps.bracketed_paste);
2523        assert!(!caps.mouse_sgr);
2524        assert!(!caps.use_sync_output());
2525        assert!(!caps.use_scroll_region());
2526    }
2527
2528    #[test]
2529    fn profile_tmux_mux_flags() {
2530        let caps = TerminalCapabilities::tmux();
2531        assert_eq!(caps.profile(), TerminalProfile::Tmux);
2532        assert!(caps.in_tmux);
2533        assert!(!caps.in_screen);
2534        assert!(!caps.in_zellij);
2535        assert!(caps.in_any_mux());
2536        // Mux policies kick in
2537        assert!(!caps.use_sync_output());
2538        assert!(!caps.use_scroll_region());
2539        assert!(!caps.use_hyperlinks());
2540    }
2541
2542    #[test]
2543    fn profile_screen_mux_flags() {
2544        let caps = TerminalCapabilities::screen();
2545        assert_eq!(caps.profile(), TerminalProfile::Screen);
2546        assert!(!caps.in_tmux);
2547        assert!(caps.in_screen);
2548        assert!(caps.in_any_mux());
2549        assert!(caps.needs_passthrough_wrap());
2550    }
2551
2552    #[test]
2553    fn profile_zellij_mux_flags() {
2554        let caps = TerminalCapabilities::zellij();
2555        assert_eq!(caps.profile(), TerminalProfile::Zellij);
2556        assert!(caps.in_zellij);
2557        assert!(caps.in_any_mux());
2558        // Zellij has true color and focus events
2559        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
2560        assert!(caps.focus_events);
2561        // But no passthrough wrap needed
2562        assert!(!caps.needs_passthrough_wrap());
2563    }
2564
2565    #[test]
2566    fn profile_kitty_full_features() {
2567        let caps = TerminalCapabilities::kitty();
2568        assert_eq!(caps.profile(), TerminalProfile::Kitty);
2569        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
2570        assert!(caps.sync_output);
2571        assert!(caps.kitty_keyboard);
2572        assert!(caps.osc8_hyperlinks);
2573    }
2574
2575    #[test]
2576    fn profile_windows_console() {
2577        let caps = TerminalCapabilities::windows_console();
2578        assert_eq!(caps.profile(), TerminalProfile::WindowsConsole);
2579        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
2580        assert!(caps.osc8_hyperlinks);
2581        assert!(caps.focus_events);
2582    }
2583
2584    #[test]
2585    fn profile_linux_console() {
2586        let caps = TerminalCapabilities::linux_console();
2587        assert_eq!(caps.profile(), TerminalProfile::LinuxConsole);
2588        assert_eq!(caps.color_depth, ColorDepth::Ansi16);
2589        assert!(!caps.double_width);
2590        assert!(caps.scroll_region);
2591    }
2592
2593    #[test]
2594    fn from_profile_roundtrip() {
2595        for profile in TerminalProfile::all_predefined() {
2596            let caps = TerminalCapabilities::from_profile(*profile);
2597            assert_eq!(caps.profile(), *profile);
2598        }
2599    }
2600
2601    #[test]
2602    fn detected_profile_has_none_name() {
2603        let caps = detect_with_override(None);
2604        assert_eq!(caps.profile(), TerminalProfile::Detected);
2605        assert_eq!(caps.profile_name(), None);
2606    }
2607
2608    #[test]
2609    fn detect_respects_test_profile_env() {
2610        let caps = detect_with_override(Some("dumb"));
2611        assert_eq!(caps.profile(), TerminalProfile::Dumb);
2612    }
2613
2614    #[test]
2615    fn detect_ignores_invalid_test_profile() {
2616        let caps = detect_with_override(Some("not-a-real-profile"));
2617        assert_eq!(caps.profile(), TerminalProfile::Detected);
2618    }
2619
2620    #[test]
2621    fn basic_has_dumb_profile() {
2622        let caps = TerminalCapabilities::basic();
2623        assert_eq!(caps.profile(), TerminalProfile::Dumb);
2624    }
2625
2626    // ====== Capability Profile Builder ======
2627
2628    #[test]
2629    fn builder_starts_empty() {
2630        let caps = CapabilityProfileBuilder::new().build();
2631        assert_eq!(caps.profile(), TerminalProfile::Custom);
2632        assert_eq!(caps.color_depth, ColorDepth::Mono);
2633        assert!(!caps.sync_output);
2634        assert!(!caps.scroll_region);
2635        assert!(!caps.mouse_sgr);
2636    }
2637
2638    #[test]
2639    fn builder_sets_one_canonical_color_depth() {
2640        let caps = CapabilityProfileBuilder::new()
2641            .color_depth(ColorDepth::TrueColor)
2642            .build();
2643        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
2644    }
2645
2646    #[test]
2647    fn builder_set_advanced() {
2648        let caps = CapabilityProfileBuilder::new()
2649            .sync_output(true)
2650            .osc8_hyperlinks(true)
2651            .scroll_region(true)
2652            .build();
2653        assert!(caps.sync_output);
2654        assert!(caps.osc8_hyperlinks);
2655        assert!(caps.scroll_region);
2656    }
2657
2658    #[test]
2659    fn builder_set_mux() {
2660        let caps = CapabilityProfileBuilder::new()
2661            .in_tmux(true)
2662            .in_screen(false)
2663            .in_zellij(false)
2664            .build();
2665        assert!(caps.in_tmux);
2666        assert!(!caps.in_screen);
2667        assert!(caps.in_any_mux());
2668    }
2669
2670    #[test]
2671    fn builder_set_input() {
2672        let caps = CapabilityProfileBuilder::new()
2673            .kitty_keyboard(true)
2674            .focus_events(true)
2675            .bracketed_paste(true)
2676            .mouse_sgr(true)
2677            .build();
2678        assert!(caps.kitty_keyboard);
2679        assert!(caps.focus_events);
2680        assert!(caps.bracketed_paste);
2681        assert!(caps.mouse_sgr);
2682    }
2683
2684    #[test]
2685    fn builder_set_clipboard() {
2686        let caps = CapabilityProfileBuilder::new()
2687            .osc52_clipboard(true)
2688            .build();
2689        assert!(caps.osc52_clipboard);
2690    }
2691
2692    #[test]
2693    fn builder_from_profile() {
2694        let caps = CapabilityProfileBuilder::from_profile(TerminalProfile::Modern)
2695            .sync_output(false) // Override one setting
2696            .build();
2697        // Should have modern features except sync_output
2698        assert_eq!(caps.color_depth, ColorDepth::TrueColor);
2699        assert!(!caps.sync_output); // Overridden
2700        assert!(caps.osc8_hyperlinks);
2701        // But profile becomes Custom
2702        assert_eq!(caps.profile(), TerminalProfile::Custom);
2703    }
2704
2705    #[test]
2706    fn builder_chain_multiple() {
2707        let caps = TerminalCapabilities::builder()
2708            .color_depth(ColorDepth::Ansi256)
2709            .bracketed_paste(true)
2710            .mouse_sgr(true)
2711            .scroll_region(true)
2712            .build();
2713        assert_eq!(caps.color_depth, ColorDepth::Ansi256);
2714        assert!(caps.bracketed_paste);
2715        assert!(caps.mouse_sgr);
2716        assert!(caps.scroll_region);
2717        assert!(!caps.supports_true_color());
2718        assert!(!caps.sync_output);
2719    }
2720
2721    #[test]
2722    fn builder_default() {
2723        let builder = CapabilityProfileBuilder::default();
2724        let caps = builder.build();
2725        assert_eq!(caps.profile(), TerminalProfile::Custom);
2726    }
2727
2728    // ==========================================================================
2729    // Mux Compatibility Matrix Tests (bd-1rz0.19)
2730    // ==========================================================================
2731    //
2732    // These tests verify the invariants and fallback behaviors for multiplexer
2733    // compatibility as documented in the capability detection system.
2734
2735    /// Tests the complete mux × capability matrix to ensure fallbacks are correct.
2736    #[test]
2737    fn mux_compatibility_matrix() {
2738        // Test matrix covers: baseline (no mux), tmux, screen, zellij, wezterm mux
2739        // Each verifies: use_sync_output, use_scroll_region, use_hyperlinks, needs_passthrough_wrap
2740
2741        // Test baseline (no mux)
2742        {
2743            let caps = TerminalCapabilities::modern();
2744            assert!(
2745                caps.use_sync_output(),
2746                "baseline: sync_output should be enabled"
2747            );
2748            assert!(
2749                caps.use_scroll_region(),
2750                "baseline: scroll_region should be enabled"
2751            );
2752            assert!(
2753                caps.use_hyperlinks(),
2754                "baseline: hyperlinks should be enabled"
2755            );
2756            assert!(!caps.needs_passthrough_wrap(), "baseline: no wrap needed");
2757        }
2758
2759        // Test tmux
2760        {
2761            let caps = TerminalCapabilities::tmux();
2762            assert!(!caps.use_sync_output(), "tmux: sync_output disabled");
2763            assert!(!caps.use_scroll_region(), "tmux: scroll_region disabled");
2764            assert!(!caps.use_hyperlinks(), "tmux: hyperlinks disabled");
2765            assert!(caps.needs_passthrough_wrap(), "tmux: needs wrap");
2766        }
2767
2768        // Test screen
2769        {
2770            let caps = TerminalCapabilities::screen();
2771            assert!(!caps.use_sync_output(), "screen: sync_output disabled");
2772            assert!(!caps.use_scroll_region(), "screen: scroll_region disabled");
2773            assert!(!caps.use_hyperlinks(), "screen: hyperlinks disabled");
2774            assert!(caps.needs_passthrough_wrap(), "screen: needs wrap");
2775        }
2776
2777        // Test zellij
2778        {
2779            let caps = TerminalCapabilities::zellij();
2780            assert!(!caps.use_sync_output(), "zellij: sync_output disabled");
2781            assert!(!caps.use_scroll_region(), "zellij: scroll_region disabled");
2782            assert!(!caps.use_hyperlinks(), "zellij: hyperlinks disabled");
2783            assert!(
2784                !caps.needs_passthrough_wrap(),
2785                "zellij: no wrap needed (native passthrough)"
2786            );
2787        }
2788
2789        // Test wezterm mux session marker
2790        {
2791            let caps = TerminalCapabilities::builder()
2792                .in_wezterm_mux(true)
2793                .sync_output(true)
2794                .scroll_region(true)
2795                .osc8_hyperlinks(true)
2796                .build();
2797            assert!(!caps.use_sync_output(), "wezterm mux: sync_output disabled");
2798            assert!(
2799                !caps.use_scroll_region(),
2800                "wezterm mux: scroll_region disabled"
2801            );
2802            assert!(!caps.use_hyperlinks(), "wezterm mux: hyperlinks disabled");
2803            assert!(
2804                !caps.needs_passthrough_wrap(),
2805                "wezterm mux: no wrap needed"
2806            );
2807        }
2808    }
2809
2810    /// Tests that modern terminal detection works correctly even inside muxes.
2811    #[test]
2812    fn modern_terminal_in_mux_matrix() {
2813        // Modern terminal (WezTerm) detected inside each mux type
2814        // Feature detection should still work, but policies should disable
2815
2816        for (mux_name, in_tmux, in_screen, in_zellij, in_wezterm_mux) in [
2817            ("tmux", true, false, false, false),
2818            ("screen", false, true, false, false),
2819            ("zellij", false, false, true, false),
2820            ("wezterm-mux", false, false, false, true),
2821        ] {
2822            let mut env = make_env("screen-256color", "WezTerm", "truecolor");
2823            env.in_tmux = in_tmux;
2824            env.in_screen = in_screen;
2825            env.in_zellij = in_zellij;
2826            env.wezterm_unix_socket = in_wezterm_mux;
2827            let caps = TerminalCapabilities::detect_from_inputs(&env);
2828
2829            // Feature DETECTION still works
2830            assert!(
2831                caps.supports_true_color(),
2832                "{mux_name}: true_color detection should work"
2833            );
2834            assert!(
2835                !caps.sync_output,
2836                "{mux_name}: sync_output hard-disabled for WezTerm safety"
2837            );
2838
2839            // But POLICIES disable features
2840            assert!(
2841                !caps.use_sync_output(),
2842                "{mux_name}: use_sync_output() should be false"
2843            );
2844            assert!(
2845                !caps.use_scroll_region(),
2846                "{mux_name}: use_scroll_region() should be false"
2847            );
2848            assert!(
2849                !caps.use_hyperlinks(),
2850                "{mux_name}: use_hyperlinks() should be false"
2851            );
2852        }
2853    }
2854
2855    /// Tests all terminal profiles against mux detection to ensure invariants hold.
2856    #[test]
2857    fn profile_mux_invariant_matrix() {
2858        // For each predefined profile, verify mux-related invariants
2859        for profile in TerminalProfile::all_predefined() {
2860            let caps = TerminalCapabilities::from_profile(*profile);
2861            let name = profile.as_str();
2862
2863            // Invariant 1: in_any_mux() is consistent with individual flags
2864            let expected_mux =
2865                caps.in_tmux || caps.in_screen || caps.in_zellij || caps.in_wezterm_mux;
2866            assert_eq!(
2867                caps.in_any_mux(),
2868                expected_mux,
2869                "{name}: in_any_mux() should match individual flags"
2870            );
2871
2872            // Invariant 2: If in any mux, policies should disable sync/scroll/hyperlinks
2873            if caps.in_any_mux() {
2874                assert!(
2875                    !caps.use_sync_output(),
2876                    "{name}: mux should disable use_sync_output()"
2877                );
2878                assert!(
2879                    !caps.use_scroll_region(),
2880                    "{name}: mux should disable use_scroll_region()"
2881                );
2882                assert!(
2883                    !caps.use_hyperlinks(),
2884                    "{name}: mux should disable use_hyperlinks()"
2885                );
2886            }
2887
2888            // Invariant 3: Only tmux and screen need passthrough wrap, not zellij
2889            if caps.in_tmux || caps.in_screen {
2890                assert!(
2891                    caps.needs_passthrough_wrap(),
2892                    "{name}: tmux/screen should need passthrough wrap"
2893                );
2894            } else if caps.in_zellij {
2895                assert!(
2896                    !caps.needs_passthrough_wrap(),
2897                    "{name}: zellij should NOT need passthrough wrap"
2898                );
2899            }
2900        }
2901    }
2902
2903    /// Tests the fallback ordering: sync_output → scroll_region → overlay_redraw
2904    #[test]
2905    fn fallback_ordering_matrix() {
2906        use crate::inline_mode::InlineStrategy;
2907
2908        // Case 1: Both sync and scroll available -> ScrollRegion strategy
2909        let caps_full = TerminalCapabilities::builder()
2910            .sync_output(true)
2911            .scroll_region(true)
2912            .build();
2913        assert_eq!(
2914            InlineStrategy::select(&caps_full),
2915            InlineStrategy::ScrollRegion,
2916            "full capabilities should use ScrollRegion"
2917        );
2918
2919        // Case 2: Scroll but no sync -> Hybrid strategy
2920        let caps_hybrid = TerminalCapabilities::builder()
2921            .sync_output(false)
2922            .scroll_region(true)
2923            .build();
2924        assert_eq!(
2925            InlineStrategy::select(&caps_hybrid),
2926            InlineStrategy::Hybrid,
2927            "scroll without sync should use Hybrid"
2928        );
2929
2930        // Case 3: Neither -> OverlayRedraw strategy
2931        let caps_none = TerminalCapabilities::builder()
2932            .sync_output(false)
2933            .scroll_region(false)
2934            .build();
2935        assert_eq!(
2936            InlineStrategy::select(&caps_none),
2937            InlineStrategy::OverlayRedraw,
2938            "no capabilities should use OverlayRedraw"
2939        );
2940
2941        // Case 4: In mux (even with capabilities) -> OverlayRedraw
2942        let caps_tmux = TerminalCapabilities::tmux();
2943        assert_eq!(
2944            InlineStrategy::select(&caps_tmux),
2945            InlineStrategy::OverlayRedraw,
2946            "tmux should force OverlayRedraw"
2947        );
2948    }
2949
2950    /// Tests the complete terminal × mux matrix for strategy selection.
2951    #[test]
2952    fn terminal_mux_strategy_matrix() {
2953        use crate::inline_mode::InlineStrategy;
2954
2955        struct TestCase {
2956            name: &'static str,
2957            profile: TerminalProfile,
2958            expected: InlineStrategy,
2959        }
2960
2961        let cases = [
2962            TestCase {
2963                name: "modern (no mux)",
2964                profile: TerminalProfile::Modern,
2965                expected: InlineStrategy::ScrollRegion,
2966            },
2967            TestCase {
2968                name: "kitty (no mux)",
2969                profile: TerminalProfile::Kitty,
2970                expected: InlineStrategy::ScrollRegion,
2971            },
2972            TestCase {
2973                name: "xterm-256color (no mux)",
2974                profile: TerminalProfile::Xterm256Color,
2975                expected: InlineStrategy::Hybrid, // has scroll_region but no sync_output
2976            },
2977            TestCase {
2978                name: "xterm (no mux)",
2979                profile: TerminalProfile::Xterm,
2980                expected: InlineStrategy::Hybrid,
2981            },
2982            TestCase {
2983                name: "vt100 (no mux)",
2984                profile: TerminalProfile::Vt100,
2985                expected: InlineStrategy::Hybrid,
2986            },
2987            TestCase {
2988                name: "dumb",
2989                profile: TerminalProfile::Dumb,
2990                expected: InlineStrategy::OverlayRedraw, // no scroll_region
2991            },
2992            TestCase {
2993                name: "tmux",
2994                profile: TerminalProfile::Tmux,
2995                expected: InlineStrategy::OverlayRedraw,
2996            },
2997            TestCase {
2998                name: "screen",
2999                profile: TerminalProfile::Screen,
3000                expected: InlineStrategy::OverlayRedraw,
3001            },
3002            TestCase {
3003                name: "zellij",
3004                profile: TerminalProfile::Zellij,
3005                expected: InlineStrategy::OverlayRedraw,
3006            },
3007        ];
3008
3009        for case in cases {
3010            let caps = TerminalCapabilities::from_profile(case.profile);
3011            let actual = InlineStrategy::select(&caps);
3012            assert_eq!(
3013                actual, case.expected,
3014                "{}: expected {:?}, got {:?}",
3015                case.name, case.expected, actual
3016            );
3017        }
3018    }
3019
3020    // ====== SharedCapabilities tests (bd-3l9qr.2) ======
3021
3022    #[test]
3023    fn shared_caps_load_returns_initial() {
3024        let shared = SharedCapabilities::new(TerminalCapabilities::modern());
3025        assert_eq!(shared.load().color_depth, ColorDepth::TrueColor);
3026        assert!(shared.load().sync_output);
3027    }
3028
3029    #[test]
3030    fn shared_caps_store_replaces_value() {
3031        let shared = SharedCapabilities::new(TerminalCapabilities::modern());
3032        shared.store(TerminalCapabilities::dumb());
3033        let loaded = shared.load();
3034        assert_eq!(loaded.color_depth, ColorDepth::Mono);
3035        assert!(!loaded.sync_output);
3036    }
3037
3038    #[test]
3039    fn shared_caps_concurrent_read_write() {
3040        use std::sync::{Arc, Barrier};
3041        use std::thread;
3042
3043        let shared = Arc::new(SharedCapabilities::new(TerminalCapabilities::basic()));
3044        let barrier = Arc::new(Barrier::new(5)); // 4 readers + 1 writer
3045
3046        let readers: Vec<_> = (0..4)
3047            .map(|_| {
3048                let s = Arc::clone(&shared);
3049                let b = Arc::clone(&barrier);
3050                thread::spawn(move || {
3051                    b.wait();
3052                    for _ in 0..10_000 {
3053                        let caps = s.load();
3054                        // Must be a valid TerminalCapabilities (no torn reads).
3055                        let _ = caps.use_sync_output();
3056                        let _ = caps.color_depth;
3057                    }
3058                })
3059            })
3060            .collect();
3061
3062        let writer = {
3063            let s = Arc::clone(&shared);
3064            let b = Arc::clone(&barrier);
3065            thread::spawn(move || {
3066                b.wait();
3067                for i in 0..1_000 {
3068                    if i % 2 == 0 {
3069                        s.store(TerminalCapabilities::modern());
3070                    } else {
3071                        s.store(TerminalCapabilities::dumb());
3072                    }
3073                }
3074            })
3075        };
3076
3077        writer.join().unwrap();
3078        for h in readers {
3079            h.join().unwrap();
3080        }
3081    }
3082}
3083
3084// ==========================================================================
3085// Property Tests for Mux Compatibility (bd-1rz0.19)
3086// ==========================================================================
3087
3088#[cfg(test)]
3089mod proptests {
3090    use super::*;
3091    use proptest::prelude::*;
3092
3093    proptest! {
3094        /// Property: in_any_mux() is always consistent with individual mux flags.
3095        #[test]
3096        fn prop_in_any_mux_consistent(
3097            in_tmux in any::<bool>(),
3098            in_screen in any::<bool>(),
3099            in_zellij in any::<bool>(),
3100            in_wezterm_mux in any::<bool>(),
3101        ) {
3102            let caps = TerminalCapabilities::builder()
3103                .in_tmux(in_tmux)
3104                .in_screen(in_screen)
3105                .in_zellij(in_zellij)
3106                .in_wezterm_mux(in_wezterm_mux)
3107                .build();
3108
3109            let expected = in_tmux || in_screen || in_zellij || in_wezterm_mux;
3110            prop_assert_eq!(caps.in_any_mux(), expected);
3111        }
3112
3113        /// Property: If in any mux, use_sync_output() is always false (regardless of sync_output flag).
3114        #[test]
3115        fn prop_mux_disables_sync_output(
3116            in_tmux in any::<bool>(),
3117            in_screen in any::<bool>(),
3118            in_zellij in any::<bool>(),
3119            in_wezterm_mux in any::<bool>(),
3120            sync_output in any::<bool>(),
3121        ) {
3122            let caps = TerminalCapabilities::builder()
3123                .in_tmux(in_tmux)
3124                .in_screen(in_screen)
3125                .in_zellij(in_zellij)
3126                .in_wezterm_mux(in_wezterm_mux)
3127                .sync_output(sync_output)
3128                .build();
3129
3130            if caps.in_any_mux() {
3131                prop_assert!(!caps.use_sync_output(), "mux should disable sync_output policy");
3132            }
3133        }
3134
3135        /// Property: If in any mux, use_scroll_region() is always false.
3136        #[test]
3137        fn prop_mux_disables_scroll_region(
3138            in_tmux in any::<bool>(),
3139            in_screen in any::<bool>(),
3140            in_zellij in any::<bool>(),
3141            in_wezterm_mux in any::<bool>(),
3142            scroll_region in any::<bool>(),
3143        ) {
3144            let caps = TerminalCapabilities::builder()
3145                .in_tmux(in_tmux)
3146                .in_screen(in_screen)
3147                .in_zellij(in_zellij)
3148                .in_wezterm_mux(in_wezterm_mux)
3149                .scroll_region(scroll_region)
3150                .build();
3151
3152            if caps.in_any_mux() {
3153                prop_assert!(!caps.use_scroll_region(), "mux should disable scroll_region policy");
3154            }
3155        }
3156
3157        /// Property: If in any mux, use_hyperlinks() is always false.
3158        #[test]
3159        fn prop_mux_disables_hyperlinks(
3160            in_tmux in any::<bool>(),
3161            in_screen in any::<bool>(),
3162            in_zellij in any::<bool>(),
3163            in_wezterm_mux in any::<bool>(),
3164            osc8_hyperlinks in any::<bool>(),
3165        ) {
3166            let caps = TerminalCapabilities::builder()
3167                .in_tmux(in_tmux)
3168                .in_screen(in_screen)
3169                .in_zellij(in_zellij)
3170                .in_wezterm_mux(in_wezterm_mux)
3171                .osc8_hyperlinks(osc8_hyperlinks)
3172                .build();
3173
3174            if caps.in_any_mux() {
3175                prop_assert!(!caps.use_hyperlinks(), "mux should disable hyperlinks policy");
3176            }
3177        }
3178
3179        /// Property: needs_passthrough_wrap() is true IFF in_tmux || in_screen (NOT zellij).
3180        #[test]
3181        fn prop_passthrough_wrap_logic(
3182            in_tmux in any::<bool>(),
3183            in_screen in any::<bool>(),
3184            in_zellij in any::<bool>(),
3185            in_wezterm_mux in any::<bool>(),
3186        ) {
3187            let caps = TerminalCapabilities::builder()
3188                .in_tmux(in_tmux)
3189                .in_screen(in_screen)
3190                .in_zellij(in_zellij)
3191                .in_wezterm_mux(in_wezterm_mux)
3192                .build();
3193
3194            let expected = in_tmux || in_screen;  // NOT zellij
3195            prop_assert_eq!(caps.needs_passthrough_wrap(), expected);
3196        }
3197
3198        /// Property: Policy methods return false when capability is not set (regardless of mux).
3199        #[test]
3200        fn prop_policy_false_when_capability_off(
3201            in_tmux in any::<bool>(),
3202            in_screen in any::<bool>(),
3203            in_zellij in any::<bool>(),
3204            in_wezterm_mux in any::<bool>(),
3205        ) {
3206            let caps = TerminalCapabilities::builder()
3207                .in_tmux(in_tmux)
3208                .in_screen(in_screen)
3209                .in_zellij(in_zellij)
3210                .in_wezterm_mux(in_wezterm_mux)
3211                .sync_output(false)
3212                .scroll_region(false)
3213                .osc8_hyperlinks(false)
3214                .osc52_clipboard(false)
3215                .build();
3216
3217            prop_assert!(!caps.use_sync_output(), "sync_output=false implies use_sync_output()=false");
3218            prop_assert!(!caps.use_scroll_region(), "scroll_region=false implies use_scroll_region()=false");
3219            prop_assert!(!caps.use_hyperlinks(), "osc8_hyperlinks=false implies use_hyperlinks()=false");
3220            prop_assert!(!caps.use_clipboard(), "osc52_clipboard=false implies use_clipboard()=false");
3221        }
3222
3223        /// Property: NO_COLOR disables all color-related features but not non-visual features.
3224        #[test]
3225        fn prop_no_color_preserves_non_visual(no_color in any::<bool>()) {
3226            let env = DetectInputs {
3227                no_color,
3228                term: "xterm-256color".to_string(),
3229                term_program: "WezTerm".to_string(),
3230                colorterm: "truecolor".to_string(),
3231                in_tmux: false,
3232                in_screen: false,
3233                in_zellij: false,
3234                wezterm_unix_socket: false,
3235                wezterm_pane: false,
3236                wezterm_executable: false,
3237                kitty_window_id: false,
3238                wt_session: false,
3239            };
3240            let caps = TerminalCapabilities::detect_from_inputs(&env);
3241
3242            if no_color {
3243                prop_assert_eq!(caps.color_depth, ColorDepth::Mono);
3244                prop_assert!(!caps.osc8_hyperlinks, "NO_COLOR disables hyperlinks");
3245            }
3246
3247            // Non-visual features preserved regardless of NO_COLOR
3248            prop_assert!(
3249                !caps.sync_output,
3250                "WezTerm sync_output stays disabled despite NO_COLOR"
3251            );
3252            prop_assert!(caps.bracketed_paste, "bracketed_paste preserved despite NO_COLOR");
3253        }
3254    }
3255}