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