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
166fn model_row(ink: &Ink, model: &ModelUsage) -> String {
167    let limit = match model.limit {
168        Some(limit) => money(limit),
169        None => ink.dim_on("—".into()),
170    };
171    let use_fraction = model.month_use();
172    let use_text = use_fraction
173        .map(|fraction| pct(fraction * 100.0, 100.0))
174        .unwrap_or_else(|| "—".into());
175    // Pad before colouring: SGR escapes would otherwise count as columns.
176    let spent = ink.cyan_on(format!("{:>10}", money(model.month.cost)));
177    let use_cell = match use_fraction {
178        Some(fraction) => {
179            let tone = ink.for_pct(fraction * 100.0);
180            if tone.is_empty() {
181                format!("{use_text:>7}")
182            } else {
183                format!("{tone}{use_text:>7}{}", ink.reset)
184            }
185        }
186        None => ink.dim_on(format!("{use_text:>7}")),
187    };
188    format!(
189        "{:<28} {:>7} {spent} {use_cell} {} {} {:>7}\n",
190        model.id,
191        limit,
192        // Go windows are per model too: 5h = 20% of the limit, weekly = 50%.
193        window_cell(ink, model, model.five_hour.cost, 0.20),
194        window_cell(ink, model, model.weekly.cost, 0.50),
195        compact(model.month.requests),
196    )
197}
198
199/// One rolling-window column, coloured against that model's own share of the
200/// limit; models with no limit (free, or Zen-only) are dimmed instead.
201fn window_cell(ink: &Ink, model: &ModelUsage, cost: f64, share: f64) -> String {
202    let text = format!("{:>8}", money(cost));
203    match model.limit.filter(|limit| *limit > 0.0) {
204        Some(limit) => {
205            let tone = ink.for_pct(cost / (limit * share) * 100.0);
206            if tone.is_empty() {
207                text
208            } else {
209                format!("{tone}{text}{}", ink.reset)
210            }
211        }
212        None => ink.dim_on(text),
213    }
214}
215
216/// `limit` rows to show; `usize::MAX` for all.
217pub fn render_text(report: &Report, db_path: &str, rows: usize, colour: bool) -> String {
218    let ink = Ink::new(colour);
219    let c = |text: String| ink.cyan_on(text);
220    let shares = &zen::catalog().go.window_share;
221    let mut out = String::new();
222    out.push_str(&format!(
223        "{}OpenCode usage{} — go + zen, from {}{}{}\n",
224        ink.bold,
225        ink.reset,
226        ink.dim,
227        shorten(db_path),
228        ink.reset,
229    ));
230    out.push_str(&format!(
231        "period: {} · {} requests · {} · {} tokens\n",
232        report.period_label,
233        c(compact(report.month.requests)),
234        c(money(report.month.cost)),
235        c(compact(report.month.tokens())),
236    ));
237
238    if report.month.requests > 0 {
239        let five_reset = report.now_ms + crate::ocuse::window::FIVE_HOUR_SECS * 1000;
240        let week_reset = report.now_ms + crate::ocuse::window::WEEKLY_SECS * 1000;
241        out.push_str(&format!(
242            "\n{}GO{} {}\n",
243            ink.bold,
244            ink.reset,
245            ink.dim_on("(limits are per model: 5h = 20%, weekly = 50%, monthly = 100%)".into()),
246        ));
247        out.push_str(&window_line(
248            &ink,
249            "5-hour",
250            &report.five_hour,
251            aggregate_cap(report, shares.five_hour),
252            Some(five_reset),
253            report.now_ms,
254        ));
255        out.push_str(&window_line(
256            &ink,
257            "weekly",
258            &report.weekly,
259            aggregate_cap(report, shares.weekly),
260            Some(week_reset),
261            report.now_ms,
262        ));
263        out.push_str(&window_line(
264            &ink,
265            "monthly",
266            &report.month,
267            aggregate_cap(report, shares.monthly),
268            None,
269            report.now_ms,
270        ));
271    }
272
273    out.push_str(&format!(
274        "\n{}MODEL                            LIMIT      SPENT     USE       5H       WK     REQ{}\n",
275        ink.bold, ink.reset
276    ));
277    for model in report.models.iter().take(rows) {
278        out.push_str(&model_row(&ink, model));
279    }
280    if report.models.len() > rows {
281        out.push_str(&format!(
282            "{}\n",
283            ink.dim_on(format!("… {} more", report.models.len() - rows))
284        ));
285    }
286    out.push_str(&format!(
287        "\n{}\n",
288        ink.dim_on(
289            "ZEN is pay-as-you-go: the same rows carry its spend, but the docs give no limit."
290                .into()
291        )
292    ));
293    out
294}
295
296/// One watch frame: the dashboard, then a burst sparkline when the 5-hour spend
297/// has moved since the previous refresh. No trailing newline — the caller
298/// appends its own status line and `redraw_frame` (cmduse's) parks the cursor
299/// there, which is what lets the countdown rewrite a single line per second.
300pub fn watch_frame(
301    report: &Report,
302    db_path: &str,
303    rows: usize,
304    colour: bool,
305    history: &[f64],
306    interval_secs: u64,
307) -> String {
308    let ink = Ink::new(colour);
309    let mut out = render_text(report, db_path, rows, colour);
310    if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
311        out.push_str(&format!(
312            "{}spend bursts ({}s samples){} {}\n",
313            ink.dim,
314            interval_secs,
315            ink.reset,
316            crate::render::sparkline(history),
317        ));
318    }
319    out.trim_end_matches('\n').to_string()
320}
321
322/// Compact single line for a status bar.
323pub fn status_line(report: &Report, colour: bool) -> String {
324    let ink = Ink::new(colour);
325    match report.models.first() {
326        Some(top) => format!(
327            "oc {} month · {} 5h · top {} {}",
328            ink.cyan_on(money(report.month.cost)),
329            ink.cyan_on(money(report.five_hour.cost)),
330            top.id,
331            ink.cyan_on(money(top.month.cost)),
332        ),
333        None => format!(
334            "oc {} month · {} 5h",
335            ink.cyan_on(money(report.month.cost)),
336            ink.cyan_on(money(report.five_hour.cost)),
337        ),
338    }
339}
340
341/// Machine-readable projection: totals plus the per-model breakdown.
342pub fn render_json(report: &Report) -> String {
343    let totals = |t: &Totals| serde_json::json!({ "cost": t.cost, "requests": t.requests, "tokens": t.tokens() });
344    let model = |m: &ModelUsage| {
345        serde_json::json!({
346            "id": m.id,
347            "provider": m.provider,
348            "limit": m.limit,
349            "month": totals(&m.month),
350            "fiveHour": totals(&m.five_hour),
351            "weekly": totals(&m.weekly),
352        })
353    };
354    serde_json::to_string_pretty(&serde_json::json!({
355        "source": "opencode.db",
356        "generatedAt": report.now_ms,
357        "periodStart": report.since_ms,
358        "periodInferred": report.month_inferred,
359        "totals": {
360            "month": totals(&report.month),
361            "fiveHour": totals(&report.five_hour),
362            "weekly": totals(&report.weekly),
363        },
364        "models": report.models.iter().map(model).collect::<Vec<_>>(),
365    }))
366    .expect("json")
367}
368
369fn shorten(path: &str) -> String {
370    match std::env::var("HOME") {
371        Ok(home) if !home.is_empty() => path.replacen(&home, "~", 1),
372        _ => path.to_string(),
373    }
374}
375
376// ---- catalogues and history tables (shared with the MCP server) -------------
377
378/// The catalogue: what each product costs and (for Go) what it allows.
379pub fn plans_text(colour: bool) -> String {
380    let ink = Ink::new(colour);
381    let catalog = crate::ocuse::zen::catalog();
382    let mut out = format!(
383        "{}OpenCode plans{} {}\n\n",
384        ink.bold,
385        ink.reset,
386        ink.dim_on(format!("(docs, extracted {})", catalog.extracted_at)),
387    );
388    out.push_str(&format!(
389        "{}GO{} — {}/month · windows: 5h {} · weekly {} · monthly {}\n",
390        ink.bold,
391        ink.reset,
392        ink.cyan_on(format!("${:.2}", catalog.go.price_usd)),
393        ink.cyan_on(format!("{:.0}%", catalog.go.window_share.five_hour * 100.0)),
394        ink.cyan_on(format!("{:.0}%", catalog.go.window_share.weekly * 100.0)),
395        ink.cyan_on(format!("{:.0}%", catalog.go.window_share.monthly * 100.0)),
396    ));
397    out.push_str(&format!(
398        "{}MODEL                            LIMIT    IN     OUT    CACHE{}\n",
399        ink.bold, ink.reset
400    ));
401    for model in &catalog.go.models {
402        let rate = model.variants.first();
403        let limit = model
404            .monthly_limit
405            .map(cmduse_core::money)
406            .unwrap_or_else(|| "—".into());
407        out.push_str(&format!(
408            "{:<28} {} {} {} {}\n",
409            model.id,
410            ink.cyan_on(format!("{limit:>7}")),
411            ink.cyan_on(format!(
412                "{:>6}",
413                rate.map(|r| format!("${}", r.input)).unwrap_or_default()
414            )),
415            ink.cyan_on(format!(
416                "{:>6}",
417                rate.map(|r| format!("${}", r.output)).unwrap_or_default()
418            )),
419            ink.cyan_on(format!(
420                "{:>8}",
421                rate.map(|r| format!("${}", r.cache_read))
422                    .unwrap_or_default()
423            )),
424        ));
425    }
426    out.push_str(&format!(
427        "\n{}\n",
428        ink.dim_on(format!(
429            "ZEN — pay-as-you-go, {} models priced per 1M tokens",
430            catalog.zen.models.len()
431        ))
432    ));
433    out
434}
435
436pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
437    let ink = Ink::new(colour);
438    let Some(model) = report.models.iter().find(|m| m.id == id) else {
439        return format!("{id}: no usage in the period\n");
440    };
441    let limit = model
442        .limit
443        .map(cmduse_core::money)
444        .unwrap_or_else(|| "—".into());
445    let row =
446        |label: &str, value: String| format!("  {}{value}\n", ink.dim_on(format!("{label:<10}")));
447    let mut out = format!(
448        "{} {}\n",
449        ink.bold_on(model.id.clone()),
450        ink.dim_on(format!("({})", model.provider)),
451    );
452    out.push_str(&row("limit", ink.cyan_on(limit)));
453    out.push_str(&row(
454        "month",
455        ink.cyan_on(format!(
456            "{} · {} requests",
457            cmduse_core::money(model.month.cost),
458            model.month.requests
459        )),
460    ));
461    out.push_str(&row(
462        "5-hour",
463        ink.cyan_on(cmduse_core::money(model.five_hour.cost)),
464    ));
465    out.push_str(&row(
466        "weekly",
467        ink.cyan_on(cmduse_core::money(model.weekly.cost)),
468    ));
469    out
470}
471
472/// Spend per bucket (day or hour), oldest first — `cmduse daily`/`hourly`.
473pub fn bucket_text(
474    rows: &[crate::ocuse::db::Row],
475    now_ms: i64,
476    days: i64,
477    size_ms: i64,
478    label: &str,
479    tz_secs: i64,
480    colour: bool,
481) -> String {
482    let ink = Ink::new(colour);
483    // Buckets are cut in the display timezone: shift, bucket, then label.
484    let shift = tz_secs * 1000;
485    let mut buckets: std::collections::BTreeMap<i64, (f64, u64, u64)> = Default::default();
486    for row in rows {
487        let bucket = (row.at_ms + shift).div_euclid(size_ms);
488        let entry = buckets.entry(bucket).or_insert((0.0, 0, 0));
489        entry.0 += row.cost_usd;
490        entry.1 += 1;
491        entry.2 += row.input + row.output + row.cache_read;
492    }
493    let start = (now_ms + shift - days * 86_400_000).div_euclid(size_ms);
494    let mut out = format!(
495        "{}{label:<12} spend     requests   tokens{}\n",
496        ink.bold, ink.reset
497    );
498    for (bucket, (cost, requests, tokens)) in buckets.range(start..) {
499        let stamp = if size_ms >= 86_400_000 {
500            date_of(*bucket)
501        } else {
502            format!(
503                "{} {:02}h",
504                date_of(bucket.div_euclid(24)),
505                bucket.rem_euclid(24)
506            )
507        };
508        out.push_str(&format!(
509            "{stamp:<12} {} {} {}\n",
510            ink.cyan_on(format!("{:>9}", cmduse_core::money(*cost))),
511            ink.cyan_on(format!("{:>12}", cmduse_core::compact(*requests))),
512            ink.cyan_on(format!("{:>9}", cmduse_core::compact(*tokens))),
513        ));
514    }
515    if out.lines().count() == 1 {
516        out.push_str(&format!(
517            "{}\n",
518            ink.dim_on("(no OpenCode usage in the window)".into())
519        ));
520    }
521    out
522}
523
524/// Spend per session, newest first.
525pub fn session_text(rows: &[crate::ocuse::db::Row], limit: usize, colour: bool) -> String {
526    let ink = Ink::new(colour);
527    let mut by_session: std::collections::BTreeMap<
528        &str,
529        (f64, u64, i64, std::collections::BTreeSet<&str>),
530    > = Default::default();
531    for row in rows {
532        let entry =
533            by_session
534                .entry(row.session.as_str())
535                .or_insert((0.0, 0, 0, Default::default()));
536        entry.0 += row.cost_usd;
537        entry.1 += 1;
538        entry.2 = entry.2.max(row.at_ms);
539        entry.3.insert(row.model.as_str());
540    }
541    let mut sessions: Vec<_> = by_session.into_iter().collect();
542    sessions.sort_by_key(|(_, (_, _, last, _))| std::cmp::Reverse(*last));
543    let mut out = format!(
544        "{}session                          last        models   spend     req{}\n",
545        ink.bold, ink.reset
546    );
547    for (id, (cost, requests, last, models)) in sessions.into_iter().take(limit) {
548        let short = id.rsplit('_').next().unwrap_or(id);
549        out.push_str(&format!(
550            "{:<32} {:<11} {:>6} {} {:>6}\n",
551            short,
552            ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
553            models.len(),
554            ink.cyan_on(format!("{:>9}", cmduse_core::money(cost))),
555            requests,
556        ));
557    }
558    if out.lines().count() == 1 {
559        out.push_str(&format!(
560            "{}\n",
561            ink.dim_on("(no sessions in the window)".into())
562        ));
563    }
564    out
565}
566
567/// `YYYY-MM-DD` for a Unix day number (civil-from-days, same helper family as
568/// the window module's).
569pub fn date_of(day: i64) -> String {
570    let z = day + 719_468;
571    let era = z.div_euclid(146_097);
572    let doe = z.rem_euclid(146_097);
573    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
574    let y = yoe + era * 400;
575    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
576    let mp = (5 * doy + 2) / 153;
577    let d = doy - (153 * mp + 2) / 5 + 1;
578    let m = if mp < 10 { mp + 3 } else { mp - 9 };
579    let year = if m <= 2 { y + 1 } else { y };
580    format!("{year:04}-{m:02}-{d:02}")
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use crate::ocuse::db::Row;
587
588    fn report() -> Report {
589        let now = 1_800_000_000_000;
590        let rows = vec![Row {
591            at_ms: now - 60_000,
592            provider: "opencode-go".into(),
593            model: "glm-5.3-flash".into(),
594            cost_usd: 30.0,
595            ..Default::default()
596        }];
597        crate::ocuse::window::build(&rows, &zen::catalog(), now, None)
598    }
599
600    #[test]
601    fn text_shows_the_model_against_its_limit() {
602        let text = render_text(&report(), "/tmp/opencode.db", 10, false);
603        assert!(text.contains("glm-5.3-flash"), "{text}");
604        assert!(text.contains("$60"), "the model's monthly limit: {text}");
605        assert!(text.contains("50%"), "30 of 60 is half: {text}");
606    }
607
608    #[test]
609    fn json_and_status_line_carry_the_totals() {
610        let json = render_json(&report());
611        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
612        assert_eq!(parsed["totals"]["month"]["cost"], 30.0);
613        assert_eq!(parsed["models"][0]["id"], "glm-5.3-flash");
614        assert!(status_line(&report(), false).contains("glm-5.3-flash"));
615    }
616
617    /// Every renderer, both ways round: piped/non-tty output must stay free of
618    /// SGR escapes, and every one of them must colour when colour is on. This is
619    /// the guard for the bug where only `render_text` ever got a colour flag, so
620    /// `daily` / `hourly` / `session` / `plans` / `model` / `statusline` printed
621    /// plain regardless of the terminal.
622    #[test]
623    fn every_renderer_respects_the_colour_flag() {
624        let report = report();
625        let rows = vec![Row {
626            at_ms: report.now_ms - 60_000,
627            session: "ses_fixture".into(),
628            provider: "opencode-go".into(),
629            model: "glm-5.3-flash".into(),
630            cost_usd: 30.0,
631            ..Default::default()
632        }];
633        let now = report.now_ms;
634
635        let cases: Vec<(&str, String, String)> = vec![
636            (
637                "render_text",
638                render_text(&report, "/tmp/oc.db", 10, false),
639                render_text(&report, "/tmp/oc.db", 10, true),
640            ),
641            (
642                "status_line",
643                status_line(&report, false),
644                status_line(&report, true),
645            ),
646            ("plans_text", plans_text(false), plans_text(true)),
647            (
648                "model_text",
649                model_text(&report, "glm-5.3-flash", false),
650                model_text(&report, "glm-5.3-flash", true),
651            ),
652            (
653                "bucket_text/day",
654                bucket_text(&rows, now, 7, 86_400_000, "day", 0, false),
655                bucket_text(&rows, now, 7, 86_400_000, "day", 0, true),
656            ),
657            (
658                "bucket_text/hour",
659                bucket_text(&rows, now, 1, 3_600_000, "hour", 0, false),
660                bucket_text(&rows, now, 1, 3_600_000, "hour", 0, true),
661            ),
662            (
663                "session_text",
664                session_text(&rows, 20, false),
665                session_text(&rows, 20, true),
666            ),
667        ];
668
669        for (name, plain, coloured) in cases {
670            assert!(
671                !plain.contains('\x1b'),
672                "{name} leaked escapes when off: {plain}"
673            );
674            assert!(
675                coloured.contains('\x1b'),
676                "{name} rendered nothing coloured when on: {coloured}"
677            );
678        }
679    }
680
681    #[test]
682    fn plain_frames_carry_no_escapes() {
683        let text = render_text(&report(), "/tmp/opencode.db", 10, false);
684        assert!(
685            !text.contains('\x1b'),
686            "piped output must stay plain: {text}"
687        );
688        // The gauge is colour-only; the plain frame keeps the old percentage text.
689        assert!(text.contains("50%"), "{text}");
690    }
691
692    #[test]
693    fn an_over_cap_window_is_flagged_in_both_modes() {
694        // 30 spent against a 5-hour cap of 20% of the $60 limit = $12.
695        let plain = render_text(&report(), "/tmp/opencode.db", 10, false);
696        assert!(plain.contains("LIMIT EXCEEDED"), "{plain}");
697        let coloured = render_text(&report(), "/tmp/opencode.db", 10, true);
698        assert!(coloured.contains("LIMIT EXCEEDED"), "{coloured}");
699        assert!(
700            coloured.contains(crate::render::RED),
701            "the flag and the gauge are red: {coloured}"
702        );
703    }
704
705    #[test]
706    fn colour_frames_get_the_palette_and_a_gauge() {
707        let text = render_text(&report(), "/tmp/opencode.db", 10, true);
708        assert!(text.contains(crate::render::BOLD), "{text}");
709        assert!(text.contains(crate::render::CYAN), "{text}");
710        assert!(
711            text.contains(crate::render::GREEN),
712            "the 50% monthly gauge and USE cell are green: {text}"
713        );
714        assert!(
715            text.contains(crate::render::RED),
716            "the over-cap 5-hour gauge (30 of 12) is red: {text}"
717        );
718        assert!(
719            text.contains('━') && text.contains('╱'),
720            "gauge bar: {text}"
721        );
722        assert!(text.contains("resets in"), "{text}");
723    }
724
725    #[test]
726    fn watch_frame_ends_on_the_status_line_and_shows_bursts() {
727        let frame = watch_frame(
728            &report(),
729            "/tmp/opencode.db",
730            10,
731            true,
732            &[0.0, 1.5, 0.0],
733            10,
734        );
735        assert!(
736            !frame.ends_with('\n'),
737            "the caller's status line owns the last row"
738        );
739        assert!(frame.contains("spend bursts (10s samples)"), "{frame}");
740        assert!(
741            frame.contains('█'),
742            "a nonzero delta shows as a tall bar: {frame}"
743        );
744    }
745
746    #[test]
747    fn idle_history_omits_the_sparkline() {
748        let all_idle = watch_frame(&report(), "/tmp/opencode.db", 10, true, &[0.0, 0.0], 10);
749        assert!(!all_idle.contains("spend bursts"), "{all_idle}");
750        let first_frame = watch_frame(&report(), "/tmp/opencode.db", 10, true, &[], 10);
751        assert!(!first_frame.contains("spend bursts"), "{first_frame}");
752    }
753}