cmd-usage 0.7.0

Live Command Code (commandcode.ai) usage dashboard: credits, 5-hour and weekly windows, plan limits. Watch mode or one-shot.
Documentation
// ocuse's presentation: a one-shot dashboard, a status line, and JSON.
//
// Reuses cmduse-core's pure formatters (money/compact/pct/duration) so both
// tools read the same. Go's allowance is per model โ€” not per account โ€” so the
// header's window lines sum the caps of the models actually used this month and
// the table shows each model against its own limit; that distinction is the
// whole point of the Go plan.
use crate::ocuse::window::{ModelUsage, Report, Totals};
use crate::ocuse::zen;
use cmduse_core::{compact, duration, money, pct};

/// ANSI styles, the same palette cmduse uses; empty strings when colour is off.
pub struct Ink {
    pub bold: &'static str,
    pub dim: &'static str,
    pub reset: &'static str,
}

impl Ink {
    pub fn new(colour: bool) -> Self {
        if colour {
            Self {
                bold: "\x1b[1m",
                dim: "\x1b[2m",
                reset: "\x1b[0m",
            }
        } else {
            Self {
                bold: "",
                dim: "",
                reset: "",
            }
        }
    }
}

fn window_line(
    label: &str,
    used: &Totals,
    cap: Option<f64>,
    reset_ms: Option<i64>,
    now_ms: i64,
) -> String {
    let cap_text = match cap {
        Some(cap) if cap > 0.0 => format!(
            "{} / {} ({})",
            money(used.cost),
            money(cap),
            pct(used.cost, cap)
        ),
        _ => money(used.cost),
    };
    let reset = reset_ms
        .map(|at| {
            format!(
                " ยท resets {}",
                duration(((at - now_ms) / 1000).max(0) as u64)
            )
        })
        .unwrap_or_default();
    format!("  {label:<8} {cap_text}{reset}\n")
}

/// Sum of `limit * share` over the models used this month โ€” an aggregate ceiling
/// across the models in play, since Go grants limits per model.
fn aggregate_cap(report: &Report, share: f64) -> Option<f64> {
    let total: f64 = report
        .models
        .iter()
        .filter(|m| m.month.cost > 0.0)
        .filter_map(|m| m.limit)
        .map(|limit| limit * share)
        .sum();
    (total > 0.0).then_some(total)
}

fn model_row(model: &ModelUsage) -> String {
    let limit = model.limit.map(money).unwrap_or_else(|| "โ€”".into());
    let use_text = model
        .month_use()
        .map(|fraction| pct(fraction * 100.0, 100.0))
        .unwrap_or_else(|| "โ€”".into());
    format!(
        "{:<28} {:>7} {:>10} {:>7} {:>8} {:>8} {:>7}\n",
        model.id,
        limit,
        money(model.month.cost),
        use_text,
        money(model.five_hour.cost),
        money(model.weekly.cost),
        compact(model.month.requests),
    )
}

/// `limit` rows to show; `usize::MAX` for all.
pub fn render_text(report: &Report, db_path: &str, rows: usize, colour: bool) -> String {
    let ink = Ink::new(colour);
    let shares = &zen::catalog().go.window_share;
    let mut out = String::new();
    out.push_str(&format!(
        "OpenCode usage โ€” go + zen, from {}\n",
        shorten(db_path)
    ));
    out.push_str(&format!(
        "period: {} ยท {} requests ยท {} ยท {} tokens\n",
        report.period_label,
        compact(report.month.requests),
        money(report.month.cost),
        compact(report.month.tokens()),
    ));

    if report.month.requests > 0 {
        let five_reset = report.now_ms + crate::ocuse::window::FIVE_HOUR_SECS * 1000;
        let week_reset = report.now_ms + crate::ocuse::window::WEEKLY_SECS * 1000;
        out.push_str(&format!(
            "\n{}GO{} (limits are per model: 5h = 20%, weekly = 50%, monthly = 100%)\n",
            ink.bold, ink.reset
        ));
        out.push_str(&window_line(
            "5-hour",
            &report.five_hour,
            aggregate_cap(report, shares.five_hour),
            Some(five_reset),
            report.now_ms,
        ));
        out.push_str(&window_line(
            "weekly",
            &report.weekly,
            aggregate_cap(report, shares.weekly),
            Some(week_reset),
            report.now_ms,
        ));
        out.push_str(&window_line(
            "monthly",
            &report.month,
            aggregate_cap(report, shares.monthly),
            None,
            report.now_ms,
        ));
    }

    out.push_str(&format!(
        "\n{}MODEL                            LIMIT      SPENT     USE       5H       WK     REQ{}\n",
        ink.bold, ink.reset
    ));
    for model in report.models.iter().take(rows) {
        out.push_str(&model_row(model));
    }
    if report.models.len() > rows {
        out.push_str(&format!("โ€ฆ {} more\n", report.models.len() - rows));
    }
    out.push_str(
        "\nZEN is pay-as-you-go: the same rows carry its spend, but the docs give no limit.\n",
    );
    out
}

