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 {
405 let ink = Ink::new(colour);
406 let catalog = crate::ocuse::zen::catalog();
407 let mut out = format!(
408 "{}OpenCode plans{} {}\n\n",
409 ink.bold,
410 ink.reset,
411 ink.dim_on(format!("(docs, extracted {})", catalog.extracted_at)),
412 );
413 out.push_str(&format!(
414 "{}GO{} — {}/month · windows: 5h {} · weekly {} · monthly {}\n",
415 ink.bold,
416 ink.reset,
417 ink.cyan_on(format!("${:.2}", catalog.go.price_usd)),
418 ink.cyan_on(format!("{:.0}%", catalog.go.window_share.five_hour * 100.0)),
419 ink.cyan_on(format!("{:.0}%", catalog.go.window_share.weekly * 100.0)),
420 ink.cyan_on(format!("{:.0}%", catalog.go.window_share.monthly * 100.0)),
421 ));
422 out.push_str(&format!(
423 "{}MODEL LIMIT IN OUT CACHE{}\n",
424 ink.bold, ink.reset
425 ));
426 for model in &catalog.go.models {
427 let rate = model.variants.first();
428 let limit = model
429 .monthly_limit
430 .map(cmduse_core::money)
431 .unwrap_or_else(|| "—".into());
432 out.push_str(&format!(
433 "{:<28} {} {} {} {}\n",
434 model.id,
435 ink.cyan_on(format!("{limit:>7}")),
436 ink.cyan_on(format!(
437 "{:>6}",
438 rate.map(|r| format!("${}", r.input)).unwrap_or_default()
439 )),
440 ink.cyan_on(format!(
441 "{:>6}",
442 rate.map(|r| format!("${}", r.output)).unwrap_or_default()
443 )),
444 ink.cyan_on(format!(
445 "{:>8}",
446 rate.map(|r| format!("${}", r.cache_read))
447 .unwrap_or_default()
448 )),
449 ));
450 }
451 out.push_str(&format!(
452 "\n{}\n",
453 ink.dim_on(format!(
454 "ZEN — pay-as-you-go, {} models priced per 1M tokens",
455 catalog.zen.models.len()
456 ))
457 ));
458 out
459}
460
461pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
462 let ink = Ink::new(colour);
463 let Some(model) = report.models.iter().find(|m| m.id == id) else {
464 return format!("{id}: no usage in the period\n");
465 };
466 let limit = model
467 .limit
468 .map(cmduse_core::money)
469 .unwrap_or_else(|| "—".into());
470 let row =
471 |label: &str, value: String| format!(" {}{value}\n", ink.dim_on(format!("{label:<10}")));
472 let mut out = format!(
473 "{} {}\n",
474 ink.bold_on(model.id.clone()),
475 ink.dim_on(format!("({})", model.provider)),
476 );
477 out.push_str(&row("limit", ink.cyan_on(limit)));
478 out.push_str(&row(
479 "month",
480 ink.cyan_on(format!(
481 "{} · {} requests",
482 cmduse_core::money(model.month.cost),
483 model.month.requests
484 )),
485 ));
486 out.push_str(&row(
487 "5-hour",
488 ink.cyan_on(cmduse_core::money(model.five_hour.cost)),
489 ));
490 out.push_str(&row(
491 "weekly",
492 ink.cyan_on(cmduse_core::money(model.weekly.cost)),
493 ));
494 if report.month.cost > 0.0 {
497 out.push_str(&row(
498 "share",
499 ink.dim_on(format!(
500 "{:.1}% of period spend",
501 model.month.cost / report.month.cost * 100.0
502 )),
503 ));
504 }
505 out
506}
507
508pub fn bucket_text(
510 rows: &[crate::ocuse::db::Row],
511 now_ms: i64,
512 days: i64,
513 size_ms: i64,
514 label: &str,
515 tz_secs: i64,
516 colour: bool,
517) -> String {
518 let ink = Ink::new(colour);
519 let shift = tz_secs * 1000;
521 let mut buckets: std::collections::BTreeMap<i64, (f64, u64, u64)> = Default::default();
522 for row in rows {
523 let bucket = (row.at_ms + shift).div_euclid(size_ms);
524 let entry = buckets.entry(bucket).or_insert((0.0, 0, 0));
525 entry.0 += row.cost_usd;
526 entry.1 += 1;
527 entry.2 += row.input + row.output + row.cache_read;
528 }
529 let start = (now_ms + shift - days * 86_400_000).div_euclid(size_ms);
530 let mut out = format!(
531 "{}{label:<12} spend requests tokens{}\n",
532 ink.bold, ink.reset
533 );
534 for (bucket, (cost, requests, tokens)) in buckets.range(start..) {
535 let stamp = if size_ms >= 86_400_000 {
536 date_of(*bucket)
537 } else {
538 format!(
539 "{} {:02}h",
540 date_of(bucket.div_euclid(24)),
541 bucket.rem_euclid(24)
542 )
543 };
544 out.push_str(&format!(
545 "{stamp:<12} {} {} {}\n",
546 ink.cyan_on(format!("{:>9}", cmduse_core::money(*cost))),
547 ink.cyan_on(format!("{:>12}", cmduse_core::compact(*requests))),
548 ink.cyan_on(format!("{:>9}", cmduse_core::compact(*tokens))),
549 ));
550 }
551 if out.lines().count() == 1 {
552 out.push_str(&format!(
553 "{}\n",
554 ink.dim_on("(no OpenCode usage in the window)".into())
555 ));
556 }
557 out
558}
559
560pub fn session_text(rows: &[crate::ocuse::db::Row], limit: usize, colour: bool) -> String {
562 let ink = Ink::new(colour);
563 let mut by_session: std::collections::BTreeMap<
564 &str,
565 (f64, u64, i64, std::collections::BTreeSet<&str>),
566 > = Default::default();
567 for row in rows {
568 let entry =
569 by_session
570 .entry(row.session.as_str())
571 .or_insert((0.0, 0, 0, Default::default()));
572 entry.0 += row.cost_usd;
573 entry.1 += 1;
574 entry.2 = entry.2.max(row.at_ms);
575 entry.3.insert(row.model.as_str());
576 }
577 let mut sessions: Vec<_> = by_session.into_iter().collect();
578 sessions.sort_by_key(|(_, (_, _, last, _))| std::cmp::Reverse(*last));
579 let mut out = format!(
580 "{}session last models spend req{}\n",
581 ink.bold, ink.reset
582 );
583 for (id, (cost, requests, last, models)) in sessions.into_iter().take(limit) {
584 let short = id.rsplit('_').next().unwrap_or(id);
585 out.push_str(&format!(
586 "{:<32} {:<11} {:>6} {} {:>6}\n",
587 short,
588 ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
589 models.len(),
590 ink.cyan_on(format!("{:>9}", cmduse_core::money(cost))),
591 requests,
592 ));
593 }
594 if out.lines().count() == 1 {
595 out.push_str(&format!(
596 "{}\n",
597 ink.dim_on("(no sessions in the window)".into())
598 ));
599 }
600 out
601}
602
603pub fn date_of(day: i64) -> String {
606 let z = day + 719_468;
607 let era = z.div_euclid(146_097);
608 let doe = z.rem_euclid(146_097);
609 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
610 let y = yoe + era * 400;
611 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
612 let mp = (5 * doy + 2) / 153;
613 let d = doy - (153 * mp + 2) / 5 + 1;
614 let m = if mp < 10 { mp + 3 } else { mp - 9 };
615 let year = if m <= 2 { y + 1 } else { y };
616 format!("{year:04}-{m:02}-{d:02}")
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use crate::ocuse::db::Row;
623
624 fn report() -> Report {
625 let now = 1_800_000_000_000;
626 let rows = vec![Row {
627 at_ms: now - 60_000,
628 provider: "opencode-go".into(),
629 model: "glm-5.3-flash".into(),
630 cost_usd: 30.0,
631 ..Default::default()
632 }];
633 crate::ocuse::window::build(&rows, &zen::catalog(), now, None)
634 }
635
636 #[test]
637 fn text_shows_the_model_against_its_limit() {
638 let text = render_text(&report(), "/tmp/opencode.db", 10, false);
639 assert!(text.contains("glm-5.3-flash"), "{text}");
640 assert!(text.contains("$60"), "the model's monthly limit: {text}");
641 assert!(text.contains("50%"), "30 of 60 is half: {text}");
642 }
643
644 #[test]
645 fn json_and_status_line_carry_the_totals() {
646 let json = render_json(&report());
647 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
648 assert_eq!(parsed["totals"]["month"]["cost"], 30.0);
649 assert_eq!(parsed["models"][0]["id"], "glm-5.3-flash");
650 assert!(status_line(&report(), false).contains("glm-5.3-flash"));
651 }
652
653 #[test]
659 fn every_renderer_respects_the_colour_flag() {
660 let report = report();
661 let rows = vec![Row {
662 at_ms: report.now_ms - 60_000,
663 session: "ses_fixture".into(),
664 provider: "opencode-go".into(),
665 model: "glm-5.3-flash".into(),
666 cost_usd: 30.0,
667 ..Default::default()
668 }];
669 let now = report.now_ms;
670
671 let cases: Vec<(&str, String, String)> = vec![
672 (
673 "render_text",
674 render_text(&report, "/tmp/oc.db", 10, false),
675 render_text(&report, "/tmp/oc.db", 10, true),
676 ),
677 (
678 "status_line",
679 status_line(&report, false),
680 status_line(&report, true),
681 ),
682 ("plans_text", plans_text(false), plans_text(true)),
683 (
684 "model_text",
685 model_text(&report, "glm-5.3-flash", false),
686 model_text(&report, "glm-5.3-flash", true),
687 ),
688 (
689 "bucket_text/day",
690 bucket_text(&rows, now, 7, 86_400_000, "day", 0, false),
691 bucket_text(&rows, now, 7, 86_400_000, "day", 0, true),
692 ),
693 (
694 "bucket_text/hour",
695 bucket_text(&rows, now, 1, 3_600_000, "hour", 0, false),
696 bucket_text(&rows, now, 1, 3_600_000, "hour", 0, true),
697 ),
698 (
699 "session_text",
700 session_text(&rows, 20, false),
701 session_text(&rows, 20, true),
702 ),
703 ];
704
705 for (name, plain, coloured) in cases {
706 assert!(
707 !plain.contains('\x1b'),
708 "{name} leaked escapes when off: {plain}"
709 );
710 assert!(
711 coloured.contains('\x1b'),
712 "{name} rendered nothing coloured when on: {coloured}"
713 );
714 }
715 }
716
717 #[test]
718 fn plain_frames_carry_no_escapes() {
719 let text = render_text(&report(), "/tmp/opencode.db", 10, false);
720 assert!(
721 !text.contains('\x1b'),
722 "piped output must stay plain: {text}"
723 );
724 assert!(text.contains("50%"), "{text}");
726 }
727
728 #[test]
729 fn an_over_cap_window_is_flagged_in_both_modes() {
730 let plain = render_text(&report(), "/tmp/opencode.db", 10, false);
732 assert!(plain.contains("LIMIT EXCEEDED"), "{plain}");
733 let coloured = render_text(&report(), "/tmp/opencode.db", 10, true);
734 assert!(coloured.contains("LIMIT EXCEEDED"), "{coloured}");
735 assert!(
736 coloured.contains(crate::render::RED),
737 "the flag and the gauge are red: {coloured}"
738 );
739 }
740
741 #[test]
742 fn a_model_over_any_of_its_own_windows_is_flagged() {
743 let plain = render_text(&report(), "/tmp/opencode.db", 10, false);
746 let row = plain
747 .lines()
748 .find(|line| line.starts_with("glm-5.3-flash"))
749 .expect("the model row");
750 assert!(row.contains("LIMIT EXCEEDED"), "{row}");
751 assert!(row.trim_end().ends_with('%'), "share ends the row: {row}");
753 }
754
755 #[test]
756 fn a_model_within_its_limits_is_not_flagged() {
757 let now = 1_800_000_000_000;
758 let rows = vec![Row {
760 at_ms: now - 60_000,
761 provider: "opencode-go".into(),
762 model: "glm-5.3-flash".into(),
763 cost_usd: 5.0,
764 ..Default::default()
765 }];
766 let report = crate::ocuse::window::build(&rows, &zen::catalog(), now, None);
767 for colour in [false, true] {
768 let text = render_text(&report, "/tmp/opencode.db", 10, colour);
769 assert!(!text.contains("LIMIT EXCEEDED"), "colour={colour}: {text}");
770 }
771 }
772
773 #[test]
774 fn the_model_detail_spells_out_the_share() {
775 let detail = model_text(&report(), "glm-5.3-flash", false);
776 assert!(detail.contains("100.0% of period spend"), "{detail}");
777 let empty = crate::ocuse::window::build(&[], &zen::catalog(), 1_800_000_000_000, None);
779 assert!(!model_text(&empty, "glm-5.3-flash", false).contains("share"));
780 }
781
782 #[test]
783 fn colour_frames_get_the_palette_and_a_gauge() {
784 let text = render_text(&report(), "/tmp/opencode.db", 10, true);
785 assert!(text.contains(crate::render::BOLD), "{text}");
786 assert!(text.contains(crate::render::CYAN), "{text}");
787 assert!(
788 text.contains(crate::render::GREEN),
789 "the 50% monthly gauge and USE cell are green: {text}"
790 );
791 assert!(
792 text.contains(crate::render::RED),
793 "the over-cap 5-hour gauge (30 of 12) is red: {text}"
794 );
795 assert!(
796 text.contains('━') && text.contains('╱'),
797 "gauge bar: {text}"
798 );
799 assert!(text.contains("resets in"), "{text}");
800 }
801
802 #[test]
803 fn watch_frame_ends_on_the_status_line_and_shows_bursts() {
804 let frame = watch_frame(
805 &report(),
806 "/tmp/opencode.db",
807 10,
808 true,
809 &[0.0, 1.5, 0.0],
810 10,
811 );
812 assert!(
813 !frame.ends_with('\n'),
814 "the caller's status line owns the last row"
815 );
816 assert!(frame.contains("spend bursts (10s samples)"), "{frame}");
817 assert!(
818 frame.contains('█'),
819 "a nonzero delta shows as a tall bar: {frame}"
820 );
821 }
822
823 #[test]
824 fn idle_history_omits_the_sparkline() {
825 let all_idle = watch_frame(&report(), "/tmp/opencode.db", 10, true, &[0.0, 0.0], 10);
826 assert!(!all_idle.contains("spend bursts"), "{all_idle}");
827 let first_frame = watch_frame(&report(), "/tmp/opencode.db", 10, true, &[], 10);
828 assert!(!first_frame.contains("spend bursts"), "{first_frame}");
829 }
830}