grav-bar 26.9.1

Fast, zero-dependency, and themed status line for the Google Antigravity CLI. Compatible also with Claude code.
//! Draws a [`Status`] as one line, shrinking it step by step until it fits.

use crate::status::{Status, Usage};
use crate::theme::{BOLD, RESET, Theme, fg};

const GAUGE_WIDTH: u32 = 5;
const MAX_LEVEL: u8 = 5;

/// Builds the line at compaction level 0 and steps up until it fits `cols`.
pub fn render(st: &Status, th: &Theme) -> String {
    let mut level = 0;
    let mut line = build(st, th, level);
    if st.cols > 0 {
        while visible_len(&line) > st.cols && level < MAX_LEVEL {
            level += 1;
            line = build(st, th, level);
        }
    }
    line
}

/// Builds the line at a given compaction level. Each level drops or shortens
/// one more element:
///   0: everything
///   1: model without its parenthesised suffix ("Opus 5 (1M context)" -> "Opus 5")
///   2: directory reduced to its basename
///   3: reset timers dropped
///   4: username dropped
///   5: git branch dropped
pub fn build(st: &Status, th: &Theme, level: u8) -> String {
    let mut model = st.model.clone();
    if level >= 1
        && let Some(i) = model.find(" (")
    {
        model.truncate(i);
    }

    let mut dir = display_dir(&st.cwd, &st.home);
    if level >= 2 {
        dir = dir.rsplit('/').next().unwrap_or(&dir).to_string();
    }

    let show_reset = level < 3;
    let user = if level >= 4 { "" } else { st.username.as_str() };
    let branch = if level >= 5 {
        None
    } else {
        st.branch.as_deref().map(shorten_branch)
    };

    let dim = fg(th.dim);
    let sep = format!("{dim} · {RESET}");

    let mut line = String::new();
    if !user.is_empty() {
        line.push_str(&format!("{}{user} {RESET}", fg(th.user)));
    }
    line.push_str(&format!("{}{dir}{}", fg(th.path), fg(th.branch)));
    if let Some(b) = branch {
        line.push_str(&format!(" ( {b} )"));
    }
    line.push_str(RESET);

    if !model.is_empty() {
        line.push_str(&format!("{sep}{BOLD}{}{model}{RESET}", fg(th.model)));
    }

    if let Some(p) = st.ctx_pct {
        line.push_str(&format!("{sep}{dim}ctx {}{p}%{RESET}", th.grad_color(p)));
    }

    for (label, usage) in [("5h", st.five), ("wk", st.week)] {
        let Some(Usage { pct, reset_secs }) = usage else {
            continue;
        };
        line.push_str(&format!(
            "{sep}{dim}{label} {RESET}{} {}{pct}%{RESET}",
            gauge(th, pct),
            th.grad_color(pct)
        ));
        if show_reset && let Some(secs) = reset_secs {
            line.push_str(&format!(" {dim}({}){RESET}", format_time_left(secs)));
        }
    }

    line
}

/// `$HOME` abbreviated to `~`, like zsh's `%~`.
fn display_dir(cwd: &str, home: &str) -> String {
    if !home.is_empty() && cwd.starts_with(home) {
        cwd.replacen(home, "~", 1)
    } else {
        cwd.to_string()
    }
}

/// Branches longer than 15 chars are shortened to their first two
/// hyphen-separated segments.
fn shorten_branch(b: &str) -> String {
    if b.chars().count() > 15 {
        let mut parts = b.split('-');
        if let (Some(a), Some(c)) = (parts.next(), parts.next())
            && !c.is_empty()
        {
            return format!("{a}-{c}");
        }
    }
    b.to_string()
}

/// 5-cell gauge; each filled cell is colored by its own position on the
/// gradient, so a full bar runs the whole gradient left to right.
fn gauge(th: &Theme, pct: u32) -> String {
    let filled = ((pct.min(100) * GAUGE_WIDTH + 50) / 100).min(GAUGE_WIDTH);
    let mut out = String::new();
    for i in 0..GAUGE_WIDTH {
        if i < filled {
            out.push_str(&th.grad_color((i * 2 + 1) * 100 / (GAUGE_WIDTH * 2)));
            out.push('');
        } else {
            out.push_str(&fg(th.dim));
            out.push('');
        }
    }
    out.push_str(RESET);
    out
}

/// Compact time-until, e.g. `3d5h`, `2h13m`, `47m`.
pub fn format_time_left(secs: u64) -> String {
    let h = secs / 3600;
    let m = (secs % 3600) / 60;
    if h >= 24 {
        format!("{}d{}h", h / 24, h % 24)
    } else if h > 0 {
        format!("{h}h{m:02}m")
    } else {
        format!("{m}m")
    }
}

