diff --git a/src/tui.rs b/src/tui.rs index 7b48075..8657d15 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -64,6 +64,28 @@ enum AggregateViewMode { Yearly, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ModelUsageShareMetric { + Tokens, + Cost, +} + +impl ModelUsageShareMetric { + fn toggle(self) -> Self { + match self { + Self::Tokens => Self::Cost, + Self::Cost => Self::Tokens, + } + } + + fn label(self) -> &'static str { + match self { + Self::Tokens => "Tokens", + Self::Cost => "Cost", + } + } +} + impl AggregateViewMode { /// Parse the configured startup view (tui.default_view). fn from_config(s: &str) -> Self { @@ -920,6 +942,7 @@ struct UiState<'a> { date_jump_buffer: &'a str, model_filter_active: bool, model_filter: &'a str, + model_usage_share_metric: ModelUsageShareMetric, project_name: Option<&'a str>, sort_reversed: bool, hide_empty_periods: bool, @@ -1094,6 +1117,7 @@ async fn run_app( let mut model_filter_active = false; let mut model_filter = String::new(); let mut model_filter_before_edit = String::new(); + let mut model_usage_share_metric = ModelUsageShareMetric::Tokens; let mut project_browser_active = false; let mut project_filter_active = false; let mut project_filter = String::new(); @@ -1275,6 +1299,7 @@ async fn run_app( date_jump_buffer: &date_jump_buffer, model_filter_active, model_filter: &model_filter, + model_usage_share_metric, project_name: selected_project_name, sort_reversed, hide_empty_periods, @@ -1833,6 +1858,10 @@ async fn run_app( model_filter_active = true; needs_redraw = true; } + KeyCode::Char('c') if matches!(*stats_view_mode, StatsViewMode::Aggregate) => { + model_usage_share_metric = model_usage_share_metric.toggle(); + needs_redraw = true; + } KeyCode::Char('m') => { *aggregate_view_mode = aggregate_view_mode.next(); @@ -2105,6 +2134,7 @@ fn draw_ui( ui_state.accent, ui_state.hidden_cols, ui_state.color_costs, + ui_state.model_usage_share_metric, ); has_estimated } @@ -2169,7 +2199,8 @@ fn draw_ui( }; format!( - "Use ←/→ or h/l to switch tabs • ↑/↓ or j/k to navigate • p for projects • f to filter models • r to reverse sort • e to toggle empty periods • s to toggle summary • / for {jump_label} • m to cycle hour/day/week/month/year • Enter to drill into period • Ctrl+T for all sessions • q to quit" + "Use ←/→ or h/l to switch tabs • ↑/↓ or j/k to navigate • p for projects • f to filter models • c for model share ({}) • r to reverse sort • e to toggle empty periods • s to toggle summary • / for {jump_label} • m to cycle hour/day/week/month/year • Enter to drill into period • Ctrl+T for all sessions • q to quit", + ui_state.model_usage_share_metric.label() ) } StatsViewMode::Session => { @@ -2428,6 +2459,7 @@ fn cost_heat(cents: u32, max: u32) -> Color { fn format_model_usage_shares( models: &BTreeMap, model_stats: &BTreeMap, + metric: ModelUsageShareMetric, ) -> String { let mut usage: BTreeMap<&str, u64> = models .keys() @@ -2435,26 +2467,36 @@ fn format_model_usage_shares( .chain(model_stats.keys().map(|model| (model.as_str(), 0))) .collect(); - for (model, stats) in model_stats { - usage.insert( - model, - stats - .input_tokens - .saturating_add(stats.output_tokens) - .saturating_add(stats.cached_tokens), - ); - } + match metric { + ModelUsageShareMetric::Tokens => { + for (model, stats) in model_stats { + usage.insert( + model, + stats + .input_tokens + .saturating_add(stats.output_tokens) + .saturating_add(stats.cached_tokens), + ); + } - let total_tokens = usage.values().copied().sum::(); - let has_complete_token_stats = models.keys().all(|model| model_stats.contains_key(model)); - if total_tokens == 0 || !has_complete_token_stats { - for (model, model_usage) in &mut usage { - *model_usage = models - .get(*model) - .copied() - .or_else(|| model_stats.get(*model).map(|stats| stats.message_count)) - .map(u64::from) - .unwrap_or(0); + let total_tokens = usage.values().copied().sum::(); + let has_complete_token_stats = + models.keys().all(|model| model_stats.contains_key(model)); + if total_tokens == 0 || !has_complete_token_stats { + for (model, model_usage) in &mut usage { + *model_usage = models + .get(*model) + .copied() + .or_else(|| model_stats.get(*model).map(|stats| stats.message_count)) + .map(u64::from) + .unwrap_or(0); + } + } + } + ModelUsageShareMetric::Cost => { + for (model, stats) in model_stats { + usage.insert(model, TuiStats::cost_micros_from_dollars(stats.cost)); + } } } let total_usage = usage.values().copied().sum::(); @@ -2574,6 +2616,7 @@ fn draw_aggregate_stats_table( accent: Color, hidden: &std::collections::HashSet, color_costs: bool, + model_usage_share_metric: ModelUsageShareMetric, ) -> (usize, bool) { let period_header = match aggregate_view_mode { AggregateViewMode::Hourly => "Hour", @@ -2604,6 +2647,7 @@ fn draw_aggregate_stats_table( } !hidden.contains(c) }; + let model_usage_header = format!("Models (% by {})", model_usage_share_metric.label()); // Find best values for highlighting // TODO: Let's refactor this. @@ -2665,7 +2709,7 @@ fn draw_aggregate_stats_table( // wrap to the final width instead of being clipped after the table is laid // out. let mut max_apps_width = APPS_COL_MIN_WIDTH; - let mut max_models_width = MODELS_COL_MIN_WIDTH; + let mut max_models_width = MODELS_COL_MIN_WIDTH.max(terminal_text_width(&model_usage_header)); let mut width_total_models = BTreeMap::new(); let mut width_total_model_stats = BTreeMap::new(); let mut width_all_apps = std::collections::BTreeSet::new(); @@ -2676,7 +2720,11 @@ fn draw_aggregate_stats_table( let period_stats = aggregate_stats .get(period) .expect("visible period key must exist in aggregate stats"); - let models = format_model_usage_shares(&period_stats.models, &period_stats.model_stats); + let models = format_model_usage_shares( + &period_stats.models, + &period_stats.model_stats, + model_usage_share_metric, + ); max_models_width = max_models_width.max(terminal_text_width(&models)); for (model, count) in &period_stats.models { *width_total_models.entry(model.clone()).or_insert(0) += count; @@ -2695,8 +2743,11 @@ fn draw_aggregate_stats_table( } let width_all_apps_text = width_all_apps.into_iter().collect::>().join(", "); - let width_all_models_text = - format_model_usage_shares(&width_total_models, &width_total_model_stats); + let width_all_models_text = format_model_usage_shares( + &width_total_models, + &width_total_model_stats, + model_usage_share_metric, + ); let mut apps_column_width = max_apps_width .max(terminal_text_width(&width_all_apps_text)) .clamp(APPS_COL_MIN_WIDTH, APPS_COL_MAX_WIDTH); @@ -2779,7 +2830,11 @@ fn draw_aggregate_stats_table( .or_insert_with(|| ModelStats::new(model.clone())) .add_model_stats(stats); } - let models = format_model_usage_shares(&period_stats.models, &period_stats.model_stats); + let models = format_model_usage_shares( + &period_stats.models, + &period_stats.model_stats, + model_usage_share_metric, + ); let mut apps_vec: Vec = period_stats.apps.keys().cloned().collect(); apps_vec.sort(); @@ -2990,7 +3045,8 @@ fn draw_aggregate_stats_table( .chain(total_model_stats.keys()) .any(|model| is_model_estimated(model)); let all_apps_text = all_apps.into_iter().collect::>().join(", "); - let all_models_text = format_model_usage_shares(&total_models, &total_model_stats); + let all_models_text = + format_model_usage_shares(&total_models, &total_model_stats, model_usage_share_metric); let mut header_cells = vec![ Cell::new(""), @@ -3020,13 +3076,13 @@ fn draw_aggregate_stats_table( } if show("models") { header_cells.push(Cell::new(wrap_model_usage_text( - "Models", + &model_usage_header, models_column_width, Style::default().add_modifier(Modifier::BOLD), ))); } let header_height = if show("models") { - wrap_model_usage_text("Models", models_column_width, Style::default()).height() + wrap_model_usage_text(&model_usage_header, models_column_width, Style::default()).height() } else { 1 }; diff --git a/src/tui/tests.rs b/src/tui/tests.rs index db0ce2b..9491209 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -5,12 +5,13 @@ use crate::tui::logic::{ filtered_aggregate_keys, }; use crate::tui::{ - AggregateViewMode, PeriodFilter, build_display_stats, collect_project_summaries, cost_heat, - create_upload_progress_callback, draw_aggregate_stats_table, filter_analyzer_view_by_model, - filter_analyzer_view_by_project, filtered_session_count, format_model_usage_shares, - format_month_for_display, format_week_for_display, format_year_for_display, parse_accent, - sessions_for_period, show_upload_error, show_upload_success, update_period_filters, - update_table_states, update_window_offsets, wrap_model_usage_text, + AggregateViewMode, ModelUsageShareMetric, PeriodFilter, build_display_stats, + collect_project_summaries, cost_heat, create_upload_progress_callback, + draw_aggregate_stats_table, filter_analyzer_view_by_model, filter_analyzer_view_by_project, + filtered_session_count, format_model_usage_shares, format_month_for_display, + format_week_for_display, format_year_for_display, parse_accent, sessions_for_period, + show_upload_error, show_upload_success, update_period_filters, update_table_states, + update_window_offsets, wrap_model_usage_text, }; use crate::types::{ AgenticCodingToolStats, AnalyzerStatsView, Application, CompactDate, ConversationMessage, @@ -320,6 +321,7 @@ fn aggregate_table_preserves_leading_digit_in_large_tool_total() { Color::Cyan, &HashSet::new(), false, + ModelUsageShareMetric::Tokens, ); }) .unwrap(); @@ -388,6 +390,7 @@ fn aggregate_table_highlights_best_value_when_sort_is_reversed() { Color::Cyan, &HashSet::new(), false, + ModelUsageShareMetric::Tokens, ); }) .unwrap(); @@ -1171,6 +1174,7 @@ fn aggregate_table_wraps_model_column_on_narrow_terminal() { Color::Cyan, &HashSet::new(), false, + ModelUsageShareMetric::Tokens, ); }) .unwrap(); @@ -1346,17 +1350,27 @@ fn model_filter_recalculates_stats_and_sessions() { assert_eq!( format_model_usage_shares( &view.daily_stats["2025-01-01"].models, - &view.daily_stats["2025-01-01"].model_stats + &view.daily_stats["2025-01-01"].model_stats, + ModelUsageShareMetric::Tokens, ), "gpt-5 85.2%, claude-sonnet-4 14.8%" ); assert_eq!( format_model_usage_shares( &BTreeMap::from([("model-a".to_string(), 2), ("model-b".to_string(), 1)]), - &BTreeMap::new() + &BTreeMap::new(), + ModelUsageShareMetric::Tokens, ), "model-a 66.7%, model-b 33.3%" ); + assert_eq!( + format_model_usage_shares( + &view.daily_stats["2025-01-01"].models, + &view.daily_stats["2025-01-01"].model_stats, + ModelUsageShareMetric::Cost, + ), + "gpt-5 76.2%, claude-sonnet-4 23.8%" + ); let format_options = crate::utils::NumberFormatOptions { use_comma: false, @@ -1384,6 +1398,7 @@ fn model_filter_recalculates_stats_and_sessions() { Color::Cyan, &HashSet::new(), false, + ModelUsageShareMetric::Tokens, ); }) .unwrap(); @@ -1394,10 +1409,39 @@ fn model_filter_recalculates_stats_and_sessions() { .iter() .map(|cell| cell.symbol()) .collect::(); + assert!(rendered.contains("Models (% by Tokens)")); assert!(rendered.contains("claude-sonnet-4 100.0%")); assert!(!rendered.contains("gpt-5")); assert!(rendered.contains("$1.25")); + terminal + .draw(|frame| { + draw_aggregate_stats_table( + frame, + Rect::new(0, 0, 160, 8), + &filtered, + &format_options, + &mut table_state, + AggregateViewMode::Daily, + "", + false, + false, + Color::Cyan, + &HashSet::new(), + false, + ModelUsageShareMetric::Cost, + ); + }) + .unwrap(); + let rendered = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(rendered.contains("Models (% by Cost)")); + let unmatched = filter_analyzer_view_by_model(&view, "gemini"); assert!(unmatched.daily_stats.is_empty()); assert!(unmatched.session_aggregates.is_empty());