Skip to main content

cmd_usage/ocuse/
render.rs

1// ocuse's presentation: a watch frame, a one-shot dashboard, a status line, and JSON.
2//
3// Reuses cmduse-core's pure formatters (money/compact/pct/duration) and cmduse's
4// own palette, gauge and sparkline, so both tools read the same. Go's allowance
5// is per model — not per account — so the header's window lines sum the caps of
6// the models actually used this month and the table shows each model against its
7// own limit; that distinction is the whole point of the Go plan.
8use crate::ocuse::window::{ModelUsage, Report, Totals};
9use crate::ocuse::zen;
10use cmduse_core::{compact, duration, money, pct};
11
12/// Gauge width for the window bars. cmduse makes this configurable; ocuse keeps
13/// one width so the frame does not reflow between refreshes.
14const BAR_WIDTH: usize = 20;
15
16/// ANSI styles — cmduse's palette, or empty strings when colour is off, so a
17/// piped run never leaks SGR escapes.
18pub struct Ink {
19    pub colour: bool,
20    pub bold: &'static str,
21    pub dim: &'static str,
22    pub reset: &'static str,
23    pub cyan: &'static str,
24    pub green: &'static str,
25    pub yellow: &'static str,
26    pub red: &'static str,
27}
28
29impl Ink {
30    pub fn new(colour: bool) -> Self {
31        if colour {
32            Self {
33                colour,
34                bold: crate::render::BOLD,
35                dim: crate::render::DIM,
36                reset: crate::render::RESET,
37                cyan: crate::render::CYAN,
38                green: crate::render::GREEN,
39                yellow: crate::render::YELLOW,
40                red: crate::render::RED,
41            }
42        } else {
43            Self {
44                colour,
45                bold: "",
46                dim: "",
47                reset: "",
48                cyan: "",
49                green: "",
50                yellow: "",
51                red: "",
52            }
53        }
54    }
55
56    /// Colour a value without disturbing the caller's column padding: pad the
57    /// text first, then wrap it.
58    fn cyan_on(&self, text: String) -> String {
59        format!("{}{text}{}", self.cyan, self.reset)
60    }
61
62    /// Severity for a used/limit percentage — cmduse's thresholds.
63    fn for_pct(&self, pct_used: f64) -> &'static str {
64        if !self.colour {
65            ""
66        } else if pct_used >= 90.0 {
67            self.red
68        } else if pct_used >= 70.0 {
69            self.yellow
70        } else {
71            self.green
72        }
73    }
74
75    /// Pct + gauge, exactly cmduse's `render::bar`; empty when colour is off so
76    /// the plain frame keeps its script-friendly column layout.
77    fn bar(&self, used: f64, cap: f64) -> String {
78        if self.colour {
79            crate::render::bar(used, cap, BAR_WIDTH)
80        } else {
81            String::new()
82        }
83    }
84}
85
86fn window_line(
87    ink: &Ink,
88    label: &str,
89    used: &Totals,
90    cap: Option<f64>,
91    reset_ms: Option<i64>,
92    now_ms: i64,
93) -> String {
94    let reset = reset_ms
95        .map(|at| duration(((at - now_ms) / 1000).max(0) as u64))
96        .unwrap_or_default();
97    let reset_text = if reset.is_empty() {
98        String::new()
99    } else if ink.colour {
100        format!(" · resets in {reset}")
101    } else {
102        format!(" · resets {reset}")
103    };
104    match cap {
105        Some(cap) if cap > 0.0 => {
106            if ink.colour {
107                format!(
108                    " {}{label:<8}{} {} {}{} / {}{}{}\n",
109                    ink.bold,
110                    ink.reset,
111                    ink.bar(used.cost, cap),
112                    ink.dim,
113                    money(used.cost),
114                    money(cap),
115                    reset_text,
116                    ink.reset,
117                )
118            } else {
119                // The plain frame is the script-facing one: keep the old text.
120                format!(
121                    "  {label:<8} {} / {} ({}){reset_text}\n",
122                    money(used.cost),
123                    money(cap),
124                    pct(used.cost, cap),
125                )
126            }
127        }
128        _ => format!("  {label:<8} {}{reset_text}\n", money(used.cost)),
129    }
130}
131
132/// Sum of `limit * share` over the models used this month — an aggregate ceiling
133/// across the models in play, since Go grants limits per model.
134fn aggregate_cap(report: &Report, share: f64) -> Option<f64> {
135    let total: f64 = report
136        .models
137        .iter()
138        .filter(|m| m.month.cost > 0.0)
139        .filter_map(|m| m.limit)
140        .map(|limit| limit * share)
141        .sum();
142    (total > 0.0).then_some(total)
143}
144
145fn model_row(ink: &Ink, model: &ModelUsage) -> String {
146    let limit = model.limit.map(money).unwrap_or_else(|| "—".into());
147    let use_fraction = model.month_use();
148    let use_text = use_fraction
149        .map(|fraction| pct(fraction * 100.0, 100.0))
150        .unwrap_or_else(|| "—".into());
151    let use_tone = use_fraction.map(|f| ink.for_pct(f * 100.0)).unwrap_or("");
152    // Pad before colouring: SGR escapes would otherwise count as columns.
153    let spent = ink.cyan_on(format!("{:>10}", money(model.month.cost)));
154    let use_cell = if use_tone.is_empty() {
155        format!("{use_text:>7}")
156    } else {
157        format!("{use_tone}{use_text:>7}{}", ink.reset)
158    };
159    format!(
160        "{:<28} {:>7} {spent} {use_cell} {:>8} {:>8} {:>7}\n",
161        model.id,
162        limit,
163        money(model.five_hour.cost),
164        money(model.weekly.cost),
165        compact(model.month.requests),
166    )
167}
168
169/// `limit` rows to show; `usize::MAX` for all.
170pub fn render_text(report: &Report, db_path: &str, rows: usize, colour: bool) -> String {
171    let ink = Ink::new(colour);
172    let c = |text: String| ink.cyan_on(text);
173    let shares = &zen::catalog().go.window_share;
174    let mut out = String::new();
175    out.push_str(&format!(
176        "{}OpenCode usage{} — go + zen, from {}{}{}\n",
177        ink.bold,
178        ink.reset,
179        ink.dim,
180        shorten(db_path),
181        ink.reset,
182    ));
183    out.push_str(&format!(
184        "period: {} · {} requests · {} · {} tokens\n",
185        report.period_label,
186        c(compact(report.month.requests)),
187        c(money(report.month.cost)),
188        c(compact(report.month.tokens())),
189    ));
190
191    if report.month.requests > 0 {
192        let five_reset = report.now_ms + crate::ocuse::window::FIVE_HOUR_SECS * 1000;
193        let week_reset = report.now_ms + crate::ocuse::window::WEEKLY_SECS * 1000;
194        out.push_str(&format!(
195            "\n{}GO{} (limits are per model: 5h = 20%, weekly = 50%, monthly = 100%)\n",
196            ink.bold, ink.reset
197        ));
198        out.push_str(&window_line(
199            &ink,
200            "5-hour",
201            &report.five_hour,
202            aggregate_cap(report, shares.five_hour),
203            Some(five_reset),
204            report.now_ms,
205        ));
206        out.push_str(&window_line(
207            &ink,
208            "weekly",
209            &report.weekly,
210            aggregate_cap(report, shares.weekly),
211            Some(week_reset),
212            report.now_ms,
213        ));
214        out.push_str(&window_line(
215            &ink,
216            "monthly",
217            &report.month,
218            aggregate_cap(report, shares.monthly),
219            None,
220            report.now_ms,
221        ));
222    }
223
224    out.push_str(&format!(
225        "\n{}MODEL                            LIMIT      SPENT     USE       5H       WK     REQ{}\n",
226        ink.bold, ink.reset
227    ));
228    for model in report.models.iter().take(rows) {
229        out.push_str(&model_row(&ink, model));
230    }
231    if report.models.len() > rows {
232        out.push_str(&format!("… {} more\n", report.models.len() - rows));
233    }
234    out.push_str(
235        "\nZEN is pay-as-you-go: the same rows carry its spend, but the docs give no limit.\n",
236    );
237    out
238}
239
240/// One watch frame: the dashboard, then a burst sparkline when the 5-hour spend
241/// has moved since the previous refresh. No trailing newline — the caller
242/// appends its own status line and `redraw_frame` (cmduse's) parks the cursor
243/// there, which is what lets the countdown rewrite a single line per second.
244pub fn watch_frame(
245    report: &Report,
246    db_path: &str,
247    rows: usize,
248    colour: bool,
249    history: &[f64],
250    interval_secs: u64,
251) -> String {
252    let ink = Ink::new(colour);
253    let mut out = render_text(report, db_path, rows, colour);
254    if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
255        out.push_str(&format!(
256            "{}spend bursts ({}s samples){} {}\n",
257            ink.dim,
258            interval_secs,
259            ink.reset,
260            crate::render::sparkline(history),
261        ));
262    }
263    out.trim_end_matches('\n').to_string()
264}
265
266/// Compact single line for a status bar.
267pub fn status_line(report: &Report) -> String {
268    match report.models.first() {
269        Some(top) => format!(
270            "oc {} month · {} 5h · top {} {}",
271            money(report.month.cost),
272            money(report.five_hour.cost),
273            top.id,
274            money(top.month.cost)
275        ),
276        None => format!(
277            "oc {} month · {} 5h",
278            money(report.month.cost),
279            money(report.five_hour.cost)
280        ),
281    }
282}
283
284/// Machine-readable projection: totals plus the per-model breakdown.
285pub fn render_json(report: &Report) -> String {
286    let totals = |t: &Totals| serde_json::json!({ "cost": t.cost, "requests": t.requests, "tokens": t.tokens() });
287    let model = |m: &ModelUsage| {
288        serde_json::json!({
289            "id": m.id,
290            "provider": m.provider,
291            "limit": m.limit,
292            "month": totals(&m.month),
293            "fiveHour": totals(&m.five_hour),
294            "weekly": totals(&m.weekly),
295        })
296    };
297    serde_json::to_string_pretty(&serde_json::json!({
298        "source": "opencode.db",
299        "generatedAt": report.now_ms,
300        "periodStart": report.since_ms,
301        "periodInferred": report.month_inferred,
302        "totals": {
303            "month": totals(&report.month),
304            "fiveHour": totals(&report.five_hour),
305            "weekly": totals(&report.weekly),
306        },
307        "models": report.models.iter().map(model).collect::<Vec<_>>(),
308    }))
309    .expect("json")
310}
311
312fn shorten(path: &str) -> String {
313    match std::env::var("HOME") {
314        Ok(home) if !home.is_empty() => path.replacen(&home, "~", 1),
315        _ => path.to_string(),
316    }
317}
318
319// ---- catalogues and history tables (shared with the MCP server) -------------
320
321/// The catalogue: what each product costs and (for Go) what it allows.
322pub fn plans_text() -> String {
323    let catalog = crate::ocuse::zen::catalog();
324    let mut out = format!(
325        "OpenCode plans (docs, extracted {})\n\nGO — ${:.2}/month · windows: 5h {:.0}% · weekly {:.0}% · monthly {:.0}%\n",
326        catalog.extracted_at,
327        catalog.go.price_usd,
328        catalog.go.window_share.five_hour * 100.0,
329        catalog.go.window_share.weekly * 100.0,
330        catalog.go.window_share.monthly * 100.0,
331    );
332    out.push_str("MODEL                            LIMIT    IN     OUT    CACHE\n");
333    for model in &catalog.go.models {
334        let rate = model.variants.first();
335        out.push_str(&format!(
336            "{:<28} {:>7} {:>6} {:>6} {:>8}\n",
337            model.id,
338            model
339                .monthly_limit
340                .map(cmduse_core::money)
341                .unwrap_or_else(|| "—".into()),
342            rate.map(|r| format!("${}", r.input)).unwrap_or_default(),
343            rate.map(|r| format!("${}", r.output)).unwrap_or_default(),
344            rate.map(|r| format!("${}", r.cache_read))
345                .unwrap_or_default(),
346        ));
347    }
348    out.push_str(&format!(
349        "\nZEN — pay-as-you-go, {} models priced per 1M tokens\n",
350        catalog.zen.models.len()
351    ));
352    out
353}
354
355pub fn model_text(report: &Report, id: &str) -> String {
356    match report.models.iter().find(|m| m.id == id) {
357        Some(model) => format!(
358            "{} ({})\n  limit     {}\n  month     {} · {} requests\n  5-hour    {}\n  weekly    {}\n",
359            model.id,
360            model.provider,
361            model.limit.map(cmduse_core::money).unwrap_or_else(|| "—".into()),
362            cmduse_core::money(model.month.cost),
363            model.month.requests,
364            cmduse_core::money(model.five_hour.cost),
365            cmduse_core::money(model.weekly.cost),
366        ),
367        None => format!("{id}: no usage in the period\n"),
368    }
369}
370
371/// Spend per bucket (day or hour), oldest first — `cmduse daily`/`hourly`.
372pub fn bucket_text(
373    rows: &[crate::ocuse::db::Row],
374    now_ms: i64,
375    days: i64,
376    size_ms: i64,
377    label: &str,
378    tz_secs: i64,
379) -> String {
380    // Buckets are cut in the display timezone: shift, bucket, then label.
381    let shift = tz_secs * 1000;
382    let mut buckets: std::collections::BTreeMap<i64, (f64, u64, u64)> = Default::default();
383    for row in rows {
384        let bucket = (row.at_ms + shift).div_euclid(size_ms);
385        let entry = buckets.entry(bucket).or_insert((0.0, 0, 0));
386        entry.0 += row.cost_usd;
387        entry.1 += 1;
388        entry.2 += row.input + row.output + row.cache_read;
389    }
390    let start = (now_ms + shift - days * 86_400_000).div_euclid(size_ms);
391    let mut out = format!("{label:<12} spend     requests   tokens\n");
392    for (bucket, (cost, requests, tokens)) in buckets.range(start..) {
393        let stamp = if size_ms >= 86_400_000 {
394            date_of(*bucket)
395        } else {
396            format!(
397                "{} {:02}h",
398                date_of(bucket.div_euclid(24)),
399                bucket.rem_euclid(24)
400            )
401        };
402        out.push_str(&format!(
403            "{stamp:<12} {:>9} {:>12} {:>9}\n",
404            cmduse_core::money(*cost),
405            cmduse_core::compact(*requests),
406            cmduse_core::compact(*tokens),
407        ));
408    }
409    if out.lines().count() == 1 {
410        out.push_str("(no OpenCode usage in the window)\n");
411    }
412    out
413}
414
415/// Spend per session, newest first.
416pub fn session_text(rows: &[crate::ocuse::db::Row], limit: usize) -> String {
417    let mut by_session: std::collections::BTreeMap<
418        &str,
419        (f64, u64, i64, std::collections::BTreeSet<&str>),
420    > = Default::default();
421    for row in rows {
422        let entry =
423            by_session
424                .entry(row.session.as_str())
425                .or_insert((0.0, 0, 0, Default::default()));
426        entry.0 += row.cost_usd;
427        entry.1 += 1;
428        entry.2 = entry.2.max(row.at_ms);
429        entry.3.insert(row.model.as_str());
430    }
431    let mut sessions: Vec<_> = by_session.into_iter().collect();
432    sessions.sort_by_key(|(_, (_, _, last, _))| std::cmp::Reverse(*last));
433    let mut out =
434        String::from("session                          last        models   spend     req\n");
435    for (id, (cost, requests, last, models)) in sessions.into_iter().take(limit) {
436        let short = id.rsplit('_').next().unwrap_or(id);
437        out.push_str(&format!(
438            "{:<32} {:<11} {:>6} {:>9} {:>6}\n",
439            short,
440            date_of(last.div_euclid(86_400_000)),
441            models.len(),
442            cmduse_core::money(cost),
443            requests,
444        ));
445    }
446    if out.lines().count() == 1 {
447        out.push_str("(no sessions in the window)\n");
448    }
449    out
450}
451
452/// `YYYY-MM-DD` for a Unix day number (civil-from-days, same helper family as
453/// the window module's).
454pub fn date_of(day: i64) -> String {
455    let z = day + 719_468;
456    let era = z.div_euclid(146_097);
457    let doe = z.rem_euclid(146_097);
458    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
459    let y = yoe + era * 400;
460    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
461    let mp = (5 * doy + 2) / 153;
462    let d = doy - (153 * mp + 2) / 5 + 1;
463    let m = if mp < 10 { mp + 3 } else { mp - 9 };
464    let year = if m <= 2 { y + 1 } else { y };
465    format!("{year:04}-{m:02}-{d:02}")
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use crate::ocuse::db::Row;
472
473    fn report() -> Report {
474        let now = 1_800_000_000_000;
475        let rows = vec![Row {
476            at_ms: now - 60_000,
477            provider: "opencode-go".into(),
478            model: "glm-5.3-flash".into(),
479            cost_usd: 30.0,
480            ..Default::default()
481        }];
482        crate::ocuse::window::build(&rows, &zen::catalog(), now, None)
483    }
484
485    #[test]
486    fn text_shows_the_model_against_its_limit() {
487        let text = render_text(&report(), "/tmp/opencode.db", 10, false);
488        assert!(text.contains("glm-5.3-flash"), "{text}");
489        assert!(text.contains("$60"), "the model's monthly limit: {text}");
490        assert!(text.contains("50%"), "30 of 60 is half: {text}");
491    }
492
493    #[test]
494    fn json_and_status_line_carry_the_totals() {
495        let json = render_json(&report());
496        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
497        assert_eq!(parsed["totals"]["month"]["cost"], 30.0);
498        assert_eq!(parsed["models"][0]["id"], "glm-5.3-flash");
499        assert!(status_line(&report()).contains("glm-5.3-flash"));
500    }
501
502    #[test]
503    fn plain_frames_carry_no_escapes() {
504        let text = render_text(&report(), "/tmp/opencode.db", 10, false);
505        assert!(
506            !text.contains('\x1b'),
507            "piped output must stay plain: {text}"
508        );
509        // The gauge is colour-only; the plain frame keeps the old percentage text.
510        assert!(text.contains("50%"), "{text}");
511    }
512
513    #[test]
514    fn colour_frames_get_the_palette_and_a_gauge() {
515        let text = render_text(&report(), "/tmp/opencode.db", 10, true);
516        assert!(text.contains(crate::render::BOLD), "{text}");
517        assert!(text.contains(crate::render::CYAN), "{text}");
518        assert!(
519            text.contains(crate::render::GREEN),
520            "the 50% monthly gauge and USE cell are green: {text}"
521        );
522        assert!(
523            text.contains(crate::render::RED),
524            "the over-cap 5-hour gauge (30 of 12) is red: {text}"
525        );
526        assert!(
527            text.contains('━') && text.contains('╱'),
528            "gauge bar: {text}"
529        );
530        assert!(text.contains("resets in"), "{text}");
531    }
532
533    #[test]
534    fn watch_frame_ends_on_the_status_line_and_shows_bursts() {
535        let frame = watch_frame(
536            &report(),
537            "/tmp/opencode.db",
538            10,
539            true,
540            &[0.0, 1.5, 0.0],
541            10,
542        );
543        assert!(
544            !frame.ends_with('\n'),
545            "the caller's status line owns the last row"
546        );
547        assert!(frame.contains("spend bursts (10s samples)"), "{frame}");
548        assert!(
549            frame.contains('█'),
550            "a nonzero delta shows as a tall bar: {frame}"
551        );
552    }
553
554    #[test]
555    fn idle_history_omits_the_sparkline() {
556        let all_idle = watch_frame(&report(), "/tmp/opencode.db", 10, true, &[0.0, 0.0], 10);
557        assert!(!all_idle.contains("spend bursts"), "{all_idle}");
558        let first_frame = watch_frame(&report(), "/tmp/opencode.db", 10, true, &[], 10);
559        assert!(!first_frame.contains("spend bursts"), "{first_frame}");
560    }
561}