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