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