1use crate::ocuse::window::{ModelUsage, Report, Totals};
9use crate::ocuse::zen;
10use cmduse_core::{compact, duration, money, pct};
11
12const BAR_WIDTH: usize = 20;
15
16pub 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 fn cyan_on(&self, text: String) -> String {
59 format!("{}{text}{}", self.cyan, self.reset)
60 }
61
62 fn dim_on(&self, text: String) -> String {
64 format!("{}{text}{}", self.dim, self.reset)
65 }
66
67 fn bold_on(&self, text: String) -> String {
68 format!("{}{text}{}", self.bold, self.reset)
69 }
70
71 fn for_pct(&self, pct_used: f64) -> &'static str {
73 if !self.colour {
74 ""
75 } else if pct_used >= 90.0 {
76 self.red
77 } else if pct_used >= 70.0 {
78 self.yellow
79 } else {
80 self.green
81 }
82 }
83
84 fn bar(&self, used: f64, cap: f64) -> String {
87 if self.colour {
88 crate::render::bar(used, cap, BAR_WIDTH)
89 } else {
90 String::new()
91 }
92 }
93}
94
95fn window_line(
96 ink: &Ink,
97 label: &str,
98 used: &Totals,
99 cap: Option<f64>,
100 reset_ms: Option<i64>,
101 now_ms: i64,
102) -> String {
103 let reset = reset_ms
104 .map(|at| duration(((at - now_ms) / 1000).max(0) as u64))
105 .unwrap_or_default();
106 let reset_text = if reset.is_empty() {
107 String::new()
108 } else if ink.colour {
109 format!(" · resets in {reset}")
110 } else {
111 format!(" · resets {reset}")
112 };
113 match cap {
114 Some(cap) if cap > 0.0 => {
115 let flag = if used.cost > cap {
117 format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
118 } else {
119 String::new()
120 };
121 if ink.colour {
122 format!(
123 " {}{label:<8}{} {} {}{} / {}{}{}{}\n",
124 ink.bold,
125 ink.reset,
126 ink.bar(used.cost, cap),
127 ink.dim,
128 money(used.cost),
129 money(cap),
130 reset_text,
131 flag,
132 ink.reset,
133 )
134 } else {
135 format!(
137 " {label:<8} {} / {} ({}){reset_text}{flag}\n",
138 money(used.cost),
139 money(cap),
140 pct(used.cost, cap),
141 )
142 }
143 }
144 _ => format!(
147 " {label:<8} {}\n",
148 ink.dim_on(format!("{}{}", money(used.cost), reset_text))
149 ),
150 }
151}
152
153fn aggregate_cap(report: &Report, share: f64) -> Option<f64> {
156 let total: f64 = report
157 .models
158 .iter()
159 .filter(|m| m.month.cost > 0.0)
160 .filter_map(|m| m.limit)
161 .map(|limit| limit * share)
162 .sum();
163 (total > 0.0).then_some(total)
164}
165
166fn model_row(ink: &Ink, model: &ModelUsage) -> String {
167 let limit = match model.limit {
168 Some(limit) => money(limit),
169 None => ink.dim_on("—".into()),
170 };
171 let use_fraction = model.month_use();
172 let use_text = use_fraction
173 .map(|fraction| pct(fraction * 100.0, 100.0))
174 .unwrap_or_else(|| "—".into());
175 let spent = ink.cyan_on(format!("{:>10}", money(model.month.cost)));
177 let use_cell = match use_fraction {
178 Some(fraction) => {
179 let tone = ink.for_pct(fraction * 100.0);
180 if tone.is_empty() {
181 format!("{use_text:>7}")
182 } else {
183 format!("{tone}{use_text:>7}{}", ink.reset)
184 }
185 }
186 None => ink.dim_on(format!("{use_text:>7}")),
187 };
188 format!(
189 "{:<28} {:>7} {spent} {use_cell} {} {} {:>7}\n",
190 model.id,
191 limit,
192 window_cell(ink, model, model.five_hour.cost, 0.20),
194 window_cell(ink, model, model.weekly.cost, 0.50),
195 compact(model.month.requests),
196 )
197}
198
199fn window_cell(ink: &Ink, model: &ModelUsage, cost: f64, share: f64) -> String {
202 let text = format!("{:>8}", money(cost));
203 match model.limit.filter(|limit| *limit > 0.0) {
204 Some(limit) => {
205 let tone = ink.for_pct(cost / (limit * share) * 100.0);
206 if tone.is_empty() {
207 text
208 } else {
209 format!("{tone}{text}{}", ink.reset)
210 }
211 }
212 None => ink.dim_on(text),
213 }
214}
215
216pub fn render_text(report: &Report, db_path: &str, rows: usize, colour: bool) -> String {
218 let ink = Ink::new(colour);
219 let c = |text: String| ink.cyan_on(text);
220 let shares = &zen::catalog().go.window_share;
221 let mut out = String::new();
222 out.push_str(&format!(
223 "{}OpenCode usage{} — go + zen, from {}{}{}\n",
224 ink.bold,
225 ink.reset,
226 ink.dim,
227 shorten(db_path),
228 ink.reset,
229 ));
230 out.push_str(&format!(
231 "period: {} · {} requests · {} · {} tokens\n",
232 report.period_label,
233 c(compact(report.month.requests)),
234 c(money(report.month.cost)),
235 c(compact(report.month.tokens())),
236 ));
237
238 if report.month.requests > 0 {
239 let five_reset = report.now_ms + crate::ocuse::window::FIVE_HOUR_SECS * 1000;
240 let week_reset = report.now_ms + crate::ocuse::window::WEEKLY_SECS * 1000;
241 out.push_str(&format!(
242 "\n{}GO{} {}\n",
243 ink.bold,
244 ink.reset,
245 ink.dim_on("(limits are per model: 5h = 20%, weekly = 50%, monthly = 100%)".into()),
246 ));
247 out.push_str(&window_line(
248 &ink,
249 "5-hour",
250 &report.five_hour,
251 aggregate_cap(report, shares.five_hour),
252 Some(five_reset),
253 report.now_ms,
254 ));
255 out.push_str(&window_line(
256 &ink,
257 "weekly",
258 &report.weekly,
259 aggregate_cap(report, shares.weekly),
260 Some(week_reset),
261 report.now_ms,
262 ));
263 out.push_str(&window_line(
264 &ink,
265 "monthly",
266 &report.month,
267 aggregate_cap(report, shares.monthly),
268 None,
269 report.now_ms,
270 ));
271 }
272
273 out.push_str(&format!(
274 "\n{}MODEL LIMIT SPENT USE 5H WK REQ{}\n",
275 ink.bold, ink.reset
276 ));
277 for model in report.models.iter().take(rows) {
278 out.push_str(&model_row(&ink, model));
279 }
280 if report.models.len() > rows {
281 out.push_str(&format!(
282 "{}\n",
283 ink.dim_on(format!("… {} more", report.models.len() - rows))
284 ));
285 }
286 out.push_str(&format!(
287 "\n{}\n",
288 ink.dim_on(
289 "ZEN is pay-as-you-go: the same rows carry its spend, but the docs give no limit."
290 .into()
291 )
292 ));
293 out
294}
295
296pub fn watch_frame(
301 report: &Report,
302 db_path: &str,
303 rows: usize,
304 colour: bool,
305 history: &[f64],
306 interval_secs: u64,
307) -> String {
308 let ink = Ink::new(colour);
309 let mut out = render_text(report, db_path, rows, colour);
310 if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
311 out.push_str(&format!(
312 "{}spend bursts ({}s samples){} {}\n",
313 ink.dim,
314 interval_secs,
315 ink.reset,
316 crate::render::sparkline(history),
317 ));
318 }
319 out.trim_end_matches('\n').to_string()
320}
321
322pub fn status_line(report: &Report, colour: bool) -> String {
324 let ink = Ink::new(colour);
325 match report.models.first() {
326 Some(top) => format!(
327 "oc {} month · {} 5h · top {} {}",
328 ink.cyan_on(money(report.month.cost)),
329 ink.cyan_on(money(report.five_hour.cost)),
330 top.id,
331 ink.cyan_on(money(top.month.cost)),
332 ),
333 None => format!(
334 "oc {} month · {} 5h",
335 ink.cyan_on(money(report.month.cost)),
336 ink.cyan_on(money(report.five_hour.cost)),
337 ),
338 }
339}
340
341pub fn render_json(report: &Report) -> String {
343 let totals = |t: &Totals| serde_json::json!({ "cost": t.cost, "requests": t.requests, "tokens": t.tokens() });
344 let model = |m: &ModelUsage| {
345 serde_json::json!({
346 "id": m.id,
347 "provider": m.provider,
348 "limit": m.limit,
349 "month": totals(&m.month),
350 "fiveHour": totals(&m.five_hour),
351 "weekly": totals(&m.weekly),
352 })
353 };
354 serde_json::to_string_pretty(&serde_json::json!({
355 "source": "opencode.db",
356 "generatedAt": report.now_ms,
357 "periodStart": report.since_ms,
358 "periodInferred": report.month_inferred,
359 "totals": {
360 "month": totals(&report.month),
361 "fiveHour": totals(&report.five_hour),
362 "weekly": totals(&report.weekly),
363 },
364 "models": report.models.iter().map(model).collect::<Vec<_>>(),
365 }))
366 .expect("json")
367}
368
369fn shorten(path: &str) -> String {
370 match std::env::var("HOME") {
371 Ok(home) if !home.is_empty() => path.replacen(&home, "~", 1),
372 _ => path.to_string(),
373 }
374}
375
376pub fn plans_text(colour: bool) -> String {
380 let ink = Ink::new(colour);
381 let catalog = crate::ocuse::zen::catalog();
382 let mut out = format!(
383 "{}OpenCode plans{} {}\n\n",
384 ink.bold,
385 ink.reset,
386 ink.dim_on(format!("(docs, extracted {})", catalog.extracted_at)),
387 );
388 out.push_str(&format!(
389 "{}GO{} — {}/month · windows: 5h {} · weekly {} · monthly {}\n",
390 ink.bold,
391 ink.reset,
392 ink.cyan_on(format!("${:.2}", catalog.go.price_usd)),
393 ink.cyan_on(format!("{:.0}%", catalog.go.window_share.five_hour * 100.0)),
394 ink.cyan_on(format!("{:.0}%", catalog.go.window_share.weekly * 100.0)),
395 ink.cyan_on(format!("{:.0}%", catalog.go.window_share.monthly * 100.0)),
396 ));
397 out.push_str(&format!(
398 "{}MODEL LIMIT IN OUT CACHE{}\n",
399 ink.bold, ink.reset
400 ));
401 for model in &catalog.go.models {
402 let rate = model.variants.first();
403 let limit = model
404 .monthly_limit
405 .map(cmduse_core::money)
406 .unwrap_or_else(|| "—".into());
407 out.push_str(&format!(
408 "{:<28} {} {} {} {}\n",
409 model.id,
410 ink.cyan_on(format!("{limit:>7}")),
411 ink.cyan_on(format!(
412 "{:>6}",
413 rate.map(|r| format!("${}", r.input)).unwrap_or_default()
414 )),
415 ink.cyan_on(format!(
416 "{:>6}",
417 rate.map(|r| format!("${}", r.output)).unwrap_or_default()
418 )),
419 ink.cyan_on(format!(
420 "{:>8}",
421 rate.map(|r| format!("${}", r.cache_read))
422 .unwrap_or_default()
423 )),
424 ));
425 }
426 out.push_str(&format!(
427 "\n{}\n",
428 ink.dim_on(format!(
429 "ZEN — pay-as-you-go, {} models priced per 1M tokens",
430 catalog.zen.models.len()
431 ))
432 ));
433 out
434}
435
436pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
437 let ink = Ink::new(colour);
438 let Some(model) = report.models.iter().find(|m| m.id == id) else {
439 return format!("{id}: no usage in the period\n");
440 };
441 let limit = model
442 .limit
443 .map(cmduse_core::money)
444 .unwrap_or_else(|| "—".into());
445 let row =
446 |label: &str, value: String| format!(" {}{value}\n", ink.dim_on(format!("{label:<10}")));
447 let mut out = format!(
448 "{} {}\n",
449 ink.bold_on(model.id.clone()),
450 ink.dim_on(format!("({})", model.provider)),
451 );
452 out.push_str(&row("limit", ink.cyan_on(limit)));
453 out.push_str(&row(
454 "month",
455 ink.cyan_on(format!(
456 "{} · {} requests",
457 cmduse_core::money(model.month.cost),
458 model.month.requests
459 )),
460 ));
461 out.push_str(&row(
462 "5-hour",
463 ink.cyan_on(cmduse_core::money(model.five_hour.cost)),
464 ));
465 out.push_str(&row(
466 "weekly",
467 ink.cyan_on(cmduse_core::money(model.weekly.cost)),
468 ));
469 out
470}
471
472pub fn bucket_text(
474 rows: &[crate::ocuse::db::Row],
475 now_ms: i64,
476 days: i64,
477 size_ms: i64,
478 label: &str,
479 tz_secs: i64,
480 colour: bool,
481) -> String {
482 let ink = Ink::new(colour);
483 let shift = tz_secs * 1000;
485 let mut buckets: std::collections::BTreeMap<i64, (f64, u64, u64)> = Default::default();
486 for row in rows {
487 let bucket = (row.at_ms + shift).div_euclid(size_ms);
488 let entry = buckets.entry(bucket).or_insert((0.0, 0, 0));
489 entry.0 += row.cost_usd;
490 entry.1 += 1;
491 entry.2 += row.input + row.output + row.cache_read;
492 }
493 let start = (now_ms + shift - days * 86_400_000).div_euclid(size_ms);
494 let mut out = format!(
495 "{}{label:<12} spend requests tokens{}\n",
496 ink.bold, ink.reset
497 );
498 for (bucket, (cost, requests, tokens)) in buckets.range(start..) {
499 let stamp = if size_ms >= 86_400_000 {
500 date_of(*bucket)
501 } else {
502 format!(
503 "{} {:02}h",
504 date_of(bucket.div_euclid(24)),
505 bucket.rem_euclid(24)
506 )
507 };
508 out.push_str(&format!(
509 "{stamp:<12} {} {} {}\n",
510 ink.cyan_on(format!("{:>9}", cmduse_core::money(*cost))),
511 ink.cyan_on(format!("{:>12}", cmduse_core::compact(*requests))),
512 ink.cyan_on(format!("{:>9}", cmduse_core::compact(*tokens))),
513 ));
514 }
515 if out.lines().count() == 1 {
516 out.push_str(&format!(
517 "{}\n",
518 ink.dim_on("(no OpenCode usage in the window)".into())
519 ));
520 }
521 out
522}
523
524pub fn session_text(rows: &[crate::ocuse::db::Row], limit: usize, colour: bool) -> String {
526 let ink = Ink::new(colour);
527 let mut by_session: std::collections::BTreeMap<
528 &str,
529 (f64, u64, i64, std::collections::BTreeSet<&str>),
530 > = Default::default();
531 for row in rows {
532 let entry =
533 by_session
534 .entry(row.session.as_str())
535 .or_insert((0.0, 0, 0, Default::default()));
536 entry.0 += row.cost_usd;
537 entry.1 += 1;
538 entry.2 = entry.2.max(row.at_ms);
539 entry.3.insert(row.model.as_str());
540 }
541 let mut sessions: Vec<_> = by_session.into_iter().collect();
542 sessions.sort_by_key(|(_, (_, _, last, _))| std::cmp::Reverse(*last));
543 let mut out = format!(
544 "{}session last models spend req{}\n",
545 ink.bold, ink.reset
546 );
547 for (id, (cost, requests, last, models)) in sessions.into_iter().take(limit) {
548 let short = id.rsplit('_').next().unwrap_or(id);
549 out.push_str(&format!(
550 "{:<32} {:<11} {:>6} {} {:>6}\n",
551 short,
552 ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
553 models.len(),
554 ink.cyan_on(format!("{:>9}", cmduse_core::money(cost))),
555 requests,
556 ));
557 }
558 if out.lines().count() == 1 {
559 out.push_str(&format!(
560 "{}\n",
561 ink.dim_on("(no sessions in the window)".into())
562 ));
563 }
564 out
565}
566
567pub fn date_of(day: i64) -> String {
570 let z = day + 719_468;
571 let era = z.div_euclid(146_097);
572 let doe = z.rem_euclid(146_097);
573 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
574 let y = yoe + era * 400;
575 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
576 let mp = (5 * doy + 2) / 153;
577 let d = doy - (153 * mp + 2) / 5 + 1;
578 let m = if mp < 10 { mp + 3 } else { mp - 9 };
579 let year = if m <= 2 { y + 1 } else { y };
580 format!("{year:04}-{m:02}-{d:02}")
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586 use crate::ocuse::db::Row;
587
588 fn report() -> Report {
589 let now = 1_800_000_000_000;
590 let rows = vec![Row {
591 at_ms: now - 60_000,
592 provider: "opencode-go".into(),
593 model: "glm-5.3-flash".into(),
594 cost_usd: 30.0,
595 ..Default::default()
596 }];
597 crate::ocuse::window::build(&rows, &zen::catalog(), now, None)
598 }
599
600 #[test]
601 fn text_shows_the_model_against_its_limit() {
602 let text = render_text(&report(), "/tmp/opencode.db", 10, false);
603 assert!(text.contains("glm-5.3-flash"), "{text}");
604 assert!(text.contains("$60"), "the model's monthly limit: {text}");
605 assert!(text.contains("50%"), "30 of 60 is half: {text}");
606 }
607
608 #[test]
609 fn json_and_status_line_carry_the_totals() {
610 let json = render_json(&report());
611 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
612 assert_eq!(parsed["totals"]["month"]["cost"], 30.0);
613 assert_eq!(parsed["models"][0]["id"], "glm-5.3-flash");
614 assert!(status_line(&report(), false).contains("glm-5.3-flash"));
615 }
616
617 #[test]
623 fn every_renderer_respects_the_colour_flag() {
624 let report = report();
625 let rows = vec![Row {
626 at_ms: report.now_ms - 60_000,
627 session: "ses_fixture".into(),
628 provider: "opencode-go".into(),
629 model: "glm-5.3-flash".into(),
630 cost_usd: 30.0,
631 ..Default::default()
632 }];
633 let now = report.now_ms;
634
635 let cases: Vec<(&str, String, String)> = vec![
636 (
637 "render_text",
638 render_text(&report, "/tmp/oc.db", 10, false),
639 render_text(&report, "/tmp/oc.db", 10, true),
640 ),
641 (
642 "status_line",
643 status_line(&report, false),
644 status_line(&report, true),
645 ),
646 ("plans_text", plans_text(false), plans_text(true)),
647 (
648 "model_text",
649 model_text(&report, "glm-5.3-flash", false),
650 model_text(&report, "glm-5.3-flash", true),
651 ),
652 (
653 "bucket_text/day",
654 bucket_text(&rows, now, 7, 86_400_000, "day", 0, false),
655 bucket_text(&rows, now, 7, 86_400_000, "day", 0, true),
656 ),
657 (
658 "bucket_text/hour",
659 bucket_text(&rows, now, 1, 3_600_000, "hour", 0, false),
660 bucket_text(&rows, now, 1, 3_600_000, "hour", 0, true),
661 ),
662 (
663 "session_text",
664 session_text(&rows, 20, false),
665 session_text(&rows, 20, true),
666 ),
667 ];
668
669 for (name, plain, coloured) in cases {
670 assert!(
671 !plain.contains('\x1b'),
672 "{name} leaked escapes when off: {plain}"
673 );
674 assert!(
675 coloured.contains('\x1b'),
676 "{name} rendered nothing coloured when on: {coloured}"
677 );
678 }
679 }
680
681 #[test]
682 fn plain_frames_carry_no_escapes() {
683 let text = render_text(&report(), "/tmp/opencode.db", 10, false);
684 assert!(
685 !text.contains('\x1b'),
686 "piped output must stay plain: {text}"
687 );
688 assert!(text.contains("50%"), "{text}");
690 }
691
692 #[test]
693 fn an_over_cap_window_is_flagged_in_both_modes() {
694 let plain = render_text(&report(), "/tmp/opencode.db", 10, false);
696 assert!(plain.contains("LIMIT EXCEEDED"), "{plain}");
697 let coloured = render_text(&report(), "/tmp/opencode.db", 10, true);
698 assert!(coloured.contains("LIMIT EXCEEDED"), "{coloured}");
699 assert!(
700 coloured.contains(crate::render::RED),
701 "the flag and the gauge are red: {coloured}"
702 );
703 }
704
705 #[test]
706 fn colour_frames_get_the_palette_and_a_gauge() {
707 let text = render_text(&report(), "/tmp/opencode.db", 10, true);
708 assert!(text.contains(crate::render::BOLD), "{text}");
709 assert!(text.contains(crate::render::CYAN), "{text}");
710 assert!(
711 text.contains(crate::render::GREEN),
712 "the 50% monthly gauge and USE cell are green: {text}"
713 );
714 assert!(
715 text.contains(crate::render::RED),
716 "the over-cap 5-hour gauge (30 of 12) is red: {text}"
717 );
718 assert!(
719 text.contains('━') && text.contains('╱'),
720 "gauge bar: {text}"
721 );
722 assert!(text.contains("resets in"), "{text}");
723 }
724
725 #[test]
726 fn watch_frame_ends_on_the_status_line_and_shows_bursts() {
727 let frame = watch_frame(
728 &report(),
729 "/tmp/opencode.db",
730 10,
731 true,
732 &[0.0, 1.5, 0.0],
733 10,
734 );
735 assert!(
736 !frame.ends_with('\n'),
737 "the caller's status line owns the last row"
738 );
739 assert!(frame.contains("spend bursts (10s samples)"), "{frame}");
740 assert!(
741 frame.contains('█'),
742 "a nonzero delta shows as a tall bar: {frame}"
743 );
744 }
745
746 #[test]
747 fn idle_history_omits_the_sparkline() {
748 let all_idle = watch_frame(&report(), "/tmp/opencode.db", 10, true, &[0.0, 0.0], 10);
749 assert!(!all_idle.contains("spend bursts"), "{all_idle}");
750 let first_frame = watch_frame(&report(), "/tmp/opencode.db", 10, true, &[], 10);
751 assert!(!first_frame.contains("spend bursts"), "{first_frame}");
752 }
753}