Skip to main content

cmd_usage/ocuse/
render.rs

1// ocuse's presentation: a watch frame, a one-shot dashboard, a status line, and JSON.
2//
3// Reuses cmduse-core's pure formatters (money/compact/pct/duration) and cmduse's
4// own palette, gauge and sparkline, so both tools read the same. Go's allowance
5// is per model — not per account — so the header's window lines sum the caps of
6// the models actually used this month and the table shows each model against its
7// own limit; that distinction is the whole point of the Go plan.
8use crate::ocuse::window::{ModelUsage, Report, Totals};
9use crate::ocuse::zen;
10use cmduse_core::{compact, duration, money, pct};
11
12/// Gauge width for the window bars. cmduse makes this configurable; ocuse keeps
13/// one width so the frame does not reflow between refreshes.
14const BAR_WIDTH: usize = 20;
15
16/// ANSI styles — cmduse's palette, or empty strings when colour is off, so a
17/// piped run never leaks SGR escapes.
18pub struct Ink {
19    pub colour: bool,
20    pub bold: &'static str,
21    pub dim: &'static str,
22    pub reset: &'static str,
23    pub cyan: &'static str,
24    pub green: &'static str,
25    pub yellow: &'static str,
26    pub red: &'static str,
27}
28
29impl Ink {
30    pub fn new(colour: bool) -> Self {
31        if colour {
32            Self {
33                colour,
34                bold: crate::render::BOLD,
35                dim: crate::render::DIM,
36                reset: crate::render::RESET,
37                cyan: crate::render::CYAN,
38                green: crate::render::GREEN,
39                yellow: crate::render::YELLOW,
40                red: crate::render::RED,
41            }
42        } else {
43            Self {
44                colour,
45                bold: "",
46                dim: "",
47                reset: "",
48                cyan: "",
49                green: "",
50                yellow: "",
51                red: "",
52            }
53        }
54    }
55
56    /// Colour a value without disturbing the caller's column padding: pad the
57    /// text first, then wrap it.
58    fn cyan_on(&self, text: String) -> String {
59        format!("{}{text}{}", self.cyan, self.reset)
60    }
61
62    /// Secondary information — cmduse's `DIM`.
63    fn dim_on(&self, text: String) -> String {
64        format!("{}{text}{}", self.dim, self.reset)
65    }
66
67    fn bold_on(&self, text: String) -> String {
68        format!("{}{text}{}", self.bold, self.reset)
69    }
70
71    /// Severity for a used/limit percentage — cmduse's thresholds.
72    fn for_pct(&self, pct_used: f64) -> &'static str {
73        if !self.colour {
74            ""
75        } else if pct_used >= 90.0 {
76            self.red
77        } else if pct_used >= 70.0 {
78            self.yellow
79        } else {
80            self.green
81        }
82    }
83
84    /// Pct + gauge, exactly cmduse's `render::bar`; empty when colour is off so
85    /// the plain frame keeps its script-friendly column layout.
86    fn bar(&self, used: f64, cap: f64) -> String {
87        if self.colour {
88            crate::render::bar(used, cap, BAR_WIDTH)
89        } else {
90            String::new()
91        }
92    }
93}
94
95fn window_line(
96    ink: &Ink,
97    label: &str,
98    used: &Totals,
99    cap: Option<f64>,
100    reset_ms: Option<i64>,
101    now_ms: i64,
102) -> String {
103    let reset = reset_ms
104        .map(|at| duration(((at - now_ms) / 1000).max(0) as u64))
105        .unwrap_or_default();
106    let reset_text = if reset.is_empty() {
107        String::new()
108    } else if ink.colour {
109        format!(" · resets in {reset}")
110    } else {
111        format!(" · resets {reset}")
112    };
113    match cap {
114        Some(cap) if cap > 0.0 => {
115            // Same placement cmduse uses for the flag: on the window it belongs to.
116            let flag = if used.cost > cap {
117                format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
118            } else {
119                String::new()
120            };
121            if ink.colour {
122                format!(
123                    " {}{label:<8}{} {} {}{} / {}{}{}{}\n",
124                    ink.bold,
125                    ink.reset,
126                    ink.bar(used.cost, cap),
127                    ink.dim,
128                    money(used.cost),
129                    money(cap),
130                    reset_text,
131                    flag,
132                    ink.reset,
133                )
134            } else {
135                // The plain frame is the script-facing one: keep the old text.
136                format!(
137                    "  {label:<8} {} / {} ({}){reset_text}{flag}\n",
138                    money(used.cost),
139                    money(cap),
140                    pct(used.cost, cap),
141                )
142            }
143        }
144        // No cap to measure against (free models, Zen-only rows): secondary, so
145        // dim it the way cmduse dims its pay-as-you-go line.
146        _ => format!(
147            "  {label:<8} {}\n",
148            ink.dim_on(format!("{}{}", money(used.cost), reset_text))
149        ),
150    }
151}
152
153/// Sum of `limit * share` over the models used this month — an aggregate ceiling
154/// across the models in play, since Go grants limits per model.
155fn aggregate_cap(report: &Report, share: f64) -> Option<f64> {
156    let total: f64 = report
157        .models
158        .iter()
159        .filter(|m| m.month.cost > 0.0)
160        .filter_map(|m| m.limit)
161        .map(|limit| limit * share)
162        .sum();
163    (total > 0.0).then_some(total)
164}
165
166/// Is any of this model's windows over its share of the limit? Go meters per
167/// model *and* per window, so being over the 5-hour cap counts like being over
168/// the month.
169fn exceeded(model: &ModelUsage) -> bool {
170    let Some(limit) = model.limit.filter(|limit| *limit > 0.0) else {
171        return false;
172    };
173    model.month.cost > limit
174        || model.five_hour.cost > limit * 0.20
175        || model.weekly.cost > limit * 0.50
176}
177
178fn model_row(ink: &Ink, model: &ModelUsage, total_cost: f64) -> String {
179    let limit = match model.limit {
180        Some(limit) => money(limit),
181        None => ink.dim_on("—".into()),
182    };
183    let use_fraction = model.month_use();
184    let use_text = use_fraction
185        .map(|fraction| pct(fraction * 100.0, 100.0))
186        .unwrap_or_else(|| "—".into());
187    // Pad before colouring: SGR escapes would otherwise count as columns.
188    let spent = ink.cyan_on(format!("{:>10}", money(model.month.cost)));
189    let use_cell = match use_fraction {
190        Some(fraction) => {
191            let tone = ink.for_pct(fraction * 100.0);
192            if tone.is_empty() {
193                format!("{use_text:>7}")
194            } else {
195                format!("{tone}{use_text:>7}{}", ink.reset)
196            }
197        }
198        None => ink.dim_on(format!("{use_text:>7}")),
199    };
200    // Actionable before informational (cmduse's ordering): the flag sits beside
201    // the model so a clipped frame keeps it; the share is the first thing to go.
202    let flag = if exceeded(model) {
203        format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
204    } else {
205        String::new()
206    };
207    let share = if total_cost > 0.0 {
208        ink.dim_on(format!(" {:>5.1}%", model.month.cost / total_cost * 100.0))
209    } else {
210        String::new()
211    };
212    format!(
213        "{:<28}{flag} {:>7} {spent} {use_cell} {} {} {:>7}{share}\n",
214        model.id,
215        limit,
216        // Go windows are per model too: 5h = 20% of the limit, weekly = 50%.
217        window_cell(ink, model, model.five_hour.cost, 0.20),
218        window_cell(ink, model, model.weekly.cost, 0.50),
219        compact(model.month.requests),
220    )
221}
222
223/// One rolling-window column, coloured against that model's own share of the
224/// limit; models with no limit (free, or Zen-only) are dimmed instead.
225fn window_cell(ink: &Ink, model: &ModelUsage, cost: f64, share: f64) -> String {
226    let text = format!("{:>8}", money(cost));
227    match model.limit.filter(|limit| *limit > 0.0) {
228        Some(limit) => {
229            let tone = ink.for_pct(cost / (limit * share) * 100.0);
230            if tone.is_empty() {
231                text
232            } else {
233                format!("{tone}{text}{}", ink.reset)
234            }
235        }
236        None => ink.dim_on(text),
237    }
238}
239
240/// `limit` rows to show; `usize::MAX` for all.
241pub fn render_text(report: &Report, db_path: &str, rows: usize, colour: bool) -> String {
242    let ink = Ink::new(colour);
243    let c = |text: String| ink.cyan_on(text);
244    let shares = &zen::catalog().go.window_share;
245    let mut out = String::new();
246    out.push_str(&format!(
247        "{}OpenCode usage{} — go + zen, from {}{}{}\n",
248        ink.bold,
249        ink.reset,
250        ink.dim,
251        shorten(db_path),
252        ink.reset,
253    ));
254    out.push_str(&format!(
255        "period: {} · {} requests · {} · {} tokens\n",
256        report.period_label,
257        c(compact(report.month.requests)),
258        c(money(report.month.cost)),
259        c(compact(report.month.tokens())),
260    ));
261
262    if report.month.requests > 0 {
263        let five_reset = report.now_ms + crate::ocuse::window::FIVE_HOUR_SECS * 1000;
264        let week_reset = report.now_ms + crate::ocuse::window::WEEKLY_SECS * 1000;
265        out.push_str(&format!(
266            "\n{}GO{} {}\n",
267            ink.bold,
268            ink.reset,
269            ink.dim_on("(limits are per model: 5h = 20%, weekly = 50%, monthly = 100%)".into()),
270        ));
271        out.push_str(&window_line(
272            &ink,
273            "5-hour",
274            &report.five_hour,
275            aggregate_cap(report, shares.five_hour),
276            Some(five_reset),
277            report.now_ms,
278        ));
279        out.push_str(&window_line(
280            &ink,
281            "weekly",
282            &report.weekly,
283            aggregate_cap(report, shares.weekly),
284            Some(week_reset),
285            report.now_ms,
286        ));
287        out.push_str(&window_line(
288            &ink,
289            "monthly",
290            &report.month,
291            aggregate_cap(report, shares.monthly),
292            None,
293            report.now_ms,
294        ));
295    }
296
297    out.push_str(&format!(
298        "\n{}MODEL                            LIMIT      SPENT     USE       5H       WK     REQ{}\n",
299        ink.bold, ink.reset
300    ));
301    let total_cost = report.month.cost;
302    for model in report.models.iter().take(rows) {
303        out.push_str(&model_row(&ink, model, total_cost));
304    }
305    if report.models.len() > rows {
306        out.push_str(&format!(
307            "{}\n",
308            ink.dim_on(format!("… {} more", report.models.len() - rows))
309        ));
310    }
311    out.push_str(&format!(
312        "\n{}\n",
313        ink.dim_on(
314            "ZEN is pay-as-you-go: the same rows carry its spend, but the docs give no limit."
315                .into()
316        )
317    ));
318    out
319}
320
321/// One watch frame: the dashboard, then a burst sparkline when the 5-hour spend
322/// has moved since the previous refresh. No trailing newline — the caller
323/// appends its own status line and `redraw_frame` (cmduse's) parks the cursor
324/// there, which is what lets the countdown rewrite a single line per second.
325pub fn watch_frame(
326    report: &Report,
327    db_path: &str,
328    rows: usize,
329    colour: bool,
330    history: &[f64],
331    interval_secs: u64,
332) -> String {
333    let ink = Ink::new(colour);
334    let mut out = render_text(report, db_path, rows, colour);
335    if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
336        out.push_str(&format!(
337            "{}spend bursts ({}s samples){} {}\n",
338            ink.dim,
339            interval_secs,
340            ink.reset,
341            crate::render::sparkline(history),
342        ));
343    }
344    out.trim_end_matches('\n').to_string()
345}
346
347/// Compact single line for a status bar.
348pub fn status_line(report: &Report, colour: bool) -> String {
349    let ink = Ink::new(colour);
350    match report.models.first() {
351        Some(top) => format!(
352            "oc {} month · {} 5h · top {} {}",
353            ink.cyan_on(money(report.month.cost)),
354            ink.cyan_on(money(report.five_hour.cost)),
355            top.id,
356            ink.cyan_on(money(top.month.cost)),
357        ),
358        None => format!(
359            "oc {} month · {} 5h",
360            ink.cyan_on(money(report.month.cost)),
361            ink.cyan_on(money(report.five_hour.cost)),
362        ),
363    }
364}
365
366/// Machine-readable projection: totals plus the per-model breakdown.
367pub fn render_json(report: &Report) -> String {
368    let totals = |t: &Totals| serde_json::json!({ "cost": t.cost, "requests": t.requests, "tokens": t.tokens() });
369    let model = |m: &ModelUsage| {
370        serde_json::json!({
371            "id": m.id,
372            "provider": m.provider,
373            "limit": m.limit,
374            "month": totals(&m.month),
375            "fiveHour": totals(&m.five_hour),
376            "weekly": totals(&m.weekly),
377        })
378    };
379    serde_json::to_string_pretty(&serde_json::json!({
380        "source": "opencode.db",
381        "generatedAt": report.now_ms,
382        "periodStart": report.since_ms,
383        "periodInferred": report.month_inferred,
384        "totals": {
385            "month": totals(&report.month),
386            "fiveHour": totals(&report.five_hour),
387            "weekly": totals(&report.weekly),
388        },
389        "models": report.models.iter().map(model).collect::<Vec<_>>(),
390    }))
391    .expect("json")
392}
393
394fn shorten(path: &str) -> String {
395    match std::env::var("HOME") {
396        Ok(home) if !home.is_empty() => path.replacen(&home, "~", 1),
397        _ => path.to_string(),
398    }
399}
400
401// ---- catalogues and history tables (shared with the MCP server) -------------
402
403/// The catalogue: what each product costs and (for Go) what it allows.
404pub fn plans_text(colour: bool) -> String {
405    let ink = Ink::new(colour);
406    let catalog = crate::ocuse::zen::catalog();
407    let mut out = format!(
408        "{}OpenCode plans{} {}\n\n",
409        ink.bold,
410        ink.reset,
411        ink.dim_on(format!("(docs, extracted {})", catalog.extracted_at)),
412    );
413    out.push_str(&format!(
414        "{}GO{} — {}/month · windows: 5h {} · weekly {} · monthly {}\n",
415        ink.bold,
416        ink.reset,
417        ink.cyan_on(format!("${:.2}", catalog.go.price_usd)),
418        ink.cyan_on(format!("{:.1}%", catalog.go.window_share.five_hour * 100.0)),
419        ink.cyan_on(format!("{:.1}%", catalog.go.window_share.weekly * 100.0)),
420        ink.cyan_on(format!("{:.1}%", catalog.go.window_share.monthly * 100.0)),
421    ));
422    out.push_str(&format!(
423        "{}MODEL                            LIMIT    IN     OUT    CACHE{}\n",
424        ink.bold, ink.reset
425    ));
426    for model in &catalog.go.models {
427        let rate = model.variants.first();
428        let limit = model
429            .monthly_limit
430            .map(cmduse_core::money)
431            .unwrap_or_else(|| "—".into());
432        out.push_str(&format!(
433            "{:<28} {} {} {} {}\n",
434            model.id,
435            ink.cyan_on(format!("{limit:>7}")),
436            ink.cyan_on(format!(
437                "{:>6}",
438                rate.map(|r| format!("${}", r.input)).unwrap_or_default()
439            )),
440            ink.cyan_on(format!(
441                "{:>6}",
442                rate.map(|r| format!("${}", r.output)).unwrap_or_default()
443            )),
444            ink.cyan_on(format!(
445                "{:>8}",
446                rate.map(|r| format!("${}", r.cache_read))
447                    .unwrap_or_default()
448            )),
449        ));
450    }
451    out.push_str(&format!(
452        "\n{}\n",
453        ink.dim_on(format!(
454            "ZEN — pay-as-you-go, {} models priced per 1M tokens",
455            catalog.zen.models.len()
456        ))
457    ));
458    out
459}
460
461pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
462    let ink = Ink::new(colour);
463    let Some(model) = report.models.iter().find(|m| m.id == id) else {
464        return format!("{id}: no usage in the period\n");
465    };
466    let limit = model
467        .limit
468        .map(cmduse_core::money)
469        .unwrap_or_else(|| "—".into());
470    let row =
471        |label: &str, value: String| format!("  {}{value}\n", ink.dim_on(format!("{label:<10}")));
472    let mut out = format!(
473        "{} {}\n",
474        ink.bold_on(model.id.clone()),
475        ink.dim_on(format!("({})", model.provider)),
476    );
477    out.push_str(&row("limit", ink.cyan_on(limit)));
478    out.push_str(&row(
479        "month",
480        ink.cyan_on(format!(
481            "{} · {} requests",
482            cmduse_core::money(model.month.cost),
483            model.month.requests
484        )),
485    ));
486    out.push_str(&row(
487        "5-hour",
488        ink.cyan_on(cmduse_core::money(model.five_hour.cost)),
489    ));
490    out.push_str(&row(
491        "weekly",
492        ink.cyan_on(cmduse_core::money(model.weekly.cost)),
493    ));
494    // The dashboard's share column needs the period total; here it is the same
495    // fact spelled out. Absent when the period has no spend to divide by.
496    if report.month.cost > 0.0 {
497        out.push_str(&row(
498            "share",
499            ink.dim_on(format!(
500                "{:.1}% of period spend",
501                model.month.cost / report.month.cost * 100.0
502            )),
503        ));
504    }
505    out
506}
507
508/// Spend per bucket (day or hour), oldest first — `cmduse daily`/`hourly`.
509pub fn bucket_text(
510    rows: &[crate::ocuse::db::Row],
511    now_ms: i64,
512    days: i64,
513    size_ms: i64,
514    label: &str,
515    tz_secs: i64,
516    colour: bool,
517) -> String {
518    let ink = Ink::new(colour);
519    // Buckets are cut in the display timezone: shift, bucket, then label.
520    let shift = tz_secs * 1000;
521    let mut buckets: std::collections::BTreeMap<i64, (f64, u64, u64)> = Default::default();
522    for row in rows {
523        let bucket = (row.at_ms + shift).div_euclid(size_ms);
524        let entry = buckets.entry(bucket).or_insert((0.0, 0, 0));
525        entry.0 += row.cost_usd;
526        entry.1 += 1;
527        entry.2 += row.input + row.output + row.cache_read;
528    }
529    let start = (now_ms + shift - days * 86_400_000).div_euclid(size_ms);
530    let mut out = format!(
531        "{}{label:<12} spend     requests   tokens{}\n",
532        ink.bold, ink.reset
533    );
534    for (bucket, (cost, requests, tokens)) in buckets.range(start..) {
535        let stamp = if size_ms >= 86_400_000 {
536            date_of(*bucket)
537        } else {
538            format!(
539                "{} {:02}h",
540                date_of(bucket.div_euclid(24)),
541                bucket.rem_euclid(24)
542            )
543        };
544        out.push_str(&format!(
545            "{stamp:<12} {} {} {}\n",
546            ink.cyan_on(format!("{:>9}", cmduse_core::money(*cost))),
547            ink.cyan_on(format!("{:>12}", cmduse_core::compact(*requests))),
548            ink.cyan_on(format!("{:>9}", cmduse_core::compact(*tokens))),
549        ));
550    }
551    if out.lines().count() == 1 {
552        out.push_str(&format!(
553            "{}\n",
554            ink.dim_on("(no OpenCode usage in the window)".into())
555        ));
556    }
557    out
558}
559
560/// Spend per session, newest first.
561pub fn session_text(rows: &[crate::ocuse::db::Row], limit: usize, colour: bool) -> String {
562    let ink = Ink::new(colour);
563    let mut by_session: std::collections::BTreeMap<
564        &str,
565        (f64, u64, i64, std::collections::BTreeSet<&str>),
566    > = Default::default();
567    for row in rows {
568        let entry =
569            by_session
570                .entry(row.session.as_str())
571                .or_insert((0.0, 0, 0, Default::default()));
572        entry.0 += row.cost_usd;
573        entry.1 += 1;
574        entry.2 = entry.2.max(row.at_ms);
575        entry.3.insert(row.model.as_str());
576    }
577    let mut sessions: Vec<_> = by_session.into_iter().collect();
578    sessions.sort_by_key(|(_, (_, _, last, _))| std::cmp::Reverse(*last));
579    let mut out = format!(
580        "{}session                          last        models   spend     req{}\n",
581        ink.bold, ink.reset
582    );
583    for (id, (cost, requests, last, models)) in sessions.into_iter().take(limit) {
584        let short = id.rsplit('_').next().unwrap_or(id);
585        out.push_str(&format!(
586            "{:<32} {:<11} {:>6} {} {:>6}\n",
587            short,
588            ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
589            models.len(),
590            ink.cyan_on(format!("{:>9}", cmduse_core::money(cost))),
591            requests,
592        ));
593    }
594    if out.lines().count() == 1 {
595        out.push_str(&format!(
596            "{}\n",
597            ink.dim_on("(no sessions in the window)".into())
598        ));
599    }
600    out
601}
602
603/// `YYYY-MM-DD` for a Unix day number (civil-from-days, same helper family as
604/// the window module's).
605pub fn date_of(day: i64) -> String {
606    let z = day + 719_468;
607    let era = z.div_euclid(146_097);
608    let doe = z.rem_euclid(146_097);
609    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
610    let y = yoe + era * 400;
611    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
612    let mp = (5 * doy + 2) / 153;
613    let d = doy - (153 * mp + 2) / 5 + 1;
614    let m = if mp < 10 { mp + 3 } else { mp - 9 };
615    let year = if m <= 2 { y + 1 } else { y };
616    format!("{year:04}-{m:02}-{d:02}")
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use crate::ocuse::db::Row;
623
624    fn report() -> Report {
625        let now = 1_800_000_000_000;
626        let rows = vec![Row {
627            at_ms: now - 60_000,
628            provider: "opencode-go".into(),
629            model: "glm-5.3-flash".into(),
630            cost_usd: 30.0,
631            ..Default::default()
632        }];
633        crate::ocuse::window::build(&rows, &zen::catalog(), now, None)
634    }
635
636    #[test]
637    fn text_shows_the_model_against_its_limit() {
638        let text = render_text(&report(), "/tmp/opencode.db", 10, false);
639        assert!(text.contains("glm-5.3-flash"), "{text}");
640        assert!(text.contains("$60"), "the model's monthly limit: {text}");
641        assert!(text.contains("50%"), "30 of 60 is half: {text}");
642    }
643
644    #[test]
645    fn json_and_status_line_carry_the_totals() {
646        let json = render_json(&report());
647        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
648        assert_eq!(parsed["totals"]["month"]["cost"], 30.0);
649        assert_eq!(parsed["models"][0]["id"], "glm-5.3-flash");
650        assert!(status_line(&report(), false).contains("glm-5.3-flash"));
651    }
652
653    /// Every renderer, both ways round: piped/non-tty output must stay free of
654    /// SGR escapes, and every one of them must colour when colour is on. This is
655    /// the guard for the bug where only `render_text` ever got a colour flag, so
656    /// `daily` / `hourly` / `session` / `plans` / `model` / `statusline` printed
657    /// plain regardless of the terminal.
658    #[test]
659    fn every_renderer_respects_the_colour_flag() {
660        let report = report();
661        let rows = vec![Row {
662            at_ms: report.now_ms - 60_000,
663            session: "ses_fixture".into(),
664            provider: "opencode-go".into(),
665            model: "glm-5.3-flash".into(),
666            cost_usd: 30.0,
667            ..Default::default()
668        }];
669        let now = report.now_ms;
670
671        let cases: Vec<(&str, String, String)> = vec![
672            (
673                "render_text",
674                render_text(&report, "/tmp/oc.db", 10, false),
675                render_text(&report, "/tmp/oc.db", 10, true),
676            ),
677            (
678                "status_line",
679                status_line(&report, false),
680                status_line(&report, true),
681            ),
682            ("plans_text", plans_text(false), plans_text(true)),
683            (
684                "model_text",
685                model_text(&report, "glm-5.3-flash", false),
686                model_text(&report, "glm-5.3-flash", true),
687            ),
688            (
689                "bucket_text/day",
690                bucket_text(&rows, now, 7, 86_400_000, "day", 0, false),
691                bucket_text(&rows, now, 7, 86_400_000, "day", 0, true),
692            ),
693            (
694                "bucket_text/hour",
695                bucket_text(&rows, now, 1, 3_600_000, "hour", 0, false),
696                bucket_text(&rows, now, 1, 3_600_000, "hour", 0, true),
697            ),
698            (
699                "session_text",
700                session_text(&rows, 20, false),
701                session_text(&rows, 20, true),
702            ),
703        ];
704
705        for (name, plain, coloured) in cases {
706            assert!(
707                !plain.contains('\x1b'),
708                "{name} leaked escapes when off: {plain}"
709            );
710            assert!(
711                coloured.contains('\x1b'),
712                "{name} rendered nothing coloured when on: {coloured}"
713            );
714        }
715    }
716
717    #[test]
718    fn plain_frames_carry_no_escapes() {
719        let text = render_text(&report(), "/tmp/opencode.db", 10, false);
720        assert!(
721            !text.contains('\x1b'),
722            "piped output must stay plain: {text}"
723        );
724        // The gauge is colour-only; the plain frame keeps the old percentage text.
725        assert!(text.contains("50%"), "{text}");
726    }
727
728    #[test]
729    fn an_over_cap_window_is_flagged_in_both_modes() {
730        // 30 spent against a 5-hour cap of 20% of the $60 limit = $12.
731        let plain = render_text(&report(), "/tmp/opencode.db", 10, false);
732        assert!(plain.contains("LIMIT EXCEEDED"), "{plain}");
733        let coloured = render_text(&report(), "/tmp/opencode.db", 10, true);
734        assert!(coloured.contains("LIMIT EXCEEDED"), "{coloured}");
735        assert!(
736            coloured.contains(crate::render::RED),
737            "the flag and the gauge are red: {coloured}"
738        );
739    }
740
741    #[test]
742    fn a_model_over_any_of_its_own_windows_is_flagged() {
743        // The fixture is at 50% of the month but over the 5-hour share, and Go
744        // meters per model per window: the row must say so, not just glow red.
745        let plain = render_text(&report(), "/tmp/opencode.db", 10, false);
746        let row = plain
747            .lines()
748            .find(|line| line.starts_with("glm-5.3-flash"))
749            .expect("the model row");
750        assert!(row.contains("LIMIT EXCEEDED"), "{row}");
751        // Share last, flag early: the informational column is clipped first.
752        assert!(row.trim_end().ends_with('%'), "share ends the row: {row}");
753    }
754
755    #[test]
756    fn a_model_within_its_limits_is_not_flagged() {
757        let now = 1_800_000_000_000;
758        // $5 against a $60 limit: every window is comfortably inside its share.
759        let rows = vec![Row {
760            at_ms: now - 60_000,
761            provider: "opencode-go".into(),
762            model: "glm-5.3-flash".into(),
763            cost_usd: 5.0,
764            ..Default::default()
765        }];
766        let report = crate::ocuse::window::build(&rows, &zen::catalog(), now, None);
767        for colour in [false, true] {
768            let text = render_text(&report, "/tmp/opencode.db", 10, colour);
769            assert!(!text.contains("LIMIT EXCEEDED"), "colour={colour}: {text}");
770        }
771    }
772
773    #[test]
774    fn the_model_detail_spells_out_the_share() {
775        let detail = model_text(&report(), "glm-5.3-flash", false);
776        assert!(detail.contains("100.0% of period spend"), "{detail}");
777        // A period with nothing to divide by omits the row rather than printing NaN.
778        let empty = crate::ocuse::window::build(&[], &zen::catalog(), 1_800_000_000_000, None);
779        assert!(!model_text(&empty, "glm-5.3-flash", false).contains("share"));
780    }
781
782    #[test]
783    fn colour_frames_get_the_palette_and_a_gauge() {
784        let text = render_text(&report(), "/tmp/opencode.db", 10, true);
785        assert!(text.contains(crate::render::BOLD), "{text}");
786        assert!(text.contains(crate::render::CYAN), "{text}");
787        assert!(
788            text.contains(crate::render::GREEN),
789            "the 50% monthly gauge and USE cell are green: {text}"
790        );
791        assert!(
792            text.contains(crate::render::RED),
793            "the over-cap 5-hour gauge (30 of 12) is red: {text}"
794        );
795        assert!(
796            text.contains('━') && text.contains('╱'),
797            "gauge bar: {text}"
798        );
799        assert!(text.contains("resets in"), "{text}");
800    }
801
802    #[test]
803    fn watch_frame_ends_on_the_status_line_and_shows_bursts() {
804        let frame = watch_frame(
805            &report(),
806            "/tmp/opencode.db",
807            10,
808            true,
809            &[0.0, 1.5, 0.0],
810            10,
811        );
812        assert!(
813            !frame.ends_with('\n'),
814            "the caller's status line owns the last row"
815        );
816        assert!(frame.contains("spend bursts (10s samples)"), "{frame}");
817        assert!(
818            frame.contains('█'),
819            "a nonzero delta shows as a tall bar: {frame}"
820        );
821    }
822
823    #[test]
824    fn idle_history_omits_the_sparkline() {
825        let all_idle = watch_frame(&report(), "/tmp/opencode.db", 10, true, &[0.0, 0.0], 10);
826        assert!(!all_idle.contains("spend bursts"), "{all_idle}");
827        let first_frame = watch_frame(&report(), "/tmp/opencode.db", 10, true, &[], 10);
828        assert!(!first_frame.contains("spend bursts"), "{first_frame}");
829    }
830}