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, 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    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. No
352/// per-model limit exists on ARK — the quota is plan-level — so there is no USE
353/// column to invent. The share of *period* AFP moved to `top_models`, so this
354/// row states only what this model itself did.
355fn model_row(ink: &Ink, model: &crate::arkuse::window::ModelUsage) -> String {
356    let coef = format!("{:>5.2}", model.coef);
357    let coef_cell = if ink.colour {
358        format!("{}{coef}{}", ink.cyan, ink.reset)
359    } else {
360        coef
361    };
362    format!(
363        "{:<28} {coef_cell} {:>9} {:>7}\n",
364        model.id,
365        afp(model.month.afp),
366        compact(model.month.requests),
367    )
368}
369
370/// The period's three biggest models, with their share of *all* use — month AFP
371/// in one column pair, tokens (input + output) in the other.
372///
373/// The share used to hang off each model row as a single trailing `%` of AFP.
374/// It moved here because a share only means something against the whole, and
375/// the whole is what this block states; the row table below is per model with no
376/// limit to divide by (the quota is plan-level, not per model). Tokens are
377/// counted without cache reads: those run an order of magnitude larger and
378/// would make every AFP share look like a rounding error.
379fn top_models(ink: &Ink, report: &Report, count: usize) -> String {
380    let tokens = |model: &crate::arkuse::window::ModelUsage| model.month.input + model.month.output;
381    let total_afp = report.month.afp;
382    let total_tokens: u64 = report.models.iter().map(tokens).sum();
383    if report.models.is_empty() || (total_afp <= 0.0 && total_tokens == 0) {
384        return String::new();
385    }
386    let mut out = format!(
387        "\n{}TOP {} MODELS{} {} {} {} {}\n",
388        ink.bold,
389        count,
390        ink.reset,
391        ink.dim_on(format!("{:<15}", "by month AFP")),
392        ink.dim_on(format!("{:>9}", "AFP")),
393        ink.dim_on(format!("{:>8}", "SHARE")),
394        ink.dim_on(format!("{:>10} {:>8}", "TOKENS", "SHARE")),
395    );
396    for model in report.models.iter().take(count) {
397        let afp_cell = ink.cyan_on(format!(
398            "{:>9} {:>8}",
399            afp(model.month.afp),
400            pct(model.month.afp, total_afp),
401        ));
402        out.push_str(&format!(
403            "{:<28} {afp_cell} {:>10} {:>8}\n",
404            model.id,
405            compact(tokens(model)),
406            pct(tokens(model) as f64, total_tokens as f64),
407        ));
408    }
409    out
410}
411
412/// `limit` rows to show; `usize::MAX` for all.
413pub fn render_text(
414    report: &Report,
415    db_path: &str,
416    rows: usize,
417    colour: bool,
418    bar_width: usize,
419) -> String {
420    let ink = Ink::new(colour).width(bar_width);
421    let catalog = ark::catalog();
422    let c = |text: String| ink.cyan_on(text);
423    let mut out = String::new();
424    let tier_label = catalog
425        .plan(&report.tier)
426        .map(|p| p.label.clone())
427        .unwrap_or_else(|| report.tier.clone());
428    out.push_str(&format!(
429        "{}Volcengine ARK usage{} — agent plan {}, from {}{}{}\n",
430        ink.bold,
431        ink.reset,
432        tier_label,
433        ink.dim,
434        shorten(db_path),
435        ink.reset,
436    ));
437    let end = report
438        .period_end_ms
439        .map(|ms| {
440            format!(
441                " · ends {}",
442                date_of((ms + report.offset_secs * 1000).div_euclid(86_400_000))
443            )
444        })
445        .unwrap_or_default();
446    let start = date_of((report.since_ms + report.offset_secs * 1000).div_euclid(86_400_000));
447    // The period line states the one share the whole dashboard is about: how
448    // much of the plan's monthly quota is gone. It is the control plane's own
449    // number, so it prints as it arrived (`53`, never `53.0`) and is absent —
450    // not estimated — when no account answered, because the local cut has no
451    // quota of its own to divide by.
452    let used = report
453        .account
454        .as_ref()
455        .map(|account| format!(" · {}% of limit", c(given_pct(account.monthly.percent))))
456        .unwrap_or_default();
457    out.push_str(&format!(
458        "period: {start}{end} · {} requests · {} AFP ({}) · {} in / {} out tokens{used}\n",
459        c(compact(report.month.requests)),
460        c(afp(report.month.afp)),
461        c(yuan(catalog.yuan(report.month.afp))),
462        c(compact(report.month.input)),
463        c(compact(report.month.output)),
464    ));
465    if let Some(plan) = catalog.plan(&report.tier) {
466        out.push_str(&format!(
467            "{}plan: {} {} · {} AFP/mo · {} · {} windows{}",
468            ink.dim,
469            plan.label,
470            yuan(plan.price),
471            afp(plan.afp),
472            catalog.currency,
473            if report.account.is_some() {
474                "metered by the control plane"
475            } else {
476                "local estimate"
477            },
478            ink.reset,
479        ));
480        out.push('\n');
481    }
482
483    match &report.account {
484        Some(account) => {
485            out.push_str(&format!(
486                "\n{}AGENT PLAN{} {}\n",
487                ink.bold,
488                ink.reset,
489                ink.dim_on(
490                    "(account meters — what the Volcengine console reports; AFP beside them is this store's own estimate)"
491                        .into()
492                ),
493            ));
494            let monthly_dur = report
495                .period_end_ms
496                .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
497            let daily_local = report.daily.clone().unwrap_or_default();
498            let mut windows: Vec<(&str, &crate::arkuse::api::Meter, &Totals, Option<u64>, bool)> =
499                vec![(
500                    "5-hour",
501                    &account.five_hour,
502                    &report.five_hour,
503                    Some(crate::arkuse::window::FIVE_HOUR_SECS as u64),
504                    false,
505                )];
506            if let Some(daily) = &account.daily {
507                windows.push((
508                    "Daily",
509                    daily,
510                    &daily_local,
511                    Some(crate::arkuse::window::DAY_SECS as u64),
512                    false,
513                ));
514            }
515            windows.push((
516                "Weekly",
517                &account.weekly,
518                &report.weekly,
519                Some(crate::arkuse::window::WEEKLY_SECS as u64),
520                false,
521            ));
522            windows.push((
523                "Monthly",
524                &account.monthly,
525                &report.month,
526                monthly_dur,
527                true,
528            ));
529            for (label, meter, used, dur, pro_rata) in windows {
530                // A just-reset block answers without `reset_at` (measured
531                // 2026-10-20: `{"label":"5h","total":10000,"percent":0}`), so
532                // fall back to the store's own rolling boundary rather than
533                // drop the countdown the row is asked for.
534                let reset = meter_reset(meter, dur)
535                    .or_else(|| dur.and_then(|dur| rolling(report, used, dur as i64)));
536                out.push_str(&window_line(
537                    &ink,
538                    &RowSpec {
539                        label,
540                        used,
541                        meter: Some(meter),
542                        reset,
543                        quota: None,
544                        pro_rata,
545                    },
546                    report.now_ms,
547                    &catalog,
548                ));
549            }
550            // Reconciliation is the point of holding both sources: the meter is
551            // account-wide, the store is this machine, so a gap is either another
552            // client or an unmodelled item — worth printing, never hidden.
553            let mut notes: Vec<String> = Vec::new();
554            if let Some(delta) = report.reconcile() {
555                notes.push(format!("local vs meter {:+.1}%", delta));
556            }
557            if let Some(per) = report.afp_per_request() {
558                notes.push(format!("{} AFP/request", afp(per)));
559            }
560            if let Some(burn) = report.burn_per_day() {
561                notes.push(format!("{} AFP/day at this pace", afp(burn)));
562            }
563            if !notes.is_empty() {
564                out.push_str(&format!(
565                    "{}    {} · {}{}\n",
566                    ink.dim,
567                    account.profile,
568                    notes.join(" · "),
569                    ink.reset,
570                ));
571            }
572        }
573        None => {
574            out.push_str(&format!(
575                "\n{}AGENT PLAN{} {}\n",
576                ink.bold,
577                ink.reset,
578                ink.dim_on("(estimated locally: no control-plane answer, so AFP alone)".into()),
579            ));
580            if let Some(why) = &report.account_error {
581                out.push_str(&format!("{}   usage API: {why}{}\n", ink.dim, ink.reset));
582            }
583            let monthly_dur = report
584                .period_end_ms
585                .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
586            // No meter: the catalogue's published split stands in for the
587            // quota, and the reset comes from the oldest row still inside the
588            // rolling cut — never a fabricated `now + length`.
589            let five_reset = rolling(
590                report,
591                &report.five_hour,
592                crate::arkuse::window::FIVE_HOUR_SECS,
593            );
594            let week_reset = rolling(report, &report.weekly, crate::arkuse::window::WEEKLY_SECS);
595            out.push_str(&window_line(
596                &ink,
597                &RowSpec {
598                    label: "5-hour",
599                    used: &report.five_hour,
600                    meter: None,
601                    reset: five_reset,
602                    quota: report.window_quota(&catalog, catalog.windows.five_hour),
603                    pro_rata: false,
604                },
605                report.now_ms,
606                &catalog,
607            ));
608            out.push_str(&window_line(
609                &ink,
610                &RowSpec {
611                    label: "Weekly",
612                    used: &report.weekly,
613                    meter: None,
614                    reset: week_reset,
615                    quota: report.window_quota(&catalog, catalog.windows.weekly),
616                    pro_rata: false,
617                },
618                report.now_ms,
619                &catalog,
620            ));
621            out.push_str(&window_line(
622                &ink,
623                &RowSpec {
624                    label: "Monthly",
625                    used: &report.month,
626                    meter: None,
627                    reset: report.period_end_ms.zip(monthly_dur),
628                    quota: report.window_quota(&catalog, catalog.windows.monthly),
629                    pro_rata: true,
630                },
631                report.now_ms,
632                &catalog,
633            ));
634        }
635    }
636
637    out.push_str(&top_models(&ink, report, 3));
638    // The header carries the row's own widths so the two cannot drift: every
639    // label is right-aligned in the field its cell occupies, and the name field
640    // is left-aligned to the model id.
641    out.push_str(&format!(
642        "\n{}{:<28} {:>5} {:>9} {:>7}{}\n",
643        ink.bold, "MODEL", "COEF", "AFP", "REQ", ink.reset
644    ));
645    for model in report.models.iter().take(rows) {
646        out.push_str(&model_row(&ink, model));
647    }
648    if report.models.len() > rows {
649        out.push_str(&format!(
650            "{}\n",
651            ink.dim_on(format!("… {} more", report.models.len() - rows))
652        ));
653    }
654    // The subtlety that makes local AFP disagree with the meter: composition.
655    out.push_str(&format!(
656        "\n{}\n",
657        ink.dim_on(
658            "AFP = (input + cache_read + output) × coefficient / 10,000; the coefficient is the plan's own and a promo halves it until its date."
659                .into()
660        ),
661    ));
662    if report.account.is_some() {
663        out.push_str(&format!(
664            "{}\n",
665            ink.dim_on(
666                "The windows are the account's own cuts (5h block, Monday week, subscription month); the model AFP is this machine inside them."
667                    .into()
668            )
669        ));
670    } else {
671        out.push_str(&format!(
672            "{}\n",
673            ink.dim_on(
674                "Windows are rolling 5h / 7d / calendar month cuts, local only — not the control plane's anchors."
675                    .into()
676            )
677        ));
678    }
679    out
680}
681
682/// One watch frame: the dashboard, then an AFP-burst sparkline when the 5-hour
683/// figure has moved since the previous refresh. No trailing newline — the caller
684/// appends its own status line and `redraw_frame` (cmduse's) parks the cursor
685/// there, which is what lets the countdown rewrite a single line per second.
686pub fn watch_frame(
687    report: &Report,
688    db_path: &str,
689    rows: usize,
690    colour: bool,
691    history: &[f64],
692    interval_secs: u64,
693    bar_width: usize,
694) -> String {
695    let ink = Ink::new(colour);
696    let mut out = render_text(report, db_path, rows, colour, bar_width);
697    if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
698        out.push_str(&format!(
699            "{}AFP bursts ({}s samples){} {}\n",
700            ink.dim,
701            interval_secs,
702            ink.reset,
703            crate::render::sparkline(history),
704        ));
705    }
706    out.trim_end_matches('\n').to_string()
707}
708
709/// Minimal one-line dashboard for terminals too small for the full frame.
710pub fn compact_line(report: &Report, history: &[f64], colour: bool) -> String {
711    let ink = Ink::new(colour);
712    let catalog = ark::catalog();
713    let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
714        format!(
715            " {}{}{}",
716            ink.dim,
717            crate::render::sparkline(history),
718            ink.reset
719        )
720    } else {
721        String::new()
722    };
723    let window_share = match &report.account {
724        Some(account) => format!(
725            " · 5h {} · wk {} · mo {}",
726            ink.cyan_on(format!("{}%", given_pct(account.five_hour.percent))),
727            ink.cyan_on(format!("{}%", given_pct(account.weekly.percent))),
728            ink.cyan_on(format!("{}%", given_pct(account.monthly.percent))),
729        ),
730        None => format!(" · 5h {}", ink.cyan_on(afp(report.five_hour.afp))),
731    };
732    format!(
733        "{}ARK{} {} AFP ({}) month{window_share}{spark}",
734        ink.bold,
735        ink.reset,
736        ink.cyan_on(afp(report.month.afp)),
737        ink.cyan_on(yuan(catalog.yuan(report.month.afp))),
738    )
739}
740
741/// arkuse's statusline: cmduse's `{key}` template engine
742/// (`report_render::render_template`) resolved against this report's own
743/// placeholders.
744///
745/// Placeholders (case-insensitive):
746///   {plan} {tier} {period}                 tier id / period label
747///   {month} {month_pct} {month_bar}
748///   {five_hour} {five_hour_pct} {five_hour_bar}   (aliases {5h} {5h_pct} {5h_bar})
749///   {weekly} {weekly_pct} {weekly_bar}            (aliases {wk} {wk_pct} {wk_bar})
750///   {yuan} {yuan_month}                    this store's AFP in yuan
751///   {models}                               top model by period AFP
752pub fn statusline_template(
753    report: &Report,
754    plan_id: &str,
755    tpl: &str,
756    colours: bool,
757    ascii: bool,
758    bar_width: usize,
759) -> String {
760    let ink = Ink::new(colours);
761    let catalog = ark::catalog();
762    let account = report.account.as_ref();
763    let quota_of = |share: f64| report.window_quota(&catalog, share);
764    // The console's own percentage when it answered, else this store's AFP
765    // against the plan's published split — never a dash for a window we can
766    // measure ourselves. The plan's quota is public, so this is not a guess.
767    let pct_of = |meter: Option<&crate::arkuse::api::Meter>, used: f64, share: f64| match meter {
768        Some(meter) => Some(meter.percent),
769        None => quota_of(share)
770            .filter(|quota| *quota > 0.0)
771            .map(|quota| used / quota * 100.0),
772    };
773    let share_of = |pct: Option<f64>| match pct {
774        Some(pct) => format!("{}%", given_pct(pct)),
775        None => "—".into(),
776    };
777    let share_bar = |pct: Option<f64>| match pct {
778        Some(pct) => crate::report_render::bar(pct, bar_width, ascii, colours),
779        None => String::new(),
780    };
781    // A window's boundary: the server's reset, else the oldest row still inside
782    // the rolling cut plus the window's length.
783    let reset_of =
784        |meter: Option<&crate::arkuse::api::Meter>, totals: &Totals, dur: i64| match meter
785            .and_then(|m| m.reset_ms())
786        {
787            Some(at) => Some((at, dur as u64)),
788            None => report.rolling_reset(totals, dur).map(|at| (at, dur as u64)),
789        };
790    let eta_of = |reset: Option<(i64, u64)>, used: f64, quota: Option<f64>| -> Option<String> {
791        let (at, dur) = reset?;
792        let quota = quota?;
793        let secs = cmduse_core::pace_eta(
794            Some(at as f64),
795            dur,
796            used,
797            quota,
798            (report.now_ms / 1000).max(0) as u64,
799        )?;
800        Some(format!("on pace to hit cap in {}", duration(secs as u64)))
801    };
802    let h5_pct = pct_of(
803        account.map(|a| &a.five_hour),
804        report.five_hour.afp,
805        catalog.windows.five_hour,
806    );
807    let wk_pct = pct_of(
808        account.map(|a| &a.weekly),
809        report.weekly.afp,
810        catalog.windows.weekly,
811    );
812    let month_pct = pct_of(
813        account.map(|a| &a.monthly),
814        report.month.afp,
815        catalog.windows.monthly,
816    );
817    let h5_eta = eta_of(
818        reset_of(
819            account.map(|a| &a.five_hour),
820            &report.five_hour,
821            crate::arkuse::window::FIVE_HOUR_SECS,
822        ),
823        report.five_hour.afp,
824        quota_of(catalog.windows.five_hour),
825    )
826    .unwrap_or_default();
827    let wk_eta = eta_of(
828        reset_of(
829            account.map(|a| &a.weekly),
830            &report.weekly,
831            crate::arkuse::window::WEEKLY_SECS,
832        ),
833        report.weekly.afp,
834        quota_of(catalog.windows.weekly),
835    )
836    .unwrap_or_default();
837    let cash = |value: f64| ink.cyan_on(afp(value));
838    let top = report
839        .models
840        .first()
841        .map(|m| m.id.clone())
842        .unwrap_or_else(|| "—".into());
843    let replacements: Vec<(&str, String)> = vec![
844        ("plan", plan_id.to_string()),
845        ("tier", plan_id.to_string()),
846        ("period", report.period_label.clone()),
847        ("month", cash(report.month.afp)),
848        ("month_pct", share_of(month_pct)),
849        ("month_bar", share_bar(month_pct)),
850        ("five_hour", cash(report.five_hour.afp)),
851        ("5h", cash(report.five_hour.afp)),
852        ("five_hour_pct", share_of(h5_pct)),
853        ("5h_pct", share_of(h5_pct)),
854        ("five_hour_bar", share_bar(h5_pct)),
855        ("5h_bar", share_bar(h5_pct)),
856        ("5h_eta", h5_eta),
857        ("weekly", cash(report.weekly.afp)),
858        ("wk", cash(report.weekly.afp)),
859        ("weekly_pct", share_of(wk_pct)),
860        ("wk_pct", share_of(wk_pct)),
861        ("weekly_bar", share_bar(wk_pct)),
862        ("wk_bar", share_bar(wk_pct)),
863        ("wk_eta", wk_eta),
864        ("yuan", yuan(catalog.yuan(report.month.afp))),
865        ("yuan_month", yuan(catalog.yuan(report.month.afp))),
866        ("models", top),
867    ];
868    crate::report_render::render_template(tpl, |key| {
869        let lower = key.to_ascii_lowercase();
870        replacements
871            .iter()
872            .find(|(name, _)| *name == lower)
873            .map(|(_, value)| value.clone())
874            .unwrap_or_default()
875    })
876}
877
878pub fn statusline_json(report: &Report, plan_id: &str) -> String {
879    let catalog = ark::catalog();
880    let quota_of = |share: f64| report.window_quota(&catalog, share);
881    serde_json::json!({
882        "plan": plan_id,
883        "period": report.period_label,
884        "source": "opencode",
885        // The shared names a statusline consumer already reads; here they carry
886        // AFP, because AFP is this plan's currency. The caps are the plan's
887        // published split of its monthly AFP, so a consumer never has to hold a
888        // table of its own.
889        "monthSpent": report.month.afp,
890        "monthCap": quota_of(catalog.windows.monthly),
891        "fiveHourSpent": report.five_hour.afp,
892        "fiveHourCap": quota_of(catalog.windows.five_hour),
893        "weeklySpent": report.weekly.afp,
894        "weeklyCap": quota_of(catalog.windows.weekly),
895        "model": report.models.first().map(|m| m.id.clone()),
896        "afp": {
897            "fiveHour": report.five_hour.afp,
898            "weekly": report.weekly.afp,
899            "monthly": report.month.afp,
900            "yuan": catalog.yuan(report.month.afp),
901        },
902        "account": report.account.as_ref().map(usage_json),
903        "accountError": report.account_error,
904        "periodEnd": report.period_end_ms,
905    })
906    .to_string()
907}
908
909/// One meter as JSON, with the server's own figures preserved — the percentage
910/// is the console's, not a recomputation.
911/// One window as this store measures it: AFP, the plan's published quota, the
912/// percentage against it, and the boundary its row counts down to.
913fn window_json(totals: &Totals, quota: Option<f64>, reset_ms: Option<i64>) -> serde_json::Value {
914    serde_json::json!({
915        "afp": totals.afp,
916        "requests": totals.requests,
917        "quota": quota,
918        "percent": quota
919            .filter(|quota| *quota > 0.0)
920            .map(|quota| totals.afp / quota * 100.0),
921        "resetAt": reset_ms,
922    })
923}
924
925fn meter_json(meter: &crate::arkuse::api::Meter) -> serde_json::Value {
926    serde_json::json!({
927        "used": meter.used,
928        "total": meter.quota,
929        "percent": meter.percent,
930        "status": meter.status,
931        "resetsAt": meter.resets_at,
932        "resetAt": meter.reset_ms(),
933    })
934}
935
936/// Every window the control plane answered, keeping the plan's own shape — a
937/// text-only workload has no `daily` period, so that key is simply absent.
938fn usage_json(usage: &crate::arkuse::api::Usage) -> serde_json::Value {
939    serde_json::json!({
940        "profile": usage.profile,
941        "tier": usage.tier,
942        "fiveHour": meter_json(&usage.five_hour),
943        "daily": usage.daily.as_ref().map(meter_json),
944        "weekly": meter_json(&usage.weekly),
945        "monthly": meter_json(&usage.monthly),
946    })
947}
948
949pub fn render_json(report: &Report) -> String {
950    let catalog = ark::catalog();
951    let totals = |t: &Totals| {
952        serde_json::json!({
953            "afp": t.afp,
954            "requests": t.requests,
955            "tokensIn": t.input,
956            "tokensOut": t.output,
957            "cacheRead": t.cache_read,
958            "cacheWrite": t.cache_write,
959        })
960    };
961    let models: serde_json::Map<String, serde_json::Value> = report
962        .models
963        .iter()
964        .map(|m| {
965            (
966                m.id.clone(),
967                serde_json::json!({
968                    "coef": m.coef,
969                    "month": totals(&m.month),
970                    "fiveHour": totals(&m.five_hour),
971                    "weekly": totals(&m.weekly),
972                }),
973            )
974        })
975        .collect();
976    serde_json::json!({
977        "source": "opencode",
978        "scope": "local",
979        "plan": report.tier,
980        "profile": report.profile,
981        "periodStart": report.since_ms,
982        "periodEndAt": report.period_end_ms,
983        "periodEnd": report.period_end_ms
984            .map(|ms| date_of((ms + report.offset_secs * 1000).div_euclid(86_400_000))),
985        "monthInferred": report.month_inferred,
986        "summary": {
987            "afp": report.month.afp,
988            "yuan": ark::catalog().yuan(report.month.afp),
989            "requests": report.month.requests,
990            "tokensIn": report.month.input,
991            "tokensOut": report.month.output,
992            "cacheRead": report.month.cache_read,
993        },
994        "fiveHour": totals(&report.five_hour),
995        "weekly": totals(&report.weekly),
996        "monthly": totals(&report.month),
997        "daily": report.daily.as_ref().map(totals),
998        // This store's view of each window against the plan's published quota —
999        // the same pair the dashboard prints, so a consumer without the account
1000        // answer still gets a percentage and a boundary.
1001        "windows": {
1002            "fiveHour": window_json(
1003                &report.five_hour,
1004                report.window_quota(&catalog, catalog.windows.five_hour),
1005                report.rolling_reset(&report.five_hour, crate::arkuse::window::FIVE_HOUR_SECS),
1006            ),
1007            "weekly": window_json(
1008                &report.weekly,
1009                report.window_quota(&catalog, catalog.windows.weekly),
1010                report.rolling_reset(&report.weekly, crate::arkuse::window::WEEKLY_SECS),
1011            ),
1012            "monthly": window_json(
1013                &report.month,
1014                report.window_quota(&catalog, catalog.windows.monthly),
1015                report.period_end_ms,
1016            ),
1017        },
1018        "account": report.account.as_ref().map(|account| {
1019            let mut body = serde_json::json!({
1020                "profile": account.profile,
1021                "tier": account.tier,
1022                "fiveHour": meter_json(&account.five_hour),
1023                "weekly": meter_json(&account.weekly),
1024                "monthly": meter_json(&account.monthly),
1025            });
1026            if let Some(daily) = &account.daily {
1027                body["daily"] = meter_json(daily);
1028            }
1029            body
1030        }),
1031        "accountError": report.account_error,
1032        "reconciledPct": report.reconcile(),
1033        "afpPerRequest": report.afp_per_request(),
1034        "models": models,
1035    })
1036    .to_string()
1037}
1038
1039/// The tier catalogue: what each plan costs, what quota it grants, and how that
1040/// quota is split across the windows. The window figures are the plan's own
1041/// fractions of its monthly AFP — the same table the console shows per tier.
1042pub fn plans_text(colour: bool) -> String {
1043    let ink = Ink::new(colour);
1044    let catalog = ark::catalog();
1045    let mut out = format!(
1046        "{}{:<8} {:>9} {:>9} {:>10} {:>10} {:>10}{}\n",
1047        ink.bold, "TIER", "YUAN/MO", "AFP/MO", "5H AFP", "DAILY AFP", "WK AFP", ink.reset
1048    );
1049    for plan in &catalog.plans {
1050        out.push_str(&format!(
1051            "{:<8} {:>9} {:>9} {:>10} {:>10} {:>10}\n",
1052            plan.label,
1053            ink.cyan_on(yuan(plan.price)),
1054            ink.cyan_on(afp(plan.afp)),
1055            afp(plan.afp * catalog.windows.five_hour),
1056            afp(plan.afp * catalog.windows.daily),
1057            afp(plan.afp * catalog.windows.weekly),
1058        ));
1059        if let Some(promo) = &plan.promo {
1060            out.push_str(&format!(
1061                "{}{} for {} months, until {}{}\n",
1062                ink.dim,
1063                yuan(promo.price),
1064                promo.months.unwrap_or(1),
1065                promo.until,
1066                ink.reset,
1067            ));
1068        }
1069    }
1070    out.push_str(&format!(
1071        "\n{}1 AFP = ¥{:.3} at every tier · 1,000 AFP = {}{}\n",
1072        ink.dim,
1073        catalog.afp_yuan,
1074        yuan(catalog.yuan(1000.0)),
1075        ink.reset,
1076    ));
1077    if !catalog.enterprise.is_empty() {
1078        out.push_str(&format!(
1079            "\n{}ENTERPRISE{} {}\n",
1080            ink.bold,
1081            ink.reset,
1082            ink.dim_on(
1083                "(seat-based; AFP is metered per seat and the personal usage API does not expose it)"
1084                    .into()
1085            ),
1086        ));
1087        for plan in &catalog.enterprise {
1088            out.push_str(&format!(
1089                "  {:<22} {}\n",
1090                plan.label,
1091                ink.cyan_on(format!("{}/seat/mo", yuan(plan.price))),
1092            ));
1093        }
1094    }
1095    if let Some(note) = catalog.notes.get("windows") {
1096        out.push_str(&format!("\n{}{}{}\n", ink.dim, note, ink.reset));
1097    }
1098    out
1099}
1100
1101pub fn plans_json() -> String {
1102    let catalog = ark::catalog();
1103    serde_json::json!({
1104        "source": catalog.source,
1105        "extractedAt": catalog.extracted_at,
1106        "currency": catalog.currency,
1107        "afpYuan": catalog.afp_yuan,
1108        "windows": {
1109            "fiveHour": catalog.windows.five_hour,
1110            "daily": catalog.windows.daily,
1111            "weekly": catalog.windows.weekly,
1112            "monthly": catalog.windows.monthly,
1113        },
1114        "plans": catalog.plans.iter().map(|p| serde_json::json!({
1115            "id": p.id,
1116            "label": p.label,
1117            "price": p.price,
1118            "listPrice": p.list_price,
1119            "afp": p.afp,
1120            "promo": p.promo.as_ref().map(|promo| serde_json::json!({
1121                "price": promo.price,
1122                "until": promo.until,
1123                "months": promo.months,
1124            })),
1125        })).collect::<Vec<_>>(),
1126        "enterprise": catalog.enterprise.iter().map(|p| serde_json::json!({
1127            "id": p.id,
1128            "label": p.label,
1129            "price": p.price,
1130        })).collect::<Vec<_>>(),
1131        "modelCount": catalog.models.len(),
1132    })
1133    .to_string()
1134}
1135
1136/// `y` yuan per million tokens at a coefficient — the same quantity the docs
1137/// publish per model, so a reader can check a coefficient without arithmetic:
1138/// 1M tokens × coef / 10,000 = 100·coef AFP, and 1 AFP = ¥0.002.
1139fn yuan_per_million(coef: f64, afp_yuan: f64) -> f64 {
1140    1_000_000.0 * coef / 10_000.0 * afp_yuan
1141}
1142
1143/// The model catalogue with its coefficient in force today. `gated` keeps only
1144/// what `plan_id` grants; the table always says which tier a model needs, because
1145/// the plan you hold changes what you can call.
1146pub fn models_text(plan_id: &str, gated: bool, colour: bool) -> String {
1147    let ink = Ink::new(colour);
1148    let catalog = ark::catalog();
1149    let now = crate::arkuse::window::now_ms();
1150    let mut out = format!(
1151        "{}{:<30} {:>6} {:>7} {:>10} {:>8} {:>8}{}\n",
1152        ink.bold, "MODEL", "KIND", "COEF", "YUAN/1M", "MIN", "GRANTED", ink.reset
1153    );
1154    for model in &catalog.models {
1155        let granted = catalog.grants(plan_id, &model.min_plan);
1156        if gated && !granted {
1157            continue;
1158        }
1159        let coef = ark::effective_coef(model, now);
1160        let promo = model.promo_coef.map(|_| {
1161            format!(
1162                " (promo until {})",
1163                model.promo_until.as_deref().unwrap_or("?")
1164            )
1165        });
1166        let coef_cell = ink.cyan_on(format!("{coef:.2}"));
1167        out.push_str(&format!(
1168            "{:<30} {:>6} {:>7} {:>10} {:>8} {:>8}",
1169            model.id,
1170            model.kind,
1171            coef_cell,
1172            format!("{:.2}", yuan_per_million(coef, catalog.afp_yuan)),
1173            model.min_plan,
1174            if granted { "yes" } else { "no" },
1175        ));
1176        if let Some(promo) = promo {
1177            out.push_str(&format!("{}{promo}{}", ink.dim, ink.reset));
1178        }
1179        out.push('\n');
1180    }
1181    out
1182}
1183
1184/// One JSON object per line, mirroring `models_text` — the shape cmduse's own
1185/// `models --json` uses, so a consumer pipes either binary the same way.
1186pub fn models_json(plan_id: &str, gated: bool) -> String {
1187    let catalog = ark::catalog();
1188    let now = crate::arkuse::window::now_ms();
1189    let mut lines: Vec<String> = Vec::new();
1190    for model in &catalog.models {
1191        let granted = catalog.grants(plan_id, &model.min_plan);
1192        if gated && !granted {
1193            continue;
1194        }
1195        let coef = ark::effective_coef(model, now);
1196        lines.push(
1197            serde_json::json!({
1198                "id": model.id,
1199                "name": model.name,
1200                "kind": model.kind,
1201                "coef": coef,
1202                "baseCoef": model.coef,
1203                "promoCoef": model.promo_coef,
1204                "promoUntil": model.promo_until,
1205                "yuanPerMillion": yuan_per_million(coef, catalog.afp_yuan),
1206                "minPlan": model.min_plan,
1207                "granted": granted,
1208            })
1209            .to_string(),
1210        );
1211    }
1212    lines.join("\n")
1213}
1214
1215/// One model's period record: what it cost in AFP, where that AFP sits in each
1216/// window, and how much of the period it accounts for.
1217pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
1218    let ink = Ink::new(colour);
1219    let Some(model) = report.models.iter().find(|m| m.id == id) else {
1220        return format!("{id}: no usage in the period\n");
1221    };
1222    let row =
1223        |label: &str, value: String| format!("  {}{value}\n", ink.dim_on(format!("{label:<10}")));
1224    let mut out = format!(
1225        "{} {}\n",
1226        ink.bold_on(model.id.clone()),
1227        ink.dim_on(format!("({})", model.provider)),
1228    );
1229    out.push_str(&row(
1230        "coef",
1231        ink.cyan_on(format!(
1232            "{:.2} · {} per million tokens",
1233            model.coef,
1234            yuan(yuan_per_million(model.coef, ark::catalog().afp_yuan))
1235        )),
1236    ));
1237    out.push_str(&row(
1238        "month",
1239        ink.cyan_on(format!(
1240            "{} AFP ({}) · {} requests",
1241            afp(model.month.afp),
1242            yuan(ark::catalog().yuan(model.month.afp)),
1243            model.month.requests
1244        )),
1245    ));
1246    out.push_str(&row(
1247        "5-hour",
1248        ink.cyan_on(format!("{} AFP", afp(model.five_hour.afp))),
1249    ));
1250    out.push_str(&row(
1251        "weekly",
1252        ink.cyan_on(format!("{} AFP", afp(model.weekly.afp))),
1253    ));
1254    if report.month.afp > 0.0 {
1255        out.push_str(&row(
1256            "share",
1257            ink.dim_on(format!(
1258                "{:.1}% of the period's AFP",
1259                model.month.afp / report.month.afp * 100.0
1260            )),
1261        ));
1262    }
1263    out
1264}
1265
1266/// Per-model report: `Json` mirrors cmduse's `model --json` envelope with AFP in
1267/// place of dollars; `Csv` is one row per model. `Table` is the caller's.
1268pub fn model_report(report: &Report, id: Option<&str>, fmt: Fmt) -> String {
1269    let models: Vec<&crate::arkuse::window::ModelUsage> = report
1270        .models
1271        .iter()
1272        .filter(|m| id.is_none_or(|want| m.id == want))
1273        .collect();
1274    match fmt {
1275        Fmt::Json => {
1276            let map: serde_json::Map<String, serde_json::Value> = models
1277                .iter()
1278                .map(|m| {
1279                    (
1280                        m.id.clone(),
1281                        serde_json::json!({
1282                            "coef": m.coef,
1283                            "requests": m.month.requests,
1284                            "tokensIn": m.month.input,
1285                            "tokensOut": m.month.output,
1286                            "cacheRead": m.month.cache_read,
1287                            "afp": m.month.afp,
1288                            "yuan": ark::catalog().yuan(m.month.afp),
1289                        }),
1290                    )
1291                })
1292                .collect();
1293            serde_json::json!({
1294                "scope": "local",
1295                "source": "opencode",
1296                "sinceMs": report.since_ms,
1297                "models": map,
1298            })
1299            .to_string()
1300        }
1301        Fmt::Csv => {
1302            let mut out = String::from("model,requests,tokens_in,tokens_out,cache_read,afp\n");
1303            for m in models {
1304                out.push_str(&format!(
1305                    "{},{},{},{},{},{:.2}\n",
1306                    crate::report_render::csv_field(&m.id),
1307                    m.month.requests,
1308                    m.month.input,
1309                    m.month.output,
1310                    m.month.cache_read,
1311                    m.month.afp,
1312                ));
1313            }
1314            out
1315        }
1316        Fmt::Table => String::new(),
1317    }
1318}
1319
1320/// The period's per-model breakdown as a table — what `arkuse model` with no id
1321/// prints. Columns mirror the dashboard's model block so a reader can put the
1322/// two side by side.
1323pub fn models_period_text(report: &Report, limit: usize, colour: bool) -> String {
1324    let ink = Ink::new(colour);
1325    let mut out = format!(
1326        "{}{:<28} {:>5} {:>9} {:>7} {:>6}{}\n",
1327        ink.bold, "MODEL", "COEF", "AFP", "REQ", "SHARE", ink.reset
1328    );
1329    let total = report.month.afp;
1330    for model in report.models.iter().take(limit.min(report.models.len())) {
1331        let share = if total > 0.0 {
1332            ink.dim_on(format!("{:>5.1}%", model.month.afp / total * 100.0))
1333        } else {
1334            "     -".to_string()
1335        };
1336        out.push_str(&format!(
1337            "{:<28} {} {:>9} {:>7} {}\n",
1338            model.id,
1339            ink.cyan_on(format!("{:>5.2}", model.coef)),
1340            ink.cyan_on(format!("{:>9}", afp(model.month.afp))),
1341            ink.cyan_on(format!("{:>7}", compact(model.month.requests))),
1342            share,
1343        ));
1344    }
1345    if report.models.is_empty() {
1346        out.push_str(&format!(
1347            "{}\n",
1348            ink.dim_on("(no agent-plan usage in the period)".into())
1349        ));
1350    } else if total > 0.0 {
1351        if report.models.len() > limit {
1352            out.push_str(&format!(
1353                "{}\n",
1354                ink.dim_on(format!("… {} more", report.models.len() - limit))
1355            ));
1356        }
1357        out.push_str(&format!(
1358            "{:<28} {:>5} {:>9} {:>7} {:>6}\n",
1359            ink.bold_on("TOTAL".into()),
1360            "",
1361            afp(total),
1362            compact(report.month.requests),
1363            "100.0%",
1364        ));
1365    }
1366    out
1367}
1368
1369/// The store's numeric fields, in the shape every machine format in this repo
1370/// uses, with AFP standing where the other binaries put dollars.
1371fn totals_json(
1372    afp: f64,
1373    requests: u64,
1374    input: u64,
1375    output: u64,
1376    cache_read: u64,
1377) -> serde_json::Value {
1378    serde_json::json!({
1379        "requests": requests,
1380        "tokensIn": input,
1381        "tokensOut": output,
1382        "cacheRead": cache_read,
1383        "afp": afp,
1384    })
1385}
1386
1387/// One day/hour bucket. AFP is accumulated per row, because the coefficient is
1388/// a property of the model and of the day it ran (a promo can expire mid-window).
1389#[derive(Default, Clone, Copy)]
1390struct Bucket {
1391    afp: f64,
1392    requests: u64,
1393    input: u64,
1394    output: u64,
1395    cache_read: u64,
1396}
1397
1398impl Bucket {
1399    fn add(&mut self, catalog: &ark::Catalog, row: &crate::arkuse::db::Row) {
1400        self.requests += 1;
1401        self.input += row.input;
1402        self.output += row.output;
1403        self.cache_read += row.cache_read;
1404        self.afp += catalog
1405            .afp(&row.model, row.at_ms, row.input, row.cache_read, row.output)
1406            .unwrap_or(0.0);
1407    }
1408
1409    fn merge(&mut self, other: &Bucket) {
1410        self.afp += other.afp;
1411        self.requests += other.requests;
1412        self.input += other.input;
1413        self.output += other.output;
1414        self.cache_read += other.cache_read;
1415    }
1416
1417    fn json(&self) -> serde_json::Value {
1418        totals_json(
1419            self.afp,
1420            self.requests,
1421            self.input,
1422            self.output,
1423            self.cache_read,
1424        )
1425    }
1426}
1427
1428/// AFP per bucket (day or hour), oldest first — `arkuse daily` / `arkuse hourly`.
1429#[allow(clippy::too_many_arguments)]
1430pub fn bucket_text(
1431    rows: &[crate::arkuse::db::Row],
1432    catalog: &ark::Catalog,
1433    now_ms: i64,
1434    lookback_ms: i64,
1435    size_ms: i64,
1436    tz_secs: i64,
1437    colour: bool,
1438    fmt: Fmt,
1439) -> String {
1440    // Buckets are cut in the display timezone: shift, bucket, then label.
1441    let shift = tz_secs * 1000;
1442    let mut buckets: std::collections::BTreeMap<i64, Bucket> = Default::default();
1443    for row in rows {
1444        let bucket = (row.at_ms + shift).div_euclid(size_ms);
1445        buckets.entry(bucket).or_default().add(catalog, row);
1446    }
1447    let start = (now_ms + shift - lookback_ms).div_euclid(size_ms);
1448    let daily = size_ms >= crate::arkuse::window::DAY_SECS * 1000;
1449    let label = if daily { "day" } else { "hour" };
1450    let stamp = |bucket: i64| {
1451        if daily {
1452            date_of(bucket)
1453        } else {
1454            format!(
1455                "{} {:02}h",
1456                date_of(bucket.div_euclid(24)),
1457                bucket.rem_euclid(24)
1458            )
1459        }
1460    };
1461    let window: Vec<(i64, Bucket)> = buckets
1462        .range(start..)
1463        .map(|(bucket, b)| (*bucket, *b))
1464        .collect();
1465
1466    match fmt {
1467        Fmt::Json => {
1468            let mut keyed = serde_json::Map::new();
1469            let mut total = Bucket::default();
1470            for (bucket, b) in &window {
1471                keyed.insert(stamp(*bucket), b.json());
1472                total.merge(b);
1473            }
1474            let key = if daily { "days" } else { "hours" };
1475            serde_json::json!({
1476                "scope": "local",
1477                "source": "opencode",
1478                "total": total.json(),
1479                key: keyed,
1480            })
1481            .to_string()
1482        }
1483        Fmt::Csv => {
1484            let mut out = format!("{label},requests,tokens_in,tokens_out,cache_read,afp\n");
1485            let mut total = Bucket::default();
1486            for (bucket, b) in &window {
1487                out.push_str(&format!(
1488                    "{},{},{},{},{},{:.2}\n",
1489                    crate::report_render::csv_field(&stamp(*bucket)),
1490                    b.requests,
1491                    b.input,
1492                    b.output,
1493                    b.cache_read,
1494                    b.afp,
1495                ));
1496                total.merge(b);
1497            }
1498            out.push_str(&format!(
1499                "total,{},{},{},{},{:.2}\n",
1500                total.requests, total.input, total.output, total.cache_read, total.afp,
1501            ));
1502            out
1503        }
1504        Fmt::Table => {
1505            let ink = Ink::new(colour);
1506            let mut out = format!(
1507                "{}{label:<12} AFP      requests   tokens{}\n",
1508                ink.bold, ink.reset
1509            );
1510            for (bucket, b) in &window {
1511                out.push_str(&format!(
1512                    "{:<12} {} {} {}\n",
1513                    stamp(*bucket),
1514                    ink.cyan_on(format!("{:>9}", afp(b.afp))),
1515                    ink.cyan_on(format!("{:>12}", compact(b.requests))),
1516                    ink.cyan_on(format!("{:>9}", compact(b.input + b.output + b.cache_read))),
1517                ));
1518            }
1519            if out.lines().count() == 1 {
1520                out.push_str(&format!(
1521                    "{}\n",
1522                    ink.dim_on("(no agent-plan usage in the window)".into())
1523                ));
1524            }
1525            out
1526        }
1527    }
1528}
1529
1530/// One session's accumulating totals: `(afp, requests, tokens_in, tokens_out,
1531/// cache_read, last_ms, distinct models)`.
1532type SessionTotals<'a> = (
1533    f64,
1534    u64,
1535    u64,
1536    u64,
1537    u64,
1538    i64,
1539    std::collections::BTreeSet<&'a str>,
1540);
1541
1542/// AFP per session, newest first — the same columns as `ocuse session`, with AFP
1543/// in place of USD.
1544pub fn session_text(
1545    rows: &[crate::arkuse::db::Row],
1546    catalog: &ark::Catalog,
1547    limit: usize,
1548    colour: bool,
1549    fmt: Fmt,
1550) -> String {
1551    let mut by_session: std::collections::BTreeMap<&str, SessionTotals> = Default::default();
1552    for row in rows {
1553        let entry = by_session
1554            .entry(row.session.as_str())
1555            .or_insert_with(|| (0.0, 0, 0, 0, 0, 0, Default::default()));
1556        entry.0 += catalog
1557            .afp(&row.model, row.at_ms, row.input, row.cache_read, row.output)
1558            .unwrap_or(0.0);
1559        entry.1 += 1;
1560        entry.2 += row.input;
1561        entry.3 += row.output;
1562        entry.4 += row.cache_read;
1563        entry.5 = entry.5.max(row.at_ms);
1564        entry.6.insert(row.model.as_str());
1565    }
1566    let mut sessions: Vec<_> = by_session.into_iter().collect();
1567    sessions.sort_by_key(|(_, (_, _, _, _, _, last, _))| std::cmp::Reverse(*last));
1568
1569    match fmt {
1570        Fmt::Json => {
1571            let mut keyed = serde_json::Map::new();
1572            for (id, (cost, requests, input, output, cache_read, last, models)) in &sessions {
1573                let mut body = totals_json(*cost, *requests, *input, *output, *cache_read);
1574                body["lastMs"] = serde_json::json!(*last);
1575                body["models"] = serde_json::json!(models.len());
1576                keyed.insert((*id).to_string(), body);
1577            }
1578            serde_json::json!({
1579                "scope": "local",
1580                "source": "opencode",
1581                "sessions": keyed,
1582            })
1583            .to_string()
1584        }
1585        Fmt::Csv => {
1586            let mut out = String::from("session,requests,tokens_in,tokens_out,cache_read,afp\n");
1587            for (id, (cost, requests, input, output, cache_read, _, _)) in &sessions {
1588                out.push_str(&format!(
1589                    "{},{},{},{},{},{:.2}\n",
1590                    crate::report_render::csv_field(id),
1591                    requests,
1592                    input,
1593                    output,
1594                    cache_read,
1595                    cost,
1596                ));
1597            }
1598            out
1599        }
1600        Fmt::Table => {
1601            let ink = Ink::new(colour);
1602            let mut out = format!(
1603                "{}session                          last        models   AFP       req{}\n",
1604                ink.bold, ink.reset
1605            );
1606            for (id, (cost, requests, _, _, _, last, models)) in sessions.iter().take(limit) {
1607                let short = id.rsplit('_').next().unwrap_or(*id);
1608                out.push_str(&format!(
1609                    "{:<32} {:<11} {:>6} {} {:>6}\n",
1610                    short,
1611                    ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
1612                    models.len(),
1613                    ink.cyan_on(format!("{:>9}", afp(*cost))),
1614                    requests,
1615                ));
1616            }
1617            if sessions.len() > limit {
1618                out.push_str(&format!(
1619                    "{}\n",
1620                    ink.dim_on(format!("… {} more", sessions.len() - limit))
1621                ));
1622            }
1623            if out.lines().count() == 1 {
1624                out.push_str(&format!(
1625                    "{}\n",
1626                    ink.dim_on("(no sessions in the window)".into())
1627                ));
1628            }
1629            out
1630        }
1631    }
1632}
1633
1634#[cfg(test)]
1635mod tests {
1636    use super::*;
1637    use crate::arkuse::db::Row;
1638
1639    fn report() -> Report {
1640        let now = 1_800_000_000_000;
1641        let rows = vec![Row {
1642            at_ms: now - 60_000,
1643            session: "ses_a".into(),
1644            provider: "agent-plan".into(),
1645            model: "deepseek-v4.1-flash".into(),
1646            input: 10_000,
1647            output: 2_000,
1648            ..Default::default()
1649        }];
1650        crate::arkuse::window::build(&rows, &ark::catalog(), ark::DEFAULT_PLAN, now, None, None)
1651    }
1652
1653    #[test]
1654    fn text_names_the_model_and_its_afp() {
1655        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1656        assert!(text.contains("deepseek-v4.1-flash"), "{text}");
1657        assert!(text.contains("AFP"), "the plan's own unit: {text}");
1658        assert!(!text.contains('$'), "ARK never prices in dollars: {text}");
1659    }
1660
1661    /// The right edge (1-based, inclusive) of every whitespace-separated cell.
1662    fn cell_ends(line: &str) -> Vec<usize> {
1663        let mut ends = Vec::new();
1664        let mut cursor = 0;
1665        for cell in line.split_whitespace() {
1666            let start = line[cursor..].find(cell).expect("cell in line") + cursor;
1667            cursor = start + cell.len();
1668            ends.push(cursor);
1669        }
1670        ends
1671    }
1672
1673    /// The header labels sit in the columns their cells do: every numeric column
1674    /// is right-aligned, so its label must end where the value ends.
1675    #[test]
1676    fn the_model_table_header_aligns_with_its_rows() {
1677        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1678        let mut lines = text.lines().skip_while(|line| !line.starts_with("MODEL "));
1679        let header = lines.next().expect("the table header");
1680        let row = lines
1681            .find(|line| line.starts_with("deepseek-v4.1-flash "))
1682            .expect("the model row");
1683        let header_ends = cell_ends(header);
1684        assert_eq!(
1685            header_ends[1..],
1686            cell_ends(row)[1..header_ends.len()],
1687            "header columns must end where their cells do:\n{header}\n{row}"
1688        );
1689    }
1690
1691    #[test]
1692    fn the_tier_table_carries_every_personal_plan() {
1693        let text = plans_text(false);
1694        for plan in ark::catalog().plans {
1695            assert!(text.contains(&plan.label), "{} missing: {text}", plan.label);
1696        }
1697        let json: serde_json::Value = serde_json::from_str(&plans_json()).unwrap();
1698        assert_eq!(
1699            json["plans"].as_array().unwrap().len(),
1700            ark::catalog().plans.len()
1701        );
1702    }
1703
1704    #[test]
1705    fn gated_models_are_filtered_by_the_tier() {
1706        let all = models_text("small", false, false);
1707        let gated = models_text("small", true, false);
1708        assert!(all.lines().count() >= gated.lines().count());
1709        assert!(all.contains("kimi-k3"), "{all}");
1710        assert!(!gated.contains("kimi-k3"), "{gated}");
1711    }
1712
1713    /// A report measured against the control plane's own meters.
1714    fn metered(account: &crate::arkuse::api::Usage) -> Report {
1715        let now = 1_800_000_000_000;
1716        let rows = vec![Row {
1717            at_ms: now - 60_000,
1718            session: "ses_a".into(),
1719            provider: "agent-plan".into(),
1720            model: "deepseek-v4.1-flash".into(),
1721            input: 10_000,
1722            output: 2_000,
1723            ..Default::default()
1724        }];
1725        crate::arkuse::window::build(
1726            &rows,
1727            &ark::catalog(),
1728            ark::DEFAULT_PLAN,
1729            now,
1730            None,
1731            Some(account),
1732        )
1733    }
1734
1735    fn meter(used: f64, quota: f64, reset: Option<&str>) -> crate::arkuse::api::Meter {
1736        let percent = if quota > 0.0 {
1737            used / quota * 100.0
1738        } else {
1739            0.0
1740        };
1741        crate::arkuse::api::Meter::from_parts("ok", percent, used, quota, reset, None)
1742    }
1743
1744    /// The shape `cli.rs` assembles once the control plane answered: `build`
1745    /// cuts the windows from the meters, then the account itself rides on the
1746    /// report for the header and the AGENT PLAN rows.
1747    fn metered_account(account: &crate::arkuse::api::Usage) -> Report {
1748        let mut report = metered(account);
1749        report.account = Some(account.clone());
1750        report.period_end_ms = account.monthly.reset_ms();
1751        report
1752    }
1753
1754    #[test]
1755    fn a_local_row_still_reads_used_over_quota_and_a_reset() {
1756        // Without the control plane the plan's published split stands in for the
1757        // quota, so the row is never AFP alone — the parity the user expects.
1758        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1759        let five = text
1760            .lines()
1761            .find(|line| line.contains("5-hour"))
1762            .expect("a 5-hour row");
1763        assert!(
1764            five.contains("/ 10,000 AFP"),
1765            "the plan's own quota: {five}"
1766        );
1767        assert!(five.contains("% of limit (est.)"), "a percentage: {five}");
1768        let weekly = text
1769            .lines()
1770            .find(|line| line.contains("Weekly"))
1771            .expect("a weekly row");
1772        assert!(weekly.contains("resets"), "a countdown: {weekly}");
1773        assert!(weekly.contains("window "), "an elapsed share: {weekly}");
1774        let json: serde_json::Value = serde_json::from_str(&render_json(&report())).unwrap();
1775        assert_eq!(json["windows"]["fiveHour"]["quota"], 10_000.0);
1776        assert!(json["windows"]["weekly"]["resetAt"].is_i64());
1777    }
1778
1779    #[test]
1780    fn a_meter_without_its_reset_falls_back_to_the_stores_boundary() {
1781        // The measured shape: a just-reset 5-hour block answers 0% with no
1782        // `reset_at`, and the row still owes the countdown.
1783        let account = crate::arkuse::api::Usage {
1784            five_hour: meter(0.0, 10_000.0, None),
1785            daily: None,
1786            weekly: meter(2_975.67, 35_000.0, Some("2026-10-26T00:00:00+08:00")),
1787            monthly: meter(2_975.67, 100_000.0, Some("2026-11-19T23:59:59+08:00")),
1788            tier: Some("medium".into()),
1789            profile: "agent-plan_cn-beijing_medium".into(),
1790        };
1791        let text = render_text(
1792            &metered(&account),
1793            "/tmp/opencode.db",
1794            10,
1795            false,
1796            DEFAULT_BAR_WIDTH,
1797        );
1798        let five = text
1799            .lines()
1800            .find(|line| line.contains("5-hour"))
1801            .expect("a 5-hour row");
1802        assert!(
1803            five.contains("0% of limit"),
1804            "the server's own share: {five}"
1805        );
1806        assert!(five.contains("resets"), "a derived countdown: {five}");
1807    }
1808
1809    /// The period line states the one share the whole dashboard is about — the
1810    /// control plane's own monthly percentage — and states nothing when no
1811    /// account answered (the local rows carry their own labelled estimate).
1812    #[test]
1813    fn period_line_carries_the_account_monthly_share() {
1814        // 53,000 of 100,000 AFP: the server's own figure, printed as a whole
1815        // percent — `53`, never `53.0`.
1816        let account = crate::arkuse::api::Usage {
1817            five_hour: meter(0.0, 10_000.0, None),
1818            daily: None,
1819            weekly: meter(2_975.67, 35_000.0, Some("2026-10-26T00:00:00+08:00")),
1820            monthly: meter(53_000.0, 100_000.0, Some("2026-11-19T23:59:59+08:00")),
1821            tier: Some("medium".into()),
1822            profile: "agent-plan_cn-beijing_medium".into(),
1823        };
1824        let with = render_text(
1825            &metered_account(&account),
1826            "/tmp/opencode.db",
1827            10,
1828            false,
1829            DEFAULT_BAR_WIDTH,
1830        );
1831        let header = with
1832            .lines()
1833            .find(|line| line.starts_with("period:"))
1834            .expect("the period line");
1835        assert!(header.contains("53% of limit"), "{header}");
1836        assert!(!header.contains("53.0"), "no invented decimal: {header}");
1837
1838        let without = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1839        let header = without
1840            .lines()
1841            .find(|line| line.starts_with("period:"))
1842            .expect("the period line");
1843        assert!(
1844            !header.contains("of limit"),
1845            "no account, no share: {header}"
1846        );
1847    }
1848
1849    /// The TOP 3 block splits the period between AFP and tokens, and both shares
1850    /// are of the whole — not of one model's own window.
1851    #[test]
1852    fn top_models_state_their_share_of_afp_and_tokens() {
1853        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1854        let block = text
1855            .lines()
1856            .skip_while(|line| !line.starts_with("TOP 3 MODELS"))
1857            .take_while(|line| !line.starts_with("MODEL "))
1858            .collect::<Vec<_>>()
1859            .join("\n");
1860        assert!(block.contains("deepseek-v4.1-flash"), "{block}");
1861        assert!(block.contains("AFP") && block.contains("TOKENS"), "{block}");
1862        // One model takes the whole period, so both shares are 100%.
1863        assert_eq!(block.matches("100.0%").count(), 2, "{block}");
1864        // The row under the table header ends on its requests: the share of
1865        // period AFP moved into the block above it.
1866        let row = text
1867            .lines()
1868            .skip_while(|line| !line.starts_with("MODEL "))
1869            .find(|line| line.starts_with("deepseek-v4.1-flash"))
1870            .expect("the model row");
1871        assert!(!row.trim_end().ends_with('%'), "no trailing share: {row}");
1872        assert!(row.trim_end().ends_with(char::is_numeric), "{row}");
1873    }
1874
1875    #[test]
1876    fn the_bar_carries_the_share_so_the_body_does_not_repeat_it() {
1877        // The row used to read `28.5% ━━━━━━━ 9,976 / 35,000 AFP · 28.5% of
1878        // limit`: one figure, twice, because the gauge and the body each printed
1879        // it. The gauge owns the percentage; the body prints the quota pair.
1880        let text = render_text(&report(), "/tmp/opencode.db", 10, true, DEFAULT_BAR_WIDTH);
1881        let weekly = text
1882            .lines()
1883            .find(|line| line.contains("Weekly"))
1884            .expect("a weekly row");
1885        assert!(!weekly.contains("of limit"), "no repeated share: {weekly}");
1886        assert!(weekly.contains("/ 35,000 AFP"), "the pair: {weekly}");
1887        assert!(
1888            weekly.contains("(est.)"),
1889            "still marked an estimate: {weekly}"
1890        );
1891    }
1892
1893    #[test]
1894    fn the_machine_formats_are_not_coloured() {
1895        let rows = vec![Row {
1896            at_ms: 1_800_000_000_000,
1897            session: "ses_a".into(),
1898            provider: "agent-plan".into(),
1899            model: "deepseek-v4.1-flash".into(),
1900            input: 1_000,
1901            ..Default::default()
1902        }];
1903        let catalog = ark::catalog();
1904        for fmt in [Fmt::Json, Fmt::Csv] {
1905            let daily = bucket_text(
1906                &rows,
1907                &catalog,
1908                1_800_000_000_000,
1909                86_400_000,
1910                86_400_000,
1911                0,
1912                true,
1913                fmt,
1914            );
1915            assert!(!daily.contains('\u{1b}'), "{fmt:?} leaked colour: {daily}");
1916        }
1917        let report = report();
1918        let json = model_report(&report, None, Fmt::Json);
1919        assert!(!json.contains('\u{1b}'));
1920        assert!(json.contains("deepseek-v4.1-flash"));
1921        let csv = model_report(&report, None, Fmt::Csv);
1922        assert!(csv.starts_with("model,requests,tokens_in,tokens_out,cache_read,afp"));
1923    }
1924}