Skip to main content

cmd_usage/arkuse/
render.rs

1// arkuse's presentation: a watch frame, a one-shot dashboard, a status line, and
2// JSON.
3//
4// Reuses cmduse-core's pure formatters (compact/pct/duration) and cmduse's own
5// palette, gauge and sparkline, so all three binaries read the same. ARK's unit
6// is AFP — the plan's own currency — so no row prints `money()`'s `$`; yuan
7// appears only where a price is meant (1 AFP = ¥0.002 at every tier).
8use crate::arkuse::ark;
9use crate::arkuse::window::{Report, Totals};
10use crate::report_render::Fmt;
11use cmduse_core::{compact, duration};
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    fn cyan_on(&self, text: String) -> String {
68        format!("{}{text}{}", self.cyan, self.reset)
69    }
70
71    fn dim_on(&self, text: String) -> String {
72        format!("{}{text}{}", self.dim, self.reset)
73    }
74
75    fn bold_on(&self, text: String) -> String {
76        format!("{}{text}{}", self.bold, self.reset)
77    }
78
79    /// Severity for a used/limit percentage — cmduse's thresholds.
80    fn for_pct(&self, pct_used: f64) -> &'static str {
81        if !self.colour {
82            ""
83        } else if pct_used >= 90.0 {
84            self.red
85        } else if pct_used >= 70.0 {
86            self.yellow
87        } else {
88            self.green
89        }
90    }
91
92    /// The percentage plus cmduse's gauge, and the row's only percentage in
93    /// colour mode — the body drops its own `x% of limit` so the figure is not
94    /// printed twice. The label is passed in because ARK's meter is the control
95    /// plane's own figure (`given_pct`, so `53` never becomes `53.0`) while a
96    /// local estimate carries our one decimal. Empty when colour is off, so the
97    /// plain frame keeps its script-friendly column layout and its own share.
98    fn bar_label(&self, pct_used: f64, label: &str) -> String {
99        if !self.colour {
100            return String::new();
101        }
102        format!(
103            "{}{label:>5}%{} {}",
104            self.for_pct(pct_used),
105            self.reset,
106            crate::render::gauge(pct_used, 100.0, self.bar_width),
107        )
108    }
109}
110
111/// AFP with thousands separators and no decimals. AFP is a countable quota, not
112/// a currency: a tenth of a point is below the control plane's own noise (it
113/// reports fractions, the console floors them).
114pub fn afp(value: f64) -> String {
115    let rounded = value.round() as i64;
116    let digits = rounded.unsigned_abs().to_string();
117    let mut out = String::new();
118    for (i, ch) in digits.chars().enumerate() {
119        if i > 0 && (digits.len() - i).is_multiple_of(3) {
120            out.push(',');
121        }
122        out.push(ch);
123    }
124    if rounded < 0 {
125        format!("-{out}")
126    } else {
127        out
128    }
129}
130
131/// Yuan, for prices only — never for metered usage, which is AFP.
132pub fn yuan(value: f64) -> String {
133    format!("¥{:.2}", (value * 100.0).round() / 100.0)
134}
135
136/// A percentage exactly as the control plane published it. ARK serves fractions
137/// (`29.756678`), so this keeps one decimal; local percentages keep `pct`'s own
138/// formatting.
139fn given_pct(value: f64) -> String {
140    if (value - value.round()).abs() < 1e-9 {
141        format!("{}", value.round() as i64)
142    } else {
143        format!("{value:.1}")
144    }
145}
146
147/// `YYYY-MM-DD` for a Unix day number — `cmduse_core::dates::civil_from_days`,
148/// so the three binaries cannot drift on the calendar.
149pub fn date_of(day: i64) -> String {
150    cmduse_core::dates::civil_from_days(day)
151}
152
153fn shorten(path: &str) -> String {
154    match std::env::var("HOME") {
155        Ok(home) if path.starts_with(&home) => format!("~{}", &path[home.len()..]),
156        _ => path.to_string(),
157    }
158}
159
160/// The account meter's window boundary: its reset instant and the window's
161/// length, so the row can measure elapsed share and pace on the server's clock.
162fn meter_reset(meter: &crate::arkuse::api::Meter, dur_secs: Option<u64>) -> Option<(i64, u64)> {
163    match (meter.reset_ms(), dur_secs) {
164        (Some(at), Some(dur)) if dur > 0 => Some((at, dur)),
165        _ => None,
166    }
167}
168
169/// A locally cut window's boundary: the oldest row still inside the cut plus the
170/// window's length, matching the Go side's rolling reset. `None` when the window
171/// holds no rows, because `now + length` is not a reset the data supports.
172fn rolling(report: &Report, totals: &Totals, dur_secs: i64) -> Option<(i64, u64)> {
173    report
174        .rolling_reset(totals, dur_secs)
175        .map(|at| (at, dur_secs as u64))
176}
177
178#[allow(clippy::too_many_arguments)]
179/// One window row's inputs. The control plane's meters and the local estimate
180/// both go through this one builder, so the two branches cannot drift apart in
181/// which fields a row carries or in the order it carries them.
182///
183/// Unlike Go, ARK publishes the quota itself (`total`), so the row can print a
184/// true `used / quota` pair and a pace against it. The local branch has no
185/// quota — it shows AFP alone, never a denominator this store cannot know.
186struct RowSpec<'a> {
187    label: &'a str,
188    /// This store's own totals for the window.
189    used: &'a Totals,
190    /// The account's meter, when it answered.
191    meter: Option<&'a crate::arkuse::api::Meter>,
192    /// The window's reset instant and its length in seconds.
193    reset: Option<(i64, u64)>,
194    /// The plan's published quota for this window, used only when no meter
195    /// answered — ARK publishes the split, so the local estimate can print the
196    /// same `used / quota` pair the console does instead of AFP alone.
197    quota: Option<f64>,
198    /// Whether an even-burn figure belongs on this row: the monthly window alone
199    /// is a budget, where "what even burn would have spent by now" is a fair
200    /// comparison; the throttles above it would read as a spend target.
201    pro_rata: bool,
202}
203
204fn window_line(ink: &Ink, spec: &RowSpec<'_>, now_ms: i64, catalog: &ark::Catalog) -> String {
205    let now_secs = (now_ms / 1000).max(0) as u64;
206    let reset_at = spec.reset.map(|(at, _)| at as f64);
207    let dur_secs = spec.reset.map(|(_, dur)| dur);
208    let elapsed = dur_secs.and_then(|d| cmduse_core::elapsed_pct(reset_at, d, Some(now_secs)));
209    let reset = spec
210        .reset
211        .map(|(at, _)| duration(((at - now_ms) / 1000).max(0) as u64))
212        .unwrap_or_default();
213    let reset_text = if reset.is_empty() {
214        String::new()
215    } else if ink.colour {
216        format!(" · resets in {reset}")
217    } else {
218        format!(" · resets {reset}")
219    };
220    let thru = elapsed
221        .map(|p| format!(" · {}window {p:.1}% elapsed{}", ink.dim, ink.reset))
222        .unwrap_or_default();
223    // This store's AFP against the plan's published quota: the local branch's
224    // percentage. It under-reads the console (the meter counts every client and
225    // bills at its own coefficient), which is why the row says `est.`.
226    let local_pct = spec
227        .quota
228        .filter(|quota| *quota > 0.0)
229        .map(|quota| spec.used.afp / quota * 100.0);
230
231    // Server branch: the quota is the server's own, so the pair and the pace are
232    // exact. Local branch: the catalogue's quota, labelled as the estimate it is.
233    let (body, tone, pct) = match spec.meter {
234        Some(meter) => {
235            let pace = spec
236                .reset
237                .and_then(|(at, d)| {
238                    cmduse_core::pace_eta(Some(at as f64), d, meter.used, meter.quota, now_secs)
239                        .map(|secs| duration(secs as u64))
240                })
241                .map(|eta| format!(" · {}on pace to hit cap in {eta}{}", ink.yellow, ink.reset))
242                .unwrap_or_default();
243            let by_now = match (spec.pro_rata, elapsed) {
244                (true, Some(p)) => format!(
245                    " · {}{} by now{}",
246                    ink.dim,
247                    afp(meter.quota * p / 100.0),
248                    ink.reset
249                ),
250                _ => String::new(),
251            };
252            let flag = if meter.exceeded() {
253                format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
254            } else if !meter.status.eq_ignore_ascii_case("ok") {
255                format!(" {}{}{}{}", ink.red, meter.status, ink.reset, "")
256            } else {
257                String::new()
258            };
259            let pct_tone = ink.for_pct(meter.percent);
260            let share = if ink.colour {
261                // The gauge already carries the share; printing it again here is
262                // the duplication this row used to read as `28.5% ━━━ 28.5% of limit`.
263                String::new()
264            } else {
265                format!(
266                    " · {}{}% of limit{}",
267                    pct_tone,
268                    given_pct(meter.percent),
269                    ink.reset
270                )
271            };
272            let tail = format!("{reset_text}{flag}{pace}{thru}{by_now}");
273            let body = format!(
274                "{} / {} AFP{share} · {}{} local{}{tail}",
275                afp(meter.used),
276                afp(meter.quota),
277                ink.dim,
278                afp(spec.used.afp),
279                ink.reset,
280            );
281            (body, pct_tone, Some(meter.percent))
282        }
283        None => {
284            let quota = spec.quota.filter(|quota| *quota > 0.0);
285            let head = match (quota, local_pct) {
286                (Some(quota), Some(p)) => {
287                    let share = if ink.colour {
288                        format!("{}(est.){}", ink.dim, ink.reset)
289                    } else {
290                        format!(" · {}{p:.1}% of limit (est.){}", ink.for_pct(p), ink.reset)
291                    };
292                    format!("{} / {} AFP{share}", afp(spec.used.afp), afp(quota),)
293                }
294                _ => format!("{} AFP", afp(spec.used.afp)),
295            };
296            let pace = match (spec.reset, quota, local_pct) {
297                (Some((at, d)), Some(quota), Some(_)) => {
298                    cmduse_core::pace_eta(Some(at as f64), d, spec.used.afp, quota, now_secs)
299                        .map(|secs| {
300                            format!(
301                                " · {}on pace to hit cap in {}{}",
302                                ink.yellow,
303                                duration(secs as u64),
304                                ink.reset
305                            )
306                        })
307                        .unwrap_or_default()
308                }
309                _ => String::new(),
310            };
311            let by_now = match (spec.pro_rata, quota, elapsed) {
312                (true, Some(quota), Some(p)) => {
313                    format!(
314                        " · {}{} by now{}",
315                        ink.dim,
316                        afp(quota * p / 100.0),
317                        ink.reset
318                    )
319                }
320                _ => String::new(),
321            };
322            let body = format!(
323                "{head} · {}{} · {} requests{}{pace}{reset_text}{thru}{by_now}",
324                ink.dim,
325                yuan(catalog.yuan(spec.used.afp)),
326                compact(spec.used.requests),
327                ink.reset,
328            );
329            (body, ink.for_pct(local_pct.unwrap_or(0.0)), local_pct)
330        }
331    };
332    if ink.colour {
333        let bar = pct
334            .map(|p| {
335                let label = match spec.meter {
336                    Some(meter) => given_pct(meter.percent),
337                    None => format!("{p:.1}"),
338                };
339                format!("{} ", ink.bar_label(p, &label))
340            })
341            .unwrap_or_default();
342        format!(
343            " {}{:<8}{} {}{}{body}{}\n",
344            ink.bold, spec.label, ink.reset, bar, tone, ink.reset,
345        )
346    } else {
347        format!("  {:<8} {body}\n", spec.label)
348    }
349}
350
351/// One model's row: id, coefficient in force, AFP and requests this period, and
352/// its share of the period's AFP. No per-model limit exists on ARK — the quota is
353/// plan-level — so there is no USE column to invent.
354fn model_row(ink: &Ink, model: &crate::arkuse::window::ModelUsage, total_afp: f64) -> String {
355    let coef = format!("{:>5.2}", model.coef);
356    let coef_cell = if ink.colour {
357        format!("{}{coef}{}", ink.cyan, ink.reset)
358    } else {
359        coef
360    };
361    let share = if total_afp > 0.0 {
362        ink.dim_on(format!(" {:>5.1}%", model.month.afp / total_afp * 100.0))
363    } else {
364        String::new()
365    };
366    format!(
367        "{:<28} {coef_cell} {:>9} {:>7}{share}\n",
368        model.id,
369        afp(model.month.afp),
370        compact(model.month.requests),
371    )
372}
373
374/// `limit` rows to show; `usize::MAX` for all.
375pub fn render_text(
376    report: &Report,
377    db_path: &str,
378    rows: usize,
379    colour: bool,
380    bar_width: usize,
381) -> String {
382    let ink = Ink::new(colour).width(bar_width);
383    let catalog = ark::catalog();
384    let c = |text: String| ink.cyan_on(text);
385    let mut out = String::new();
386    let tier_label = catalog
387        .plan(&report.tier)
388        .map(|p| p.label.clone())
389        .unwrap_or_else(|| report.tier.clone());
390    out.push_str(&format!(
391        "{}Volcengine ARK usage{} — agent plan {}, from {}{}{}\n",
392        ink.bold,
393        ink.reset,
394        tier_label,
395        ink.dim,
396        shorten(db_path),
397        ink.reset,
398    ));
399    let end = report
400        .period_end_ms
401        .map(|ms| {
402            format!(
403                " · ends {}",
404                date_of((ms + report.offset_secs * 1000).div_euclid(86_400_000))
405            )
406        })
407        .unwrap_or_default();
408    let start = date_of((report.since_ms + report.offset_secs * 1000).div_euclid(86_400_000));
409    out.push_str(&format!(
410        "period: {start}{end} · {} requests · {} AFP ({}) · {} in / {} out tokens\n",
411        c(compact(report.month.requests)),
412        c(afp(report.month.afp)),
413        c(yuan(catalog.yuan(report.month.afp))),
414        c(compact(report.month.input)),
415        c(compact(report.month.output)),
416    ));
417    if let Some(plan) = catalog.plan(&report.tier) {
418        out.push_str(&format!(
419            "{}plan: {} {} · {} AFP/mo · {} · {} windows{}",
420            ink.dim,
421            plan.label,
422            yuan(plan.price),
423            afp(plan.afp),
424            catalog.currency,
425            if report.account.is_some() {
426                "metered by the control plane"
427            } else {
428                "local estimate"
429            },
430            ink.reset,
431        ));
432        out.push('\n');
433    }
434
435    match &report.account {
436        Some(account) => {
437            out.push_str(&format!(
438                "\n{}AGENT PLAN{} {}\n",
439                ink.bold,
440                ink.reset,
441                ink.dim_on(
442                    "(account meters — what the Volcengine console reports; AFP beside them is this store's own estimate)"
443                        .into()
444                ),
445            ));
446            let monthly_dur = report
447                .period_end_ms
448                .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
449            let daily_local = report.daily.clone().unwrap_or_default();
450            let mut windows: Vec<(&str, &crate::arkuse::api::Meter, &Totals, Option<u64>, bool)> =
451                vec![(
452                    "5-hour",
453                    &account.five_hour,
454                    &report.five_hour,
455                    Some(crate::arkuse::window::FIVE_HOUR_SECS as u64),
456                    false,
457                )];
458            if let Some(daily) = &account.daily {
459                windows.push((
460                    "Daily",
461                    daily,
462                    &daily_local,
463                    Some(crate::arkuse::window::DAY_SECS as u64),
464                    false,
465                ));
466            }
467            windows.push((
468                "Weekly",
469                &account.weekly,
470                &report.weekly,
471                Some(crate::arkuse::window::WEEKLY_SECS as u64),
472                false,
473            ));
474            windows.push((
475                "Monthly",
476                &account.monthly,
477                &report.month,
478                monthly_dur,
479                true,
480            ));
481            for (label, meter, used, dur, pro_rata) in windows {
482                // A just-reset block answers without `reset_at` (measured
483                // 2026-10-20: `{"label":"5h","total":10000,"percent":0}`), so
484                // fall back to the store's own rolling boundary rather than
485                // drop the countdown the row is asked for.
486                let reset = meter_reset(meter, dur)
487                    .or_else(|| dur.and_then(|dur| rolling(report, used, dur as i64)));
488                out.push_str(&window_line(
489                    &ink,
490                    &RowSpec {
491                        label,
492                        used,
493                        meter: Some(meter),
494                        reset,
495                        quota: None,
496                        pro_rata,
497                    },
498                    report.now_ms,
499                    &catalog,
500                ));
501            }
502            // Reconciliation is the point of holding both sources: the meter is
503            // account-wide, the store is this machine, so a gap is either another
504            // client or an unmodelled item — worth printing, never hidden.
505            let mut notes: Vec<String> = Vec::new();
506            if let Some(delta) = report.reconcile() {
507                notes.push(format!("local vs meter {:+.1}%", delta));
508            }
509            if let Some(per) = report.afp_per_request() {
510                notes.push(format!("{} AFP/request", afp(per)));
511            }
512            if let Some(burn) = report.burn_per_day() {
513                notes.push(format!("{} AFP/day at this pace", afp(burn)));
514            }
515            if !notes.is_empty() {
516                out.push_str(&format!(
517                    "{}    {} · {}{}\n",
518                    ink.dim,
519                    account.profile,
520                    notes.join(" · "),
521                    ink.reset,
522                ));
523            }
524        }
525        None => {
526            out.push_str(&format!(
527                "\n{}AGENT PLAN{} {}\n",
528                ink.bold,
529                ink.reset,
530                ink.dim_on("(estimated locally: no control-plane answer, so AFP alone)".into()),
531            ));
532            if let Some(why) = &report.account_error {
533                out.push_str(&format!("{}   usage API: {why}{}\n", ink.dim, ink.reset));
534            }
535            let monthly_dur = report
536                .period_end_ms
537                .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
538            // No meter: the catalogue's published split stands in for the
539            // quota, and the reset comes from the oldest row still inside the
540            // rolling cut — never a fabricated `now + length`.
541            let five_reset = rolling(
542                report,
543                &report.five_hour,
544                crate::arkuse::window::FIVE_HOUR_SECS,
545            );
546            let week_reset = rolling(report, &report.weekly, crate::arkuse::window::WEEKLY_SECS);
547            out.push_str(&window_line(
548                &ink,
549                &RowSpec {
550                    label: "5-hour",
551                    used: &report.five_hour,
552                    meter: None,
553                    reset: five_reset,
554                    quota: report.window_quota(&catalog, catalog.windows.five_hour),
555                    pro_rata: false,
556                },
557                report.now_ms,
558                &catalog,
559            ));
560            out.push_str(&window_line(
561                &ink,
562                &RowSpec {
563                    label: "Weekly",
564                    used: &report.weekly,
565                    meter: None,
566                    reset: week_reset,
567                    quota: report.window_quota(&catalog, catalog.windows.weekly),
568                    pro_rata: false,
569                },
570                report.now_ms,
571                &catalog,
572            ));
573            out.push_str(&window_line(
574                &ink,
575                &RowSpec {
576                    label: "Monthly",
577                    used: &report.month,
578                    meter: None,
579                    reset: report.period_end_ms.zip(monthly_dur),
580                    quota: report.window_quota(&catalog, catalog.windows.monthly),
581                    pro_rata: true,
582                },
583                report.now_ms,
584                &catalog,
585            ));
586        }
587    }
588
589    out.push_str(&format!(
590        "\n{}MODEL                            COEF       AFP     REQ  SHARE{}\n",
591        ink.bold, ink.reset
592    ));
593    let total_afp = report.month.afp;
594    for model in report.models.iter().take(rows) {
595        out.push_str(&model_row(&ink, model, total_afp));
596    }
597    if report.models.len() > rows {
598        out.push_str(&format!(
599            "{}\n",
600            ink.dim_on(format!("… {} more", report.models.len() - rows))
601        ));
602    }
603    // The subtlety that makes local AFP disagree with the meter: composition.
604    out.push_str(&format!(
605        "\n{}\n",
606        ink.dim_on(
607            "AFP = (input + cache_read + output) × coefficient / 10,000; the coefficient is the plan's own and a promo halves it until its date."
608                .into()
609        ),
610    ));
611    if report.account.is_some() {
612        out.push_str(&format!(
613            "{}\n",
614            ink.dim_on(
615                "The windows are the account's own cuts (5h block, Monday week, subscription month); the model AFP is this machine inside them."
616                    .into()
617            )
618        ));
619    } else {
620        out.push_str(&format!(
621            "{}\n",
622            ink.dim_on(
623                "Windows are rolling 5h / 7d / calendar month cuts, local only — not the control plane's anchors."
624                    .into()
625            )
626        ));
627    }
628    out
629}
630
631/// One watch frame: the dashboard, then an AFP-burst sparkline when the 5-hour
632/// figure has moved since the previous refresh. No trailing newline — the caller
633/// appends its own status line and `redraw_frame` (cmduse's) parks the cursor
634/// there, which is what lets the countdown rewrite a single line per second.
635pub fn watch_frame(
636    report: &Report,
637    db_path: &str,
638    rows: usize,
639    colour: bool,
640    history: &[f64],
641    interval_secs: u64,
642    bar_width: usize,
643) -> String {
644    let ink = Ink::new(colour);
645    let mut out = render_text(report, db_path, rows, colour, bar_width);
646    if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
647        out.push_str(&format!(
648            "{}AFP bursts ({}s samples){} {}\n",
649            ink.dim,
650            interval_secs,
651            ink.reset,
652            crate::render::sparkline(history),
653        ));
654    }
655    out.trim_end_matches('\n').to_string()
656}
657
658/// Minimal one-line dashboard for terminals too small for the full frame.
659pub fn compact_line(report: &Report, history: &[f64], colour: bool) -> String {
660    let ink = Ink::new(colour);
661    let catalog = ark::catalog();
662    let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
663        format!(
664            " {}{}{}",
665            ink.dim,
666            crate::render::sparkline(history),
667            ink.reset
668        )
669    } else {
670        String::new()
671    };
672    let window_share = match &report.account {
673        Some(account) => format!(
674            " · 5h {} · wk {} · mo {}",
675            ink.cyan_on(format!("{}%", given_pct(account.five_hour.percent))),
676            ink.cyan_on(format!("{}%", given_pct(account.weekly.percent))),
677            ink.cyan_on(format!("{}%", given_pct(account.monthly.percent))),
678        ),
679        None => format!(" · 5h {}", ink.cyan_on(afp(report.five_hour.afp))),
680    };
681    format!(
682        "{}ARK{} {} AFP ({}) month{window_share}{spark}",
683        ink.bold,
684        ink.reset,
685        ink.cyan_on(afp(report.month.afp)),
686        ink.cyan_on(yuan(catalog.yuan(report.month.afp))),
687    )
688}
689
690/// arkuse's statusline: cmduse's `{key}` template engine
691/// (`report_render::render_template`) resolved against this report's own
692/// placeholders.
693///
694/// Placeholders (case-insensitive):
695///   {plan} {tier} {period}                 tier id / period label
696///   {month} {month_pct} {month_bar}
697///   {five_hour} {five_hour_pct} {five_hour_bar}   (aliases {5h} {5h_pct} {5h_bar})
698///   {weekly} {weekly_pct} {weekly_bar}            (aliases {wk} {wk_pct} {wk_bar})
699///   {yuan} {yuan_month}                    this store's AFP in yuan
700///   {models}                               top model by period AFP
701pub fn statusline_template(
702    report: &Report,
703    plan_id: &str,
704    tpl: &str,
705    colours: bool,
706    ascii: bool,
707    bar_width: usize,
708) -> String {
709    let ink = Ink::new(colours);
710    let catalog = ark::catalog();
711    let account = report.account.as_ref();
712    let quota_of = |share: f64| report.window_quota(&catalog, share);
713    // The console's own percentage when it answered, else this store's AFP
714    // against the plan's published split — never a dash for a window we can
715    // measure ourselves. The plan's quota is public, so this is not a guess.
716    let pct_of = |meter: Option<&crate::arkuse::api::Meter>, used: f64, share: f64| match meter {
717        Some(meter) => Some(meter.percent),
718        None => quota_of(share)
719            .filter(|quota| *quota > 0.0)
720            .map(|quota| used / quota * 100.0),
721    };
722    let share_of = |pct: Option<f64>| match pct {
723        Some(pct) => format!("{}%", given_pct(pct)),
724        None => "—".into(),
725    };
726    let share_bar = |pct: Option<f64>| match pct {
727        Some(pct) => crate::report_render::bar(pct, bar_width, ascii, colours),
728        None => String::new(),
729    };
730    // A window's boundary: the server's reset, else the oldest row still inside
731    // the rolling cut plus the window's length.
732    let reset_of =
733        |meter: Option<&crate::arkuse::api::Meter>, totals: &Totals, dur: i64| match meter
734            .and_then(|m| m.reset_ms())
735        {
736            Some(at) => Some((at, dur as u64)),
737            None => report.rolling_reset(totals, dur).map(|at| (at, dur as u64)),
738        };
739    let eta_of = |reset: Option<(i64, u64)>, used: f64, quota: Option<f64>| -> Option<String> {
740        let (at, dur) = reset?;
741        let quota = quota?;
742        let secs = cmduse_core::pace_eta(
743            Some(at as f64),
744            dur,
745            used,
746            quota,
747            (report.now_ms / 1000).max(0) as u64,
748        )?;
749        Some(format!("on pace to hit cap in {}", duration(secs as u64)))
750    };
751    let h5_pct = pct_of(
752        account.map(|a| &a.five_hour),
753        report.five_hour.afp,
754        catalog.windows.five_hour,
755    );
756    let wk_pct = pct_of(
757        account.map(|a| &a.weekly),
758        report.weekly.afp,
759        catalog.windows.weekly,
760    );
761    let month_pct = pct_of(
762        account.map(|a| &a.monthly),
763        report.month.afp,
764        catalog.windows.monthly,
765    );
766    let h5_eta = eta_of(
767        reset_of(
768            account.map(|a| &a.five_hour),
769            &report.five_hour,
770            crate::arkuse::window::FIVE_HOUR_SECS,
771        ),
772        report.five_hour.afp,
773        quota_of(catalog.windows.five_hour),
774    )
775    .unwrap_or_default();
776    let wk_eta = eta_of(
777        reset_of(
778            account.map(|a| &a.weekly),
779            &report.weekly,
780            crate::arkuse::window::WEEKLY_SECS,
781        ),
782        report.weekly.afp,
783        quota_of(catalog.windows.weekly),
784    )
785    .unwrap_or_default();
786    let cash = |value: f64| ink.cyan_on(afp(value));
787    let top = report
788        .models
789        .first()
790        .map(|m| m.id.clone())
791        .unwrap_or_else(|| "—".into());
792    let replacements: Vec<(&str, String)> = vec![
793        ("plan", plan_id.to_string()),
794        ("tier", plan_id.to_string()),
795        ("period", report.period_label.clone()),
796        ("month", cash(report.month.afp)),
797        ("month_pct", share_of(month_pct)),
798        ("month_bar", share_bar(month_pct)),
799        ("five_hour", cash(report.five_hour.afp)),
800        ("5h", cash(report.five_hour.afp)),
801        ("five_hour_pct", share_of(h5_pct)),
802        ("5h_pct", share_of(h5_pct)),
803        ("five_hour_bar", share_bar(h5_pct)),
804        ("5h_bar", share_bar(h5_pct)),
805        ("5h_eta", h5_eta),
806        ("weekly", cash(report.weekly.afp)),
807        ("wk", cash(report.weekly.afp)),
808        ("weekly_pct", share_of(wk_pct)),
809        ("wk_pct", share_of(wk_pct)),
810        ("weekly_bar", share_bar(wk_pct)),
811        ("wk_bar", share_bar(wk_pct)),
812        ("wk_eta", wk_eta),
813        ("yuan", yuan(catalog.yuan(report.month.afp))),
814        ("yuan_month", yuan(catalog.yuan(report.month.afp))),
815        ("models", top),
816    ];
817    crate::report_render::render_template(tpl, |key| {
818        let lower = key.to_ascii_lowercase();
819        replacements
820            .iter()
821            .find(|(name, _)| *name == lower)
822            .map(|(_, value)| value.clone())
823            .unwrap_or_default()
824    })
825}
826
827pub fn statusline_json(report: &Report, plan_id: &str) -> String {
828    let catalog = ark::catalog();
829    let quota_of = |share: f64| report.window_quota(&catalog, share);
830    serde_json::json!({
831        "plan": plan_id,
832        "period": report.period_label,
833        "source": "opencode",
834        // The shared names a statusline consumer already reads; here they carry
835        // AFP, because AFP is this plan's currency. The caps are the plan's
836        // published split of its monthly AFP, so a consumer never has to hold a
837        // table of its own.
838        "monthSpent": report.month.afp,
839        "monthCap": quota_of(catalog.windows.monthly),
840        "fiveHourSpent": report.five_hour.afp,
841        "fiveHourCap": quota_of(catalog.windows.five_hour),
842        "weeklySpent": report.weekly.afp,
843        "weeklyCap": quota_of(catalog.windows.weekly),
844        "model": report.models.first().map(|m| m.id.clone()),
845        "afp": {
846            "fiveHour": report.five_hour.afp,
847            "weekly": report.weekly.afp,
848            "monthly": report.month.afp,
849            "yuan": catalog.yuan(report.month.afp),
850        },
851        "account": report.account.as_ref().map(usage_json),
852        "accountError": report.account_error,
853        "periodEnd": report.period_end_ms,
854    })
855    .to_string()
856}
857
858/// One meter as JSON, with the server's own figures preserved — the percentage
859/// is the console's, not a recomputation.
860/// One window as this store measures it: AFP, the plan's published quota, the
861/// percentage against it, and the boundary its row counts down to.
862fn window_json(totals: &Totals, quota: Option<f64>, reset_ms: Option<i64>) -> serde_json::Value {
863    serde_json::json!({
864        "afp": totals.afp,
865        "requests": totals.requests,
866        "quota": quota,
867        "percent": quota
868            .filter(|quota| *quota > 0.0)
869            .map(|quota| totals.afp / quota * 100.0),
870        "resetAt": reset_ms,
871    })
872}
873
874fn meter_json(meter: &crate::arkuse::api::Meter) -> serde_json::Value {
875    serde_json::json!({
876        "used": meter.used,
877        "total": meter.quota,
878        "percent": meter.percent,
879        "status": meter.status,
880        "resetsAt": meter.resets_at,
881        "resetAt": meter.reset_ms(),
882    })
883}
884
885/// Every window the control plane answered, keeping the plan's own shape — a
886/// text-only workload has no `daily` period, so that key is simply absent.
887fn usage_json(usage: &crate::arkuse::api::Usage) -> serde_json::Value {
888    serde_json::json!({
889        "profile": usage.profile,
890        "tier": usage.tier,
891        "fiveHour": meter_json(&usage.five_hour),
892        "daily": usage.daily.as_ref().map(meter_json),
893        "weekly": meter_json(&usage.weekly),
894        "monthly": meter_json(&usage.monthly),
895    })
896}
897
898pub fn render_json(report: &Report) -> String {
899    let catalog = ark::catalog();
900    let totals = |t: &Totals| {
901        serde_json::json!({
902            "afp": t.afp,
903            "requests": t.requests,
904            "tokensIn": t.input,
905            "tokensOut": t.output,
906            "cacheRead": t.cache_read,
907            "cacheWrite": t.cache_write,
908        })
909    };
910    let models: serde_json::Map<String, serde_json::Value> = report
911        .models
912        .iter()
913        .map(|m| {
914            (
915                m.id.clone(),
916                serde_json::json!({
917                    "coef": m.coef,
918                    "month": totals(&m.month),
919                    "fiveHour": totals(&m.five_hour),
920                    "weekly": totals(&m.weekly),
921                }),
922            )
923        })
924        .collect();
925    serde_json::json!({
926        "source": "opencode",
927        "scope": "local",
928        "plan": report.tier,
929        "profile": report.profile,
930        "periodStart": report.since_ms,
931        "periodEndAt": report.period_end_ms,
932        "periodEnd": report.period_end_ms
933            .map(|ms| date_of((ms + report.offset_secs * 1000).div_euclid(86_400_000))),
934        "monthInferred": report.month_inferred,
935        "summary": {
936            "afp": report.month.afp,
937            "yuan": ark::catalog().yuan(report.month.afp),
938            "requests": report.month.requests,
939            "tokensIn": report.month.input,
940            "tokensOut": report.month.output,
941            "cacheRead": report.month.cache_read,
942        },
943        "fiveHour": totals(&report.five_hour),
944        "weekly": totals(&report.weekly),
945        "monthly": totals(&report.month),
946        "daily": report.daily.as_ref().map(totals),
947        // This store's view of each window against the plan's published quota —
948        // the same pair the dashboard prints, so a consumer without the account
949        // answer still gets a percentage and a boundary.
950        "windows": {
951            "fiveHour": window_json(
952                &report.five_hour,
953                report.window_quota(&catalog, catalog.windows.five_hour),
954                report.rolling_reset(&report.five_hour, crate::arkuse::window::FIVE_HOUR_SECS),
955            ),
956            "weekly": window_json(
957                &report.weekly,
958                report.window_quota(&catalog, catalog.windows.weekly),
959                report.rolling_reset(&report.weekly, crate::arkuse::window::WEEKLY_SECS),
960            ),
961            "monthly": window_json(
962                &report.month,
963                report.window_quota(&catalog, catalog.windows.monthly),
964                report.period_end_ms,
965            ),
966        },
967        "account": report.account.as_ref().map(|account| {
968            let mut body = serde_json::json!({
969                "profile": account.profile,
970                "tier": account.tier,
971                "fiveHour": meter_json(&account.five_hour),
972                "weekly": meter_json(&account.weekly),
973                "monthly": meter_json(&account.monthly),
974            });
975            if let Some(daily) = &account.daily {
976                body["daily"] = meter_json(daily);
977            }
978            body
979        }),
980        "accountError": report.account_error,
981        "reconciledPct": report.reconcile(),
982        "afpPerRequest": report.afp_per_request(),
983        "models": models,
984    })
985    .to_string()
986}
987
988/// The tier catalogue: what each plan costs, what quota it grants, and how that
989/// quota is split across the windows. The window figures are the plan's own
990/// fractions of its monthly AFP — the same table the console shows per tier.
991pub fn plans_text(colour: bool) -> String {
992    let ink = Ink::new(colour);
993    let catalog = ark::catalog();
994    let mut out = format!(
995        "{}{:<8} {:>9} {:>9} {:>10} {:>10} {:>10}{}\n",
996        ink.bold, "TIER", "YUAN/MO", "AFP/MO", "5H AFP", "DAILY AFP", "WK AFP", ink.reset
997    );
998    for plan in &catalog.plans {
999        out.push_str(&format!(
1000            "{:<8} {:>9} {:>9} {:>10} {:>10} {:>10}\n",
1001            plan.label,
1002            ink.cyan_on(yuan(plan.price)),
1003            ink.cyan_on(afp(plan.afp)),
1004            afp(plan.afp * catalog.windows.five_hour),
1005            afp(plan.afp * catalog.windows.daily),
1006            afp(plan.afp * catalog.windows.weekly),
1007        ));
1008        if let Some(promo) = &plan.promo {
1009            out.push_str(&format!(
1010                "{}{} for {} months, until {}{}\n",
1011                ink.dim,
1012                yuan(promo.price),
1013                promo.months.unwrap_or(1),
1014                promo.until,
1015                ink.reset,
1016            ));
1017        }
1018    }
1019    out.push_str(&format!(
1020        "\n{}1 AFP = ¥{:.3} at every tier · 1,000 AFP = {}{}\n",
1021        ink.dim,
1022        catalog.afp_yuan,
1023        yuan(catalog.yuan(1000.0)),
1024        ink.reset,
1025    ));
1026    if !catalog.enterprise.is_empty() {
1027        out.push_str(&format!(
1028            "\n{}ENTERPRISE{} {}\n",
1029            ink.bold,
1030            ink.reset,
1031            ink.dim_on(
1032                "(seat-based; AFP is metered per seat and the personal usage API does not expose it)"
1033                    .into()
1034            ),
1035        ));
1036        for plan in &catalog.enterprise {
1037            out.push_str(&format!(
1038                "  {:<22} {}\n",
1039                plan.label,
1040                ink.cyan_on(format!("{}/seat/mo", yuan(plan.price))),
1041            ));
1042        }
1043    }
1044    if let Some(note) = catalog.notes.get("windows") {
1045        out.push_str(&format!("\n{}{}{}\n", ink.dim, note, ink.reset));
1046    }
1047    out
1048}
1049
1050pub fn plans_json() -> String {
1051    let catalog = ark::catalog();
1052    serde_json::json!({
1053        "source": catalog.source,
1054        "extractedAt": catalog.extracted_at,
1055        "currency": catalog.currency,
1056        "afpYuan": catalog.afp_yuan,
1057        "windows": {
1058            "fiveHour": catalog.windows.five_hour,
1059            "daily": catalog.windows.daily,
1060            "weekly": catalog.windows.weekly,
1061            "monthly": catalog.windows.monthly,
1062        },
1063        "plans": catalog.plans.iter().map(|p| serde_json::json!({
1064            "id": p.id,
1065            "label": p.label,
1066            "price": p.price,
1067            "listPrice": p.list_price,
1068            "afp": p.afp,
1069            "promo": p.promo.as_ref().map(|promo| serde_json::json!({
1070                "price": promo.price,
1071                "until": promo.until,
1072                "months": promo.months,
1073            })),
1074        })).collect::<Vec<_>>(),
1075        "enterprise": catalog.enterprise.iter().map(|p| serde_json::json!({
1076            "id": p.id,
1077            "label": p.label,
1078            "price": p.price,
1079        })).collect::<Vec<_>>(),
1080        "modelCount": catalog.models.len(),
1081    })
1082    .to_string()
1083}
1084
1085/// `y` yuan per million tokens at a coefficient — the same quantity the docs
1086/// publish per model, so a reader can check a coefficient without arithmetic:
1087/// 1M tokens × coef / 10,000 = 100·coef AFP, and 1 AFP = ¥0.002.
1088fn yuan_per_million(coef: f64, afp_yuan: f64) -> f64 {
1089    1_000_000.0 * coef / 10_000.0 * afp_yuan
1090}
1091
1092/// The model catalogue with its coefficient in force today. `gated` keeps only
1093/// what `plan_id` grants; the table always says which tier a model needs, because
1094/// the plan you hold changes what you can call.
1095pub fn models_text(plan_id: &str, gated: bool, colour: bool) -> String {
1096    let ink = Ink::new(colour);
1097    let catalog = ark::catalog();
1098    let now = crate::arkuse::window::now_ms();
1099    let mut out = format!(
1100        "{}{:<30} {:>6} {:>7} {:>10} {:>8} {:>8}{}\n",
1101        ink.bold, "MODEL", "KIND", "COEF", "YUAN/1M", "MIN", "GRANTED", ink.reset
1102    );
1103    for model in &catalog.models {
1104        let granted = catalog.grants(plan_id, &model.min_plan);
1105        if gated && !granted {
1106            continue;
1107        }
1108        let coef = ark::effective_coef(model, now);
1109        let promo = model.promo_coef.map(|_| {
1110            format!(
1111                " (promo until {})",
1112                model.promo_until.as_deref().unwrap_or("?")
1113            )
1114        });
1115        let coef_cell = ink.cyan_on(format!("{coef:.2}"));
1116        out.push_str(&format!(
1117            "{:<30} {:>6} {:>7} {:>10} {:>8} {:>8}",
1118            model.id,
1119            model.kind,
1120            coef_cell,
1121            format!("{:.2}", yuan_per_million(coef, catalog.afp_yuan)),
1122            model.min_plan,
1123            if granted { "yes" } else { "no" },
1124        ));
1125        if let Some(promo) = promo {
1126            out.push_str(&format!("{}{promo}{}", ink.dim, ink.reset));
1127        }
1128        out.push('\n');
1129    }
1130    out
1131}
1132
1133/// One JSON object per line, mirroring `models_text` — the shape cmduse's own
1134/// `models --json` uses, so a consumer pipes either binary the same way.
1135pub fn models_json(plan_id: &str, gated: bool) -> String {
1136    let catalog = ark::catalog();
1137    let now = crate::arkuse::window::now_ms();
1138    let mut lines: Vec<String> = Vec::new();
1139    for model in &catalog.models {
1140        let granted = catalog.grants(plan_id, &model.min_plan);
1141        if gated && !granted {
1142            continue;
1143        }
1144        let coef = ark::effective_coef(model, now);
1145        lines.push(
1146            serde_json::json!({
1147                "id": model.id,
1148                "name": model.name,
1149                "kind": model.kind,
1150                "coef": coef,
1151                "baseCoef": model.coef,
1152                "promoCoef": model.promo_coef,
1153                "promoUntil": model.promo_until,
1154                "yuanPerMillion": yuan_per_million(coef, catalog.afp_yuan),
1155                "minPlan": model.min_plan,
1156                "granted": granted,
1157            })
1158            .to_string(),
1159        );
1160    }
1161    lines.join("\n")
1162}
1163
1164/// One model's period record: what it cost in AFP, where that AFP sits in each
1165/// window, and how much of the period it accounts for.
1166pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
1167    let ink = Ink::new(colour);
1168    let Some(model) = report.models.iter().find(|m| m.id == id) else {
1169        return format!("{id}: no usage in the period\n");
1170    };
1171    let row =
1172        |label: &str, value: String| format!("  {}{value}\n", ink.dim_on(format!("{label:<10}")));
1173    let mut out = format!(
1174        "{} {}\n",
1175        ink.bold_on(model.id.clone()),
1176        ink.dim_on(format!("({})", model.provider)),
1177    );
1178    out.push_str(&row(
1179        "coef",
1180        ink.cyan_on(format!(
1181            "{:.2} · {} per million tokens",
1182            model.coef,
1183            yuan(yuan_per_million(model.coef, ark::catalog().afp_yuan))
1184        )),
1185    ));
1186    out.push_str(&row(
1187        "month",
1188        ink.cyan_on(format!(
1189            "{} AFP ({}) · {} requests",
1190            afp(model.month.afp),
1191            yuan(ark::catalog().yuan(model.month.afp)),
1192            model.month.requests
1193        )),
1194    ));
1195    out.push_str(&row(
1196        "5-hour",
1197        ink.cyan_on(format!("{} AFP", afp(model.five_hour.afp))),
1198    ));
1199    out.push_str(&row(
1200        "weekly",
1201        ink.cyan_on(format!("{} AFP", afp(model.weekly.afp))),
1202    ));
1203    if report.month.afp > 0.0 {
1204        out.push_str(&row(
1205            "share",
1206            ink.dim_on(format!(
1207                "{:.1}% of the period's AFP",
1208                model.month.afp / report.month.afp * 100.0
1209            )),
1210        ));
1211    }
1212    out
1213}
1214
1215/// Per-model report: `Json` mirrors cmduse's `model --json` envelope with AFP in
1216/// place of dollars; `Csv` is one row per model. `Table` is the caller's.
1217pub fn model_report(report: &Report, id: Option<&str>, fmt: Fmt) -> String {
1218    let models: Vec<&crate::arkuse::window::ModelUsage> = report
1219        .models
1220        .iter()
1221        .filter(|m| id.is_none_or(|want| m.id == want))
1222        .collect();
1223    match fmt {
1224        Fmt::Json => {
1225            let map: serde_json::Map<String, serde_json::Value> = models
1226                .iter()
1227                .map(|m| {
1228                    (
1229                        m.id.clone(),
1230                        serde_json::json!({
1231                            "coef": m.coef,
1232                            "requests": m.month.requests,
1233                            "tokensIn": m.month.input,
1234                            "tokensOut": m.month.output,
1235                            "cacheRead": m.month.cache_read,
1236                            "afp": m.month.afp,
1237                            "yuan": ark::catalog().yuan(m.month.afp),
1238                        }),
1239                    )
1240                })
1241                .collect();
1242            serde_json::json!({
1243                "scope": "local",
1244                "source": "opencode",
1245                "sinceMs": report.since_ms,
1246                "models": map,
1247            })
1248            .to_string()
1249        }
1250        Fmt::Csv => {
1251            let mut out = String::from("model,requests,tokens_in,tokens_out,cache_read,afp\n");
1252            for m in models {
1253                out.push_str(&format!(
1254                    "{},{},{},{},{},{:.2}\n",
1255                    crate::report_render::csv_field(&m.id),
1256                    m.month.requests,
1257                    m.month.input,
1258                    m.month.output,
1259                    m.month.cache_read,
1260                    m.month.afp,
1261                ));
1262            }
1263            out
1264        }
1265        Fmt::Table => String::new(),
1266    }
1267}
1268
1269/// The period's per-model breakdown as a table — what `arkuse model` with no id
1270/// prints. Columns mirror the dashboard's model block so a reader can put the
1271/// two side by side.
1272pub fn models_period_text(report: &Report, limit: usize, colour: bool) -> String {
1273    let ink = Ink::new(colour);
1274    let mut out = format!(
1275        "{}{:<28} {:>5} {:>9} {:>7} {:>6}{}\n",
1276        ink.bold, "MODEL", "COEF", "AFP", "REQ", "SHARE", ink.reset
1277    );
1278    let total = report.month.afp;
1279    for model in report.models.iter().take(limit.min(report.models.len())) {
1280        let share = if total > 0.0 {
1281            ink.dim_on(format!("{:>5.1}%", model.month.afp / total * 100.0))
1282        } else {
1283            "     -".to_string()
1284        };
1285        out.push_str(&format!(
1286            "{:<28} {} {:>9} {:>7} {}\n",
1287            model.id,
1288            ink.cyan_on(format!("{:>5.2}", model.coef)),
1289            ink.cyan_on(format!("{:>9}", afp(model.month.afp))),
1290            ink.cyan_on(format!("{:>7}", compact(model.month.requests))),
1291            share,
1292        ));
1293    }
1294    if report.models.is_empty() {
1295        out.push_str(&format!(
1296            "{}\n",
1297            ink.dim_on("(no agent-plan usage in the period)".into())
1298        ));
1299    } else if total > 0.0 {
1300        if report.models.len() > limit {
1301            out.push_str(&format!(
1302                "{}\n",
1303                ink.dim_on(format!("… {} more", report.models.len() - limit))
1304            ));
1305        }
1306        out.push_str(&format!(
1307            "{:<28} {:>5} {:>9} {:>7} {:>6}\n",
1308            ink.bold_on("TOTAL".into()),
1309            "",
1310            afp(total),
1311            compact(report.month.requests),
1312            "100.0%",
1313        ));
1314    }
1315    out
1316}
1317
1318/// The store's numeric fields, in the shape every machine format in this repo
1319/// uses, with AFP standing where the other binaries put dollars.
1320fn totals_json(
1321    afp: f64,
1322    requests: u64,
1323    input: u64,
1324    output: u64,
1325    cache_read: u64,
1326) -> serde_json::Value {
1327    serde_json::json!({
1328        "requests": requests,
1329        "tokensIn": input,
1330        "tokensOut": output,
1331        "cacheRead": cache_read,
1332        "afp": afp,
1333    })
1334}
1335
1336/// One day/hour bucket. AFP is accumulated per row, because the coefficient is
1337/// a property of the model and of the day it ran (a promo can expire mid-window).
1338#[derive(Default, Clone, Copy)]
1339struct Bucket {
1340    afp: f64,
1341    requests: u64,
1342    input: u64,
1343    output: u64,
1344    cache_read: u64,
1345}
1346
1347impl Bucket {
1348    fn add(&mut self, catalog: &ark::Catalog, row: &crate::arkuse::db::Row) {
1349        self.requests += 1;
1350        self.input += row.input;
1351        self.output += row.output;
1352        self.cache_read += row.cache_read;
1353        self.afp += catalog
1354            .afp(&row.model, row.at_ms, row.input, row.cache_read, row.output)
1355            .unwrap_or(0.0);
1356    }
1357
1358    fn merge(&mut self, other: &Bucket) {
1359        self.afp += other.afp;
1360        self.requests += other.requests;
1361        self.input += other.input;
1362        self.output += other.output;
1363        self.cache_read += other.cache_read;
1364    }
1365
1366    fn json(&self) -> serde_json::Value {
1367        totals_json(
1368            self.afp,
1369            self.requests,
1370            self.input,
1371            self.output,
1372            self.cache_read,
1373        )
1374    }
1375}
1376
1377/// AFP per bucket (day or hour), oldest first — `arkuse daily` / `arkuse hourly`.
1378#[allow(clippy::too_many_arguments)]
1379pub fn bucket_text(
1380    rows: &[crate::arkuse::db::Row],
1381    catalog: &ark::Catalog,
1382    now_ms: i64,
1383    lookback_ms: i64,
1384    size_ms: i64,
1385    tz_secs: i64,
1386    colour: bool,
1387    fmt: Fmt,
1388) -> String {
1389    // Buckets are cut in the display timezone: shift, bucket, then label.
1390    let shift = tz_secs * 1000;
1391    let mut buckets: std::collections::BTreeMap<i64, Bucket> = Default::default();
1392    for row in rows {
1393        let bucket = (row.at_ms + shift).div_euclid(size_ms);
1394        buckets.entry(bucket).or_default().add(catalog, row);
1395    }
1396    let start = (now_ms + shift - lookback_ms).div_euclid(size_ms);
1397    let daily = size_ms >= crate::arkuse::window::DAY_SECS * 1000;
1398    let label = if daily { "day" } else { "hour" };
1399    let stamp = |bucket: i64| {
1400        if daily {
1401            date_of(bucket)
1402        } else {
1403            format!(
1404                "{} {:02}h",
1405                date_of(bucket.div_euclid(24)),
1406                bucket.rem_euclid(24)
1407            )
1408        }
1409    };
1410    let window: Vec<(i64, Bucket)> = buckets
1411        .range(start..)
1412        .map(|(bucket, b)| (*bucket, *b))
1413        .collect();
1414
1415    match fmt {
1416        Fmt::Json => {
1417            let mut keyed = serde_json::Map::new();
1418            let mut total = Bucket::default();
1419            for (bucket, b) in &window {
1420                keyed.insert(stamp(*bucket), b.json());
1421                total.merge(b);
1422            }
1423            let key = if daily { "days" } else { "hours" };
1424            serde_json::json!({
1425                "scope": "local",
1426                "source": "opencode",
1427                "total": total.json(),
1428                key: keyed,
1429            })
1430            .to_string()
1431        }
1432        Fmt::Csv => {
1433            let mut out = format!("{label},requests,tokens_in,tokens_out,cache_read,afp\n");
1434            let mut total = Bucket::default();
1435            for (bucket, b) in &window {
1436                out.push_str(&format!(
1437                    "{},{},{},{},{},{:.2}\n",
1438                    crate::report_render::csv_field(&stamp(*bucket)),
1439                    b.requests,
1440                    b.input,
1441                    b.output,
1442                    b.cache_read,
1443                    b.afp,
1444                ));
1445                total.merge(b);
1446            }
1447            out.push_str(&format!(
1448                "total,{},{},{},{},{:.2}\n",
1449                total.requests, total.input, total.output, total.cache_read, total.afp,
1450            ));
1451            out
1452        }
1453        Fmt::Table => {
1454            let ink = Ink::new(colour);
1455            let mut out = format!(
1456                "{}{label:<12} AFP      requests   tokens{}\n",
1457                ink.bold, ink.reset
1458            );
1459            for (bucket, b) in &window {
1460                out.push_str(&format!(
1461                    "{:<12} {} {} {}\n",
1462                    stamp(*bucket),
1463                    ink.cyan_on(format!("{:>9}", afp(b.afp))),
1464                    ink.cyan_on(format!("{:>12}", compact(b.requests))),
1465                    ink.cyan_on(format!("{:>9}", compact(b.input + b.output + b.cache_read))),
1466                ));
1467            }
1468            if out.lines().count() == 1 {
1469                out.push_str(&format!(
1470                    "{}\n",
1471                    ink.dim_on("(no agent-plan usage in the window)".into())
1472                ));
1473            }
1474            out
1475        }
1476    }
1477}
1478
1479/// One session's accumulating totals: `(afp, requests, tokens_in, tokens_out,
1480/// cache_read, last_ms, distinct models)`.
1481type SessionTotals<'a> = (
1482    f64,
1483    u64,
1484    u64,
1485    u64,
1486    u64,
1487    i64,
1488    std::collections::BTreeSet<&'a str>,
1489);
1490
1491/// AFP per session, newest first — the same columns as `ocuse session`, with AFP
1492/// in place of USD.
1493pub fn session_text(
1494    rows: &[crate::arkuse::db::Row],
1495    catalog: &ark::Catalog,
1496    limit: usize,
1497    colour: bool,
1498    fmt: Fmt,
1499) -> String {
1500    let mut by_session: std::collections::BTreeMap<&str, SessionTotals> = Default::default();
1501    for row in rows {
1502        let entry = by_session
1503            .entry(row.session.as_str())
1504            .or_insert_with(|| (0.0, 0, 0, 0, 0, 0, Default::default()));
1505        entry.0 += catalog
1506            .afp(&row.model, row.at_ms, row.input, row.cache_read, row.output)
1507            .unwrap_or(0.0);
1508        entry.1 += 1;
1509        entry.2 += row.input;
1510        entry.3 += row.output;
1511        entry.4 += row.cache_read;
1512        entry.5 = entry.5.max(row.at_ms);
1513        entry.6.insert(row.model.as_str());
1514    }
1515    let mut sessions: Vec<_> = by_session.into_iter().collect();
1516    sessions.sort_by_key(|(_, (_, _, _, _, _, last, _))| std::cmp::Reverse(*last));
1517
1518    match fmt {
1519        Fmt::Json => {
1520            let mut keyed = serde_json::Map::new();
1521            for (id, (cost, requests, input, output, cache_read, last, models)) in &sessions {
1522                let mut body = totals_json(*cost, *requests, *input, *output, *cache_read);
1523                body["lastMs"] = serde_json::json!(*last);
1524                body["models"] = serde_json::json!(models.len());
1525                keyed.insert((*id).to_string(), body);
1526            }
1527            serde_json::json!({
1528                "scope": "local",
1529                "source": "opencode",
1530                "sessions": keyed,
1531            })
1532            .to_string()
1533        }
1534        Fmt::Csv => {
1535            let mut out = String::from("session,requests,tokens_in,tokens_out,cache_read,afp\n");
1536            for (id, (cost, requests, input, output, cache_read, _, _)) in &sessions {
1537                out.push_str(&format!(
1538                    "{},{},{},{},{},{:.2}\n",
1539                    crate::report_render::csv_field(id),
1540                    requests,
1541                    input,
1542                    output,
1543                    cache_read,
1544                    cost,
1545                ));
1546            }
1547            out
1548        }
1549        Fmt::Table => {
1550            let ink = Ink::new(colour);
1551            let mut out = format!(
1552                "{}session                          last        models   AFP       req{}\n",
1553                ink.bold, ink.reset
1554            );
1555            for (id, (cost, requests, _, _, _, last, models)) in sessions.iter().take(limit) {
1556                let short = id.rsplit('_').next().unwrap_or(*id);
1557                out.push_str(&format!(
1558                    "{:<32} {:<11} {:>6} {} {:>6}\n",
1559                    short,
1560                    ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
1561                    models.len(),
1562                    ink.cyan_on(format!("{:>9}", afp(*cost))),
1563                    requests,
1564                ));
1565            }
1566            if sessions.len() > limit {
1567                out.push_str(&format!(
1568                    "{}\n",
1569                    ink.dim_on(format!("… {} more", sessions.len() - limit))
1570                ));
1571            }
1572            if out.lines().count() == 1 {
1573                out.push_str(&format!(
1574                    "{}\n",
1575                    ink.dim_on("(no sessions in the window)".into())
1576                ));
1577            }
1578            out
1579        }
1580    }
1581}
1582
1583#[cfg(test)]
1584mod tests {
1585    use super::*;
1586    use crate::arkuse::db::Row;
1587
1588    fn report() -> Report {
1589        let now = 1_800_000_000_000;
1590        let rows = vec![Row {
1591            at_ms: now - 60_000,
1592            session: "ses_a".into(),
1593            provider: "agent-plan".into(),
1594            model: "deepseek-v4.1-flash".into(),
1595            input: 10_000,
1596            output: 2_000,
1597            ..Default::default()
1598        }];
1599        crate::arkuse::window::build(&rows, &ark::catalog(), ark::DEFAULT_PLAN, now, None, None)
1600    }
1601
1602    #[test]
1603    fn text_names_the_model_and_its_afp() {
1604        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1605        assert!(text.contains("deepseek-v4.1-flash"), "{text}");
1606        assert!(text.contains("AFP"), "the plan's own unit: {text}");
1607        assert!(!text.contains('$'), "ARK never prices in dollars: {text}");
1608    }
1609
1610    #[test]
1611    fn the_tier_table_carries_every_personal_plan() {
1612        let text = plans_text(false);
1613        for plan in ark::catalog().plans {
1614            assert!(text.contains(&plan.label), "{} missing: {text}", plan.label);
1615        }
1616        let json: serde_json::Value = serde_json::from_str(&plans_json()).unwrap();
1617        assert_eq!(
1618            json["plans"].as_array().unwrap().len(),
1619            ark::catalog().plans.len()
1620        );
1621    }
1622
1623    #[test]
1624    fn gated_models_are_filtered_by_the_tier() {
1625        let all = models_text("small", false, false);
1626        let gated = models_text("small", true, false);
1627        assert!(all.lines().count() >= gated.lines().count());
1628        assert!(all.contains("kimi-k3"), "{all}");
1629        assert!(!gated.contains("kimi-k3"), "{gated}");
1630    }
1631
1632    /// A report measured against the control plane's own meters.
1633    fn metered(account: &crate::arkuse::api::Usage) -> Report {
1634        let now = 1_800_000_000_000;
1635        let rows = vec![Row {
1636            at_ms: now - 60_000,
1637            session: "ses_a".into(),
1638            provider: "agent-plan".into(),
1639            model: "deepseek-v4.1-flash".into(),
1640            input: 10_000,
1641            output: 2_000,
1642            ..Default::default()
1643        }];
1644        crate::arkuse::window::build(
1645            &rows,
1646            &ark::catalog(),
1647            ark::DEFAULT_PLAN,
1648            now,
1649            None,
1650            Some(account),
1651        )
1652    }
1653
1654    fn meter(used: f64, quota: f64, reset: Option<&str>) -> crate::arkuse::api::Meter {
1655        let percent = if quota > 0.0 {
1656            used / quota * 100.0
1657        } else {
1658            0.0
1659        };
1660        crate::arkuse::api::Meter::from_parts("ok", percent, used, quota, reset, None)
1661    }
1662
1663    #[test]
1664    fn a_local_row_still_reads_used_over_quota_and_a_reset() {
1665        // Without the control plane the plan's published split stands in for the
1666        // quota, so the row is never AFP alone — the parity the user expects.
1667        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1668        let five = text
1669            .lines()
1670            .find(|line| line.contains("5-hour"))
1671            .expect("a 5-hour row");
1672        assert!(
1673            five.contains("/ 10,000 AFP"),
1674            "the plan's own quota: {five}"
1675        );
1676        assert!(five.contains("% of limit (est.)"), "a percentage: {five}");
1677        let weekly = text
1678            .lines()
1679            .find(|line| line.contains("Weekly"))
1680            .expect("a weekly row");
1681        assert!(weekly.contains("resets"), "a countdown: {weekly}");
1682        assert!(weekly.contains("window "), "an elapsed share: {weekly}");
1683        let json: serde_json::Value = serde_json::from_str(&render_json(&report())).unwrap();
1684        assert_eq!(json["windows"]["fiveHour"]["quota"], 10_000.0);
1685        assert!(json["windows"]["weekly"]["resetAt"].is_i64());
1686    }
1687
1688    #[test]
1689    fn a_meter_without_its_reset_falls_back_to_the_stores_boundary() {
1690        // The measured shape: a just-reset 5-hour block answers 0% with no
1691        // `reset_at`, and the row still owes the countdown.
1692        let account = crate::arkuse::api::Usage {
1693            five_hour: meter(0.0, 10_000.0, None),
1694            daily: None,
1695            weekly: meter(2_975.67, 35_000.0, Some("2026-10-26T00:00:00+08:00")),
1696            monthly: meter(2_975.67, 100_000.0, Some("2026-11-19T23:59:59+08:00")),
1697            tier: Some("medium".into()),
1698            profile: "agent-plan_cn-beijing_medium".into(),
1699        };
1700        let text = render_text(
1701            &metered(&account),
1702            "/tmp/opencode.db",
1703            10,
1704            false,
1705            DEFAULT_BAR_WIDTH,
1706        );
1707        let five = text
1708            .lines()
1709            .find(|line| line.contains("5-hour"))
1710            .expect("a 5-hour row");
1711        assert!(
1712            five.contains("0% of limit"),
1713            "the server's own share: {five}"
1714        );
1715        assert!(five.contains("resets"), "a derived countdown: {five}");
1716    }
1717
1718    #[test]
1719    fn the_bar_carries_the_share_so_the_body_does_not_repeat_it() {
1720        // The row used to read `28.5% ━━━━━━━ 9,976 / 35,000 AFP · 28.5% of
1721        // limit`: one figure, twice, because the gauge and the body each printed
1722        // it. The gauge owns the percentage; the body prints the quota pair.
1723        let text = render_text(&report(), "/tmp/opencode.db", 10, true, DEFAULT_BAR_WIDTH);
1724        let weekly = text
1725            .lines()
1726            .find(|line| line.contains("Weekly"))
1727            .expect("a weekly row");
1728        assert!(!weekly.contains("of limit"), "no repeated share: {weekly}");
1729        assert!(weekly.contains("/ 35,000 AFP"), "the pair: {weekly}");
1730        assert!(
1731            weekly.contains("(est.)"),
1732            "still marked an estimate: {weekly}"
1733        );
1734    }
1735
1736    #[test]
1737    fn the_machine_formats_are_not_coloured() {
1738        let rows = vec![Row {
1739            at_ms: 1_800_000_000_000,
1740            session: "ses_a".into(),
1741            provider: "agent-plan".into(),
1742            model: "deepseek-v4.1-flash".into(),
1743            input: 1_000,
1744            ..Default::default()
1745        }];
1746        let catalog = ark::catalog();
1747        for fmt in [Fmt::Json, Fmt::Csv] {
1748            let daily = bucket_text(
1749                &rows,
1750                &catalog,
1751                1_800_000_000_000,
1752                86_400_000,
1753                86_400_000,
1754                0,
1755                true,
1756                fmt,
1757            );
1758            assert!(!daily.contains('\u{1b}'), "{fmt:?} leaked colour: {daily}");
1759        }
1760        let report = report();
1761        let json = model_report(&report, None, Fmt::Json);
1762        assert!(!json.contains('\u{1b}'));
1763        assert!(json.contains("deepseek-v4.1-flash"));
1764        let csv = model_report(&report, None, Fmt::Csv);
1765        assert!(csv.starts_with("model,requests,tokens_in,tokens_out,cache_read,afp"));
1766    }
1767}