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
}