diff --git a/crates/tokscale-cli/src/main.rs b/crates/tokscale-cli/src/main.rs index 274b411d..865219d6 100644 --- a/crates/tokscale-cli/src/main.rs +++ b/crates/tokscale-cli/src/main.rs @@ -661,7 +661,7 @@ fn main() -> Result<()> { since, until, year, - Some(Tab::Daily), + Some(Tab::Monthly), ) } } diff --git a/crates/tokscale-cli/src/tui/app.rs b/crates/tokscale-cli/src/tui/app.rs index 3f6f86b9..e5163b9a 100644 --- a/crates/tokscale-cli/src/tui/app.rs +++ b/crates/tokscale-cli/src/tui/app.rs @@ -8,7 +8,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, use ratatui::layout::Rect; use tokscale_core::ClientId; -use super::data::{AgentUsage, DailyUsage, DataLoader, ModelUsage, UsageData}; +use super::data::{AgentUsage, DailyUsage, DataLoader, ModelUsage, MonthlyModelUsage, UsageData}; use super::settings::Settings; use super::themes::{Theme, ThemeName}; use super::ui::dialog::{ClientPickerDialog, DialogStack}; @@ -30,6 +30,7 @@ pub enum Tab { Overview, Models, Daily, + Monthly, Stats, Agents, } @@ -40,6 +41,7 @@ impl Tab { Tab::Overview, Tab::Models, Tab::Daily, + Tab::Monthly, Tab::Stats, Tab::Agents, ] @@ -50,6 +52,7 @@ impl Tab { Tab::Overview => "Overview", Tab::Models => "Models", Tab::Daily => "Daily", + Tab::Monthly => "Monthly", Tab::Stats => "Stats", Tab::Agents => "Agents", } @@ -60,6 +63,7 @@ impl Tab { Tab::Overview => "Ovw", Tab::Models => "Mod", Tab::Daily => "Day", + Tab::Monthly => "Mth", Tab::Stats => "Sta", Tab::Agents => "Agt", } @@ -69,7 +73,8 @@ impl Tab { match self { Tab::Overview => Tab::Models, Tab::Models => Tab::Daily, - Tab::Daily => Tab::Stats, + Tab::Daily => Tab::Monthly, + Tab::Monthly => Tab::Stats, Tab::Stats => Tab::Agents, Tab::Agents => Tab::Overview, } @@ -80,7 +85,8 @@ impl Tab { Tab::Overview => Tab::Agents, Tab::Models => Tab::Overview, Tab::Daily => Tab::Models, - Tab::Stats => Tab::Daily, + Tab::Monthly => Tab::Daily, + Tab::Stats => Tab::Monthly, Tab::Agents => Tab::Stats, } } @@ -597,6 +603,7 @@ impl App { Tab::Overview | Tab::Models => self.data.models.len(), Tab::Agents => self.data.agents.len(), Tab::Daily => self.data.daily.len(), + Tab::Monthly => self.data.monthly_models.len(), Tab::Stats => { if self.selected_graph_cell.is_some() { self.stats_breakdown_total_lines @@ -750,6 +757,15 @@ impl App { .get_sorted_daily() .get(self.selected_index) .map(|d| format!("{}: {} tokens, ${:.4}", d.date, d.tokens.total(), d.cost)), + Tab::Monthly => self.get_sorted_monthly().get(self.selected_index).map(|m| { + format!( + "{} {}: {} tokens, ${:.4}", + m.month, + m.model, + m.tokens.total(), + m.cost + ) + }), Tab::Stats => None, }; @@ -938,6 +954,53 @@ impl App { daily } + pub fn get_sorted_monthly(&self) -> Vec<&MonthlyModelUsage> { + let mut monthly: Vec<&MonthlyModelUsage> = self.data.monthly_models.iter().collect(); + + match (self.sort_field, self.sort_direction) { + (SortField::Cost, SortDirection::Descending) => monthly.sort_by(|a, b| { + b.cost + .total_cmp(&a.cost) + .then_with(|| b.month.cmp(&a.month)) + .then_with(|| a.model.cmp(&b.model)) + }), + (SortField::Cost, SortDirection::Ascending) => monthly.sort_by(|a, b| { + a.cost + .total_cmp(&b.cost) + .then_with(|| a.month.cmp(&b.month)) + .then_with(|| a.model.cmp(&b.model)) + }), + (SortField::Tokens, SortDirection::Descending) => monthly.sort_by(|a, b| { + b.tokens + .total() + .cmp(&a.tokens.total()) + .then_with(|| b.month.cmp(&a.month)) + .then_with(|| a.model.cmp(&b.model)) + }), + (SortField::Tokens, SortDirection::Ascending) => monthly.sort_by(|a, b| { + a.tokens + .total() + .cmp(&b.tokens.total()) + .then_with(|| a.month.cmp(&b.month)) + .then_with(|| a.model.cmp(&b.model)) + }), + (SortField::Date, SortDirection::Descending) => monthly.sort_by(|a, b| { + b.month + .cmp(&a.month) + .then_with(|| b.cost.total_cmp(&a.cost)) + .then_with(|| a.model.cmp(&b.model)) + }), + (SortField::Date, SortDirection::Ascending) => monthly.sort_by(|a, b| { + a.month + .cmp(&b.month) + .then_with(|| b.cost.total_cmp(&a.cost)) + .then_with(|| a.model.cmp(&b.model)) + }), + } + + monthly + } + pub fn is_narrow(&self) -> bool { self.terminal_width < 80 } @@ -955,19 +1018,21 @@ mod tests { #[test] fn test_tab_all() { let tabs = Tab::all(); - assert_eq!(tabs.len(), 5); + assert_eq!(tabs.len(), 6); assert_eq!(tabs[0], Tab::Overview); assert_eq!(tabs[1], Tab::Models); assert_eq!(tabs[2], Tab::Daily); - assert_eq!(tabs[3], Tab::Stats); - assert_eq!(tabs[4], Tab::Agents); + assert_eq!(tabs[3], Tab::Monthly); + assert_eq!(tabs[4], Tab::Stats); + assert_eq!(tabs[5], Tab::Agents); } #[test] fn test_tab_next() { assert_eq!(Tab::Overview.next(), Tab::Models); assert_eq!(Tab::Models.next(), Tab::Daily); - assert_eq!(Tab::Daily.next(), Tab::Stats); + assert_eq!(Tab::Daily.next(), Tab::Monthly); + assert_eq!(Tab::Monthly.next(), Tab::Stats); assert_eq!(Tab::Stats.next(), Tab::Agents); assert_eq!(Tab::Agents.next(), Tab::Overview); } @@ -977,7 +1042,8 @@ mod tests { assert_eq!(Tab::Overview.prev(), Tab::Agents); assert_eq!(Tab::Models.prev(), Tab::Overview); assert_eq!(Tab::Daily.prev(), Tab::Models); - assert_eq!(Tab::Stats.prev(), Tab::Daily); + assert_eq!(Tab::Monthly.prev(), Tab::Daily); + assert_eq!(Tab::Stats.prev(), Tab::Monthly); assert_eq!(Tab::Agents.prev(), Tab::Stats); } @@ -985,18 +1051,20 @@ mod tests { fn test_tab_as_str() { assert_eq!(Tab::Overview.as_str(), "Overview"); assert_eq!(Tab::Models.as_str(), "Models"); - assert_eq!(Tab::Agents.as_str(), "Agents"); assert_eq!(Tab::Daily.as_str(), "Daily"); + assert_eq!(Tab::Monthly.as_str(), "Monthly"); assert_eq!(Tab::Stats.as_str(), "Stats"); + assert_eq!(Tab::Agents.as_str(), "Agents"); } #[test] fn test_tab_short_name() { assert_eq!(Tab::Overview.short_name(), "Ovw"); assert_eq!(Tab::Models.short_name(), "Mod"); - assert_eq!(Tab::Agents.short_name(), "Agt"); assert_eq!(Tab::Daily.short_name(), "Day"); + assert_eq!(Tab::Monthly.short_name(), "Mth"); assert_eq!(Tab::Stats.short_name(), "Sta"); + assert_eq!(Tab::Agents.short_name(), "Agt"); } #[test] @@ -1268,6 +1336,9 @@ mod tests { app.handle_key_event(key(KeyCode::Tab)); assert_eq!(app.current_tab, Tab::Daily); + app.handle_key_event(key(KeyCode::Tab)); + assert_eq!(app.current_tab, Tab::Monthly); + app.handle_key_event(key(KeyCode::Tab)); assert_eq!(app.current_tab, Tab::Stats); @@ -1289,11 +1360,17 @@ mod tests { app.handle_key_event(key(KeyCode::BackTab)); assert_eq!(app.current_tab, Tab::Stats); + app.handle_key_event(key(KeyCode::BackTab)); + assert_eq!(app.current_tab, Tab::Monthly); + app.handle_key_event(key(KeyCode::BackTab)); assert_eq!(app.current_tab, Tab::Daily); app.handle_key_event(key(KeyCode::BackTab)); assert_eq!(app.current_tab, Tab::Models); + + app.handle_key_event(key(KeyCode::BackTab)); + assert_eq!(app.current_tab, Tab::Overview); } #[test] diff --git a/crates/tokscale-cli/src/tui/cache.rs b/crates/tokscale-cli/src/tui/cache.rs index e633a90e..a0ee60a1 100644 --- a/crates/tokscale-cli/src/tui/cache.rs +++ b/crates/tokscale-cli/src/tui/cache.rs @@ -332,6 +332,8 @@ impl TryFrom for UsageData { models: u.models.into_iter().map(|m| m.into()).collect(), agents: u.agents.into_iter().map(|a| a.into()).collect(), daily: daily?, + monthly_models: Vec::new(), + total_months: 0, graph: graph.transpose()?, total_tokens: u.total_tokens, total_cost: u.total_cost, diff --git a/crates/tokscale-cli/src/tui/data/mod.rs b/crates/tokscale-cli/src/tui/data/mod.rs index 8936d4f0..f3a47351 100644 --- a/crates/tokscale-cli/src/tui/data/mod.rs +++ b/crates/tokscale-cli/src/tui/data/mod.rs @@ -64,6 +64,17 @@ pub struct DailyUsage { pub models: BTreeMap, } +#[derive(Debug, Clone)] +pub struct MonthlyModelUsage { + pub month: String, + pub model: String, + pub provider: String, + pub client: String, + pub tokens: TokenBreakdown, + pub cost: f64, + pub message_count: u32, +} + #[derive(Debug, Clone)] pub struct ContributionDay { pub date: NaiveDate, @@ -82,6 +93,10 @@ pub struct UsageData { pub models: Vec, pub agents: Vec, pub daily: Vec, + #[allow(dead_code)] + pub monthly_models: Vec, + #[allow(dead_code)] + pub total_months: u32, pub graph: Option, pub total_tokens: u64, pub total_cost: f64, @@ -175,6 +190,8 @@ impl DataLoader { let mut agent_map: HashMap = HashMap::new(); let mut agent_clients: HashMap> = HashMap::new(); let mut daily_map: HashMap = HashMap::new(); + let mut monthly_map: HashMap<(String, String), MonthlyModelUsage> = HashMap::new(); + let mut month_set: BTreeSet = BTreeSet::new(); let mut model_session_ids: HashMap> = HashMap::new(); for msg in &messages { @@ -239,7 +256,7 @@ impl DataLoader { model_entry.cost += msg_cost; let session_key = format!("{}:{}", msg.client, msg.session_id); - let model_sessions = model_session_ids.entry(key).or_default(); + let model_sessions = model_session_ids.entry(key.clone()).or_default(); if model_sessions.insert(session_key) { model_entry.session_count += 1; } @@ -290,6 +307,62 @@ impl DataLoader { } if let Some(date) = parse_date(&msg.date) { + let month = date.format("%Y-%m").to_string(); + month_set.insert(month.clone()); + let monthly_key = (month.clone(), key.clone()); + + let monthly_entry = + monthly_map + .entry(monthly_key) + .or_insert_with(|| MonthlyModelUsage { + month: month.clone(), + model: normalized_model.clone(), + provider: msg.provider_id.clone(), + client: msg.client.clone(), + tokens: TokenBreakdown::default(), + cost: 0.0, + message_count: 0, + }); + + if *group_by == GroupBy::Model + && !monthly_entry.client.split(", ").any(|s| s == msg.client) + { + monthly_entry.client = format!("{}, {}", monthly_entry.client, msg.client); + } + + if *group_by != GroupBy::ClientProviderModel + && !monthly_entry + .provider + .split(", ") + .any(|p| p == msg.provider_id) + { + monthly_entry.provider = + format!("{}, {}", monthly_entry.provider, msg.provider_id); + } + + monthly_entry.tokens.input = monthly_entry + .tokens + .input + .saturating_add(msg.tokens.input.max(0) as u64); + monthly_entry.tokens.output = monthly_entry + .tokens + .output + .saturating_add(msg.tokens.output.max(0) as u64); + monthly_entry.tokens.cache_read = monthly_entry + .tokens + .cache_read + .saturating_add(msg.tokens.cache_read.max(0) as u64); + monthly_entry.tokens.cache_write = monthly_entry + .tokens + .cache_write + .saturating_add(msg.tokens.cache_write.max(0) as u64); + monthly_entry.tokens.reasoning = monthly_entry + .tokens + .reasoning + .saturating_add(msg.tokens.reasoning.max(0) as u64); + monthly_entry.cost += msg_cost; + monthly_entry.message_count = monthly_entry.message_count.saturating_add(1); + let daily_entry = daily_map.entry(date).or_insert_with(|| DailyUsage { date, tokens: TokenBreakdown::default(), @@ -388,6 +461,15 @@ impl DataLoader { let mut daily: Vec = daily_map.into_values().collect(); daily.sort_by(|a, b| b.date.cmp(&a.date)); + let mut monthly_models: Vec = monthly_map.into_values().collect(); + monthly_models.sort_by(|a, b| { + b.month + .cmp(&a.month) + .then_with(|| b.cost.total_cmp(&a.cost)) + .then_with(|| a.model.cmp(&b.model)) + }); + let total_months = month_set.len() as u32; + let total_tokens: u64 = models.iter().map(|m| m.tokens.total()).sum(); let total_cost: f64 = models .iter() @@ -401,6 +483,8 @@ impl DataLoader { models, agents, daily, + monthly_models, + total_months, graph: Some(graph), total_tokens, total_cost, diff --git a/crates/tokscale-cli/src/tui/ui/footer.rs b/crates/tokscale-cli/src/tui/ui/footer.rs index 8185cd14..f465058a 100644 --- a/crates/tokscale-cli/src/tui/ui/footer.rs +++ b/crates/tokscale-cli/src/tui/ui/footer.rs @@ -138,6 +138,7 @@ fn render_main_row(frame: &mut Frame, app: &mut App, area: Rect) { if !is_very_narrow { let count_label = match app.current_tab { Tab::Agents => format!(" ({} agents)", app.data.agents.len()), + Tab::Monthly => format!(" ({} entries)", app.data.monthly_models.len()), _ => format!(" ({} models)", app.data.models.len()), }; right_spans.push(Span::styled( diff --git a/crates/tokscale-cli/src/tui/ui/mod.rs b/crates/tokscale-cli/src/tui/ui/mod.rs index 9137a256..331618a6 100644 --- a/crates/tokscale-cli/src/tui/ui/mod.rs +++ b/crates/tokscale-cli/src/tui/ui/mod.rs @@ -5,6 +5,7 @@ pub mod dialog; mod footer; mod header; mod models; +mod monthly; mod overview; pub mod spinner; mod stats; @@ -45,6 +46,7 @@ pub fn render(frame: &mut Frame, app: &mut App) { Tab::Models => models::render(frame, app, chunks[1]), Tab::Agents => agents::render(frame, app, chunks[1]), Tab::Daily => daily::render(frame, app, chunks[1]), + Tab::Monthly => monthly::render(frame, app, chunks[1]), Tab::Stats => stats::render(frame, app, chunks[1]), } } diff --git a/crates/tokscale-cli/src/tui/ui/monthly.rs b/crates/tokscale-cli/src/tui/ui/monthly.rs new file mode 100644 index 00000000..48f42dfc --- /dev/null +++ b/crates/tokscale-cli/src/tui/ui/monthly.rs @@ -0,0 +1,282 @@ +use chrono::Local; +use ratatui::prelude::*; +use ratatui::widgets::{ + Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, +}; + +use super::widgets::{ + format_cost, format_tokens, get_client_display_name, get_model_color, get_provider_display_name, +}; +use crate::tui::app::{App, SortDirection, SortField}; + +pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(app.theme.border)) + .title(Span::styled( + " Monthly Usage ", + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD), + )) + .style(Style::default().bg(app.theme.background)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let average_visible = app.data.total_months > 0; + let areas = if average_visible { + Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(inner) + } else { + Layout::vertical([Constraint::Min(0)]).split(inner) + }; + + let table_area = areas[0]; + let visible_height = table_area.height.saturating_sub(1) as usize; + app.max_visible_items = visible_height; + + let monthly = app.get_sorted_monthly(); + if monthly.is_empty() { + let empty_msg = Paragraph::new("No monthly usage data found. Press 'r' to refresh.") + .style(Style::default().fg(app.theme.muted)) + .alignment(Alignment::Center); + frame.render_widget(empty_msg, inner); + return; + } + + let is_narrow = app.is_narrow(); + let is_very_narrow = app.is_very_narrow(); + let sort_field = app.sort_field; + let sort_direction = app.sort_direction; + let scroll_offset = app.scroll_offset; + let selected_index = app.selected_index; + let theme_accent = app.theme.accent; + let theme_muted = app.theme.muted; + let theme_selection = app.theme.selection; + let current_month = Local::now().format("%Y-%m").to_string(); + + let header_cells = if is_very_narrow { + vec!["Month", "Model", "Cost"] + } else if is_narrow { + vec!["Month", "Model", "Tokens", "Cost"] + } else { + vec![ + "Month", "Model", "Provider", "Client", "Input", "Output", "Cache", "Total", "Cost", + ] + }; + + let sort_indicator = |field: SortField| -> &'static str { + if sort_field == field { + match sort_direction { + SortDirection::Ascending => " ▲", + SortDirection::Descending => " ▼", + } + } else { + "" + } + }; + + let header = Row::new( + header_cells + .iter() + .enumerate() + .map(|(i, h)| { + let indicator = match (i, is_narrow, is_very_narrow) { + (0, _, _) => sort_indicator(SortField::Date), + (2, _, true) => sort_indicator(SortField::Cost), + (2, true, false) => sort_indicator(SortField::Tokens), + (3, true, false) => sort_indicator(SortField::Cost), + (7, false, false) => sort_indicator(SortField::Tokens), + (8, false, false) => sort_indicator(SortField::Cost), + _ => "", + }; + Cell::from(format!("{}{}", h, indicator)) + }) + .collect::>(), + ) + .style( + Style::default() + .fg(theme_accent) + .add_modifier(Modifier::BOLD), + ) + .height(1); + + let monthly_len = monthly.len(); + let start = scroll_offset.min(monthly_len.saturating_sub(1)); + let end = (start + visible_height).min(monthly_len); + + if start >= monthly_len { + return; + } + + let rows: Vec = monthly[start..end] + .iter() + .enumerate() + .map(|(i, entry)| { + let idx = i + start; + let is_selected = idx == selected_index; + let is_striped = idx % 2 == 1; + let is_current_month = entry.month == current_month; + let month_style = if is_current_month { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().add_modifier(Modifier::BOLD) + }; + let model_style = Style::default() + .fg(get_model_color(&entry.model)) + .add_modifier(Modifier::BOLD); + + let cells: Vec = if is_very_narrow { + vec![ + Cell::from(entry.month.clone()).style(month_style), + Cell::from(truncate(&entry.model, 18)).style(model_style), + Cell::from(format_cost(entry.cost)).style(Style::default().fg(Color::Green)), + ] + } else if is_narrow { + vec![ + Cell::from(entry.month.clone()).style(month_style), + Cell::from(truncate(&entry.model, 20)).style(model_style), + Cell::from(format_tokens(entry.tokens.total())), + Cell::from(format_cost(entry.cost)).style(Style::default().fg(Color::Green)), + ] + } else { + vec![ + Cell::from(entry.month.clone()).style(month_style), + Cell::from(truncate(&entry.model, 20)).style(model_style), + Cell::from(get_provider_display_name(&entry.provider)), + Cell::from(get_client_display_name(&entry.client)) + .style(Style::default().fg(theme_muted)), + Cell::from(format_tokens(entry.tokens.input)) + .style(Style::default().fg(Color::Rgb(100, 200, 100))), + Cell::from(format_tokens(entry.tokens.output)) + .style(Style::default().fg(Color::Rgb(200, 100, 100))), + Cell::from(format_tokens( + entry.tokens.cache_read + entry.tokens.cache_write, + )) + .style(Style::default().fg(Color::Rgb(100, 150, 200))), + Cell::from(format_tokens(entry.tokens.total())), + Cell::from(format_cost(entry.cost)).style(Style::default().fg(Color::Green)), + ] + }; + + let row_style = if is_selected { + Style::default().bg(theme_selection) + } else if is_current_month { + Style::default().bg(Color::Rgb(28, 42, 34)) + } else if is_striped { + Style::default().bg(Color::Rgb(20, 24, 30)) + } else { + Style::default() + }; + + Row::new(cells).style(row_style).height(1) + }) + .collect(); + + let widths = if is_very_narrow { + vec![ + Constraint::Percentage(30), + Constraint::Percentage(40), + Constraint::Percentage(30), + ] + } else if is_narrow { + vec![ + Constraint::Percentage(20), + Constraint::Percentage(35), + Constraint::Percentage(20), + Constraint::Percentage(25), + ] + } else { + vec![ + Constraint::Length(8), + Constraint::Length(20), + Constraint::Length(12), + Constraint::Length(10), + Constraint::Length(9), + Constraint::Length(9), + Constraint::Length(10), + Constraint::Length(9), + Constraint::Length(9), + ] + }; + + let table = Table::new(rows, widths.clone()) + .header(header) + .row_highlight_style(Style::default().bg(theme_selection)); + + frame.render_widget(table, table_area); + + if average_visible { + let average_tokens = app.data.total_tokens / app.data.total_months as u64; + let average_cost = app.data.total_cost / app.data.total_months as f64; + let average_style = Style::default() + .fg(Color::Cyan) + .bg(Color::Rgb(25, 35, 45)) + .add_modifier(Modifier::BOLD); + + let average_cells = if is_very_narrow { + vec![ + Cell::from("AVG/MO"), + Cell::from(""), + Cell::from(format_cost(average_cost)), + ] + } else if is_narrow { + vec![ + Cell::from("AVG/MO"), + Cell::from(""), + Cell::from(format_tokens(average_tokens)), + Cell::from(format_cost(average_cost)), + ] + } else { + vec![ + Cell::from("AVG/MO"), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(format_tokens(average_tokens)), + Cell::from(format_cost(average_cost)), + ] + }; + + let average_table = Table::new(vec![Row::new(average_cells).style(average_style)], widths); + frame.render_widget(average_table, areas[1]); + } + + if monthly_len > visible_height { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("▲")) + .end_symbol(Some("▼")); + + let mut scrollbar_state = ScrollbarState::new(monthly_len).position(scroll_offset); + + frame.render_stateful_widget( + scrollbar, + area.inner(Margin { + horizontal: 0, + vertical: 1, + }), + &mut scrollbar_state, + ); + } +} + +fn truncate(s: &str, max_chars: usize) -> String { + if max_chars == 0 { + return String::new(); + } + + let char_count = s.chars().count(); + if char_count <= max_chars { + s.to_string() + } else if max_chars <= 3 { + s.chars().take(max_chars).collect() + } else { + let head: String = s.chars().take(max_chars - 3).collect(); + format!("{}...", head) + } +}