ui.rs
Rust · 455 lines · 15.6 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 `mind get` and similar views (in a colour terminal: a gold bar, then the
/// heading in light lavender).
pub fn heading(s: &str) -> String {
if on() {
return format!("{} {}", rgb(GOLD, "33", "▍"), rgb((214, 208, 255), "1;35", s));
}
s.to_string()
}
/// The product name as shown at the top of reports (in a colour terminal: `◆ MEGA MIND · what`
/// in the purple-to-gold gradient).
pub fn wordmark(what: &str) -> String {
if on() {
let text = if what.is_empty() { "MEGA MIND".to_string() } else { format!("MEGA MIND · {what}") };
return format!("\n {} {}", rgb(GOLD, "33", "◆"), gradient(&text, PURPLE, GOLD));
}
let name = bold("Mega Mind");
if what.is_empty() { name } else { format!("{name} {}", bold(what)) }
}
/// A thin rule between parts of a view (in a colour terminal: fading from purple to gold).
pub fn rule() -> String {
if on() && truecolor() {
let mut out = String::new();
for i in 0..56 {
let c = mix((60, 52, 120), (110, 90, 40), i as f32 / 55.0);
out.push_str(&format!("\x1b[38;2;{};{};{}m─", c.0, c.1, c.2));
}
return out + "\x1b[0m";
}
dim(&"─".repeat(56))
}
/// A status line's mark in a colour terminal: ✓ in green, ▲ in amber, ✗ in red.
pub fn mark_ok() -> String {
if on() { rgb((127, 214, 164), "32", "✓") } else { "✓".to_string() }
}
pub fn mark_warn() -> String {
if on() { rgb((245, 196, 94), "33", "▲") } else { "⚠".to_string() }
}
pub fn mark_bad() -> String {
if on() { rgb((255, 128, 112), "31", "✗") } else { "✗".to_string() }
}
/// 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
}
// ------------------------------------------------------------------ the rich view
// Used only when stdout is a colour terminal (agents, whose output is piped, never see it).
/// Whether output goes to a colour terminal (the rich views are drawn only then).
pub fn rich() -> bool {
on()
}
/// 24-bit colour, when the terminal says it supports it (COLORTERM); else the 16 basic colours.
fn truecolor() -> bool {
static TC: OnceLock<bool> = OnceLock::new();
*TC.get_or_init(|| std::env::var("COLORTERM").is_ok_and(|v| v.contains("truecolor") || v.contains("24bit")))
}
type Rgb = (u8, u8, u8);
/// The brand colours.
pub const PURPLE: Rgb = (121, 103, 236);
pub const GOLD: Rgb = (255, 210, 110);
const INK: Rgb = (22, 20, 30);
fn mix(a: Rgb, b: Rgb, k: f32) -> Rgb {
let k = k.clamp(0.0, 1.0);
let m = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * k).round() as u8;
(m(a.0, b.0), m(a.1, b.1), m(a.2, b.2))
}
/// `s` in colour `c` (24-bit), or in the nearest basic colour `basic` (an SGR code).
pub fn rgb(c: Rgb, basic: &str, s: &str) -> String {
if !on() {
return s.to_string();
}
if truecolor() { format!("\x1b[38;2;{};{};{}m{s}\x1b[0m", c.0, c.1, c.2) } else { paint(basic, s) }
}
/// `s` with its colour sliding from `a` to `b` across the characters (bold).
pub fn gradient(s: &str, a: Rgb, b: Rgb) -> String {
if !on() {
return s.to_string();
}
if !truecolor() {
return paint("1;35", s);
}
let n = s.chars().count().max(2) - 1;
let mut out = String::new();
for (i, ch) in s.chars().enumerate() {
let c = mix(a, b, i as f32 / n as f32);
out.push_str(&format!("\x1b[1;38;2;{};{};{}m{ch}", c.0, c.1, c.2));
}
out + "\x1b[0m"
}
/// The rail down the left of a result card, coloured `k` of the way from purple to gold.
pub fn rail(ch: &str, k: f32) -> String {
rgb(mix(PURPLE, GOLD, k), "35", ch)
}
/// An entry kind as a solid badge: ` FIX ` in the kind's colour.
pub fn kind_badge(k: &str) -> String {
let (c, basic): (Rgb, &str) = match k {
"system" => ((121, 103, 236), "44"),
"workflow" => ((103, 232, 249), "46"),
"fix" => ((127, 214, 164), "42"),
"gotcha" => ((245, 196, 94), "43"),
"decision" => ((240, 140, 200), "45"),
"runbook" => ((255, 138, 120), "41"),
_ => ((200, 196, 210), "47"),
};
let label = format!(" {} ", k.to_uppercase());
if !on() {
return label;
}
if truecolor() {
format!("\x1b[1;48;2;{};{};{};38;2;{};{};{}m{label}\x1b[0m", c.0, c.1, c.2, INK.0, INK.1, INK.2)
} else {
format!("\x1b[1;30;{basic}m{label}\x1b[0m")
}
}
/// Text in the soft body colour of the rich view (dimmer than titles, brighter than metadata).
pub fn soft(s: &str) -> String {
rgb((207, 202, 222), "37", s)
}
/// Text in the muted colour of the rich view (paths, ids).
pub fn muted(s: &str) -> String {
rgb((150, 144, 170), "2", s)
}
/// A match strength as a ten-cell meter that brightens from purple to gold, then the percentage.
pub fn meter(sim: f32) -> String {
let filled = ((sim.clamp(0.0, 1.0) * 10.0).round() as usize).min(10);
let mut out = String::new();
for i in 0..10 {
let cell = if i < filled { "▰" } else { "▱" };
out.push_str(&if i < filled { rgb(mix(PURPLE, GOLD, i as f32 / 9.0), "33", cell) } else { rgb((70, 66, 88), "2", cell) });
}
format!("{out} {}", rgb(GOLD, "1;33", &format!("{:.0}%", sim * 100.0)))
}
/// `line` with the words of `query` (3+ letters) picked out in bold gold, case-insensitively.
pub fn highlight(line: &str, query: &str) -> String {
// Common words would light up half the passage: only the query's meaningful words.
const STOP: [&str; 40] = [
"the", "and", "for", "are", "was", "how", "what", "when", "where", "why", "who", "with", "from", "into", "about",
"does", "did", "get", "got", "can", "should", "would", "this", "that", "these", "there", "their", "them", "they",
"you", "your", "our", "has", "have", "had", "not", "all", "any", "out", "use",
];
let words: Vec<String> = query
.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.chars().count() >= 3)
.map(|w| w.to_lowercase())
.filter(|w| !STOP.contains(&w.as_str()))
.collect();
if !on() || words.is_empty() {
return line.to_string();
}
let mut out = String::new();
let mut word = String::new();
let flush = |word: &mut String, out: &mut String| {
if word.is_empty() {
return;
}
let lw = word.to_lowercase();
// A long word also matches without its last letter (`retries` finds `retried`); cut by
// characters, never bytes, so a query like `résumé` can't split a character.
let stem = |q: &str| q.chars().count() >= 5 && lw.starts_with(&q.chars().take(q.chars().count() - 1).collect::<String>());
if words.iter().any(|q| lw.starts_with(q.as_str()) || stem(q)) {
out.push_str(&rgb(GOLD, "1;33", word));
} else {
out.push_str(&soft(word));
}
word.clear();
};
for ch in line.chars() {
if ch.is_alphanumeric() {
word.push(ch);
} else {
flush(&mut word, &mut out);
out.push_str(&soft(&ch.to_string()));
}
}
flush(&mut word, &mut out);
out
}
/// One piece of a wrapped Markdown paragraph for the rich view: **bold** in bold, `code` in lilac,
/// the rest in the soft body colour. `state` (inside `code`, inside **bold**) carries over from the
/// previous piece, so a span split by wrapping keeps its style.
fn md_inline_from(line: &str, state: &mut (bool, bool)) -> String {
if !on() {
return line.to_string();
}
let mut out = String::new();
let (mut code, mut strong) = *state;
let mut buf = String::new();
let flush = |buf: &mut String, out: &mut String, code: bool, strong: bool| {
if buf.is_empty() {
return;
}
let t = std::mem::take(buf);
out.push_str(&if code { rgb((190, 178, 255), "35", &t) } else if strong { bold(&t) } else { soft(&t) });
};
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
if c == '`' {
flush(&mut buf, &mut out, code, strong);
code = !code;
} else if !code && c == '*' && chars.peek() == Some(&'*') {
chars.next();
flush(&mut buf, &mut out, code, strong);
strong = !strong;
} else {
buf.push(c);
}
}
flush(&mut buf, &mut out, code, strong);
*state = (code, strong);
out
}
/// An entry's Markdown body for the rich view: headings with a gold bar, bullets as purple dots,
/// paragraphs wrapped to the terminal, inside a left margin of `indent` spaces.
pub fn md_body(body: &str, indent: usize) -> String {
let width = width().saturating_sub(2);
let pad = " ".repeat(indent);
let mut out = String::new();
let mut in_code = false;
// A blank line in the source becomes one blank line (not after a heading, never two in a row).
let mut gap = false;
let mut after_heading = false;
for line in body.trim().lines() {
if !in_code && line.trim().is_empty() {
gap = true;
continue;
}
if gap && !after_heading && !out.is_empty() && !out.ends_with("\n\n") && !line.starts_with("## ") {
out.push('\n');
}
gap = false;
if line.trim_start().starts_with("```") {
in_code = !in_code;
continue;
}
if in_code {
out.push_str(&format!("{pad} {}\n", rgb((190, 178, 255), "35", line)));
continue;
}
if let Some(h) = line.strip_prefix("## ") {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&format!("{} {}\n", " ".repeat(indent.saturating_sub(2)), heading(h)));
after_heading = true;
continue;
}
after_heading = false;
let trimmed = line.trim_start();
let lead = line.len() - trimmed.len();
let (bullet, text) = match trimmed.strip_prefix("- ").or_else(|| trimmed.strip_prefix("* ")) {
Some(rest) => (Some(rgb(PURPLE, "35", "•")), rest),
None => (None, trimmed),
};
let first = indent + lead + if bullet.is_some() { 2 } else { 0 };
let wrapped = wrap(text, first, first, width);
let mut state = (false, false);
for (i, l) in wrapped.lines().enumerate() {
let l = l.trim_start();
let mark = match (&bullet, i) {
(Some(b), 0) => format!("{b} "),
(Some(_), _) => " ".to_string(),
_ => String::new(),
};
out.push_str(&format!("{pad}{}{mark}{}\n", " ".repeat(lead), md_inline_from(l, &mut state)));
}
}
out
}
/// Draws a card in the rich view: `lines` down a rail fading purple → gold, then `foot` (muted).
pub fn card(lines: &[String], foot: &str) {
let n = lines.len() + 1;
for (j, l) in lines.iter().enumerate() {
let ch = if j == 0 { "╭" } else { "│" };
println!(" {} {l}", rail(ch, j as f32 / n as f32));
}
println!(" {} {}", rail("╰", 1.0), muted(foot));
}
/// A hint or empty-state line in the rich view: ` ◇ text`, wrapped and muted.
pub fn say(text: &str) -> String {
format!(" {} {}", rail("◇", 0.0), soft(&wrap(text, 3, 3, width())))
}
/// A closing status line in the rich view: ` ✓ text` (or ✗ when `ok` is false).
pub fn done(ok: bool, text: &str) -> String {
let mark = if ok { mark_ok() } else { mark_bad() };
format!(" {mark} {}", soft(&wrap(text, 3, 3, width())))
}
/// A project name in the rich view's cyan.
pub fn project(p: &str) -> String {
rgb((103, 232, 249), "36", p)
}
/// A one-line outcome: in the rich view ` ✓ text` (✗ when `ok` is false); otherwise `text` as is.
pub fn status(ok: bool, text: &str) -> String {
if rich() { done(ok, text) } else { text.to_string() }
}