Skip to main content

oy/cli/
ui.rs

1use std::fmt::Display;
2use std::io::IsTerminal as _;
3use std::sync::LazyLock;
4use std::sync::atomic::{AtomicU8, Ordering};
5
6mod progress;
7mod render;
8mod text;
9
10pub use progress::{format_duration, progress, tool_error, tool_result, tool_start};
11pub use render::{block_title, code, diff, markdown, text_block};
12pub use text::{clamp_lines, compact_preview, compact_spaces, head_tail, truncate_chars};
13
14/// Controls how much user-facing output `oy` writes while it runs.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum OutputMode {
17    /// Suppress normal progress output.
18    Quiet = 0,
19    /// Show standard human-readable progress output.
20    Normal = 1,
21    /// Show fuller tool previews and diagnostic context.
22    Verbose = 2,
23    /// Prefer machine-readable JSON where a command supports it.
24    Json = 3,
25}
26
27static OUTPUT_MODE: AtomicU8 = AtomicU8::new(OutputMode::Normal as u8);
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum ColorMode {
31    Auto,
32    Always,
33    Never,
34}
35
36static COLOR_MODE: LazyLock<ColorMode> = LazyLock::new(color_mode_from_env);
37
38pub fn init_output_mode(mode: Option<OutputMode>) {
39    let mode = mode
40        .or_else(output_mode_from_env)
41        .unwrap_or(OutputMode::Normal);
42    set_output_mode(mode);
43}
44
45/// Sets the process-wide output mode used by CLI rendering helpers.
46pub fn set_output_mode(mode: OutputMode) {
47    OUTPUT_MODE.store(mode as u8, Ordering::Relaxed);
48}
49
50pub fn output_mode() -> OutputMode {
51    match OUTPUT_MODE.load(Ordering::Relaxed) {
52        0 => OutputMode::Quiet,
53        2 => OutputMode::Verbose,
54        3 => OutputMode::Json,
55        _ => OutputMode::Normal,
56    }
57}
58
59pub fn is_quiet() -> bool {
60    matches!(output_mode(), OutputMode::Quiet | OutputMode::Json)
61}
62
63pub fn is_json() -> bool {
64    matches!(output_mode(), OutputMode::Json)
65}
66
67pub fn is_verbose() -> bool {
68    matches!(output_mode(), OutputMode::Verbose)
69}
70
71fn output_mode_from_env() -> Option<OutputMode> {
72    if truthy_env("OY_QUIET") {
73        return Some(OutputMode::Quiet);
74    }
75    if truthy_env("OY_VERBOSE") {
76        return Some(OutputMode::Verbose);
77    }
78    match std::env::var("OY_OUTPUT")
79        .ok()?
80        .to_ascii_lowercase()
81        .as_str()
82    {
83        "quiet" => Some(OutputMode::Quiet),
84        "verbose" => Some(OutputMode::Verbose),
85        "json" => Some(OutputMode::Json),
86        "normal" => Some(OutputMode::Normal),
87        _ => None,
88    }
89}
90
91fn truthy_env(name: &str) -> bool {
92    matches!(
93        std::env::var(name).ok().as_deref(),
94        Some("1" | "true" | "yes" | "on")
95    )
96}
97
98fn color_mode_from_env() -> ColorMode {
99    color_mode_from_values(
100        std::env::var_os("NO_COLOR").is_some(),
101        std::env::var("OY_COLOR").ok().as_deref(),
102    )
103}
104
105fn color_mode_from_values(no_color: bool, oy_color: Option<&str>) -> ColorMode {
106    if no_color {
107        return ColorMode::Never;
108    }
109    match oy_color.map(str::to_ascii_lowercase).as_deref() {
110        Some("always" | "1" | "true" | "yes" | "on") => ColorMode::Always,
111        Some("never" | "0" | "false" | "no" | "off") => ColorMode::Never,
112        _ => ColorMode::Auto,
113    }
114}
115
116pub fn color_enabled() -> bool {
117    color_enabled_for_stdout(std::io::stdout().is_terminal())
118}
119
120fn color_enabled_for_stdout(stdout_is_terminal: bool) -> bool {
121    color_enabled_for_mode(*COLOR_MODE, stdout_is_terminal)
122}
123
124fn color_enabled_for_mode(mode: ColorMode, stdout_is_terminal: bool) -> bool {
125    match mode {
126        ColorMode::Always => true,
127        ColorMode::Never => false,
128        ColorMode::Auto => stdout_is_terminal,
129    }
130}
131
132pub fn terminal_width() -> usize {
133    terminal_size::terminal_size()
134        .map(|(terminal_size::Width(width), _)| width as usize)
135        .filter(|width| *width >= 40)
136        .unwrap_or(100)
137}
138
139pub fn paint(code: &str, text: impl Display) -> String {
140    let text = text.to_string();
141    if text.contains('\x1b') {
142        return sanitize_terminal(&text);
143    }
144    if color_enabled() {
145        format!("\x1b[{code}m{text}\x1b[0m")
146    } else {
147        text
148    }
149}
150
151/// Strip terminal escape sequences to prevent injection from untrusted input.
152fn sanitize_terminal(text: &str) -> String {
153    text.replace('\x1b', "␛")
154}
155
156pub fn faint(text: impl Display) -> String {
157    paint("2", text)
158}
159
160pub fn bold(text: impl Display) -> String {
161    paint("1", text)
162}
163
164pub fn cyan(text: impl Display) -> String {
165    paint("36", text)
166}
167
168pub fn green(text: impl Display) -> String {
169    paint("32", text)
170}
171
172pub fn yellow(text: impl Display) -> String {
173    paint("33", text)
174}
175
176pub fn red(text: impl Display) -> String {
177    paint("31", text)
178}
179
180pub fn status_text(ok: bool, text: impl Display) -> String {
181    if ok { green(text) } else { red(text) }
182}
183
184pub fn bool_text(value: bool) -> String {
185    status_text(value, value)
186}
187
188pub fn path(text: impl Display) -> String {
189    paint("1;36", text)
190}
191
192pub fn out(text: &str) {
193    print!("{text}");
194}
195
196pub fn err(text: &str) {
197    eprint!("{text}");
198}
199
200pub fn line(text: impl Display) {
201    out(&format!("{text}\n"));
202}
203
204pub fn err_line(text: impl Display) {
205    err(&format!("{text}\n"));
206}
207
208pub fn section(title: &str) {
209    line(bold(title));
210}
211
212pub fn kv(key: &str, value: impl Display) {
213    line(format_args!(
214        "  {} {value}",
215        faint(format_args!("{key:<11}"))
216    ));
217}
218
219pub fn success(text: impl Display) {
220    line(format_args!("{} {text}", green("✓")));
221}
222
223pub fn warn(text: impl Display) {
224    line(format_args!("{} {text}", yellow("!")));
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    fn color_mode_name(mode: ColorMode) -> &'static str {
232        match mode {
233            ColorMode::Auto => "auto",
234            ColorMode::Always => "always",
235            ColorMode::Never => "never",
236        }
237    }
238
239    #[test]
240    fn color_mode_env_parsing() {
241        assert_eq!(color_mode_name(color_mode_from_values(false, None)), "auto");
242        assert_eq!(
243            color_mode_name(color_mode_from_values(false, Some("always"))),
244            "always"
245        );
246        assert_eq!(
247            color_mode_name(color_mode_from_values(false, Some("on"))),
248            "always"
249        );
250        assert_eq!(
251            color_mode_name(color_mode_from_values(false, Some("off"))),
252            "never"
253        );
254        assert_eq!(
255            color_mode_name(color_mode_from_values(true, Some("always"))),
256            "never"
257        );
258    }
259
260    #[test]
261    fn color_auto_requires_terminal() {
262        assert!(!color_enabled_for_mode(ColorMode::Auto, false));
263        assert!(color_enabled_for_mode(ColorMode::Auto, true));
264        assert!(color_enabled_for_mode(ColorMode::Always, false));
265        assert!(!color_enabled_for_mode(ColorMode::Never, true));
266    }
267}