Mega Mind v0.5.4

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() }
}