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