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