Skip to main content

ccstat_terminal/
blocks_monitor.rs

1//! Enhanced live monitoring display for billing blocks
2//!
3//! This module provides a modern, informative terminal UI for monitoring
4//! active billing blocks with progress bars, burn rate calculations,
5//! and usage projections.
6
7use ccstat_core::aggregation_types::SessionBlock;
8use ccstat_core::model_formatter::format_model_name;
9use chrono::{DateTime, Duration, Utc};
10use colored::*;
11use std::fmt;
12
13/// Box drawing characters for UI (ASCII)
14const 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
23/// Progress bar characters (ASCII)
24const PROGRESS_FULL: &str = "#";
25const PROGRESS_EMPTY: &str = ".";
26
27/// Default maximum historical cost for blocks monitoring (in USD)
28pub const DEFAULT_MAX_COST: f64 = 50.0;
29
30/// Threshold for exceeding projection limit (percentage)
31const PROJECTION_EXCEED_THRESHOLD: f64 = 100.0;
32
33/// Threshold for approaching projection limit (percentage)
34const PROJECTION_APPROACHING_THRESHOLD: f64 = 80.0;
35
36/// Enhanced display for billing blocks
37pub struct BlocksMonitor {
38    width: usize,
39    timezone: chrono_tz::Tz,
40    max_historical_cost: f64,
41    /// Whether to use colored output (respects NO_COLOR environment variable)
42    colored_output: bool,
43}
44
45impl BlocksMonitor {
46    /// Create a new blocks monitor
47    pub fn new(timezone: chrono_tz::Tz, max_historical_cost: Option<f64>) -> Self {
48        // Get terminal width or use default
49        // Use minimum width of 60 to support smaller terminals, but allow smaller if needed
50        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        // Use provided max cost or default to a reasonable value
57        let max_historical_cost = max_historical_cost.unwrap_or(DEFAULT_MAX_COST);
58        // Check NO_COLOR environment variable for accessibility
59        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    /// Render the active block with enhanced UI
69    pub fn render_active_block(&self, block: &SessionBlock, now: DateTime<Utc>) -> String {
70        let mut output = String::new();
71
72        // Calculate metrics
73        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        // Calculate burn rate and projection based on cost
83        // Note: For blocks with less than one minute elapsed, burn rate may be overestimated.
84        // To avoid this, use seconds for very short elapsed times.
85        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            // Use seconds for more accurate burn rate when elapsed time is less than a minute
89            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        // Usage percentage based on cost
97        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        // Fixed projection formula: (Current Cost + Burn Rate * Remaining time) / Maximum block Cost * 100%
103        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        // Determine status color
110        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        // Build the UI
119        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        // Block progress section
125        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        // Usage section
135        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        // Projection section
145        output.push_str(&self.draw_projection_section(
146            projection_percentage,
147            projected_cost,
148            status_color,
149        ));
150        output.push('\n');
151
152        // Info section
153        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    /// Draw the top of the box
163    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    /// Draw the bottom of the box
173    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    /// Draw a separator line
183    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    /// Draw the title
193    fn draw_title(&self) -> String {
194        let title = "CCSTAT - LIVE BILLING BLOCK MONITOR";
195        self.draw_centered_line(title)
196    }
197
198    /// Draw the footer
199    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    /// Draw a centered line within the box
205    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            // Text is too long, just use it as-is
210            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    /// Draw a left-aligned line with padding
222    fn draw_line(&self, content: &str) -> String {
223        let available_width = self.width.saturating_sub(4); // Account for "| " and " |"
224        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    /// Draw block progress section
238    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        // Progress bar
249        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        // Time details
254        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    /// Draw usage section
273    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        // Usage progress bar based on cost
284        let bar = self.create_colored_progress_bar(usage_percentage, 40, status_color);
285        // Use fixed width for cost values to prevent layout issues
286        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        // Burn rate and tokens
296        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    /// Draw projection section
310    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        // Projection bar based on cost
319        let bar = self.create_colored_progress_bar(projection_percentage, 40, status_color);
320        // Use fixed width for cost values to prevent layout issues
321        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        // Status
331        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    /// Draw info section
360    fn draw_info_section(&self, block: &SessionBlock) -> String {
361        let models = if block.models_used.is_empty() {
362            "None".to_string()
363        } else {
364            // Format model names to short version
365            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    /// Create a progress bar
389    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); // Ensure we don't exceed width
393        let empty = width.saturating_sub(filled);
394        format!(
395            "[{}{}]",
396            PROGRESS_FULL.repeat(filled),
397            PROGRESS_EMPTY.repeat(empty)
398        )
399    }
400
401    /// Create a colored progress bar
402    fn create_colored_progress_bar(&self, percentage: f64, width: usize, color: &str) -> String {
403        // Allow percentage to go over 100% for projections
404        let display_percentage = if percentage > 100.0 {
405            // Show filled bar for over 100%
406            100.0
407        } else {
408            percentage
409        };
410        let bar = self.create_progress_bar(display_percentage, width);
411
412        // Return plain bar if colors are disabled
413        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    /// Get burn rate status (based on cost per minute)
425    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        // Return plain text if colors are disabled
438        if !self.colored_output {
439            return status_text.to_string();
440        }
441
442        // Apply colors based on status
443        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    /// Format a duration
453    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    /// Format a number with thousands separator
460    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
473/// Get terminal width using the cross-platform terminal_size crate
474fn 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}