Skip to main content

yarli_cli/
mode.rs

1//! Render mode auto-detection (Section 16.3).
2//!
3//! Determines the appropriate rendering mode based on:
4//! - TTY detection: non-TTY (pipe/redirect) forces stream mode.
5//! - Terminal size: < 80 cols or < 24 rows forces stream mode.
6//! - CLI flags: `--stream` or `--tui` override auto-detection.
7
8use std::io::{self, IsTerminal};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum TerminalColorSupport {
12    None,
13    Ansi16,
14    TrueColor,
15}
16
17impl TerminalColorSupport {
18    pub fn detect_from_env() -> Self {
19        Self::detect_with(|key| std::env::var(key).ok())
20    }
21
22    fn detect_with(get_env: impl Fn(&str) -> Option<String>) -> Self {
23        if get_env("NO_COLOR").is_some() {
24            return Self::None;
25        }
26
27        let colorterm = get_env("COLORTERM")
28            .unwrap_or_default()
29            .to_ascii_lowercase();
30        if colorterm.contains("truecolor") || colorterm.contains("24bit") {
31            return Self::TrueColor;
32        }
33
34        let term = get_env("TERM").unwrap_or_default().to_ascii_lowercase();
35        if term.contains("truecolor") || term.contains("24bit") || term.contains("direct") {
36            return Self::TrueColor;
37        }
38
39        if get_env("TERM_PROGRAM")
40            .unwrap_or_default()
41            .eq_ignore_ascii_case("wezterm")
42        {
43            return Self::TrueColor;
44        }
45
46        if get_env("KITTY_WINDOW_ID").is_some() {
47            return Self::TrueColor;
48        }
49
50        Self::Ansi16
51    }
52
53    pub fn is_truecolor(self) -> bool {
54        matches!(self, Self::TrueColor)
55    }
56}
57
58/// Rendering mode for the CLI output.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum RenderMode {
61    /// Inline viewport with live status, completed output in native scrollback.
62    /// Used for CI, pipes, small terminals, non-TTY.
63    Stream,
64    /// Fullscreen panel layout (Milestone 4 — not yet implemented).
65    Dashboard,
66}
67
68/// Minimum terminal width for dashboard mode.
69const MIN_DASHBOARD_COLS: u16 = 80;
70/// Minimum terminal height for dashboard mode.
71const MIN_DASHBOARD_ROWS: u16 = 24;
72
73/// Terminal capability information used for mode detection.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct TerminalInfo {
76    pub is_tty: bool,
77    pub cols: u16,
78    pub rows: u16,
79    pub color_support: TerminalColorSupport,
80}
81
82impl TerminalInfo {
83    /// Probe the current terminal.
84    pub fn detect() -> Self {
85        let is_tty = io::stdout().is_terminal();
86        let (cols, rows) = crossterm::terminal::size().unwrap_or((0, 0));
87        let color_support = if is_tty {
88            TerminalColorSupport::detect_from_env()
89        } else {
90            TerminalColorSupport::None
91        };
92        Self {
93            is_tty,
94            cols,
95            rows,
96            color_support,
97        }
98    }
99
100    /// Whether the terminal meets minimum size requirements for dashboard mode.
101    pub fn supports_dashboard(&self) -> bool {
102        self.is_tty && self.cols >= MIN_DASHBOARD_COLS && self.rows >= MIN_DASHBOARD_ROWS
103    }
104}
105
106/// Select the render mode based on terminal info and CLI flags.
107///
108/// Priority:
109/// 1. `--stream` flag → Stream
110/// 2. `--tui` flag → Dashboard (requires TTY with sufficient size)
111/// 3. Auto-detect from terminal capabilities
112pub fn select_render_mode(
113    info: &TerminalInfo,
114    force_stream: bool,
115    force_tui: bool,
116) -> Result<RenderMode, RenderModeError> {
117    if force_stream {
118        return Ok(RenderMode::Stream);
119    }
120
121    if force_tui {
122        if !info.supports_dashboard() {
123            return Err(RenderModeError::TerminalTooSmall {
124                cols: info.cols,
125                rows: info.rows,
126            });
127        }
128        return Ok(RenderMode::Dashboard);
129    }
130
131    // Auto-detect: dashboard if TTY meets minimum size, stream otherwise.
132    if info.supports_dashboard() {
133        Ok(RenderMode::Dashboard)
134    } else {
135        Ok(RenderMode::Stream)
136    }
137}
138
139/// Errors from render mode selection.
140#[derive(Debug, thiserror::Error)]
141pub enum RenderModeError {
142    #[error("terminal too small for dashboard mode ({cols}x{rows}, need {MIN_DASHBOARD_COLS}x{MIN_DASHBOARD_ROWS})")]
143    TerminalTooSmall { cols: u16, rows: u16 },
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    fn tty_large() -> TerminalInfo {
151        TerminalInfo {
152            is_tty: true,
153            cols: 120,
154            rows: 40,
155            color_support: TerminalColorSupport::Ansi16,
156        }
157    }
158
159    fn tty_small() -> TerminalInfo {
160        TerminalInfo {
161            is_tty: true,
162            cols: 60,
163            rows: 20,
164            color_support: TerminalColorSupport::Ansi16,
165        }
166    }
167
168    fn pipe() -> TerminalInfo {
169        TerminalInfo {
170            is_tty: false,
171            cols: 120,
172            rows: 40,
173            color_support: TerminalColorSupport::None,
174        }
175    }
176
177    fn tty_narrow() -> TerminalInfo {
178        TerminalInfo {
179            is_tty: true,
180            cols: 79,
181            rows: 40,
182            color_support: TerminalColorSupport::Ansi16,
183        }
184    }
185
186    fn tty_short() -> TerminalInfo {
187        TerminalInfo {
188            is_tty: true,
189            cols: 120,
190            rows: 23,
191            color_support: TerminalColorSupport::Ansi16,
192        }
193    }
194
195    // --- supports_dashboard ---
196
197    #[test]
198    fn large_tty_supports_dashboard() {
199        assert!(tty_large().supports_dashboard());
200    }
201
202    #[test]
203    fn pipe_does_not_support_dashboard() {
204        assert!(!pipe().supports_dashboard());
205    }
206
207    #[test]
208    fn small_tty_does_not_support_dashboard() {
209        assert!(!tty_small().supports_dashboard());
210    }
211
212    #[test]
213    fn narrow_tty_does_not_support_dashboard() {
214        assert!(!tty_narrow().supports_dashboard());
215    }
216
217    #[test]
218    fn short_tty_does_not_support_dashboard() {
219        assert!(!tty_short().supports_dashboard());
220    }
221
222    #[test]
223    fn exact_minimum_supports_dashboard() {
224        let info = TerminalInfo {
225            is_tty: true,
226            cols: MIN_DASHBOARD_COLS,
227            rows: MIN_DASHBOARD_ROWS,
228            color_support: TerminalColorSupport::Ansi16,
229        };
230        assert!(info.supports_dashboard());
231    }
232
233    #[test]
234    fn detects_truecolor_from_colorterm() {
235        let detected = TerminalColorSupport::detect_with(|key| match key {
236            "COLORTERM" => Some("truecolor".to_string()),
237            _ => None,
238        });
239        assert_eq!(detected, TerminalColorSupport::TrueColor);
240    }
241
242    #[test]
243    fn detects_truecolor_from_term_suffix() {
244        let detected = TerminalColorSupport::detect_with(|key| match key {
245            "TERM" => Some("xterm-direct".to_string()),
246            _ => None,
247        });
248        assert_eq!(detected, TerminalColorSupport::TrueColor);
249    }
250
251    #[test]
252    fn no_color_disables_color_output() {
253        let detected = TerminalColorSupport::detect_with(|key| match key {
254            "NO_COLOR" => Some("1".to_string()),
255            "COLORTERM" => Some("truecolor".to_string()),
256            _ => None,
257        });
258        assert_eq!(detected, TerminalColorSupport::None);
259    }
260
261    // --- select_render_mode ---
262
263    #[test]
264    fn force_stream_always_stream() {
265        let mode = select_render_mode(&tty_large(), true, false).unwrap();
266        assert_eq!(mode, RenderMode::Stream);
267    }
268
269    #[test]
270    fn force_tui_returns_dashboard_on_large_tty() {
271        let mode = select_render_mode(&tty_large(), false, true).unwrap();
272        assert_eq!(mode, RenderMode::Dashboard);
273    }
274
275    #[test]
276    fn force_tui_errors_on_small_terminal() {
277        let result = select_render_mode(&tty_small(), false, true);
278        assert!(result.is_err());
279        assert!(result.unwrap_err().to_string().contains("too small"));
280    }
281
282    #[test]
283    fn force_stream_wins_over_tui() {
284        // --stream flag is checked first, so it takes precedence.
285        let mode = select_render_mode(&tty_large(), true, true).unwrap();
286        assert_eq!(mode, RenderMode::Stream);
287    }
288
289    #[test]
290    fn auto_detect_pipe_returns_stream() {
291        let mode = select_render_mode(&pipe(), false, false).unwrap();
292        assert_eq!(mode, RenderMode::Stream);
293    }
294
295    #[test]
296    fn auto_detect_small_tty_returns_stream() {
297        let mode = select_render_mode(&tty_small(), false, false).unwrap();
298        assert_eq!(mode, RenderMode::Stream);
299    }
300
301    #[test]
302    fn auto_detect_large_tty_returns_dashboard() {
303        let mode = select_render_mode(&tty_large(), false, false).unwrap();
304        assert_eq!(mode, RenderMode::Dashboard);
305    }
306
307    #[test]
308    fn auto_detect_narrow_returns_stream() {
309        let mode = select_render_mode(&tty_narrow(), false, false).unwrap();
310        assert_eq!(mode, RenderMode::Stream);
311    }
312
313    #[test]
314    fn auto_detect_short_returns_stream() {
315        let mode = select_render_mode(&tty_short(), false, false).unwrap();
316        assert_eq!(mode, RenderMode::Stream);
317    }
318
319    #[test]
320    fn terminal_info_detect_does_not_panic() {
321        // Should not panic even in non-TTY (CI) environments.
322        let _info = TerminalInfo::detect();
323    }
324}