diff --git a/src/app/view/stats.rs b/src/app/view/stats.rs index 1dd53db..2e6973b 100644 --- a/src/app/view/stats.rs +++ b/src/app/view/stats.rs @@ -1,9 +1,11 @@ use super::*; +use crate::app::generation; use iced::widget::column; impl App { pub(super) fn stats_dashboard(&self) -> Element<'_, Message> { let stats = &self.metrics_snapshot; + let session = SessionStats::from_messages(&self.conversation, self.config.model); let context_fraction = if stats.context_limit == 0 { 0.0 } else { @@ -97,6 +99,51 @@ impl App { ] .spacing(10); + let session_title = self + .selected_session + .and_then(|id| self.session_title(id)) + .unwrap_or_else(|| "No session selected".to_owned()); + let session_cache_fraction = if session.input_tokens == 0 { + 0.0 + } else { + session.cached_tokens as f64 / session.input_tokens as f64 + }; + let current_session = stats_panel( + "CURRENT SESSION", + column![ + metric_row("Session", session_title), + metric_row( + "Messages", + format!( + "{} · {} user · {} assistant · {} tool", + format_count(session.messages), + format_count(session.user_messages), + format_count(session.assistant_messages), + format_count(session.tool_messages), + ), + ), + metric_row("Tool calls", format_count(session.tool_calls)), + metric_row("Context rebuilds", format_count(session.context_rebuilds)), + metric_row("Completed turns", format_count(session.completed_turns),), + metric_row( + "Generation time", + format_duration(session.duration_ms as f64 / 1_000.0), + ), + metric_row("Input tokens", format_count(session.input_tokens)), + metric_row( + "Cached tokens", + format!( + "{} · {:.1}% reused", + format_count(session.cached_tokens), + session_cache_fraction * 100.0, + ), + ), + metric_row("Output tokens", format_count(session.output_tokens)), + ] + .spacing(9) + .into(), + ); + let throughput = stats_panel( "MODEL ACTIVITY · LAST 24 SECONDS", column![ @@ -528,6 +575,7 @@ impl App { column![ heading, headline, + current_session, throughput, ssd_activity, kv_io, @@ -681,6 +729,59 @@ impl App { } } +#[derive(Debug, Default, PartialEq, Eq)] +struct SessionStats { + messages: u64, + user_messages: u64, + assistant_messages: u64, + tool_messages: u64, + tool_calls: u64, + context_rebuilds: u64, + completed_turns: u64, + duration_ms: u64, + input_tokens: u64, + cached_tokens: u64, + output_tokens: u64, +} + +impl SessionStats { + fn from_messages(messages: &[generation::ChatMessage], model: ModelChoice) -> Self { + let mut summary = Self::default(); + for message in messages { + if let Some(stats) = message.generation_stats { + summary.completed_turns = summary.completed_turns.saturating_add(1); + summary.duration_ms = summary.duration_ms.saturating_add(stats.duration_ms); + summary.input_tokens = summary + .input_tokens + .saturating_add(u64::from(stats.input_tokens)); + summary.cached_tokens = summary + .cached_tokens + .saturating_add(u64::from(stats.cached_tokens)); + summary.output_tokens = summary + .output_tokens + .saturating_add(u64::from(stats.output_tokens)); + } + if message.compaction { + summary.context_rebuilds = summary.context_rebuilds.saturating_add(1); + } else if !message.system { + summary.messages = summary.messages.saturating_add(1); + if message.user { + summary.user_messages = summary.user_messages.saturating_add(1); + } else if message.tool { + summary.tool_messages = summary.tool_messages.saturating_add(1); + } else { + summary.assistant_messages = summary.assistant_messages.saturating_add(1); + let calls = crate::agent::parse_tool_calls(model, &message.content) + .map(|(_, calls)| calls.len() as u64) + .unwrap_or(0); + summary.tool_calls = summary.tool_calls.saturating_add(calls); + } + } + } + summary + } +} + /// Sessions sit outside the age scale: they are not evicted, only deleted. const SESSION_COLOR: Color = Color::from_rgb(0.85, 0.44, 0.56); @@ -705,3 +806,74 @@ fn shorten(title: &str) -> String { fn portion(bytes: u64, capacity: u64) -> u16 { ((bytes.saturating_mul(1000) / capacity.max(1)) as u16).max(1) } + +#[cfg(test)] +mod tests { + use super::*; + + fn message(user: bool, tool: bool, system: bool, content: &str) -> generation::ChatMessage { + generation::ChatMessage { + id: 1, + user, + tool, + system, + compaction: false, + compaction_tail_start: None, + generation_stats: None, + reasoning: None, + reasoning_complete: true, + reasoning_open: false, + content: content.to_owned(), + model_content: None, + markdown: markdown::Content::new(), + transcript: text_editor::Content::new(), + a2ui_lines_processed: 0, + a2ui_errors: Vec::new(), + a2ui_replies: Vec::new(), + a2ui_open_urls: Vec::new(), + } + } + + #[test] + fn current_session_summary_uses_persisted_chat_facts() { + let mut user = message(true, false, false, "Run it"); + user.generation_stats = Some(generation::GenerationStats { + duration_ms: 1_250, + input_tokens: 2_000, + cached_tokens: 1_500, + output_tokens: 300, + }); + let assistant = message( + false, + false, + false, + "Checking<|DSML|tool_calls><|DSML|invoke name=\"read\"><|DSML|parameter name=\"path\" string=\"true\">src/main.rs<|DSML|invoke name=\"list\"><|DSML|parameter name=\"path\" string=\"true\">src", + ); + let tool = message(false, true, false, "Tool result 1\n..."); + let mut compaction = message(false, false, true, "summary"); + compaction.compaction = true; + let system = message(false, false, true, "reminder"); + + let summary = SessionStats::from_messages( + &[user, assistant, tool, compaction, system], + ModelChoice::DeepSeekV4Flash, + ); + + assert_eq!( + summary, + SessionStats { + messages: 3, + user_messages: 1, + assistant_messages: 1, + tool_messages: 1, + tool_calls: 2, + context_rebuilds: 1, + completed_turns: 1, + duration_ms: 1_250, + input_tokens: 2_000, + cached_tokens: 1_500, + output_tokens: 300, + } + ); + } +}