/// Compact single line for a status bar.
pub fn status_line(report: &Report) -> String {
    match report.models.first() {
        Some(top) => format!(
            "oc {} month ยท {} 5h ยท top {} {}",
            money(report.month.cost),
            money(report.five_hour.cost),
            top.id,
            money(top.month.cost)
        ),
        None => format!(
            "oc {} month ยท {} 5h",
            money(report.month.cost),
            money(report.five_hour.cost)
        ),
    }
}

/// Machine-readable projection: totals plus the per-model breakdown.
pub fn render_json(report: &Report) -> String {
    let totals = |t: &Totals| serde_json::json!({ "cost": t.cost, "requests": t.requests, "tokens": t.tokens() });
    let model = |m: &ModelUsage| {
        serde_json::json!({
            "id": m.id,
            "provider": m.provider,
            "limit": m.limit,
            "month": totals(&m.month),
            "fiveHour": totals(&m.five_hour),
            "weekly": totals(&m.weekly),
        })
    };
    serde_json::to_string_pretty(&serde_json::json!({
        "source": "opencode.db",
        "generatedAt": report.now_ms,
        "periodStart": report.since_ms,
        "periodInferred": report.month_inferred,
        "totals": {
            "month": totals(&report.month),
            "fiveHour": totals(&report.five_hour),
            "weekly": totals(&report.weekly),
        },
        "models": report.models.iter().map(model).collect::<Vec<_>>(),
    }))
    .expect("json")
}

fn shorten(path: &str) -> String {
    match std::env::var("HOME") {
        Ok(home) if !home.is_empty() => path.replacen(&home, "~", 1),
        _ => path.to_string(),
    }
}

// ---- catalogues and history tables (shared with the MCP server) -------------

/// The catalogue: what each product costs and (for Go) what it allows.
pub fn plans_text() -> String {
    let catalog = crate::ocuse::zen::catalog();
    let mut out = format!(
        "OpenCode plans (docs, extracted {})\n\nGO โ€” ${:.2}/month ยท windows: 5h {:.0}% ยท weekly {:.0}% ยท monthly {:.0}%\n",
        catalog.extracted_at,
        catalog.go.price_usd,
        catalog.go.window_share.five_hour * 100.0,
        catalog.go.window_share.weekly * 100.0,
        catalog.go.window_share.monthly * 100.0,
    );
    out.push_str("MODEL                            LIMIT    IN     OUT    CACHE\n");
    for model in &catalog.go.models {
        let rate = model.variants.first();
        out.push_str(&format!(
            "{:<28} {:>7} {:>6} {:>6} {:>8}\n",
            model.id,
            model
                .monthly_limit
                .map(cmduse_core::money)
                .unwrap_or_else(|| "โ€”".into()),
            rate.map(|r| format!("${}", r.input)).unwrap_or_default(),
            rate.map(|r| format!("${}", r.output)).unwrap_or_default(),
            rate.map(|r| format!("${}", r.cache_read))
                .unwrap_or_default(),
        ));
    }
    out.push_str(&format!(
        "\nZEN โ€” pay-as-you-go, {} models priced per 1M tokens\n",
        catalog.zen.models.len()
    ));
    out
}

pub fn model_text(report: &Report, id: &str) -> String {
    match report.models.iter().find(|m| m.id == id) {
        Some(model) => format!(
            "{} ({})\n  limit     {}\n  month     {} ยท {} requests\n  5-hour    {}\n  weekly    {}\n",
            model.id,
            model.provider,
            model.limit.map(cmduse_core::money).unwrap_or_else(|| "โ€”".into()),
            cmduse_core::money(model.month.cost),
            model.month.requests,
            cmduse_core::money(model.five_hour.cost),
            cmduse_core::money(model.weekly.cost),
        ),
        None => format!("{id}: no usage in the period\n"),
    }
}

