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    #[must_use]
690    pub fn builder() -> CapabilityProfileBuilder {
691        CapabilityProfileBuilder::new()
692    }
693}
694
695// ============================================================================
696// Capability Profile Builder (bd-k4lj.2)
697// ============================================================================
698
699/// Builder for custom terminal capability profiles.
700///
701/// Enables fine-grained control over capability configuration for testing
702/// and simulation purposes.
703///
704/// # Example
705///
706/// ```
707/// use ftui_core::terminal_capabilities::CapabilityProfileBuilder;
708///
709/// let profile = CapabilityProfileBuilder::new()
710///     .colors_256(true)
711///     .true_color(true)
712///     .mouse_sgr(true)
713///     .bracketed_paste(true)
714///     .build();
715///
716/// assert!(profile.colors_256);
717/// assert!(profile.true_color);
718/// ```
719#[derive(Debug, Clone)]
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    #[must_use]
733    pub fn new() -> Self {
734        Self {
735            caps: TerminalCapabilities {
736                profile: TerminalProfile::Custom,
737                true_color: false,
738                colors_256: false,
739                unicode_box_drawing: false,
740                unicode_emoji: false,
741                double_width: false,
742                sync_output: false,
743                osc8_hyperlinks: false,
744                scroll_region: false,
745                in_tmux: false,
746                in_screen: false,
747                in_zellij: false,
748                kitty_keyboard: false,
749                focus_events: false,
750                bracketed_paste: false,
751                mouse_sgr: false,
752                osc52_clipboard: false,
753            },
754        }
755    }
756
757    /// Start from an existing profile.
758    #[must_use]
759    pub fn from_profile(profile: TerminalProfile) -> Self {
760        let mut caps = TerminalCapabilities::from_profile(profile);
761        caps.profile = TerminalProfile::Custom;
762        Self { caps }
763    }
764
765    /// Build the final capability set.
766    #[must_use]
767    pub fn build(self) -> TerminalCapabilities {
768        self.caps
769    }
770
771    // ── Color Capabilities ─────────────────────────────────────────────
772
773    /// Set true color (24-bit RGB) support.
774    #[must_use]
775    pub const fn true_color(mut self, enabled: bool) -> Self {
776        self.caps.true_color = enabled;
777        self
778    }
779
780    /// Set 256-color palette support.
781    #[must_use]
782    pub const fn colors_256(mut self, enabled: bool) -> Self {
783        self.caps.colors_256 = enabled;
784        self
785    }
786
787    // ── Advanced Features ──────────────────────────────────────────────
788
789    /// Set synchronized output (DEC mode 2026) support.
790    #[must_use]
791    pub const fn sync_output(mut self, enabled: bool) -> Self {
792        self.caps.sync_output = enabled;
793        self
794    }
795
796    /// Set OSC 8 hyperlinks support.
797    #[must_use]
798    pub const fn osc8_hyperlinks(mut self, enabled: bool) -> Self {
799        self.caps.osc8_hyperlinks = enabled;
800        self
801    }
802
803    /// Set scroll region (DECSTBM) support.
804    #[must_use]
805    pub const fn scroll_region(mut self, enabled: bool) -> Self {
806        self.caps.scroll_region = enabled;
807        self
808    }
809
810    // ── Multiplexer Flags ──────────────────────────────────────────────
811
812    /// Set whether running inside tmux.
813    #[must_use]
814    pub const fn in_tmux(mut self, enabled: bool) -> Self {
815        self.caps.in_tmux = enabled;
816        self
817    }
818
819    /// Set whether running inside GNU screen.
820    #[must_use]
821    pub const fn in_screen(mut self, enabled: bool) -> Self {
822        self.caps.in_screen = enabled;
823        self
824    }
825
826    /// Set whether running inside Zellij.
827    #[must_use]
828    pub const fn in_zellij(mut self, enabled: bool) -> Self {
829        self.caps.in_zellij = enabled;
830        self
831    }
832
833    // ── Input Features ─────────────────────────────────────────────────
834
835    /// Set Kitty keyboard protocol support.
836    #[must_use]
837    pub const fn kitty_keyboard(mut self, enabled: bool) -> Self {
838        self.caps.kitty_keyboard = enabled;
839        self
840    }
841
842    /// Set focus event reporting support.
843    #[must_use]
844    pub const fn focus_events(mut self, enabled: bool) -> Self {
845        self.caps.focus_events = enabled;
846        self
847    }
848
849    /// Set bracketed paste mode support.
850    #[must_use]
851    pub const fn bracketed_paste(mut self, enabled: bool) -> Self {
852        self.caps.bracketed_paste = enabled;
853        self
854    }
855
856    /// Set SGR mouse protocol support.
857    #[must_use]
858    pub const fn mouse_sgr(mut self, enabled: bool) -> Self {
859        self.caps.mouse_sgr = enabled;
860        self
861    }
862
863    // ── Optional Features ──────────────────────────────────────────────
864
865    /// Set OSC 52 clipboard support.
866    #[must_use]
867    pub const fn osc52_clipboard(mut self, enabled: bool) -> Self {
868        self.caps.osc52_clipboard = enabled;
869        self
870    }
871}
872
873impl TerminalCapabilities {
874    /// Detect terminal capabilities from the environment.
875    ///
876    /// This examines environment variables to determine what features the
877    /// current terminal supports. When in doubt, capabilities are disabled
878    /// for safety.
879    #[must_use]
880    pub fn detect() -> Self {
881        let value = env::var("FTUI_TEST_PROFILE").ok();
882        Self::detect_with_test_profile_override(value.as_deref())
883    }
884
885    fn detect_with_test_profile_override(value: Option<&str>) -> Self {
886        if let Some(value) = value
887            && let Ok(profile) = TerminalProfile::from_str(value.trim())
888            && profile != TerminalProfile::Detected
889        {
890            return Self::from_profile(profile);
891        }
892        let env = DetectInputs::from_env();
893        Self::detect_from_inputs(&env)
894    }
895
896    fn detect_from_inputs(env: &DetectInputs) -> Self {
897        // Multiplexer detection
898        let in_tmux = env.in_tmux;
899        let in_screen = env.in_screen;
900        let in_zellij = env.in_zellij;
901        let in_any_mux = in_tmux || in_screen || in_zellij;
902
903        let term = env.term.as_str();
904        let term_program = env.term_program.as_str();
905        let colorterm = env.colorterm.as_str();
906
907        // Windows Terminal detection
908        let is_windows_terminal = env.wt_session;
909
910        // Check for dumb terminal
911        //
912        // NOTE: Windows Terminal often omits TERM; treat it as non-dumb when
913        // WT_SESSION is present so we don't incorrectly disable features.
914        let is_dumb = term == "dumb" || (term.is_empty() && !is_windows_terminal);
915
916        // Kitty detection
917        let is_kitty = env.kitty_window_id || term.contains("kitty");
918
919        // Check if running in a modern terminal
920        let is_modern_terminal = MODERN_TERMINALS
921            .iter()
922            .any(|t| term_program.contains(t) || term.contains(&t.to_lowercase()))
923            || is_windows_terminal;
924
925        // True color detection
926        let true_color = !env.no_color
927            && !is_dumb
928            && (colorterm.contains("truecolor")
929                || colorterm.contains("24bit")
930                || is_modern_terminal
931                || is_kitty);
932
933        // 256-color detection
934        let colors_256 = !env.no_color
935            && !is_dumb
936            && (true_color || term.contains("256color") || term.contains("256"));
937
938        // Synchronized output detection
939        let sync_output = !is_dumb
940            && (is_kitty
941                || SYNC_OUTPUT_TERMINALS
942                    .iter()
943                    .any(|t| term_program.contains(t)));
944
945        // OSC 8 hyperlinks detection
946        let osc8_hyperlinks = !env.no_color && !is_dumb && is_modern_terminal;
947
948        // Scroll region support (broadly available except dumb)
949        let scroll_region = !is_dumb;
950
951        // Kitty keyboard protocol (kitty + other compatible terminals)
952        let kitty_keyboard = is_kitty
953            || KITTY_KEYBOARD_TERMINALS
954                .iter()
955                .any(|t| term_program.contains(t) || term.contains(&t.to_lowercase()));
956
957        // Focus events (available in most modern terminals)
958        let focus_events = !is_dumb && (is_modern_terminal || is_kitty);
959
960        // Bracketed paste (broadly available except dumb)
961        let bracketed_paste = !is_dumb;
962
963        // SGR mouse (broadly available except dumb)
964        let mouse_sgr = !is_dumb;
965
966        // OSC 52 clipboard (security restricted in multiplexers by default)
967        let osc52_clipboard = !is_dumb && !in_any_mux && (is_modern_terminal || is_kitty);
968
969        // Unicode glyph support (assume available in modern terminals)
970        let unicode_box_drawing = !is_dumb;
971        let unicode_emoji = !is_dumb && (is_modern_terminal || is_kitty);
972        let double_width = !is_dumb;
973
974        Self {
975            profile: TerminalProfile::Detected,
976            true_color,
977            colors_256,
978            unicode_box_drawing,
979            unicode_emoji,
980            double_width,
981            sync_output,
982            osc8_hyperlinks,
983            scroll_region,
984            in_tmux,
985            in_screen,
986            in_zellij,
987            kitty_keyboard,
988            focus_events,
989            bracketed_paste,
990            mouse_sgr,
991            osc52_clipboard,
992        }
993    }
994
995    /// Create a minimal fallback capability set.
996    ///
997    /// This is safe to use on any terminal, including dumb terminals.
998    /// All advanced features are disabled.
999    #[must_use]
1000    pub const fn basic() -> Self {
1001        Self {
1002            profile: TerminalProfile::Dumb,
1003            true_color: false,
1004            colors_256: false,
1005            unicode_box_drawing: false,
1006            unicode_emoji: false,
1007            double_width: false,
1008            sync_output: false,
1009            osc8_hyperlinks: false,
1010            scroll_region: false,
1011            in_tmux: false,
1012            in_screen: false,
1013            in_zellij: false,
1014            kitty_keyboard: false,
1015            focus_events: false,
1016            bracketed_paste: false,
1017            mouse_sgr: false,
1018            osc52_clipboard: false,
1019        }
1020    }
1021
1022    /// Check if running inside any terminal multiplexer.
1023    ///
1024    /// This includes tmux, GNU screen, and Zellij.
1025    #[must_use]
1026    #[inline]
1027    pub const fn in_any_mux(&self) -> bool {
1028        self.in_tmux || self.in_screen || self.in_zellij
1029    }
1030
1031    /// Check if any color support is available.
1032    #[must_use]
1033    #[inline]
1034    pub const fn has_color(&self) -> bool {
1035        self.true_color || self.colors_256
1036    }
1037
1038    /// Get the maximum color depth as a string identifier.
1039    #[must_use]
1040    pub const fn color_depth(&self) -> &'static str {
1041        if self.true_color {
1042            "truecolor"
1043        } else if self.colors_256 {
1044            "256"
1045        } else {
1046            "mono"
1047        }
1048    }
1049
1050    // --- Mux-aware feature policies ---
1051    //
1052    // These methods apply conservative defaults when running inside a
1053    // multiplexer to avoid quirks with sequence passthrough.
1054
1055    /// Whether synchronized output (DEC 2026) should be used.
1056    ///
1057    /// Disabled in multiplexers because passthrough is unreliable
1058    /// for mode-setting sequences.
1059    #[must_use]
1060    #[inline]
1061    pub const fn use_sync_output(&self) -> bool {
1062        if self.in_tmux || self.in_screen || self.in_zellij {
1063            return false;
1064        }
1065        self.sync_output
1066    }
1067
1068    /// Whether scroll-region optimization (DECSTBM) is safe to use.
1069    ///
1070    /// Disabled in multiplexers due to inconsistent scroll margin
1071    /// handling across tmux, screen, and Zellij.
1072    #[must_use]
1073    #[inline]
1074    pub const fn use_scroll_region(&self) -> bool {
1075        if self.in_tmux || self.in_screen || self.in_zellij {
1076            return false;
1077        }
1078        self.scroll_region
1079    }
1080
1081    /// Whether OSC 8 hyperlinks should be emitted.
1082    ///
1083    /// Disabled in tmux and screen because passthrough for OSC
1084    /// sequences is fragile. Zellij (0.39+) has better passthrough
1085    /// but is still disabled by default for safety.
1086    #[must_use]
1087    #[inline]
1088    pub const fn use_hyperlinks(&self) -> bool {
1089        if self.in_tmux || self.in_screen || self.in_zellij {
1090            return false;
1091        }
1092        self.osc8_hyperlinks
1093    }
1094
1095    /// Whether OSC 52 clipboard access should be used.
1096    ///
1097    /// Already gated by mux detection in `detect()`, but this method
1098    /// provides a consistent policy interface.
1099    #[must_use]
1100    #[inline]
1101    pub const fn use_clipboard(&self) -> bool {
1102        if self.in_tmux || self.in_screen || self.in_zellij {
1103            return false;
1104        }
1105        self.osc52_clipboard
1106    }
1107
1108    /// Whether the passthrough wrapping is needed for this environment.
1109    ///
1110    /// Returns `true` if running in tmux or screen, which require
1111    /// DCS passthrough for escape sequences to reach the inner terminal.
1112    /// Zellij handles passthrough natively and doesn't need wrapping.
1113    #[must_use]
1114    #[inline]
1115    pub const fn needs_passthrough_wrap(&self) -> bool {
1116        self.in_tmux || self.in_screen
1117    }
1118}
1119
1120#[cfg(test)]
1121mod tests {
1122    use super::*;
1123
1124    fn detect_with_override(value: Option<&str>) -> TerminalCapabilities {
1125        TerminalCapabilities::detect_with_test_profile_override(value)
1126    }
1127
1128    #[test]
1129    fn basic_is_minimal() {
1130        let caps = TerminalCapabilities::basic();
1131        assert!(!caps.true_color);
1132        assert!(!caps.colors_256);
1133        assert!(!caps.sync_output);
1134        assert!(!caps.osc8_hyperlinks);
1135        assert!(!caps.scroll_region);
1136        assert!(!caps.in_tmux);
1137        assert!(!caps.in_screen);
1138        assert!(!caps.in_zellij);
1139        assert!(!caps.kitty_keyboard);
1140        assert!(!caps.focus_events);
1141        assert!(!caps.bracketed_paste);
1142        assert!(!caps.mouse_sgr);
1143        assert!(!caps.osc52_clipboard);
1144    }
1145
1146    #[test]
1147    fn basic_is_default() {
1148        let basic = TerminalCapabilities::basic();
1149        let default = TerminalCapabilities::default();
1150        assert_eq!(basic, default);
1151    }
1152
1153    #[test]
1154    fn in_any_mux_logic() {
1155        let mut caps = TerminalCapabilities::basic();
1156        assert!(!caps.in_any_mux());
1157
1158        caps.in_tmux = true;
1159        assert!(caps.in_any_mux());
1160
1161        caps.in_tmux = false;
1162        caps.in_screen = true;
1163        assert!(caps.in_any_mux());
1164
1165        caps.in_screen = false;
1166        caps.in_zellij = true;
1167        assert!(caps.in_any_mux());
1168    }
1169
1170    #[test]
1171    fn has_color_logic() {
1172        let mut caps = TerminalCapabilities::basic();
1173        assert!(!caps.has_color());
1174
1175        caps.colors_256 = true;
1176        assert!(caps.has_color());
1177
1178        caps.colors_256 = false;
1179        caps.true_color = true;
1180        assert!(caps.has_color());
1181    }
1182
1183    #[test]
1184    fn color_depth_strings() {
1185        let mut caps = TerminalCapabilities::basic();
1186        assert_eq!(caps.color_depth(), "mono");
1187
1188        caps.colors_256 = true;
1189        assert_eq!(caps.color_depth(), "256");
1190
1191        caps.true_color = true;
1192        assert_eq!(caps.color_depth(), "truecolor");
1193    }
1194
1195    #[test]
1196    fn detect_does_not_panic() {
1197        // detect() should never panic, even with unusual environment
1198        let _caps = TerminalCapabilities::detect();
1199    }
1200
1201    #[test]
1202    fn windows_terminal_not_dumb_when_term_missing() {
1203        let env = DetectInputs {
1204            no_color: false,
1205            term: String::new(),
1206            term_program: String::new(),
1207            colorterm: String::new(),
1208            in_tmux: false,
1209            in_screen: false,
1210            in_zellij: false,
1211            kitty_window_id: false,
1212            wt_session: true,
1213        };
1214
1215        let caps = TerminalCapabilities::detect_from_inputs(&env);
1216        assert!(caps.true_color, "WT_SESSION implies true color by default");
1217        assert!(caps.colors_256, "truecolor implies 256-color");
1218        assert!(
1219            caps.osc8_hyperlinks,
1220            "WT_SESSION implies OSC 8 hyperlink support by default"
1221        );
1222        assert!(
1223            caps.bracketed_paste,
1224            "WT_SESSION should not be treated as dumb"
1225        );
1226        assert!(caps.mouse_sgr, "WT_SESSION should not be treated as dumb");
1227    }
1228
1229    #[test]
1230    #[cfg(target_os = "windows")]
1231    fn detect_windows_terminal_from_wt_session() {
1232        let mut env = make_env("", "", "");
1233        env.wt_session = true;
1234        let caps = TerminalCapabilities::detect_from_inputs(&env);
1235        assert!(caps.true_color, "WT_SESSION implies true color");
1236        assert!(caps.colors_256, "WT_SESSION implies 256-color");
1237        assert!(caps.osc8_hyperlinks, "WT_SESSION implies OSC 8 support");
1238    }
1239
1240    #[test]
1241    fn no_color_disables_color_and_links() {
1242        let env = DetectInputs {
1243            no_color: true,
1244            term: "xterm-256color".to_string(),
1245            term_program: "WezTerm".to_string(),
1246            colorterm: "truecolor".to_string(),
1247            in_tmux: false,
1248            in_screen: false,
1249            in_zellij: false,
1250            kitty_window_id: false,
1251            wt_session: false,
1252        };
1253
1254        let caps = TerminalCapabilities::detect_from_inputs(&env);
1255        assert!(!caps.true_color, "NO_COLOR must disable true color");
1256        assert!(!caps.colors_256, "NO_COLOR must disable 256-color");
1257        assert!(
1258            !caps.osc8_hyperlinks,
1259            "NO_COLOR must disable OSC 8 hyperlinks"
1260        );
1261    }
1262
1263    // --- Mux-aware policy tests ---
1264
1265    #[test]
1266    fn use_sync_output_disabled_in_tmux() {
1267        let mut caps = TerminalCapabilities::basic();
1268        caps.sync_output = true;
1269        assert!(caps.use_sync_output());
1270
1271        caps.in_tmux = true;
1272        assert!(!caps.use_sync_output());
1273    }
1274
1275    #[test]
1276    fn use_sync_output_disabled_in_screen() {
1277        let mut caps = TerminalCapabilities::basic();
1278        caps.sync_output = true;
1279        caps.in_screen = true;
1280        assert!(!caps.use_sync_output());
1281    }
1282
1283    #[test]
1284    fn use_sync_output_disabled_in_zellij() {
1285        let mut caps = TerminalCapabilities::basic();
1286        caps.sync_output = true;
1287        caps.in_zellij = true;
1288        assert!(!caps.use_sync_output());
1289    }
1290
1291    #[test]
1292    fn use_scroll_region_disabled_in_mux() {
1293        let mut caps = TerminalCapabilities::basic();
1294        caps.scroll_region = true;
1295        assert!(caps.use_scroll_region());
1296
1297        caps.in_tmux = true;
1298        assert!(!caps.use_scroll_region());
1299
1300        caps.in_tmux = false;
1301        caps.in_screen = true;
1302        assert!(!caps.use_scroll_region());
1303
1304        caps.in_screen = false;
1305        caps.in_zellij = true;
1306        assert!(!caps.use_scroll_region());
1307    }
1308
1309    #[test]
1310    fn use_hyperlinks_disabled_in_mux() {
1311        let mut caps = TerminalCapabilities::basic();
1312        caps.osc8_hyperlinks = true;
1313        assert!(caps.use_hyperlinks());
1314
1315        caps.in_tmux = true;
1316        assert!(!caps.use_hyperlinks());
1317    }
1318
1319    #[test]
1320    fn use_clipboard_disabled_in_mux() {
1321        let mut caps = TerminalCapabilities::basic();
1322        caps.osc52_clipboard = true;
1323        assert!(caps.use_clipboard());
1324
1325        caps.in_screen = true;
1326        assert!(!caps.use_clipboard());
1327    }
1328
1329    #[test]
1330    fn needs_passthrough_wrap_only_for_tmux_screen() {
1331        let mut caps = TerminalCapabilities::basic();
1332        assert!(!caps.needs_passthrough_wrap());
1333
1334        caps.in_tmux = true;
1335        assert!(caps.needs_passthrough_wrap());
1336
1337        caps.in_tmux = false;
1338        caps.in_screen = true;
1339        assert!(caps.needs_passthrough_wrap());
1340
1341        // Zellij doesn't need wrapping
1342        caps.in_screen = false;
1343        caps.in_zellij = true;
1344        assert!(!caps.needs_passthrough_wrap());
1345    }
1346
1347    #[test]
1348    fn policies_return_false_when_capability_absent() {
1349        // Even without mux, policies return false when capability is off
1350        let caps = TerminalCapabilities::basic();
1351        assert!(!caps.use_sync_output());
1352        assert!(!caps.use_scroll_region());
1353        assert!(!caps.use_hyperlinks());
1354        assert!(!caps.use_clipboard());
1355    }
1356
1357    // ====== Specific terminal detection ======
1358
1359    fn make_env(term: &str, term_program: &str, colorterm: &str) -> DetectInputs {
1360        DetectInputs {
1361            no_color: false,
1362            term: term.to_string(),
1363            term_program: term_program.to_string(),
1364            colorterm: colorterm.to_string(),
1365            in_tmux: false,
1366            in_screen: false,
1367            in_zellij: false,
1368            kitty_window_id: false,
1369            wt_session: false,
1370        }
1371    }
1372
1373    #[test]
1374    fn detect_dumb_terminal() {
1375        let env = make_env("dumb", "", "");
1376        let caps = TerminalCapabilities::detect_from_inputs(&env);
1377        assert!(!caps.true_color);
1378        assert!(!caps.colors_256);
1379        assert!(!caps.sync_output);
1380        assert!(!caps.osc8_hyperlinks);
1381        assert!(!caps.scroll_region);
1382        assert!(!caps.focus_events);
1383        assert!(!caps.bracketed_paste);
1384        assert!(!caps.mouse_sgr);
1385    }
1386
1387    #[test]
1388    fn detect_dumb_overrides_truecolor_env() {
1389        let env = make_env("dumb", "WezTerm", "truecolor");
1390        let caps = TerminalCapabilities::detect_from_inputs(&env);
1391        assert!(!caps.true_color, "dumb should override COLORTERM");
1392        assert!(!caps.colors_256);
1393        assert!(!caps.bracketed_paste);
1394        assert!(!caps.mouse_sgr);
1395        assert!(!caps.osc8_hyperlinks);
1396    }
1397
1398    #[test]
1399    fn detect_empty_term_is_dumb() {
1400        let env = make_env("", "", "");
1401        let caps = TerminalCapabilities::detect_from_inputs(&env);
1402        assert!(!caps.true_color);
1403        assert!(!caps.bracketed_paste);
1404    }
1405
1406    #[test]
1407    fn detect_xterm_256color() {
1408        let env = make_env("xterm-256color", "", "");
1409        let caps = TerminalCapabilities::detect_from_inputs(&env);
1410        assert!(caps.colors_256, "xterm-256color implies 256 color");
1411        assert!(!caps.true_color, "256color alone does not imply truecolor");
1412        assert!(caps.bracketed_paste);
1413        assert!(caps.mouse_sgr);
1414        assert!(caps.scroll_region);
1415    }
1416
1417    #[test]
1418    fn detect_colorterm_truecolor() {
1419        let env = make_env("xterm-256color", "", "truecolor");
1420        let caps = TerminalCapabilities::detect_from_inputs(&env);
1421        assert!(caps.true_color, "COLORTERM=truecolor enables truecolor");
1422        assert!(caps.colors_256, "truecolor implies 256-color");
1423    }
1424
1425    #[test]
1426    fn detect_colorterm_24bit() {
1427        let env = make_env("xterm-256color", "", "24bit");
1428        let caps = TerminalCapabilities::detect_from_inputs(&env);
1429        assert!(caps.true_color, "COLORTERM=24bit enables truecolor");
1430    }
1431
1432    #[test]
1433    fn detect_kitty_by_window_id() {
1434        let mut env = make_env("xterm-kitty", "", "");
1435        env.kitty_window_id = true;
1436        let caps = TerminalCapabilities::detect_from_inputs(&env);
1437        assert!(caps.true_color, "Kitty supports truecolor");
1438        assert!(
1439            caps.kitty_keyboard,
1440            "Kitty supports kitty keyboard protocol"
1441        );
1442        assert!(caps.sync_output, "Kitty supports sync output");
1443    }
1444
1445    #[test]
1446    fn detect_kitty_by_term() {
1447        let env = make_env("xterm-kitty", "", "");
1448        let caps = TerminalCapabilities::detect_from_inputs(&env);
1449        assert!(caps.true_color, "kitty TERM implies truecolor");
1450        assert!(caps.kitty_keyboard);
1451    }
1452
1453    #[test]
1454    fn detect_wezterm() {
1455        let env = make_env("xterm-256color", "WezTerm", "truecolor");
1456        let caps = TerminalCapabilities::detect_from_inputs(&env);
1457        assert!(caps.true_color);
1458        assert!(caps.sync_output, "WezTerm supports sync output");
1459        assert!(caps.osc8_hyperlinks, "WezTerm supports hyperlinks");
1460        assert!(caps.kitty_keyboard, "WezTerm supports kitty keyboard");
1461        assert!(caps.focus_events);
1462        assert!(caps.osc52_clipboard);
1463    }
1464
1465    #[test]
1466    #[cfg(target_os = "macos")]
1467    fn detect_iterm2_from_term_program() {
1468        let env = make_env("xterm-256color", "iTerm.app", "truecolor");
1469        let caps = TerminalCapabilities::detect_from_inputs(&env);
1470        assert!(caps.true_color, "iTerm2 implies truecolor");
1471        assert!(caps.osc8_hyperlinks, "iTerm2 supports OSC 8 hyperlinks");
1472    }
1473
1474    #[test]
1475    fn detect_alacritty() {
1476        let env = make_env("alacritty", "Alacritty", "truecolor");
1477        let caps = TerminalCapabilities::detect_from_inputs(&env);
1478        assert!(caps.true_color);
1479        assert!(caps.sync_output);
1480        assert!(caps.osc8_hyperlinks);
1481        assert!(caps.kitty_keyboard);
1482        assert!(caps.focus_events);
1483    }
1484
1485    #[test]
1486    fn detect_ghostty() {
1487        let env = make_env("xterm-ghostty", "Ghostty", "truecolor");
1488        let caps = TerminalCapabilities::detect_from_inputs(&env);
1489        assert!(caps.true_color);
1490        assert!(caps.sync_output);
1491        assert!(caps.osc8_hyperlinks);
1492        assert!(caps.kitty_keyboard);
1493        assert!(caps.focus_events);
1494    }
1495
1496    #[test]
1497    fn detect_iterm() {
1498        let env = make_env("xterm-256color", "iTerm.app", "truecolor");
1499        let caps = TerminalCapabilities::detect_from_inputs(&env);
1500        assert!(caps.true_color);
1501        assert!(caps.osc8_hyperlinks);
1502        assert!(caps.kitty_keyboard);
1503        assert!(caps.focus_events);
1504    }
1505
1506    #[test]
1507    fn detect_vscode_terminal() {
1508        let env = make_env("xterm-256color", "vscode", "truecolor");
1509        let caps = TerminalCapabilities::detect_from_inputs(&env);
1510        assert!(caps.true_color);
1511        assert!(caps.osc8_hyperlinks);
1512        assert!(caps.focus_events);
1513    }
1514
1515    // ====== Multiplexer detection ======
1516
1517    #[test]
1518    fn detect_in_tmux() {
1519        let mut env = make_env("screen-256color", "", "");
1520        env.in_tmux = true;
1521        let caps = TerminalCapabilities::detect_from_inputs(&env);
1522        assert!(caps.in_tmux);
1523        assert!(caps.in_any_mux());
1524        assert!(caps.colors_256);
1525        assert!(!caps.osc52_clipboard, "clipboard disabled in tmux");
1526    }
1527
1528    #[test]
1529    fn detect_in_screen() {
1530        let mut env = make_env("screen", "", "");
1531        env.in_screen = true;
1532        let caps = TerminalCapabilities::detect_from_inputs(&env);
1533        assert!(caps.in_screen);
1534        assert!(caps.in_any_mux());
1535        assert!(caps.needs_passthrough_wrap());
1536    }
1537
1538    #[test]
1539    fn detect_in_zellij() {
1540        let mut env = make_env("xterm-256color", "", "truecolor");
1541        env.in_zellij = true;
1542        let caps = TerminalCapabilities::detect_from_inputs(&env);
1543        assert!(caps.in_zellij);
1544        assert!(caps.in_any_mux());
1545        assert!(
1546            !caps.needs_passthrough_wrap(),
1547            "Zellij handles passthrough natively"
1548        );
1549        assert!(!caps.osc52_clipboard, "clipboard disabled in mux");
1550    }
1551
1552    #[test]
1553    fn detect_modern_terminal_in_tmux() {
1554        let mut env = make_env("screen-256color", "WezTerm", "truecolor");
1555        env.in_tmux = true;
1556        let caps = TerminalCapabilities::detect_from_inputs(&env);
1557        // Feature detection still works
1558        assert!(caps.true_color);
1559        assert!(caps.sync_output);
1560        // But policies disable features in mux
1561        assert!(!caps.use_sync_output());
1562        assert!(!caps.use_hyperlinks());
1563        assert!(!caps.use_scroll_region());
1564    }
1565
1566    // ====== NO_COLOR interaction with mux ======
1567
1568    #[test]
1569    fn no_color_overrides_everything() {
1570        let mut env = make_env("xterm-256color", "WezTerm", "truecolor");
1571        env.no_color = true;
1572        let caps = TerminalCapabilities::detect_from_inputs(&env);
1573        assert!(!caps.true_color);
1574        assert!(!caps.colors_256);
1575        assert!(!caps.osc8_hyperlinks);
1576        // But non-color features still work
1577        assert!(caps.sync_output);
1578        assert!(caps.bracketed_paste);
1579        assert!(caps.mouse_sgr);
1580    }
1581
1582    // ====== Edge cases ======
1583
1584    #[test]
1585    fn unknown_term_program() {
1586        let env = make_env("xterm", "SomeUnknownTerminal", "");
1587        let caps = TerminalCapabilities::detect_from_inputs(&env);
1588        assert!(
1589            !caps.true_color,
1590            "unknown terminal should not assume truecolor"
1591        );
1592        assert!(!caps.osc8_hyperlinks);
1593        // But basic features still work
1594        assert!(caps.bracketed_paste);
1595        assert!(caps.mouse_sgr);
1596        assert!(caps.scroll_region);
1597    }
1598
1599    #[test]
1600    fn all_mux_flags_simultaneous() {
1601        let mut env = make_env("screen", "", "");
1602        env.in_tmux = true;
1603        env.in_screen = true;
1604        env.in_zellij = true;
1605        let caps = TerminalCapabilities::detect_from_inputs(&env);
1606        assert!(caps.in_any_mux());
1607        assert!(caps.needs_passthrough_wrap());
1608        assert!(!caps.use_sync_output());
1609        assert!(!caps.use_hyperlinks());
1610        assert!(!caps.use_clipboard());
1611    }
1612
1613    // ====== Additional terminal detection (coverage gaps) ======
1614
1615    #[test]
1616    fn detect_rio() {
1617        let env = make_env("xterm-256color", "Rio", "truecolor");
1618        let caps = TerminalCapabilities::detect_from_inputs(&env);
1619        assert!(caps.true_color);
1620        assert!(caps.osc8_hyperlinks);
1621        assert!(caps.kitty_keyboard);
1622        assert!(caps.focus_events);
1623    }
1624
1625    #[test]
1626    fn detect_contour() {
1627        let env = make_env("xterm-256color", "Contour", "truecolor");
1628        let caps = TerminalCapabilities::detect_from_inputs(&env);
1629        assert!(caps.true_color);
1630        assert!(caps.sync_output);
1631        assert!(caps.osc8_hyperlinks);
1632        assert!(caps.focus_events);
1633    }
1634
1635    #[test]
1636    fn detect_foot() {
1637        let env = make_env("foot", "foot", "truecolor");
1638        let caps = TerminalCapabilities::detect_from_inputs(&env);
1639        assert!(caps.kitty_keyboard, "foot supports kitty keyboard");
1640    }
1641
1642    #[test]
1643    fn detect_hyper() {
1644        let env = make_env("xterm-256color", "Hyper", "truecolor");
1645        let caps = TerminalCapabilities::detect_from_inputs(&env);
1646        assert!(caps.true_color);
1647        assert!(caps.osc8_hyperlinks);
1648        assert!(caps.focus_events);
1649    }
1650
1651    #[test]
1652    fn detect_linux_console() {
1653        let env = make_env("linux", "", "");
1654        let caps = TerminalCapabilities::detect_from_inputs(&env);
1655        assert!(!caps.true_color, "linux console doesn't support truecolor");
1656        assert!(!caps.colors_256, "linux console doesn't support 256 colors");
1657        // But basic features work
1658        assert!(caps.bracketed_paste);
1659        assert!(caps.mouse_sgr);
1660        assert!(caps.scroll_region);
1661    }
1662
1663    #[test]
1664    fn detect_xterm_direct() {
1665        let env = make_env("xterm", "", "");
1666        let caps = TerminalCapabilities::detect_from_inputs(&env);
1667        assert!(!caps.true_color, "plain xterm has no truecolor");
1668        assert!(!caps.colors_256, "plain xterm has no 256color");
1669        assert!(caps.bracketed_paste);
1670        assert!(caps.mouse_sgr);
1671    }
1672
1673    #[test]
1674    fn detect_screen_256color() {
1675        let env = make_env("screen-256color", "", "");
1676        let caps = TerminalCapabilities::detect_from_inputs(&env);
1677        assert!(caps.colors_256, "screen-256color has 256 colors");
1678        assert!(!caps.true_color);
1679    }
1680
1681    // ====== Only TERM_PROGRAM without COLORTERM ======
1682
1683    #[test]
1684    fn wezterm_without_colorterm() {
1685        let env = make_env("xterm-256color", "WezTerm", "");
1686        let caps = TerminalCapabilities::detect_from_inputs(&env);
1687        // Modern terminal detection still works via TERM_PROGRAM
1688        assert!(caps.true_color, "WezTerm is modern, implies truecolor");
1689        assert!(caps.sync_output);
1690        assert!(caps.osc8_hyperlinks);
1691    }
1692
1693    #[test]
1694    fn alacritty_via_term_only() {
1695        // Alacritty sets TERM=alacritty
1696        let env = make_env("alacritty", "", "");
1697        let caps = TerminalCapabilities::detect_from_inputs(&env);
1698        // TERM contains "alacritty" which matches lowercase of MODERN_TERMINALS
1699        assert!(caps.true_color);
1700        assert!(caps.osc8_hyperlinks);
1701    }
1702
1703    // ====== Kitty detection edge cases ======
1704
1705    #[test]
1706    fn kitty_via_term_without_window_id() {
1707        let env = make_env("xterm-kitty", "", "");
1708        let caps = TerminalCapabilities::detect_from_inputs(&env);
1709        assert!(caps.kitty_keyboard);
1710        assert!(caps.true_color);
1711        assert!(caps.sync_output);
1712    }
1713
1714    #[test]
1715    fn kitty_window_id_with_generic_term() {
1716        let mut env = make_env("xterm-256color", "", "");
1717        env.kitty_window_id = true;
1718        let caps = TerminalCapabilities::detect_from_inputs(&env);
1719        assert!(caps.kitty_keyboard);
1720        assert!(caps.true_color);
1721    }
1722
1723    // ====== Policy edge cases ======
1724
1725    #[test]
1726    fn use_clipboard_enabled_when_no_mux_and_modern() {
1727        let env = make_env("xterm-256color", "WezTerm", "truecolor");
1728        let caps = TerminalCapabilities::detect_from_inputs(&env);
1729        assert!(caps.osc52_clipboard);
1730        assert!(caps.use_clipboard());
1731    }
1732
1733    #[test]
1734    fn use_clipboard_disabled_in_tmux_even_if_detected() {
1735        let mut env = make_env("xterm-256color", "WezTerm", "truecolor");
1736        env.in_tmux = true;
1737        let caps = TerminalCapabilities::detect_from_inputs(&env);
1738        // osc52_clipboard is already false due to mux detection in detect_from_inputs
1739        assert!(!caps.osc52_clipboard);
1740        assert!(!caps.use_clipboard());
1741    }
1742
1743    #[test]
1744    fn scroll_region_enabled_for_basic_xterm() {
1745        let env = make_env("xterm", "", "");
1746        let caps = TerminalCapabilities::detect_from_inputs(&env);
1747        assert!(caps.scroll_region);
1748        assert!(caps.use_scroll_region());
1749    }
1750
1751    #[test]
1752    fn no_color_preserves_non_visual_features() {
1753        let mut env = make_env("xterm-256color", "WezTerm", "truecolor");
1754        env.no_color = true;
1755        let caps = TerminalCapabilities::detect_from_inputs(&env);
1756        // Visual features disabled
1757        assert!(!caps.true_color);
1758        assert!(!caps.colors_256);
1759        assert!(!caps.osc8_hyperlinks);
1760        // Non-visual features preserved
1761        assert!(caps.sync_output);
1762        assert!(caps.kitty_keyboard);
1763        assert!(caps.focus_events);
1764        assert!(caps.bracketed_paste);
1765        assert!(caps.mouse_sgr);
1766    }
1767
1768    // ====== COLORTERM variations ======
1769
1770    #[test]
1771    fn colorterm_yes_not_truecolor() {
1772        let env = make_env("xterm-256color", "", "yes");
1773        let caps = TerminalCapabilities::detect_from_inputs(&env);
1774        assert!(!caps.true_color, "COLORTERM=yes is not truecolor");
1775        assert!(caps.colors_256, "TERM=xterm-256color implies 256");
1776    }
1777
1778    // ====== Capability Profiles (bd-k4lj.2) ======
1779
1780    #[test]
1781    fn profile_enum_as_str() {
1782        assert_eq!(TerminalProfile::Modern.as_str(), "modern");
1783        assert_eq!(TerminalProfile::Xterm256Color.as_str(), "xterm-256color");
1784        assert_eq!(TerminalProfile::Vt100.as_str(), "vt100");
1785        assert_eq!(TerminalProfile::Dumb.as_str(), "dumb");
1786        assert_eq!(TerminalProfile::Tmux.as_str(), "tmux");
1787        assert_eq!(TerminalProfile::Screen.as_str(), "screen");
1788        assert_eq!(TerminalProfile::Kitty.as_str(), "kitty");
1789    }
1790
1791    #[test]
1792    fn profile_enum_from_str() {
1793        use std::str::FromStr;
1794        assert_eq!(
1795            TerminalProfile::from_str("modern"),
1796            Ok(TerminalProfile::Modern)
1797        );
1798        assert_eq!(
1799            TerminalProfile::from_str("xterm-256color"),
1800            Ok(TerminalProfile::Xterm256Color)
1801        );
1802        assert_eq!(
1803            TerminalProfile::from_str("xterm256color"),
1804            Ok(TerminalProfile::Xterm256Color)
1805        );
1806        assert_eq!(TerminalProfile::from_str("DUMB"), Ok(TerminalProfile::Dumb));
1807        assert!(TerminalProfile::from_str("unknown").is_err());
1808    }
1809
1810    #[test]
1811    fn profile_all_predefined() {
1812        let all = TerminalProfile::all_predefined();
1813        assert!(all.len() >= 10);
1814        assert!(all.contains(&TerminalProfile::Modern));
1815        assert!(all.contains(&TerminalProfile::Dumb));
1816        assert!(!all.contains(&TerminalProfile::Custom));
1817        assert!(!all.contains(&TerminalProfile::Detected));
1818    }
1819
1820    #[test]
1821    fn profile_modern_has_all_features() {
1822        let caps = TerminalCapabilities::modern();
1823        assert_eq!(caps.profile(), TerminalProfile::Modern);
1824        assert_eq!(caps.profile_name(), Some("modern"));
1825        assert!(caps.true_color);
1826        assert!(caps.colors_256);
1827        assert!(caps.sync_output);
1828        assert!(caps.osc8_hyperlinks);
1829        assert!(caps.scroll_region);
1830        assert!(caps.kitty_keyboard);
1831        assert!(caps.focus_events);
1832        assert!(caps.bracketed_paste);
1833        assert!(caps.mouse_sgr);
1834        assert!(caps.osc52_clipboard);
1835        assert!(!caps.in_any_mux());
1836    }
1837
1838    #[test]
1839    fn profile_xterm_256color() {
1840        let caps = TerminalCapabilities::xterm_256color();
1841        assert_eq!(caps.profile(), TerminalProfile::Xterm256Color);
1842        assert!(!caps.true_color);
1843        assert!(caps.colors_256);
1844        assert!(!caps.sync_output);
1845        assert!(!caps.osc8_hyperlinks);
1846        assert!(caps.scroll_region);
1847        assert!(caps.bracketed_paste);
1848        assert!(caps.mouse_sgr);
1849    }
1850
1851    #[test]
1852    fn profile_xterm_basic() {
1853        let caps = TerminalCapabilities::xterm();
1854        assert_eq!(caps.profile(), TerminalProfile::Xterm);
1855        assert!(!caps.true_color);
1856        assert!(!caps.colors_256);
1857        assert!(caps.scroll_region);
1858    }
1859
1860    #[test]
1861    fn profile_vt100_minimal() {
1862        let caps = TerminalCapabilities::vt100();
1863        assert_eq!(caps.profile(), TerminalProfile::Vt100);
1864        assert!(!caps.true_color);
1865        assert!(!caps.colors_256);
1866        assert!(caps.scroll_region);
1867        assert!(!caps.bracketed_paste);
1868        assert!(!caps.mouse_sgr);
1869    }
1870
1871    #[test]
1872    fn profile_dumb_no_features() {
1873        let caps = TerminalCapabilities::dumb();
1874        assert_eq!(caps.profile(), TerminalProfile::Dumb);
1875        assert!(!caps.true_color);
1876        assert!(!caps.colors_256);
1877        assert!(!caps.scroll_region);
1878        assert!(!caps.bracketed_paste);
1879        assert!(!caps.mouse_sgr);
1880        assert!(!caps.use_sync_output());
1881        assert!(!caps.use_scroll_region());
1882    }
1883
1884    #[test]
1885    fn profile_tmux_mux_flags() {
1886        let caps = TerminalCapabilities::tmux();
1887        assert_eq!(caps.profile(), TerminalProfile::Tmux);
1888        assert!(caps.in_tmux);
1889        assert!(!caps.in_screen);
1890        assert!(!caps.in_zellij);
1891        assert!(caps.in_any_mux());
1892        // Mux policies kick in
1893        assert!(!caps.use_sync_output());
1894        assert!(!caps.use_scroll_region());
1895        assert!(!caps.use_hyperlinks());
1896    }
1897
1898    #[test]
1899    fn profile_screen_mux_flags() {
1900        let caps = TerminalCapabilities::screen();
1901        assert_eq!(caps.profile(), TerminalProfile::Screen);
1902        assert!(!caps.in_tmux);
1903        assert!(caps.in_screen);
1904        assert!(caps.in_any_mux());
1905        assert!(caps.needs_passthrough_wrap());
1906    }
1907
1908    #[test]
1909    fn profile_zellij_mux_flags() {
1910        let caps = TerminalCapabilities::zellij();
1911        assert_eq!(caps.profile(), TerminalProfile::Zellij);
1912        assert!(caps.in_zellij);
1913        assert!(caps.in_any_mux());
1914        // Zellij has true color and focus events
1915        assert!(caps.true_color);
1916        assert!(caps.focus_events);
1917        // But no passthrough wrap needed
1918        assert!(!caps.needs_passthrough_wrap());
1919    }
1920
1921    #[test]
1922    fn profile_kitty_full_features() {
1923        let caps = TerminalCapabilities::kitty();
1924        assert_eq!(caps.profile(), TerminalProfile::Kitty);
1925        assert!(caps.true_color);
1926        assert!(caps.sync_output);
1927        assert!(caps.kitty_keyboard);
1928        assert!(caps.osc8_hyperlinks);
1929    }
1930
1931    #[test]
1932    fn profile_windows_console() {
1933        let caps = TerminalCapabilities::windows_console();
1934        assert_eq!(caps.profile(), TerminalProfile::WindowsConsole);
1935        assert!(caps.true_color);
1936        assert!(caps.osc8_hyperlinks);
1937        assert!(caps.focus_events);
1938    }
1939
1940    #[test]
1941    fn profile_linux_console() {
1942        let caps = TerminalCapabilities::linux_console();
1943        assert_eq!(caps.profile(), TerminalProfile::LinuxConsole);
1944        assert!(!caps.true_color);
1945        assert!(!caps.colors_256);
1946        assert!(caps.scroll_region);
1947    }
1948
1949    #[test]
1950    fn from_profile_roundtrip() {
1951        for profile in TerminalProfile::all_predefined() {
1952            let caps = TerminalCapabilities::from_profile(*profile);
1953            assert_eq!(caps.profile(), *profile);
1954        }
1955    }
1956
1957    #[test]
1958    fn detected_profile_has_none_name() {
1959        let caps = detect_with_override(None);
1960        assert_eq!(caps.profile(), TerminalProfile::Detected);
1961        assert_eq!(caps.profile_name(), None);
1962    }
1963
1964    #[test]
1965    fn detect_respects_test_profile_env() {
1966        let caps = detect_with_override(Some("dumb"));
1967        assert_eq!(caps.profile(), TerminalProfile::Dumb);
1968    }
1969
1970    #[test]
1971    fn detect_ignores_invalid_test_profile() {
1972        let caps = detect_with_override(Some("not-a-real-profile"));
1973        assert_eq!(caps.profile(), TerminalProfile::Detected);
1974    }
1975
1976    #[test]
1977    fn basic_has_dumb_profile() {
1978        let caps = TerminalCapabilities::basic();
1979        assert_eq!(caps.profile(), TerminalProfile::Dumb);
1980    }
1981
1982    // ====== Capability Profile Builder ======
1983
1984    #[test]
1985    fn builder_starts_empty() {
1986        let caps = CapabilityProfileBuilder::new().build();
1987        assert_eq!(caps.profile(), TerminalProfile::Custom);
1988        assert!(!caps.true_color);
1989        assert!(!caps.colors_256);
1990        assert!(!caps.sync_output);
1991        assert!(!caps.scroll_region);
1992        assert!(!caps.mouse_sgr);
1993    }
1994
1995    #[test]
1996    fn builder_set_colors() {
1997        let caps = CapabilityProfileBuilder::new()
1998            .true_color(true)
1999            .colors_256(true)
2000            .build();
2001        assert!(caps.true_color);
2002        assert!(caps.colors_256);
2003    }
2004
2005    #[test]
2006    fn builder_set_advanced() {
2007        let caps = CapabilityProfileBuilder::new()
2008            .sync_output(true)
2009            .osc8_hyperlinks(true)
2010            .scroll_region(true)
2011            .build();
2012        assert!(caps.sync_output);
2013        assert!(caps.osc8_hyperlinks);
2014        assert!(caps.scroll_region);
2015    }
2016
2017    #[test]
2018    fn builder_set_mux() {
2019        let caps = CapabilityProfileBuilder::new()
2020            .in_tmux(true)
2021            .in_screen(false)
2022            .in_zellij(false)
2023            .build();
2024        assert!(caps.in_tmux);
2025        assert!(!caps.in_screen);
2026        assert!(caps.in_any_mux());
2027    }
2028
2029    #[test]
2030    fn builder_set_input() {
2031        let caps = CapabilityProfileBuilder::new()
2032            .kitty_keyboard(true)
2033            .focus_events(true)
2034            .bracketed_paste(true)
2035            .mouse_sgr(true)
2036            .build();
2037        assert!(caps.kitty_keyboard);
2038        assert!(caps.focus_events);
2039        assert!(caps.bracketed_paste);
2040        assert!(caps.mouse_sgr);
2041    }
2042
2043    #[test]
2044    fn builder_set_clipboard() {
2045        let caps = CapabilityProfileBuilder::new()
2046            .osc52_clipboard(true)
2047            .build();
2048        assert!(caps.osc52_clipboard);
2049    }
2050
2051    #[test]
2052    fn builder_from_profile() {
2053        let caps = CapabilityProfileBuilder::from_profile(TerminalProfile::Modern)
2054            .sync_output(false) // Override one setting
2055            .build();
2056        // Should have modern features except sync_output
2057        assert!(caps.true_color);
2058        assert!(caps.colors_256);
2059        assert!(!caps.sync_output); // Overridden
2060        assert!(caps.osc8_hyperlinks);
2061        // But profile becomes Custom
2062        assert_eq!(caps.profile(), TerminalProfile::Custom);
2063    }
2064
2065    #[test]
2066    fn builder_chain_multiple() {
2067        let caps = TerminalCapabilities::builder()
2068            .colors_256(true)
2069            .bracketed_paste(true)
2070            .mouse_sgr(true)
2071            .scroll_region(true)
2072            .build();
2073        assert!(caps.colors_256);
2074        assert!(caps.bracketed_paste);
2075        assert!(caps.mouse_sgr);
2076        assert!(caps.scroll_region);
2077        assert!(!caps.true_color);
2078        assert!(!caps.sync_output);
2079    }
2080
2081    #[test]
2082    fn builder_default() {
2083        let builder = CapabilityProfileBuilder::default();
2084        let caps = builder.build();
2085        assert_eq!(caps.profile(), TerminalProfile::Custom);
2086    }
2087
2088    // ==========================================================================
2089    // Mux Compatibility Matrix Tests (bd-1rz0.19)
2090    // ==========================================================================
2091    //
2092    // These tests verify the invariants and fallback behaviors for multiplexer
2093    // compatibility as documented in the capability detection system.
2094
2095    /// Tests the complete mux × capability matrix to ensure fallbacks are correct.
2096    #[test]
2097    fn mux_compatibility_matrix() {
2098        // Test matrix covers: baseline (no mux), tmux, screen, zellij
2099        // Each verifies: use_sync_output, use_scroll_region, use_hyperlinks, needs_passthrough_wrap
2100
2101        // Test baseline (no mux)
2102        {
2103            let caps = TerminalCapabilities::modern();
2104            assert!(
2105                caps.use_sync_output(),
2106                "baseline: sync_output should be enabled"
2107            );
2108            assert!(
2109                caps.use_scroll_region(),
2110                "baseline: scroll_region should be enabled"
2111            );
2112            assert!(
2113                caps.use_hyperlinks(),
2114                "baseline: hyperlinks should be enabled"
2115            );
2116            assert!(!caps.needs_passthrough_wrap(), "baseline: no wrap needed");
2117        }
2118
2119        // Test tmux
2120        {
2121            let caps = TerminalCapabilities::tmux();
2122            assert!(!caps.use_sync_output(), "tmux: sync_output disabled");
2123            assert!(!caps.use_scroll_region(), "tmux: scroll_region disabled");
2124            assert!(!caps.use_hyperlinks(), "tmux: hyperlinks disabled");
2125            assert!(caps.needs_passthrough_wrap(), "tmux: needs wrap");
2126        }
2127
2128        // Test screen
2129        {
2130            let caps = TerminalCapabilities::screen();
2131            assert!(!caps.use_sync_output(), "screen: sync_output disabled");
2132            assert!(!caps.use_scroll_region(), "screen: scroll_region disabled");
2133            assert!(!caps.use_hyperlinks(), "screen: hyperlinks disabled");
2134            assert!(caps.needs_passthrough_wrap(), "screen: needs wrap");
2135        }
2136
2137        // Test zellij
2138        {
2139            let caps = TerminalCapabilities::zellij();
2140            assert!(!caps.use_sync_output(), "zellij: sync_output disabled");
2141            assert!(!caps.use_scroll_region(), "zellij: scroll_region disabled");
2142            assert!(!caps.use_hyperlinks(), "zellij: hyperlinks disabled");
2143            assert!(
2144                !caps.needs_passthrough_wrap(),
2145                "zellij: no wrap needed (native passthrough)"
2146            );
2147        }
2148    }
2149
2150    /// Tests that modern terminal detection works correctly even inside muxes.
2151    #[test]
2152    fn modern_terminal_in_mux_matrix() {
2153        // Modern terminal (WezTerm) detected inside each mux type
2154        // Feature detection should still work, but policies should disable
2155
2156        for (mux_name, in_tmux, in_screen, in_zellij) in [
2157            ("tmux", true, false, false),
2158            ("screen", false, true, false),
2159            ("zellij", false, false, true),
2160        ] {
2161            let mut env = make_env("screen-256color", "WezTerm", "truecolor");
2162            env.in_tmux = in_tmux;
2163            env.in_screen = in_screen;
2164            env.in_zellij = in_zellij;
2165            let caps = TerminalCapabilities::detect_from_inputs(&env);
2166
2167            // Feature DETECTION still works
2168            assert!(
2169                caps.true_color,
2170                "{mux_name}: true_color detection should work"
2171            );
2172            assert!(
2173                caps.sync_output,
2174                "{mux_name}: sync_output detection should work"
2175            );
2176
2177            // But POLICIES disable features
2178            assert!(
2179                !caps.use_sync_output(),
2180                "{mux_name}: use_sync_output() should be false"
2181            );
2182            assert!(
2183                !caps.use_scroll_region(),
2184                "{mux_name}: use_scroll_region() should be false"
2185            );
2186            assert!(
2187                !caps.use_hyperlinks(),
2188                "{mux_name}: use_hyperlinks() should be false"
2189            );
2190        }
2191    }
2192
2193    /// Tests all terminal profiles against mux detection to ensure invariants hold.
2194    #[test]
2195    fn profile_mux_invariant_matrix() {
2196        // For each predefined profile, verify mux-related invariants
2197        for profile in TerminalProfile::all_predefined() {
2198            let caps = TerminalCapabilities::from_profile(*profile);
2199            let name = profile.as_str();
2200
2201            // Invariant 1: in_any_mux() is consistent with individual flags
2202            let expected_mux = caps.in_tmux || caps.in_screen || caps.in_zellij;
2203            assert_eq!(
2204                caps.in_any_mux(),
2205                expected_mux,
2206                "{name}: in_any_mux() should match individual flags"
2207            );
2208
2209            // Invariant 2: If in any mux, policies should disable sync/scroll/hyperlinks
2210            if caps.in_any_mux() {
2211                assert!(
2212                    !caps.use_sync_output(),
2213                    "{name}: mux should disable use_sync_output()"
2214                );
2215                assert!(
2216                    !caps.use_scroll_region(),
2217                    "{name}: mux should disable use_scroll_region()"
2218                );
2219                assert!(
2220                    !caps.use_hyperlinks(),
2221                    "{name}: mux should disable use_hyperlinks()"
2222                );
2223            }
2224
2225            // Invariant 3: Only tmux and screen need passthrough wrap, not zellij
2226            if caps.in_tmux || caps.in_screen {
2227                assert!(
2228                    caps.needs_passthrough_wrap(),
2229                    "{name}: tmux/screen should need passthrough wrap"
2230                );
2231            } else if caps.in_zellij {
2232                assert!(
2233                    !caps.needs_passthrough_wrap(),
2234                    "{name}: zellij should NOT need passthrough wrap"
2235                );
2236            }
2237        }
2238    }
2239
2240    /// Tests the fallback ordering: sync_output → scroll_region → overlay_redraw
2241    #[test]
2242    fn fallback_ordering_matrix() {
2243        use crate::inline_mode::InlineStrategy;
2244
2245        // Case 1: Both sync and scroll available -> ScrollRegion strategy
2246        let caps_full = TerminalCapabilities::builder()
2247            .sync_output(true)
2248            .scroll_region(true)
2249            .build();
2250        assert_eq!(
2251            InlineStrategy::select(&caps_full),
2252            InlineStrategy::ScrollRegion,
2253            "full capabilities should use ScrollRegion"
2254        );
2255
2256        // Case 2: Scroll but no sync -> Hybrid strategy
2257        let caps_hybrid = TerminalCapabilities::builder()
2258            .sync_output(false)
2259            .scroll_region(true)
2260            .build();
2261        assert_eq!(
2262            InlineStrategy::select(&caps_hybrid),
2263            InlineStrategy::Hybrid,
2264            "scroll without sync should use Hybrid"
2265        );
2266
2267        // Case 3: Neither -> OverlayRedraw strategy
2268        let caps_none = TerminalCapabilities::builder()
2269            .sync_output(false)
2270            .scroll_region(false)
2271            .build();
2272        assert_eq!(
2273            InlineStrategy::select(&caps_none),
2274            InlineStrategy::OverlayRedraw,
2275            "no capabilities should use OverlayRedraw"
2276        );
2277
2278        // Case 4: In mux (even with capabilities) -> OverlayRedraw
2279        let caps_tmux = TerminalCapabilities::tmux();
2280        assert_eq!(
2281            InlineStrategy::select(&caps_tmux),
2282            InlineStrategy::OverlayRedraw,
2283            "tmux should force OverlayRedraw"
2284        );
2285    }
2286
2287    /// Tests the complete terminal × mux matrix for strategy selection.
2288    #[test]
2289    fn terminal_mux_strategy_matrix() {
2290        use crate::inline_mode::InlineStrategy;
2291
2292        struct TestCase {
2293            name: &'static str,
2294            profile: TerminalProfile,
2295            expected: InlineStrategy,
2296        }
2297
2298        let cases = [
2299            TestCase {
2300                name: "modern (no mux)",
2301                profile: TerminalProfile::Modern,
2302                expected: InlineStrategy::ScrollRegion,
2303            },
2304            TestCase {
2305                name: "kitty (no mux)",
2306                profile: TerminalProfile::Kitty,
2307                expected: InlineStrategy::ScrollRegion,
2308            },
2309            TestCase {
2310                name: "xterm-256color (no mux)",
2311                profile: TerminalProfile::Xterm256Color,
2312                expected: InlineStrategy::Hybrid, // has scroll_region but no sync_output
2313            },
2314            TestCase {
2315                name: "xterm (no mux)",
2316                profile: TerminalProfile::Xterm,
2317                expected: InlineStrategy::Hybrid,
2318            },
2319            TestCase {
2320                name: "vt100 (no mux)",
2321                profile: TerminalProfile::Vt100,
2322                expected: InlineStrategy::Hybrid,
2323            },
2324            TestCase {
2325                name: "dumb",
2326                profile: TerminalProfile::Dumb,
2327                expected: InlineStrategy::OverlayRedraw, // no scroll_region
2328            },
2329            TestCase {
2330                name: "tmux",
2331                profile: TerminalProfile::Tmux,
2332                expected: InlineStrategy::OverlayRedraw,
2333            },
2334            TestCase {
2335                name: "screen",
2336                profile: TerminalProfile::Screen,
2337                expected: InlineStrategy::OverlayRedraw,
2338            },
2339            TestCase {
2340                name: "zellij",
2341                profile: TerminalProfile::Zellij,
2342                expected: InlineStrategy::OverlayRedraw,
2343            },
2344        ];
2345
2346        for case in cases {
2347            let caps = TerminalCapabilities::from_profile(case.profile);
2348            let actual = InlineStrategy::select(&caps);
2349            assert_eq!(
2350                actual, case.expected,
2351                "{}: expected {:?}, got {:?}",
2352                case.name, case.expected, actual
2353            );
2354        }
2355    }
2356}
2357
2358// ==========================================================================
2359// Property Tests for Mux Compatibility (bd-1rz0.19)
2360// ==========================================================================
2361
2362#[cfg(test)]
2363mod proptests {
2364    use super::*;
2365    use proptest::prelude::*;
2366
2367    proptest! {
2368        /// Property: in_any_mux() is always consistent with individual mux flags.
2369        #[test]
2370        fn prop_in_any_mux_consistent(
2371            in_tmux in any::<bool>(),
2372            in_screen in any::<bool>(),
2373            in_zellij in any::<bool>(),
2374        ) {
2375            let caps = TerminalCapabilities::builder()
2376                .in_tmux(in_tmux)
2377                .in_screen(in_screen)
2378                .in_zellij(in_zellij)
2379                .build();
2380
2381            let expected = in_tmux || in_screen || in_zellij;
2382            prop_assert_eq!(caps.in_any_mux(), expected);
2383        }
2384
2385        /// Property: If in any mux, use_sync_output() is always false (regardless of sync_output flag).
2386        #[test]
2387        fn prop_mux_disables_sync_output(
2388            in_tmux in any::<bool>(),
2389            in_screen in any::<bool>(),
2390            in_zellij in any::<bool>(),
2391            sync_output in any::<bool>(),
2392        ) {
2393            let caps = TerminalCapabilities::builder()
2394                .in_tmux(in_tmux)
2395                .in_screen(in_screen)
2396                .in_zellij(in_zellij)
2397                .sync_output(sync_output)
2398                .build();
2399
2400            if caps.in_any_mux() {
2401                prop_assert!(!caps.use_sync_output(), "mux should disable sync_output policy");
2402            }
2403        }
2404
2405        /// Property: If in any mux, use_scroll_region() is always false.
2406        #[test]
2407        fn prop_mux_disables_scroll_region(
2408            in_tmux in any::<bool>(),
2409            in_screen in any::<bool>(),
2410            in_zellij in any::<bool>(),
2411            scroll_region in any::<bool>(),
2412        ) {
2413            let caps = TerminalCapabilities::builder()
2414                .in_tmux(in_tmux)
2415                .in_screen(in_screen)
2416                .in_zellij(in_zellij)
2417                .scroll_region(scroll_region)
2418                .build();
2419
2420            if caps.in_any_mux() {
2421                prop_assert!(!caps.use_scroll_region(), "mux should disable scroll_region policy");
2422            }
2423        }
2424
2425        /// Property: If in any mux, use_hyperlinks() is always false.
2426        #[test]
2427        fn prop_mux_disables_hyperlinks(
2428            in_tmux in any::<bool>(),
2429            in_screen in any::<bool>(),
2430            in_zellij in any::<bool>(),
2431            osc8_hyperlinks in any::<bool>(),
2432        ) {
2433            let caps = TerminalCapabilities::builder()
2434                .in_tmux(in_tmux)
2435                .in_screen(in_screen)
2436                .in_zellij(in_zellij)
2437                .osc8_hyperlinks(osc8_hyperlinks)
2438                .build();
2439
2440            if caps.in_any_mux() {
2441                prop_assert!(!caps.use_hyperlinks(), "mux should disable hyperlinks policy");
2442            }
2443        }
2444
2445        /// Property: needs_passthrough_wrap() is true IFF in_tmux || in_screen (NOT zellij).
2446        #[test]
2447        fn prop_passthrough_wrap_logic(
2448            in_tmux in any::<bool>(),
2449            in_screen in any::<bool>(),
2450            in_zellij in any::<bool>(),
2451        ) {
2452            let caps = TerminalCapabilities::builder()
2453                .in_tmux(in_tmux)
2454                .in_screen(in_screen)
2455                .in_zellij(in_zellij)
2456                .build();
2457
2458            let expected = in_tmux || in_screen;  // NOT zellij
2459            prop_assert_eq!(caps.needs_passthrough_wrap(), expected);
2460        }
2461
2462        /// Property: Policy methods return false when capability is not set (regardless of mux).
2463        #[test]
2464        fn prop_policy_false_when_capability_off(
2465            in_tmux in any::<bool>(),
2466            in_screen in any::<bool>(),
2467            in_zellij in any::<bool>(),
2468        ) {
2469            let caps = TerminalCapabilities::builder()
2470                .in_tmux(in_tmux)
2471                .in_screen(in_screen)
2472                .in_zellij(in_zellij)
2473                .sync_output(false)
2474                .scroll_region(false)
2475                .osc8_hyperlinks(false)
2476                .osc52_clipboard(false)
2477                .build();
2478
2479            prop_assert!(!caps.use_sync_output(), "sync_output=false implies use_sync_output()=false");
2480            prop_assert!(!caps.use_scroll_region(), "scroll_region=false implies use_scroll_region()=false");
2481            prop_assert!(!caps.use_hyperlinks(), "osc8_hyperlinks=false implies use_hyperlinks()=false");
2482            prop_assert!(!caps.use_clipboard(), "osc52_clipboard=false implies use_clipboard()=false");
2483        }
2484
2485        /// Property: NO_COLOR disables all color-related features but not non-visual features.
2486        #[test]
2487        fn prop_no_color_preserves_non_visual(no_color in any::<bool>()) {
2488            let env = DetectInputs {
2489                no_color,
2490                term: "xterm-256color".to_string(),
2491                term_program: "WezTerm".to_string(),
2492                colorterm: "truecolor".to_string(),
2493                in_tmux: false,
2494                in_screen: false,
2495                in_zellij: false,
2496                kitty_window_id: false,
2497                wt_session: false,
2498            };
2499            let caps = TerminalCapabilities::detect_from_inputs(&env);
2500
2501            if no_color {
2502                prop_assert!(!caps.true_color, "NO_COLOR disables true_color");
2503                prop_assert!(!caps.colors_256, "NO_COLOR disables colors_256");
2504                prop_assert!(!caps.osc8_hyperlinks, "NO_COLOR disables hyperlinks");
2505            }
2506
2507            // Non-visual features preserved regardless of NO_COLOR
2508            prop_assert!(caps.sync_output, "sync_output preserved despite NO_COLOR");
2509            prop_assert!(caps.bracketed_paste, "bracketed_paste preserved despite NO_COLOR");
2510        }
2511    }
2512}