//! Tool usage statistics aggregation for the pager. //! //! This module provides: //! //! - [`ToolCategory`] — categories for tool calls (Execute, Read, Edit, Search, ListDir, Other) //! - [`BlockStatus`] — status of a tool block (Success, Failed, Running) //! - [`CategoryStats`] — per-category statistics (counts, failures, positions) //! - [`ToolUsageStats`] — aggregated stats with scope (Session / SelectedTurn) //! //! ## Phase 1 MVP //! //! Stats are computed over visible scrollback blocks only (ToolCallBlock variants). //! Thinking blocks and non-tool RenderBlock variants are excluded. //! Time tracking is deferred to Phase 2. use std::collections::{BTreeMap, HashMap}; use std::ops::Range; use std::time::Instant; use ratatui::style::Color; use crate::scrollback::blocks::tool::ToolCallBlock; use crate::scrollback::state::ScrollbackState; use crate::theme::Theme; // --------------------------------------------------------------------------- // ToolCategory — categories derived from ToolCallBlock variants only // --------------------------------------------------------------------------- /// Block category for stats aggregation. /// Maps scrollback block variants to semantic groups. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum ToolCategory { /// Shell command execution (run_terminal_cmd, bash). Execute, /// File read operations (read_file). Read, /// File edits (search_replace, write, apply_patch). Edit, /// Search/grep operations (grep, glob). Search, /// Skill invocations (user-defined skills via the Skill tool). Skill, /// Directory listing (list_dir, ls). ListDir, /// Web fetch (URL content retrieval). WebFetch, /// Web search (web search with citations). WebSearch, /// Other/unknown tool types. Other, /// Agent thinking/reasoning. Thinking, /// Agent response message. Message, } impl ToolCategory { /// Display name for UI. pub fn label(&self) -> &'static str { match self { Self::Execute => "Execute", Self::Read => "Read", Self::Edit => "Edit", Self::Search => "Search", Self::Skill => "Skill", Self::ListDir => "ListDir", Self::WebFetch => "Fetch", Self::WebSearch => "WebSearch", Self::Other => "Other", Self::Thinking => "Thinking", Self::Message => "Message", } } /// Compact symbol for sequence bar (single char, no emoji). pub fn symbol(&self) -> char { match self { Self::Execute => '█', Self::Read => '▓', Self::Edit => '▒', Self::Search => '░', Self::Skill => crate::glyphs::diamond_filled_char(), Self::ListDir => '▀', Self::WebFetch => '▄', Self::WebSearch => '○', Self::Other => crate::glyphs::diamond_filled_char(), Self::Thinking => crate::glyphs::diamond_hollow_char(), Self::Message => '▪', } } /// Category color from theme. pub fn color(&self, theme: &Theme) -> Color { match self { Self::Execute => theme.command, Self::Read => theme.accent_system, Self::Edit => theme.accent_success, Self::Search => theme.running, Self::Skill => theme.accent_skill, Self::ListDir => theme.accent_model, Self::WebFetch => theme.accent_tool, Self::WebSearch => theme.accent_tool, Self::Other => theme.path, Self::Thinking => theme.accent_running, Self::Message => theme.text_primary, } } /// Map from ToolCallBlock to category. /// Only ToolCallBlock variants are supported; other blocks are excluded. pub fn from_tool_block(tc: &ToolCallBlock) -> Self { match tc { ToolCallBlock::Execute(_) => Self::Execute, ToolCallBlock::Read(_) => Self::Read, ToolCallBlock::Edit(_) => Self::Edit, ToolCallBlock::Search(_) => Self::Search, ToolCallBlock::Skill(_) => Self::Skill, ToolCallBlock::ListDir(_) => Self::ListDir, ToolCallBlock::WebFetch(_) => Self::WebFetch, ToolCallBlock::WebSearch(_) => Self::WebSearch, ToolCallBlock::IntegrationSearch(_) | ToolCallBlock::UseTool(_) => Self::Other, ToolCallBlock::MemorySearch(_) | ToolCallBlock::Other(_) | ToolCallBlock::Lifecycle(_) => Self::Other, } } } // --------------------------------------------------------------------------- // BlockStatus — status dimension (not category) // --------------------------------------------------------------------------- /// Status of a tool block at aggregation time. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub enum BlockStatus { /// Tool completed successfully. #[default] Success, /// Tool failed with an error. Failed, /// Tool is currently running/streaming. Running, } /// A single entry in the activity lineage (ordered timeline). #[derive(Debug, Clone)] pub struct LineageEntry { /// What kind of block this is. pub category: ToolCategory, /// Duration in ms (None if unknown/pre-completed). pub duration_ms: Option, /// Whether this block is still running. pub running: bool, } // --------------------------------------------------------------------------- // CategoryStats — per-category aggregation // --------------------------------------------------------------------------- /// Statistics for a single tool category. #[derive(Debug, Clone, Default)] pub struct CategoryStats { /// Number of operations in this category (all statuses). pub count: usize, /// Count by status. pub by_status: HashMap, /// Sequence positions where this category appeared. /// Used for building the sequence strip. pub sequence_positions: Vec, /// Total elapsed time in ms for this category (Phase 2). pub total_time_ms: i64, } impl CategoryStats { /// Number of failed operations. pub fn failed_count(&self) -> usize { *self.by_status.get(&BlockStatus::Failed).unwrap_or(&0) } /// Number of running operations. pub fn running_count(&self) -> usize { *self.by_status.get(&BlockStatus::Running).unwrap_or(&0) } /// Percentage of total operations. pub fn percent_of(&self, total: usize) -> f64 { if total == 0 { return 0.0; } (self.count as f64 / total as f64) * 100.0 } /// Format total time as human-readable string (Phase 2). pub fn format_time(&self) -> String { if self.total_time_ms == 0 { return "—".to_string(); } let secs = self.total_time_ms / 1000; let ms = self.total_time_ms % 1000; if secs == 0 { format!("{}ms", ms) } else if secs < 60 { format!("{}.{:03}s", secs, ms) } else { let mins = secs / 60; let secs = secs % 60; format!("{}m {}s", mins, secs) } } } // --------------------------------------------------------------------------- // StatsScope — aggregation scope // --------------------------------------------------------------------------- /// Aggregation scope. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum StatsScope { /// All visible scrollback entries for the current agent session. #[default] Session, /// The turn returned by `ScrollbackState::current_turn()`, /// with fallback to the latest turn if no current turn exists. SelectedTurn, } // --------------------------------------------------------------------------- // ToolUsageStats — full aggregation // --------------------------------------------------------------------------- /// Aggregated tool usage statistics. #[derive(Debug, Clone)] pub struct ToolUsageStats { /// Per-category stats (includes Thinking and Message). pub categories: BTreeMap, /// Total operations across all categories. pub total_operations: usize, /// When stats were last computed. pub computed_at: Instant, /// Scope of aggregation. pub scope: StatsScope, /// Ordered timeline of all blocks with category and duration. pub lineage: Vec, } impl Default for ToolUsageStats { fn default() -> Self { Self { categories: BTreeMap::new(), total_operations: 0, computed_at: Instant::now(), scope: StatsScope::default(), lineage: Vec::new(), } } } impl ToolUsageStats { /// Aggregate stats from entire visible scrollback (session scope). pub fn from_scrollback(scrollback: &ScrollbackState) -> Self { let n = scrollback.len(); Self::from_range(scrollback, 0..n, StatsScope::Session) } /// Aggregate stats from a specific turn. /// /// If turn_index is out of range, returns empty stats. pub fn from_turn(scrollback: &ScrollbackState, turn_index: usize) -> Self { match scrollback.turn(turn_index) { Some(turn) => Self::from_range(scrollback, turn.range(), StatsScope::SelectedTurn), None => ToolUsageStats { scope: StatsScope::SelectedTurn, computed_at: Instant::now(), ..Default::default() }, } } /// Aggregate stats from selected turn if any, otherwise latest turn. /// /// Returns empty stats if no turns exist. pub fn from_selected_or_latest(scrollback: &ScrollbackState) -> Self { // Prefer selected turn; fall back to latest turn. let turn_idx = scrollback.current_turn().or_else(|| { let count = scrollback.turns().len(); if count > 0 { Some(count - 1) } else { None } }); match turn_idx { Some(idx) => Self::from_turn(scrollback, idx), None => ToolUsageStats { scope: StatsScope::SelectedTurn, computed_at: Instant::now(), ..Default::default() }, } } /// Core aggregation logic over a range of entry indices. /// /// Uses real ScrollbackState/Entry APIs: /// - scrollback.len() / scrollback.entry(i) /// - scrollback.turns() / scrollback.turn(i) / scrollback.current_turn() fn from_range(scrollback: &ScrollbackState, range: Range, scope: StatsScope) -> Self { let mut stats = ToolUsageStats { scope, computed_at: Instant::now(), ..Default::default() }; for idx in range.clone() { let Some(entry) = scrollback.entry(idx) else { continue; }; match &entry.block { crate::scrollback::block::RenderBlock::ToolCall(tc) => { let category = ToolCategory::from_tool_block(tc); let status = if entry.is_running { BlockStatus::Running } else if !Self::tool_block_is_success(tc) { BlockStatus::Failed } else { BlockStatus::Success }; let elapsed = Self::tool_block_elapsed_ms(tc); let cat_stats = stats.categories.entry(category).or_default(); cat_stats.count += 1; *cat_stats.by_status.entry(status).or_default() += 1; cat_stats.sequence_positions.push(idx); if let Some(ms) = elapsed { cat_stats.total_time_ms += ms; } stats.lineage.push(LineageEntry { category, duration_ms: elapsed, running: entry.is_running, }); } crate::scrollback::block::RenderBlock::Thinking(thinking) => { let elapsed = thinking.elapsed_time_ms(); let cat_stats = stats.categories.entry(ToolCategory::Thinking).or_default(); cat_stats.count += 1; let status = if entry.is_running { BlockStatus::Running } else { BlockStatus::Success }; *cat_stats.by_status.entry(status).or_default() += 1; cat_stats.sequence_positions.push(idx); if let Some(ms) = elapsed { cat_stats.total_time_ms += ms; } stats.lineage.push(LineageEntry { category: ToolCategory::Thinking, duration_ms: elapsed, running: entry.is_running, }); } crate::scrollback::block::RenderBlock::AgentMessage(_) => { let cat_stats = stats.categories.entry(ToolCategory::Message).or_default(); cat_stats.count += 1; let status = if entry.is_running { BlockStatus::Running } else { BlockStatus::Success }; *cat_stats.by_status.entry(status).or_default() += 1; cat_stats.sequence_positions.push(idx); stats.lineage.push(LineageEntry { category: ToolCategory::Message, duration_ms: None, running: entry.is_running, }); } _ => {} } } stats.total_operations = stats.categories.values().map(|c| c.count).sum(); stats } /// Check if a tool block completed successfully. /// /// Each ToolCallBlock variant has an is_success() method or error field. fn tool_block_is_success(tc: &ToolCallBlock) -> bool { match tc { ToolCallBlock::Execute(b) => b.is_success(), ToolCallBlock::Read(b) => b.is_success(), ToolCallBlock::Edit(b) => b.is_success(), ToolCallBlock::Search(b) => b.is_success(), ToolCallBlock::ListDir(b) => b.is_success(), ToolCallBlock::WebFetch(b) => b.is_success(), ToolCallBlock::WebSearch(b) => b.is_success(), ToolCallBlock::IntegrationSearch(b) => b.is_success(), ToolCallBlock::UseTool(b) => b.is_success(), ToolCallBlock::MemorySearch(b) => b.is_success(), ToolCallBlock::Skill(b) => b.is_success(), ToolCallBlock::Other(b) => b.is_success(), ToolCallBlock::Lifecycle(_) => true, } } /// Get elapsed time from a tool block (Phase 2). fn tool_block_elapsed_ms(tc: &ToolCallBlock) -> Option { match tc { ToolCallBlock::Execute(b) => b.elapsed_ms(), ToolCallBlock::Read(b) => b.elapsed_ms(), ToolCallBlock::Edit(b) => b.elapsed_ms(), ToolCallBlock::Search(b) => b.elapsed_ms(), ToolCallBlock::ListDir(b) => b.elapsed_ms(), ToolCallBlock::WebFetch(b) => b.elapsed_ms(), ToolCallBlock::WebSearch(b) => b.elapsed_ms(), ToolCallBlock::IntegrationSearch(b) => b.elapsed_ms(), ToolCallBlock::UseTool(b) => b.elapsed_ms(), ToolCallBlock::MemorySearch(b) => b.elapsed_ms(), ToolCallBlock::Skill(b) => b.elapsed_ms(), ToolCallBlock::Other(b) => b.elapsed_ms(), ToolCallBlock::Lifecycle(_) => None, } } /// Get categories sorted by count (descending). pub fn sorted_categories(&self) -> Vec<(ToolCategory, &CategoryStats)> { let mut items: Vec<_> = self.categories.iter().map(|(k, v)| (*k, v)).collect(); items.sort_by_key(|item| std::cmp::Reverse(item.1.count)); items } } #[cfg(test)] mod tests { use super::*; use crate::scrollback::block::RenderBlock; use crate::scrollback::blocks::tool::{ EditToolCallBlock, ExecuteToolCallBlock, ReadToolCallBlock, ToolCallBlock, }; use crate::scrollback::state::ScrollbackState; #[test] fn test_tool_category_labels() { assert_eq!(ToolCategory::Execute.label(), "Execute"); assert_eq!(ToolCategory::Read.label(), "Read"); assert_eq!(ToolCategory::Edit.label(), "Edit"); assert_eq!(ToolCategory::Search.label(), "Search"); assert_eq!(ToolCategory::ListDir.label(), "ListDir"); assert_eq!(ToolCategory::Other.label(), "Other"); } #[test] fn test_category_stats_percent() { let stats = CategoryStats { count: 10, ..Default::default() }; assert_eq!(stats.percent_of(100), 10.0); assert_eq!(stats.percent_of(0), 0.0); } #[test] fn test_block_status_default() { let status = BlockStatus::default(); assert_eq!(status, BlockStatus::Success); } #[test] fn test_from_scrollback_mixed_categories() { let mut scrollback = ScrollbackState::new(); // Push mixed tool blocks scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Execute( ExecuteToolCallBlock::new("cargo build"), ))); scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Read( ReadToolCallBlock::new("src/main.rs"), ))); scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Edit( EditToolCallBlock::new("src/lib.rs", Vec::new()), ))); scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Execute( ExecuteToolCallBlock::new("cargo test"), ))); let stats = ToolUsageStats::from_scrollback(&scrollback); assert_eq!(stats.total_operations, 4); assert_eq!(stats.scope, StatsScope::Session); // Verify category counts let execute_count = stats .categories .get(&ToolCategory::Execute) .map(|c| c.count) .unwrap_or(0); let read_count = stats .categories .get(&ToolCategory::Read) .map(|c| c.count) .unwrap_or(0); let edit_count = stats .categories .get(&ToolCategory::Edit) .map(|c| c.count) .unwrap_or(0); assert_eq!(execute_count, 2); assert_eq!(read_count, 1); assert_eq!(edit_count, 1); } #[test] fn test_from_scrollback_failed_blocks() { let mut scrollback = ScrollbackState::new(); // Push execute blocks - one with error, one without let mut success_block = ExecuteToolCallBlock::new("echo hello"); success_block.finish(); scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Execute(success_block))); let mut failed_block = ExecuteToolCallBlock::new("bad_command").with_error("command not found"); failed_block.finish(); scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Execute(failed_block))); let stats = ToolUsageStats::from_scrollback(&scrollback); assert_eq!(stats.total_operations, 2); let exec_stats = stats.categories.get(&ToolCategory::Execute).unwrap(); assert_eq!(exec_stats.count, 2); assert_eq!(exec_stats.failed_count(), 1); } #[test] fn test_from_selected_or_latest_empty_scrollback() { let scrollback = ScrollbackState::new(); let stats = ToolUsageStats::from_selected_or_latest(&scrollback); assert_eq!(stats.total_operations, 0); assert_eq!(stats.scope, StatsScope::SelectedTurn); } #[test] fn test_from_selected_or_latest_no_turns_fallback() { let mut scrollback = ScrollbackState::new(); // Push tool blocks without starting a turn scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Execute( ExecuteToolCallBlock::new("echo test"), ))); let stats = ToolUsageStats::from_selected_or_latest(&scrollback); // No turns exist, so it falls back to latest turn (which is empty) // or returns empty stats assert_eq!(stats.scope, StatsScope::SelectedTurn); } #[test] fn test_from_selected_or_latest_with_turns() { let mut scrollback = ScrollbackState::new(); // Push user prompt (starts a turn) and some tool blocks scrollback.push_block(RenderBlock::user_prompt("test prompt")); scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Execute( ExecuteToolCallBlock::new("echo hello"), ))); scrollback.push_block(RenderBlock::ToolCall(ToolCallBlock::Read( ReadToolCallBlock::new("file.txt"), ))); // Set as current turn scrollback.set_selected(Some(0)); let stats = ToolUsageStats::from_selected_or_latest(&scrollback); assert_eq!(stats.scope, StatsScope::SelectedTurn); // Should have aggregated the tool blocks in the turn // Verify it computed without panicking let _ = stats.total_operations; } #[test] fn test_tool_block_is_success() { let success = ExecuteToolCallBlock::new("echo test"); assert!(ToolUsageStats::tool_block_is_success( &ToolCallBlock::Execute(success) )); let mut failed = ExecuteToolCallBlock::new("bad"); failed.set_error(Some("error".into())); assert!(!ToolUsageStats::tool_block_is_success( &ToolCallBlock::Execute(failed) )); } }