/// Spend per bucket (day or hour), oldest first โ€” `cmduse daily`/`hourly`.
pub fn bucket_text(
    rows: &[crate::ocuse::db::Row],
    now_ms: i64,
    days: i64,
    size_ms: i64,
    label: &str,
    tz_secs: i64,
) -> String {
    // Buckets are cut in the display timezone: shift, bucket, then label.
    let shift = tz_secs * 1000;
    let mut buckets: std::collections::BTreeMap<i64, (f64, u64, u64)> = Default::default();
    for row in rows {
        let bucket = (row.at_ms + shift).div_euclid(size_ms);
        let entry = buckets.entry(bucket).or_insert((0.0, 0, 0));
        entry.0 += row.cost_usd;
        entry.1 += 1;
        entry.2 += row.input + row.output + row.cache_read;
    }
    let start = (now_ms + shift - days * 86_400_000).div_euclid(size_ms);
    let mut out = format!("{label:<12} spend     requests   tokens\n");
    for (bucket, (cost, requests, tokens)) in buckets.range(start..) {
        let stamp = if size_ms >= 86_400_000 {
            date_of(*bucket)
        } else {
            format!(
                "{} {:02}h",
                date_of(bucket.div_euclid(24)),
                bucket.rem_euclid(24)
            )
        };
        out.push_str(&format!(
            "{stamp:<12} {:>9} {:>12} {:>9}\n",
            cmduse_core::money(*cost),
            cmduse_core::compact(*requests),
            cmduse_core::compact(*tokens),
        ));
    }
    if out.lines().count() == 1 {
        out.push_str("(no OpenCode usage in the window)\n");
    }
    out
}

/// Spend per session, newest first.
pub fn session_text(rows: &[crate::ocuse::db::Row], limit: usize) -> String {
    let mut by_session: std::collections::BTreeMap<
        &str,
        (f64, u64, i64, std::collections::BTreeSet<&str>),
    > = Default::default();
    for row in rows {
        let entry =
            by_session
                .entry(row.session.as_str())
                .or_insert((0.0, 0, 0, Default::default()));
        entry.0 += row.cost_usd;
        entry.1 += 1;
        entry.2 = entry.2.max(row.at_ms);
        entry.3.insert(row.model.as_str());
    }
    let mut sessions: Vec<_> = by_session.into_iter().collect();
    sessions.sort_by_key(|(_, (_, _, last, _))| std::cmp::Reverse(*last));
    let mut out =
        String::from("session                          last        models   spend     req\n");
    for (id, (cost, requests, last, models)) in sessions.into_iter().take(limit) {
        let short = id.rsplit('_').next().unwrap_or(id);
        out.push_str(&format!(
            "{:<32} {:<11} {:>6} {:>9} {:>6}\n",
            short,
            date_of(last.div_euclid(86_400_000)),
            models.len(),
            cmduse_core::money(cost),
            requests,
        ));
    }
    if out.lines().count() == 1 {
        out.push_str("(no sessions in the window)\n");
    }
    out
}

/// `YYYY-MM-DD` for a Unix day number (civil-from-days, same helper family as
/// the window module's).
pub fn date_of(day: i64) -> String {
    let z = day + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z.rem_euclid(146_097);
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let year = if m <= 2 { y + 1 } else { y };
    format!("{year:04}-{m:02}-{d:02}")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ocuse::db::Row;

    fn report() -> Report {
        let now = 1_800_000_000_000;
        let rows = vec![Row {
            at_ms: now - 60_000,
            provider: "opencode-go".into(),
            model: "glm-5.3-flash".into(),
            cost_usd: 30.0,
            ..Default::default()
        }];
        crate::ocuse::window::build(&rows, &zen::catalog(), now, None)
    }

    #[test]
    fn text_shows_the_model_against_its_limit() {
        let text = render_text(&report(), "/tmp/opencode.db", 10, false);
        assert!(text.contains("glm-5.3-flash"), "{text}");
        assert!(text.contains("$60"), "the model's monthly limit: {text}");
        assert!(text.contains("50%"), "30 of 60 is half: {text}");
    }

    #[test]
    fn json_and_status_line_carry_the_totals() {
        let json = render_json(&report());
        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed["totals"]["month"]["cost"], 30.0);
        assert_eq!(parsed["models"][0]["id"], "glm-5.3-flash");
        assert!(status_line(&report()).contains("glm-5.3-flash"));
    }
}