/// Number of terminal cells a string occupies, ignoring SGR escape sequences.
pub fn visible_len(s: &str) -> usize {
    let mut len = 0;
    let mut in_ansi = false;
    for c in s.chars() {
        if c == '\x1b' {
            in_ansi = true;
        } else if in_ansi {
            if c.is_ascii_alphabetic() {
                in_ansi = false;
            }
        } else {
            len += 1;
        }
    }
    len
}

#[cfg(test)]
mod tests {
    use super::*;

    fn strip(s: &str) -> String {
        let mut out = String::new();
        let mut in_ansi = false;
        for c in s.chars() {
            if c == '\x1b' {
                in_ansi = true;
            } else if in_ansi {
                if c.is_ascii_alphabetic() {
                    in_ansi = false;
                }
            } else {
                out.push(c);
            }
        }
        out
    }

    fn sample(cols: usize) -> Status {
        Status {
            username: "ash".into(),
            home: "/Users/ash".into(),
            cwd: "/Users/ash/repos/grav-bar".into(),
            branch: Some("feature-long-branch-name".into()),
            model: "Fable 5.1 (1M context)".into(),
            ctx_pct: Some(4),
            five: Some(Usage {
                pct: 37,
                reset_secs: Some(8100),
            }),
            week: Some(Usage {
                pct: 62,
                reset_secs: Some(200_000),
            }),
            cols,
        }
    }

    #[test]
    fn full_line_matches_bash_layout() {
        let line = render(&sample(0), Theme::default_theme());
        assert_eq!(
            strip(&line),
            "ash ~/repos/grav-bar ( feature-long ) · Fable 5.1 (1M context) · ctx 4% · 5h ██░░░ 37% (2h15m) · wk ███░░ 62% (2d7h)"
        );
    }

    #[test]
    fn compaction_levels_shrink_monotonically() {
        let st = sample(0);
        let th = Theme::default_theme();
        let mut prev = usize::MAX;
        for level in 0..=MAX_LEVEL {
            let len = visible_len(&build(&st, th, level));
            assert!(len < prev, "level {level} did not shrink: {len} >= {prev}");
            prev = len;
        }
        assert_eq!(
            strip(&build(&st, th, MAX_LEVEL)),
            "grav-bar · Fable 5.1 · ctx 4% · 5h ██░░░ 37% · wk ███░░ 62%"
        );
    }

    #[test]
    fn render_stops_at_first_fitting_level() {
        let th = Theme::default_theme();
        let wide = render(&sample(200), th);
        assert_eq!(visible_len(&wide), visible_len(&build(&sample(0), th, 0)));
        let narrow = render(&sample(76), th);
        assert_eq!(visible_len(&narrow), 76);
        assert_eq!(
            strip(&narrow),
            "grav-bar ( feature-long ) · Fable 5.1 · ctx 4% · 5h ██░░░ 37% · wk ███░░ 62%"
        );
        let tiny = render(&sample(10), th);
        assert_eq!(strip(&tiny), strip(&build(&sample(0), th, MAX_LEVEL)));
    }

    #[test]
    fn missing_segments_are_omitted() {
        let mut st = sample(0);
        st.branch = None;
        st.ctx_pct = None;
        st.five = None;
        st.week = None;
        st.model = String::new();
        assert_eq!(
            strip(&render(&st, Theme::default_theme())),
            "ash ~/repos/grav-bar"
        );
    }

    #[test]
    fn gauge_fill_counts() {
        let th = Theme::default_theme();
        let cells = |pct| strip(&gauge(th, pct)).matches('').count();
        assert_eq!(cells(0), 0);
        assert_eq!(cells(9), 0);
        assert_eq!(cells(10), 1);
        assert_eq!(cells(50), 3);
        assert_eq!(cells(89), 4);
        assert_eq!(cells(90), 5);
        assert_eq!(cells(100), 5);
        assert_eq!(strip(&gauge(th, 100)).chars().count(), 5);
    }

    #[test]
    fn time_left_formats() {
        assert_eq!(format_time_left(47 * 60), "47m");
        assert_eq!(format_time_left(2 * 3600 + 5 * 60), "2h05m");
        assert_eq!(format_time_left(8100), "2h15m");
        assert_eq!(format_time_left(200_000), "2d7h");
        assert_eq!(format_time_left(24 * 3600), "1d0h");
        assert_eq!(format_time_left(30), "0m");
    }

    #[test]
    fn branch_shortening() {
        assert_eq!(shorten_branch("main"), "main");
        assert_eq!(shorten_branch("feature-long-branch-name"), "feature-long");
        assert_eq!(
            shorten_branch("averyveryverylongbranch"),
            "averyveryverylongbranch"
        );
        assert_eq!(shorten_branch("exactly-fifteen"), "exactly-fifteen");
    }

    #[test]
    fn visible_len_ignores_truecolor_sequences() {
        assert_eq!(visible_len("\x1b[38;2;1;2;3mab\x1b[0m·█"), 4);
    }
}