From 61158910112fc746173249bd76b76da1291bcf17 Mon Sep 17 00:00:00 2001 From: BubbleBuffer Date: Sun, 31 May 2026 16:30:32 +0200 Subject: [PATCH 1/4] Add Agents page with per-agent token tracking Adds a new Agents tab showing token usage broken down by agent (e.g. superpawers-implementer, build, explore). Includes per-agent charts, detail panels with cost/tokens/sessions/rate, and per-model sub-breakdowns within each agent. Fuzzy search and keyboard navigation supported. --- src/analytics/agent_stats.rs | 311 +++++++++++++++++++++++++++++++++++ src/analytics/mod.rs | 8 + src/analytics/model_stats.rs | 22 +-- src/ui/agents.rs | 238 +++++++++++++++++++++++++++ src/ui/app.rs | 58 ++++++- src/ui/mod.rs | 1 + src/ui/models.rs | 6 +- 7 files changed, 627 insertions(+), 17 deletions(-) create mode 100644 src/analytics/agent_stats.rs create mode 100644 src/ui/agents.rs diff --git a/src/analytics/agent_stats.rs b/src/analytics/agent_stats.rs new file mode 100644 index 0000000..369b521 --- /dev/null +++ b/src/analytics/agent_stats.rs @@ -0,0 +1,311 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::NaiveDate; + +use crate::cache::models_cache::PricingCatalog; +use crate::db::models::{TokenUsage, UsageEvent}; +use crate::utils::formatting::percentage; +use crate::utils::pricing::{PriceSummary, ZeroCostBehavior, update_price_summary}; +use crate::utils::time::TimeRange; + +use super::model_stats::UsageAccumulator; + +#[derive(Clone, Debug)] +pub struct AgentModelBreakdown { + pub model_id: String, + pub tokens: u64, + pub cost: PriceSummary, + pub sessions: usize, +} + +#[derive(Clone, Debug)] +pub struct AgentUsageRow { + pub agent_id: String, + pub total_tokens: u64, + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_tokens: u64, + pub sessions: usize, + pub active_days: usize, + pub cost: PriceSummary, + pub percentage: f64, + pub p50_output_tokens_per_second: f64, + pub model_breakdown: Vec, +} + +use super::model_stats::{ModelChartData, build_chart_for_models, median}; + +pub fn build_agent_chart( + events: &[&UsageEvent], + pricing: &PricingCatalog, + range: TimeRange, + today: NaiveDate, + zero_cost_behavior: ZeroCostBehavior, +) -> (Vec, ModelChartData) { + let mut agent_rows = BTreeMap::::new(); + let mut agent_model_tokens = BTreeMap::>::new(); + let mut agent_model_cost = BTreeMap::>::new(); + let mut agent_model_sessions = BTreeMap::>>::new(); + + for event in events { + let agent = event + .agent + .clone() + .filter(|a| !a.is_empty()) + .unwrap_or_else(|| "unknown".to_string()); + let model = event.model_id.clone(); + + let entry = agent_rows.entry(agent.clone()).or_default(); + entry.tokens.add_assign(&event.tokens); + entry.sessions.insert(event.session_id.clone()); + update_price_summary(&mut entry.cost, pricing, event, zero_cost_behavior); + if let Some(date) = event.activity_date() { + entry.active_days.insert(date); + let total = entry.daily_tokens.entry(date).or_default(); + *total = total.saturating_add(event.tokens.total()); + } + if event.is_rate_eligible() + && let Some(duration_ms) = event.duration_ms() + { + let rate = event.tokens.output as f64 / (duration_ms as f64 / 1_000.0); + entry.output_rates.push(rate); + } + + let model_tokens = agent_model_tokens + .entry(agent.clone()) + .or_default() + .entry(model.clone()) + .or_default(); + model_tokens.add_assign(&event.tokens); + + let model_cost = agent_model_cost + .entry(agent.clone()) + .or_default() + .entry(model.clone()) + .or_default(); + update_price_summary(model_cost, pricing, event, zero_cost_behavior); + + agent_model_sessions + .entry(agent.clone()) + .or_default() + .entry(model.clone()) + .or_default() + .insert(event.session_id.clone()); + } + + let overall_tokens = agent_rows + .values() + .map(|row| row.tokens.total()) + .fold(0u64, |total, value| total.saturating_add(value)); + let mut rows = agent_rows + .into_iter() + .map(|(agent_id, row)| { + let model_breakdown = agent_model_tokens + .get(&agent_id) + .map(|models| { + let mut breakdown: Vec = models + .iter() + .map(|(model_id, tokens)| AgentModelBreakdown { + model_id: model_id.clone(), + tokens: tokens.total(), + cost: agent_model_cost + .get(&agent_id) + .and_then(|costs| costs.get(model_id).cloned()) + .unwrap_or_default(), + sessions: agent_model_sessions + .get(&agent_id) + .and_then(|sessions| sessions.get(model_id)) + .map(|s| s.len()) + .unwrap_or(0), + }) + .collect(); + breakdown.sort_by_key(|b| std::cmp::Reverse(b.tokens)); + breakdown + }) + .unwrap_or_default(); + + AgentUsageRow { + agent_id, + total_tokens: row.tokens.total(), + input_tokens: row.tokens.input, + output_tokens: row.tokens.output, + cache_tokens: row.tokens.cache_read.saturating_add(row.tokens.cache_write), + percentage: percentage(row.tokens.total(), overall_tokens), + sessions: row.sessions.len(), + active_days: row.active_days.len(), + cost: row.cost, + p50_output_tokens_per_second: median(&row.output_rates), + model_breakdown, + } + }) + .collect::>(); + + rows.sort_by_key(|right| std::cmp::Reverse(right.total_tokens)); + + let top_agents = rows + .iter() + .map(|row| row.agent_id.clone()) + .collect::>(); + let chart = build_chart_for_models(events, &top_agents, range, today, |event| { + event + .agent + .clone() + .filter(|a| !a.is_empty()) + .unwrap_or_else(|| "unknown".to_string()) + }); + (rows, chart) +} + +#[cfg(test)] +mod tests { + use super::build_agent_chart; + use crate::db::models::{DataSourceKind, TokenUsage, UsageEvent}; + use crate::utils::time::TimeRange; + use chrono::{Local, TimeZone}; + + #[test] + fn agents_group_events_by_agent_field() { + let created_at = Local + .with_ymd_and_hms(2026, 3, 12, 9, 30, 0) + .single() + .unwrap(); + let day = created_at.date_naive(); + let events = vec![ + UsageEvent { + session_id: "ses_1".to_string(), + parent_session_id: None, + session_title: None, + session_started_at: Some(created_at), + session_archived_at: None, + project_name: None, + project_path: None, + provider_id: Some("openai".to_string()), + model_id: "gpt-5".to_string(), + agent: Some("build".to_string()), + finish_reason: Some("stop".to_string()), + tokens: TokenUsage { + input: 100, + output: 200, + cache_read: 0, + cache_write: 0, + }, + created_at: Some(created_at), + completed_at: Some(created_at), + stored_cost_usd: None, + source: DataSourceKind::Json, + }, + UsageEvent { + session_id: "ses_2".to_string(), + parent_session_id: Some("ses_1".to_string()), + session_title: None, + session_started_at: Some(created_at), + session_archived_at: None, + project_name: None, + project_path: None, + provider_id: Some("anthropic".to_string()), + model_id: "claude-sonnet".to_string(), + agent: Some("explore".to_string()), + finish_reason: Some("stop".to_string()), + tokens: TokenUsage { + input: 50, + output: 100, + cache_read: 0, + cache_write: 0, + }, + created_at: Some(created_at), + completed_at: Some(created_at), + stored_cost_usd: None, + source: DataSourceKind::Json, + }, + UsageEvent { + session_id: "ses_3".to_string(), + parent_session_id: None, + session_title: None, + session_started_at: Some(created_at), + session_archived_at: None, + project_name: None, + project_path: None, + provider_id: Some("openai".to_string()), + model_id: "gpt-5.5".to_string(), + agent: Some("build".to_string()), + finish_reason: Some("stop".to_string()), + tokens: TokenUsage { + input: 300, + output: 400, + cache_read: 0, + cache_write: 0, + }, + created_at: Some(created_at), + completed_at: Some(created_at), + stored_cost_usd: None, + source: DataSourceKind::Json, + }, + UsageEvent { + session_id: "ses_1".to_string(), + parent_session_id: None, + session_title: None, + session_started_at: Some(created_at), + session_archived_at: None, + project_name: None, + project_path: None, + provider_id: Some("unknown".to_string()), + model_id: "unknown-model".to_string(), + agent: None, + finish_reason: Some("stop".to_string()), + tokens: TokenUsage { + input: 10, + output: 20, + cache_read: 0, + cache_write: 0, + }, + created_at: Some(created_at), + completed_at: Some(created_at), + stored_cost_usd: None, + source: DataSourceKind::Json, + }, + ]; + + let pricing = crate::cache::models_cache::PricingCatalog { + models: std::collections::BTreeMap::new(), + cache_path: std::path::PathBuf::from("/tmp/models.json"), + refresh_needed: false, + availability: crate::cache::models_cache::PricingAvailability::Empty, + load_notice: None, + }; + let (rows, _chart) = build_agent_chart( + &events.iter().collect::>(), + &pricing, + TimeRange::All, + day, + crate::utils::pricing::ZeroCostBehavior::KeepZero, + ); + + assert_eq!(rows.len(), 3); + + assert_eq!(rows[0].agent_id, "build"); + assert_eq!(rows[0].total_tokens, 1000); + assert_eq!(rows[0].sessions, 2); + assert_eq!(rows[0].model_breakdown.len(), 2); + assert_eq!(rows[0].model_breakdown[0].model_id, "gpt-5.5"); + assert_eq!(rows[0].model_breakdown[0].tokens, 700); + assert_eq!(rows[0].model_breakdown[0].sessions, 1); + assert_eq!(rows[0].model_breakdown[1].model_id, "gpt-5"); + assert_eq!(rows[0].model_breakdown[1].tokens, 300); + assert_eq!(rows[0].model_breakdown[1].sessions, 1); + + assert_eq!(rows[1].agent_id, "explore"); + assert_eq!(rows[1].total_tokens, 150); + assert_eq!(rows[1].sessions, 1); + assert_eq!(rows[1].model_breakdown.len(), 1); + assert_eq!(rows[1].model_breakdown[0].model_id, "claude-sonnet"); + assert_eq!(rows[1].model_breakdown[0].tokens, 150); + assert_eq!(rows[1].model_breakdown[0].sessions, 1); + + assert_eq!(rows[2].agent_id, "unknown"); + assert_eq!(rows[2].total_tokens, 30); + assert_eq!(rows[2].model_breakdown.len(), 1); + assert_eq!(rows[2].model_breakdown[0].model_id, "unknown-model"); + assert_eq!(rows[2].model_breakdown[0].tokens, 30); + assert_eq!(rows[2].model_breakdown[0].sessions, 1); + } +} diff --git a/src/analytics/mod.rs b/src/analytics/mod.rs index a7dcfe6..1cf4f1a 100644 --- a/src/analytics/mod.rs +++ b/src/analytics/mod.rs @@ -1,3 +1,4 @@ +pub mod agent_stats; pub mod daily; pub mod heatmap_data; pub mod model_stats; @@ -8,6 +9,7 @@ use std::collections::BTreeSet; use chrono::NaiveDate; +use crate::analytics::agent_stats::{AgentUsageRow, build_agent_chart}; use crate::analytics::daily::aggregate_daily; use crate::analytics::heatmap_data::{HeatmapData, build_heatmap_data}; use crate::analytics::model_stats::{ @@ -42,6 +44,8 @@ pub struct AnalyticsSnapshot { pub chart: ModelChartData, pub providers: Vec, pub provider_chart: ModelChartData, + pub agents: Vec, + pub agent_chart: ModelChartData, pub heatmap: HeatmapData, } @@ -79,6 +83,8 @@ pub fn build_snapshot( zero_cost_behavior, ); let heatmap = build_heatmap_data(&data.events, today); + let (agents, agent_chart) = + build_agent_chart(&filtered_events, pricing, range, today, zero_cost_behavior); let total_tokens = saturating_sum(filtered_events.iter().map(|event| event.tokens.total())); let input_tokens = saturating_sum(filtered_events.iter().map(|event| event.tokens.input)); @@ -134,6 +140,8 @@ pub fn build_snapshot( chart, providers, provider_chart, + agents, + agent_chart, heatmap, } } diff --git a/src/analytics/model_stats.rs b/src/analytics/model_stats.rs index 212eb93..208aa9c 100644 --- a/src/analytics/model_stats.rs +++ b/src/analytics/model_stats.rs @@ -237,7 +237,7 @@ pub fn chart_with_focus(chart: &ModelChartData, focused_model_id: Option<&str>) } } -fn build_chart_for_models( +pub fn build_chart_for_models( events: &[&UsageEvent], top_models: &[String], range: TimeRange, @@ -424,18 +424,18 @@ fn format_tick_label(value: f64) -> String { } #[derive(Default)] -struct UsageAccumulator { - tokens: TokenUsage, - messages: usize, - prompts: usize, - sessions: BTreeSet, - active_days: BTreeSet, - cost: PriceSummary, - daily_tokens: BTreeMap, - output_rates: Vec, +pub struct UsageAccumulator { + pub tokens: TokenUsage, + pub messages: usize, + pub prompts: usize, + pub sessions: BTreeSet, + pub active_days: BTreeSet, + pub cost: PriceSummary, + pub daily_tokens: BTreeMap, + pub output_rates: Vec, } -fn median(values: &[f64]) -> f64 { +pub fn median(values: &[f64]) -> f64 { if values.is_empty() { return 0.0; } diff --git a/src/ui/agents.rs b/src/ui/agents.rs new file mode 100644 index 0000000..34129f7 --- /dev/null +++ b/src/ui/agents.rs @@ -0,0 +1,238 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; + +use crate::analytics::AnalyticsSnapshot; +use crate::analytics::agent_stats::AgentUsageRow; +use crate::analytics::model_stats::chart_with_focus; +use crate::ui::models::{SearchItem, SearchState, layout_rows}; +use crate::ui::theme::Theme; +use crate::ui::widgets::common::{metric_line, truncate_label}; +use crate::ui::widgets::linechart::build_chart; +use crate::utils::formatting::{format_price_summary, format_tokens}; +use crate::utils::time::TimeRange; + +impl SearchItem for AgentUsageRow { + fn item_id(&self) -> &str { + &self.agent_id + } + fn item_pct(&self) -> f64 { + self.percentage + } +} + +pub fn render_agents( + frame: &mut ratatui::Frame<'_>, + area: Rect, + snapshot: &AnalyticsSnapshot, + _range: TimeRange, + focused_agent_index: usize, + search: Option<&SearchState>, + theme: &Theme, +) { + let [ + chart_area, + spacer1, + header_area, + spacer2, + detail_area, + model_area, + ] = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Fill(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(4), + Constraint::Min(0), + ]) + .areas(area); + + let effective_focus: Option = search + .and_then(|s| s.filtered_indices.get(s.selected).copied()) + .or(Some(focused_agent_index)); + + let focused_row = effective_focus.and_then(|i| snapshot.agents.get(i)); + let chart_data = chart_with_focus( + &snapshot.agent_chart, + focused_row.map(|row| row.agent_id.as_str()), + ); + frame.render_widget(build_chart(&chart_data, theme), chart_area); + + if let Some(search) = search { + frame.render_widget(Paragraph::new(""), spacer1); + super::models::render_search_overlay( + frame, + header_area, + spacer2, + detail_area, + search, + &snapshot.agents, + theme, + ); + } else if let Some(row) = focused_row { + frame.render_widget( + Paragraph::new(focus_agent_line( + row, + focused_agent_index, + &snapshot.agents, + theme, + )), + header_area, + ); + frame.render_widget(Paragraph::new(""), spacer2); + render_agent_detail(frame, detail_area, row, theme); + render_model_breakdown(frame, model_area, row, theme); + } else { + frame.render_widget(Paragraph::new(""), spacer2); + frame.render_widget( + Paragraph::new("No agent activity in this time range.").style(theme.muted_style()), + detail_area, + ); + } +} + +fn focus_agent_line( + row: &AgentUsageRow, + focused_agent_index: usize, + agents: &[AgentUsageRow], + theme: &Theme, +) -> Line<'static> { + let total = agents.len().max(1); + Line::from(vec![ + Span::styled( + format!(" ● {}", truncate_label(&row.agent_id, 26)), + Style::default().fg(theme.series_color(focused_agent_index)), + ), + Span::styled(format!(" ({:.2}%)", row.percentage), theme.muted_style()), + Span::styled(" | ", theme.muted_style()), + Span::styled( + format!("{}/{}", focused_agent_index.min(total - 1) + 1, total), + theme.muted_style(), + ), + Span::styled(" | ", theme.muted_style()), + Span::styled("j/k ↑/↓", theme.muted_style()), + Span::styled(" | ", theme.muted_style()), + Span::styled("f find", theme.muted_style()), + ]) +} + +fn render_agent_detail( + frame: &mut ratatui::Frame<'_>, + area: Rect, + row: &AgentUsageRow, + theme: &Theme, +) { + let rows = layout_rows::<4, 2>(area); + + frame.render_widget( + Paragraph::new(metric_line( + "Total tokens: ", + format_tokens(row.total_tokens), + theme, + )), + rows[0][0], + ); + frame.render_widget( + Paragraph::new(metric_line( + "Total cost: ", + format_price_summary(&row.cost), + theme, + )), + rows[0][1], + ); + frame.render_widget( + Paragraph::new(metric_line( + "Input: ", + format_tokens(row.input_tokens), + theme, + )), + rows[1][0], + ); + frame.render_widget( + Paragraph::new(metric_line("Sessions: ", row.sessions.to_string(), theme)), + rows[1][1], + ); + frame.render_widget( + Paragraph::new(metric_line( + "Output: ", + format_tokens(row.output_tokens), + theme, + )), + rows[2][0], + ); + frame.render_widget( + Paragraph::new(metric_line( + "Active days: ", + row.active_days.to_string(), + theme, + )), + rows[2][1], + ); + frame.render_widget( + Paragraph::new(metric_line( + "Cache: ", + format_tokens(row.cache_tokens), + theme, + )), + rows[3][0], + ); + frame.render_widget( + Paragraph::new(metric_line( + "Rate: ", + format!("{:.2} tok/s", row.p50_output_tokens_per_second), + theme, + )), + rows[3][1], + ); +} + +fn render_model_breakdown( + frame: &mut ratatui::Frame<'_>, + area: Rect, + row: &AgentUsageRow, + theme: &Theme, +) { + let models = &row.model_breakdown; + let available = area.height as usize; + + if models.is_empty() || available == 0 { + return; + } + + let show_count = available.min(models.len()); + + let constraints: Vec = (0..show_count).map(|_| Constraint::Length(1)).collect(); + let lines = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area); + + for (i, line_area) in lines.iter().enumerate() { + if i >= models.len() { + break; + } + let m = &models[i]; + + let label = crate::ui::widgets::common::truncate_label(&m.model_id, 20); + let pct = if row.total_tokens > 0 { + (m.tokens as f64 / row.total_tokens as f64) * 100.0 + } else { + 0.0 + }; + let tokens = format_tokens(m.tokens); + let cost = format_price_summary(&m.cost); + + let model_line = Line::from(vec![ + Span::styled(" · ", theme.muted_style()), + Span::styled(label, Style::default().fg(theme.foreground)), + Span::styled(format!(": {tokens} ({pct:.1}%)"), theme.muted_style()), + Span::styled(format!(" | sessions: {}", m.sessions), theme.muted_style()), + Span::styled(format!(" | {cost}"), theme.muted_style()), + ]); + + frame.render_widget(Paragraph::new(model_line), *line_area); + } +} diff --git a/src/ui/app.rs b/src/ui/app.rs index 92b2238..123ac24 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -15,6 +15,7 @@ use tokio::sync::mpsc; use crate::analytics::{AnalyticsSnapshot, build_snapshot}; use crate::cache::models_cache::{PricingCatalog, refresh_remote_models}; use crate::db::models::AppData; +use crate::ui::agents::render_agents; use crate::ui::export::render_share_card; use crate::ui::models::MAX_QUERY_LEN; use crate::ui::models::{SearchState, render_models, render_providers}; @@ -53,6 +54,7 @@ pub enum Page { Overview, Models, Providers, + Agents, } impl Page { @@ -60,15 +62,17 @@ impl Page { match self { Self::Overview => Self::Models, Self::Models => Self::Providers, - Self::Providers => Self::Overview, + Self::Providers => Self::Agents, + Self::Agents => Self::Overview, } } pub fn previous(self) -> Self { match self { - Self::Overview => Self::Providers, + Self::Overview => Self::Agents, Self::Models => Self::Overview, Self::Providers => Self::Models, + Self::Agents => Self::Providers, } } } @@ -85,6 +89,7 @@ pub struct App { status_message: Option, pub focused_model_index: usize, pub focused_provider_index: usize, + pub focused_agent_index: usize, pub search: Option, pricing_updates: mpsc::UnboundedReceiver>, clipboard_sender: mpsc::UnboundedSender, @@ -119,6 +124,7 @@ impl App { status_message: None, focused_model_index: 0, focused_provider_index: 0, + focused_agent_index: 0, search: None, pricing_updates: receiver, clipboard_sender, @@ -264,6 +270,15 @@ impl App { self.search.as_ref(), theme, ), + Page::Agents => render_agents( + frame, + body, + &self.snapshot, + self.range, + self.focused_agent_index, + self.search.as_ref(), + theme, + ), } self.render_footer(frame, footer, theme); @@ -275,6 +290,7 @@ impl App { segment_span("Overview", self.page == Page::Overview, theme), segment_span("Models", self.page == Page::Models, theme), segment_span("Providers", self.page == Page::Providers, theme), + segment_span("Agents", self.page == Page::Agents, theme), ratatui::text::Span::raw(" "), segment_span(" All ", self.range == TimeRange::All, theme), segment_span("7 Days", self.range == TimeRange::Last7Days, theme), @@ -307,6 +323,7 @@ impl App { match self.page { Page::Models => self.focused_model_index = real_idx, Page::Providers => self.focused_provider_index = real_idx, + Page::Agents => self.focused_agent_index = real_idx, _ => {} } } @@ -376,6 +393,7 @@ impl App { self.range = self.range.cycle(); self.focused_model_index = 0; self.focused_provider_index = 0; + self.focused_agent_index = 0; self.search = None; self.recompute(); } @@ -385,7 +403,9 @@ impl App { KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.copy_current_page(); } - KeyCode::Char('f') if matches!(self.page, Page::Models | Page::Providers) => { + KeyCode::Char('f') + if matches!(self.page, Page::Models | Page::Providers | Page::Agents) => + { self.enter_search(); } KeyCode::Char(value) => { @@ -393,6 +413,7 @@ impl App { self.range = range; self.focused_model_index = 0; self.focused_provider_index = 0; + self.focused_agent_index = 0; self.search = None; self.recompute(); } @@ -423,6 +444,16 @@ impl App { let next = (current + delta).rem_euclid(total) as usize; self.focused_provider_index = next; } + Page::Agents => { + if self.snapshot.agents.is_empty() { + return; + } + + let current = self.focused_agent_index as isize; + let total = self.snapshot.agents.len() as isize; + let next = (current + delta).rem_euclid(total) as usize; + self.focused_agent_index = next; + } Page::Overview => {} } } @@ -445,6 +476,14 @@ impl App { .collect::>(), self.focused_provider_index, ), + Page::Agents => ( + self.snapshot + .agents + .iter() + .map(|a| a.agent_id.clone()) + .collect::>(), + self.focused_agent_index, + ), _ => return, }; self.search = Some(SearchState::new(ids, focused)); @@ -553,6 +592,19 @@ impl App { }) .collect::>() .join("\n"), + Page::Agents => { + let mut lines = Vec::new(); + for row in self.snapshot.agents.iter().take(8) { + lines.push(format!( + "{}: {} tokens ({:.2}%)", + row.agent_id, row.total_tokens, row.percentage + )); + for m in &row.model_breakdown { + lines.push(format!(" {}: {} tokens", m.model_id, m.tokens)); + } + } + lines.join("\n") + } } } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index fad54b7..bcd7a9c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,4 @@ +pub mod agents; pub mod app; pub mod export; pub mod models; diff --git a/src/ui/models.rs b/src/ui/models.rs index 606ea2a..f0a0341 100644 --- a/src/ui/models.rs +++ b/src/ui/models.rs @@ -492,7 +492,7 @@ fn render_provider_detail( ); } -trait SearchItem { +pub trait SearchItem { fn item_id(&self) -> &str; fn item_pct(&self) -> f64; } @@ -515,7 +515,7 @@ impl SearchItem for ProviderUsageRow { } } -fn render_search_overlay( +pub fn render_search_overlay( frame: &mut ratatui::Frame<'_>, header_area: Rect, spacer_area: Rect, @@ -646,7 +646,7 @@ fn render_search_overlay( } } -fn layout_rows(area: Rect) -> [[Rect; COL]; ROW] { +pub fn layout_rows(area: Rect) -> [[Rect; COL]; ROW] { Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(1); ROW]) From 735ff494ca447558fd81d98a133a484ae6d8b12e Mon Sep 17 00:00:00 2001 From: BubbleBuffer Date: Wed, 3 Jun 2026 21:54:59 +0200 Subject: [PATCH 2/4] fix: refine Agents page with per-model chart and aligned detail layout --- src/analytics/agent_stats.rs | 96 ++++++++++++- src/analytics/mod.rs | 4 +- src/ui/agents.rs | 267 ++++++++++++++++++++++++++--------- src/ui/app.rs | 79 ++++++++--- 4 files changed, 356 insertions(+), 90 deletions(-) diff --git a/src/analytics/agent_stats.rs b/src/analytics/agent_stats.rs index 369b521..1e73382 100644 --- a/src/analytics/agent_stats.rs +++ b/src/analytics/agent_stats.rs @@ -14,8 +14,13 @@ use super::model_stats::UsageAccumulator; pub struct AgentModelBreakdown { pub model_id: String, pub tokens: u64, + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_tokens: u64, pub cost: PriceSummary, pub sessions: usize, + pub active_days: usize, + pub p50_output_tokens_per_second: f64, } #[derive(Clone, Debug)] @@ -41,11 +46,13 @@ pub fn build_agent_chart( range: TimeRange, today: NaiveDate, zero_cost_behavior: ZeroCostBehavior, -) -> (Vec, ModelChartData) { +) -> (Vec, ModelChartData, Vec<(String, ModelChartData)>) { let mut agent_rows = BTreeMap::::new(); let mut agent_model_tokens = BTreeMap::>::new(); let mut agent_model_cost = BTreeMap::>::new(); let mut agent_model_sessions = BTreeMap::>>::new(); + let mut agent_model_days = BTreeMap::>>::new(); + let mut agent_model_rates = BTreeMap::>>::new(); for event in events { let agent = event @@ -69,6 +76,12 @@ pub fn build_agent_chart( { let rate = event.tokens.output as f64 / (duration_ms as f64 / 1_000.0); entry.output_rates.push(rate); + agent_model_rates + .entry(agent.clone()) + .or_default() + .entry(model.clone()) + .or_default() + .push(rate); } let model_tokens = agent_model_tokens @@ -91,6 +104,15 @@ pub fn build_agent_chart( .entry(model.clone()) .or_default() .insert(event.session_id.clone()); + + if let Some(date) = event.activity_date() { + agent_model_days + .entry(agent.clone()) + .or_default() + .entry(model.clone()) + .or_default() + .insert(date); + } } let overall_tokens = agent_rows @@ -108,6 +130,11 @@ pub fn build_agent_chart( .map(|(model_id, tokens)| AgentModelBreakdown { model_id: model_id.clone(), tokens: tokens.total(), + input_tokens: tokens.input, + output_tokens: tokens.output, + cache_tokens: tokens + .cache_read + .saturating_add(tokens.cache_write), cost: agent_model_cost .get(&agent_id) .and_then(|costs| costs.get(model_id).cloned()) @@ -117,6 +144,16 @@ pub fn build_agent_chart( .and_then(|sessions| sessions.get(model_id)) .map(|s| s.len()) .unwrap_or(0), + active_days: agent_model_days + .get(&agent_id) + .and_then(|days| days.get(model_id)) + .map(|d| d.len()) + .unwrap_or(0), + p50_output_tokens_per_second: agent_model_rates + .get(&agent_id) + .and_then(|rates| rates.get(model_id)) + .map(|r| median(r)) + .unwrap_or(0.0), }) .collect(); breakdown.sort_by_key(|b| std::cmp::Reverse(b.tokens)); @@ -153,7 +190,33 @@ pub fn build_agent_chart( .filter(|a| !a.is_empty()) .unwrap_or_else(|| "unknown".to_string()) }); - (rows, chart) + + let mut agent_model_charts = Vec::new(); + for (agent_id, models) in &agent_model_tokens { + let model_ids: Vec = models.keys().cloned().collect(); + let agent_events: Vec<&UsageEvent> = events + .iter() + .filter(|event| { + event + .agent + .as_deref() + .filter(|a| !a.is_empty()) + .unwrap_or("unknown") + == agent_id.as_str() + }) + .copied() + .collect(); + let agent_chart = build_chart_for_models( + &agent_events, + &model_ids, + range, + today, + |event| event.model_id.clone(), + ); + agent_model_charts.push((agent_id.clone(), agent_chart)); + } + + (rows, chart, agent_model_charts) } #[cfg(test)] @@ -272,7 +335,7 @@ mod tests { availability: crate::cache::models_cache::PricingAvailability::Empty, load_notice: None, }; - let (rows, _chart) = build_agent_chart( + let (rows, _chart, _agent_model_charts) = build_agent_chart( &events.iter().collect::>(), &pricing, TimeRange::All, @@ -285,21 +348,44 @@ mod tests { assert_eq!(rows[0].agent_id, "build"); assert_eq!(rows[0].total_tokens, 1000); assert_eq!(rows[0].sessions, 2); + assert_eq!(rows[0].input_tokens, 400); + assert_eq!(rows[0].output_tokens, 600); + assert_eq!(rows[0].cache_tokens, 0); + assert_eq!(rows[0].active_days, 1); + assert!((rows[0].p50_output_tokens_per_second - 0.0).abs() < f64::EPSILON); assert_eq!(rows[0].model_breakdown.len(), 2); assert_eq!(rows[0].model_breakdown[0].model_id, "gpt-5.5"); assert_eq!(rows[0].model_breakdown[0].tokens, 700); + assert_eq!(rows[0].model_breakdown[0].input_tokens, 300); + assert_eq!(rows[0].model_breakdown[0].output_tokens, 400); + assert_eq!(rows[0].model_breakdown[0].cache_tokens, 0); assert_eq!(rows[0].model_breakdown[0].sessions, 1); + assert_eq!(rows[0].model_breakdown[0].active_days, 1); + assert!((rows[0].model_breakdown[0].p50_output_tokens_per_second - 0.0).abs() < f64::EPSILON); assert_eq!(rows[0].model_breakdown[1].model_id, "gpt-5"); assert_eq!(rows[0].model_breakdown[1].tokens, 300); + assert_eq!(rows[0].model_breakdown[1].input_tokens, 100); + assert_eq!(rows[0].model_breakdown[1].output_tokens, 200); + assert_eq!(rows[0].model_breakdown[1].cache_tokens, 0); assert_eq!(rows[0].model_breakdown[1].sessions, 1); + assert_eq!(rows[0].model_breakdown[1].active_days, 1); assert_eq!(rows[1].agent_id, "explore"); assert_eq!(rows[1].total_tokens, 150); assert_eq!(rows[1].sessions, 1); + assert_eq!(rows[1].input_tokens, 50); + assert_eq!(rows[1].output_tokens, 100); + assert_eq!(rows[1].cache_tokens, 0); + assert_eq!(rows[1].active_days, 1); + assert!(rows[1].p50_output_tokens_per_second < f64::EPSILON); assert_eq!(rows[1].model_breakdown.len(), 1); assert_eq!(rows[1].model_breakdown[0].model_id, "claude-sonnet"); assert_eq!(rows[1].model_breakdown[0].tokens, 150); + assert_eq!(rows[1].model_breakdown[0].input_tokens, 50); + assert_eq!(rows[1].model_breakdown[0].output_tokens, 100); + assert_eq!(rows[1].model_breakdown[0].cache_tokens, 0); assert_eq!(rows[1].model_breakdown[0].sessions, 1); + assert_eq!(rows[1].model_breakdown[0].active_days, 1); assert_eq!(rows[2].agent_id, "unknown"); assert_eq!(rows[2].total_tokens, 30); @@ -307,5 +393,9 @@ mod tests { assert_eq!(rows[2].model_breakdown[0].model_id, "unknown-model"); assert_eq!(rows[2].model_breakdown[0].tokens, 30); assert_eq!(rows[2].model_breakdown[0].sessions, 1); + assert_eq!(rows[2].input_tokens, 10); + assert_eq!(rows[2].output_tokens, 20); + assert_eq!(rows[2].cache_tokens, 0); + assert_eq!(rows[2].active_days, 1); } } diff --git a/src/analytics/mod.rs b/src/analytics/mod.rs index 1cf4f1a..4d8fa40 100644 --- a/src/analytics/mod.rs +++ b/src/analytics/mod.rs @@ -46,6 +46,7 @@ pub struct AnalyticsSnapshot { pub provider_chart: ModelChartData, pub agents: Vec, pub agent_chart: ModelChartData, + pub agent_model_charts: Vec<(String, ModelChartData)>, pub heatmap: HeatmapData, } @@ -83,7 +84,7 @@ pub fn build_snapshot( zero_cost_behavior, ); let heatmap = build_heatmap_data(&data.events, today); - let (agents, agent_chart) = + let (agents, agent_chart, agent_model_charts) = build_agent_chart(&filtered_events, pricing, range, today, zero_cost_behavior); let total_tokens = saturating_sum(filtered_events.iter().map(|event| event.tokens.total())); @@ -142,6 +143,7 @@ pub fn build_snapshot( provider_chart, agents, agent_chart, + agent_model_charts, heatmap, } } diff --git a/src/ui/agents.rs b/src/ui/agents.rs index 34129f7..262c32b 100644 --- a/src/ui/agents.rs +++ b/src/ui/agents.rs @@ -4,8 +4,9 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use crate::analytics::AnalyticsSnapshot; -use crate::analytics::agent_stats::AgentUsageRow; +use crate::analytics::agent_stats::{AgentModelBreakdown, AgentUsageRow}; use crate::analytics::model_stats::chart_with_focus; +use crate::ui::app::AgentChartMode; use crate::ui::models::{SearchItem, SearchState, layout_rows}; use crate::ui::theme::Theme; use crate::ui::widgets::common::{metric_line, truncate_label}; @@ -22,31 +23,26 @@ impl SearchItem for AgentUsageRow { } } +#[allow(clippy::too_many_arguments)] pub fn render_agents( frame: &mut ratatui::Frame<'_>, area: Rect, snapshot: &AnalyticsSnapshot, _range: TimeRange, focused_agent_index: usize, + chart_mode: AgentChartMode, + focused_model_index: usize, search: Option<&SearchState>, theme: &Theme, ) { - let [ - chart_area, - spacer1, - header_area, - spacer2, - detail_area, - model_area, - ] = Layout::default() + let [chart_area, spacer1, header_area, spacer2, detail_area] = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Fill(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), - Constraint::Length(4), - Constraint::Min(0), + Constraint::Length(5), ]) .areas(area); @@ -55,14 +51,34 @@ pub fn render_agents( .or(Some(focused_agent_index)); let focused_row = effective_focus.and_then(|i| snapshot.agents.get(i)); - let chart_data = chart_with_focus( - &snapshot.agent_chart, - focused_row.map(|row| row.agent_id.as_str()), - ); + let focused_model = focused_row + .and_then(|row| row.model_breakdown.get(focused_model_index)); + + let chart_data = match chart_mode { + AgentChartMode::AllAgents => chart_with_focus( + &snapshot.agent_chart, + focused_row.map(|row| row.agent_id.as_str()), + ), + AgentChartMode::PerModel => match focused_row { + Some(row) => { + let highlight = focused_model.map(|m| m.model_id.as_str()); + snapshot + .agent_model_charts + .iter() + .find(|(id, _)| id == &row.agent_id) + .map(|(_, chart)| chart_with_focus(chart, highlight)) + .unwrap_or_else(|| chart_with_focus(&snapshot.agent_chart, None)) + } + None => chart_with_focus(&snapshot.agent_chart, None), + }, + }; frame.render_widget(build_chart(&chart_data, theme), chart_area); + frame.render_widget( + Paragraph::new(mode_indicator(chart_mode, focused_row, theme)), + spacer1, + ); if let Some(search) = search { - frame.render_widget(Paragraph::new(""), spacer1); super::models::render_search_overlay( frame, header_area, @@ -73,18 +89,51 @@ pub fn render_agents( theme, ); } else if let Some(row) = focused_row { - frame.render_widget( - Paragraph::new(focus_agent_line( - row, - focused_agent_index, - &snapshot.agents, - theme, - )), - header_area, - ); - frame.render_widget(Paragraph::new(""), spacer2); - render_agent_detail(frame, detail_area, row, theme); - render_model_breakdown(frame, model_area, row, theme); + match chart_mode { + AgentChartMode::AllAgents => { + frame.render_widget( + Paragraph::new(focus_agent_line( + row, + focused_agent_index, + &snapshot.agents, + theme, + )), + header_area, + ); + frame.render_widget(Paragraph::new(""), spacer2); + render_agent_detail(frame, detail_area, row, theme); + } + AgentChartMode::PerModel => { + if let Some(model) = focused_model { + frame.render_widget( + Paragraph::new(focus_model_line( + model, + focused_model_index, + row, + theme, + )), + header_area, + ); + frame.render_widget(Paragraph::new(""), spacer2); + render_model_detail(frame, detail_area, model, theme); + } else { + frame.render_widget( + Paragraph::new(focus_agent_line( + row, + focused_agent_index, + &snapshot.agents, + theme, + )), + header_area, + ); + frame.render_widget(Paragraph::new(""), spacer2); + frame.render_widget( + Paragraph::new("No model data for this agent.").style(theme.muted_style()), + detail_area, + ); + } + } + } } else { frame.render_widget(Paragraph::new(""), spacer2); frame.render_widget( @@ -94,6 +143,26 @@ pub fn render_agents( } } +fn mode_indicator( + chart_mode: AgentChartMode, + focused_row: Option<&AgentUsageRow>, + theme: &Theme, +) -> Line<'static> { + let text = match chart_mode { + AgentChartMode::AllAgents => " All agents".to_string(), + AgentChartMode::PerModel => match focused_row { + Some(agent) => { + format!( + " Models used by {}", + truncate_label(&agent.agent_id, 40), + ) + } + None => " Per-model (no data)".to_string(), + }, + }; + Line::from(Span::styled(text, theme.muted_style())) +} + fn focus_agent_line( row: &AgentUsageRow, focused_agent_index: usize, @@ -116,6 +185,40 @@ fn focus_agent_line( Span::styled("j/k ↑/↓", theme.muted_style()), Span::styled(" | ", theme.muted_style()), Span::styled("f find", theme.muted_style()), + Span::styled(" | ", theme.muted_style()), + Span::styled("m chart", theme.muted_style()), + ]) +} + +fn focus_model_line( + model: &AgentModelBreakdown, + focused_model_index: usize, + agent_row: &AgentUsageRow, + theme: &Theme, +) -> Line<'static> { + let total = agent_row.model_breakdown.len().max(1); + let pct = if agent_row.total_tokens > 0 { + (model.tokens as f64 / agent_row.total_tokens as f64) * 100.0 + } else { + 0.0 + }; + Line::from(vec![ + Span::styled( + format!(" ● {}", truncate_label(&model.model_id, 26)), + Style::default().fg(theme.series_color(focused_model_index)), + ), + Span::styled(format!(" ({:.2}%)", pct), theme.muted_style()), + Span::styled(" | ", theme.muted_style()), + Span::styled( + format!("{}/{}", focused_model_index.min(total - 1) + 1, total), + theme.muted_style(), + ), + Span::styled(" | ", theme.muted_style()), + Span::styled("j/k ↑/↓", theme.muted_style()), + Span::styled(" | ", theme.muted_style()), + Span::styled("f find", theme.muted_style()), + Span::styled(" | ", theme.muted_style()), + Span::styled("m chart", theme.muted_style()), ]) } @@ -189,50 +292,76 @@ fn render_agent_detail( ); } -fn render_model_breakdown( +fn render_model_detail( frame: &mut ratatui::Frame<'_>, area: Rect, - row: &AgentUsageRow, + model: &AgentModelBreakdown, theme: &Theme, ) { - let models = &row.model_breakdown; - let available = area.height as usize; - - if models.is_empty() || available == 0 { - return; - } - - let show_count = available.min(models.len()); - - let constraints: Vec = (0..show_count).map(|_| Constraint::Length(1)).collect(); - let lines = Layout::default() - .direction(Direction::Vertical) - .constraints(constraints) - .split(area); - - for (i, line_area) in lines.iter().enumerate() { - if i >= models.len() { - break; - } - let m = &models[i]; - - let label = crate::ui::widgets::common::truncate_label(&m.model_id, 20); - let pct = if row.total_tokens > 0 { - (m.tokens as f64 / row.total_tokens as f64) * 100.0 - } else { - 0.0 - }; - let tokens = format_tokens(m.tokens); - let cost = format_price_summary(&m.cost); - - let model_line = Line::from(vec![ - Span::styled(" · ", theme.muted_style()), - Span::styled(label, Style::default().fg(theme.foreground)), - Span::styled(format!(": {tokens} ({pct:.1}%)"), theme.muted_style()), - Span::styled(format!(" | sessions: {}", m.sessions), theme.muted_style()), - Span::styled(format!(" | {cost}"), theme.muted_style()), - ]); + let rows = layout_rows::<4, 2>(area); - frame.render_widget(Paragraph::new(model_line), *line_area); - } + frame.render_widget( + Paragraph::new(metric_line( + "Total tokens: ", + format_tokens(model.tokens), + theme, + )), + rows[0][0], + ); + frame.render_widget( + Paragraph::new(metric_line( + "Total cost: ", + format_price_summary(&model.cost), + theme, + )), + rows[0][1], + ); + frame.render_widget( + Paragraph::new(metric_line( + "Input: ", + format_tokens(model.input_tokens), + theme, + )), + rows[1][0], + ); + frame.render_widget( + Paragraph::new(metric_line( + "Sessions: ", + model.sessions.to_string(), + theme, + )), + rows[1][1], + ); + frame.render_widget( + Paragraph::new(metric_line( + "Output: ", + format_tokens(model.output_tokens), + theme, + )), + rows[2][0], + ); + frame.render_widget( + Paragraph::new(metric_line( + "Active days: ", + model.active_days.to_string(), + theme, + )), + rows[2][1], + ); + frame.render_widget( + Paragraph::new(metric_line( + "Cache: ", + format_tokens(model.cache_tokens), + theme, + )), + rows[3][0], + ); + frame.render_widget( + Paragraph::new(metric_line( + "Rate: ", + format!("{:.2} tok/s", model.p50_output_tokens_per_second), + theme, + )), + rows[3][1], + ); } diff --git a/src/ui/app.rs b/src/ui/app.rs index 123ac24..e5b7b77 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -57,6 +57,13 @@ pub enum Page { Agents, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum AgentChartMode { + #[default] + AllAgents, + PerModel, +} + impl Page { pub fn next(self) -> Self { match self { @@ -90,6 +97,7 @@ pub struct App { pub focused_model_index: usize, pub focused_provider_index: usize, pub focused_agent_index: usize, + pub agent_chart_mode: AgentChartMode, pub search: Option, pricing_updates: mpsc::UnboundedReceiver>, clipboard_sender: mpsc::UnboundedSender, @@ -125,6 +133,7 @@ impl App { focused_model_index: 0, focused_provider_index: 0, focused_agent_index: 0, + agent_chart_mode: AgentChartMode::default(), search: None, pricing_updates: receiver, clipboard_sender, @@ -276,6 +285,8 @@ impl App { &self.snapshot, self.range, self.focused_agent_index, + self.agent_chart_mode, + self.focused_model_index, self.search.as_ref(), theme, ), @@ -296,7 +307,10 @@ impl App { segment_span("7 Days", self.range == TimeRange::Last7Days, theme), segment_span("30 Days", self.range == TimeRange::Last30Days, theme), ratatui::text::Span::raw(" "), - ratatui::text::Span::styled(format!("{:?}", self.data.source), theme.muted_style()), + ratatui::text::Span::styled( + format!("{:?}", self.data.source), + theme.muted_style(), + ), ]); frame.render_widget(ratatui::widgets::Paragraph::new(line), content); @@ -323,7 +337,10 @@ impl App { match self.page { Page::Models => self.focused_model_index = real_idx, Page::Providers => self.focused_provider_index = real_idx, - Page::Agents => self.focused_agent_index = real_idx, + Page::Agents => { + self.focused_agent_index = real_idx; + self.focused_model_index = 0; + } _ => {} } } @@ -408,6 +425,21 @@ impl App { { self.enter_search(); } + KeyCode::Char('n' | '0') if matches!(self.page, Page::Agents) => { + if self.agent_chart_mode == AgentChartMode::PerModel { + self.focused_model_index = 0; + } else { + self.focused_agent_index = 0; + self.focused_model_index = 0; + } + } + KeyCode::Char('m') if matches!(self.page, Page::Agents) => { + self.agent_chart_mode = match self.agent_chart_mode { + AgentChartMode::AllAgents => AgentChartMode::PerModel, + AgentChartMode::PerModel => AgentChartMode::AllAgents, + }; + self.focused_model_index = 0; + } KeyCode::Char(value) => { if let Some(range) = TimeRange::from_shortcut(value) { self.range = range; @@ -449,10 +481,23 @@ impl App { return; } - let current = self.focused_agent_index as isize; - let total = self.snapshot.agents.len() as isize; - let next = (current + delta).rem_euclid(total) as usize; - self.focused_agent_index = next; + if self.agent_chart_mode == AgentChartMode::PerModel { + let focused = self.focused_agent_index.min(self.snapshot.agents.len() - 1); + let models = &self.snapshot.agents[focused].model_breakdown; + if models.is_empty() { + return; + } + let current = self.focused_model_index.min(models.len() - 1) as isize; + let total = models.len() as isize; + let next = (current + delta).rem_euclid(total) as usize; + self.focused_model_index = next; + } else { + let current = self.focused_agent_index as isize; + let total = self.snapshot.agents.len() as isize; + let next = (current + delta).rem_euclid(total) as usize; + self.focused_agent_index = next; + self.focused_model_index = 0; + } } Page::Overview => {} } @@ -592,19 +637,19 @@ impl App { }) .collect::>() .join("\n"), - Page::Agents => { - let mut lines = Vec::new(); - for row in self.snapshot.agents.iter().take(8) { - lines.push(format!( + Page::Agents => self + .snapshot + .agents + .iter() + .take(8) + .map(|row| { + format!( "{}: {} tokens ({:.2}%)", row.agent_id, row.total_tokens, row.percentage - )); - for m in &row.model_breakdown { - lines.push(format!(" {}: {} tokens", m.model_id, m.tokens)); - } - } - lines.join("\n") - } + ) + }) + .collect::>() + .join("\n"), } } } From 784dcb4cb27ca167fbdbba63db6e3cd359e22bb6 Mon Sep 17 00:00:00 2001 From: BubbleBuffer Date: Wed, 3 Jun 2026 22:00:00 +0200 Subject: [PATCH 3/4] fix: resolve clippy useless_vec warning in agent_stats test --- src/analytics/agent_stats.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/analytics/agent_stats.rs b/src/analytics/agent_stats.rs index 1e73382..bb2f596 100644 --- a/src/analytics/agent_stats.rs +++ b/src/analytics/agent_stats.rs @@ -233,8 +233,7 @@ mod tests { .single() .unwrap(); let day = created_at.date_naive(); - let events = vec![ - UsageEvent { + let events = [UsageEvent { session_id: "ses_1".to_string(), parent_session_id: None, session_title: None, @@ -325,8 +324,7 @@ mod tests { completed_at: Some(created_at), stored_cost_usd: None, source: DataSourceKind::Json, - }, - ]; + }]; let pricing = crate::cache::models_cache::PricingCatalog { models: std::collections::BTreeMap::new(), From 74092a638bc29e44e512cfa0c2da3c4e51a79e6a Mon Sep 17 00:00:00 2001 From: BubbleBuffer Date: Wed, 3 Jun 2026 22:10:49 +0200 Subject: [PATCH 4/4] style: apply cargo fmt formatting --- src/analytics/agent_stats.rs | 31 +++++++++++++++++-------------- src/ui/agents.rs | 21 ++++----------------- src/ui/app.rs | 5 +---- 3 files changed, 22 insertions(+), 35 deletions(-) diff --git a/src/analytics/agent_stats.rs b/src/analytics/agent_stats.rs index bb2f596..ea65664 100644 --- a/src/analytics/agent_stats.rs +++ b/src/analytics/agent_stats.rs @@ -46,7 +46,11 @@ pub fn build_agent_chart( range: TimeRange, today: NaiveDate, zero_cost_behavior: ZeroCostBehavior, -) -> (Vec, ModelChartData, Vec<(String, ModelChartData)>) { +) -> ( + Vec, + ModelChartData, + Vec<(String, ModelChartData)>, +) { let mut agent_rows = BTreeMap::::new(); let mut agent_model_tokens = BTreeMap::>::new(); let mut agent_model_cost = BTreeMap::>::new(); @@ -132,9 +136,7 @@ pub fn build_agent_chart( tokens: tokens.total(), input_tokens: tokens.input, output_tokens: tokens.output, - cache_tokens: tokens - .cache_read - .saturating_add(tokens.cache_write), + cache_tokens: tokens.cache_read.saturating_add(tokens.cache_write), cost: agent_model_cost .get(&agent_id) .and_then(|costs| costs.get(model_id).cloned()) @@ -206,13 +208,10 @@ pub fn build_agent_chart( }) .copied() .collect(); - let agent_chart = build_chart_for_models( - &agent_events, - &model_ids, - range, - today, - |event| event.model_id.clone(), - ); + let agent_chart = + build_chart_for_models(&agent_events, &model_ids, range, today, |event| { + event.model_id.clone() + }); agent_model_charts.push((agent_id.clone(), agent_chart)); } @@ -233,7 +232,8 @@ mod tests { .single() .unwrap(); let day = created_at.date_naive(); - let events = [UsageEvent { + let events = [ + UsageEvent { session_id: "ses_1".to_string(), parent_session_id: None, session_title: None, @@ -324,7 +324,8 @@ mod tests { completed_at: Some(created_at), stored_cost_usd: None, source: DataSourceKind::Json, - }]; + }, + ]; let pricing = crate::cache::models_cache::PricingCatalog { models: std::collections::BTreeMap::new(), @@ -359,7 +360,9 @@ mod tests { assert_eq!(rows[0].model_breakdown[0].cache_tokens, 0); assert_eq!(rows[0].model_breakdown[0].sessions, 1); assert_eq!(rows[0].model_breakdown[0].active_days, 1); - assert!((rows[0].model_breakdown[0].p50_output_tokens_per_second - 0.0).abs() < f64::EPSILON); + assert!( + (rows[0].model_breakdown[0].p50_output_tokens_per_second - 0.0).abs() < f64::EPSILON + ); assert_eq!(rows[0].model_breakdown[1].model_id, "gpt-5"); assert_eq!(rows[0].model_breakdown[1].tokens, 300); assert_eq!(rows[0].model_breakdown[1].input_tokens, 100); diff --git a/src/ui/agents.rs b/src/ui/agents.rs index 262c32b..6bc215d 100644 --- a/src/ui/agents.rs +++ b/src/ui/agents.rs @@ -51,8 +51,7 @@ pub fn render_agents( .or(Some(focused_agent_index)); let focused_row = effective_focus.and_then(|i| snapshot.agents.get(i)); - let focused_model = focused_row - .and_then(|row| row.model_breakdown.get(focused_model_index)); + let focused_model = focused_row.and_then(|row| row.model_breakdown.get(focused_model_index)); let chart_data = match chart_mode { AgentChartMode::AllAgents => chart_with_focus( @@ -106,12 +105,7 @@ pub fn render_agents( AgentChartMode::PerModel => { if let Some(model) = focused_model { frame.render_widget( - Paragraph::new(focus_model_line( - model, - focused_model_index, - row, - theme, - )), + Paragraph::new(focus_model_line(model, focused_model_index, row, theme)), header_area, ); frame.render_widget(Paragraph::new(""), spacer2); @@ -152,10 +146,7 @@ fn mode_indicator( AgentChartMode::AllAgents => " All agents".to_string(), AgentChartMode::PerModel => match focused_row { Some(agent) => { - format!( - " Models used by {}", - truncate_label(&agent.agent_id, 40), - ) + format!(" Models used by {}", truncate_label(&agent.agent_id, 40),) } None => " Per-model (no data)".to_string(), }, @@ -325,11 +316,7 @@ fn render_model_detail( rows[1][0], ); frame.render_widget( - Paragraph::new(metric_line( - "Sessions: ", - model.sessions.to_string(), - theme, - )), + Paragraph::new(metric_line("Sessions: ", model.sessions.to_string(), theme)), rows[1][1], ); frame.render_widget( diff --git a/src/ui/app.rs b/src/ui/app.rs index e5b7b77..b7914ba 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -307,10 +307,7 @@ impl App { segment_span("7 Days", self.range == TimeRange::Last7Days, theme), segment_span("30 Days", self.range == TimeRange::Last30Days, theme), ratatui::text::Span::raw(" "), - ratatui::text::Span::styled( - format!("{:?}", self.data.source), - theme.muted_style(), - ), + ratatui::text::Span::styled(format!("{:?}", self.data.source), theme.muted_style()), ]); frame.render_widget(ratatui::widgets::Paragraph::new(line), content);