Big Brain v0.4.0

ui.rs

Rust · 158 lines · 4.4 KB
//! Terminal styling. Colours only when the stream is a terminal and NO_COLOR isn't set, so agents
//! (whose output is piped) always get plain text. On Windows, the console is switched to ANSI mode
//! first; where that isn't possible (very old consoles) output stays plain.

use std::io::IsTerminal;
use std::sync::OnceLock;

fn enabled(is_tty: bool) -> bool {
    if !is_tty || std::env::var_os("NO_COLOR").is_some() {
        return false;
    }
    if cfg!(windows) {
        return anstyle_query::windows::enable_ansi_colors().unwrap_or(false);
    }
    true
}

fn on() -> bool {
    static ON: OnceLock<bool> = OnceLock::new();
    *ON.get_or_init(|| enabled(std::io::stdout().is_terminal()))
}

fn err_on() -> bool {
    static ON: OnceLock<bool> = OnceLock::new();
    *ON.get_or_init(|| enabled(std::io::stderr().is_terminal()))
}

fn paint_if(on: bool, code: &str, s: &str) -> String {
    if on { format!("\x1b[{code}m{s}\x1b[0m") } else { s.to_string() }
}

fn paint(code: &str, s: &str) -> String {
    paint_if(on(), code, s)
}

pub fn bold(s: &str) -> String {
    paint("1", s)
}
pub fn dim(s: &str) -> String {
    paint("2", s)
}
pub fn cyan(s: &str) -> String {
    paint("36", s)
}
pub fn green(s: &str) -> String {
    paint("32", s)
}
pub fn yellow(s: &str) -> String {
    paint("33", s)
}
pub fn red(s: &str) -> String {
    paint("31", s)
}

/// Red, for text written to stderr (coloured only when stderr is a terminal).
pub fn err_red(s: &str) -> String {
    paint_if(err_on(), "1;31", s)
}

/// An entry kind in its own colour, so kinds are easy to scan in lists.
pub fn kind(k: &str) -> String {
    let code = match k {
        "system" => "34",
        "workflow" => "36",
        "fix" => "32",
        "gotcha" => "33",
        "decision" => "35",
        "runbook" => "31",
        _ => "1",
    };
    paint(code, k)
}

/// A similarity as a percentage, coloured against the provider's thresholds (models score on
/// different scales): green at or above `related`, yellow above `weak`, dim below.
pub fn pct(sim: f32, weak: f32, related: f32) -> String {
    let s = format!("{:.0}%", sim * 100.0);
    if sim >= related {
        green(&s)
    } else if sim >= weak {
        yellow(&s)
    } else {
        dim(&s)
    }
}

/// A section heading in `brain get` and similar views.
pub fn heading(s: &str) -> String {
    paint("1;36", s)
}

/// The product name as shown at the top of reports.
pub fn wordmark(what: &str) -> String {
    let name = bold("Big Brain");
    if what.is_empty() { name } else { format!("{name} {}", bold(what)) }
}

/// A thin rule between parts of a view.
pub fn rule() -> String {
    dim(&"─".repeat(56))
}

/// Model-written text, labelled so a reader (human or agent) knows it is unverified.
pub fn ai_note(verdict: &str, note: &str) -> String {
    let v = match verdict {
        "accurate" => green(verdict),
        _ => yellow(verdict),
    };
    format!("{} {v}{}{}", dim("librarian (AI, unverified):"), if note.is_empty() { "" } else { " — " }, note)
}

/// Terminal width for wrapping: COLUMNS, else the terminal's real size, else 100; clamped to a
/// readable range.
pub fn width() -> usize {
    std::env::var("COLUMNS")
        .ok()
        .and_then(|c| c.parse().ok())
        .or_else(tty_columns)
        .unwrap_or(100)
        .clamp(40, 120)
}

#[cfg(unix)]
fn tty_columns() -> Option<usize> {
    // SAFETY: TIOCGWINSZ only fills the winsize struct we pass; failure leaves it untouched.
    unsafe {
        let mut ws: libc::winsize = std::mem::zeroed();
        (libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) == 0 && ws.ws_col > 0).then_some(ws.ws_col as usize)
    }
}

#[cfg(not(unix))]
fn tty_columns() -> Option<usize> {
    None
}

/// Word-wrap `text`: the first line starts at column `first_used`, later lines are indented by
/// `indent` spaces, and no line passes `width` (unless a single word is longer).
pub fn wrap(text: &str, first_used: usize, indent: usize, width: usize) -> String {
    let mut out = String::new();
    let mut col = first_used;
    for (i, w) in text.split_whitespace().enumerate() {
        let wl = w.chars().count();
        if i > 0 {
            if col + 1 + wl > width {
                out.push('\n');
                out.push_str(&" ".repeat(indent));
                col = indent;
            } else {
                out.push(' ');
                col += 1;
            }
        }
        out.push_str(w);
        col += wl;
    }
    out
}