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 each plan allows.
404///
405/// Both Go plans are listed because the docs define two — Go and Go Plus — that
406/// share one token price list but grant each model a different monthly limit.
407pub fn plans_text(colour: bool) -> String {
408    let ink = Ink::new(colour);
409    let catalog = crate::ocuse::zen::catalog();
410    let mut out = format!(
411        "{}OpenCode plans{} {}\n",
412        ink.bold,
413        ink.reset,
414        ink.dim_on(format!("(docs, extracted {})", catalog.extracted_at)),
415    );
416    out.push_str(&format!(
417        "{}windows (both plans): 5h {} · weekly {} · monthly {}{}\n",
418        ink.dim,
419        ink.cyan_on(format!("{:.1}%", catalog.go.window_share.five_hour * 100.0)),
420        ink.cyan_on(format!("{:.1}%", catalog.go.window_share.weekly * 100.0)),
421        ink.cyan_on(format!("{:.1}%", catalog.go.window_share.monthly * 100.0)),
422        ink.reset,
423    ));
424    for plan in catalog.go.plans.values() {
425        out.push_str(&format!(
426            "\n{}{}{} — {}/month\n",
427            ink.bold,
428            plan.label,
429            ink.reset,
430            ink.cyan_on(format!("${:.2}", plan.price_usd)),
431        ));
432        out.push_str(&format!(
433            "{}MODEL                            LIMIT    IN     OUT    CACHE{}\n",
434            ink.bold, ink.reset
435        ));
436        for model in &catalog.go.models {
437            let rate = model.variants.first();
438            let limit = plan
439                .allowance_by_model
440                .get(&model.id)
441                .map(|limit| cmduse_core::money(*limit))
442                .unwrap_or_else(|| "—".into());
443            out.push_str(&format!(
444                "{:<28} {} {} {} {}\n",
445                model.id,
446                ink.cyan_on(format!("{limit:>7}")),
447                ink.cyan_on(format!(
448                    "{:>6}",
449                    rate.map(|r| format!("${}", r.input)).unwrap_or_default()
450                )),
451                ink.cyan_on(format!(
452                    "{:>6}",
453                    rate.map(|r| format!("${}", r.output)).unwrap_or_default()
454                )),
455                ink.cyan_on(format!(
456                    "{:>8}",
457                    rate.map(|r| format!("${}", r.cache_read))
458                        .unwrap_or_default()
459                )),
460            ));
461        }
462    }
463    out.push_str(&format!(
464        "\n{}\n",
465        ink.dim_on(format!(
466            "ZEN — pay-as-you-go, {} models priced per 1M tokens",
467            catalog.zen.models.len()
468        ))
469    ));
470    out
471}
472
473pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
474    let ink = Ink::new(colour);
475    let Some(model) = report.models.iter().find(|m| m.id == id) else {
476        return format!("{id}: no usage in the period\n");
477    };
478    let limit = model
479        .limit
480        .map(cmduse_core::money)
481        .unwrap_or_else(|| "—".into());
482    let row =
483        |label: &str, value: String| format!("  {}{value}\n", ink.dim_on(format!("{label:<10}")));
484    let mut out = format!(
485        "{} {}\n",
486        ink.bold_on(model.id.clone()),
487        ink.dim_on(format!("({})", model.provider)),
488    );
489    out.push_str(&row("limit", ink.cyan_on(limit)));
490    out.push_str(&row(
491        "month",
492        ink.cyan_on(format!(
493            "{} · {} requests",
494            cmduse_core::money(model.month.cost),
495            model.month.requests
496        )),
497    ));
498    out.push_str(&row(
499        "5-hour",
500        ink.cyan_on(cmduse_core::money(model.five_hour.cost)),
501    ));
502    out.push_str(&row(
503        "weekly",
504        ink.cyan_on(cmduse_core::money(model.weekly.cost)),
505    ));
506    // The dashboard's share column needs the period total; here it is the same
507    // fact spelled out. Absent when the period has no spend to divide by.
508    if report.month.cost > 0.0 {
509        out.push_str(&row(
510            "share",
511            ink.dim_on(format!(
512                "{:.1}% of period spend",
513                model.month.cost / report.month.cost * 100.0
514            )),
515        ));
516    }
517    out
518}
519
520/// Spend per bucket (day or hour), oldest first — `cmduse daily`/`hourly`.
521pub fn bucket_text(
522    rows: &[crate::ocuse::db::Row],
523    now_ms: i64,
524    days: i64,
525    size_ms: i64,
526    label: &str,
527    tz_secs: i64,
528    colour: bool,
529) -> String {
530    let ink = Ink::new(colour);
531    // Buckets are cut in the display timezone: shift, bucket, then label.
532    let shift = tz_secs * 1000;
533    let mut buckets: std::collections::BTreeMap<i64, (f64, u64, u64)> = Default::default();
534    for row in rows {
535        let bucket = (row.at_ms + shift).div_euclid(size_ms);
536        let entry = buckets.entry(bucket).or_insert((0.0, 0, 0));
537        entry.0 += row.cost_usd;
538        entry.1 += 1;
539        entry.2 += row.input + row.output + row.cache_read;
540    }
541    let start = (now_ms + shift - days * 86_400_000).div_euclid(size_ms);
542    let mut out = format!(
543        "{}{label:<12} spend     requests   tokens{}\n",
544        ink.bold, ink.reset
545    );
546    for (bucket, (cost, requests, tokens)) in buckets.range(start..) {
547        let stamp = if size_ms >= 86_400_000 {
548            date_of(*bucket)
549        } else {
550            format!(
551                "{} {:02}h",
552                date_of(bucket.div_euclid(24)),
553                bucket.rem_euclid(24)
554            )
555        };
556        out.push_str(&format!(
557            "{stamp:<12} {} {} {}\n",
558            ink.cyan_on(format!("{:>9}", cmduse_core::money(*cost))),
559            ink.cyan_on(format!("{:>12}", cmduse_core::compact(*requests))),
560            ink.cyan_on(format!("{:>9}", cmduse_core::compact(*tokens))),
561        ));
562    }
563    if out.lines().count() == 1 {
564        out.push_str(&format!(
565            "{}\n",
566            ink.dim_on("(no OpenCode usage in the window)".into())
567        ));
568    }
569    out
570}
571
572/// Spend per session, newest first.
573pub fn session_text(rows: &[crate::ocuse::db::Row], limit: usize, colour: bool) -> String {
574    let ink = Ink::new(colour);
575    let mut by_session: std::collections::BTreeMap<
576        &str,
577        (f64, u64, i64, std::collections::BTreeSet<&str>),
578    > = Default::default();
579    for row in rows {
580        let entry =
581            by_session
582                .entry(row.session.as_str())
583                .or_insert((0.0, 0, 0, Default::default()));
584        entry.0 += row.cost_usd;
585        entry.1 += 1;
586        entry.2 = entry.2.max(row.at_ms);
587        entry.3.insert(row.model.as_str());
588    }
589    let mut sessions: Vec<_> = by_session.into_iter().collect();
590    sessions.sort_by_key(|(_, (_, _, last, _))| std::cmp::Reverse(*last));
591    let mut out = format!(
592        "{}session                          last        models   spend     req{}\n",
593        ink.bold, ink.reset
594    );
595    for (id, (cost, requests, last, models)) in sessions.into_iter().take(limit) {
596        let short = id.rsplit('_').next().unwrap_or(id);
597        out.push_str(&format!(
598            "{:<32} {:<11} {:>6} {} {:>6}\n",
599            short,
600            ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
601            models.len(),
602            ink.cyan_on(format!("{:>9}", cmduse_core::money(cost))),
603            requests,
604        ));
605    }
606    if out.lines().count() == 1 {
607        out.push_str(&format!(
608            "{}\n",
609            ink.dim_on("(no sessions in the window)".into())
610        ));
611    }
612    out
613}
614
615/// `YYYY-MM-DD` for a Unix day number (civil-from-days, same helper family as
616/// the window module's).
617pub fn date_of(day: i64) -> String {
618    let z = day + 719_468;
619    let era = z.div_euclid(146_097);
620    let doe = z.rem_euclid(146_097);
621    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
622    let y = yoe + era * 400;
623    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
624    let mp = (5 * doy + 2) / 153;
625    let d = doy - (153 * mp + 2) / 5 + 1;
626    let m = if mp < 10 { mp + 3 } else { mp - 9 };
627    let year = if m <= 2 { y + 1 } else { y };
628    format!("{year:04}-{m:02}-{d:02}")
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634    use crate::ocuse::db::Row;
635
636    fn report() -> Report {
637        let now = 1_800_000_000_000;
638        let rows = vec![Row {
639            at_ms: now - 60_000,
640            provider: "opencode-go".into(),
641            model: "glm-5.3-flash".into(),
642            cost_usd: 30.0,
643            ..Default::default()
644        }];
645        crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None)
646    }
647
648    #[test]
649    fn text_shows_the_model_against_its_limit() {
650        let text = render_text(&report(), "/tmp/opencode.db", 10, false);
651        assert!(text.contains("glm-5.3-flash"), "{text}");
652        assert!(text.contains("$60"), "the model's monthly limit: {text}");
653        assert!(text.contains("50%"), "30 of 60 is half: {text}");
654    }
655
656    #[test]
657    fn json_and_status_line_carry_the_totals() {
658        let json = render_json(&report());
659        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
660        assert_eq!(parsed["totals"]["month"]["cost"], 30.0);
661        assert_eq!(parsed["models"][0]["id"], "glm-5.3-flash");
662        assert!(status_line(&report(), false).contains("glm-5.3-flash"));
663    }
664
665    /// Every renderer, both ways round: piped/non-tty output must stay free of
666    /// SGR escapes, and every one of them must colour when colour is on. This is
667    /// the guard for the bug where only `render_text` ever got a colour flag, so
668    /// `daily` / `hourly` / `session` / `plans` / `model` / `statusline` printed
669    /// plain regardless of the terminal.
670    #[test]
671    fn every_renderer_respects_the_colour_flag() {
672        let report = report();
673        let rows = vec![Row {
674            at_ms: report.now_ms - 60_000,
675            session: "ses_fixture".into(),
676            provider: "opencode-go".into(),
677            model: "glm-5.3-flash".into(),
678            cost_usd: 30.0,
679            ..Default::default()
680        }];
681        let now = report.now_ms;
682
683        let cases: Vec<(&str, String, String)> = vec![
684            (
685                "render_text",
686                render_text(&report, "/tmp/oc.db", 10, false),
687                render_text(&report, "/tmp/oc.db", 10, true),
688            ),
689            (
690                "status_line",
691                status_line(&report, false),
692                status_line(&report, true),
693            ),
694            ("plans_text", plans_text(false), plans_text(true)),
695            (
696                "model_text",
697                model_text(&report, "glm-5.3-flash", false),
698                model_text(&report, "glm-5.3-flash", true),
699            ),
700            (
701                "bucket_text/day",
702                bucket_text(&rows, now, 7, 86_400_000, "day", 0, false),
703                bucket_text(&rows, now, 7, 86_400_000, "day", 0, true),
704            ),
705            (
706                "bucket_text/hour",
707                bucket_text(&rows, now, 1, 3_600_000, "hour", 0, false),
708                bucket_text(&rows, now, 1, 3_600_000, "hour", 0, true),
709            ),
710            (
711                "session_text",
712                session_text(&rows, 20, false),
713                session_text(&rows, 20, true),
714            ),
715        ];
716
717        for (name, plain, coloured) in cases {
718            assert!(
719                !plain.contains('\x1b'),
720                "{name} leaked escapes when off: {plain}"
721            );
722            assert!(
723                coloured.contains('\x1b'),
724                "{name} rendered nothing coloured when on: {coloured}"
725            );
726        }
727    }
728
729    #[test]
730    fn plain_frames_carry_no_escapes() {
731        let text = render_text(&report(), "/tmp/opencode.db", 10, false);
732        assert!(
733            !text.contains('\x1b'),
734            "piped output must stay plain: {text}"
735        );
736        // The gauge is colour-only; the plain frame keeps the old percentage text.
737        assert!(text.contains("50%"), "{text}");
738    }
739
740    #[test]
741    fn an_over_cap_window_is_flagged_in_both_modes() {
742        // 30 spent against a 5-hour cap of 20% of the $60 limit = $12.
743        let plain = render_text(&report(), "/tmp/opencode.db", 10, false);
744        assert!(plain.contains("LIMIT EXCEEDED"), "{plain}");
745        let coloured = render_text(&report(), "/tmp/opencode.db", 10, true);
746        assert!(coloured.contains("LIMIT EXCEEDED"), "{coloured}");
747        assert!(
748            coloured.contains(crate::render::RED),
749            "the flag and the gauge are red: {coloured}"
750        );
751    }
752
753    #[test]
754    fn a_model_over_any_of_its_own_windows_is_flagged() {
755        // The fixture is at 50% of the month but over the 5-hour share, and Go
756        // meters per model per window: the row must say so, not just glow red.
757        let plain = render_text(&report(), "/tmp/opencode.db", 10, false);
758        let row = plain
759            .lines()
760            .find(|line| line.starts_with("glm-5.3-flash"))
761            .expect("the model row");
762        assert!(row.contains("LIMIT EXCEEDED"), "{row}");
763        // Share last, flag early: the informational column is clipped first.
764        assert!(row.trim_end().ends_with('%'), "share ends the row: {row}");
765    }
766
767    #[test]
768    fn a_model_within_its_limits_is_not_flagged() {
769        let now = 1_800_000_000_000;
770        // $5 against a $60 limit: every window is comfortably inside its share.
771        let rows = vec![Row {
772            at_ms: now - 60_000,
773            provider: "opencode-go".into(),
774            model: "glm-5.3-flash".into(),
775            cost_usd: 5.0,
776            ..Default::default()
777        }];
778        let report =
779            crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None);
780        for colour in [false, true] {
781            let text = render_text(&report, "/tmp/opencode.db", 10, colour);
782            assert!(!text.contains("LIMIT EXCEEDED"), "colour={colour}: {text}");
783        }
784    }
785
786    #[test]
787    fn the_model_detail_spells_out_the_share() {
788        let detail = model_text(&report(), "glm-5.3-flash", false);
789        assert!(detail.contains("100.0% of period spend"), "{detail}");
790        // A period with nothing to divide by omits the row rather than printing NaN.
791        let empty = crate::ocuse::window::build(
792            &[],
793            &zen::catalog(),
794            zen::DEFAULT_PLAN,
795            1_800_000_000_000,
796            None,
797        );
798        assert!(!model_text(&empty, "glm-5.3-flash", false).contains("share"));
799    }
800
801    #[test]
802    fn colour_frames_get_the_palette_and_a_gauge() {
803        let text = render_text(&report(), "/tmp/opencode.db", 10, true);
804        assert!(text.contains(crate::render::BOLD), "{text}");
805        assert!(text.contains(crate::render::CYAN), "{text}");
806        assert!(
807            text.contains(crate::render::GREEN),
808            "the 50% monthly gauge and USE cell are green: {text}"
809        );
810        assert!(
811            text.contains(crate::render::RED),
812            "the over-cap 5-hour gauge (30 of 12) is red: {text}"
813        );
814        assert!(
815            text.contains('━') && text.contains('╱'),
816            "gauge bar: {text}"
817        );
818        assert!(text.contains("resets in"), "{text}");
819    }
820
821    #[test]
822    fn watch_frame_ends_on_the_status_line_and_shows_bursts() {
823        let frame = watch_frame(
824            &report(),
825            "/tmp/opencode.db",
826            10,
827            true,
828            &[0.0, 1.5, 0.0],
829            10,
830        );
831        assert!(
832            !frame.ends_with('\n'),
833            "the caller's status line owns the last row"
834        );
835        assert!(frame.contains("spend bursts (10s samples)"), "{frame}");
836        assert!(
837            frame.contains('█'),
838            "a nonzero delta shows as a tall bar: {frame}"
839        );
840    }
841
842    #[test]
843    fn idle_history_omits_the_sparkline() {
844        let all_idle = watch_frame(&report(), "/tmp/opencode.db", 10, true, &[0.0, 0.0], 10);
845        assert!(!all_idle.contains("spend bursts"), "{all_idle}");
846        let first_frame = watch_frame(&report(), "/tmp/opencode.db", 10, true, &[], 10);
847        assert!(!first_frame.contains("spend bursts"), "{first_frame}");
848    }
849}