diff --git a/src/analyzer.rs b/src/analyzer.rs index 33785bf..1dbd105 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -1040,6 +1040,7 @@ mod tests { application: Application::ClaudeCode, date, project_hash: "proj".into(), + project_path: None, conversation_hash: "conv".into(), local_hash: None, global_hash: "global".into(), diff --git a/src/analyzers/antigravity.rs b/src/analyzers/antigravity.rs index 90eb0df..cd7979e 100644 --- a/src/analyzers/antigravity.rs +++ b/src/analyzers/antigravity.rs @@ -643,6 +643,7 @@ impl Analyzer for AntigravityCliAnalyzer { application: Application::AntigravityCli, date: ts, project_hash: "".to_string(), + project_path: None, conversation_hash: conversation_hash.clone(), local_hash: None, global_hash, diff --git a/src/analyzers/claude_code.rs b/src/analyzers/claude_code.rs index 47c5e90..0bc551f 100644 --- a/src/analyzers/claude_code.rs +++ b/src/analyzers/claude_code.rs @@ -676,6 +676,7 @@ pub fn parse_jsonl_file( let mut fallback_session_name = None; let mut current_model = None; + let mut current_project_path = None; // Read entire file at once to avoid per-line allocations let mut buffer = Vec::new(); @@ -695,6 +696,9 @@ pub fn parse_jsonl_file( summaries.insert(summary.leaf_uuid, summary.summary); } Ok(ClaudeCodeEntry::Message(entry)) => { + if let Some(cwd) = entry.cwd.as_deref().and_then(normalize_project_path) { + current_project_path = Some(cwd); + } // Track all UUIDs for summary linking, even if we skip the message all_uuids.push(entry.uuid.clone()); @@ -726,6 +730,7 @@ pub fn parse_jsonl_file( model: model.clone(), date: timestamp, project_hash: project_hash.to_string(), + project_path: current_project_path.clone(), conversation_hash: conversation_hash.to_string(), stats: Stats::default(), // Will be filled below role: match role.as_deref() { @@ -828,6 +833,16 @@ pub fn parse_jsonl_file( Ok((messages, summaries, all_uuids, fallback_session_name)) } +fn normalize_project_path(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let normalized: PathBuf = Path::new(trimmed).components().collect(); + Some(normalized.to_string_lossy().into_owned()) +} + // Type alias for token fingerprint pub type TokenFingerprint = (u64, u64, u64, u64, u64); diff --git a/src/analyzers/claude_code_history.rs b/src/analyzers/claude_code_history.rs index b735049..e03f0dd 100644 --- a/src/analyzers/claude_code_history.rs +++ b/src/analyzers/claude_code_history.rs @@ -292,6 +292,7 @@ mod tests { application: Application::ClaudeCode, date: Utc.with_ymd_and_hms(2025, 8, 2, 14, 5, 17).unwrap(), project_hash: "project".to_string(), + project_path: None, conversation_hash: conversation.to_string(), local_hash: Some(local_hash.to_string()), global_hash: hash.to_string(), diff --git a/src/analyzers/cline.rs b/src/analyzers/cline.rs index 6d57b93..809e7d4 100644 --- a/src/analyzers/cline.rs +++ b/src/analyzers/cline.rs @@ -201,6 +201,7 @@ fn parse_cline_task_directory(task_dir: &Path) -> Result Result = HashSet::with_capacity(20); let mut session_name: Option = None; let mut fallback_session_name: Option = None; + let mut project_path: Option = None; + let mut project_hash = String::new(); + let mut has_repository_project_id = false; for line in buffer.split(|&b| b == b'\n') { // Skip empty lines @@ -356,11 +359,24 @@ pub(crate) fn parse_codex_cli_jsonl_file( "session_meta" => { // Try to parse the payload as session metadata let mut payload_bytes = simd_json::to_vec(&wrapper.payload)?; - if let Ok(_session_meta) = + if let Ok(session_meta) = simd_json::from_slice::(&mut payload_bytes) { session_model = extract_model_from_value(&wrapper.payload).map(SessionModel::explicit); + project_path = session_meta.cwd.as_deref().and_then(normalize_project_path); + if let Some(repository_url) = session_meta + .git + .as_ref() + .and_then(|git| git.repository_url.as_deref()) + .map(str::trim) + .filter(|url| !url.is_empty()) + { + project_hash = hash_text(repository_url); + has_repository_project_id = true; + } else if let Some(path) = project_path.as_deref() { + project_hash = hash_text(path); + } } } "turn_context" => { @@ -368,6 +384,12 @@ pub(crate) fn parse_codex_cli_jsonl_file( if let Ok(context) = simd_json::from_slice::(&mut payload_bytes) { + if let Some(cwd) = context.cwd.as_deref().and_then(normalize_project_path) { + if !has_repository_project_id { + project_hash = hash_text(&cwd); + } + project_path = Some(cwd); + } if let Some(model_name) = extract_model_from_value(&wrapper.payload) { session_model = Some(SessionModel::explicit(model_name)); } @@ -484,7 +506,8 @@ pub(crate) fn parse_codex_cli_jsonl_file( local_hash: None, conversation_hash: hash_text(&session_path_str), application: Application::CodexCli, - project_hash: "".to_string(), + project_hash: project_hash.clone(), + project_path: project_path.clone(), model: None, stats: Stats::default(), role: MessageRole::User, @@ -521,7 +544,8 @@ pub(crate) fn parse_codex_cli_jsonl_file( local_hash: None, conversation_hash: hash_text(&session_path_str), date: wrapper.timestamp, - project_hash: "".to_string(), + project_hash: project_hash.clone(), + project_path: project_path.clone(), stats: Stats::default(), role: MessageRole::Assistant, uuid: None, @@ -589,7 +613,8 @@ pub(crate) fn parse_codex_cli_jsonl_file( local_hash: None, conversation_hash: hash_text(&session_path_str), date: wrapper.timestamp, - project_hash: "".to_string(), + project_hash: project_hash.clone(), + project_path: project_path.clone(), stats, role: MessageRole::Assistant, uuid: None, @@ -614,6 +639,17 @@ pub(crate) fn parse_codex_cli_jsonl_file( Ok((entries, detected_model)) } +fn normalize_project_path(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let portable = trimmed.replace('\\', "/"); + let normalized: PathBuf = Path::new(&portable).components().collect(); + Some(normalized.to_string_lossy().replace('\\', "/")) +} + fn calculate_cost_from_tokens( usage: &CodexCliTokenUsage, model_name: &str, @@ -736,3 +772,20 @@ fn normalize_model_name(raw: &str) -> Option { Some(trimmed.to_string()) } } + +#[cfg(test)] +mod tests { + use super::normalize_project_path; + + #[test] + fn project_paths_are_normalized_independently_of_host_separators() { + assert_eq!( + normalize_project_path(r"C:\work\.\splitrail"), + Some("C:/work/splitrail".to_string()) + ); + assert_eq!( + normalize_project_path("/home/user/./splitrail"), + Some("/home/user/splitrail".to_string()) + ); + } +} diff --git a/src/analyzers/copilot.rs b/src/analyzers/copilot.rs index 850abf9..7138881 100644 --- a/src/analyzers/copilot.rs +++ b/src/analyzers/copilot.rs @@ -364,6 +364,7 @@ pub(crate) fn parse_copilot_session_file(session_file: &Path) -> Result Result, session_name: Option<&String>, ) { let user_local_hash = format!("{conversation_hash}-cli-user-{}", *user_index); @@ -341,6 +342,7 @@ fn push_copilot_cli_user_message( application: Application::CopilotCli, date: pending_user.date, project_hash: project_hash.to_string(), + project_path: project_path.map(str::to_string), conversation_hash: conversation_hash.to_string(), local_hash: Some(user_local_hash), global_hash: user_global_hash, @@ -552,6 +554,7 @@ fn flush_copilot_cli_turn( assistant_index: &mut usize, conversation_hash: &str, project_hash: &str, + project_path: Option<&str>, session_name: Option<&String>, ) { let Some(turn) = current_turn.take() else { @@ -571,6 +574,7 @@ fn flush_copilot_cli_turn( user_index, conversation_hash, project_hash, + project_path, session_name, ); pending_user.emitted = true; @@ -614,6 +618,7 @@ fn flush_copilot_cli_turn( application: Application::CopilotCli, date: assistant_date, project_hash: project_hash.to_string(), + project_path: project_path.map(str::to_string), conversation_hash: conversation_hash.to_string(), local_hash: Some(assistant_local_hash), global_hash: assistant_global_hash, @@ -719,6 +724,7 @@ pub(crate) fn parse_copilot_cli_session_file( &mut assistant_index, &conversation_hash, &project_hash, + workspace_path.as_deref(), session_name.as_ref(), ); @@ -731,6 +737,7 @@ pub(crate) fn parse_copilot_cli_session_file( &mut user_index, &conversation_hash, &project_hash, + workspace_path.as_deref(), session_name.as_ref(), ); } @@ -769,6 +776,7 @@ pub(crate) fn parse_copilot_cli_session_file( &mut assistant_index, &conversation_hash, &project_hash, + workspace_path.as_deref(), session_name.as_ref(), ); @@ -795,6 +803,7 @@ pub(crate) fn parse_copilot_cli_session_file( &mut assistant_index, &conversation_hash, &project_hash, + workspace_path.as_deref(), session_name.as_ref(), ); } @@ -1051,6 +1060,7 @@ pub(crate) fn parse_copilot_cli_session_file( &mut assistant_index, &conversation_hash, &project_hash, + workspace_path.as_deref(), session_name.as_ref(), ); @@ -1063,6 +1073,7 @@ pub(crate) fn parse_copilot_cli_session_file( &mut user_index, &conversation_hash, &project_hash, + workspace_path.as_deref(), session_name.as_ref(), ); } diff --git a/src/analyzers/deepseek_harness.rs b/src/analyzers/deepseek_harness.rs index 0a73747..0c0dfcd 100644 --- a/src/analyzers/deepseek_harness.rs +++ b/src/analyzers/deepseek_harness.rs @@ -315,6 +315,7 @@ fn parse_session_reader(path: &Path, reader: R) -> Result(path: &Path, reader: R) -> Result Result> application: Application::Grok, date, project_hash: project_hash.clone(), + project_path: None, conversation_hash: conversation_hash.clone(), local_hash: Some(format!("{conversation_hash}:{line_index}")), global_hash: hash_text(&format!("{file_path}:{line_index}")), diff --git a/src/analyzers/kilo_code.rs b/src/analyzers/kilo_code.rs index f6f7733..e0c543a 100644 --- a/src/analyzers/kilo_code.rs +++ b/src/analyzers/kilo_code.rs @@ -184,6 +184,7 @@ fn parse_kilo_code_task_directory(task_dir: &Path) -> Result Result( application: Application::PiAgent, date: timestamp, project_hash: project_hash.to_string(), + project_path: None, conversation_hash: conversation_hash.to_string(), local_hash: Some(global_hash.clone()), global_hash, @@ -360,6 +361,7 @@ fn parse_jsonl_file( application: Application::PiAgent, date: timestamp, project_hash: project_hash.to_string(), + project_path: None, conversation_hash: conversation_hash.to_string(), local_hash: None, global_hash, diff --git a/src/analyzers/piebald.rs b/src/analyzers/piebald.rs index 6f83bed..1937c03 100644 --- a/src/analyzers/piebald.rs +++ b/src/analyzers/piebald.rs @@ -308,6 +308,7 @@ fn convert_messages( application: Application::Piebald, date, project_hash, + project_path: chat.project_directory.clone(), conversation_hash, local_hash: Some(msg.id.to_string()), global_hash, @@ -447,6 +448,7 @@ mod tests { let converted = convert_messages(&chats, messages, &tool_call_counts); assert_eq!(converted.len(), 1); + assert_eq!(converted[0].project_path.as_deref(), Some("/tmp/project")); assert_eq!(converted[0].stats.output_tokens, 1_000_000); assert_eq!(converted[0].stats.reasoning_tokens, 100_000); assert_eq!(converted[0].stats.cost, 38.0); diff --git a/src/analyzers/qwen_code.rs b/src/analyzers/qwen_code.rs index 00b07b6..deb67ac 100644 --- a/src/analyzers/qwen_code.rs +++ b/src/analyzers/qwen_code.rs @@ -353,6 +353,7 @@ pub fn parse_jsonl_session_file(file_path: &Path) -> Result Result bool { } // Pipelines or multiple commands chained - if lower.contains(" && ") || lower.contains(" || ") || lower.contains(" | ") { - return true; - } - - false + lower.contains(" && ") || lower.contains(" || ") || lower.contains(" | ") } // Helper function to extract model from environment details text @@ -217,6 +213,7 @@ fn parse_roo_code_task_directory( application: application.clone(), date, project_hash: project_hash.clone(), + project_path: None, conversation_hash: conversation_hash.clone(), local_hash: Some(local_hash), global_hash, @@ -267,6 +264,7 @@ fn parse_roo_code_task_directory( application: application.clone(), date, project_hash: project_hash.clone(), + project_path: None, conversation_hash: conversation_hash.clone(), local_hash: Some(local_hash), global_hash, diff --git a/src/analyzers/tests/claude_code.rs b/src/analyzers/tests/claude_code.rs index 932c7ef..acded65 100644 --- a/src/analyzers/tests/claude_code.rs +++ b/src/analyzers/tests/claude_code.rs @@ -86,6 +86,7 @@ fn test_deduplicate_messages_resolves_same_local_hash_across_uuids() { application: Application::ClaudeCode, date: Utc.with_ymd_and_hms(2025, 8, 2, 16, 0, 0).unwrap(), project_hash: "project".to_string(), + project_path: None, conversation_hash: "conversation".to_string(), local_hash: Some("shared-local-hash".to_string()), global_hash: "uuid-a".to_string(), @@ -125,6 +126,7 @@ fn test_streaming_snapshots_do_not_inflate_identical_fields() { application: Application::ClaudeCode, date: Utc.with_ymd_and_hms(2025, 8, 2, 16, 0, 0).unwrap(), project_hash: "project".to_string(), + project_path: None, conversation_hash: "conversation".to_string(), local_hash: Some("streaming-local-hash".to_string()), global_hash: "uuid-partial".to_string(), @@ -171,6 +173,11 @@ fn test_parse_jsonl_file_basic() { .unwrap(); assert_eq!(messages.len(), 4); + assert!( + messages + .iter() + .all(|message| message.project_path.as_deref() == Some(r"D:\splitrail")) + ); // Check first message (user message) assert_eq!(messages[0].role, MessageRole::User); @@ -451,6 +458,7 @@ fn test_deduplicate_messages_by_local_hash() { model: Some("test-model".to_string()), date: Utc.timestamp_opt(1609459200, 0).unwrap(), project_hash: "project1".to_string(), + project_path: None, conversation_hash: "conv1".to_string(), stats: Stats { input_tokens: 10, diff --git a/src/analyzers/tests/codex_cli.rs b/src/analyzers/tests/codex_cli.rs index b2010f7..5c8ea1c 100644 --- a/src/analyzers/tests/codex_cli.rs +++ b/src/analyzers/tests/codex_cli.rs @@ -199,6 +199,45 @@ fn test_parse_codex_cli_new_wrapper_format() { assert_eq!(assistant_msg.stats.output_tokens, 14); // Codex output_tokens already include reasoning assert_eq!(assistant_msg.stats.reasoning_tokens, 0); assert_eq!(assistant_msg.stats.cached_tokens, 2560); + assert_eq!(assistant_msg.project_path.as_deref(), Some("/home/test")); + assert_eq!( + assistant_msg.project_hash, + crate::utils::hash_text("https://github.com/test/repo.git") + ); + let serialized = simd_json::to_string(assistant_msg).unwrap(); + assert!(!serialized.contains("projectPath")); +} + +#[test] +fn test_codex_cli_turn_context_updates_project_path() { + let mut temp_file = NamedTempFile::new().unwrap(); + writeln!( + temp_file, + r#"{{"timestamp":"2025-09-18T00:00:00.000Z","type":"session_meta","payload":{{"id":"session","timestamp":"2025-09-18T00:00:00.000Z","cwd":"/home/test/old"}}}}"# + ) + .unwrap(); + writeln!( + temp_file, + r#"{{"timestamp":"2025-09-18T00:00:01.000Z","type":"turn_context","payload":{{"cwd":"/home/test/new/./project","model":"gpt-5-codex"}}}}"# + ) + .unwrap(); + writeln!( + temp_file, + r#"{{"timestamp":"2025-09-18T00:00:02.000Z","type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"Hello"}}]}}}}"# + ) + .unwrap(); + + let (messages, _) = parse_codex_cli_jsonl_file(temp_file.path()).unwrap(); + + assert_eq!(messages.len(), 1); + assert_eq!( + messages[0].project_path.as_deref(), + Some("/home/test/new/project") + ); + assert_eq!( + messages[0].project_hash, + crate::utils::hash_text("/home/test/new/project") + ); } #[test] diff --git a/src/analyzers/tests/copilot_cli.rs b/src/analyzers/tests/copilot_cli.rs index 520a8d8..1b89fea 100644 --- a/src/analyzers/tests/copilot_cli.rs +++ b/src/analyzers/tests/copilot_cli.rs @@ -84,6 +84,10 @@ fn test_parse_sample_copilot_cli_session() { assert_eq!(messages[0].model, None); assert_eq!(messages[0].stats.input_tokens, 0); assert_eq!(messages[0].stats.output_tokens, 0); + assert_eq!( + messages[0].project_path.as_deref(), + Some("/home/user/project") + ); assert_eq!(messages[1].role, MessageRole::Assistant); assert_eq!(messages[1].model.as_deref(), Some("gpt-4.1")); @@ -92,6 +96,10 @@ fn test_parse_sample_copilot_cli_session() { assert_eq!(messages[1].stats.terminal_commands, 1); assert!(messages[1].stats.input_tokens > 0); assert!(messages[1].stats.output_tokens > 0); + assert_eq!( + messages[1].project_path.as_deref(), + Some("/home/user/project") + ); assert_eq!( messages[1].session_name.as_deref(), Some("Add a health check endpoint") diff --git a/src/contribution_cache/mod.rs b/src/contribution_cache/mod.rs index 08e29b4..bea63eb 100644 --- a/src/contribution_cache/mod.rs +++ b/src/contribution_cache/mod.rs @@ -35,6 +35,13 @@ fn merge_daily_add( for &(model, count) in activity.models.iter() { daily.models.increment(model, count); } + for (model, stats) in &activity.model_stats { + daily + .model_stats + .entry(model.clone()) + .or_insert_with(|| crate::types::ModelStats::new(model.clone())) + .add_model_stats(stats); + } } } @@ -52,6 +59,12 @@ fn merge_daily_subtract( for &(model, count) in activity.models.iter() { daily.models.decrement(model, count); } + for (model, stats) in &activity.model_stats { + if let Some(existing) = daily.model_stats.get_mut(model) { + existing.sub_model_stats(stats); + } + } + daily.model_stats.retain(|_, stats| stats.message_count > 0); } } dst.retain(|_, activity| activity.message_count > 0); @@ -328,6 +341,19 @@ impl AnalyzerStatsView { }); day_stats.ai_messages += activity.ai_message_count; day_stats.stats += activity.stats; + for &(model, count) in activity.models.iter() { + *day_stats + .models + .entry(crate::types::resolve_model(model).to_string()) + .or_insert(0) += count; + } + for (model, stats) in &activity.model_stats { + day_stats + .model_stats + .entry(model.clone()) + .or_insert_with(|| crate::types::ModelStats::new(model.clone())) + .add_model_stats(stats); + } if *date != contrib.date { day_stats.conversations = day_stats.conversations.saturating_add(1); } @@ -337,6 +363,8 @@ impl AnalyzerStatsView { if let Some(existing) = self.session_aggregates.iter_mut().find(|s| { SingleMessageContribution::hash_session_id(&s.session_id) == contrib.session_hash }) { + existing.project_id = contrib.project_id.clone(); + existing.project_path = contrib.project_path.clone(); existing.stats += contrib.stats; for &(model, count) in contrib.models.iter() { existing.models.increment(model, count); @@ -355,6 +383,21 @@ impl AnalyzerStatsView { .ai_messages .saturating_sub(activity.ai_message_count); day_stats.stats -= activity.stats; + for &(model, count) in activity.models.iter() { + let model = crate::types::resolve_model(model); + if let Some(existing) = day_stats.models.get_mut(model) { + *existing = existing.saturating_sub(count); + } + } + day_stats.models.retain(|_, count| *count > 0); + for (model, stats) in &activity.model_stats { + if let Some(existing) = day_stats.model_stats.get_mut(model) { + existing.sub_model_stats(stats); + } + } + day_stats + .model_stats + .retain(|_, stats| stats.message_count > 0); if *date != contrib.date { day_stats.conversations = day_stats.conversations.saturating_sub(1); } diff --git a/src/contribution_cache/single_message.rs b/src/contribution_cache/single_message.rs index e36035b..0315d69 100644 --- a/src/contribution_cache/single_message.rs +++ b/src/contribution_cache/single_message.rs @@ -44,6 +44,7 @@ use crate::types::{CompactDate, ConversationMessage, TuiStats, intern_model}; /// - duration_ms: bits 165-175 (11 bits; reserved for future use) #[repr(C, align(1))] #[derive(BitfieldStruct, Clone, Copy, Default)] +#[allow(clippy::duplicated_attributes)] pub struct PackedStatsDate { #[bitfield(name = "input_tokens", ty = "u32", bits = "0..=26")] #[bitfield(name = "output_tokens", ty = "u32", bits = "27..=52")] diff --git a/src/contribution_cache/single_session.rs b/src/contribution_cache/single_session.rs index cfb1577..69d7c65 100644 --- a/src/contribution_cache/single_session.rs +++ b/src/contribution_cache/single_session.rs @@ -1,11 +1,12 @@ //! Single-session contribution type for 1-file-1-session analyzers. use std::collections::BTreeMap; +use std::sync::Arc; use super::SessionHash; use crate::types::{ - CompactDate, ConversationMessage, MessageRole, ModelCounts, SessionPeriodAggregate, TuiStats, - intern_model, + CompactDate, ConversationMessage, MessageRole, ModelCounts, ModelStats, SessionPeriodAggregate, + TuiStats, intern_model, }; // ============================================================================ @@ -23,6 +24,10 @@ pub struct SingleSessionContribution { pub date: CompactDate, /// Models used in this session with reference counts pub models: ModelCounts, + /// Stable local project identity used to group moved repositories and worktrees. + pub project_id: Option>, + /// Local project path used to keep incremental TUI updates in the right project scope. + pub project_path: Option>, /// Hash of conversation_hash for session lookup pub session_hash: SessionHash, /// Number of AI messages (for daily_stats.ai_messages) @@ -60,9 +65,13 @@ impl SingleSessionContribution { day.stats += message_stats; if let Some(model) = &msg.model { - let model = intern_model(model); - models.increment(model, 1); - day.models.increment(model, 1); + let model_key = intern_model(model); + models.increment(model_key, 1); + day.models.increment(model_key, 1); + day.model_stats + .entry(model.to_string()) + .or_insert_with(|| ModelStats::new(model.to_string())) + .add_message(&msg.stats); } } } @@ -71,6 +80,21 @@ impl SingleSessionContribution { stats, date: first_date, models, + project_id: messages + .iter() + .find_map(|message| { + (!message.project_hash.is_empty()).then_some(message.project_hash.as_str()) + }) + .or_else(|| { + messages + .iter() + .find_map(|message| message.project_path.as_deref()) + }) + .map(Arc::from), + project_path: messages + .iter() + .find_map(|message| message.project_path.as_deref()) + .map(Arc::from), session_hash, ai_message_count, daily, diff --git a/src/contribution_cache/tests/basic_operations.rs b/src/contribution_cache/tests/basic_operations.rs index 2ccbc41..b59d8d6 100644 --- a/src/contribution_cache/tests/basic_operations.rs +++ b/src/contribution_cache/tests/basic_operations.rs @@ -53,6 +53,8 @@ fn test_contribution_cache_single_session_insert_get() { stats: Default::default(), date: crate::types::CompactDate::from_str("2025-01-15").unwrap(), models: crate::types::ModelCounts::new(), + project_id: None, + project_path: None, session_hash: SessionHash::from_str("session1"), ai_message_count: 5, daily: Default::default(), @@ -105,6 +107,8 @@ fn test_contribution_cache_remove_any() { stats: Default::default(), date: Default::default(), models: crate::types::ModelCounts::new(), + project_id: None, + project_path: None, session_hash: SessionHash::from_str("s2"), ai_message_count: 0, daily: Default::default(), diff --git a/src/contribution_cache/tests/mod.rs b/src/contribution_cache/tests/mod.rs index c1b4554..9723648 100644 --- a/src/contribution_cache/tests/mod.rs +++ b/src/contribution_cache/tests/mod.rs @@ -47,6 +47,7 @@ pub fn make_message( application: Application::ClaudeCode, date, project_hash: "test_project".into(), + project_path: None, conversation_hash: session_id.into(), local_hash: Some(format!("local_{}", session_id)), global_hash: format!("global_{}_{}", session_id, input_tokens), @@ -89,6 +90,8 @@ pub fn make_view_with_session(analyzer_name: &str, session_id: &str) -> Analyzer analyzer_name: Arc::clone(&analyzer_name), stats: TuiStats::default(), models: crate::types::ModelCounts::new(), + project_id: None, + project_path: None, session_name: Some(format!("Session {}", session_id)), date: CompactDate::from_str("2025-01-01").unwrap(), daily: BTreeMap::new(), diff --git a/src/tui.rs b/src/tui.rs index ee2cb85..91389bf 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -32,8 +32,9 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, Cell, Paragraph, Row, Table, TableState, Tabs}; use ratatui::{Frame, Terminal}; -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::io::{Write, stdout}; +use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -271,6 +272,8 @@ fn sessions_for_period( analyzer_name: Arc::clone(&session.analyzer_name), stats: TuiStats::default(), models: ModelCounts::new(), + project_id: session.project_id.clone(), + project_path: session.project_path.clone(), session_name: session.session_name.clone(), date: session.date, daily: BTreeMap::new(), @@ -470,6 +473,248 @@ fn filter_stats_by_model( .collect() } +#[derive(Debug, Clone, Default)] +struct ProjectSummary { + ids: BTreeSet, + path: String, + paths: BTreeSet, + name: String, + stats: TuiStats, + sessions: u64, + tools: BTreeMap, + models: BTreeMap, +} + +impl ProjectSummary { + fn primary_id(&self) -> &str { + self.ids + .iter() + .next() + .map(String::as_str) + .unwrap_or_default() + } + + fn includes_session(&self, session: &SessionAggregate) -> bool { + session + .project_id + .as_deref() + .is_some_and(|id| self.ids.contains(id)) + || session + .project_path + .as_deref() + .is_some_and(|path| self.paths.contains(path)) + } + + fn merge(&mut self, other: Self, path_exists: &mut HashMap) { + self.ids.extend(other.ids); + self.paths.extend(other.paths); + self.stats += other.stats; + self.sessions = self.sessions.saturating_add(other.sessions); + for (tool, count) in other.tools { + *self.tools.entry(tool).or_insert(0) += count; + } + for (model, count) in other.models { + *self.models.entry(model).or_insert(0) += count; + } + self.prefer_path(&other.path, path_exists); + } + + fn prefer_path(&mut self, candidate: &str, path_exists: &mut HashMap) { + let current_exists = *path_exists + .entry(self.path.clone()) + .or_insert_with(|| Path::new(&self.path).exists()); + let candidate_exists = *path_exists + .entry(candidate.to_string()) + .or_insert_with(|| Path::new(candidate).exists()); + if (candidate_exists && !current_exists) + || (candidate_exists == current_exists && candidate < self.path.as_str()) + { + self.path = candidate.to_string(); + self.name = project_display_name(candidate); + } + } + + fn total_tokens(&self) -> u64 { + self.stats + .input_tokens + .saturating_add(self.stats.output_tokens) + .saturating_add(self.stats.cached_tokens) + } + + fn matches(&self, filter: &str) -> bool { + let filter = filter.trim().to_lowercase(); + filter.is_empty() + || self.name.to_lowercase().contains(&filter) + || self.path.to_lowercase().contains(&filter) + || self + .paths + .iter() + .any(|path| path.to_lowercase().contains(&filter)) + || self + .tools + .keys() + .any(|tool| tool.to_lowercase().contains(&filter)) + || self + .models + .keys() + .any(|model| model.to_lowercase().contains(&filter)) + } +} + +fn project_display_name(path: &str) -> String { + let path = Path::new(path); + let name = path + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| path.to_str().unwrap_or_default()); + path.parent() + .and_then(Path::file_name) + .and_then(|parent| parent.to_str()) + .filter(|parent| !parent.is_empty()) + .map(|parent| format!("{parent}/{name}")) + .unwrap_or_else(|| name.to_string()) +} + +fn collect_project_summaries(stats: &[SharedAnalyzerView]) -> Vec { + let mut projects: Vec = Vec::new(); + let mut path_exists = HashMap::new(); + + for stats in stats { + let view = stats.read(); + for session in &view.session_aggregates { + let Some(path) = session.project_path.as_deref() else { + continue; + }; + let project_id = session.project_id.as_deref().unwrap_or(path); + let mut matching: Vec<_> = projects + .iter() + .enumerate() + .filter_map(|(index, project)| { + (project.ids.contains(project_id) || project.paths.contains(path)) + .then_some(index) + }) + .collect(); + let mut project = ProjectSummary { + path: path.to_string(), + name: project_display_name(path), + ..Default::default() + }; + for index in matching.drain(..).rev() { + project.merge(projects.remove(index), &mut path_exists); + } + project.ids.insert(project_id.to_string()); + project.paths.insert(path.to_string()); + project.prefer_path(path, &mut path_exists); + project.stats += session.stats; + project.sessions = project.sessions.saturating_add(1); + *project + .tools + .entry(view.analyzer_name.to_string()) + .or_insert(0) += 1; + for &(model, count) in session.models.iter() { + *project + .models + .entry(resolve_model(model).to_string()) + .or_insert(0) += count; + } + projects.push(project); + } + } + + let mut display_name_counts = BTreeMap::new(); + for project in &projects { + *display_name_counts + .entry(project.name.clone()) + .or_insert(0u32) += 1; + } + for project in &mut projects { + if display_name_counts.get(&project.name).copied().unwrap_or(0) > 1 { + let hash = crate::utils::hash_text(&project.path); + project.name = format!("{} [{}]", project.name, &hash[..6]); + } + } + projects.sort_by(|left, right| { + right + .total_tokens() + .cmp(&left.total_tokens()) + .then_with(|| left.name.cmp(&right.name)) + .then_with(|| left.path.cmp(&right.path)) + }); + projects +} + +fn filter_analyzer_view_by_project( + view: &AnalyzerStatsView, + project: &ProjectSummary, +) -> AnalyzerStatsView { + let session_aggregates: Vec<_> = view + .session_aggregates + .iter() + .filter(|session| project.includes_session(session)) + .cloned() + .collect(); + let mut daily_stats: BTreeMap = BTreeMap::new(); + + for session in &session_aggregates { + for (date, activity) in &session.daily { + let day = daily_stats + .entry(date.to_string()) + .or_insert_with(|| DailyStats { + date: *date, + ..Default::default() + }); + day.user_messages = day.user_messages.saturating_add( + activity + .message_count + .saturating_sub(activity.ai_message_count), + ); + day.ai_messages = day.ai_messages.saturating_add(activity.ai_message_count); + day.stats += activity.stats; + if *date == session.date { + day.conversations = day.conversations.saturating_add(1); + } + for &(model, count) in activity.models.iter() { + *day.models + .entry(resolve_model(model).to_string()) + .or_insert(0) += count; + } + for (model, stats) in &activity.model_stats { + day.model_stats + .entry(model.clone()) + .or_insert_with(|| ModelStats::new(model.clone())) + .add_model_stats(stats); + } + } + } + + AnalyzerStatsView { + daily_stats, + num_conversations: session_aggregates.len() as u64, + session_aggregates, + analyzer_name: Arc::clone(&view.analyzer_name), + } +} + +fn apply_stats_filters( + stats: &[SharedAnalyzerView], + project: Option<&ProjectSummary>, + model_filter: &str, +) -> Vec { + let project_filtered = match project { + Some(project) => stats + .iter() + .filter_map(|stats| { + let filtered = filter_analyzer_view_by_project(&stats.read(), project); + (!filtered.session_aggregates.is_empty()) + .then(|| Arc::new(parking_lot::RwLock::new(filtered))) + }) + .collect(), + None => stats.to_vec(), + }; + filter_stats_by_model(&project_filtered, model_filter) +} + fn clamp_table_selection(table_state: &mut TableState, total_rows: usize) { if total_rows == 0 { table_state.select(None); @@ -576,6 +821,7 @@ struct UiState<'a> { date_jump_buffer: &'a str, model_filter_active: bool, model_filter: &'a str, + project_name: Option<&'a str>, sort_reversed: bool, hide_empty_periods: bool, show_totals: bool, @@ -586,6 +832,15 @@ struct UiState<'a> { show_header: bool, } +struct ProjectBrowserUiState<'a> { + table_state: &'a mut TableState, + filter: &'a str, + filter_active: bool, + accent: Color, + show_header: bool, + quit_pending: bool, +} + /// Build the tab data shown in the TUI, prepending a synthetic "All Tools" /// view ahead of the individual analyzer tabs. pub(crate) fn build_display_stats( @@ -735,6 +990,13 @@ 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 project_browser_active = false; + let mut project_filter_active = false; + let mut project_filter = String::new(); + let mut project_filter_before_edit = String::new(); + let mut selected_project: Option = None; + let mut project_table_state = TableState::default(); + project_table_state.select(Some(0)); let mut sort_reversed = tui_config.reverse_sort_default; let mut hide_empty_periods = tui_config.hide_empty_periods; let mut show_totals = true; @@ -780,7 +1042,14 @@ async fn run_app( .filter(|stats| has_data_shared(stats)) .cloned() .collect(); - let mut filtered_stats = filter_stats_by_model(&available_stats, &model_filter); + let mut project_summaries = collect_project_summaries(&available_stats); + let mut visible_projects: Vec = project_summaries + .iter() + .filter(|project| project.matches(&project_filter)) + .cloned() + .collect(); + let mut filtered_stats = + apply_stats_filters(&available_stats, selected_project.as_ref(), &model_filter); let mut display_stats = build_display_stats(&filtered_stats); // Open on the configured default tab (matched by tool name; empty or @@ -817,7 +1086,15 @@ async fn run_app( .filter(|stats| has_data_shared(stats)) .cloned() .collect(); - filtered_stats = filter_stats_by_model(&available_stats, &model_filter); + project_summaries = collect_project_summaries(&available_stats); + visible_projects = project_summaries + .iter() + .filter(|project| project.matches(&project_filter)) + .cloned() + .collect(); + clamp_table_selection(&mut project_table_state, visible_projects.len()); + filtered_stats = + apply_stats_filters(&available_stats, selected_project.as_ref(), &model_filter); display_stats = build_display_stats(&filtered_stats); update_table_states(&mut table_states, ¤t_stats, selected_tab); update_window_offsets(&mut session_window_offsets, &table_states.len()); @@ -862,6 +1139,26 @@ async fn run_app( // Only redraw if something has changed if needs_redraw { terminal.draw(|frame| { + if project_browser_active { + let mut project_ui_state = ProjectBrowserUiState { + table_state: &mut project_table_state, + filter: &project_filter, + filter_active: project_filter_active, + accent, + show_header, + quit_pending, + }; + draw_project_browser( + frame, + &visible_projects, + format_options, + &mut project_ui_state, + ); + return; + } + let selected_project_name = selected_project + .as_ref() + .map(|project| project.name.as_str()); let mut ui_state = UiState { table_states: &mut table_states, _scroll_offset: *scroll_offset, @@ -874,6 +1171,7 @@ async fn run_app( date_jump_buffer: &date_jump_buffer, model_filter_active, model_filter: &model_filter, + project_name: selected_project_name, sort_reversed, hide_empty_periods, show_totals, @@ -914,7 +1212,10 @@ async fn run_app( // Handle quitting. Esc is intentionally *not* a quit key; it acts as // a context-aware "go back"/cancel below. - if !model_filter_active && matches!(key.code, KeyCode::Char('q')) { + if !model_filter_active + && !project_filter_active + && matches!(key.code, KeyCode::Char('q')) + { if tui_config.confirm_quit && !quit_pending { quit_pending = true; needs_redraw = true; @@ -929,7 +1230,10 @@ async fn run_app( } // Handle update notification dismissal - if !model_filter_active && matches!(key.code, KeyCode::Char('u')) { + if !model_filter_active + && !project_browser_active + && matches!(key.code, KeyCode::Char('u')) + { let mut status = update_status.lock(); if matches!( *status, @@ -940,6 +1244,113 @@ async fn run_app( } } + if project_filter_active { + let mut filter_changed = false; + match key.code { + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + project_filter.clear(); + filter_changed = true; + } + KeyCode::Char(c) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + project_filter.push(c); + filter_changed = true; + } + KeyCode::Backspace => { + project_filter.pop(); + filter_changed = true; + } + KeyCode::Enter => { + project_filter = project_filter.trim().to_string(); + project_filter_active = false; + filter_changed = true; + } + KeyCode::Esc => { + project_filter.clone_from(&project_filter_before_edit); + project_filter_active = false; + filter_changed = true; + } + _ => {} + } + + if filter_changed { + visible_projects = project_summaries + .iter() + .filter(|project| project.matches(&project_filter)) + .cloned() + .collect(); + clamp_table_selection(&mut project_table_state, visible_projects.len()); + } + needs_redraw = true; + continue; + } + + if project_browser_active { + match key.code { + KeyCode::Char('/') => { + project_filter_before_edit.clone_from(&project_filter); + project_filter_active = true; + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + project_filter.clear(); + visible_projects = project_summaries.clone(); + clamp_table_selection(&mut project_table_state, visible_projects.len()); + } + KeyCode::Down | KeyCode::Char('j') => { + let selected = project_table_state.selected().unwrap_or(0); + if selected + 1 < visible_projects.len() { + project_table_state.select(Some(selected + 1)); + } + } + KeyCode::Up | KeyCode::Char('k') => { + let selected = project_table_state.selected().unwrap_or(0); + project_table_state.select(Some(selected.saturating_sub(1))); + } + KeyCode::Home => project_table_state.select(Some(0)), + KeyCode::End => { + project_table_state.select(visible_projects.len().checked_sub(1)) + } + KeyCode::Enter => { + if let Some(project) = project_table_state + .selected() + .and_then(|index| visible_projects.get(index)) + { + selected_project = Some(project.clone()); + filtered_stats = apply_stats_filters( + &available_stats, + selected_project.as_ref(), + &model_filter, + ); + display_stats = build_display_stats(&filtered_stats); + *selected_tab = 0; + *stats_view_mode = StatsViewMode::Aggregate; + table_states + .iter_mut() + .for_each(|state| state.select(Some(0))); + session_window_offsets.fill(0); + session_period_filters.fill(None); + project_browser_active = false; + } + } + KeyCode::Char('p') => project_browser_active = false, + KeyCode::Esc => { + project_browser_active = false; + if selected_project.take().is_some() { + filtered_stats = + apply_stats_filters(&available_stats, None, &model_filter); + display_stats = build_display_stats(&filtered_stats); + *selected_tab = 0; + } + } + _ => {} + } + needs_redraw = true; + continue; + } + if model_filter_active { let mut filter_changed = false; match key.code { @@ -973,7 +1384,11 @@ async fn run_app( } if filter_changed { - filtered_stats = filter_stats_by_model(&available_stats, &model_filter); + filtered_stats = apply_stats_filters( + &available_stats, + selected_project.as_ref(), + &model_filter, + ); display_stats = build_display_stats(&filtered_stats); session_window_offsets.fill(0); } @@ -981,6 +1396,19 @@ async fn run_app( continue; } + if matches!(key.code, KeyCode::Char('p')) { + project_browser_active = true; + let selected_index = selected_project.as_ref().and_then(|selected| { + visible_projects + .iter() + .position(|project| project.primary_id() == selected.primary_id()) + }); + project_table_state + .select(selected_index.or_else(|| (!visible_projects.is_empty()).then_some(0))); + needs_redraw = true; + continue; + } + // Only handle navigation keys if we have data (`display_stats` is non-empty). if display_stats.is_empty() { continue; @@ -1363,6 +1791,9 @@ async fn run_app( date_jump_active = false; date_jump_buffer.clear(); needs_redraw = true; + } else if selected_project.is_some() { + project_browser_active = true; + needs_redraw = true; } } KeyCode::Enter => { @@ -1633,11 +2064,11 @@ fn draw_ui( }; format!( - "Use ←/→ or h/l to switch tabs • ↑/↓ or j/k to navigate • f to filter models • r to reverse sort • e to toggle empty periods • s to toggle summary • / for {jump_label} • m to cycle 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 • r to reverse sort • e to toggle empty periods • s to toggle summary • / for {jump_label} • m to cycle day/week/month/year • Enter to drill into period • Ctrl+T for all sessions • q to quit" ) } StatsViewMode::Session => { - "Use ←/→ or h/l to switch tabs • ↑/↓ or j/k to navigate • f to filter models • r to reverse sort • e to toggle empty periods • s to toggle summary • m to cycle day/week/month/year • Esc or Ctrl+T for aggregate view • q to quit".to_string() + "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 • m to cycle day/week/month/year • Esc or Ctrl+T for aggregate view • q to quit".to_string() } }; @@ -1664,6 +2095,12 @@ fn draw_ui( base_help_text }; + let help_text = if let Some(project_name) = ui_state.project_name { + format!("Project: {project_name} • {help_text}") + } else { + help_text + }; + let help_style = if ui_state.quit_pending { Style::default() .fg(Color::Yellow) @@ -1746,6 +2183,115 @@ fn draw_ui( } } +fn draw_project_browser( + frame: &mut Frame, + projects: &[ProjectSummary], + format_options: &NumberFormatOptions, + ui_state: &mut ProjectBrowserUiState, +) { + let chunks = Layout::vertical([ + Constraint::Length(if ui_state.show_header { 3 } else { 0 }), + Constraint::Min(3), + Constraint::Length(2), + ]) + .split(frame.area()); + + if ui_state.show_header { + frame.render_widget( + Paragraph::new(Text::from(vec![ + Line::styled( + "PROJECT ACTIVITY", + Style::default() + .fg(ui_state.accent) + .add_modifier(Modifier::BOLD), + ), + Line::styled( + "================", + Style::default() + .fg(ui_state.accent) + .add_modifier(Modifier::BOLD), + ), + ])), + chunks[0], + ); + } + + if projects.is_empty() { + frame.render_widget( + Paragraph::new("No matching projects found") + .style(Style::default().add_modifier(Modifier::DIM)), + chunks[1], + ); + } else { + let rows = projects.iter().map(|project| { + let tools = project.tools.keys().cloned().collect::>().join(", "); + let models = project + .models + .keys() + .cloned() + .collect::>() + .join(", "); + Row::new(vec![ + Cell::new(project.name.clone()), + Cell::new(format_number_fit( + project.total_tokens(), + format_options, + 11, + )), + Cell::new(format!( + "{}{:.prec$}", + format_options.currency_symbol, + project.stats.cost(), + prec = format_options.cost_decimal_places + )), + Cell::new(format_number(project.sessions, format_options)), + Cell::new(tools), + Cell::new(models), + ]) + }); + let header = Row::new(["Project", "Tokens", "Cost", "Sessions", "Tools", "Models"]) + .style(Style::default().add_modifier(Modifier::BOLD)); + let table = Table::new( + rows, + [ + Constraint::Length(24), + Constraint::Length(11), + Constraint::Length(9), + Constraint::Length(8), + Constraint::Length(12), + Constraint::Min(12), + ], + ) + .header(header) + .highlight_symbol("→ ") + .row_highlight_style(Style::default().fg(Color::Black).bg(ui_state.accent)); + frame.render_stateful_widget(table, chunks[1], ui_state.table_state); + } + + let help = if ui_state.filter_active { + format!( + "Project filter: {}_ • Enter to apply • Esc to cancel", + ui_state.filter + ) + } else if ui_state.quit_pending { + "Quit splitrail? Press q again to confirm • any other key to cancel".to_string() + } else if ui_state.filter.is_empty() { + "↑/↓ or j/k to navigate • Enter to open project • / to filter • Esc to return • q to quit" + .to_string() + } else { + format!( + "Project filter: {} • ↑/↓ or j/k to navigate • Enter to open • / to edit • Ctrl+U to clear • Esc to return", + ui_state.filter + ) + }; + frame.render_widget( + Paragraph::new(help) + .style(Style::default().add_modifier(Modifier::DIM)) + .wrap(ratatui::widgets::Wrap { trim: true }), + chunks[2], + ); +} + #[allow(clippy::too_many_arguments)] /// Parse the configured accent color name into a ratatui Color. fn parse_accent(s: &str) -> Color { diff --git a/src/tui/logic.rs b/src/tui/logic.rs index 18c0d13..d9353b7 100644 --- a/src/tui/logic.rs +++ b/src/tui/logic.rs @@ -401,20 +401,22 @@ pub fn aggregate_sessions_from_messages( messages: &[ConversationMessage], analyzer_name: Arc, ) -> Vec { - let mut sessions: BTreeMap = BTreeMap::new(); + let mut sessions: BTreeMap<(String, Option), SessionAggregate> = BTreeMap::new(); for msg in messages { - // Use or_insert_with_key to avoid redundant cloning: - // - Pass owned key to entry() (1 clone of conversation_hash) - // - Clone key only when inserting a new session (via closure's &key) + let project_id = (!msg.project_hash.is_empty()) + .then(|| msg.project_hash.clone()) + .or_else(|| msg.project_path.clone()); let entry = sessions - .entry(msg.conversation_hash.clone()) - .or_insert_with_key(|key| SessionAggregate { - session_id: key.clone(), + .entry((msg.conversation_hash.clone(), project_id.clone())) + .or_insert_with(|| SessionAggregate { + session_id: msg.conversation_hash.clone(), first_timestamp: msg.date, analyzer_name: Arc::clone(&analyzer_name), stats: TuiStats::default(), models: ModelCounts::new(), + project_id: project_id.as_deref().map(Arc::from), + project_path: msg.project_path.as_deref().map(Arc::from), session_name: None, date: CompactDate::from_local(&msg.date), daily: BTreeMap::new(), @@ -436,9 +438,14 @@ pub fn aggregate_sessions_from_messages( accumulate_tui_stats(&mut daily.stats, &msg.stats); if let Some(model) = &msg.model { - let model = intern_model(model); - entry.models.increment(model, 1); - daily.models.increment(model, 1); + let model_key = intern_model(model); + entry.models.increment(model_key, 1); + daily.models.increment(model_key, 1); + daily + .model_stats + .entry(model.to_string()) + .or_insert_with(|| crate::types::ModelStats::new(model.to_string())) + .add_message(&msg.stats); } } diff --git a/src/tui/tests.rs b/src/tui/tests.rs index 0f6fea9..97d4353 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -5,12 +5,12 @@ use crate::tui::logic::{ filtered_aggregate_keys, }; use crate::tui::{ - AggregateViewMode, PeriodFilter, build_display_stats, cost_heat, + AggregateViewMode, PeriodFilter, build_display_stats, collect_project_summaries, cost_heat, create_upload_progress_callback, draw_aggregate_stats_table, filter_analyzer_view_by_model, - 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, + 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, }; use crate::types::{ AgenticCodingToolStats, AnalyzerStatsView, Application, CompactDate, ConversationMessage, @@ -32,6 +32,7 @@ fn session_detail_includes_sessions_active_after_their_start_date() { application: Application::CodexCli, date: Utc.with_ymd_and_hms(2026, 7, day, 12, 0, 0).unwrap(), project_hash: String::new(), + project_path: None, conversation_hash: "continued-session".into(), local_hash: None, global_hash: format!("message-{day}"), @@ -69,6 +70,46 @@ fn session_detail_includes_sessions_active_after_their_start_date() { assert_eq!(filtered[0].stats.input_tokens, 20); } +#[test] +fn aggregate_sessions_splits_conversation_when_project_changes() { + let make_message = |project_hash: &str, project_path: &str, input_tokens| ConversationMessage { + application: Application::CodexCli, + date: Utc.with_ymd_and_hms(2026, 8, 26, 12, 0, 0).unwrap(), + project_hash: project_hash.to_string(), + project_path: Some(project_path.to_string()), + conversation_hash: "shared-conversation".into(), + local_hash: None, + global_hash: format!("{project_hash}-message"), + model: Some("gpt-5.6-sol".into()), + stats: Stats { + input_tokens, + ..Default::default() + }, + role: MessageRole::Assistant, + uuid: None, + session_name: None, + }; + let messages = [ + make_message("project-a", "/work/a", 10), + make_message("project-b", "/work/b", 20), + ]; + + let sessions = aggregate_sessions_from_messages(&messages, Arc::from("Codex CLI")); + + assert_eq!(sessions.len(), 2); + assert!( + sessions + .iter() + .all(|session| session.session_id == "shared-conversation") + ); + assert!(sessions.iter().any(|session| { + session.project_id.as_deref() == Some("project-a") && session.stats.input_tokens == 10 + })); + assert!(sessions.iter().any(|session| { + session.project_id.as_deref() == Some("project-b") && session.stats.input_tokens == 20 + })); +} + // ============================================================================ // CONFIG-DRIVEN APPEARANCE TESTS // ============================================================================ @@ -966,6 +1007,110 @@ fn test_date_filter_exclusions() { assert!(!date_matches_buffer("2025-12-31", "2024")); } +#[test] +fn project_summary_and_filter_merge_tools_by_path() { + let make_message = |session: &str, + project_id: &str, + project: &str, + model: &str, + input_tokens: u64| + -> ConversationMessage { + ConversationMessage { + application: Application::CodexCli, + date: Utc.with_ymd_and_hms(2026, 8, 26, 12, 0, 0).unwrap(), + project_hash: project_id.to_string(), + project_path: Some(project.to_string()), + conversation_hash: session.to_string(), + local_hash: None, + global_hash: format!("{session}-{model}"), + model: Some(model.to_string()), + stats: Stats { + input_tokens, + output_tokens: 10, + reasoning_tokens: 5, + cost: 0.25, + ..Default::default() + }, + role: MessageRole::Assistant, + uuid: None, + session_name: None, + } + }; + let messages = vec![ + make_message( + "splitrail-1", + "splitrail-repository", + "/old/work/splitrail", + "gpt-5", + 100, + ), + make_message( + "splitrail-2", + "splitrail-repository", + "/work/splitrail", + "gpt-5.6", + 200, + ), + make_message( + "homelab-1", + "homelab-repository", + "/work/homelab", + "gpt-5", + 50, + ), + ]; + let sessions = aggregate_sessions_from_messages(&messages, Arc::from("Codex CLI")); + let view = AnalyzerStatsView { + daily_stats: BTreeMap::new(), + session_aggregates: sessions, + num_conversations: 3, + analyzer_name: Arc::from("Codex CLI"), + }; + let shared = Arc::new(parking_lot::RwLock::new(view.clone())); + let claude_messages = vec![make_message( + "splitrail-claude", + "claude-project-directory", + "/work/splitrail", + "claude-sonnet-4", + 75, + )]; + let claude_view = AnalyzerStatsView { + daily_stats: BTreeMap::new(), + session_aggregates: aggregate_sessions_from_messages( + &claude_messages, + Arc::from("Claude Code"), + ), + num_conversations: 1, + analyzer_name: Arc::from("Claude Code"), + }; + + let projects = + collect_project_summaries(&[shared, Arc::new(parking_lot::RwLock::new(claude_view))]); + assert_eq!(projects.len(), 2, "{projects:#?}"); + assert_eq!(projects[0].name, "work/splitrail"); + assert_eq!(projects[0].sessions, 3); + assert_eq!(projects[0].total_tokens(), 405); + assert_eq!( + projects[0].tools.keys().collect::>(), + vec!["Claude Code", "Codex CLI"] + ); + assert_eq!(projects[0].models.len(), 3); + + let splitrail = projects + .iter() + .find(|project| project.ids.contains("splitrail-repository")) + .unwrap(); + let filtered = filter_analyzer_view_by_project(&view, splitrail); + assert_eq!(filtered.num_conversations, 2); + assert_eq!(filtered.session_aggregates.len(), 2); + let day = filtered.daily_stats.get("2026-08-26").unwrap(); + assert_eq!(day.stats.input_tokens, 300); + assert_eq!(day.stats.output_tokens, 20); + assert_eq!(day.conversations, 2); + assert_eq!(day.model_stats["gpt-5"].input_tokens, 100); + assert_eq!(day.model_stats["gpt-5.6"].input_tokens, 200); +} + #[test] fn model_filter_recalculates_stats_and_sessions() { let date = CompactDate::from_str("2025-01-01").unwrap(); @@ -1024,6 +1169,8 @@ fn model_filter_recalculates_stats_and_sessions() { models.increment(intern_model(model), 1); models }, + project_id: None, + project_path: None, session_name: None, date, daily: BTreeMap::new(), @@ -1040,6 +1187,8 @@ fn model_filter_recalculates_stats_and_sessions() { analyzer_name: Arc::from("Test"), stats: TuiStats::default(), models: multi_models.clone(), + project_id: None, + project_path: None, session_name: None, date, daily: BTreeMap::from([( diff --git a/src/types.rs b/src/types.rs index 10e7229..8a21f83 100644 --- a/src/types.rs +++ b/src/types.rs @@ -195,6 +195,7 @@ impl ModelCounts { pub struct SessionPeriodAggregate { pub stats: TuiStats, pub models: ModelCounts, + pub model_stats: BTreeMap, pub message_count: u32, pub ai_message_count: u32, } @@ -209,6 +210,10 @@ pub struct SessionAggregate { /// Reference-counted model names for correct incremental update subtraction. /// Inline storage for up to 3 models; interned keys are 4 bytes each. pub models: ModelCounts, + /// Stable local project identity used to group moved repositories and worktrees. + pub project_id: Option>, + /// Local project path used for TUI project browsing. Never uploaded. + pub project_path: Option>, pub session_name: Option, pub date: CompactDate, /// Per-day activity used when drilling into a day, week, month, or year. @@ -252,6 +257,9 @@ pub struct ConversationMessage { #[serde(rename = "date")] pub date: DateTime, pub project_hash: String, + /// Local project path used for TUI project browsing. Never serialized or uploaded. + #[serde(skip)] + pub project_path: Option, pub conversation_hash: String, /// The hash of this message, local to the application that we're gathering data from. E.g., /// in the Claude Code analyzer, this will be set to the message's hash within Claude Code. @@ -819,6 +827,7 @@ mod tests { application: Application::ClaudeCode, date: Utc.from_utc_datetime(&date), project_hash: "proj".into(), + project_path: None, conversation_hash: conv_hash.into(), local_hash: None, global_hash: format!("global_{}", conv_hash), @@ -940,6 +949,8 @@ mod tests { analyzer_name: Arc::from("Test"), stats: TuiStats::default(), models: ModelCounts::from_single(intern_model(model), count), + project_id: None, + project_path: None, session_name: None, date: CompactDate::default(), daily: BTreeMap::new(), diff --git a/src/upload/tests.rs b/src/upload/tests.rs index bba04d3..91c8620 100644 --- a/src/upload/tests.rs +++ b/src/upload/tests.rs @@ -31,6 +31,7 @@ fn make_test_message(conversation_hash: &str) -> ConversationMessage { application: Application::ClaudeCode, date: Utc::now(), project_hash: "project".to_string(), + project_path: None, conversation_hash: conversation_hash.to_string(), local_hash: None, global_hash: format!("global-{conversation_hash}"), diff --git a/src/utils/tests.rs b/src/utils/tests.rs index 1c47034..21c3e64 100644 --- a/src/utils/tests.rs +++ b/src/utils/tests.rs @@ -273,6 +273,7 @@ async fn test_get_messages_later_than() { date: date_before, application: crate::types::Application::ClaudeCode, project_hash: "p".to_string(), + project_path: None, conversation_hash: "c1".to_string(), local_hash: None, global_hash: "g1".to_string(), @@ -310,6 +311,7 @@ fn test_aggregate_by_date_basic() { date, application: crate::types::Application::ClaudeCode, project_hash: "p".to_string(), + project_path: None, conversation_hash: "c1".to_string(), local_hash: None, global_hash: "g1".to_string(), @@ -341,6 +343,7 @@ fn test_aggregate_by_date_preserves_subcent_costs() { date, application: crate::types::Application::ClaudeCode, project_hash: "p".to_string(), + project_path: None, conversation_hash: "c1".to_string(), local_hash: None, global_hash: global_hash.to_string(), @@ -375,6 +378,7 @@ fn test_aggregate_by_date_gap_filling() { date: date1, application: crate::types::Application::ClaudeCode, project_hash: "p".to_string(), + project_path: None, conversation_hash: "c1".to_string(), local_hash: None, global_hash: "g1".to_string(), @@ -428,6 +432,7 @@ fn test_aggregate_by_date_counts_assistant_without_model_as_ai_message() { date, application: crate::types::Application::CopilotCli, project_hash: "p".to_string(), + project_path: None, conversation_hash: "c1".to_string(), local_hash: None, global_hash: "g1".to_string(), @@ -467,6 +472,7 @@ fn test_filter_zero_cost_messages_all_zero_cost() { date, application: crate::types::Application::ClaudeCode, project_hash: "p".to_string(), + project_path: None, conversation_hash: "c1".to_string(), local_hash: None, global_hash: "g1".to_string(), @@ -500,6 +506,7 @@ fn test_filter_zero_cost_messages_no_zero_cost() { date, application: crate::types::Application::ClaudeCode, project_hash: "p".to_string(), + project_path: None, conversation_hash: "c1".to_string(), local_hash: None, global_hash: "g1".to_string(), @@ -537,6 +544,7 @@ fn test_filter_zero_cost_messages_mixed() { date, application: crate::types::Application::ClaudeCode, project_hash: "p".to_string(), + project_path: None, conversation_hash: "c_zero".to_string(), local_hash: None, global_hash: "g_zero".to_string(), @@ -591,6 +599,7 @@ fn test_filter_zero_cost_messages_near_zero() { date, application: crate::types::Application::ClaudeCode, project_hash: "p".to_string(), + project_path: None, conversation_hash: "c_small".to_string(), local_hash: None, global_hash: "g_small".to_string(), @@ -644,6 +653,7 @@ fn test_filter_zero_cost_messages_negative_cost() { date, application: crate::types::Application::ClaudeCode, project_hash: "p".to_string(), + project_path: None, conversation_hash: "c_neg_small".to_string(), local_hash: None, global_hash: "g_neg_small".to_string(), @@ -687,6 +697,7 @@ fn test_deduplicate_by_global_hash() { date, application: crate::types::Application::ClaudeCode, project_hash: "p".to_string(), + project_path: None, conversation_hash: "c1".to_string(), local_hash: None, global_hash: "same_hash".to_string(), // Same hash @@ -728,6 +739,7 @@ fn test_deduplicate_by_local_hash() { date, application: crate::types::Application::ClaudeCode, project_hash: "p".to_string(), + project_path: None, conversation_hash: "c1".to_string(), local_hash: Some("local_same".to_string()), // Same local hash global_hash: "g1".to_string(), @@ -765,6 +777,7 @@ fn test_deduplicate_keeps_messages_without_local_hash() { date, application: crate::types::Application::ClaudeCode, project_hash: "p".to_string(), + project_path: None, conversation_hash: "c1".to_string(), local_hash: Some("local_hash".to_string()), global_hash: "g1".to_string(), diff --git a/src/watcher.rs b/src/watcher.rs index 772ab23..35fe3c8 100644 --- a/src/watcher.rs +++ b/src/watcher.rs @@ -361,6 +361,7 @@ mod tests { application: Application::ClaudeCode, date, project_hash: "proj".into(), + project_path: None, conversation_hash: "conv".into(), local_hash: None, global_hash: "global".into(),