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 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 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 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
132fn 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 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
169pub 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
240pub 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
266pub 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
284pub 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
319pub 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
371pub 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 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
415pub 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
452pub 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 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}