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 crate::report_render::Fmt;
11use cmduse_core::{compact, duration, money, pct};
12
13/// Gauge width for the window bars when neither config nor `--bar-width` says
14/// otherwise — cmduse's own default.
15pub const DEFAULT_BAR_WIDTH: usize = 20;
16
17/// ANSI styles — cmduse's palette, or empty strings when colour is off, so a
18/// piped run never leaks SGR escapes.
19pub struct Ink {
20    pub colour: bool,
21    /// Gauge width for the window bars (config / `--bar-width`).
22    pub bar_width: usize,
23    pub bold: &'static str,
24    pub dim: &'static str,
25    pub reset: &'static str,
26    pub cyan: &'static str,
27    pub green: &'static str,
28    pub yellow: &'static str,
29    pub red: &'static str,
30}
31
32impl Ink {
33    pub fn new(colour: bool) -> Self {
34        if colour {
35            Self {
36                colour,
37                bar_width: DEFAULT_BAR_WIDTH,
38                bold: crate::render::BOLD,
39                dim: crate::render::DIM,
40                reset: crate::render::RESET,
41                cyan: crate::render::CYAN,
42                green: crate::render::GREEN,
43                yellow: crate::render::YELLOW,
44                red: crate::render::RED,
45            }
46        } else {
47            Self {
48                colour,
49                bar_width: DEFAULT_BAR_WIDTH,
50                bold: "",
51                dim: "",
52                reset: "",
53                cyan: "",
54                green: "",
55                yellow: "",
56                red: "",
57            }
58        }
59    }
60
61    /// Override the gauge width (config / `--bar-width`); chains after `new`.
62    pub fn width(mut self, width: usize) -> Self {
63        self.bar_width = width;
64        self
65    }
66
67    /// Colour a value without disturbing the caller's column padding: pad the
68    /// text first, then wrap it.
69    fn cyan_on(&self, text: String) -> String {
70        format!("{}{text}{}", self.cyan, self.reset)
71    }
72
73    /// Secondary information — cmduse's `DIM`.
74    fn dim_on(&self, text: String) -> String {
75        format!("{}{text}{}", self.dim, self.reset)
76    }
77
78    fn bold_on(&self, text: String) -> String {
79        format!("{}{text}{}", self.bold, self.reset)
80    }
81
82    /// Severity for a used/limit percentage — cmduse's thresholds.
83    fn for_pct(&self, pct_used: f64) -> &'static str {
84        if !self.colour {
85            ""
86        } else if pct_used >= 90.0 {
87            self.red
88        } else if pct_used >= 70.0 {
89            self.yellow
90        } else {
91            self.green
92        }
93    }
94
95    /// Pct + gauge, exactly cmduse's `render::bar`; empty when colour is off so
96    /// the plain frame keeps its script-friendly column layout.
97    fn bar(&self, used: f64, cap: f64) -> String {
98        if self.colour {
99            crate::render::bar(used, cap, self.bar_width)
100        } else {
101            String::new()
102        }
103    }
104}
105
106#[allow(clippy::too_many_arguments)]
107/// One window row's inputs. The account's own meters and the local estimate both
108/// go through this one builder, so the two branches cannot drift apart in which
109/// fields a window row carries or in the order it carries them.
110///
111/// There is deliberately no cap *field* here: Go grants limits per model, so no
112/// account-wide pool exists to divide by, and summing the per-model limits into
113/// one ceiling read as a budget the console does not have (it meters each window
114/// on its own basis) — that is where `$215` came from. The money pair comes back
115/// the honest way instead: the server's own share back-solves the ceiling it is
116/// metering against, so `used / implied` divides to exactly the share beside it.
117struct RowSpec<'a> {
118    label: &'a str,
119    /// This store's own totals for the window.
120    used: &'a Totals,
121    /// The window's reset instant and its length in seconds. `None` on the local
122    /// estimate's rolling 5h/weekly windows: those are cut at `now - window`, so
123    /// they have no boundary to measure an elapsed share against.
124    reset: Option<(i64, u64)>,
125    /// The account's own share of the limit, when it answered — the console's
126    /// figure, and the reason the endpoint is read at all. `None` on the local
127    /// estimate, whose rows then show spend alone.
128    share_pct: Option<f64>,
129    /// The account's own verdict: a status it does not call `ok` rides beside
130    /// the limit flag.
131    status: Option<&'a str>,
132    /// Whether an even-burn figure belongs on this row. True for the monthly
133    /// window alone: that one is a budget, so "what even burn would have spent by
134    /// now" is a fair comparison, where the 5-hour and weekly throttles would
135    /// read the same number as a spend target.
136    pro_rata: bool,
137}
138
139fn window_line(ink: &Ink, spec: &RowSpec<'_>, now_ms: i64) -> String {
140    let reset = spec
141        .reset
142        .map(|(at, _)| duration(((at - now_ms) / 1000).max(0) as u64))
143        .unwrap_or_default();
144    let reset_text = if reset.is_empty() {
145        String::new()
146    } else if ink.colour {
147        format!(" · resets in {reset}")
148    } else {
149        format!(" · resets {reset}")
150    };
151    // cmduse's field order is importance order, so a clipped watch frame keeps
152    // the actionable parts: limit flag, pace warning, elapsed share, then the
153    // informational pro-rata figure. Percentages come from core (one decimal);
154    // duration (never rel_time) formats the ETA.
155    let now_secs = (now_ms / 1000).max(0) as u64;
156    let reset_at = spec.reset.map(|(at, _)| at as f64);
157    let dur_secs = spec.reset.map(|(_, dur)| dur);
158    let elapsed = dur_secs.and_then(|d| cmduse_core::elapsed_pct(reset_at, d, Some(now_secs)));
159    let share = spec.share_pct.map(|p| p.max(0.0));
160    // The ceiling the server's own share implies, in this store's dollars. No
161    // pool is invented here: the console's percentage *is* the fraction of the
162    // ceiling it meters against, so `spend / share` is the ceiling, and the pair
163    // below divides to exactly the percentage printed beside it.
164    let cap = share.and_then(|p| implied_cap(spec.used.cost, p));
165    let pace = cap
166        .and_then(|cap| {
167            dur_secs.and_then(|d| {
168                cmduse_core::pace_eta(reset_at, d, spec.used.cost, cap, now_secs)
169                    .map(|secs| duration(secs as u64))
170            })
171        })
172        .map(|eta| format!(" · {}on pace to hit cap in {eta}{}", ink.yellow, ink.reset))
173        .unwrap_or_default();
174    let thru = elapsed
175        .map(|p| format!(" · {}window {p:.1}% elapsed{}", ink.dim, ink.reset))
176        .unwrap_or_default();
177    let by_now = match (spec.pro_rata, elapsed, cap) {
178        (true, Some(p), Some(cap)) => format!(
179            " · {}{} by now{}",
180            ink.dim,
181            money(cap * p / 100.0),
182            ink.reset
183        ),
184        _ => String::new(),
185    };
186    let label = spec.label;
187    // The account's own share is labelled, because the money beside it is this
188    // store's spend and the two need not divide to each other (the server
189    // meters higher). Same placement cmduse uses for the flag: on the window
190    // it belongs to.
191    let flag = if share.is_some_and(|p| p >= 100.0) {
192        format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
193    } else if spec.status.is_some_and(|s| !s.eq_ignore_ascii_case("ok")) {
194        format!(
195            " {}{}{}{}",
196            ink.red,
197            spec.status.unwrap_or_default(),
198            ink.reset,
199            ""
200        )
201    } else {
202        String::new()
203    };
204    // No denominator is printed. `cap` is spent ÷ share, so printing it offered
205    // a ceiling of ~$40 beside a documented $80 monthly limit and read as "my
206    // limit is $40" — the console meters at roughly twice the cost this store
207    // records, so the two bases never meet. It stays a local value for the pace
208    // and the even-burn figure, both of which are ratios in this store's dollars.
209    let paren = match share {
210        Some(p) => format!(" · {}% of limit", given_pct(p)),
211        None => String::new(),
212    };
213    // Composed in pieces: the pair and the pro-rata figure are conditional, and
214    // one format string for both branches is a placeholder count waiting to drift.
215    let body = format!(
216        "{}{paren}{reset_text}{flag}{pace}{thru}{by_now}",
217        money(spec.used.cost)
218    );
219    if ink.colour {
220        // The gauge bar belongs to the share that gates: the account's own
221        // when it answered. The local estimate has no share, so its rows
222        // carry no bar — spend alone, never a gauge against a pool.
223        let bar = match share {
224            Some(p) => format!("{} ", ink.bar(p, 100.0)),
225            None => String::new(),
226        };
227        format!(
228            " {}{label:<8}{} {}{}{body}{}\n",
229            ink.bold, ink.reset, bar, ink.dim, ink.reset,
230        )
231    } else {
232        // The plain frame is the script-facing one: same field order,
233        // no SGR (the Ink helpers are empty strings when colour is off).
234        format!("  {label:<8} {body}\n")
235    }
236}
237
238/// A percentage exactly as the endpoint published it.
239///
240/// The server sends integers (`"percent":53`), so `{:.1}` invented a decimal the
241/// console never reports — and a tenth implies a precision its rounding does not
242/// have. Local, computed percentages keep `pct`'s one decimal; this is only for
243/// the values we are handed.
244fn given_pct(value: f64) -> String {
245    if (value - value.round()).abs() < f64::EPSILON {
246        format!("{}", value.round() as i64)
247    } else {
248        format!("{:.1}", value)
249    }
250}
251
252/// The ceiling a share implies, in this store's dollars: `spend / share`.
253///
254/// Kept for the pace and even-burn figures only — never printed, because it
255/// contradicts the documented allowance. `None` on a zero share, which would
256/// divide by it.
257fn implied_cap(spend: f64, share_pct: f64) -> Option<f64> {
258    (share_pct >= 0.05).then(|| spend * 100.0 / share_pct)
259}
260
261/// The account meter's own window boundary, when the endpoint gave one: its
262/// reset instant together with the window's length, so the row can measure an
263/// elapsed share and a pace against the server's clock rather than a guess.
264fn meter_reset(meter: &crate::ocuse::api::Meter, dur_secs: Option<u64>) -> Option<(i64, u64)> {
265    match (meter.reset_ms(), dur_secs) {
266        (Some(at), Some(dur)) if dur > 0 => Some((at, dur)),
267        _ => None,
268    }
269}
270
271/// Is any of this model's windows over its share of the limit? Go meters per
272/// model *and* per window, so being over the 5-hour cap counts like being over
273/// the month. One verdict, shared with the watch loop's cap notification.
274fn exceeded(model: &ModelUsage) -> bool {
275    crate::ocuse::window::over_window(model).is_some()
276}
277
278fn model_row(ink: &Ink, model: &ModelUsage, total_cost: f64) -> String {
279    let limit = match model.limit {
280        Some(limit) => money(limit),
281        None => ink.dim_on("—".into()),
282    };
283    let use_fraction = model.month_use();
284    let use_text = use_fraction
285        .map(|fraction| pct(fraction * 100.0, 100.0))
286        .unwrap_or_else(|| "—".into());
287    // Pad before colouring: SGR escapes would otherwise count as columns.
288    let spent = ink.cyan_on(format!("{:>10}", money(model.month.cost)));
289    let use_cell = match use_fraction {
290        Some(fraction) => {
291            let tone = ink.for_pct(fraction * 100.0);
292            if tone.is_empty() {
293                format!("{use_text:>7}")
294            } else {
295                format!("{tone}{use_text:>7}{}", ink.reset)
296            }
297        }
298        None => ink.dim_on(format!("{use_text:>7}")),
299    };
300    // Actionable before informational (cmduse's ordering): the flag sits beside
301    // the model so a clipped frame keeps it; the share is the first thing to go.
302    let flag = if exceeded(model) {
303        format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
304    } else {
305        String::new()
306    };
307    let share = if total_cost > 0.0 {
308        ink.dim_on(format!(" {:>5.1}%", model.month.cost / total_cost * 100.0))
309    } else {
310        String::new()
311    };
312    format!(
313        "{:<28}{flag} {:>7} {spent} {use_cell} {} {} {:>7}{share}\n",
314        model.id,
315        limit,
316        // Go windows are per model too: 5h = 20% of the limit, weekly = 50%.
317        window_cell(ink, model, model.five_hour.cost, 0.20),
318        window_cell(ink, model, model.weekly.cost, 0.50),
319        compact(model.month.requests),
320    )
321}
322
323/// One rolling-window column, coloured against that model's own share of the
324/// limit; models with no limit (free, or Zen-only) are dimmed instead.
325fn window_cell(ink: &Ink, model: &ModelUsage, cost: f64, share: f64) -> String {
326    let text = format!("{:>8}", money(cost));
327    match model.limit.filter(|limit| *limit > 0.0) {
328        Some(limit) => {
329            let tone = ink.for_pct(cost / (limit * share) * 100.0);
330            if tone.is_empty() {
331                text
332            } else {
333                format!("{tone}{text}{}", ink.reset)
334            }
335        }
336        None => ink.dim_on(text),
337    }
338}
339
340/// `limit` rows to show; `usize::MAX` for all.
341pub fn render_text(
342    report: &Report,
343    db_path: &str,
344    rows: usize,
345    colour: bool,
346    bar_width: usize,
347) -> String {
348    let ink = Ink::new(colour).width(bar_width);
349    let c = |text: String| ink.cyan_on(text);
350    let mut out = String::new();
351    out.push_str(&format!(
352        "{}OpenCode usage{} — go + zen, from {}{}{}\n",
353        ink.bold,
354        ink.reset,
355        ink.dim,
356        shorten(db_path),
357        ink.reset,
358    ));
359    // cmduse prints `Success N%` from its account API; opencode's own store
360    // records no per-request success/error signal, so there is no rate to read
361    // here — omitted rather than synthesised (source-forced).
362    let end = report
363        .period_end_ms
364        .map(|ms| format!(" · ends {}", date_of(ms / 86_400_000)))
365        .unwrap_or_default();
366    out.push_str(&format!(
367        "period: {}{end} · {} requests · {} · {} in / {} out tokens\n",
368        report.period_label,
369        c(compact(report.month.requests)),
370        c(money(report.month.cost)),
371        c(compact(report.month.input)),
372        c(compact(report.month.output)),
373    ));
374
375    // The three window rows. When the account answered, they are its own meters
376    // — the only view that agrees with opencode.ai/console, and the reason this
377    // endpoint is read at all: the store cannot reproduce the server's window
378    // anchors or its price basis (see `ocuse::api`).
379    match &report.account {
380        Some(account) => {
381            out.push_str(&format!(
382                "\n{}GO{} {}\n",
383                ink.bold,
384                ink.reset,
385                ink.dim_on(
386                    "(account meters — what opencode.ai/console reports; the $ is this store's spend priced on the documented peak/off-peak schedule)"
387                        .into()
388                ),
389            ));
390            // Every field that is true stays on the row: this store's money, the
391            // ceiling the server's own share implies, the share labelled beside
392            // it, its reset instant, and the elapsed share measured against the
393            // server's clock. No account-wide pool is invented here — summing the
394            // per-model limits into one ceiling is where the old `$215` came from.
395            let monthly_dur = report
396                .period_end_ms
397                .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
398            let rows = [
399                (
400                    "5-hour",
401                    &account.rolling,
402                    &report.five_hour,
403                    Some(crate::ocuse::window::FIVE_HOUR_SECS as u64),
404                ),
405                (
406                    "Weekly",
407                    &account.weekly,
408                    &report.weekly,
409                    Some(crate::ocuse::window::WEEKLY_SECS as u64),
410                ),
411                ("Monthly", &account.monthly, &report.month, monthly_dur),
412            ];
413            for (label, meter, used, dur_secs) in rows {
414                out.push_str(&window_line(
415                    &ink,
416                    &RowSpec {
417                        label,
418                        used,
419                        reset: meter_reset(meter, dur_secs),
420                        share_pct: Some(meter.percent),
421                        status: Some(meter.status.as_str()),
422                        // The monthly window is the budget; the two throttles
423                        // above it would read a pro-rata figure as a target.
424                        pro_rata: label == "Monthly",
425                    },
426                    report.now_ms,
427                ));
428            }
429        }
430        // No Go key, no network, or a key without the subscription: the store's
431        // own spend, which says so. Limits are per model, so the rows carry
432        // no pool to divide by -- spend alone, with the Monthly row's real
433        // reset and elapsed share. They can disagree with the account.
434        None if report.month.requests > 0 => {
435            // Only the monthly period has a boundary of its own (`period_end_ms`).
436            // The rolling 5h/weekly windows are cut at `now - window`, so they get
437            // no reset, no elapsed share and no pace — the fabricated `now + window`
438            // countdown that used to sit here moved on every frame.
439            let monthly_dur = report
440                .period_end_ms
441                .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
442            out.push_str(&format!(
443                "\n{}GO{} {}\n",
444                ink.bold,
445                ink.reset,
446                ink.dim_on(
447                    "(estimated locally: limits are per model, so no pool: spend alone)".into()
448                ),
449            ));
450            // Why the console's own numbers are missing, when there is a reason:
451            // "estimated locally" alone cannot tell a missing key from an expired
452            // one, and only the second is something a person can act on.
453            if let Some(why) = &report.account_error {
454                out.push_str(&format!("{}   usage API: {why}{}\n", ink.dim, ink.reset));
455            }
456            out.push_str(&window_line(
457                &ink,
458                &RowSpec {
459                    label: "5-hour",
460                    used: &report.five_hour,
461                    reset: None,
462                    share_pct: None,
463                    status: None,
464                    pro_rata: false,
465                },
466                report.now_ms,
467            ));
468            out.push_str(&window_line(
469                &ink,
470                &RowSpec {
471                    label: "Weekly",
472                    used: &report.weekly,
473                    reset: None,
474                    share_pct: None,
475                    status: None,
476                    pro_rata: false,
477                },
478                report.now_ms,
479            ));
480            out.push_str(&window_line(
481                &ink,
482                &RowSpec {
483                    label: "Monthly",
484                    used: &report.month,
485                    reset: report.period_end_ms.zip(monthly_dur),
486                    share_pct: None,
487                    status: None,
488                    // No account share, so no ceiling to pro-rate: this row keeps
489                    // its real reset and elapsed share and nothing invented.
490                    pro_rata: false,
491                },
492                report.now_ms,
493            ));
494        }
495        None => {}
496    }
497
498    out.push_str(&format!(
499        "\n{}MODEL                            LIMIT      SPENT     USE       5H       WK     REQ{}\n",
500        ink.bold, ink.reset
501    ));
502    let total_cost = report.month.cost;
503    for model in report.models.iter().take(rows) {
504        out.push_str(&model_row(&ink, model, total_cost));
505    }
506    if report.models.len() > rows {
507        out.push_str(&format!(
508            "{}\n",
509            ink.dim_on(format!("… {} more", report.models.len() - rows))
510        ));
511    }
512    out.push_str(&format!(
513        "\n{}\n",
514        ink.dim_on(
515            "ZEN is pay-as-you-go: the same rows carry its spend, but the docs give no limit."
516                .into()
517        )
518    ));
519    // The GO block above is the account's own metering. These two columns are
520    // per model, so say whose they are: with an account answer they are cut at
521    // the server's own window bounds (the same block and Monday-anchored week its
522    // percentages describe), and only without one do they fall back to this
523    // machine's rolling cuts.
524    if report.account.is_some() {
525        out.push_str(&format!(
526            "{}\n",
527            ink.dim_on(
528                "5H/WK columns: this model's own spend inside the account's windows (the same cuts its percentages use)."
529                    .into()
530            )
531        ));
532    } else {
533        out.push_str(&format!(
534            "{}\n",
535            ink.dim_on(
536                "5H/WK columns: this model's own spend over the last 5h / 7 days (local, not the account's windows)."
537                    .into()
538            )
539        ));
540    }
541    out
542}
543
544/// One watch frame: the dashboard, then a burst sparkline when the 5-hour spend
545/// has moved since the previous refresh. No trailing newline — the caller
546/// appends its own status line and `redraw_frame` (cmduse's) parks the cursor
547/// there, which is what lets the countdown rewrite a single line per second.
548pub fn watch_frame(
549    report: &Report,
550    db_path: &str,
551    rows: usize,
552    colour: bool,
553    history: &[f64],
554    interval_secs: u64,
555    bar_width: usize,
556) -> String {
557    let ink = Ink::new(colour);
558    let mut out = render_text(report, db_path, rows, colour, bar_width);
559    if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
560        out.push_str(&format!(
561            "{}spend bursts ({}s samples){} {}\n",
562            ink.dim,
563            interval_secs,
564            ink.reset,
565            crate::render::sparkline(history),
566        ));
567    }
568    out.trim_end_matches('\n').to_string()
569}
570
571/// Minimal one-line dashboard for terminals too small for the full frame:
572/// period spend, rolling windows, and the burst sparkline inlined when active.
573/// No trailing newline — the caller appends its own status line, exactly like
574/// `watch_frame`.
575pub fn compact_line(report: &Report, history: &[f64], colour: bool) -> String {
576    let ink = Ink::new(colour);
577    let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
578        format!(
579            " {}{}{}",
580            ink.dim,
581            crate::render::sparkline(history),
582            ink.reset
583        )
584    } else {
585        String::new()
586    };
587    // The 5-hour figure is the one a narrow terminal shows, and the store's
588    // rolling 5h is not the server's block: prefer the account's own share
589    // whenever the endpoint answered (see `ocuse::api`). The month spend stays
590    // money — it is a budget, not a throttle.
591    let window_share = match &report.account {
592        Some(account) => format!(
593            " · 5h {} · wk {}",
594            ink.cyan_on(pct(account.rolling.percent, 100.0)),
595            ink.cyan_on(pct(account.weekly.percent, 100.0)),
596        ),
597        None => format!(" · 5h {}", ink.cyan_on(money(report.five_hour.cost))),
598    };
599    format!(
600        "{}oc{} {} month{window_share}{spark}",
601        ink.bold,
602        ink.reset,
603        ink.cyan_on(money(report.month.cost)),
604    )
605}
606
607/// ocuse's statusline: cmduse's `{key}` template engine (`report_render::
608/// render_template`) resolved against this report's own placeholders, so both
609/// binaries are driven by `statusline_template` in their config.
610///
611/// Placeholders (case-insensitive):
612///   {plan} {period}                plan id / period label
613///   {month} {month_pct} {month_bar}
614///   {five_hour} {five_hour_pct} {five_hour_bar}   (aliases {5h} {5h_pct} {5h_bar})
615///   {weekly} {weekly_pct} {weekly_bar}            (aliases {wk} {wk_pct} {wk_bar})
616///   {models}                       top model by period spend
617///   | or newline                    literal separators
618/// Unknown placeholders are dropped. Percentages are one decimal; money reuses
619/// `cmduse_core::money`; bars reuse `report_render::bar` (ascii/colour aware).
620pub fn statusline_template(
621    report: &Report,
622    plan_id: &str,
623    tpl: &str,
624    colours: bool,
625    ascii: bool,
626    bar_width: usize,
627) -> String {
628    let ink = Ink::new(colours);
629    let cash = |v: f64| ink.cyan_on(money(v));
630    // The account's own share outranks the local one wherever both exist: it is
631    // what opencode.ai/console shows. Money tokens stay local either way — the
632    // endpoint publishes shares and instants, not dollars. Without an account
633    // there is no pool to divide by (Go grants limits per model), so the share
634    // placeholders degrade to the money-less dash instead of a ratio against a
635    // summed ceiling that reads as a budget the console does not have.
636    let account = report.account.as_ref();
637    let share_of = |meter: Option<&crate::ocuse::api::Meter>| match meter {
638        Some(meter) => pct(meter.percent, 100.0),
639        None => "—".into(),
640    };
641    let share_bar = |meter: Option<&crate::ocuse::api::Meter>| match meter {
642        Some(meter) => crate::report_render::bar(meter.percent, bar_width, ascii, colours),
643        None => String::new(),
644    };
645    let top_model = || {
646        report
647            .models
648            .first()
649            .map(|m| m.id.clone())
650            .unwrap_or_else(|| "—".into())
651    };
652    crate::report_render::render_template(tpl, |key| match key {
653        "plan" => plan_id.to_string(),
654        "period" => report.period_label.clone(),
655        "month" => cash(report.month.cost),
656        "month_pct" => share_of(account.map(|a| &a.monthly)),
657        "month_bar" => share_bar(account.map(|a| &a.monthly)),
658        "five_hour" | "5h" => cash(report.five_hour.cost),
659        "five_hour_pct" | "5h_pct" => share_of(account.map(|a| &a.rolling)),
660        "five_hour_bar" | "5h_bar" => share_bar(account.map(|a| &a.rolling)),
661        "weekly" | "wk" => cash(report.weekly.cost),
662        "weekly_pct" | "wk_pct" => share_of(account.map(|a| &a.weekly)),
663        "weekly_bar" | "wk_bar" => share_bar(account.map(|a| &a.weekly)),
664        "models" => top_model(),
665        _ => String::new(),
666    })
667}
668
669/// `ocuse statusline --json`: the same numbers the template resolves, in one
670/// object — plan and period label, period/month spend, the two rolling windows,
671/// and the top model. The `*Cap` keys are retired (always null): Go grants
672/// limits per model, so no account-wide pool exists, and the summed ceiling
673/// they carried read as a budget the console does not have.
674pub fn statusline_json(report: &Report, plan_id: &str) -> String {
675    serde_json::json!({
676        "plan": plan_id,
677        "period": report.period_label,
678        "monthSpent": report.month.cost,
679        "monthCap": serde_json::Value::Null,
680        "fiveHourSpent": report.five_hour.cost,
681        "fiveHourCap": serde_json::Value::Null,
682        "weeklySpent": report.weekly.cost,
683        "weeklyCap": serde_json::Value::Null,
684        "model": report.models.first().map(|m| m.id.clone()),
685    })
686    .to_string()
687}
688
689/// Machine-readable projection: totals plus the per-model breakdown.
690pub fn render_json(report: &Report) -> String {
691    let totals = |t: &Totals| serde_json::json!({ "cost": t.cost, "requests": t.requests, "tokens": t.tokens() });
692    let model = |m: &ModelUsage| {
693        serde_json::json!({
694            "id": m.id,
695            "provider": m.provider,
696            "limit": m.limit,
697            "month": totals(&m.month),
698            "fiveHour": totals(&m.five_hour),
699            "weekly": totals(&m.weekly),
700        })
701    };
702    // The account's own meters, when the Go usage endpoint answered. Consumers
703    // (the sidebar's Go block among them) should prefer these over the local
704    // `totals`, which cannot reproduce the server's anchors or price basis.
705    let meter = |m: &crate::ocuse::api::Meter| {
706        serde_json::json!({
707            "status": m.status,
708            "percent": m.percent,
709            "resetsAt": m.reset_ms(),
710        })
711    };
712    serde_json::to_string_pretty(&serde_json::json!({
713        "source": "opencode.db",
714        "generatedAt": report.now_ms,
715        "periodStart": report.since_ms,
716        // Period bounds in epoch ms, the same units as cmduse's `-1 --json`
717        // (`periodStartAt`/`periodEndAt`) — `periodStart` stays for the plugin's
718        // existing read. `periodEndAt` is null when the period has no boundary
719        // (`--window all`).
720        "periodStartAt": report.since_ms,
721        "periodEndAt": report.period_end_ms,
722        "periodInferred": report.month_inferred,
723        "totals": {
724            "month": totals(&report.month),
725            "fiveHour": totals(&report.five_hour),
726            "weekly": totals(&report.weekly),
727        },
728        // `null` when no Go key, no network, or a key without the subscription:
729        // the caller then has only `totals`, which is the local estimate.
730        "account": report.account.as_ref().map(|a| serde_json::json!({
731            "rolling": meter(&a.rolling),
732            "weekly": meter(&a.weekly),
733            "monthly": meter(&a.monthly),
734        })),
735        // Why `account` is null, when that is worth saying — `HTTP 401
736        // api_key_expired`, `no Go key`, a transport fault. `null` when the
737        // endpoint answered, and for `--no-api`. Consumers otherwise show the
738        // local estimate without being able to tell the three apart.
739        "accountError": report.account_error,
740        "models": report.models.iter().map(model).collect::<Vec<_>>(),
741    }))
742    .expect("json")
743}
744
745fn shorten(path: &str) -> String {
746    match std::env::var("HOME") {
747        Ok(home) if !home.is_empty() => path.replacen(&home, "~", 1),
748        _ => path.to_string(),
749    }
750}
751
752// ---- catalogues and history tables (shared with the MCP server) -------------
753
754/// The catalogue: what each product costs and (for Go) what each plan allows.
755///
756/// Both Go plans are listed because the docs define two — Go and Go Plus — that
757/// share one token price list but grant each model a different monthly limit.
758pub fn plans_text(colour: bool) -> String {
759    let ink = Ink::new(colour);
760    let catalog = crate::ocuse::zen::catalog();
761    let mut out = format!(
762        "{}OpenCode plans{} {}\n",
763        ink.bold,
764        ink.reset,
765        ink.dim_on(format!("(docs, extracted {})", catalog.extracted_at)),
766    );
767    out.push_str(&format!(
768        "{}windows (both plans): 5h {} · weekly {} · monthly {}{}\n",
769        ink.dim,
770        ink.cyan_on(format!("{:.1}%", catalog.go.window_share.five_hour * 100.0)),
771        ink.cyan_on(format!("{:.1}%", catalog.go.window_share.weekly * 100.0)),
772        ink.cyan_on(format!("{:.1}%", catalog.go.window_share.monthly * 100.0)),
773        ink.reset,
774    ));
775    for plan in catalog.go.plans.values() {
776        out.push_str(&format!(
777            "\n{}{}{} — {}/month\n",
778            ink.bold,
779            plan.label,
780            ink.reset,
781            ink.cyan_on(format!("${:.2}", plan.price_usd)),
782        ));
783        out.push_str(&format!(
784            "{}MODEL                            LIMIT    IN     OUT    CACHE{}\n",
785            ink.bold, ink.reset
786        ));
787        for model in &catalog.go.models {
788            let rate = model.variants.first();
789            let limit = plan
790                .allowance_by_model
791                .get(&model.id)
792                .map(|limit| cmduse_core::money(*limit))
793                .unwrap_or_else(|| "—".into());
794            out.push_str(&format!(
795                "{:<28} {} {} {} {}\n",
796                model.id,
797                ink.cyan_on(format!("{limit:>7}")),
798                ink.cyan_on(format!(
799                    "{:>6}",
800                    rate.map(|r| format!("${}", r.input)).unwrap_or_default()
801                )),
802                ink.cyan_on(format!(
803                    "{:>6}",
804                    rate.map(|r| format!("${}", r.output)).unwrap_or_default()
805                )),
806                ink.cyan_on(format!(
807                    "{:>8}",
808                    rate.map(|r| format!("${}", r.cache_read))
809                        .unwrap_or_default()
810                )),
811            ));
812        }
813    }
814    out.push_str(&format!(
815        "\n{}\n",
816        ink.dim_on(format!(
817            "ZEN — pay-as-you-go, {} models priced per 1M tokens",
818            catalog.zen.models.len()
819        ))
820    ));
821    out
822}
823
824/// The Go/Zen catalogue as one JSON object — the machine twin of `plans_text`:
825/// both plans (label, price, per-model monthly allowances), the shared window
826/// shares, and the Go and Zen rate tables.
827pub fn plans_json() -> String {
828    let catalog = crate::ocuse::zen::catalog();
829    let plans: Vec<serde_json::Value> = catalog
830        .go
831        .plans
832        .iter()
833        .map(|(id, plan)| {
834            serde_json::json!({
835                "id": id,
836                "label": plan.label,
837                "priceUsd": plan.price_usd,
838                "allowanceByModel": &plan.allowance_by_model,
839            })
840        })
841        .collect();
842    serde_json::json!({
843        "extractedAt": catalog.extracted_at,
844        "windows": {
845            "fiveHour": catalog.go.window_share.five_hour,
846            "weekly": catalog.go.window_share.weekly,
847            "monthly": catalog.go.window_share.monthly,
848        },
849        "plans": plans,
850        "models": catalog.go.models.iter().map(rate_json).collect::<Vec<_>>(),
851        "zen": catalog.zen.models.iter().map(rate_json).collect::<Vec<_>>(),
852    })
853    .to_string()
854}
855
856/// One catalogue row's shared `$`/1M rates, taken from its base (first) variant.
857fn rate_json(model: &zen::Model) -> serde_json::Value {
858    let rate = model.variants.first();
859    serde_json::json!({
860        "id": model.id,
861        "name": model.name,
862        "input": rate.map(|r| r.input),
863        "output": rate.map(|r| r.output),
864        "cacheRead": rate.map(|r| r.cache_read),
865        "cacheWrite": rate.map(|r| r.cache_write),
866    })
867}
868
869/// One `ocuse models` row: a catalogued Go model, its rates, and the selected
870/// plan's monthly limit (`None` = the plan does not grant it).
871struct ModelRow {
872    id: String,
873    name: String,
874    input: Option<f64>,
875    output: Option<f64>,
876    cache_read: Option<f64>,
877    limit: Option<f64>,
878}
879
880/// The Go catalogue joined with the plan's allowances; `gated` keeps only the
881/// models the plan grants (present in `allowanceByModel`).
882fn model_rows(plan_id: &str, gated: bool) -> Vec<ModelRow> {
883    let catalog = crate::ocuse::zen::catalog();
884    let plan = catalog.plan(plan_id);
885    let mut rows: Vec<ModelRow> = catalog
886        .go
887        .models
888        .iter()
889        .map(|m| {
890            let rate = m.variants.first();
891            ModelRow {
892                id: m.id.clone(),
893                name: m.name.clone(),
894                input: rate.map(|r| r.input),
895                output: rate.map(|r| r.output),
896                cache_read: rate.map(|r| r.cache_read),
897                limit: plan.and_then(|p| p.allowance_by_model.get(&m.id).copied()),
898            }
899        })
900        .collect();
901    if gated {
902        rows.retain(|r| r.limit.is_some());
903    }
904    rows
905}
906
907/// `ocuse models`: the Go catalogue for one plan — name, id, `$`/1M in / out /
908/// cache-read, and that plan's monthly limit. Without `gated`, every catalogued
909/// model is listed and the `GRANTED` column marks which the plan allows.
910pub fn models_text(plan_id: &str, gated: bool, colour: bool) -> String {
911    let ink = Ink::new(colour);
912    let catalog = crate::ocuse::zen::catalog();
913    let label = catalog
914        .plan(plan_id)
915        .map(|p| p.label.clone())
916        .unwrap_or_else(|| plan_id.to_string());
917    let granted = catalog
918        .plan(plan_id)
919        .map(|p| p.allowance_by_model.len())
920        .unwrap_or(0);
921    let rows = model_rows(plan_id, gated);
922    let mut out = format!(
923        "{}{label} models{} {}\n",
924        ink.bold,
925        ink.reset,
926        ink.dim_on(format!(
927            "(plan {plan_id} · {granted}/{} granted)",
928            catalog.go.models.len()
929        )),
930    );
931    out.push_str(&format!(
932        "{}{:<30} {:<28} {:>7} {:>7} {:>7} {:>8} {:>7}{}\n",
933        ink.bold, "MODEL", "ID", "IN", "OUT", "CACHE", "LIMIT", "GRANTED", ink.reset
934    ));
935    let rate_cell = |v: Option<f64>| match v {
936        Some(v) => ink.cyan_on(format!("{:>7}", format!("${v}"))),
937        None => ink.dim_on(format!("{:>7}", "—")),
938    };
939    for r in &rows {
940        let limit = match r.limit {
941            Some(l) => ink.cyan_on(format!("{:>8}", cmduse_core::money(l))),
942            None => ink.dim_on(format!("{:>8}", "—")),
943        };
944        let granted = if r.limit.is_some() { "yes" } else { "no" };
945        out.push_str(&format!(
946            "{:<30} {:<28} {} {} {} {} {:>7}\n",
947            r.name,
948            r.id,
949            rate_cell(r.input),
950            rate_cell(r.output),
951            rate_cell(r.cache_read),
952            limit,
953            granted,
954        ));
955    }
956    out
957}
958
959/// `ocuse models --json`: one JSON object per line (matching cmduse's
960/// `models --json`), each model's id, name, `$`/1M rates, plan limit (null
961/// when not granted) and the `granted` verdict.
962pub fn models_json(plan_id: &str, gated: bool) -> String {
963    let mut out = String::new();
964    for r in model_rows(plan_id, gated) {
965        out.push_str(
966            &serde_json::json!({
967                "id": r.id,
968                "name": r.name,
969                "input": r.input,
970                "output": r.output,
971                "cacheRead": r.cache_read,
972                "limit": r.limit,
973                "granted": r.limit.is_some(),
974            })
975            .to_string(),
976        );
977        out.push('\n');
978    }
979    out
980}
981
982pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
983    let ink = Ink::new(colour);
984    let Some(model) = report.models.iter().find(|m| m.id == id) else {
985        return format!("{id}: no usage in the period\n");
986    };
987    let limit = model
988        .limit
989        .map(cmduse_core::money)
990        .unwrap_or_else(|| "—".into());
991    let row =
992        |label: &str, value: String| format!("  {}{value}\n", ink.dim_on(format!("{label:<10}")));
993    let mut out = format!(
994        "{} {}\n",
995        ink.bold_on(model.id.clone()),
996        ink.dim_on(format!("({})", model.provider)),
997    );
998    out.push_str(&row("limit", ink.cyan_on(limit)));
999    out.push_str(&row(
1000        "month",
1001        ink.cyan_on(format!(
1002            "{} · {} requests",
1003            cmduse_core::money(model.month.cost),
1004            model.month.requests
1005        )),
1006    ));
1007    out.push_str(&row(
1008        "5-hour",
1009        ink.cyan_on(cmduse_core::money(model.five_hour.cost)),
1010    ));
1011    out.push_str(&row(
1012        "weekly",
1013        ink.cyan_on(cmduse_core::money(model.weekly.cost)),
1014    ));
1015    // The dashboard's share column needs the period total; here it is the same
1016    // fact spelled out. Absent when the period has no spend to divide by.
1017    if report.month.cost > 0.0 {
1018        out.push_str(&row(
1019            "share",
1020            ink.dim_on(format!(
1021                "{:.1}% of period spend",
1022                model.month.cost / report.month.cost * 100.0
1023            )),
1024        ));
1025    }
1026    out
1027}
1028
1029/// Per-key JSON totals — the same field vocabulary cmduse's
1030/// `report_render::totals_json` uses. `cacheWrite` is deliberately absent:
1031/// ocuse's `window::Totals` carries no cache-write (the store's rows do, but
1032/// nothing aggregates them into a report), so no column is invented for it.
1033fn totals_json(
1034    cost: f64,
1035    requests: u64,
1036    input: u64,
1037    output: u64,
1038    cache_read: u64,
1039) -> serde_json::Value {
1040    serde_json::json!({
1041        "requests": requests,
1042        "tokensIn": input,
1043        "tokensOut": output,
1044        "cacheRead": cache_read,
1045        "costUsd": cost,
1046    })
1047}
1048
1049/// One day/hour bucket's totals, kept apart per column so the CSV/JSON forms do
1050/// not have to re-derive them from a packed tuple.
1051#[derive(Default, Clone, Copy)]
1052struct Bucket {
1053    cost: f64,
1054    requests: u64,
1055    input: u64,
1056    output: u64,
1057    cache_read: u64,
1058}
1059
1060impl Bucket {
1061    fn add(&mut self, row: &crate::ocuse::db::Row) {
1062        self.cost += row.cost_usd;
1063        self.requests += 1;
1064        self.input += row.input;
1065        self.output += row.output;
1066        self.cache_read += row.cache_read;
1067    }
1068
1069    fn json(&self) -> serde_json::Value {
1070        totals_json(
1071            self.cost,
1072            self.requests,
1073            self.input,
1074            self.output,
1075            self.cache_read,
1076        )
1077    }
1078}
1079
1080/// Per-model report — `ocuse model` on the whole period (or one id). The
1081/// machine formats project the report's models: `Json` mirrors cmduse's
1082/// `model --json` envelope (`{source, sinceMs, models:{id: totals}}`, with the
1083/// Go limit added), `Csv` is one row per model
1084/// (`model,requests,tokens_in,tokens_out,cache_read,cost_usd`). The `Table` form
1085/// is the caller's — the dashboard (no id) or the single-model detail (with an
1086/// id) — which needs the db path and row cap, so it is rendered there.
1087pub fn model_report(report: &Report, id: Option<&str>, fmt: Fmt) -> String {
1088    let models: Vec<&ModelUsage> = report
1089        .models
1090        .iter()
1091        .filter(|m| id.is_none_or(|want| m.id == want))
1092        .collect();
1093    match fmt {
1094        Fmt::Json => {
1095            let map: serde_json::Map<String, serde_json::Value> = models
1096                .iter()
1097                .map(|m| {
1098                    let mut body = totals_json(
1099                        m.month.cost,
1100                        m.month.requests,
1101                        m.month.input,
1102                        m.month.output,
1103                        m.month.cache_read,
1104                    );
1105                    body["limit"] = serde_json::json!(m.limit);
1106                    (m.id.clone(), body)
1107                })
1108                .collect();
1109            serde_json::json!({
1110                "scope": "local",
1111                "source": "opencode",
1112                "sinceMs": report.since_ms,
1113                "models": map,
1114            })
1115            .to_string()
1116        }
1117        Fmt::Csv => {
1118            let mut out = String::from("model,requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1119            for m in models {
1120                out.push_str(&format!(
1121                    "{},{},{},{},{},{:.2}\n",
1122                    crate::report_render::csv_field(&m.id),
1123                    m.month.requests,
1124                    m.month.input,
1125                    m.month.output,
1126                    m.month.cache_read,
1127                    m.month.cost,
1128                ));
1129            }
1130            out
1131        }
1132        Fmt::Table => String::new(), // the caller renders the table form
1133    }
1134}
1135
1136/// Spend per bucket (day or hour), oldest first — `cmduse daily`/`hourly`.
1137/// `lookback_ms` is the read window and `size_ms` the bucket width (which also
1138/// names the first CSV/JSON column: `day` for a ≥24h bucket, else `hour`). The
1139/// `Csv` columns are that key plus the store's numeric fields
1140/// (`…,requests,tokens_in,tokens_out,cache_read,cost_usd`; no cache-write), the
1141/// `Json` form `{scope,source,total,days|hours}`, and `Table` is the original
1142/// framing but for `Ink`'s colour. `Json`/`Csv` are never coloured.
1143pub fn bucket_text(
1144    rows: &[crate::ocuse::db::Row],
1145    now_ms: i64,
1146    lookback_ms: i64,
1147    size_ms: i64,
1148    tz_secs: i64,
1149    colour: bool,
1150    fmt: Fmt,
1151) -> String {
1152    // Buckets are cut in the display timezone: shift, bucket, then label.
1153    let shift = tz_secs * 1000;
1154    let mut buckets: std::collections::BTreeMap<i64, Bucket> = Default::default();
1155    for row in rows {
1156        let bucket = (row.at_ms + shift).div_euclid(size_ms);
1157        buckets.entry(bucket).or_default().add(row);
1158    }
1159    let start = (now_ms + shift - lookback_ms).div_euclid(size_ms);
1160    let daily = size_ms >= 86_400_000;
1161    let label = if daily { "day" } else { "hour" };
1162    let stamp = |bucket: i64| {
1163        if daily {
1164            date_of(bucket)
1165        } else {
1166            format!(
1167                "{} {:02}h",
1168                date_of(bucket.div_euclid(24)),
1169                bucket.rem_euclid(24)
1170            )
1171        }
1172    };
1173    let window: Vec<(i64, Bucket)> = buckets
1174        .range(start..)
1175        .map(|(bucket, b)| (*bucket, *b))
1176        .collect();
1177
1178    match fmt {
1179        Fmt::Json => {
1180            let mut keyed = serde_json::Map::new();
1181            let mut total = Bucket::default();
1182            for (bucket, b) in &window {
1183                keyed.insert(stamp(*bucket), b.json());
1184                total.add_bucket(b);
1185            }
1186            let key = if daily { "days" } else { "hours" };
1187            serde_json::json!({
1188                "scope": "local",
1189                "source": "opencode",
1190                "total": total.json(),
1191                key: keyed,
1192            })
1193            .to_string()
1194        }
1195        Fmt::Csv => {
1196            let head = if daily { "day" } else { "hour" };
1197            let mut out = format!("{head},requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1198            let mut total = Bucket::default();
1199            for (bucket, b) in &window {
1200                out.push_str(&format!(
1201                    "{},{},{},{},{},{:.2}\n",
1202                    crate::report_render::csv_field(&stamp(*bucket)),
1203                    b.requests,
1204                    b.input,
1205                    b.output,
1206                    b.cache_read,
1207                    b.cost,
1208                ));
1209                total.add_bucket(b);
1210            }
1211            out.push_str(&format!(
1212                "total,{},{},{},{},{:.2}\n",
1213                total.requests, total.input, total.output, total.cache_read, total.cost,
1214            ));
1215            out
1216        }
1217        Fmt::Table => {
1218            let ink = Ink::new(colour);
1219            let mut out = format!(
1220                "{}{label:<12} spend     requests   tokens{}\n",
1221                ink.bold, ink.reset
1222            );
1223            for (bucket, b) in &window {
1224                out.push_str(&format!(
1225                    "{:<12} {} {} {}\n",
1226                    stamp(*bucket),
1227                    ink.cyan_on(format!("{:>9}", cmduse_core::money(b.cost))),
1228                    ink.cyan_on(format!("{:>12}", cmduse_core::compact(b.requests))),
1229                    ink.cyan_on(format!(
1230                        "{:>9}",
1231                        cmduse_core::compact(b.input + b.output + b.cache_read)
1232                    )),
1233                ));
1234            }
1235            if out.lines().count() == 1 {
1236                out.push_str(&format!(
1237                    "{}\n",
1238                    ink.dim_on("(no OpenCode usage in the window)".into())
1239                ));
1240            }
1241            out
1242        }
1243    }
1244}
1245
1246/// One session's accumulating totals: `(cost, requests, tokens_in, tokens_out,
1247/// cache_read, last_ms, distinct models)`.
1248type SessionTotals<'a> = (
1249    f64,
1250    u64,
1251    u64,
1252    u64,
1253    u64,
1254    i64,
1255    std::collections::BTreeSet<&'a str>,
1256);
1257
1258/// Spend per session, newest first. The `Csv` form is one row per session
1259/// (`session,requests,tokens_in,tokens_out,cache_read,cost_usd`), `Json` a
1260/// `{scope,source,sessions}` object whose entries add the last-used instant and
1261/// the distinct-model count; `Table` keeps its own (last, models) columns.
1262pub fn session_text(
1263    rows: &[crate::ocuse::db::Row],
1264    limit: usize,
1265    colour: bool,
1266    fmt: Fmt,
1267) -> String {
1268    let mut by_session: std::collections::BTreeMap<&str, SessionTotals> = Default::default();
1269    for row in rows {
1270        let entry = by_session
1271            .entry(row.session.as_str())
1272            .or_insert_with(|| (0.0, 0, 0, 0, 0, 0, Default::default()));
1273        entry.0 += row.cost_usd;
1274        entry.1 += 1;
1275        entry.2 += row.input;
1276        entry.3 += row.output;
1277        entry.4 += row.cache_read;
1278        entry.5 = entry.5.max(row.at_ms);
1279        entry.6.insert(row.model.as_str());
1280    }
1281    let mut sessions: Vec<_> = by_session.into_iter().collect();
1282    sessions.sort_by_key(|(_, (_, _, _, _, _, last, _))| std::cmp::Reverse(*last));
1283    sessions.truncate(limit);
1284
1285    match fmt {
1286        Fmt::Json => {
1287            let mut keyed = serde_json::Map::new();
1288            for (id, (cost, requests, input, output, cache_read, last, models)) in &sessions {
1289                let mut body = totals_json(*cost, *requests, *input, *output, *cache_read);
1290                body["lastMs"] = serde_json::json!(*last);
1291                body["models"] = serde_json::json!(models.len());
1292                keyed.insert((*id).to_string(), body);
1293            }
1294            serde_json::json!({
1295                "scope": "local",
1296                "source": "opencode",
1297                "sessions": keyed,
1298            })
1299            .to_string()
1300        }
1301        Fmt::Csv => {
1302            let mut out =
1303                String::from("session,requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1304            for (id, (cost, requests, input, output, cache_read, _, _)) in &sessions {
1305                out.push_str(&format!(
1306                    "{},{},{},{},{},{:.2}\n",
1307                    crate::report_render::csv_field(id),
1308                    requests,
1309                    input,
1310                    output,
1311                    cache_read,
1312                    cost,
1313                ));
1314            }
1315            out
1316        }
1317        Fmt::Table => {
1318            let ink = Ink::new(colour);
1319            let mut out = format!(
1320                "{}session                          last        models   spend     req{}\n",
1321                ink.bold, ink.reset
1322            );
1323            for (id, (cost, requests, _, _, _, last, models)) in sessions {
1324                let short = id.rsplit('_').next().unwrap_or(id);
1325                out.push_str(&format!(
1326                    "{:<32} {:<11} {:>6} {} {:>6}\n",
1327                    short,
1328                    ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
1329                    models.len(),
1330                    ink.cyan_on(format!("{:>9}", cmduse_core::money(cost))),
1331                    requests,
1332                ));
1333            }
1334            if out.lines().count() == 1 {
1335                out.push_str(&format!(
1336                    "{}\n",
1337                    ink.dim_on("(no sessions in the window)".into())
1338                ));
1339            }
1340            out
1341        }
1342    }
1343}
1344
1345impl Bucket {
1346    /// Fold another bucket's totals in (for the report's `total` row/entry).
1347    fn add_bucket(&mut self, other: &Bucket) {
1348        self.cost += other.cost;
1349        self.requests += other.requests;
1350        self.input += other.input;
1351        self.output += other.output;
1352        self.cache_read += other.cache_read;
1353    }
1354}
1355
1356/// `YYYY-MM-DD` for a Unix day number (civil-from-days, same helper family as
1357/// the window module's).
1358pub fn date_of(day: i64) -> String {
1359    let z = day + 719_468;
1360    let era = z.div_euclid(146_097);
1361    let doe = z.rem_euclid(146_097);
1362    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1363    let y = yoe + era * 400;
1364    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1365    let mp = (5 * doy + 2) / 153;
1366    let d = doy - (153 * mp + 2) / 5 + 1;
1367    let m = if mp < 10 { mp + 3 } else { mp - 9 };
1368    let year = if m <= 2 { y + 1 } else { y };
1369    format!("{year:04}-{m:02}-{d:02}")
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374    use super::*;
1375    use crate::ocuse::db::Row;
1376
1377    fn report() -> Report {
1378        let now = 1_800_000_000_000;
1379        let rows = vec![Row {
1380            at_ms: now - 60_000,
1381            provider: "opencode-go".into(),
1382            model: "glm-5.3-flash".into(),
1383            cost_usd: 30.0,
1384            ..Default::default()
1385        }];
1386        crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None, None)
1387    }
1388
1389    #[test]
1390    fn text_shows_the_model_against_its_limit() {
1391        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1392        assert!(text.contains("glm-5.3-flash"), "{text}");
1393        assert!(text.contains("$60"), "the model's monthly limit: {text}");
1394        assert!(
1395            text.contains("50.0%"),
1396            "the model table keeps its own USE: {text}"
1397        );
1398    }
1399
1400    fn meter(status: &str, percent: f64, resets_at: &str) -> crate::ocuse::api::Meter {
1401        crate::ocuse::api::Meter {
1402            status: status.into(),
1403            percent,
1404            resets_at: Some(resets_at.into()),
1405        }
1406    }
1407
1408    /// The same report with the account's own meters attached — the shape a Go
1409    /// key produces. Reset instants sit after `now` (2027-01-15T08:00:00Z), so
1410    /// the countdowns render rather than clamp to zero. The period bounds mirror
1411    /// what `cli.rs` derives from the monthly reset (`prev_month`), so the
1412    /// Monthly row carries the same elapsed share and pro-rata figure a live
1413    /// dashboard shows.
1414    fn report_with_account() -> Report {
1415        let mut report = report();
1416        report.account = Some(crate::ocuse::api::Usage {
1417            rolling: meter("ok", 5.0, "2027-01-15T12:31:00.000Z"),
1418            weekly: meter("ok", 59.0, "2027-01-18T00:00:00.000Z"),
1419            monthly: meter("ok", 29.0, "2027-02-15T08:00:00.000Z"),
1420        });
1421        report.period_end_ms = Some(1_802_678_400_000); // 2027-02-15T08:00:00Z
1422        report.since_ms = crate::ocuse::window::prev_month(1_802_678_400_000);
1423        report
1424    }
1425
1426    #[test]
1427    fn the_window_rows_come_from_the_account_when_it_answered() {
1428        let text = render_text(
1429            &report_with_account(),
1430            "/tmp/opencode.db",
1431            10,
1432            false,
1433            DEFAULT_BAR_WIDTH,
1434        );
1435        // The console's three shares, and its reset instants. Whole percents, as
1436        // the endpoint publishes them: `"percent":53`, never `53.0`.
1437        assert!(text.contains("· 5% of limit"), "{text}");
1438        assert!(text.contains("· 59% of limit"), "{text}");
1439        assert!(text.contains("· 29% of limit"), "{text}");
1440        assert!(text.contains("account meters"), "{text}");
1441        assert!(
1442            text.contains("resets 4h 31m"),
1443            "the 5h block's own end: {text}"
1444        );
1445        // Every field that is true stays on the row: this store's own money and
1446        // the server's share labelled beside it. No denominator is printed — it
1447        // would have to be `spend / share`, which contradicted the documented
1448        // allowance ($40 next to an $80 limit) because the console meters at
1449        // roughly twice the cost this store records.
1450        assert!(text.contains(".00 · 5% of limit"), "{text}");
1451        assert!(text.contains(".00 · 29% of limit"), "{text}");
1452        assert!(text.contains("of limit"), "{text}");
1453        // The monthly window measures against the period bounds, like cmduse's:
1454        // an elapsed share, the server's reset, and the even-burn figure — which
1455        // only the budget window gets, because the two throttles above it would
1456        // read it as a spend target.
1457        assert!(text.contains("elapsed"), "{text}");
1458        assert_eq!(text.matches("by now").count(), 1, "{text}");
1459        // …and the per-model columns are named as the same cuts, not a second
1460        // measurement of a different window.
1461        assert!(text.contains("the same cuts its percentages use"), "{text}");
1462    }
1463
1464    /// The denominator is the server's own percentage read backwards: whatever
1465    /// this store spent, divided by the share the console prints, is the ceiling
1466    /// that share is metering against. A share too small to imply one (the
1467    /// endpoint rounds to one decimal, and sends 0.0 for a window it meters on
1468    /// another basis) leaves the row with the share alone, never a division by
1469    /// it.
1470    #[test]
1471    fn the_pair_is_the_ceiling_the_share_implies() {
1472        // The $30 fixture spend at each of the fixture's shares.
1473        assert_eq!(implied_cap(30.0, 5.0), Some(600.0));
1474        assert_eq!(implied_cap(30.0, 59.0), Some(50.847457627118644));
1475        // Under the display's precision there is no ceiling to print.
1476        assert_eq!(implied_cap(20.45, 0.0), None);
1477        assert_eq!(implied_cap(20.45, 0.04), None);
1478        assert_eq!(implied_cap(20.45, 0.1), Some(20450.0));
1479        assert_eq!(implied_cap(0.0, 1.0), Some(0.0));
1480    }
1481
1482    /// A pace line needs a cap the burn can actually reach before the reset.
1483    #[test]
1484    fn a_reachable_cap_gets_the_pace_line() {
1485        let mut report = report_with_account();
1486        if let Some(account) = &mut report.account {
1487            // 90% of the ceiling this store's $30 implies ($33.33), one hour into
1488            // a 5-hour window with four left: the burn reaches it well inside.
1489            account.rolling = meter("ok", 90.0, "2027-01-15T12:00:00.000Z");
1490        }
1491        let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1492        assert!(text.contains("on pace to hit cap in"), "{text}");
1493    }
1494
1495    #[test]
1496    fn without_an_account_the_rows_say_they_are_local() {
1497        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1498        assert!(text.contains("estimated locally"), "{text}");
1499        assert!(text.contains("no pool"), "no aggregate ceiling: {text}");
1500        // Spend alone: no pair, no share, no countdown without a boundary.
1501        assert!(!text.contains("of limit"), "{text}");
1502        assert!(!text.contains("resets"), "{text}");
1503        assert!(!text.contains("elapsed"), "{text}");
1504        // Nothing claims to be the account's own view.
1505        assert!(!text.contains("account meters"), "{text}");
1506        assert!(
1507            !text.contains("the same cuts its percentages use"),
1508            "{text}"
1509        );
1510        // Without an answer the columns keep saying they are this machine's own
1511        // rolling cuts, since there is no server window to align them to.
1512        assert!(text.contains("local, not the account's windows"), "{text}");
1513    }
1514
1515    #[test]
1516    fn an_over_limit_meter_says_so_before_its_countdown() {
1517        let mut report = report_with_account();
1518        if let Some(account) = &mut report.account {
1519            account.rolling = meter("rate_limited", 100.0, "2027-01-15T12:31:00.000Z");
1520        }
1521        let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1522        assert!(text.contains("LIMIT EXCEEDED"), "{text}");
1523    }
1524
1525    #[test]
1526    fn json_carries_the_account_meters_and_null_without_them() {
1527        let parsed: serde_json::Value =
1528            serde_json::from_str(&render_json(&report_with_account())).unwrap();
1529        assert_eq!(parsed["account"]["rolling"]["percent"], 5.0);
1530        assert_eq!(parsed["account"]["weekly"]["percent"], 59.0);
1531        assert_eq!(parsed["account"]["monthly"]["status"], "ok");
1532        // `resetsAt` in epoch ms (the same units as `periodEndAt`), not the ISO
1533        // string the endpoint sent; `api::tests` pins the literal itself.
1534        let monthly = cmduse_core::dates::parse_iso_utc("2027-02-15T08:00:00.000Z").unwrap();
1535        assert_eq!(parsed["account"]["monthly"]["resetsAt"], monthly as i64);
1536
1537        // No account: the key is present and null, so a consumer can tell the
1538        // difference between "no key" and "zero".
1539        let parsed: serde_json::Value = serde_json::from_str(&render_json(&report())).unwrap();
1540        assert_eq!(parsed["account"], serde_json::Value::Null);
1541    }
1542
1543    #[test]
1544    fn statusline_prefers_the_accounts_share_over_the_local_estimate() {
1545        let tpl = "{five_hour_pct} {weekly_pct} {month_pct} {five_hour}";
1546        let account = statusline_template(
1547            &report_with_account(),
1548            "go",
1549            tpl,
1550            false,
1551            false,
1552            DEFAULT_BAR_WIDTH,
1553        );
1554        assert!(account.starts_with("5.0% 59.0% 29.0%"), "{account}");
1555        // Money stays local either way: the endpoint publishes shares, not dollars.
1556        assert!(account.contains("$30.00"), "{account}");
1557
1558        // Without an account the same tokens resolve against the store, which is
1559        // what makes the fallback worth having.
1560        let local = statusline_template(&report(), "go", tpl, false, false, DEFAULT_BAR_WIDTH);
1561        assert!(!local.starts_with("5.0%"), "{local}");
1562    }
1563
1564    #[test]
1565    fn json_and_statusline_carry_the_totals() {
1566        let json = render_json(&report());
1567        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1568        assert_eq!(parsed["totals"]["month"]["cost"], 30.0);
1569        assert_eq!(parsed["models"][0]["id"], "glm-5.3-flash");
1570        let line = statusline_template(&report(), "go", "{plan} {models}", false, false, 20);
1571        assert_eq!(line, "go glm-5.3-flash");
1572    }
1573
1574    #[test]
1575    fn json_carries_the_period_bounds() {
1576        // The default/month report has an end resolved from its own start, and
1577        // `periodStartAt` is the alias of the existing `periodStart`.
1578        let mut r = report();
1579        r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1580        let parsed: serde_json::Value = serde_json::from_str(&render_json(&r)).unwrap();
1581        assert_eq!(parsed["periodStart"], r.since_ms);
1582        assert_eq!(parsed["periodStartAt"], r.since_ms);
1583        assert_eq!(parsed["periodEndAt"], r.period_end_ms.unwrap());
1584
1585        // `--window all` resolves no end: the key is present and null, and the
1586        // old keys are unchanged.
1587        let mut all = report();
1588        all.period_end_ms = None;
1589        let parsed: serde_json::Value = serde_json::from_str(&render_json(&all)).unwrap();
1590        assert_eq!(parsed["periodEndAt"], serde_json::Value::Null);
1591        assert_eq!(parsed["periodStart"], all.since_ms);
1592        assert!(parsed.get("periodInferred").is_some());
1593    }
1594
1595    /// Every renderer, both ways round: piped/non-tty output must stay free of
1596    /// SGR escapes, and every one of them must colour when colour is on. This is
1597    /// the guard for the bug where only `render_text` ever got a colour flag, so
1598    /// `daily` / `hourly` / `session` / `plans` / `model` / `statusline` printed
1599    /// plain regardless of the terminal.
1600    #[test]
1601    fn every_renderer_respects_the_colour_flag() {
1602        let mut report = report();
1603        // Exercise the monthly row's real period (reset, elapsed, by-now) both
1604        // ways round, so a colour leak into the new plain fields fails here too.
1605        report.period_end_ms = Some(crate::ocuse::window::next_month(report.since_ms));
1606        let rows = vec![Row {
1607            at_ms: report.now_ms - 60_000,
1608            session: "ses_fixture".into(),
1609            provider: "opencode-go".into(),
1610            model: "glm-5.3-flash".into(),
1611            cost_usd: 30.0,
1612            ..Default::default()
1613        }];
1614        let now = report.now_ms;
1615
1616        let cases: Vec<(&str, String, String)> = vec![
1617            (
1618                "render_text",
1619                render_text(&report, "/tmp/oc.db", 10, false, DEFAULT_BAR_WIDTH),
1620                render_text(&report, "/tmp/oc.db", 10, true, DEFAULT_BAR_WIDTH),
1621            ),
1622            (
1623                "statusline_template",
1624                statusline_template(
1625                    &report,
1626                    "go",
1627                    "{period} {month} {month_pct}",
1628                    false,
1629                    false,
1630                    20,
1631                ),
1632                statusline_template(
1633                    &report,
1634                    "go",
1635                    "{period} {month} {month_pct}",
1636                    true,
1637                    false,
1638                    20,
1639                ),
1640            ),
1641            (
1642                "compact_line",
1643                compact_line(&report, &[], false),
1644                compact_line(&report, &[], true),
1645            ),
1646            ("plans_text", plans_text(false), plans_text(true)),
1647            (
1648                "model_text",
1649                model_text(&report, "glm-5.3-flash", false),
1650                model_text(&report, "glm-5.3-flash", true),
1651            ),
1652            (
1653                "bucket_text/day",
1654                bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Table),
1655                bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, true, Fmt::Table),
1656            ),
1657            (
1658                "bucket_text/hour",
1659                bucket_text(&rows, now, 3_600_000, 3_600_000, 0, false, Fmt::Table),
1660                bucket_text(&rows, now, 3_600_000, 3_600_000, 0, true, Fmt::Table),
1661            ),
1662            (
1663                "session_text",
1664                session_text(&rows, 20, false, Fmt::Table),
1665                session_text(&rows, 20, true, Fmt::Table),
1666            ),
1667            (
1668                "models_text",
1669                models_text("go", false, false),
1670                models_text("go", false, true),
1671            ),
1672        ];
1673
1674        for (name, plain, coloured) in cases {
1675            assert!(
1676                !plain.contains('\x1b'),
1677                "{name} leaked escapes when off: {plain}"
1678            );
1679            assert!(
1680                coloured.contains('\x1b'),
1681                "{name} rendered nothing coloured when on: {coloured}"
1682            );
1683        }
1684
1685        // Machine formats must ignore the colour flag entirely: no escapes
1686        // whether it is off or on.
1687        let flagged: Vec<(&str, String, String)> = vec![
1688            (
1689                "bucket_text/day csv",
1690                bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Csv),
1691                bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, true, Fmt::Csv),
1692            ),
1693            (
1694                "bucket_text/hour json",
1695                bucket_text(&rows, now, 3_600_000, 3_600_000, 0, false, Fmt::Json),
1696                bucket_text(&rows, now, 3_600_000, 3_600_000, 0, true, Fmt::Json),
1697            ),
1698            (
1699                "session_text csv",
1700                session_text(&rows, 20, false, Fmt::Csv),
1701                session_text(&rows, 20, true, Fmt::Csv),
1702            ),
1703            (
1704                "session_text json",
1705                session_text(&rows, 20, false, Fmt::Json),
1706                session_text(&rows, 20, true, Fmt::Json),
1707            ),
1708        ];
1709        for (name, off, on) in flagged {
1710            assert!(
1711                !off.contains('\x1b'),
1712                "{name} leaked escapes with colour off: {off}"
1713            );
1714            assert!(
1715                !on.contains('\x1b'),
1716                "{name} must stay uncoloured with colour on: {on}"
1717            );
1718        }
1719        for (name, out) in [
1720            ("model_report json", model_report(&report, None, Fmt::Json)),
1721            ("model_report csv", model_report(&report, None, Fmt::Csv)),
1722            ("plans_json", plans_json()),
1723            ("statusline_json", statusline_json(&report, "go")),
1724            ("models_json", models_json("go", false)),
1725        ] {
1726            assert!(
1727                !out.contains('\x1b'),
1728                "{name} must never be coloured: {out}"
1729            );
1730        }
1731    }
1732
1733    #[test]
1734    fn bucket_reports_carry_the_documented_columns() {
1735        let now = 1_800_000_000_000;
1736        let rows = vec![Row {
1737            at_ms: now - 60_000,
1738            provider: "opencode-go".into(),
1739            model: "glm-5.3-flash".into(),
1740            cost_usd: 1.5,
1741            input: 10,
1742            output: 20,
1743            cache_read: 30,
1744            cache_write: 40,
1745            ..Default::default()
1746        }];
1747        let csv = bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Csv);
1748        assert!(
1749            csv.starts_with("day,requests,tokens_in,tokens_out,cache_read,cost_usd\n"),
1750            "{csv}"
1751        );
1752        // cache-write is absent from the columns by design; the total row closes.
1753        assert!(!csv.contains("cache_write"), "{csv}");
1754        assert!(csv.contains(",10,20,30,1.50\n"), "{csv}");
1755        assert!(csv.trim_end().ends_with("total,1,10,20,30,1.50"), "{csv}");
1756
1757        let json: serde_json::Value = serde_json::from_str(&bucket_text(
1758            &rows,
1759            now,
1760            7 * 86_400_000,
1761            86_400_000,
1762            0,
1763            false,
1764            Fmt::Json,
1765        ))
1766        .unwrap();
1767        assert_eq!(json["total"]["requests"], 1);
1768        assert_eq!(json["total"]["cacheRead"], 30);
1769        assert_eq!(json["days"].as_object().unwrap().len(), 1);
1770    }
1771
1772    #[test]
1773    fn session_report_json_and_csv_agree_with_the_store() {
1774        let now = 1_800_000_000_000;
1775        let rows = vec![
1776            Row {
1777                at_ms: now - 60_000,
1778                session: "ses_first".into(),
1779                model: "glm-5.3-flash".into(),
1780                cost_usd: 2.0,
1781                input: 5,
1782                output: 6,
1783                cache_read: 7,
1784                ..Default::default()
1785            },
1786            Row {
1787                at_ms: now - 30_000,
1788                session: "ses_second".into(),
1789                model: "kimi-k3".into(),
1790                cost_usd: 1.0,
1791                ..Default::default()
1792            },
1793        ];
1794        let json: serde_json::Value =
1795            serde_json::from_str(&session_text(&rows, 20, false, Fmt::Json)).unwrap();
1796        assert_eq!(json["sessions"]["ses_first"]["tokensIn"], 5);
1797        assert_eq!(json["sessions"]["ses_first"]["models"], 1);
1798        assert_eq!(json["sessions"]["ses_second"]["costUsd"], 1.0);
1799        let csv = session_text(&rows, 20, false, Fmt::Csv);
1800        assert!(
1801            csv.starts_with("session,requests,tokens_in,tokens_out,cache_read,cost_usd\n"),
1802            "{csv}"
1803        );
1804        assert!(csv.contains("ses_second,1,0,0,0,1.00\n"), "{csv}");
1805    }
1806
1807    #[test]
1808    fn model_report_projects_the_report_models() {
1809        let json: serde_json::Value =
1810            serde_json::from_str(&model_report(&report(), None, Fmt::Json)).unwrap();
1811        assert_eq!(json["source"], "opencode");
1812        assert_eq!(json["models"]["glm-5.3-flash"]["costUsd"], 30.0);
1813        assert_eq!(json["models"]["glm-5.3-flash"]["limit"], 60.0);
1814        let csv = model_report(&report(), None, Fmt::Csv);
1815        assert!(csv.contains("glm-5.3-flash,1,0,0,0,30.00\n"), "{csv}");
1816        // An id that never ran yields just the header, not a stray row.
1817        assert_eq!(
1818            model_report(&report(), Some("nope"), Fmt::Csv),
1819            "model,requests,tokens_in,tokens_out,cache_read,cost_usd\n"
1820        );
1821    }
1822
1823    #[test]
1824    fn catalogue_json_carries_both_plans_and_windows() {
1825        let json: serde_json::Value = serde_json::from_str(&plans_json()).unwrap();
1826        assert_eq!(json["windows"]["fiveHour"], 0.2);
1827        assert_eq!(json["windows"]["monthly"], 1.0);
1828        assert_eq!(json["plans"][0]["id"], "go");
1829        assert_eq!(json["plans"][0]["allowanceByModel"]["glm-5.3-flash"], 60.0);
1830        assert_eq!(json["plans"][1]["priceUsd"], 40.0);
1831        assert!(!json["models"].as_array().unwrap().is_empty());
1832        assert!(!json["zen"].as_array().unwrap().is_empty());
1833    }
1834
1835    #[test]
1836    fn models_lists_the_plan_and_marks_granted() {
1837        // The ungated table lists every catalogued model and marks the granted.
1838        let all = models_text("go", false, false);
1839        assert!(all.contains("glm-5.3-flash"), "{all}");
1840        assert!(all.contains("GRANTED"), "{all}");
1841        // The gated list keeps only what the plan grants.
1842        let gated = models_text("go", true, false);
1843        for line in gated.lines().skip(2) {
1844            assert!(
1845                line.trim_end().ends_with("yes"),
1846                "ungranted row kept: {line}"
1847            );
1848        }
1849        // JSON: one object per line, and gated drops the ungranted.
1850        let every: Vec<serde_json::Value> = models_json("go", false)
1851            .lines()
1852            .map(|l| serde_json::from_str(l).unwrap())
1853            .collect();
1854        assert!(every.iter().any(|m| m["granted"] == false));
1855        assert!(every
1856            .iter()
1857            .any(|m| m["id"] == "glm-5.3-flash" && m["limit"] == 60.0));
1858        let only: Vec<serde_json::Value> = models_json("go", true)
1859            .lines()
1860            .map(|l| serde_json::from_str(l).unwrap())
1861            .collect();
1862        assert!(!only.is_empty());
1863        assert!(only.iter().all(|m| m["granted"] == true));
1864    }
1865
1866    #[test]
1867    fn statusline_json_mirrors_the_template_numbers() {
1868        let json: serde_json::Value =
1869            serde_json::from_str(&statusline_json(&report(), "go")).unwrap();
1870        assert_eq!(json["plan"], "go");
1871        assert_eq!(json["period"], "calendar month");
1872        assert_eq!(json["monthSpent"], 30.0);
1873        // No account-wide pool exists (limits are per model), so the cap keys
1874        // stay null; the per-model limits live on the models array.
1875        assert_eq!(json["monthCap"], serde_json::Value::Null);
1876        assert_eq!(json["fiveHourCap"], serde_json::Value::Null);
1877        assert_eq!(json["weeklyCap"], serde_json::Value::Null);
1878        assert_eq!(json["model"], "glm-5.3-flash");
1879    }
1880
1881    #[test]
1882    fn plain_frames_carry_no_escapes() {
1883        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1884        assert!(
1885            !text.contains('\x1b'),
1886            "piped output must stay plain: {text}"
1887        );
1888        // The header rows carry no gauge and no share without an account; the
1889        // plain frame keeps the money the colour frame shows.
1890        assert!(text.contains("$30.00"), "{text}");
1891    }
1892
1893    /// The monthly row is the only one with a real period end, so it alone gains
1894    /// the reset and the elapsed share; the rolling windows have no boundary
1895    /// and must not invent one. Nothing divides by a pool: limits are per model.
1896    #[test]
1897    fn monthly_row_carries_the_real_period_metrics() {
1898        let mut r = report();
1899        r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1900        let text = render_text(&r, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1901
1902        let monthly = text
1903            .lines()
1904            .find(|line| line.trim_start().starts_with("Monthly"))
1905            .expect("the monthly row");
1906        assert!(monthly.contains("resets"), "{monthly}");
1907        assert!(
1908            monthly.contains("window") && monthly.contains("% elapsed"),
1909            "the real period gives an elapsed share: {monthly}"
1910        );
1911
1912        for label in ["5-hour", "Weekly"] {
1913            let row = text
1914                .lines()
1915                .find(|line| line.trim_start().starts_with(label))
1916                .unwrap_or_else(|| panic!("the {label} row"));
1917            assert!(!row.contains("elapsed"), "{label} has no boundary: {row}");
1918            assert!(!row.contains("resets"), "{label} has no boundary: {row}");
1919        }
1920    }
1921
1922    /// The period header splits tokens like cmduse and names the resolved end
1923    /// date when the period has one (`--window all` has none).
1924    #[test]
1925    fn period_line_splits_tokens_and_shows_the_end_date() {
1926        let mut r = report();
1927        r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1928        let text = render_text(&r, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1929        let header = text
1930            .lines()
1931            .find(|line| line.starts_with("period:"))
1932            .expect("the period line");
1933        assert!(
1934            header.contains(" in / "),
1935            "token split like cmduse: {header}"
1936        );
1937        assert!(header.contains(" out tokens"), "{header}");
1938        assert!(
1939            header.contains(&date_of(r.period_end_ms.unwrap() / 86_400_000)),
1940            "the resolved end date: {header}"
1941        );
1942
1943        // No end resolved: no date is invented.
1944        let no_end = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1945        let header = no_end
1946            .lines()
1947            .find(|line| line.starts_with("period:"))
1948            .expect("the period line");
1949        assert!(!header.contains("ends"), "{header}");
1950    }
1951
1952    #[test]
1953    fn an_over_cap_window_is_flagged_in_both_modes() {
1954        // 30 spent against a 5-hour cap of 20% of the $60 limit = $12.
1955        let plain = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1956        assert!(plain.contains("LIMIT EXCEEDED"), "{plain}");
1957        let coloured = render_text(&report(), "/tmp/opencode.db", 10, true, DEFAULT_BAR_WIDTH);
1958        assert!(coloured.contains("LIMIT EXCEEDED"), "{coloured}");
1959        assert!(
1960            coloured.contains(crate::render::RED),
1961            "the flag and the gauge are red: {coloured}"
1962        );
1963    }
1964
1965    #[test]
1966    fn a_model_over_any_of_its_own_windows_is_flagged() {
1967        // The fixture is at 50% of the month but over the 5-hour share, and Go
1968        // meters per model per window: the row must say so, not just glow red.
1969        let plain = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1970        let row = plain
1971            .lines()
1972            .find(|line| line.starts_with("glm-5.3-flash"))
1973            .expect("the model row");
1974        assert!(row.contains("LIMIT EXCEEDED"), "{row}");
1975        // Share last, flag early: the informational column is clipped first.
1976        assert!(row.trim_end().ends_with('%'), "share ends the row: {row}");
1977    }
1978
1979    #[test]
1980    fn a_model_within_its_limits_is_not_flagged() {
1981        let now = 1_800_000_000_000;
1982        // $5 against a $60 limit: every window is comfortably inside its share.
1983        let rows = vec![Row {
1984            at_ms: now - 60_000,
1985            provider: "opencode-go".into(),
1986            model: "glm-5.3-flash".into(),
1987            cost_usd: 5.0,
1988            ..Default::default()
1989        }];
1990        let report =
1991            crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None, None);
1992        for colour in [false, true] {
1993            let text = render_text(&report, "/tmp/opencode.db", 10, colour, DEFAULT_BAR_WIDTH);
1994            assert!(!text.contains("LIMIT EXCEEDED"), "colour={colour}: {text}");
1995        }
1996    }
1997
1998    #[test]
1999    fn the_model_detail_spells_out_the_share() {
2000        let detail = model_text(&report(), "glm-5.3-flash", false);
2001        assert!(detail.contains("100.0% of period spend"), "{detail}");
2002        // A period with nothing to divide by omits the row rather than printing NaN.
2003        let empty = crate::ocuse::window::build(
2004            &[],
2005            &zen::catalog(),
2006            zen::DEFAULT_PLAN,
2007            1_800_000_000_000,
2008            None,
2009            None,
2010        );
2011        assert!(!model_text(&empty, "glm-5.3-flash", false).contains("share"));
2012    }
2013
2014    #[test]
2015    fn colour_frames_get_the_palette_and_a_gauge() {
2016        // Gauge bars exist only where a share gates: the account's own rows.
2017        // The local estimate divides by no pool, so its rows carry no bar —
2018        // spend alone, never a gauge against a fiction. Palette assertions live
2019        // on the model table, whose per-model limits are real.
2020        let text = render_text(
2021            &report_with_account(),
2022            "/tmp/opencode.db",
2023            10,
2024            true,
2025            DEFAULT_BAR_WIDTH,
2026        );
2027        assert!(text.contains(crate::render::BOLD), "{text}");
2028        assert!(text.contains(crate::render::CYAN), "{text}");
2029        assert!(
2030            text.contains(crate::render::GREEN),
2031            "the 50% USE cell is green: {text}"
2032        );
2033        assert!(
2034            text.contains('━') && text.contains('╱'),
2035            "the account share's gauge bar: {text}"
2036        );
2037        assert!(text.contains("resets in"), "{text}");
2038    }
2039
2040    #[test]
2041    fn watch_frame_ends_on_the_status_line_and_shows_bursts() {
2042        let frame = watch_frame(
2043            &report(),
2044            "/tmp/opencode.db",
2045            10,
2046            true,
2047            &[0.0, 1.5, 0.0],
2048            10,
2049            DEFAULT_BAR_WIDTH,
2050        );
2051        assert!(
2052            !frame.ends_with('\n'),
2053            "the caller's status line owns the last row"
2054        );
2055        assert!(frame.contains("spend bursts (10s samples)"), "{frame}");
2056        assert!(
2057            frame.contains('█'),
2058            "a nonzero delta shows as a tall bar: {frame}"
2059        );
2060    }
2061
2062    #[test]
2063    fn idle_history_omits_the_sparkline() {
2064        let all_idle = watch_frame(
2065            &report(),
2066            "/tmp/opencode.db",
2067            10,
2068            true,
2069            &[0.0, 0.0],
2070            10,
2071            DEFAULT_BAR_WIDTH,
2072        );
2073        assert!(!all_idle.contains("spend bursts"), "{all_idle}");
2074        let first_frame = watch_frame(
2075            &report(),
2076            "/tmp/opencode.db",
2077            10,
2078            true,
2079            &[],
2080            10,
2081            DEFAULT_BAR_WIDTH,
2082        );
2083        assert!(!first_frame.contains("spend bursts"), "{first_frame}");
2084    }
2085
2086    /// Every statusline placeholder resolves, unknown ones are dropped, and
2087    /// colours off means no SGR at all. The default template is the one the
2088    /// config ships, so it must render end to end.
2089    #[test]
2090    fn statusline_resolves_every_placeholder() {
2091        let r = report();
2092        let line = |tpl: &str| statusline_template(&r, "go", tpl, false, false, 20);
2093
2094        // {plan} {period} are plain text
2095        assert_eq!(line("{plan}"), "go");
2096        assert_eq!(line("{period}"), "calendar month");
2097        // money placeholders reuse cmduse_core::money
2098        assert_eq!(line("{month}"), "$30.00");
2099        assert_eq!(line("{five_hour}"), "$30.00");
2100        assert_eq!(line("{weekly}"), "$30.00");
2101        // {models} is the top model by spend
2102        assert_eq!(line("{models}"), "glm-5.3-flash");
2103        // without an account there is no pool to divide by (limits are per
2104        // model), so the share placeholders degrade to the dash; the account's
2105        // own shares resolve when it answers (pinned by the account test above)
2106        assert_eq!(line("{month_pct}"), "—");
2107        assert_eq!(line("{month_bar}"), "");
2108        assert_eq!(line("{five_hour_pct}"), "—");
2109        assert_eq!(line("{weekly_pct}"), "—");
2110
2111        // the default template resolves as a whole
2112        let default = line("{period} {month} · 5h {five_hour} · wk {weekly}");
2113        assert_eq!(default, "calendar month $30.00 · 5h $30.00 · wk $30.00");
2114
2115        // unknown placeholders drop; text and separators pass through
2116        assert_eq!(line("{plan} {bogus} | tail"), "go  | tail");
2117        // unclosed brace is emitted verbatim
2118        assert_eq!(line("{plan} {oops"), "go {oops");
2119        // with colours off there is no escape anywhere
2120        assert!(!line("{plan} {month} {month_pct} {month_bar}").contains('\x1b'));
2121    }
2122
2123    /// A period with no spend resolves no shares either way, so the pct
2124    /// placeholders degrade to the money-less dash instead of NaN.
2125    #[test]
2126    fn statusline_without_caps_degrades_gracefully() {
2127        let empty = crate::ocuse::window::build(
2128            &[],
2129            &zen::catalog(),
2130            zen::DEFAULT_PLAN,
2131            1_800_000_000_000,
2132            None,
2133            None,
2134        );
2135        let line = statusline_template(&empty, "go", "{month_pct} {models}", false, false, 20);
2136        assert_eq!(line, "— —");
2137    }
2138
2139    /// The one-line compact frame is a single line and inlines the sparkline.
2140    #[test]
2141    fn compact_line_is_one_line_with_inline_sparkline() {
2142        let plain = compact_line(&report(), &[], true);
2143        assert_eq!(plain.lines().count(), 1, "{plain}");
2144        assert!(!plain.ends_with('\n'), "{plain}");
2145        let bursty = compact_line(&report(), &[0.0, 1.5], true);
2146        assert!(bursty.contains('█'), "nonzero delta shows: {bursty}");
2147        // colour off ⇒ no escapes; colour on ⇒ the palette appears
2148        assert!(!compact_line(&report(), &[], false).contains('\x1b'));
2149        assert!(compact_line(&report(), &[], true).contains(crate::render::BOLD));
2150    }
2151}