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