1use ccstat_core::aggregation_types::SessionBlock;
8use ccstat_core::model_formatter::format_model_name;
9use chrono::{DateTime, Duration, Utc};
10use colored::*;
11use std::fmt;
12
13const BOX_TOP_LEFT: &str = "+";
15const BOX_TOP_RIGHT: &str = "+";
16const BOX_BOTTOM_LEFT: &str = "+";
17const BOX_BOTTOM_RIGHT: &str = "+";
18const BOX_HORIZONTAL: &str = "-";
19const BOX_VERTICAL: &str = "|";
20const BOX_T_LEFT: &str = "+";
21const BOX_T_RIGHT: &str = "+";
22
23const PROGRESS_FULL: &str = "#";
25const PROGRESS_EMPTY: &str = ".";
26
27pub const DEFAULT_MAX_COST: f64 = 50.0;
29
30const PROJECTION_EXCEED_THRESHOLD: f64 = 100.0;
32
33const PROJECTION_APPROACHING_THRESHOLD: f64 = 80.0;
35
36pub struct BlocksMonitor {
38 width: usize,
39 timezone: chrono_tz::Tz,
40 max_historical_cost: f64,
41 colored_output: bool,
43}
44
45impl BlocksMonitor {
46 pub fn new(timezone: chrono_tz::Tz, max_historical_cost: Option<f64>) -> Self {
48 let raw_width = terminal_width().unwrap_or(100);
51 let width = if raw_width < 60 {
52 raw_width
53 } else {
54 raw_width.clamp(60, 120)
55 };
56 let max_historical_cost = max_historical_cost.unwrap_or(DEFAULT_MAX_COST);
58 let colored_output = std::env::var("NO_COLOR").is_err();
60 Self {
61 width,
62 timezone,
63 max_historical_cost,
64 colored_output,
65 }
66 }
67
68 pub fn render_active_block(&self, block: &SessionBlock, now: DateTime<Utc>) -> String {
70 let mut output = String::new();
71
72 let elapsed = now - block.start_time;
74 let remaining = block.end_time - now;
75 let block_duration = block.end_time - block.start_time;
76 let block_progress = if block_duration.num_seconds() > 0 {
77 (elapsed.num_seconds() as f64 / block_duration.num_seconds() as f64) * 100.0
78 } else {
79 0.0
80 };
81
82 let current_cost = block.total_cost;
86 let elapsed_minutes = elapsed.num_minutes();
87 let burn_rate = if elapsed_minutes < 1 && elapsed.num_seconds() > 0 {
88 current_cost / (elapsed.num_seconds() as f64 / 60.0)
90 } else {
91 current_cost / (elapsed_minutes.max(1) as f64)
92 };
93 let remaining_minutes = remaining.num_minutes().max(0) as f64;
94 let projected_cost = current_cost + (burn_rate * remaining_minutes);
95
96 let usage_percentage = if self.max_historical_cost > 0.0 {
98 (current_cost / self.max_historical_cost) * 100.0
99 } else {
100 0.0
101 };
102 let projection_percentage = if self.max_historical_cost > 0.0 {
104 (projected_cost / self.max_historical_cost) * 100.0
105 } else {
106 0.0
107 };
108
109 let status_color = if projection_percentage > PROJECTION_EXCEED_THRESHOLD {
111 "red"
112 } else if projection_percentage > PROJECTION_APPROACHING_THRESHOLD {
113 "yellow"
114 } else {
115 "green"
116 };
117
118 output.push_str(&self.draw_box_top());
120 output.push_str(&self.draw_title());
121 output.push_str(&self.draw_separator());
122 output.push('\n');
123
124 output.push_str(&self.draw_block_progress(
126 block_progress,
127 block.start_time,
128 elapsed,
129 remaining,
130 block.end_time,
131 ));
132 output.push('\n');
133
134 output.push_str(&self.draw_usage_section(
136 usage_percentage,
137 current_cost,
138 burn_rate,
139 block.tokens.total() as f64,
140 status_color,
141 ));
142 output.push('\n');
143
144 output.push_str(&self.draw_projection_section(
146 projection_percentage,
147 projected_cost,
148 status_color,
149 ));
150 output.push('\n');
151
152 output.push_str(&self.draw_info_section(block));
154
155 output.push_str(&self.draw_separator());
156 output.push_str(&self.draw_footer());
157 output.push_str(&self.draw_box_bottom());
158
159 output
160 }
161
162 fn draw_box_top(&self) -> String {
164 format!(
165 "{}{}{}",
166 BOX_TOP_LEFT,
167 BOX_HORIZONTAL.repeat(self.width - 2),
168 BOX_TOP_RIGHT
169 )
170 }
171
172 fn draw_box_bottom(&self) -> String {
174 format!(
175 "\n{}{}{}",
176 BOX_BOTTOM_LEFT,
177 BOX_HORIZONTAL.repeat(self.width - 2),
178 BOX_BOTTOM_RIGHT
179 )
180 }
181
182 fn draw_separator(&self) -> String {
184 format!(
185 "\n{}{}{}",
186 BOX_T_LEFT,
187 BOX_HORIZONTAL.repeat(self.width - 2),
188 BOX_T_RIGHT
189 )
190 }
191
192 fn draw_title(&self) -> String {
194 let title = "CCSTAT - LIVE BILLING BLOCK MONITOR";
195 self.draw_centered_line(title)
196 }
197
198 fn draw_footer(&self) -> String {
200 let footer = "Refreshing every 5s - Press Ctrl+C to stop";
201 self.draw_centered_line(footer)
202 }
203
204 fn draw_centered_line(&self, text: &str) -> String {
206 let text_width = console::measure_text_width(text);
207 let available_width = self.width.saturating_sub(2);
208 if text_width >= available_width {
209 return format!("\n{} {} {}", BOX_VERTICAL, text, BOX_VERTICAL);
211 }
212 let padding = (available_width - text_width) / 2;
213 let left_pad = " ".repeat(padding);
214 let right_pad = " ".repeat(available_width - padding - text_width);
215 format!(
216 "\n{}{}{}{}{}",
217 BOX_VERTICAL, left_pad, text, right_pad, BOX_VERTICAL
218 )
219 }
220
221 fn draw_line(&self, content: &str) -> String {
223 let available_width = self.width.saturating_sub(4); let truncated_content = console::truncate_str(content, available_width, "...");
225 let final_width = console::measure_text_width(&truncated_content);
226
227 let padding = available_width.saturating_sub(final_width);
228 format!(
229 "\n{} {}{} {}",
230 BOX_VERTICAL,
231 truncated_content,
232 " ".repeat(padding),
233 BOX_VERTICAL
234 )
235 }
236
237 fn draw_block_progress(
239 &self,
240 progress: f64,
241 start_time: DateTime<Utc>,
242 elapsed: Duration,
243 remaining: Duration,
244 end_time: DateTime<Utc>,
245 ) -> String {
246 let mut output = String::new();
247
248 let bar = self.create_progress_bar(progress, 40);
250 let progress_line = format!("TIME {} {:5.1}%", bar, progress.min(999.9));
251 output.push_str(&self.draw_line(&progress_line));
252
253 let start_str = start_time.with_timezone(&self.timezone).format("%H:%M:%S");
255 let end_str = end_time.with_timezone(&self.timezone).format("%H:%M:%S");
256 let elapsed_str = self.format_duration(elapsed);
257 let remaining_str = if remaining.num_seconds() > 0 {
258 self.format_duration(remaining)
259 } else {
260 "Expired".to_string()
261 };
262
263 let time_line = format!(
264 " Started: {} Elapsed: {} Remaining: {} ({})",
265 start_str, elapsed_str, remaining_str, end_str
266 );
267 output.push_str(&self.draw_line(&time_line));
268
269 output
270 }
271
272 fn draw_usage_section(
274 &self,
275 usage_percentage: f64,
276 current_cost: f64,
277 burn_rate: f64,
278 total_tokens: f64,
279 status_color: &str,
280 ) -> String {
281 let mut output = String::new();
282
283 let bar = self.create_colored_progress_bar(usage_percentage, 40, status_color);
285 let usage_line = format!(
287 "USAGE {} {:5.1}% (${:8.2}/${:8.2})",
288 bar,
289 usage_percentage.min(999.9),
290 current_cost.min(99999.99),
291 self.max_historical_cost.min(99999.99)
292 );
293 output.push_str(&self.draw_line(&usage_line));
294
295 let burn_status = self.get_burn_status(burn_rate);
297 let detail_line = format!(
298 " Cost: ${:8.2} (Burn: ${:.3}/min {}) Tokens: {}",
299 current_cost.min(99999.99),
300 burn_rate.min(999.999),
301 burn_status,
302 self.format_number(total_tokens as u64)
303 );
304 output.push_str(&self.draw_line(&detail_line));
305
306 output
307 }
308
309 fn draw_projection_section(
311 &self,
312 projection_percentage: f64,
313 projected_cost: f64,
314 status_color: &str,
315 ) -> String {
316 let mut output = String::new();
317
318 let bar = self.create_colored_progress_bar(projection_percentage, 40, status_color);
320 let projection_line = format!(
322 "PROJECTION {} {:5.1}% (${:8.2}/${:8.2})",
323 bar,
324 projection_percentage.min(999.9),
325 projected_cost.min(99999.99),
326 self.max_historical_cost.min(99999.99)
327 );
328 output.push_str(&self.draw_line(&projection_line));
329
330 let (status_text, color) = if projection_percentage > PROJECTION_EXCEED_THRESHOLD {
332 ("WILL EXCEED LIMIT", "red")
333 } else if projection_percentage > PROJECTION_APPROACHING_THRESHOLD {
334 ("APPROACHING LIMIT", "yellow")
335 } else {
336 ("WITHIN LIMITS", "green")
337 };
338
339 let status = if self.colored_output {
340 match color {
341 "red" => status_text.red().to_string(),
342 "yellow" => status_text.yellow().to_string(),
343 _ => status_text.green().to_string(),
344 }
345 } else {
346 status_text.to_string()
347 };
348
349 let status_line = format!(
350 " Status: {} Projected Cost: ${:8.2}",
351 status,
352 projected_cost.min(99999.99)
353 );
354 output.push_str(&self.draw_line(&status_line));
355
356 output
357 }
358
359 fn draw_info_section(&self, block: &SessionBlock) -> String {
361 let models = if block.models_used.is_empty() {
362 "None".to_string()
363 } else {
364 block
366 .models_used
367 .iter()
368 .map(|m| format_model_name(m, false))
369 .collect::<Vec<_>>()
370 .join(", ")
371 };
372
373 let projects = if block.projects_used.is_empty() {
374 "Default".to_string()
375 } else {
376 format!("{}", block.projects_used.len())
377 };
378
379 let info_line = format!(
380 "Models: {} Sessions: {} Projects: {}",
381 models,
382 block.sessions.len(),
383 projects
384 );
385 self.draw_line(&info_line)
386 }
387
388 fn create_progress_bar(&self, percentage: f64, width: usize) -> String {
390 let clamped_percentage = percentage.clamp(0.0, 100.0);
391 let filled = ((clamped_percentage / 100.0) * width as f64) as usize;
392 let filled = filled.min(width); let empty = width.saturating_sub(filled);
394 format!(
395 "[{}{}]",
396 PROGRESS_FULL.repeat(filled),
397 PROGRESS_EMPTY.repeat(empty)
398 )
399 }
400
401 fn create_colored_progress_bar(&self, percentage: f64, width: usize, color: &str) -> String {
403 let display_percentage = if percentage > 100.0 {
405 100.0
407 } else {
408 percentage
409 };
410 let bar = self.create_progress_bar(display_percentage, width);
411
412 if !self.colored_output {
414 return bar;
415 }
416
417 match color {
418 "red" => bar.red().to_string(),
419 "yellow" => bar.yellow().to_string(),
420 _ => bar.green().to_string(),
421 }
422 }
423
424 fn get_burn_status(&self, burn_rate: f64) -> String {
426 const HIGH_BURN_RATE_THRESHOLD: f64 = 0.5;
427 const ELEVATED_BURN_RATE_THRESHOLD: f64 = 0.2;
428
429 let status_text = if burn_rate > HIGH_BURN_RATE_THRESHOLD {
430 "HIGH"
431 } else if burn_rate > ELEVATED_BURN_RATE_THRESHOLD {
432 "ELEVATED"
433 } else {
434 "NORMAL"
435 };
436
437 if !self.colored_output {
439 return status_text.to_string();
440 }
441
442 if burn_rate > HIGH_BURN_RATE_THRESHOLD {
444 status_text.red().to_string()
445 } else if burn_rate > ELEVATED_BURN_RATE_THRESHOLD {
446 status_text.yellow().to_string()
447 } else {
448 status_text.green().to_string()
449 }
450 }
451
452 fn format_duration(&self, duration: Duration) -> String {
454 let hours = duration.num_hours();
455 let minutes = duration.num_minutes() % 60;
456 format!("{}h {}m", hours, minutes)
457 }
458
459 fn format_number(&self, num: u64) -> String {
461 let s = num.to_string();
462 let mut result = String::new();
463 for (i, c) in s.chars().rev().enumerate() {
464 if i > 0 && i % 3 == 0 {
465 result.push(',');
466 }
467 result.push(c);
468 }
469 result.chars().rev().collect()
470 }
471}
472
473fn terminal_width() -> Option<usize> {
475 terminal_size::terminal_size().map(|(width, _)| width.0 as usize)
476}
477
478impl fmt::Display for BlocksMonitor {
479 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
480 write!(f, "BlocksMonitor(width: {})", self.width)
481 }
482}