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