Skip to main content

oy/cli/
ui.rs

1use kdam::Animation;
2use std::borrow::Cow;
3use std::fmt::{Display, Write as _};
4use std::io::IsTerminal as _;
5use std::num::NonZeroU16;
6use std::sync::LazyLock;
7use std::sync::atomic::{AtomicU8, Ordering};
8use std::time::Duration;
9use syntect::easy::HighlightLines;
10use syntect::highlighting::{Theme, ThemeSet};
11use syntect::parsing::SyntaxSet;
12use syntect::util::as_24_bit_terminal_escaped;
13use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
14
15/// Controls how much user-facing output `oy` writes while it runs.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum OutputMode {
18    /// Suppress normal progress output.
19    Quiet = 0,
20    /// Show standard human-readable progress output.
21    Normal = 1,
22    /// Show fuller tool previews and diagnostic context.
23    Verbose = 2,
24    /// Prefer machine-readable JSON where a command supports it.
25    Json = 3,
26}
27
28static OUTPUT_MODE: AtomicU8 = AtomicU8::new(OutputMode::Normal as u8);
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31enum ColorMode {
32    Auto,
33    Always,
34    Never,
35}
36
37static COLOR_MODE: LazyLock<ColorMode> = LazyLock::new(color_mode_from_env);
38
39pub fn init_output_mode(mode: Option<OutputMode>) {
40    let mode = mode
41        .or_else(output_mode_from_env)
42        .unwrap_or(OutputMode::Normal);
43    set_output_mode(mode);
44}
45
46/// Sets the process-wide output mode used by CLI rendering helpers.
47pub fn set_output_mode(mode: OutputMode) {
48    OUTPUT_MODE.store(mode as u8, Ordering::Relaxed);
49}
50
51pub fn output_mode() -> OutputMode {
52    match OUTPUT_MODE.load(Ordering::Relaxed) {
53        0 => OutputMode::Quiet,
54        2 => OutputMode::Verbose,
55        3 => OutputMode::Json,
56        _ => OutputMode::Normal,
57    }
58}
59
60pub fn is_quiet() -> bool {
61    matches!(output_mode(), OutputMode::Quiet | OutputMode::Json)
62}
63
64pub fn is_json() -> bool {
65    matches!(output_mode(), OutputMode::Json)
66}
67
68pub fn is_verbose() -> bool {
69    matches!(output_mode(), OutputMode::Verbose)
70}
71
72fn output_mode_from_env() -> Option<OutputMode> {
73    if truthy_env("OY_QUIET") {
74        return Some(OutputMode::Quiet);
75    }
76    if truthy_env("OY_VERBOSE") {
77        return Some(OutputMode::Verbose);
78    }
79    match std::env::var("OY_OUTPUT")
80        .ok()?
81        .to_ascii_lowercase()
82        .as_str()
83    {
84        "quiet" => Some(OutputMode::Quiet),
85        "verbose" => Some(OutputMode::Verbose),
86        "json" => Some(OutputMode::Json),
87        "normal" => Some(OutputMode::Normal),
88        _ => None,
89    }
90}
91
92fn truthy_env(name: &str) -> bool {
93    matches!(
94        std::env::var(name).ok().as_deref(),
95        Some("1" | "true" | "yes" | "on")
96    )
97}
98
99fn color_mode_from_env() -> ColorMode {
100    color_mode_from_values(
101        std::env::var_os("NO_COLOR").is_some(),
102        std::env::var("OY_COLOR").ok().as_deref(),
103    )
104}
105
106fn color_mode_from_values(no_color: bool, oy_color: Option<&str>) -> ColorMode {
107    if no_color {
108        return ColorMode::Never;
109    }
110    match oy_color.map(str::to_ascii_lowercase).as_deref() {
111        Some("always" | "1" | "true" | "yes" | "on") => ColorMode::Always,
112        Some("never" | "0" | "false" | "no" | "off") => ColorMode::Never,
113        _ => ColorMode::Auto,
114    }
115}
116
117pub fn color_enabled() -> bool {
118    color_enabled_for_stdout(std::io::stdout().is_terminal())
119}
120
121fn color_enabled_for_stdout(stdout_is_terminal: bool) -> bool {
122    color_enabled_for_mode(*COLOR_MODE, stdout_is_terminal)
123}
124
125fn color_enabled_for_mode(mode: ColorMode, stdout_is_terminal: bool) -> bool {
126    match mode {
127        ColorMode::Always => true,
128        ColorMode::Never => false,
129        ColorMode::Auto => stdout_is_terminal,
130    }
131}
132
133pub fn terminal_width() -> usize {
134    terminal_size::terminal_size()
135        .map(|(terminal_size::Width(width), _)| width as usize)
136        .filter(|width| *width >= 40)
137        .unwrap_or(100)
138}
139
140pub fn paint(code: &str, text: impl Display) -> String {
141    if color_enabled() {
142        format!("\x1b[{code}m{text}\x1b[0m")
143    } else {
144        text.to_string()
145    }
146}
147
148pub fn faint(text: impl Display) -> String {
149    paint("2", text)
150}
151
152pub fn bold(text: impl Display) -> String {
153    paint("1", text)
154}
155
156pub fn cyan(text: impl Display) -> String {
157    paint("36", text)
158}
159
160pub fn green(text: impl Display) -> String {
161    paint("32", text)
162}
163
164pub fn yellow(text: impl Display) -> String {
165    paint("33", text)
166}
167
168pub fn red(text: impl Display) -> String {
169    paint("31", text)
170}
171
172pub fn magenta(text: impl Display) -> String {
173    paint("35", text)
174}
175
176pub fn status_text(ok: bool, text: impl Display) -> String {
177    if ok { green(text) } else { red(text) }
178}
179
180pub fn bool_text(value: bool) -> String {
181    status_text(value, value)
182}
183
184pub fn path(text: impl Display) -> String {
185    paint("1;36", text)
186}
187
188pub fn out(text: &str) {
189    print!("{text}");
190}
191
192pub fn err(text: &str) {
193    eprint!("{text}");
194}
195
196pub fn line(text: impl Display) {
197    out(&format!("{text}\n"));
198}
199
200pub fn err_line(text: impl Display) {
201    err(&format!("{text}\n"));
202}
203
204pub fn markdown(text: &str) {
205    out(&render_markdown(text));
206}
207
208fn render_markdown(text: &str) -> String {
209    if !color_enabled() {
210        return text.to_string();
211    }
212    let mut in_fence = false;
213    let mut out = String::new();
214    for line in text.lines() {
215        let trimmed = line.trim_start();
216        let rendered = if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
217            in_fence = !in_fence;
218            faint(line)
219        } else if in_fence {
220            cyan(line)
221        } else if trimmed.starts_with('#') {
222            paint("1;35", line)
223        } else if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
224            cyan(line)
225        } else {
226            line.to_string()
227        };
228        let _ = writeln!(out, "{rendered}");
229    }
230    if text.ends_with('\n') {
231        out
232    } else {
233        out.trim_end_matches('\n').to_string()
234    }
235}
236
237pub fn code(path: &str, text: &str, first_line: usize) -> String {
238    numbered_block(path, &normalize_code_preview_text(text), first_line)
239}
240
241pub fn text_block(title: &str, text: &str) -> String {
242    numbered_block(title, text, 1)
243}
244
245pub fn block_title(title: &str) -> String {
246    path(format_args!("── {title}"))
247}
248
249#[cfg(test)]
250fn numbered_line(line_number: usize, width: usize, text: &str) -> String {
251    numbered_line_with_max_width(line_number, width, text, usize::MAX)
252}
253
254fn numbered_line_with_max_width(
255    line_number: usize,
256    width: usize,
257    text: &str,
258    max_width: usize,
259) -> String {
260    let text = normalize_code_preview_text(text);
261    let prefix = format!(
262        "{} {} ",
263        faint(format_args!("{line_number:>width$}")),
264        faint("│")
265    );
266    let available = max_width
267        .saturating_sub(ansi_stripped_width(&prefix))
268        .max(1);
269    format!("{prefix}{}", truncate_width(&text, available))
270}
271
272fn normalize_code_preview_text(text: &str) -> Cow<'_, str> {
273    const TAB_WIDTH: usize = 4;
274    if !text.contains('\t') {
275        return Cow::Borrowed(text);
276    }
277
278    let mut out = String::with_capacity(text.len());
279    let mut column = 0usize;
280    for ch in text.chars() {
281        match ch {
282            '\t' => {
283                let spaces = TAB_WIDTH - (column % TAB_WIDTH);
284                out.extend(std::iter::repeat_n(' ', spaces));
285                column += spaces;
286            }
287            '\n' | '\r' => {
288                out.push(ch);
289                column = 0;
290            }
291            _ => {
292                out.push(ch);
293                column += UnicodeWidthChar::width(ch).unwrap_or(0);
294            }
295        }
296    }
297    Cow::Owned(out)
298}
299
300fn numbered_block(title: &str, text: &str, first_line: usize) -> String {
301    let title = if title.is_empty() { "text" } else { title };
302    let line_count = text.lines().count().max(1);
303    let width = first_line
304        .saturating_add(line_count.saturating_sub(1))
305        .max(1)
306        .to_string()
307        .len();
308    let max_width = terminal_width().saturating_sub(4).max(40);
309    let code_width = max_width.saturating_sub(width + 3).max(1);
310    let mut out = String::new();
311    let _ = writeln!(out, "{}", truncate_width(&block_title(title), max_width));
312    if text.is_empty() {
313        let _ = writeln!(
314            out,
315            "{}",
316            numbered_line_with_max_width(first_line, width, "", max_width)
317        );
318    } else {
319        let display_text = text
320            .lines()
321            .map(|line| truncate_width(line, code_width))
322            .collect::<Vec<_>>()
323            .join("\n");
324        let highlighted = highlighted_block(title, &display_text);
325        let lines = highlighted.as_deref().unwrap_or(&display_text).lines();
326        for (idx, line) in lines.enumerate() {
327            let _ = writeln!(
328                out,
329                "{}",
330                numbered_line_with_max_width(first_line + idx, width, line, max_width)
331            );
332        }
333    }
334    out.trim_end().to_string()
335}
336
337static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
338static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
339
340fn highlighted_block(title: &str, text: &str) -> Option<String> {
341    if !color_enabled() {
342        return None;
343    }
344    let syntax = syntax_for_title(title)?;
345    let theme = terminal_theme()?;
346    let mut highlighter = HighlightLines::new(syntax, theme);
347    let mut out = String::new();
348    for line in text.lines() {
349        let ranges = highlighter.highlight_line(line, &SYNTAX_SET).ok()?;
350        let _ = writeln!(out, "{}", as_24_bit_terminal_escaped(&ranges, false));
351    }
352    Some(if text.ends_with('\n') {
353        out
354    } else {
355        out.trim_end_matches('\n').to_string()
356    })
357}
358
359fn syntax_for_title(title: &str) -> Option<&'static syntect::parsing::SyntaxReference> {
360    let syntaxes = &*SYNTAX_SET;
361    let name = title.rsplit('/').next().unwrap_or(title);
362    if let Some(ext) = name.rsplit_once('.').map(|(_, ext)| ext) {
363        syntaxes.find_syntax_by_extension(ext)
364    } else {
365        syntaxes.find_syntax_by_token(name)
366    }
367    .or_else(|| syntaxes.find_syntax_by_name(title))
368}
369
370fn terminal_theme() -> Option<&'static Theme> {
371    THEME_SET
372        .themes
373        .get("base16-ocean.dark")
374        .or_else(|| THEME_SET.themes.values().next())
375}
376
377pub fn diff(text: &str) -> String {
378    if !color_enabled() {
379        return text.to_string();
380    }
381    let mut out = String::new();
382    for line in text.lines() {
383        let rendered = if line.starts_with("+++") || line.starts_with("---") {
384            bold(line)
385        } else if line.starts_with("@@") {
386            cyan(line)
387        } else if line.starts_with('+') {
388            green(line)
389        } else if line.starts_with('-') {
390            red(line)
391        } else {
392            line.to_string()
393        };
394        let _ = writeln!(out, "{rendered}");
395    }
396    if text.ends_with('\n') {
397        out
398    } else {
399        out.trim_end_matches('\n').to_string()
400    }
401}
402
403pub fn section(title: &str) {
404    line(bold(title));
405}
406
407pub fn kv(key: &str, value: impl Display) {
408    line(format_args!(
409        "  {} {value}",
410        faint(format_args!("{key:<11}"))
411    ));
412}
413
414pub fn success(text: impl Display) {
415    line(format_args!("{} {text}", green("✓")));
416}
417
418pub fn warn(text: impl Display) {
419    line(format_args!("{} {text}", yellow("!")));
420}
421
422pub fn progress(
423    label: &str,
424    current: usize,
425    total: usize,
426    detail: impl Display,
427    elapsed: Duration,
428) {
429    if is_quiet() {
430        return;
431    }
432    line(progress_line(
433        label,
434        current,
435        total,
436        &detail.to_string(),
437        elapsed,
438    ));
439}
440
441fn progress_line(
442    label: &str,
443    current: usize,
444    total: usize,
445    detail: &str,
446    elapsed: Duration,
447) -> String {
448    let total = total.max(1);
449    let current = current.min(total);
450    let head = format!(
451        "  {} {current}/{total} {}",
452        progress_bar(current, total, 18),
453        cyan(label)
454    );
455    if detail.trim().is_empty() {
456        format!("{head} · {}", faint(format_duration(elapsed)))
457    } else {
458        format!("{head} · {detail} · {}", faint(format_duration(elapsed)))
459    }
460}
461
462fn progress_bar(current: usize, total: usize, width: u16) -> String {
463    let total = total.max(1);
464    let current = current.min(total);
465    let percentage = current as f32 / total as f32;
466    Animation::FillUp.fmt_render(
467        NonZeroU16::new(width.max(1)).expect("progress bar width is non-zero"),
468        percentage,
469        &None,
470    )
471}
472
473pub fn tool_batch(round: usize, count: usize) {
474    if is_quiet() {
475        return;
476    }
477    err_line(tool_batch_line(round, count));
478}
479
480pub fn tool_start(name: &str, detail: &str) {
481    if is_quiet() {
482        return;
483    }
484    err_line(tool_start_line(name, detail));
485}
486
487pub fn tool_result(name: &str, elapsed: Duration, preview: &str) {
488    if is_quiet() {
489        return;
490    }
491    let preview = preview.trim_end();
492    let head = tool_result_head(name, elapsed);
493    let Some((first, rest)) = preview.split_once('\n') else {
494        if preview.is_empty() {
495            err_line(head);
496        } else {
497            err_line(format_args!("{head} · {first}", first = preview));
498        }
499        return;
500    };
501    err_line(format_args!("{head} · {first}"));
502    for line in rest.lines() {
503        err_line(format_args!("    {line}"));
504    }
505}
506
507pub fn tool_error(name: &str, elapsed: Duration, err: impl Display) {
508    if is_quiet() {
509        return;
510    }
511    err_line(format_args!(
512        "  {} {name} {} · {err:#}",
513        red("✗"),
514        format_duration(elapsed)
515    ));
516}
517
518pub fn format_duration(elapsed: Duration) -> String {
519    if elapsed.as_millis() < 1000 {
520        format!("{}ms", elapsed.as_millis())
521    } else {
522        format!("{:.1}s", elapsed.as_secs_f64())
523    }
524}
525
526fn tool_batch_line(round: usize, count: usize) -> String {
527    format!("{} tools r{round} ×{count}", magenta("↻"))
528}
529
530fn tool_start_line(name: &str, detail: &str) -> String {
531    if detail.is_empty() {
532        format!("  {} {name}", cyan("→"))
533    } else {
534        format!("  {} {name} · {detail}", cyan("→"))
535    }
536}
537
538fn tool_result_head(name: &str, elapsed: Duration) -> String {
539    format!("  {} {name} {}", green("✓"), format_duration(elapsed))
540}
541
542pub fn compact_spaces(value: &str) -> String {
543    value.split_whitespace().collect::<Vec<_>>().join(" ")
544}
545
546pub fn truncate_chars(text: &str, max: usize) -> String {
547    truncate_width(text, max)
548}
549
550pub fn truncate_width(text: &str, max_width: usize) -> String {
551    if ansi_stripped_width(text) <= max_width {
552        return text.to_string();
553    }
554    truncate_plain_width(text, max_width)
555}
556
557fn truncate_plain_width(text: &str, max_width: usize) -> String {
558    if UnicodeWidthStr::width(text) <= max_width {
559        return text.to_string();
560    }
561    let ellipsis = "…";
562    let limit = max_width.saturating_sub(UnicodeWidthStr::width(ellipsis));
563    let mut out = String::new();
564    let mut width = 0usize;
565    for ch in text.chars() {
566        let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
567        if width + ch_width > limit {
568            break;
569        }
570        width += ch_width;
571        out.push(ch);
572    }
573    out.push_str(ellipsis);
574    out
575}
576
577fn ansi_stripped_width(text: &str) -> usize {
578    let mut width = 0usize;
579    let mut chars = text.chars().peekable();
580    while let Some(ch) = chars.next() {
581        if ch == '\u{1b}' && chars.peek() == Some(&'[') {
582            chars.next();
583            for next in chars.by_ref() {
584                if ('@'..='~').contains(&next) {
585                    break;
586                }
587            }
588        } else {
589            width += UnicodeWidthChar::width(ch).unwrap_or(0);
590        }
591    }
592    width
593}
594
595pub fn compact_preview(text: &str, max: usize) -> String {
596    truncate_width(&compact_spaces(text), max)
597}
598
599pub fn clamp_lines(text: &str, max_lines: usize, max_cols: usize) -> String {
600    let mut out = String::new();
601    let lines = text.lines().collect::<Vec<_>>();
602    for line in lines.iter().take(max_lines) {
603        if !out.is_empty() {
604            out.push('\n');
605        }
606        out.push_str(&truncate_width(line, max_cols));
607    }
608    if lines.len() > max_lines {
609        let _ = write!(out, "\n… {} more lines", lines.len() - max_lines);
610    }
611    out
612}
613
614#[allow(dead_code)]
615pub fn wrap_line(text: &str, indent: &str) -> String {
616    let width = terminal_width().saturating_sub(indent.width()).max(20);
617    textwrap::wrap(text, width)
618        .into_iter()
619        .map(|line| format!("{indent}{line}"))
620        .collect::<Vec<_>>()
621        .join("\n")
622}
623
624pub fn head_tail(text: &str, max_chars: usize) -> (String, bool) {
625    if text.chars().count() <= max_chars {
626        return (text.to_string(), false);
627    }
628    let head_len = max_chars / 2;
629    let tail_len = max_chars.saturating_sub(head_len);
630    let head = text.chars().take(head_len).collect::<String>();
631    let tail = text
632        .chars()
633        .rev()
634        .take(tail_len)
635        .collect::<Vec<_>>()
636        .into_iter()
637        .rev()
638        .collect::<String>();
639    let hidden = text
640        .chars()
641        .count()
642        .saturating_sub(head.chars().count() + tail.chars().count());
643    (
644        format!("{head}\n… [truncated {hidden} chars] …\n{tail}"),
645        true,
646    )
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652
653    fn color_mode_name(mode: ColorMode) -> &'static str {
654        match mode {
655            ColorMode::Auto => "auto",
656            ColorMode::Always => "always",
657            ColorMode::Never => "never",
658        }
659    }
660
661    #[test]
662    fn color_mode_env_parsing() {
663        assert_eq!(color_mode_name(color_mode_from_values(false, None)), "auto");
664        assert_eq!(
665            color_mode_name(color_mode_from_values(false, Some("always"))),
666            "always"
667        );
668        assert_eq!(
669            color_mode_name(color_mode_from_values(false, Some("on"))),
670            "always"
671        );
672        assert_eq!(
673            color_mode_name(color_mode_from_values(false, Some("off"))),
674            "never"
675        );
676        assert_eq!(
677            color_mode_name(color_mode_from_values(true, Some("always"))),
678            "never"
679        );
680    }
681
682    #[test]
683    fn color_auto_requires_terminal() {
684        assert!(!color_enabled_for_mode(ColorMode::Auto, false));
685        assert!(color_enabled_for_mode(ColorMode::Auto, true));
686        assert!(color_enabled_for_mode(ColorMode::Always, false));
687        assert!(!color_enabled_for_mode(ColorMode::Never, true));
688    }
689
690    #[test]
691    fn elapsed_format_is_compact() {
692        assert_eq!(format_duration(Duration::from_millis(42)), "42ms");
693        assert_eq!(format_duration(Duration::from_millis(1250)), "1.2s");
694    }
695
696    #[test]
697    fn progress_line_shows_bar_count_detail_and_elapsed() {
698        set_output_mode(OutputMode::Normal);
699        assert_eq!(progress_bar(2, 4, 8), "|████▂   |");
700        assert_eq!(
701            progress_line("review", 2, 4, "chunk 3", Duration::from_millis(1250)),
702            "  |█████████▂        | 2/4 review · chunk 3 · 1.2s"
703        );
704    }
705
706    #[test]
707    fn tool_progress_lines_are_dense() {
708        set_output_mode(OutputMode::Normal);
709        assert_eq!(tool_batch_line(2, 3), "↻ tools r2 ×3");
710        assert_eq!(
711            tool_start_line("read", "path=src/main.rs"),
712            "  → read · path=src/main.rs"
713        );
714        assert_eq!(
715            tool_result_head("read", Duration::from_millis(42)),
716            "  ✓ read 42ms"
717        );
718    }
719
720    #[test]
721    fn numbered_line_expands_tabs_to_stable_columns() {
722        set_output_mode(OutputMode::Normal);
723        assert_eq!(numbered_line(7, 1, "\tlet x = 1;"), "7 │     let x = 1;");
724        assert_eq!(numbered_line(8, 1, "ab\tcd"), "8 │ ab  cd");
725        assert_eq!(
726            code("demo.rs", "\tfn main() {}\n\t\tprintln!(\"hi\");", 1),
727            "── demo.rs\n1 │     fn main() {}\n2 │         println!(\"hi\");"
728        );
729    }
730
731    #[test]
732    fn numbered_line_clamps_long_read_lines_to_preview_width() {
733        set_output_mode(OutputMode::Normal);
734        let line = numbered_line_with_max_width(
735            394,
736            3,
737            r#"        .filter(|line| !line.starts_with(&format!("> {}", prompts::AUDIT_TRANSPARENCY_PREFIX)))"#,
738            40,
739        );
740        assert!(UnicodeWidthStr::width(line.as_str()) <= 40, "{line}");
741        assert!(line.starts_with("394 │ "));
742        assert!(line.ends_with('…'));
743        assert!(!line.contains('\n'));
744    }
745
746    #[test]
747    fn code_preview_lines_fit_tool_result_indent_width() {
748        set_output_mode(OutputMode::Normal);
749        let preview = code(
750            "src/audit.rs",
751            r#"pub(crate) fn with_transparency_line(report: &str, snippet: &str) -> String {
752    .filter(|line| !line.starts_with(&format!("> {}", prompts::AUDIT_TRANSPARENCY_PREFIX)))"#,
753            390,
754        );
755        let max_width = terminal_width().saturating_sub(4).max(40);
756        for line in preview.lines() {
757            assert!(
758                UnicodeWidthStr::width(line) <= max_width,
759                "line exceeded {max_width}: {line}"
760            );
761        }
762    }
763}