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 exceeded(model: &ModelUsage) -> bool {
170 let Some(limit) = model.limit.filter(|limit| *limit > 0.0) else {
171 return false;
172 };
173 model.month.cost > limit
174 || model.five_hour.cost > limit * 0.20
175 || model.weekly.cost > limit * 0.50
176}
177
178fn model_row(ink: &Ink, model: &ModelUsage, total_cost: f64) -> String {
179 let limit = match model.limit {
180 Some(limit) => money(limit),
181 None => ink.dim_on("—".into()),
182 };
183 let use_fraction = model.month_use();
184 let use_text = use_fraction
185 .map(|fraction| pct(fraction * 100.0, 100.0))
186 .unwrap_or_else(|| "—".into());
187 let spent = ink.cyan_on(format!("{:>10}", money(model.month.cost)));
189 let use_cell = match use_fraction {
190 Some(fraction) => {
191 let tone = ink.for_pct(fraction * 100.0);
192 if tone.is_empty() {
193 format!("{use_text:>7}")
194 } else {
195 format!("{tone}{use_text:>7}{}", ink.reset)
196 }
197 }
198 None => ink.dim_on(format!("{use_text:>7}")),
199 };
200 let flag = if exceeded(model) {
203 format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
204 } else {
205 String::new()
206 };
207 let share = if total_cost > 0.0 {
208 ink.dim_on(format!(" {:>5.1}%", model.month.cost / total_cost * 100.0))
209 } else {
210 String::new()
211 };
212 format!(
213 "{:<28}{flag} {:>7} {spent} {use_cell} {} {} {:>7}{share}\n",
214 model.id,
215 limit,
216 window_cell(ink, model, model.five_hour.cost, 0.20),
218 window_cell(ink, model, model.weekly.cost, 0.50),
219 compact(model.month.requests),
220 )
221}
222
223fn window_cell(ink: &Ink, model: &ModelUsage, cost: f64, share: f64) -> String {
226 let text = format!("{:>8}", money(cost));
227 match model.limit.filter(|limit| *limit > 0.0) {
228 Some(limit) => {
229 let tone = ink.for_pct(cost / (limit * share) * 100.0);
230 if tone.is_empty() {
231 text
232 } else {
233 format!("{tone}{text}{}", ink.reset)
234 }
235 }
236 None => ink.dim_on(text),
237 }
238}
239
240pub fn render_text(report: &Report, db_path: &str, rows: usize, colour: bool) -> String {
242 let ink = Ink::new(colour);
243 let c = |text: String| ink.cyan_on(text);
244 let shares = &zen::catalog().go.window_share;
245 let mut out = String::new();
246 out.push_str(&format!(
247 "{}OpenCode usage{} — go + zen, from {}{}{}\n",
248 ink.bold,
249 ink.reset,
250 ink.dim,
251 shorten(db_path),
252 ink.reset,
253 ));
254 out.push_str(&format!(
255 "period: {} · {} requests · {} · {} tokens\n",
256 report.period_label,
257 c(compact(report.month.requests)),
258 c(money(report.month.cost)),
259 c(compact(report.month.tokens())),
260 ));
261
262 if report.month.requests > 0 {
263 let five_reset = report.now_ms + crate::ocuse::window::FIVE_HOUR_SECS * 1000;
264 let week_reset = report.now_ms + crate::ocuse::window::WEEKLY_SECS * 1000;
265 out.push_str(&format!(
266 "\n{}GO{} {}\n",
267 ink.bold,
268 ink.reset,
269 ink.dim_on("(limits are per model: 5h = 20%, weekly = 50%, monthly = 100%)".into()),
270 ));
271 out.push_str(&window_line(
272 &ink,
273 "5-hour",
274 &report.five_hour,
275 aggregate_cap(report, shares.five_hour),
276 Some(five_reset),
277 report.now_ms,
278 ));
279 out.push_str(&window_line(
280 &ink,
281 "weekly",
282 &report.weekly,
283 aggregate_cap(report, shares.weekly),
284 Some(week_reset),
285 report.now_ms,
286 ));
287 out.push_str(&window_line(
288 &ink,
289 "monthly",
290 &report.month,
291 aggregate_cap(report, shares.monthly),
292 None,
293 report.now_ms,
294 ));
295 }
296
297 out.push_str(&format!(
298 "\n{}MODEL LIMIT SPENT USE 5H WK REQ{}\n",
299 ink.bold, ink.reset
300 ));
301 let total_cost = report.month.cost;
302 for model in report.models.iter().take(rows) {
303 out.push_str(&model_row(&ink, model, total_cost));
304 }
305 if report.models.len() > rows {
306 out.push_str(&format!(
307 "{}\n",
308 ink.dim_on(format!("… {} more", report.models.len() - rows))
309 ));
310 }
311 out.push_str(&format!(
312 "\n{}\n",
313 ink.dim_on(
314 "ZEN is pay-as-you-go: the same rows carry its spend, but the docs give no limit."
315 .into()
316 )
317 ));
318 out
319}
320
321pub fn watch_frame(
326 report: &Report,
327 db_path: &str,
328 rows: usize,
329 colour: bool,
330 history: &[f64],
331 interval_secs: u64,
332) -> String {
333 let ink = Ink::new(colour);
334 let mut out = render_text(report, db_path, rows, colour);
335 if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
336 out.push_str(&format!(
337 "{}spend bursts ({}s samples){} {}\n",
338 ink.dim,
339 interval_secs,
340 ink.reset,
341 crate::render::sparkline(history),
342 ));
343 }
344 out.trim_end_matches('\n').to_string()
345}
346
347pub fn status_line(report: &Report, colour: bool) -> String {
349 let ink = Ink::new(colour);
350 match report.models.first() {
351 Some(top) => format!(
352 "oc {} month · {} 5h · top {} {}",
353 ink.cyan_on(money(report.month.cost)),
354 ink.cyan_on(money(report.five_hour.cost)),
355 top.id,
356 ink.cyan_on(money(top.month.cost)),
357 ),
358 None => format!(
359 "oc {} month · {} 5h",
360 ink.cyan_on(money(report.month.cost)),
361 ink.cyan_on(money(report.five_hour.cost)),
362 ),
363 }
364}
365
366pub fn render_json(report: &Report) -> String {
368 let totals = |t: &Totals| serde_json::json!({ "cost": t.cost, "requests": t.requests, "tokens": t.tokens() });
369 let model = |m: &ModelUsage| {
370 serde_json::json!({
371 "id": m.id,
372 "provider": m.provider,
373 "limit": m.limit,
374 "month": totals(&m.month),
375 "fiveHour": totals(&m.five_hour),
376 "weekly": totals(&m.weekly),
377 })
378 };
379 serde_json::to_string_pretty(&serde_json::json!({
380 "source": "opencode.db",
381 "generatedAt": report.now_ms,
382 "periodStart": report.since_ms,
383 "periodInferred": report.month_inferred,
384 "totals": {
385 "month": totals(&report.month),
386 "fiveHour": totals(&report.five_hour),
387 "weekly": totals(&report.weekly),
388 },
389 "models": report.models.iter().map(model).collect::<Vec<_>>(),
390 }))
391 .expect("json")
392}
393
394fn shorten(path: &str) -> String {
395 match std::env::var("HOME") {
396 Ok(home) if !home.is_empty() => path.replacen(&home, "~", 1),
397 _ => path.to_string(),
398 }
399}
400
401pub fn plans_text(colour: bool) -> String {
408 let ink = Ink::new(colour);
409 let catalog = crate::ocuse::zen::catalog();
410 let mut out = format!(
411 "{}OpenCode plans{} {}\n",
412 ink.bold,
413 ink.reset,
414 ink.dim_on(format!("(docs, extracted {})", catalog.extracted_at)),
415 );
416 out.push_str(&format!(
417 "{}windows (both plans): 5h {} · weekly {} · monthly {}{}\n",
418 ink.dim,
419 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.five_hour * 100.0)),
420 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.weekly * 100.0)),
421 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.monthly * 100.0)),
422 ink.reset,
423 ));
424 for plan in catalog.go.plans.values() {
425 out.push_str(&format!(
426 "\n{}{}{} — {}/month\n",
427 ink.bold,
428 plan.label,
429 ink.reset,
430 ink.cyan_on(format!("${:.2}", plan.price_usd)),
431 ));
432 out.push_str(&format!(
433 "{}MODEL LIMIT IN OUT CACHE{}\n",
434 ink.bold, ink.reset
435 ));
436 for model in &catalog.go.models {
437 let rate = model.variants.first();
438 let limit = plan
439 .allowance_by_model
440 .get(&model.id)
441 .map(|limit| cmduse_core::money(*limit))
442 .unwrap_or_else(|| "—".into());
443 out.push_str(&format!(
444 "{:<28} {} {} {} {}\n",
445 model.id,
446 ink.cyan_on(format!("{limit:>7}")),
447 ink.cyan_on(format!(
448 "{:>6}",
449 rate.map(|r| format!("${}", r.input)).unwrap_or_default()
450 )),
451 ink.cyan_on(format!(
452 "{:>6}",
453 rate.map(|r| format!("${}", r.output)).unwrap_or_default()
454 )),
455 ink.cyan_on(format!(
456 "{:>8}",
457 rate.map(|r| format!("${}", r.cache_read))
458 .unwrap_or_default()
459 )),
460 ));
461 }
462 }
463 out.push_str(&format!(
464 "\n{}\n",
465 ink.dim_on(format!(
466 "ZEN — pay-as-you-go, {} models priced per 1M tokens",
467 catalog.zen.models.len()
468 ))
469 ));
470 out
471}
472
473pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
474 let ink = Ink::new(colour);
475 let Some(model) = report.models.iter().find(|m| m.id == id) else {
476 return format!("{id}: no usage in the period\n");
477 };
478 let limit = model
479 .limit
480 .map(cmduse_core::money)
481 .unwrap_or_else(|| "—".into());
482 let row =
483 |label: &str, value: String| format!(" {}{value}\n", ink.dim_on(format!("{label:<10}")));
484 let mut out = format!(
485 "{} {}\n",
486 ink.bold_on(model.id.clone()),
487 ink.dim_on(format!("({})", model.provider)),
488 );
489 out.push_str(&row("limit", ink.cyan_on(limit)));
490 out.push_str(&row(
491 "month",
492 ink.cyan_on(format!(
493 "{} · {} requests",
494 cmduse_core::money(model.month.cost),
495 model.month.requests
496 )),
497 ));
498 out.push_str(&row(
499 "5-hour",
500 ink.cyan_on(cmduse_core::money(model.five_hour.cost)),
501 ));
502 out.push_str(&row(
503 "weekly",
504 ink.cyan_on(cmduse_core::money(model.weekly.cost)),
505 ));
506 if report.month.cost > 0.0 {
509 out.push_str(&row(
510 "share",
511 ink.dim_on(format!(
512 "{:.1}% of period spend",
513 model.month.cost / report.month.cost * 100.0
514 )),
515 ));
516 }
517 out
518}
519
520pub fn bucket_text(
522 rows: &[crate::ocuse::db::Row],
523 now_ms: i64,
524 days: i64,
525 size_ms: i64,
526 label: &str,
527 tz_secs: i64,
528 colour: bool,
529) -> String {
530 let ink = Ink::new(colour);
531 let shift = tz_secs * 1000;
533 let mut buckets: std::collections::BTreeMap<i64, (f64, u64, u64)> = Default::default();
534 for row in rows {
535 let bucket = (row.at_ms + shift).div_euclid(size_ms);
536 let entry = buckets.entry(bucket).or_insert((0.0, 0, 0));
537 entry.0 += row.cost_usd;
538 entry.1 += 1;
539 entry.2 += row.input + row.output + row.cache_read;
540 }
541 let start = (now_ms + shift - days * 86_400_000).div_euclid(size_ms);
542 let mut out = format!(
543 "{}{label:<12} spend requests tokens{}\n",
544 ink.bold, ink.reset
545 );
546 for (bucket, (cost, requests, tokens)) in buckets.range(start..) {
547 let stamp = if size_ms >= 86_400_000 {
548 date_of(*bucket)
549 } else {
550 format!(
551 "{} {:02}h",
552 date_of(bucket.div_euclid(24)),
553 bucket.rem_euclid(24)
554 )
555 };
556 out.push_str(&format!(
557 "{stamp:<12} {} {} {}\n",
558 ink.cyan_on(format!("{:>9}", cmduse_core::money(*cost))),
559 ink.cyan_on(format!("{:>12}", cmduse_core::compact(*requests))),
560 ink.cyan_on(format!("{:>9}", cmduse_core::compact(*tokens))),
561 ));
562 }
563 if out.lines().count() == 1 {
564 out.push_str(&format!(
565 "{}\n",
566 ink.dim_on("(no OpenCode usage in the window)".into())
567 ));
568 }
569 out
570}
571
572pub fn session_text(rows: &[crate::ocuse::db::Row], limit: usize, colour: bool) -> String {
574 let ink = Ink::new(colour);
575 let mut by_session: std::collections::BTreeMap<
576 &str,
577 (f64, u64, i64, std::collections::BTreeSet<&str>),
578 > = Default::default();
579 for row in rows {
580 let entry =
581 by_session
582 .entry(row.session.as_str())
583 .or_insert((0.0, 0, 0, Default::default()));
584 entry.0 += row.cost_usd;
585 entry.1 += 1;
586 entry.2 = entry.2.max(row.at_ms);
587 entry.3.insert(row.model.as_str());
588 }
589 let mut sessions: Vec<_> = by_session.into_iter().collect();
590 sessions.sort_by_key(|(_, (_, _, last, _))| std::cmp::Reverse(*last));
591 let mut out = format!(
592 "{}session last models spend req{}\n",
593 ink.bold, ink.reset
594 );
595 for (id, (cost, requests, last, models)) in sessions.into_iter().take(limit) {
596 let short = id.rsplit('_').next().unwrap_or(id);
597 out.push_str(&format!(
598 "{:<32} {:<11} {:>6} {} {:>6}\n",
599 short,
600 ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
601 models.len(),
602 ink.cyan_on(format!("{:>9}", cmduse_core::money(cost))),
603 requests,
604 ));
605 }
606 if out.lines().count() == 1 {
607 out.push_str(&format!(
608 "{}\n",
609 ink.dim_on("(no sessions in the window)".into())
610 ));
611 }
612 out
613}
614
615pub fn date_of(day: i64) -> String {
618 let z = day + 719_468;
619 let era = z.div_euclid(146_097);
620 let doe = z.rem_euclid(146_097);
621 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
622 let y = yoe + era * 400;
623 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
624 let mp = (5 * doy + 2) / 153;
625 let d = doy - (153 * mp + 2) / 5 + 1;
626 let m = if mp < 10 { mp + 3 } else { mp - 9 };
627 let year = if m <= 2 { y + 1 } else { y };
628 format!("{year:04}-{m:02}-{d:02}")
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634 use crate::ocuse::db::Row;
635
636 fn report() -> Report {
637 let now = 1_800_000_000_000;
638 let rows = vec![Row {
639 at_ms: now - 60_000,
640 provider: "opencode-go".into(),
641 model: "glm-5.3-flash".into(),
642 cost_usd: 30.0,
643 ..Default::default()
644 }];
645 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None)
646 }
647
648 #[test]
649 fn text_shows_the_model_against_its_limit() {
650 let text = render_text(&report(), "/tmp/opencode.db", 10, false);
651 assert!(text.contains("glm-5.3-flash"), "{text}");
652 assert!(text.contains("$60"), "the model's monthly limit: {text}");
653 assert!(text.contains("50%"), "30 of 60 is half: {text}");
654 }
655
656 #[test]
657 fn json_and_status_line_carry_the_totals() {
658 let json = render_json(&report());
659 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
660 assert_eq!(parsed["totals"]["month"]["cost"], 30.0);
661 assert_eq!(parsed["models"][0]["id"], "glm-5.3-flash");
662 assert!(status_line(&report(), false).contains("glm-5.3-flash"));
663 }
664
665 #[test]
671 fn every_renderer_respects_the_colour_flag() {
672 let report = report();
673 let rows = vec![Row {
674 at_ms: report.now_ms - 60_000,
675 session: "ses_fixture".into(),
676 provider: "opencode-go".into(),
677 model: "glm-5.3-flash".into(),
678 cost_usd: 30.0,
679 ..Default::default()
680 }];
681 let now = report.now_ms;
682
683 let cases: Vec<(&str, String, String)> = vec![
684 (
685 "render_text",
686 render_text(&report, "/tmp/oc.db", 10, false),
687 render_text(&report, "/tmp/oc.db", 10, true),
688 ),
689 (
690 "status_line",
691 status_line(&report, false),
692 status_line(&report, true),
693 ),
694 ("plans_text", plans_text(false), plans_text(true)),
695 (
696 "model_text",
697 model_text(&report, "glm-5.3-flash", false),
698 model_text(&report, "glm-5.3-flash", true),
699 ),
700 (
701 "bucket_text/day",
702 bucket_text(&rows, now, 7, 86_400_000, "day", 0, false),
703 bucket_text(&rows, now, 7, 86_400_000, "day", 0, true),
704 ),
705 (
706 "bucket_text/hour",
707 bucket_text(&rows, now, 1, 3_600_000, "hour", 0, false),
708 bucket_text(&rows, now, 1, 3_600_000, "hour", 0, true),
709 ),
710 (
711 "session_text",
712 session_text(&rows, 20, false),
713 session_text(&rows, 20, true),
714 ),
715 ];
716
717 for (name, plain, coloured) in cases {
718 assert!(
719 !plain.contains('\x1b'),
720 "{name} leaked escapes when off: {plain}"
721 );
722 assert!(
723 coloured.contains('\x1b'),
724 "{name} rendered nothing coloured when on: {coloured}"
725 );
726 }
727 }
728
729 #[test]
730 fn plain_frames_carry_no_escapes() {
731 let text = render_text(&report(), "/tmp/opencode.db", 10, false);
732 assert!(
733 !text.contains('\x1b'),
734 "piped output must stay plain: {text}"
735 );
736 assert!(text.contains("50%"), "{text}");
738 }
739
740 #[test]
741 fn an_over_cap_window_is_flagged_in_both_modes() {
742 let plain = render_text(&report(), "/tmp/opencode.db", 10, false);
744 assert!(plain.contains("LIMIT EXCEEDED"), "{plain}");
745 let coloured = render_text(&report(), "/tmp/opencode.db", 10, true);
746 assert!(coloured.contains("LIMIT EXCEEDED"), "{coloured}");
747 assert!(
748 coloured.contains(crate::render::RED),
749 "the flag and the gauge are red: {coloured}"
750 );
751 }
752
753 #[test]
754 fn a_model_over_any_of_its_own_windows_is_flagged() {
755 let plain = render_text(&report(), "/tmp/opencode.db", 10, false);
758 let row = plain
759 .lines()
760 .find(|line| line.starts_with("glm-5.3-flash"))
761 .expect("the model row");
762 assert!(row.contains("LIMIT EXCEEDED"), "{row}");
763 assert!(row.trim_end().ends_with('%'), "share ends the row: {row}");
765 }
766
767 #[test]
768 fn a_model_within_its_limits_is_not_flagged() {
769 let now = 1_800_000_000_000;
770 let rows = vec![Row {
772 at_ms: now - 60_000,
773 provider: "opencode-go".into(),
774 model: "glm-5.3-flash".into(),
775 cost_usd: 5.0,
776 ..Default::default()
777 }];
778 let report =
779 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None);
780 for colour in [false, true] {
781 let text = render_text(&report, "/tmp/opencode.db", 10, colour);
782 assert!(!text.contains("LIMIT EXCEEDED"), "colour={colour}: {text}");
783 }
784 }
785
786 #[test]
787 fn the_model_detail_spells_out_the_share() {
788 let detail = model_text(&report(), "glm-5.3-flash", false);
789 assert!(detail.contains("100.0% of period spend"), "{detail}");
790 let empty = crate::ocuse::window::build(
792 &[],
793 &zen::catalog(),
794 zen::DEFAULT_PLAN,
795 1_800_000_000_000,
796 None,
797 );
798 assert!(!model_text(&empty, "glm-5.3-flash", false).contains("share"));
799 }
800
801 #[test]
802 fn colour_frames_get_the_palette_and_a_gauge() {
803 let text = render_text(&report(), "/tmp/opencode.db", 10, true);
804 assert!(text.contains(crate::render::BOLD), "{text}");
805 assert!(text.contains(crate::render::CYAN), "{text}");
806 assert!(
807 text.contains(crate::render::GREEN),
808 "the 50% monthly gauge and USE cell are green: {text}"
809 );
810 assert!(
811 text.contains(crate::render::RED),
812 "the over-cap 5-hour gauge (30 of 12) is red: {text}"
813 );
814 assert!(
815 text.contains('━') && text.contains('╱'),
816 "gauge bar: {text}"
817 );
818 assert!(text.contains("resets in"), "{text}");
819 }
820
821 #[test]
822 fn watch_frame_ends_on_the_status_line_and_shows_bursts() {
823 let frame = watch_frame(
824 &report(),
825 "/tmp/opencode.db",
826 10,
827 true,
828 &[0.0, 1.5, 0.0],
829 10,
830 );
831 assert!(
832 !frame.ends_with('\n'),
833 "the caller's status line owns the last row"
834 );
835 assert!(frame.contains("spend bursts (10s samples)"), "{frame}");
836 assert!(
837 frame.contains('█'),
838 "a nonzero delta shows as a tall bar: {frame}"
839 );
840 }
841
842 #[test]
843 fn idle_history_omits_the_sparkline() {
844 let all_idle = watch_frame(&report(), "/tmp/opencode.db", 10, true, &[0.0, 0.0], 10);
845 assert!(!all_idle.contains("spend bursts"), "{all_idle}");
846 let first_frame = watch_frame(&report(), "/tmp/opencode.db", 10, true, &[], 10);
847 assert!(!first_frame.contains("spend bursts"), "{first_frame}");
848 }
849}