From 1b985d45a7b36d99b51b171d19a6e48406857071 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 09:21:50 -0400 Subject: [PATCH 001/100] feat: add git-ai activity command for local AI usage statistics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `git-ai activity` CLI command that shows aggregated stats from locally persisted metric events (Committed, Checkpoint, SessionEvent). - Schema migration 2→3: adds local_events table (never cleared, unlike the upload queue) with indexes on event_id and ts - store_local_events() in telemetry_worker fires before upload attempts, persisting interesting events regardless of network status - local_stats module aggregates events in memory: AI/human line splits, per-tool breakdowns, unique session counts, files touched - git-ai activity [--period 7d|30d|all] [--json] (default: 30d) - Excluded from daemon-required commands; reads local DB standalone Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 215 ++++++++++++++++++++++++++++++++ src/commands/git_ai_handlers.rs | 7 ++ src/commands/mod.rs | 1 + src/daemon/telemetry_worker.rs | 32 +++++ src/metrics/db.rs | 74 ++++++++++- src/metrics/local_stats.rs | 201 +++++++++++++++++++++++++++++ src/metrics/mod.rs | 1 + 7 files changed, 528 insertions(+), 3 deletions(-) create mode 100644 src/commands/activity.rs create mode 100644 src/metrics/local_stats.rs diff --git a/src/commands/activity.rs b/src/commands/activity.rs new file mode 100644 index 0000000000..5e77f6abb3 --- /dev/null +++ b/src/commands/activity.rs @@ -0,0 +1,215 @@ +//! `git-ai activity` — local statistics from persisted metric events. + +use crate::metrics::local_stats::{LocalActivityStats, compute_activity}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn handle_activity(args: &[String]) { + let mut json = false; + let mut period = "30d".to_string(); + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--json" => json = true, + "--period" if i + 1 < args.len() => { + period = args[i + 1].clone(); + i += 1; + } + "--help" | "-h" => { + print_help(); + return; + } + other => { + eprintln!("Unknown argument: {}", other); + eprintln!("Run 'git-ai activity --help' for usage."); + std::process::exit(1); + } + } + i += 1; + } + + let (since_ts, period_label) = match period.as_str() { + "7d" => (days_ago(7), "last 7 days".to_string()), + "30d" => (days_ago(30), "last 30 days".to_string()), + "all" => (0u32, "all time".to_string()), + other => { + eprintln!("Unknown period '{}'. Use 7d, 30d, or all.", other); + std::process::exit(1); + } + }; + + let stats = match compute_activity(since_ts, period_label) { + Ok(s) => s, + Err(e) => { + eprintln!("error: {}", e); + std::process::exit(1); + } + }; + + if json { + match serde_json::to_string_pretty(&stats) { + Ok(s) => println!("{}", s), + Err(e) => { + eprintln!("error serializing JSON: {}", e); + std::process::exit(1); + } + } + } else { + print_terminal(&stats); + } +} + +fn days_ago(days: u64) -> u32 { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + now.saturating_sub(days * 24 * 3600) as u32 +} + +fn print_help() { + eprintln!("git-ai activity - Show local AI activity statistics"); + eprintln!(); + eprintln!("Usage: git-ai activity [options]"); + eprintln!(); + eprintln!("Options:"); + eprintln!(" --period <7d|30d|all> Time window (default: 30d)"); + eprintln!(" --json Output as JSON"); + eprintln!(" --help Show this help"); + eprintln!(); + eprintln!("Statistics are sourced from locally recorded metric events."); + eprintln!("Events accumulate over time and are never deleted from local storage."); +} + +fn print_terminal(stats: &LocalActivityStats) { + const GRAY: &str = "\x1b[90m"; + const BOLD: &str = "\x1b[1m"; + const RESET: &str = "\x1b[0m"; + const BAR_WIDTH: u32 = 20; + + println!( + "{BOLD}git-ai activity{RESET} {GRAY}— {}{RESET}", + stats.period_label + ); + + // --- Commits section --- + println!(); + println!( + " {BOLD}Commits with AI{RESET} {:>6}", + stats.commits.total + ); + + let total_lines = stats.commits.ai_lines + stats.commits.human_lines; + if let Some(ai_pct) = (stats.commits.ai_lines * 100).checked_div(total_lines) { + let human_pct = 100 - ai_pct; + println!( + " AI lines added {:>6} {} {:>3}%", + format_num(stats.commits.ai_lines), + bar(ai_pct, BAR_WIDTH), + ai_pct, + ); + println!( + " Human lines added {:>6} {} {:>3}%", + format_num(stats.commits.human_lines), + bar(human_pct, BAR_WIDTH), + human_pct, + ); + } else { + println!( + " AI lines added {:>6}", + format_num(stats.commits.ai_lines) + ); + println!( + " Human lines added {:>6}", + format_num(stats.commits.human_lines) + ); + } + + if !stats.commits.by_tool.is_empty() { + let parts: Vec = stats + .commits + .by_tool + .iter() + .map(|(tool, count)| format!("{}: {}", tool, format_num(*count))) + .collect(); + println!(" {GRAY}By tool: {}{RESET}", parts.join(" · ")); + } + + // --- Checkpoints section --- + println!(); + println!( + " {BOLD}Checkpoints{RESET} {:>6}", + format_num(stats.checkpoints.total) + ); + + let total_cp_lines = stats.checkpoints.ai_lines_added + stats.checkpoints.human_lines_added; + if let Some(ai_pct) = (stats.checkpoints.ai_lines_added * 100).checked_div(total_cp_lines) { + let human_pct = 100 - ai_pct; + println!( + " AI edits {:>6} {} {:>3}%", + format_num(stats.checkpoints.ai_lines_added), + bar(ai_pct, BAR_WIDTH), + ai_pct, + ); + println!( + " Human edits {:>6} {} {:>3}%", + format_num(stats.checkpoints.human_lines_added), + bar(human_pct, BAR_WIDTH), + human_pct, + ); + } else { + println!( + " AI edits {:>6}", + format_num(stats.checkpoints.ai_lines_added) + ); + println!( + " Human edits {:>6}", + format_num(stats.checkpoints.human_lines_added) + ); + } + println!( + " Files touched {:>6}", + format_num(stats.checkpoints.files_edited) + ); + + // --- Sessions section --- + println!(); + println!( + " {BOLD}Sessions{RESET} {:>6}", + format_num(stats.sessions.total) + ); + + if !stats.sessions.by_tool.is_empty() { + let parts: Vec = stats + .sessions + .by_tool + .iter() + .map(|(tool, count)| format!("{}: {}", tool, count)) + .collect(); + println!(" {GRAY}By tool: {}{RESET}", parts.join(" · ")); + } + + println!(); +} + +fn bar(pct: u32, width: u32) -> String { + let filled = (pct * width / 100).min(width); + let empty = width - filled; + format!( + "{}{}", + "█".repeat(filled as usize), + "░".repeat(empty as usize) + ) +} + +fn format_num(n: u32) -> String { + let s = n.to_string(); + let mut result = String::new(); + for (i, c) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + result.push(','); + } + result.push(c); + } + result.chars().rev().collect() +} diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index 8b24c0282c..2acb03b6bd 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -53,6 +53,7 @@ pub fn handle_git_ai(args: &[String]) { | "install-hooks" | "install" | "uninstall-hooks" + | "activity" ); if needs_daemon { use crate::daemon::telemetry_handle::{ @@ -108,6 +109,9 @@ pub fn handle_git_ai(args: &[String]) { } handle_stats(&args[1..]); } + "activity" => { + commands::activity::handle_activity(&args[1..]); + } "status" => { commands::status::handle_status(&args[1..]); } @@ -335,6 +339,9 @@ fn print_help() { ); eprintln!(" stats [commit] Show AI authorship statistics for a commit"); eprintln!(" --json Output in JSON format"); + eprintln!(" activity Show local AI activity statistics"); + eprintln!(" --period <7d|30d|all> Time window (default: 30d)"); + eprintln!(" --json Output in JSON format"); eprintln!(" status Show uncommitted AI authorship status (debug)"); eprintln!(" --json Output in JSON format"); eprintln!(" show Display authorship logs for a revision or range"); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 8bf97f32a1..a8b70767e5 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod activity; pub mod blame; pub mod checkpoint_agent; pub mod ci_handlers; diff --git a/src/daemon/telemetry_worker.rs b/src/daemon/telemetry_worker.rs index f2fffa1944..56bfcaa569 100644 --- a/src/daemon/telemetry_worker.rs +++ b/src/daemon/telemetry_worker.rs @@ -316,6 +316,9 @@ fn flush_telemetry_batch(batch: TelemetryBuffer) { } fn flush_metrics(events: &[MetricEvent]) { + // Persist interesting events to local_events before attempting upload. + store_local_events(events); + let context = ApiContext::new(None); let api_base_url = context.base_url.clone(); let client = ApiClient::new(context); @@ -359,6 +362,35 @@ fn store_metrics_in_db(events: &[MetricEvent]) { } } +fn store_local_events(events: &[MetricEvent]) { + // Only persist event types that are useful for local activity stats. + const INTERESTING: &[u16] = &[ + 1, // Committed + 4, // Checkpoint + 5, // SessionEvent + ]; + + let tuples: Vec<(u16, u32, String)> = events + .iter() + .filter(|e| INTERESTING.contains(&e.event_id)) + .filter_map(|e| { + serde_json::to_string(e) + .ok() + .map(|json| (e.event_id, e.timestamp, json)) + }) + .collect(); + + if tuples.is_empty() { + return; + } + + if let Ok(db) = MetricsDatabase::global() + && let Ok(mut db_lock) = db.lock() + { + let _ = db_lock.insert_local_events(&tuples); + } +} + fn flush_sentry_and_posthog( config: &Config, distinct_id: &str, diff --git a/src/metrics/db.rs b/src/metrics/db.rs index d0f2586f40..611cb6fe6d 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -9,7 +9,7 @@ use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; /// Current schema version (must match MIGRATIONS.len()) -const SCHEMA_VERSION: usize = 2; +const SCHEMA_VERSION: usize = 3; /// Database migrations - each migration upgrades the schema by one version const MIGRATIONS: &[&str] = &[ @@ -27,6 +27,17 @@ const MIGRATIONS: &[&str] = &[ last_sent_ts INTEGER NOT NULL ); "#, + // Migration 2 -> 3: Persistent local event history for `git-ai activity` + r#" + CREATE TABLE IF NOT EXISTS local_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + event_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS local_events_ts ON local_events (ts); + CREATE INDEX IF NOT EXISTS local_events_event_id ON local_events (event_id); + "#, ]; /// Global database singleton @@ -39,6 +50,14 @@ pub struct MetricRecord { pub event_json: String, } +/// Record from the local_events table +#[derive(Debug, Clone)] +pub struct LocalEventRecord { + pub event_id: u16, + pub ts: u32, + pub event_json: String, +} + /// Database wrapper for metrics storage pub struct MetricsDatabase { conn: Connection, @@ -261,6 +280,55 @@ impl MetricsDatabase { Ok(count as usize) } + /// Insert events into the local_events table (persistent, never deleted). + /// + /// Each tuple is (event_id, ts, event_json). Call this with events filtered to + /// only the interesting event types before inserting. + pub fn insert_local_events(&mut self, events: &[(u16, u32, String)]) -> Result<(), GitAiError> { + if events.is_empty() { + return Ok(()); + } + + let tx = self.conn.transaction()?; + + { + let mut stmt = tx.prepare_cached( + "INSERT INTO local_events (event_id, ts, event_json) VALUES (?1, ?2, ?3)", + )?; + + for (event_id, ts, json) in events { + stmt.execute(params![*event_id as i64, *ts as i64, json])?; + } + } + + tx.commit()?; + Ok(()) + } + + /// Query local_events since `since_ts` (Unix seconds), returning all interesting event types. + pub fn get_local_events(&self, since_ts: u32) -> Result, GitAiError> { + let mut stmt = self.conn.prepare( + "SELECT event_id, ts, event_json FROM local_events \ + WHERE ts >= ?1 \ + ORDER BY ts ASC", + )?; + + let rows = stmt.query_map(params![since_ts as i64], |row| { + Ok(LocalEventRecord { + event_id: row.get::<_, i64>(0)? as u16, + ts: row.get::<_, i64>(1)? as u32, + event_json: row.get(2)?, + }) + })?; + + let mut records = Vec::new(); + for row in rows { + records.push(row?); + } + + Ok(records) + } + /// Returns whether an `agent_usage` event should be emitted for this prompt_id. /// /// If emitted, this method also updates the prompt's last-sent timestamp. @@ -345,7 +413,7 @@ mod tests { |row| row.get(0), ) .unwrap(); - assert_eq!(version, "2"); + assert_eq!(version, "3"); } #[test] @@ -383,7 +451,7 @@ mod tests { |row| row.get(0), ) .unwrap(); - assert_eq!(version, "2"); + assert_eq!(version, "3"); } #[test] diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs new file mode 100644 index 0000000000..932463b7d9 --- /dev/null +++ b/src/metrics/local_stats.rs @@ -0,0 +1,201 @@ +//! In-memory aggregation of local_events for `git-ai activity`. + +use crate::error::GitAiError; +use crate::metrics::attrs::attr_pos; +use crate::metrics::db::MetricsDatabase; +use crate::metrics::events::{checkpoint_pos, committed_pos}; +use crate::metrics::pos_encoded::{ + sparse_get_string, sparse_get_u32, sparse_get_vec_string, sparse_get_vec_u32, +}; +use crate::metrics::types::MetricEvent; +use serde::Serialize; +use std::cmp::Reverse; +use std::collections::{HashMap, HashSet}; + +#[derive(Debug, Serialize)] +pub struct LocalActivityStats { + pub period_label: String, + pub commits: CommitSummary, + pub checkpoints: CheckpointSummary, + pub sessions: SessionSummary, +} + +#[derive(Debug, Serialize)] +pub struct CommitSummary { + pub total: u32, + pub ai_lines: u32, + pub human_lines: u32, + /// Per-tool AI line counts, sorted descending. Tool name only (strips "::model" suffix). + pub by_tool: Vec<(String, u32)>, +} + +#[derive(Debug, Serialize)] +pub struct CheckpointSummary { + pub total: u32, + pub ai_lines_added: u32, + pub human_lines_added: u32, + pub files_edited: u32, +} + +#[derive(Debug, Serialize)] +pub struct SessionSummary { + pub total: u32, + pub by_tool: Vec<(String, u32)>, +} + +/// Aggregate local_events since `since_ts` (Unix seconds) into activity stats. +pub fn compute_activity( + since_ts: u32, + period_label: String, +) -> Result { + let records = { + let db = MetricsDatabase::global()?; + let db_lock = db + .lock() + .map_err(|_| GitAiError::Generic("metrics DB lock poisoned".to_string()))?; + db_lock.get_local_events(since_ts)? + }; + + let mut total_commits = 0u32; + let mut total_ai_lines = 0u32; + let mut total_human_lines = 0u32; + let mut commit_tool_counts: HashMap = HashMap::new(); + + let mut total_checkpoints = 0u32; + let mut ai_lines_added = 0u32; + let mut human_lines_added = 0u32; + let mut files_edited: HashSet = HashSet::new(); + + let mut session_ids: HashSet = HashSet::new(); + let mut session_tool_counts: HashMap = HashMap::new(); + + for record in &records { + let event: MetricEvent = match serde_json::from_str(&record.event_json) { + Ok(e) => e, + Err(_) => continue, + }; + + match record.event_id { + 1 => aggregate_committed( + &event, + &mut total_commits, + &mut total_ai_lines, + &mut total_human_lines, + &mut commit_tool_counts, + ), + 4 => aggregate_checkpoint( + &event, + &mut total_checkpoints, + &mut ai_lines_added, + &mut human_lines_added, + &mut files_edited, + ), + 5 => aggregate_session(&event, &mut session_ids, &mut session_tool_counts), + _ => {} + } + } + + let mut commit_by_tool: Vec<(String, u32)> = commit_tool_counts.into_iter().collect(); + commit_by_tool.sort_by_key(|&(_, count)| Reverse(count)); + + let mut session_by_tool: Vec<(String, u32)> = session_tool_counts.into_iter().collect(); + session_by_tool.sort_by_key(|&(_, count)| Reverse(count)); + + Ok(LocalActivityStats { + period_label, + commits: CommitSummary { + total: total_commits, + ai_lines: total_ai_lines, + human_lines: total_human_lines, + by_tool: commit_by_tool, + }, + checkpoints: CheckpointSummary { + total: total_checkpoints, + ai_lines_added, + human_lines_added, + files_edited: files_edited.len() as u32, + }, + sessions: SessionSummary { + total: session_ids.len() as u32, + by_tool: session_by_tool, + }, + }) +} + +fn aggregate_committed( + event: &MetricEvent, + total_commits: &mut u32, + total_ai_lines: &mut u32, + total_human_lines: &mut u32, + commit_tool_counts: &mut HashMap, +) { + *total_commits += 1; + + let human = sparse_get_u32(&event.values, committed_pos::HUMAN_ADDITIONS) + .flatten() + .unwrap_or(0); + *total_human_lines += human; + + let ai_vecs = sparse_get_vec_u32(&event.values, committed_pos::AI_ADDITIONS) + .flatten() + .unwrap_or_default(); + let total_ai = ai_vecs.first().copied().unwrap_or(0); + *total_ai_lines += total_ai; + + // Per-tool breakdown: index 0 = "all" aggregate, 1+ = per tool::model. + let pairs = sparse_get_vec_string(&event.values, committed_pos::TOOL_MODEL_PAIRS) + .flatten() + .unwrap_or_default(); + for (i, pair) in pairs.iter().enumerate().skip(1) { + let tool = pair.split("::").next().unwrap_or(pair).to_string(); + let ai_for_tool = ai_vecs.get(i).copied().unwrap_or(0); + *commit_tool_counts.entry(tool).or_insert(0) += ai_for_tool; + } +} + +fn aggregate_checkpoint( + event: &MetricEvent, + total_checkpoints: &mut u32, + ai_lines_added: &mut u32, + human_lines_added: &mut u32, + files_edited: &mut HashSet, +) { + *total_checkpoints += 1; + + let kind = sparse_get_string(&event.values, checkpoint_pos::KIND) + .flatten() + .unwrap_or_default(); + let file_path = sparse_get_string(&event.values, checkpoint_pos::FILE_PATH) + .flatten() + .unwrap_or_default(); + let lines_added = sparse_get_u32(&event.values, checkpoint_pos::LINES_ADDED) + .flatten() + .unwrap_or(0); + + if !file_path.is_empty() { + files_edited.insert(file_path); + } + + match kind.as_str() { + "ai_agent" | "ai_tab" => *ai_lines_added += lines_added, + "known_human" => *human_lines_added += lines_added, + _ => {} + } +} + +fn aggregate_session( + event: &MetricEvent, + session_ids: &mut HashSet, + session_tool_counts: &mut HashMap, +) { + let session_id = sparse_get_string(&event.attrs, attr_pos::SESSION_ID).flatten(); + let tool = sparse_get_string(&event.attrs, attr_pos::TOOL) + .flatten() + .unwrap_or_else(|| "unknown".to_string()); + + if let Some(sid) = session_id + && session_ids.insert(sid) + { + *session_tool_counts.entry(tool).or_insert(0) += 1; + } +} diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs index a925d1ff1c..6b6bf9a4c4 100644 --- a/src/metrics/mod.rs +++ b/src/metrics/mod.rs @@ -8,6 +8,7 @@ pub mod attrs; pub mod db; pub mod events; +pub mod local_stats; pub mod pos_encoded; pub mod types; From ac58a02f75e44c24b75aed5a3ba3edd338639ee2 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 09:25:43 -0400 Subject: [PATCH 002/100] feat: git-ai activity 1d period --- src/commands/activity.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 5e77f6abb3..3f6840dd3d 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -29,6 +29,7 @@ pub fn handle_activity(args: &[String]) { } let (since_ts, period_label) = match period.as_str() { + "71" => (days_ago(1), "last 1 days".to_string()), "7d" => (days_ago(7), "last 7 days".to_string()), "30d" => (days_ago(30), "last 30 days".to_string()), "all" => (0u32, "all time".to_string()), From 89c551d38a96eef513b13313928b06bf8204acf3 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 09:28:40 -0400 Subject: [PATCH 003/100] fix: 1d not 71 --- src/commands/activity.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 3f6840dd3d..c0678b64e1 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -29,7 +29,7 @@ pub fn handle_activity(args: &[String]) { } let (since_ts, period_label) = match period.as_str() { - "71" => (days_ago(1), "last 1 days".to_string()), + "1d" => (days_ago(1), "last 1 days".to_string()), "7d" => (days_ago(7), "last 7 days".to_string()), "30d" => (days_ago(30), "last 30 days".to_string()), "all" => (0u32, "all time".to_string()), From 55a0cd2bb366cf245a74f3acb99cd3e3bd6cd01f Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 09:31:55 -0400 Subject: [PATCH 004/100] fix: only count commits with AI lines in activity stats Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/local_stats.rs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 932463b7d9..f91c4dbd0c 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -129,19 +129,24 @@ fn aggregate_committed( total_human_lines: &mut u32, commit_tool_counts: &mut HashMap, ) { + let ai_vecs = sparse_get_vec_u32(&event.values, committed_pos::AI_ADDITIONS) + .flatten() + .unwrap_or_default(); + let total_ai = ai_vecs.first().copied().unwrap_or(0); + + // Only count commits that actually contain AI-attributed lines. + if total_ai == 0 { + return; + } + *total_commits += 1; + *total_ai_lines += total_ai; let human = sparse_get_u32(&event.values, committed_pos::HUMAN_ADDITIONS) .flatten() .unwrap_or(0); *total_human_lines += human; - let ai_vecs = sparse_get_vec_u32(&event.values, committed_pos::AI_ADDITIONS) - .flatten() - .unwrap_or_default(); - let total_ai = ai_vecs.first().copied().unwrap_or(0); - *total_ai_lines += total_ai; - // Per-tool breakdown: index 0 = "all" aggregate, 1+ = per tool::model. let pairs = sparse_get_vec_string(&event.values, committed_pos::TOOL_MODEL_PAIRS) .flatten() @@ -149,7 +154,9 @@ fn aggregate_committed( for (i, pair) in pairs.iter().enumerate().skip(1) { let tool = pair.split("::").next().unwrap_or(pair).to_string(); let ai_for_tool = ai_vecs.get(i).copied().unwrap_or(0); - *commit_tool_counts.entry(tool).or_insert(0) += ai_for_tool; + if ai_for_tool > 0 { + *commit_tool_counts.entry(tool).or_insert(0) += ai_for_tool; + } } } From 2a6d730677d9bb35c46599852ad8fd598d9294b2 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 09:33:36 -0400 Subject: [PATCH 005/100] fix: update text --- src/commands/activity.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index c0678b64e1..211f2ea4d9 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -69,7 +69,7 @@ fn days_ago(days: u64) -> u32 { } fn print_help() { - eprintln!("git-ai activity - Show local AI activity statistics"); + eprintln!("git-ai activity - Show local activity statistics"); eprintln!(); eprintln!("Usage: git-ai activity [options]"); eprintln!(); From 341e9e89e11951e18264fa2259673a42841371ac Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 09:55:24 -0400 Subject: [PATCH 006/100] feat: add 3d period to git ai activity --- src/commands/activity.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 211f2ea4d9..24683b00a3 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -30,6 +30,7 @@ pub fn handle_activity(args: &[String]) { let (since_ts, period_label) = match period.as_str() { "1d" => (days_ago(1), "last 1 days".to_string()), + "3d" => (days_ago(3), "last 3 days".to_string()), "7d" => (days_ago(7), "last 7 days".to_string()), "30d" => (days_ago(30), "last 30 days".to_string()), "all" => (0u32, "all time".to_string()), From b395245f2c6e9f9afb1720376ea5b61b69fdbb64 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 10:19:37 -0400 Subject: [PATCH 007/100] fix: accumulate human lines before AI early-return in aggregate_committed Commits with no AI lines were dropping their human_additions because the early-return guard fired before the accumulation. Moved the accumulation above the guard. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/local_stats.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index f91c4dbd0c..7391a21811 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -129,12 +129,18 @@ fn aggregate_committed( total_human_lines: &mut u32, commit_tool_counts: &mut HashMap, ) { + let human = sparse_get_u32(&event.values, committed_pos::HUMAN_ADDITIONS) + .flatten() + .unwrap_or(0); let ai_vecs = sparse_get_vec_u32(&event.values, committed_pos::AI_ADDITIONS) .flatten() .unwrap_or_default(); let total_ai = ai_vecs.first().copied().unwrap_or(0); - // Only count commits that actually contain AI-attributed lines. + // Always accumulate human lines regardless of whether the commit has AI lines. + *total_human_lines += human; + + // Only count the commit and accumulate AI lines when AI was involved. if total_ai == 0 { return; } @@ -142,11 +148,6 @@ fn aggregate_committed( *total_commits += 1; *total_ai_lines += total_ai; - let human = sparse_get_u32(&event.values, committed_pos::HUMAN_ADDITIONS) - .flatten() - .unwrap_or(0); - *total_human_lines += human; - // Per-tool breakdown: index 0 = "all" aggregate, 1+ = per tool::model. let pairs = sparse_get_vec_string(&event.values, committed_pos::TOOL_MODEL_PAIRS) .flatten() From 6a33d4ceb6ea1c86efe10a153e6aef498ab2b8b2 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 10:19:44 -0400 Subject: [PATCH 008/100] feat: show AI acceptance rate in git-ai activity output Displays AI acceptance rate (committed AI lines / checkpoint AI lines) below the per-tool breakdown. Only shown when checkpoint history is sufficient to produce a meaningful ratio (<= 100%). Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 24683b00a3..d5826fa8a6 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -137,6 +137,18 @@ fn print_terminal(stats: &LocalActivityStats) { println!(" {GRAY}By tool: {}{RESET}", parts.join(" · ")); } + if let Some(acceptance_pct) = + (stats.commits.ai_lines * 100).checked_div(stats.checkpoints.ai_lines_added) + && acceptance_pct <= 100 + { + println!( + " AI acceptance rate {:>6} {} {:>3}%", + "", + bar(acceptance_pct, BAR_WIDTH), + acceptance_pct, + ); + } + // --- Checkpoints section --- println!(); println!( From a504cc2308c382abe9393bf66990d7e00c246ab6 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 10:34:32 -0400 Subject: [PATCH 009/100] feat: add activity over time, velocity, and time-of-day heatmap to git-ai activity - Activity over time: bar chart bucketed by day (<=7d), week (30d), or month (all) with empty buckets filled in so the chart is always contiguous - AI coding velocity: each bucket shows AI lines + commit count so output doubles as a velocity trend across the period - Time of day: 24-slot sparkline (local time) showing when AI-assisted commits land, using Unicode block chars for density Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 96 ++++++++++++++++--- src/metrics/local_stats.rs | 186 +++++++++++++++++++++++++++++++++++-- 2 files changed, 261 insertions(+), 21 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index d5826fa8a6..d7b02c2e18 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -1,6 +1,6 @@ //! `git-ai activity` — local statistics from persisted metric events. -use crate::metrics::local_stats::{LocalActivityStats, compute_activity}; +use crate::metrics::local_stats::{BucketGranularity, LocalActivityStats, compute_activity}; use std::time::{SystemTime, UNIX_EPOCH}; pub fn handle_activity(args: &[String]) { @@ -28,19 +28,19 @@ pub fn handle_activity(args: &[String]) { i += 1; } - let (since_ts, period_label) = match period.as_str() { - "1d" => (days_ago(1), "last 1 days".to_string()), - "3d" => (days_ago(3), "last 3 days".to_string()), - "7d" => (days_ago(7), "last 7 days".to_string()), - "30d" => (days_ago(30), "last 30 days".to_string()), - "all" => (0u32, "all time".to_string()), + let (since_ts, period_label, granularity) = match period.as_str() { + "1d" => (days_ago(1), "last 1 day".to_string(), BucketGranularity::Daily), + "3d" => (days_ago(3), "last 3 days".to_string(), BucketGranularity::Daily), + "7d" => (days_ago(7), "last 7 days".to_string(), BucketGranularity::Daily), + "30d" => (days_ago(30), "last 30 days".to_string(), BucketGranularity::Weekly), + "all" => (0u32, "all time".to_string(), BucketGranularity::Monthly), other => { - eprintln!("Unknown period '{}'. Use 7d, 30d, or all.", other); + eprintln!("Unknown period '{}'. Use 1d, 3d, 7d, 30d, or all.", other); std::process::exit(1); } }; - let stats = match compute_activity(since_ts, period_label) { + let stats = match compute_activity(since_ts, period_label, granularity) { Ok(s) => s, Err(e) => { eprintln!("error: {}", e); @@ -75,9 +75,9 @@ fn print_help() { eprintln!("Usage: git-ai activity [options]"); eprintln!(); eprintln!("Options:"); - eprintln!(" --period <7d|30d|all> Time window (default: 30d)"); - eprintln!(" --json Output as JSON"); - eprintln!(" --help Show this help"); + eprintln!(" --period <1d|3d|7d|30d|all> Time window (default: 30d)"); + eprintln!(" --json Output as JSON"); + eprintln!(" --help Show this help"); eprintln!(); eprintln!("Statistics are sourced from locally recorded metric events."); eprintln!("Events accumulate over time and are never deleted from local storage."); @@ -149,6 +149,29 @@ fn print_terminal(stats: &LocalActivityStats) { ); } + // --- Activity over time --- + if !stats.buckets.is_empty() { + println!(); + println!(" {BOLD}Activity over time{RESET}"); + let max_ai = stats.buckets.iter().map(|b| b.ai_lines).max().unwrap_or(1).max(1); + for bucket in &stats.buckets { + let filled = (bucket.ai_lines * BAR_WIDTH / max_ai).min(BAR_WIDTH); + let empty = BAR_WIDTH - filled; + let bar_str = format!("{}{}", "█".repeat(filled as usize), "░".repeat(empty as usize)); + if bucket.ai_lines > 0 { + println!( + " {GRAY}{}{RESET} {} {GRAY}{} lines · {} commits{RESET}", + bucket.label, + bar_str, + format_num(bucket.ai_lines), + bucket.commit_count, + ); + } else { + println!(" {GRAY}{} {}{RESET}", bucket.label, bar_str); + } + } + } + // --- Checkpoints section --- println!(); println!( @@ -203,9 +226,58 @@ fn print_terminal(stats: &LocalActivityStats) { println!(" {GRAY}By tool: {}{RESET}", parts.join(" · ")); } + // --- Time of day heatmap --- + if stats.hourly.iter().any(|&v| v > 0) { + println!(); + println!(" {BOLD}Time of day{RESET} {GRAY}(AI lines committed){RESET}"); + let max_hour = stats.hourly.iter().copied().max().unwrap_or(1).max(1); + + // Render two rows: sparkline + hour labels + // Each slot is 3 chars: spark char + 2 spaces. Labels are left-padded to 3. + let spark: String = stats + .hourly + .iter() + .map(|&v| spark_char(v, max_hour)) + .collect::>() + .join(" "); + println!(" {}", spark); + + let labels: Vec = (0..24) + .map(|h| match h { + 0 => "am".to_string(), + 12 => "pm".to_string(), + h if h < 12 => format!("{h}"), + h => format!("{}", h - 12), + }) + .collect(); + let label_row: String = labels + .iter() + .map(|l| format!("{:<3}", l)) + .collect::>() + .join(""); + println!(" {GRAY}{}{RESET}", label_row.trim_end()); + } + println!(); } +fn spark_char(value: u32, max: u32) -> &'static str { + if value == 0 { + return "·"; + } + let pct = value * 8 / max; + match pct { + 0 => "▁", + 1 => "▂", + 2 => "▃", + 3 => "▄", + 4 => "▅", + 5 => "▆", + 6 => "▇", + _ => "█", + } +} + fn bar(pct: u32, width: u32) -> String { let filled = (pct * width / 100).min(width); let empty = width - filled; diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 7391a21811..85ddfb74ae 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -8,6 +8,7 @@ use crate::metrics::pos_encoded::{ sparse_get_string, sparse_get_u32, sparse_get_vec_string, sparse_get_vec_u32, }; use crate::metrics::types::MetricEvent; +use chrono::{DateTime, Datelike, Local, NaiveDate, TimeZone, Timelike}; use serde::Serialize; use std::cmp::Reverse; use std::collections::{HashMap, HashSet}; @@ -18,6 +19,17 @@ pub struct LocalActivityStats { pub commits: CommitSummary, pub checkpoints: CheckpointSummary, pub sessions: SessionSummary, + /// Activity bucketed by day/week/month depending on period. + pub buckets: Vec, + /// AI lines committed per hour of day (local time), 24 elements. + pub hourly: Vec, +} + +#[derive(Debug, Serialize)] +pub struct BucketStats { + pub label: String, + pub ai_lines: u32, + pub commit_count: u32, } #[derive(Debug, Serialize)] @@ -43,10 +55,18 @@ pub struct SessionSummary { pub by_tool: Vec<(String, u32)>, } +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum BucketGranularity { + Daily, + Weekly, + Monthly, +} + /// Aggregate local_events since `since_ts` (Unix seconds) into activity stats. pub fn compute_activity( since_ts: u32, period_label: String, + granularity: BucketGranularity, ) -> Result { let records = { let db = MetricsDatabase::global()?; @@ -69,6 +89,13 @@ pub fn compute_activity( let mut session_ids: HashSet = HashSet::new(); let mut session_tool_counts: HashMap = HashMap::new(); + // bucket_key -> (ai_lines, commit_count) + let mut bucket_map: HashMap = HashMap::new(); + // bucket_key -> sort key (for ordering) + let mut bucket_order: HashMap = HashMap::new(); + + let mut hourly: Vec = vec![0u32; 24]; + for record in &records { let event: MetricEvent = match serde_json::from_str(&record.event_json) { Ok(e) => e, @@ -76,13 +103,27 @@ pub fn compute_activity( }; match record.event_id { - 1 => aggregate_committed( - &event, - &mut total_commits, - &mut total_ai_lines, - &mut total_human_lines, - &mut commit_tool_counts, - ), + 1 => { + let ai_lines_this = aggregate_committed( + &event, + &mut total_commits, + &mut total_ai_lines, + &mut total_human_lines, + &mut commit_tool_counts, + ); + + if ai_lines_this > 0 { + let local_dt = ts_to_local(record.ts); + let hour = local_dt.hour() as usize; + hourly[hour] += ai_lines_this; + + let (key, order_key) = bucket_key(&local_dt, granularity); + let entry = bucket_map.entry(key.clone()).or_insert((0, 0)); + entry.0 += ai_lines_this; + entry.1 += 1; + bucket_order.entry(key).or_insert(order_key); + } + } 4 => aggregate_checkpoint( &event, &mut total_checkpoints, @@ -101,6 +142,19 @@ pub fn compute_activity( let mut session_by_tool: Vec<(String, u32)> = session_tool_counts.into_iter().collect(); session_by_tool.sort_by_key(|&(_, count)| Reverse(count)); + // Sort buckets chronologically using their order key. + let mut bucket_pairs: Vec<(String, i64, u32, u32)> = bucket_map + .into_iter() + .map(|(label, (ai, commits))| { + let order = bucket_order[&label]; + (label, order, ai, commits) + }) + .collect(); + bucket_pairs.sort_by_key(|&(_, order, _, _)| order); + + // Fill in empty buckets between since_ts and now so the chart has no gaps. + let filled = fill_buckets(bucket_pairs, since_ts, granularity); + Ok(LocalActivityStats { period_label, commits: CommitSummary { @@ -119,16 +173,128 @@ pub fn compute_activity( total: session_ids.len() as u32, by_tool: session_by_tool, }, + buckets: filled, + hourly, }) } +fn ts_to_local(ts: u32) -> DateTime { + Local + .timestamp_opt(ts as i64, 0) + .single() + .unwrap_or_else(Local::now) +} + +fn bucket_key(dt: &DateTime, granularity: BucketGranularity) -> (String, i64) { + match granularity { + BucketGranularity::Daily => { + let label = dt.format("%b %d").to_string(); + let order = dt.date_naive().num_days_from_ce() as i64; + (label, order) + } + BucketGranularity::Weekly => { + // ISO week: key on Monday of the week. + let weekday = dt.weekday().num_days_from_monday() as i64; + let monday = dt.date_naive() - chrono::Duration::days(weekday); + let label = monday.format("%b %d").to_string(); + let order = monday.num_days_from_ce() as i64; + (label, order) + } + BucketGranularity::Monthly => { + let label = dt.format("%b %Y").to_string(); + let order = dt.year() as i64 * 12 + dt.month0() as i64; + (label, order) + } + } +} + +/// Fill gaps between `since_ts` and today so charts have contiguous buckets. +fn fill_buckets( + data: Vec<(String, i64, u32, u32)>, + since_ts: u32, + granularity: BucketGranularity, +) -> Vec { + // Build a map from order_key → (label, ai, commits) from real data. + let mut data_map: HashMap = data + .into_iter() + .map(|(label, order, ai, commits)| (order, (label, ai, commits))) + .collect(); + + let now = Local::now(); + let since_dt = ts_to_local(since_ts); + + // Generate all expected bucket keys between since and now. + let mut result = Vec::new(); + match granularity { + BucketGranularity::Daily => { + let mut day = since_dt.date_naive(); + let today = now.date_naive(); + while day <= today { + let order = day.num_days_from_ce() as i64; + let label = day.format("%b %d").to_string(); + let (ai, commits) = data_map + .remove(&order) + .map(|(_, ai, c)| (ai, c)) + .unwrap_or((0, 0)); + result.push(BucketStats { label, ai_lines: ai, commit_count: commits }); + day = day.succ_opt().unwrap_or(today); + } + } + BucketGranularity::Weekly => { + let weekday = since_dt.weekday().num_days_from_monday() as i64; + let mut monday: NaiveDate = + since_dt.date_naive() - chrono::Duration::days(weekday); + let today = now.date_naive(); + while monday <= today { + let order = monday.num_days_from_ce() as i64; + let label = monday.format("%b %d").to_string(); + let (ai, commits) = data_map + .remove(&order) + .map(|(_, ai, c)| (ai, c)) + .unwrap_or((0, 0)); + result.push(BucketStats { label, ai_lines: ai, commit_count: commits }); + monday = monday + .checked_add_signed(chrono::Duration::weeks(1)) + .unwrap_or(today); + } + } + BucketGranularity::Monthly => { + let mut year = since_dt.year(); + let mut month = since_dt.month(); + let now_year = now.year(); + let now_month = now.month(); + loop { + let order = year as i64 * 12 + (month - 1) as i64; + let date = NaiveDate::from_ymd_opt(year, month, 1).unwrap(); + let label = date.format("%b %Y").to_string(); + let (ai, commits) = data_map + .remove(&order) + .map(|(_, ai, c)| (ai, c)) + .unwrap_or((0, 0)); + result.push(BucketStats { label, ai_lines: ai, commit_count: commits }); + if year == now_year && month == now_month { + break; + } + month += 1; + if month > 12 { + month = 1; + year += 1; + } + } + } + } + + result +} + +/// Returns the AI lines for this commit (0 if none). fn aggregate_committed( event: &MetricEvent, total_commits: &mut u32, total_ai_lines: &mut u32, total_human_lines: &mut u32, commit_tool_counts: &mut HashMap, -) { +) -> u32 { let human = sparse_get_u32(&event.values, committed_pos::HUMAN_ADDITIONS) .flatten() .unwrap_or(0); @@ -142,7 +308,7 @@ fn aggregate_committed( // Only count the commit and accumulate AI lines when AI was involved. if total_ai == 0 { - return; + return 0; } *total_commits += 1; @@ -159,6 +325,8 @@ fn aggregate_committed( *commit_tool_counts.entry(tool).or_insert(0) += ai_for_tool; } } + + total_ai } fn aggregate_checkpoint( From 6a8411b31631ae96d56c901dc2ee2dcbf24bc0ff Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 10:38:35 -0400 Subject: [PATCH 010/100] fix: show week range labels and move activity chart below sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weekly buckets now display "May 18 – May 24" instead of just "May 18" so users can see their work falls within the bucket rather than thinking it refers to a specific day. Activity over time section moved below Sessions and above Time of day. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 46 +++++++++++++++++++------------------- src/metrics/local_stats.rs | 6 +++-- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index d7b02c2e18..f3484ed17b 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -149,29 +149,6 @@ fn print_terminal(stats: &LocalActivityStats) { ); } - // --- Activity over time --- - if !stats.buckets.is_empty() { - println!(); - println!(" {BOLD}Activity over time{RESET}"); - let max_ai = stats.buckets.iter().map(|b| b.ai_lines).max().unwrap_or(1).max(1); - for bucket in &stats.buckets { - let filled = (bucket.ai_lines * BAR_WIDTH / max_ai).min(BAR_WIDTH); - let empty = BAR_WIDTH - filled; - let bar_str = format!("{}{}", "█".repeat(filled as usize), "░".repeat(empty as usize)); - if bucket.ai_lines > 0 { - println!( - " {GRAY}{}{RESET} {} {GRAY}{} lines · {} commits{RESET}", - bucket.label, - bar_str, - format_num(bucket.ai_lines), - bucket.commit_count, - ); - } else { - println!(" {GRAY}{} {}{RESET}", bucket.label, bar_str); - } - } - } - // --- Checkpoints section --- println!(); println!( @@ -226,6 +203,29 @@ fn print_terminal(stats: &LocalActivityStats) { println!(" {GRAY}By tool: {}{RESET}", parts.join(" · ")); } + // --- Activity over time --- + if !stats.buckets.is_empty() { + println!(); + println!(" {BOLD}Activity over time{RESET}"); + let max_ai = stats.buckets.iter().map(|b| b.ai_lines).max().unwrap_or(1).max(1); + for bucket in &stats.buckets { + let filled = (bucket.ai_lines * BAR_WIDTH / max_ai).min(BAR_WIDTH); + let empty = BAR_WIDTH - filled; + let bar_str = format!("{}{}", "█".repeat(filled as usize), "░".repeat(empty as usize)); + if bucket.ai_lines > 0 { + println!( + " {GRAY}{}{RESET} {} {GRAY}{} lines · {} commits{RESET}", + bucket.label, + bar_str, + format_num(bucket.ai_lines), + bucket.commit_count, + ); + } else { + println!(" {GRAY}{} {}{RESET}", bucket.label, bar_str); + } + } + } + // --- Time of day heatmap --- if stats.hourly.iter().any(|&v| v > 0) { println!(); diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 85ddfb74ae..7c94d623d9 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -196,7 +196,8 @@ fn bucket_key(dt: &DateTime, granularity: BucketGranularity) -> (String, // ISO week: key on Monday of the week. let weekday = dt.weekday().num_days_from_monday() as i64; let monday = dt.date_naive() - chrono::Duration::days(weekday); - let label = monday.format("%b %d").to_string(); + let sunday = monday + chrono::Duration::days(6); + let label = format!("{} – {}", monday.format("%b %d"), sunday.format("%b %d")); let order = monday.num_days_from_ce() as i64; (label, order) } @@ -247,7 +248,8 @@ fn fill_buckets( let today = now.date_naive(); while monday <= today { let order = monday.num_days_from_ce() as i64; - let label = monday.format("%b %d").to_string(); + let sunday = monday + chrono::Duration::days(6); + let label = format!("{} – {}", monday.format("%b %d"), sunday.format("%b %d")); let (ai, commits) = data_map .remove(&order) .map(|(_, ai, c)| (ai, c)) From ebaf56a38f0e9ae23ba98427030cee312cdc41af Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 10:46:45 -0400 Subject: [PATCH 011/100] refactor: restructure activity output into AI/Human stacked sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Commits/Checkpoints/Sessions headers with a top-level AI vs Human percentage bar followed by stacked AI and Human sections. Surfaces the same data with clearer framing — no internal "checkpoint" terminology. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 117 ++++++++++++--------------------------- 1 file changed, 34 insertions(+), 83 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index f3484ed17b..2dc760e5e9 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -94,39 +94,45 @@ fn print_terminal(stats: &LocalActivityStats) { stats.period_label ); - // --- Commits section --- + // --- Top bar: AI vs Human split --- println!(); - println!( - " {BOLD}Commits with AI{RESET} {:>6}", - stats.commits.total - ); - let total_lines = stats.commits.ai_lines + stats.commits.human_lines; if let Some(ai_pct) = (stats.commits.ai_lines * 100).checked_div(total_lines) { let human_pct = 100 - ai_pct; println!( - " AI lines added {:>6} {} {:>3}%", - format_num(stats.commits.ai_lines), + " {BOLD}AI{RESET} {} {:>3}% {BOLD}Human{RESET} {} {:>3}%", bar(ai_pct, BAR_WIDTH), ai_pct, - ); - println!( - " Human lines added {:>6} {} {:>3}%", - format_num(stats.commits.human_lines), bar(human_pct, BAR_WIDTH), human_pct, ); - } else { - println!( - " AI lines added {:>6}", - format_num(stats.commits.ai_lines) - ); - println!( - " Human lines added {:>6}", - format_num(stats.commits.human_lines) - ); } + // --- AI section --- + println!(); + println!(" {BOLD}AI{RESET}"); + println!( + " Sessions {:>6}", + format_num(stats.sessions.total) + ); + println!( + " Commits {:>6}", + format_num(stats.commits.total) + ); + println!( + " Lines committed {:>6}", + format_num(stats.commits.ai_lines) + ); + println!( + " Edits {:>6}", + format_num(stats.checkpoints.ai_lines_added) + ); + if let Some(acceptance_pct) = + (stats.commits.ai_lines * 100).checked_div(stats.checkpoints.ai_lines_added) + && acceptance_pct <= 100 + { + println!(" Acceptance rate {:>5}%", acceptance_pct); + } if !stats.commits.by_tool.is_empty() { let parts: Vec = stats .commits @@ -134,75 +140,21 @@ fn print_terminal(stats: &LocalActivityStats) { .iter() .map(|(tool, count)| format!("{}: {}", tool, format_num(*count))) .collect(); - println!(" {GRAY}By tool: {}{RESET}", parts.join(" · ")); - } - - if let Some(acceptance_pct) = - (stats.commits.ai_lines * 100).checked_div(stats.checkpoints.ai_lines_added) - && acceptance_pct <= 100 - { - println!( - " AI acceptance rate {:>6} {} {:>3}%", - "", - bar(acceptance_pct, BAR_WIDTH), - acceptance_pct, - ); + println!(" {GRAY}{}{RESET}", parts.join(" · ")); } - // --- Checkpoints section --- + // --- Human section --- println!(); + println!(" {BOLD}Human{RESET}"); println!( - " {BOLD}Checkpoints{RESET} {:>6}", - format_num(stats.checkpoints.total) - ); - - let total_cp_lines = stats.checkpoints.ai_lines_added + stats.checkpoints.human_lines_added; - if let Some(ai_pct) = (stats.checkpoints.ai_lines_added * 100).checked_div(total_cp_lines) { - let human_pct = 100 - ai_pct; - println!( - " AI edits {:>6} {} {:>3}%", - format_num(stats.checkpoints.ai_lines_added), - bar(ai_pct, BAR_WIDTH), - ai_pct, - ); - println!( - " Human edits {:>6} {} {:>3}%", - format_num(stats.checkpoints.human_lines_added), - bar(human_pct, BAR_WIDTH), - human_pct, - ); - } else { - println!( - " AI edits {:>6}", - format_num(stats.checkpoints.ai_lines_added) - ); - println!( - " Human edits {:>6}", - format_num(stats.checkpoints.human_lines_added) - ); - } - println!( - " Files touched {:>6}", - format_num(stats.checkpoints.files_edited) + " Lines committed {:>6}", + format_num(stats.commits.human_lines) ); - - // --- Sessions section --- - println!(); println!( - " {BOLD}Sessions{RESET} {:>6}", - format_num(stats.sessions.total) + " Edits {:>6}", + format_num(stats.checkpoints.human_lines_added) ); - if !stats.sessions.by_tool.is_empty() { - let parts: Vec = stats - .sessions - .by_tool - .iter() - .map(|(tool, count)| format!("{}: {}", tool, count)) - .collect(); - println!(" {GRAY}By tool: {}{RESET}", parts.join(" · ")); - } - // --- Activity over time --- if !stats.buckets.is_empty() { println!(); @@ -232,7 +184,6 @@ fn print_terminal(stats: &LocalActivityStats) { println!(" {BOLD}Time of day{RESET} {GRAY}(AI lines committed){RESET}"); let max_hour = stats.hourly.iter().copied().max().unwrap_or(1).max(1); - // Render two rows: sparkline + hour labels // Each slot is 3 chars: spark char + 2 spaces. Labels are left-padded to 3. let spark: String = stats .hourly From b43d0cf24461a129039191a8b53c75fd6c9a496e Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 10:48:21 -0400 Subject: [PATCH 012/100] refactor: replace two-bar split with a single combined AI/Human bar Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 2dc760e5e9..89870960bd 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -100,10 +100,9 @@ fn print_terminal(stats: &LocalActivityStats) { if let Some(ai_pct) = (stats.commits.ai_lines * 100).checked_div(total_lines) { let human_pct = 100 - ai_pct; println!( - " {BOLD}AI{RESET} {} {:>3}% {BOLD}Human{RESET} {} {:>3}%", - bar(ai_pct, BAR_WIDTH), + " {} {BOLD}AI{RESET} {:>3}% · {BOLD}Human{RESET} {:>3}%", + bar(ai_pct, 40), ai_pct, - bar(human_pct, BAR_WIDTH), human_pct, ); } From 6eee0af9700060f60a6b8761019b09f1ffd0c7a1 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 10:50:55 -0400 Subject: [PATCH 013/100] feat: show model alongside tool in activity breakdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve the model from tool_model_pairs instead of stripping it, so the breakdown distinguishes sonnet/opus/etc. Trims the redundant tool prefix from the model name (claude::claude-sonnet-4-6 -> "claude · sonnet-4-6"). Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/local_stats.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 7c94d623d9..c2ddbbabff 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -321,16 +321,30 @@ fn aggregate_committed( .flatten() .unwrap_or_default(); for (i, pair) in pairs.iter().enumerate().skip(1) { - let tool = pair.split("::").next().unwrap_or(pair).to_string(); + let label = format_tool_model(pair); let ai_for_tool = ai_vecs.get(i).copied().unwrap_or(0); if ai_for_tool > 0 { - *commit_tool_counts.entry(tool).or_insert(0) += ai_for_tool; + *commit_tool_counts.entry(label).or_insert(0) += ai_for_tool; } } total_ai } +/// Format a "tool::model" pair into a readable "tool · model" label, +/// trimming a redundant tool prefix from the model (e.g. "claude::claude-sonnet-4-6" +/// becomes "claude · sonnet-4-6"). +fn format_tool_model(pair: &str) -> String { + match pair.split_once("::") { + Some((tool, model)) if !model.is_empty() => { + let prefix = format!("{tool}-"); + let model = model.strip_prefix(&prefix).unwrap_or(model); + format!("{tool} · {model}") + } + _ => pair.to_string(), + } +} + fn aggregate_checkpoint( event: &MetricEvent, total_checkpoints: &mut u32, From 5e2d093a88852477cb480919901a51e6418d4d1e Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 10:52:13 -0400 Subject: [PATCH 014/100] refactor: list per-model breakdown one per line instead of inline Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 89870960bd..f57098965b 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -132,14 +132,8 @@ fn print_terminal(stats: &LocalActivityStats) { { println!(" Acceptance rate {:>5}%", acceptance_pct); } - if !stats.commits.by_tool.is_empty() { - let parts: Vec = stats - .commits - .by_tool - .iter() - .map(|(tool, count)| format!("{}: {}", tool, format_num(*count))) - .collect(); - println!(" {GRAY}{}{RESET}", parts.join(" · ")); + for (tool, count) in &stats.commits.by_tool { + println!(" {GRAY}{}: {}{RESET}", tool, format_num(*count)); } // --- Human section --- From 25e2e621683d631ab77cb41e1777621edd44bbad Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 10:54:58 -0400 Subject: [PATCH 015/100] Add 60d to period filter of git ai activity --- src/commands/activity.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index f57098965b..505c5fc2d1 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -33,6 +33,7 @@ pub fn handle_activity(args: &[String]) { "3d" => (days_ago(3), "last 3 days".to_string(), BucketGranularity::Daily), "7d" => (days_ago(7), "last 7 days".to_string(), BucketGranularity::Daily), "30d" => (days_ago(30), "last 30 days".to_string(), BucketGranularity::Weekly), + "60d" => (days_ago(60), "last 60 days".to_string(), BucketGranularity::Weekly), "all" => (0u32, "all time".to_string(), BucketGranularity::Monthly), other => { eprintln!("Unknown period '{}'. Use 1d, 3d, 7d, 30d, or all.", other); From 6826baecacaa89631d162254a5ddecaa45cb807d Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 10:56:03 -0400 Subject: [PATCH 016/100] fix spacing in CLI --- src/commands/activity.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 505c5fc2d1..3b7f542738 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -76,9 +76,9 @@ fn print_help() { eprintln!("Usage: git-ai activity [options]"); eprintln!(); eprintln!("Options:"); - eprintln!(" --period <1d|3d|7d|30d|all> Time window (default: 30d)"); - eprintln!(" --json Output as JSON"); - eprintln!(" --help Show this help"); + eprintln!(" --period <1d|3d|7d|30d|60d|all> Time window (default: 30d)"); + eprintln!(" --json Output as JSON"); + eprintln!(" --help Show this help"); eprintln!(); eprintln!("Statistics are sourced from locally recorded metric events."); eprintln!("Events accumulate over time and are never deleted from local storage."); From fdea10a6b8c9a253bc592cd3081da0cb61f61fc2 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 12:14:21 -0400 Subject: [PATCH 017/100] feat: add attribution coverage metric to git-ai activity Surface how much committed code was confidently attributed to AI or known-human vs left "untracked". Computed as (ai + human) / total git diff additions. Answers the "can I trust these numbers" question by exposing the size of the unattributed holes in the data. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 14 ++++++++++++++ src/metrics/local_stats.rs | 15 ++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 3b7f542738..df84e74b6f 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -108,6 +108,20 @@ fn print_terminal(stats: &LocalActivityStats) { ); } + // --- Attribution coverage: how much committed code we confidently attributed --- + let attributed = stats.commits.ai_lines + stats.commits.human_lines; + if let Some(coverage_pct) = (attributed * 100).checked_div(stats.commits.diff_added_lines) { + let untracked = stats.commits.diff_added_lines.saturating_sub(attributed); + let untracked_pct = 100 - coverage_pct; + println!( + " {} {BOLD}Attributed{RESET} {:>3}% {GRAY}· {} untracked ({}%){RESET}", + bar(coverage_pct, 40), + coverage_pct, + format_num(untracked), + untracked_pct, + ); + } + // --- AI section --- println!(); println!(" {BOLD}AI{RESET}"); diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index c2ddbbabff..c567267d7d 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -37,6 +37,10 @@ pub struct CommitSummary { pub total: u32, pub ai_lines: u32, pub human_lines: u32, + /// Total lines added across all commits (git diff additions), used to + /// measure attribution coverage: lines not attributed to AI or known-human + /// are "untracked" holes in the data. + pub diff_added_lines: u32, /// Per-tool AI line counts, sorted descending. Tool name only (strips "::model" suffix). pub by_tool: Vec<(String, u32)>, } @@ -79,6 +83,7 @@ pub fn compute_activity( let mut total_commits = 0u32; let mut total_ai_lines = 0u32; let mut total_human_lines = 0u32; + let mut total_diff_added = 0u32; let mut commit_tool_counts: HashMap = HashMap::new(); let mut total_checkpoints = 0u32; @@ -109,6 +114,7 @@ pub fn compute_activity( &mut total_commits, &mut total_ai_lines, &mut total_human_lines, + &mut total_diff_added, &mut commit_tool_counts, ); @@ -161,6 +167,7 @@ pub fn compute_activity( total: total_commits, ai_lines: total_ai_lines, human_lines: total_human_lines, + diff_added_lines: total_diff_added, by_tool: commit_by_tool, }, checkpoints: CheckpointSummary { @@ -295,18 +302,24 @@ fn aggregate_committed( total_commits: &mut u32, total_ai_lines: &mut u32, total_human_lines: &mut u32, + total_diff_added: &mut u32, commit_tool_counts: &mut HashMap, ) -> u32 { let human = sparse_get_u32(&event.values, committed_pos::HUMAN_ADDITIONS) .flatten() .unwrap_or(0); + let diff_added = sparse_get_u32(&event.values, committed_pos::GIT_DIFF_ADDED_LINES) + .flatten() + .unwrap_or(0); let ai_vecs = sparse_get_vec_u32(&event.values, committed_pos::AI_ADDITIONS) .flatten() .unwrap_or_default(); let total_ai = ai_vecs.first().copied().unwrap_or(0); - // Always accumulate human lines regardless of whether the commit has AI lines. + // Always accumulate human lines and total diff additions regardless of + // whether the commit has AI lines (coverage spans all committed code). *total_human_lines += human; + *total_diff_added += diff_added; // Only count the commit and accumulate AI lines when AI was involved. if total_ai == 0 { From 7d251ad0e83f30923e5fccef656164ac70a74716 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 12:22:43 -0400 Subject: [PATCH 018/100] refactor: move attribution coverage into per-bucket activity-over-time Replace the single summarized "Attributed" bar with per-bucket coverage in the Activity over time chart, so the trend (improving/degrading) is visible rather than one aggregate number. Buckets now accumulate diff additions and attributed lines across all commits; AI-lines bar and commit count remain AI-only for consistency. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 22 +++----- src/metrics/local_stats.rs | 105 ++++++++++++++++++++++--------------- 2 files changed, 69 insertions(+), 58 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index df84e74b6f..a7d6556c17 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -108,20 +108,6 @@ fn print_terminal(stats: &LocalActivityStats) { ); } - // --- Attribution coverage: how much committed code we confidently attributed --- - let attributed = stats.commits.ai_lines + stats.commits.human_lines; - if let Some(coverage_pct) = (attributed * 100).checked_div(stats.commits.diff_added_lines) { - let untracked = stats.commits.diff_added_lines.saturating_sub(attributed); - let untracked_pct = 100 - coverage_pct; - println!( - " {} {BOLD}Attributed{RESET} {:>3}% {GRAY}· {} untracked ({}%){RESET}", - bar(coverage_pct, 40), - coverage_pct, - format_num(untracked), - untracked_pct, - ); - } - // --- AI section --- println!(); println!(" {BOLD}AI{RESET}"); @@ -173,12 +159,18 @@ fn print_terminal(stats: &LocalActivityStats) { let empty = BAR_WIDTH - filled; let bar_str = format!("{}{}", "█".repeat(filled as usize), "░".repeat(empty as usize)); if bucket.ai_lines > 0 { + // Coverage for this bucket: attributed / total diff additions. + let coverage = (bucket.attributed_lines * 100) + .checked_div(bucket.diff_added_lines) + .map(|pct| format!(" · {}% attributed", pct)) + .unwrap_or_default(); println!( - " {GRAY}{}{RESET} {} {GRAY}{} lines · {} commits{RESET}", + " {GRAY}{}{RESET} {} {GRAY}{} lines · {} commits{}{RESET}", bucket.label, bar_str, format_num(bucket.ai_lines), bucket.commit_count, + coverage, ); } else { println!(" {GRAY}{} {}{RESET}", bucket.label, bar_str); diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index c567267d7d..19db641e09 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -30,6 +30,10 @@ pub struct BucketStats { pub label: String, pub ai_lines: u32, pub commit_count: u32, + /// Total git diff additions in this bucket (across all commits). + pub diff_added_lines: u32, + /// Lines attributed to AI or known-human in this bucket. + pub attributed_lines: u32, } #[derive(Debug, Serialize)] @@ -94,8 +98,8 @@ pub fn compute_activity( let mut session_ids: HashSet = HashSet::new(); let mut session_tool_counts: HashMap = HashMap::new(); - // bucket_key -> (ai_lines, commit_count) - let mut bucket_map: HashMap = HashMap::new(); + // bucket_key -> accumulated stats + let mut bucket_map: HashMap = HashMap::new(); // bucket_key -> sort key (for ordering) let mut bucket_order: HashMap = HashMap::new(); @@ -109,7 +113,7 @@ pub fn compute_activity( match record.event_id { 1 => { - let ai_lines_this = aggregate_committed( + let c = aggregate_committed( &event, &mut total_commits, &mut total_ai_lines, @@ -118,15 +122,23 @@ pub fn compute_activity( &mut commit_tool_counts, ); - if ai_lines_this > 0 { + // Bucket every commit that added lines so coverage spans all + // committed code, not just AI commits. + if c.diff_added > 0 { let local_dt = ts_to_local(record.ts); - let hour = local_dt.hour() as usize; - hourly[hour] += ai_lines_this; + if c.ai_lines > 0 { + hourly[local_dt.hour() as usize] += c.ai_lines; + } let (key, order_key) = bucket_key(&local_dt, granularity); - let entry = bucket_map.entry(key.clone()).or_insert((0, 0)); - entry.0 += ai_lines_this; - entry.1 += 1; + let entry = bucket_map.entry(key.clone()).or_default(); + entry.ai_lines += c.ai_lines; + // Count AI commits only, to match the AI-lines bar. + if c.ai_lines > 0 { + entry.commit_count += 1; + } + entry.diff_added += c.diff_added; + entry.attributed += c.ai_lines + c.human_lines; bucket_order.entry(key).or_insert(order_key); } } @@ -148,18 +160,14 @@ pub fn compute_activity( let mut session_by_tool: Vec<(String, u32)> = session_tool_counts.into_iter().collect(); session_by_tool.sort_by_key(|&(_, count)| Reverse(count)); - // Sort buckets chronologically using their order key. - let mut bucket_pairs: Vec<(String, i64, u32, u32)> = bucket_map + // Map by order key for fill_buckets to look up real data. + let bucket_by_order: HashMap = bucket_map .into_iter() - .map(|(label, (ai, commits))| { - let order = bucket_order[&label]; - (label, order, ai, commits) - }) + .map(|(label, accum)| (bucket_order[&label], accum)) .collect(); - bucket_pairs.sort_by_key(|&(_, order, _, _)| order); // Fill in empty buckets between since_ts and now so the chart has no gaps. - let filled = fill_buckets(bucket_pairs, since_ts, granularity); + let filled = fill_buckets(bucket_by_order, since_ts, granularity); Ok(LocalActivityStats { period_label, @@ -218,19 +226,21 @@ fn bucket_key(dt: &DateTime, granularity: BucketGranularity) -> (String, /// Fill gaps between `since_ts` and today so charts have contiguous buckets. fn fill_buckets( - data: Vec<(String, i64, u32, u32)>, + mut data_map: HashMap, since_ts: u32, granularity: BucketGranularity, ) -> Vec { - // Build a map from order_key → (label, ai, commits) from real data. - let mut data_map: HashMap = data - .into_iter() - .map(|(label, order, ai, commits)| (order, (label, ai, commits))) - .collect(); - let now = Local::now(); let since_dt = ts_to_local(since_ts); + let make = |label: String, accum: BucketAccum| BucketStats { + label, + ai_lines: accum.ai_lines, + commit_count: accum.commit_count, + diff_added_lines: accum.diff_added, + attributed_lines: accum.attributed, + }; + // Generate all expected bucket keys between since and now. let mut result = Vec::new(); match granularity { @@ -240,11 +250,7 @@ fn fill_buckets( while day <= today { let order = day.num_days_from_ce() as i64; let label = day.format("%b %d").to_string(); - let (ai, commits) = data_map - .remove(&order) - .map(|(_, ai, c)| (ai, c)) - .unwrap_or((0, 0)); - result.push(BucketStats { label, ai_lines: ai, commit_count: commits }); + result.push(make(label, data_map.remove(&order).unwrap_or_default())); day = day.succ_opt().unwrap_or(today); } } @@ -257,11 +263,7 @@ fn fill_buckets( let order = monday.num_days_from_ce() as i64; let sunday = monday + chrono::Duration::days(6); let label = format!("{} – {}", monday.format("%b %d"), sunday.format("%b %d")); - let (ai, commits) = data_map - .remove(&order) - .map(|(_, ai, c)| (ai, c)) - .unwrap_or((0, 0)); - result.push(BucketStats { label, ai_lines: ai, commit_count: commits }); + result.push(make(label, data_map.remove(&order).unwrap_or_default())); monday = monday .checked_add_signed(chrono::Duration::weeks(1)) .unwrap_or(today); @@ -276,11 +278,7 @@ fn fill_buckets( let order = year as i64 * 12 + (month - 1) as i64; let date = NaiveDate::from_ymd_opt(year, month, 1).unwrap(); let label = date.format("%b %Y").to_string(); - let (ai, commits) = data_map - .remove(&order) - .map(|(_, ai, c)| (ai, c)) - .unwrap_or((0, 0)); - result.push(BucketStats { label, ai_lines: ai, commit_count: commits }); + result.push(make(label, data_map.remove(&order).unwrap_or_default())); if year == now_year && month == now_month { break; } @@ -296,7 +294,22 @@ fn fill_buckets( result } -/// Returns the AI lines for this commit (0 if none). +/// Per-bucket accumulator for the activity-over-time chart. +#[derive(Debug, Default, Clone)] +struct BucketAccum { + ai_lines: u32, + commit_count: u32, + diff_added: u32, + attributed: u32, +} + +/// Per-commit contribution returned by `aggregate_committed` for bucketing. +struct CommitContribution { + ai_lines: u32, + human_lines: u32, + diff_added: u32, +} + fn aggregate_committed( event: &MetricEvent, total_commits: &mut u32, @@ -304,7 +317,7 @@ fn aggregate_committed( total_human_lines: &mut u32, total_diff_added: &mut u32, commit_tool_counts: &mut HashMap, -) -> u32 { +) -> CommitContribution { let human = sparse_get_u32(&event.values, committed_pos::HUMAN_ADDITIONS) .flatten() .unwrap_or(0); @@ -321,9 +334,15 @@ fn aggregate_committed( *total_human_lines += human; *total_diff_added += diff_added; + let contribution = CommitContribution { + ai_lines: total_ai, + human_lines: human, + diff_added, + }; + // Only count the commit and accumulate AI lines when AI was involved. if total_ai == 0 { - return 0; + return contribution; } *total_commits += 1; @@ -341,7 +360,7 @@ fn aggregate_committed( } } - total_ai + contribution } /// Format a "tool::model" pair into a readable "tool · model" label, From 39393671399eb45fea82ec457cbc66732f387fe1 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 12:31:16 -0400 Subject: [PATCH 019/100] feat: add token usage and estimated cost to git-ai activity Extract per-message token usage (input/output/cache read/cache write) from SessionEvent transcript JSON, grouped by model, with an estimated USD cost from a built-in Anthropic pricing table (labeled estimate). Dedups by assistant message id keeping field-wise max, since the incremental transcript reader re-emits the same message and streaming partials report lower token counts than the final message. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 34 +++++++ src/metrics/local_stats.rs | 175 ++++++++++++++++++++++++++++++++++++- 2 files changed, 207 insertions(+), 2 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index a7d6556c17..22ee192d8f 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -149,6 +149,36 @@ fn print_terminal(stats: &LocalActivityStats) { format_num(stats.checkpoints.human_lines_added) ); + // --- Tokens section --- + let t = &stats.tokens; + if t.input + t.output + t.cache_read + t.cache_creation > 0 { + println!(); + println!(" {BOLD}Tokens{RESET} {GRAY}(estimated cost){RESET}"); + println!(" Input {:>12}", format_num_u64(t.input)); + println!(" Output {:>12}", format_num_u64(t.output)); + println!(" Cache read {:>12}", format_num_u64(t.cache_read)); + println!(" Cache write {:>12}", format_num_u64(t.cache_creation)); + if t.estimated_cost_usd > 0.0 { + println!( + " {BOLD}Est. cost{RESET} {:>12}", + format!("~${:.2}", t.estimated_cost_usd) + ); + } + for m in &t.by_model { + let cost = m + .estimated_cost_usd + .map(|c| format!(" ~${:.2}", c)) + .unwrap_or_default(); + let total = m.input + m.output + m.cache_read + m.cache_creation; + println!( + " {GRAY}{}: {} tokens{}{RESET}", + m.model, + format_num_u64(total), + cost, + ); + } + } + // --- Activity over time --- if !stats.buckets.is_empty() { println!(); @@ -240,6 +270,10 @@ fn bar(pct: u32, width: u32) -> String { } fn format_num(n: u32) -> String { + format_num_u64(n as u64) +} + +fn format_num_u64(n: u64) -> String { let s = n.to_string(); let mut result = String::new(); for (i, c) in s.chars().rev().enumerate() { diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 19db641e09..9ddb048eea 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -3,7 +3,7 @@ use crate::error::GitAiError; use crate::metrics::attrs::attr_pos; use crate::metrics::db::MetricsDatabase; -use crate::metrics::events::{checkpoint_pos, committed_pos}; +use crate::metrics::events::{checkpoint_pos, committed_pos, session_event_pos}; use crate::metrics::pos_encoded::{ sparse_get_string, sparse_get_u32, sparse_get_vec_string, sparse_get_vec_u32, }; @@ -19,12 +19,36 @@ pub struct LocalActivityStats { pub commits: CommitSummary, pub checkpoints: CheckpointSummary, pub sessions: SessionSummary, + pub tokens: TokenSummary, /// Activity bucketed by day/week/month depending on period. pub buckets: Vec, /// AI lines committed per hour of day (local time), 24 elements. pub hourly: Vec, } +#[derive(Debug, Default, Serialize)] +pub struct TokenSummary { + pub input: u64, + pub output: u64, + pub cache_read: u64, + pub cache_creation: u64, + /// Estimated cost in USD, summed across models with known pricing. + pub estimated_cost_usd: f64, + /// Per-model breakdown, sorted by total tokens descending. + pub by_model: Vec, +} + +#[derive(Debug, Default, Serialize)] +pub struct TokenModelStat { + pub model: String, + pub input: u64, + pub output: u64, + pub cache_read: u64, + pub cache_creation: u64, + /// Estimated cost in USD; None if the model has no pricing entry. + pub estimated_cost_usd: Option, +} + #[derive(Debug, Serialize)] pub struct BucketStats { pub label: String, @@ -98,6 +122,12 @@ pub fn compute_activity( let mut session_ids: HashSet = HashSet::new(); let mut session_tool_counts: HashMap = HashMap::new(); + // Token usage keyed by assistant message id. The incremental transcript + // reader re-emits the same message multiple times, and streaming partials + // carry lower token counts than the final message — so we keep the + // field-wise max per message id, then fold into per-model totals. + let mut message_usage: HashMap = HashMap::new(); + // bucket_key -> accumulated stats let mut bucket_map: HashMap = HashMap::new(); // bucket_key -> sort key (for ordering) @@ -149,7 +179,10 @@ pub fn compute_activity( &mut human_lines_added, &mut files_edited, ), - 5 => aggregate_session(&event, &mut session_ids, &mut session_tool_counts), + 5 => { + aggregate_session(&event, &mut session_ids, &mut session_tool_counts); + aggregate_session_tokens(&event, &mut message_usage); + } _ => {} } } @@ -160,6 +193,8 @@ pub fn compute_activity( let mut session_by_tool: Vec<(String, u32)> = session_tool_counts.into_iter().collect(); session_by_tool.sort_by_key(|&(_, count)| Reverse(count)); + let tokens = build_token_summary(message_usage); + // Map by order key for fill_buckets to look up real data. let bucket_by_order: HashMap = bucket_map .into_iter() @@ -188,11 +223,104 @@ pub fn compute_activity( total: session_ids.len() as u32, by_tool: session_by_tool, }, + tokens, buckets: filled, hourly, }) } +/// Per-model token accumulator. +#[derive(Debug, Default, Clone)] +struct TokenAccum { + input: u64, + output: u64, + cache_read: u64, + cache_creation: u64, +} + +/// Per-million-token pricing for a model (USD). +struct ModelPricing { + input: f64, + output: f64, + cache_write: f64, + cache_read: f64, +} + +/// Built-in pricing estimate, matched by substring of the model id. +/// Rates are public Anthropic list prices (USD per million tokens) and are +/// only an estimate — they go stale as pricing changes. +fn pricing_for(model: &str) -> Option { + let m = model.to_lowercase(); + if m.contains("opus") { + Some(ModelPricing { input: 15.0, output: 75.0, cache_write: 18.75, cache_read: 1.5 }) + } else if m.contains("sonnet") { + Some(ModelPricing { input: 3.0, output: 15.0, cache_write: 3.75, cache_read: 0.3 }) + } else if m.contains("haiku") { + Some(ModelPricing { input: 0.8, output: 4.0, cache_write: 1.0, cache_read: 0.08 }) + } else { + None + } +} + +fn estimate_cost(acc: &TokenAccum, pricing: &ModelPricing) -> f64 { + (acc.input as f64 * pricing.input + + acc.output as f64 * pricing.output + + acc.cache_creation as f64 * pricing.cache_write + + acc.cache_read as f64 * pricing.cache_read) + / 1_000_000.0 +} + +/// Shorten a model id for display: strip a trailing "-YYYYMMDD" date snapshot +/// (e.g. "claude-haiku-4-5-20251001" -> "claude-haiku-4-5"). +fn shorten_model(model: &str) -> String { + match model.rsplit_once('-') { + Some((head, tail)) if tail.len() == 8 && tail.chars().all(|c| c.is_ascii_digit()) => { + head.to_string() + } + _ => model.to_string(), + } +} + +fn build_token_summary(message_usage: HashMap) -> TokenSummary { + // Fold per-message (deduped, max) usage into per-model totals. + let mut model_tokens: HashMap = HashMap::new(); + for (_id, (model, acc)) in message_usage { + let entry = model_tokens.entry(model).or_default(); + entry.input += acc.input; + entry.output += acc.output; + entry.cache_read += acc.cache_read; + entry.cache_creation += acc.cache_creation; + } + + let mut summary = TokenSummary::default(); + let mut by_model: Vec = Vec::new(); + + for (model, acc) in model_tokens { + summary.input += acc.input; + summary.output += acc.output; + summary.cache_read += acc.cache_read; + summary.cache_creation += acc.cache_creation; + + let cost = pricing_for(&model).map(|p| estimate_cost(&acc, &p)); + if let Some(c) = cost { + summary.estimated_cost_usd += c; + } + + by_model.push(TokenModelStat { + model: shorten_model(&model), + input: acc.input, + output: acc.output, + cache_read: acc.cache_read, + cache_creation: acc.cache_creation, + estimated_cost_usd: cost, + }); + } + + by_model.sort_by_key(|m| Reverse(m.input + m.output + m.cache_read + m.cache_creation)); + summary.by_model = by_model; + summary +} + fn ts_to_local(ts: u32) -> DateTime { Local .timestamp_opt(ts as i64, 0) @@ -423,3 +551,46 @@ fn aggregate_session( *session_tool_counts.entry(tool).or_insert(0) += 1; } } + +/// Extract token usage from a session event's raw transcript JSON (position 0). +/// Only assistant messages carry usage. Keyed by message id, keeping the +/// field-wise max across re-emitted copies (streaming partials report lower +/// counts than the final message). +fn aggregate_session_tokens( + event: &MetricEvent, + message_usage: &mut HashMap, +) { + let Some(raw) = event.values.get(&session_event_pos::RAW_JSON.to_string()) else { + return; + }; + let Some(message) = raw.get("message") else { + return; + }; + if message.get("role").and_then(|r| r.as_str()) != Some("assistant") { + return; + } + let Some(usage) = message.get("usage") else { + return; + }; + let Some(id) = message.get("id").and_then(|i| i.as_str()) else { + return; + }; + + let model = message + .get("model") + .and_then(|m| m.as_str()) + .unwrap_or("unknown") + .to_string(); + + let get = |key: &str| usage.get(key).and_then(|v| v.as_u64()).unwrap_or(0); + + let (_, acc) = message_usage + .entry(id.to_string()) + .or_insert_with(|| (model, TokenAccum::default())); + // Field-wise max: input/cache are fixed per message; output grows while + // streaming, so the final (largest) value is authoritative. + acc.input = acc.input.max(get("input_tokens")); + acc.output = acc.output.max(get("output_tokens")); + acc.cache_read = acc.cache_read.max(get("cache_read_input_tokens")); + acc.cache_creation = acc.cache_creation.max(get("cache_creation_input_tokens")); +} From db07bcf1dd32c0ba0af64b975737aaba492ae07e Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 12:37:49 -0400 Subject: [PATCH 020/100] feat: add codex token usage parsing to git-ai activity Codex transcripts use a different schema than Claude: cumulative per-session totals on token_count events (payload.info.total_token_usage) rather than per-message usage. Parse them via a session-keyed accumulator that keeps the running max, mapping codex field semantics onto ours (input_tokens includes cached, so non-cached input is the difference; cached -> cache_read; no cache-creation concept). Model name is captured from the separate payload.model event. Adds GPT-5 family pricing estimate. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/local_stats.rs | 93 +++++++++++++++++++++++++++++++++++--- 1 file changed, 87 insertions(+), 6 deletions(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 9ddb048eea..4bcb29dc28 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -122,12 +122,17 @@ pub fn compute_activity( let mut session_ids: HashSet = HashSet::new(); let mut session_tool_counts: HashMap = HashMap::new(); - // Token usage keyed by assistant message id. The incremental transcript - // reader re-emits the same message multiple times, and streaming partials - // carry lower token counts than the final message — so we keep the + // Claude-shaped token usage keyed by assistant message id. The incremental + // transcript reader re-emits the same message multiple times, and streaming + // partials carry lower token counts than the final message — so we keep the // field-wise max per message id, then fold into per-model totals. let mut message_usage: HashMap = HashMap::new(); + // Codex-shaped token usage keyed by session id. Codex reports cumulative + // session totals (total_token_usage) on each token_count event, so we keep + // the per-session max rather than summing. + let mut codex_sessions: HashMap = HashMap::new(); + // bucket_key -> accumulated stats let mut bucket_map: HashMap = HashMap::new(); // bucket_key -> sort key (for ordering) @@ -181,7 +186,14 @@ pub fn compute_activity( ), 5 => { aggregate_session(&event, &mut session_ids, &mut session_tool_counts); - aggregate_session_tokens(&event, &mut message_usage); + let tool = sparse_get_string(&event.attrs, attr_pos::TOOL) + .flatten() + .unwrap_or_default(); + if tool == "codex" { + aggregate_codex_tokens(&event, &mut codex_sessions); + } else { + aggregate_session_tokens(&event, &mut message_usage); + } } _ => {} } @@ -193,7 +205,7 @@ pub fn compute_activity( let mut session_by_tool: Vec<(String, u32)> = session_tool_counts.into_iter().collect(); session_by_tool.sort_by_key(|&(_, count)| Reverse(count)); - let tokens = build_token_summary(message_usage); + let tokens = build_token_summary(message_usage, codex_sessions); // Map by order key for fill_buckets to look up real data. let bucket_by_order: HashMap = bucket_map @@ -238,6 +250,20 @@ struct TokenAccum { cache_creation: u64, } +/// Per-session codex accumulator. Codex reports *cumulative* session totals on +/// each `token_count` event, so we track the max of each raw field. The model +/// name arrives on a separate event (`payload.model`), captured when seen. +#[derive(Debug, Default, Clone)] +struct CodexSessionAccum { + model: Option, + /// Cumulative input tokens (includes cached). + input_tokens: u64, + /// Cumulative cached input tokens (subset of input_tokens). + cached_input_tokens: u64, + /// Cumulative output tokens (includes reasoning). + output_tokens: u64, +} + /// Per-million-token pricing for a model (USD). struct ModelPricing { input: f64, @@ -257,6 +283,10 @@ fn pricing_for(model: &str) -> Option { Some(ModelPricing { input: 3.0, output: 15.0, cache_write: 3.75, cache_read: 0.3 }) } else if m.contains("haiku") { Some(ModelPricing { input: 0.8, output: 4.0, cache_write: 1.0, cache_read: 0.08 }) + } else if m.contains("gpt") { + // OpenAI GPT-5 family estimate; cache_write unused (codex reports no + // cache-creation tokens). + Some(ModelPricing { input: 1.25, output: 10.0, cache_write: 1.25, cache_read: 0.125 }) } else { None } @@ -281,7 +311,10 @@ fn shorten_model(model: &str) -> String { } } -fn build_token_summary(message_usage: HashMap) -> TokenSummary { +fn build_token_summary( + message_usage: HashMap, + codex_sessions: HashMap, +) -> TokenSummary { // Fold per-message (deduped, max) usage into per-model totals. let mut model_tokens: HashMap = HashMap::new(); for (_id, (model, acc)) in message_usage { @@ -292,6 +325,18 @@ fn build_token_summary(message_usage: HashMap) -> entry.cache_creation += acc.cache_creation; } + // Fold per-session codex totals into per-model totals, mapping codex's + // field semantics onto ours: codex input_tokens *includes* cached, so the + // non-cached input is the difference; cached maps to cache_read; codex has + // no cache-creation concept. + for (_sid, acc) in codex_sessions { + let model = acc.model.unwrap_or_else(|| "codex".to_string()); + let entry = model_tokens.entry(model).or_default(); + entry.input += acc.input_tokens.saturating_sub(acc.cached_input_tokens); + entry.output += acc.output_tokens; + entry.cache_read += acc.cached_input_tokens; + } + let mut summary = TokenSummary::default(); let mut by_model: Vec = Vec::new(); @@ -594,3 +639,39 @@ fn aggregate_session_tokens( acc.cache_read = acc.cache_read.max(get("cache_read_input_tokens")); acc.cache_creation = acc.cache_creation.max(get("cache_creation_input_tokens")); } + +/// Extract token usage from a codex session event. Codex emits `token_count` +/// events carrying cumulative `payload.info.total_token_usage`, and reports its +/// model on a separate event via `payload.model`. Both are keyed by session id; +/// cumulative totals are tracked as a per-session max. +fn aggregate_codex_tokens( + event: &MetricEvent, + codex_sessions: &mut HashMap, +) { + let Some(session_id) = sparse_get_string(&event.attrs, attr_pos::SESSION_ID).flatten() else { + return; + }; + let Some(raw) = event.values.get(&session_event_pos::RAW_JSON.to_string()) else { + return; + }; + let Some(payload) = raw.get("payload") else { + return; + }; + + let entry = codex_sessions.entry(session_id).or_default(); + + // Capture the model name when it appears (not on token_count events). + if let Some(model) = payload.get("model").and_then(|m| m.as_str()) + && entry.model.is_none() + { + entry.model = Some(model.to_string()); + } + + // Cumulative session totals; keep the running max. + if let Some(usage) = payload.get("info").and_then(|i| i.get("total_token_usage")) { + let get = |key: &str| usage.get(key).and_then(|v| v.as_u64()).unwrap_or(0); + entry.input_tokens = entry.input_tokens.max(get("input_tokens")); + entry.cached_input_tokens = entry.cached_input_tokens.max(get("cached_input_tokens")); + entry.output_tokens = entry.output_tokens.max(get("output_tokens")); + } +} From ac577f1eb0b04d7e53dfdd9c7d0d56fadb24c417 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 12:52:55 -0400 Subject: [PATCH 021/100] feat: add yield classification to git-ai activity sessions For each session, check whether a commit landed within 4 hours of the session's last observed event. Surfaces shipped / abandoned / yield% inline with the Sessions count in the AI section. Co-Authored-By: Claude Opus 4.7 --- src/commands/activity.rs | 20 ++++++++++++++---- src/metrics/local_stats.rs | 43 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 22ee192d8f..ac111d2f84 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -111,10 +111,22 @@ fn print_terminal(stats: &LocalActivityStats) { // --- AI section --- println!(); println!(" {BOLD}AI{RESET}"); - println!( - " Sessions {:>6}", - format_num(stats.sessions.total) - ); + let yield_total = stats.sessions.yield_stats.shipped + stats.sessions.yield_stats.abandoned; + if yield_total > 0 { + let shipped_pct = stats.sessions.yield_stats.shipped * 100 / yield_total; + println!( + " Sessions {:>6} {GRAY}({} shipped · {} abandoned · {}% yield){RESET}", + format_num(stats.sessions.total), + format_num(stats.sessions.yield_stats.shipped), + format_num(stats.sessions.yield_stats.abandoned), + shipped_pct, + ); + } else { + println!( + " Sessions {:>6}", + format_num(stats.sessions.total) + ); + } println!( " Commits {:>6}", format_num(stats.commits.total) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 4bcb29dc28..413d1d5da0 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -85,6 +85,17 @@ pub struct CheckpointSummary { pub struct SessionSummary { pub total: u32, pub by_tool: Vec<(String, u32)>, + pub yield_stats: YieldStats, +} + +/// Classifies sessions by whether they were followed by a commit within +/// a short window — a proxy for "did this AI session actually ship work?" +#[derive(Debug, Default, Serialize)] +pub struct YieldStats { + /// Sessions followed by at least one commit within `YIELD_WINDOW_SECS`. + pub shipped: u32, + /// Sessions with no commit found within the window. + pub abandoned: u32, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -140,6 +151,11 @@ pub fn compute_activity( let mut hourly: Vec = vec![0u32; 24]; + // Yield classification: track the latest timestamp seen per session, and + // all commit timestamps, then correlate after the loop. + let mut session_last_ts: HashMap = HashMap::new(); + let mut commit_timestamps: Vec = Vec::new(); + for record in &records { let event: MetricEvent = match serde_json::from_str(&record.event_json) { Ok(e) => e, @@ -148,6 +164,7 @@ pub fn compute_activity( match record.event_id { 1 => { + commit_timestamps.push(record.ts); let c = aggregate_committed( &event, &mut total_commits, @@ -186,6 +203,14 @@ pub fn compute_activity( ), 5 => { aggregate_session(&event, &mut session_ids, &mut session_tool_counts); + + // Track last-seen timestamp per session for yield classification. + if let Some(sid) = + sparse_get_string(&event.attrs, attr_pos::SESSION_ID).flatten() + { + let entry = session_last_ts.entry(sid).or_insert(0); + *entry = (*entry).max(record.ts); + } let tool = sparse_get_string(&event.attrs, attr_pos::TOOL) .flatten() .unwrap_or_default(); @@ -199,6 +224,23 @@ pub fn compute_activity( } } + // Yield classification: for each unique session, check if a commit landed + // within 4 hours of the session's last observed event. + const YIELD_WINDOW_SECS: u32 = 4 * 3600; + commit_timestamps.sort_unstable(); + let mut yield_shipped = 0u32; + let mut yield_abandoned = 0u32; + for (_sid, last_ts) in &session_last_ts { + let window_end = last_ts.saturating_add(YIELD_WINDOW_SECS); + // Find the first commit at or after this session's last event. + let pos = commit_timestamps.partition_point(|&t| t < *last_ts); + if commit_timestamps.get(pos).map_or(false, |&t| t <= window_end) { + yield_shipped += 1; + } else { + yield_abandoned += 1; + } + } + let mut commit_by_tool: Vec<(String, u32)> = commit_tool_counts.into_iter().collect(); commit_by_tool.sort_by_key(|&(_, count)| Reverse(count)); @@ -234,6 +276,7 @@ pub fn compute_activity( sessions: SessionSummary { total: session_ids.len() as u32, by_tool: session_by_tool, + yield_stats: YieldStats { shipped: yield_shipped, abandoned: yield_abandoned }, }, tokens, buckets: filled, From ee62aaac2da00a0c2b0e1f285799419dc4f2fad2 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 12:56:45 -0400 Subject: [PATCH 022/100] feat: add day-of-week dimension to git-ai activity Accumulates AI lines committed per weekday (Mon-Sun) from committed event timestamps and renders a sparkline heatmap below the hourly time-of-day chart. Co-Authored-By: Claude Opus 4.7 --- src/commands/activity.rs | 16 ++++++++++++++++ src/metrics/local_stats.rs | 6 ++++++ 2 files changed, 22 insertions(+) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index ac111d2f84..fe398e0ab2 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -251,6 +251,22 @@ fn print_terminal(stats: &LocalActivityStats) { println!(" {GRAY}{}{RESET}", label_row.trim_end()); } + // --- Day of week heatmap --- + if stats.daily.iter().any(|&v| v > 0) { + println!(); + println!(" {BOLD}Day of week{RESET} {GRAY}(AI lines committed){RESET}"); + let max_day = stats.daily.iter().copied().max().unwrap_or(1).max(1); + let spark: String = stats + .daily + .iter() + .map(|&v| spark_char(v, max_day)) + .collect::>() + .join(" "); + println!(" {}", spark); + let label_row = "Mon Tue Wed Thu Fri Sat Sun"; + println!(" {GRAY}{}{RESET}", label_row); + } + println!(); } diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 413d1d5da0..fe901a3a2b 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -24,6 +24,8 @@ pub struct LocalActivityStats { pub buckets: Vec, /// AI lines committed per hour of day (local time), 24 elements. pub hourly: Vec, + /// AI lines committed per day of week (local time), 7 elements: Mon=0 … Sun=6. + pub daily: Vec, } #[derive(Debug, Default, Serialize)] @@ -150,6 +152,7 @@ pub fn compute_activity( let mut bucket_order: HashMap = HashMap::new(); let mut hourly: Vec = vec![0u32; 24]; + let mut daily: Vec = vec![0u32; 7]; // Yield classification: track the latest timestamp seen per session, and // all commit timestamps, then correlate after the loop. @@ -180,6 +183,8 @@ pub fn compute_activity( let local_dt = ts_to_local(record.ts); if c.ai_lines > 0 { hourly[local_dt.hour() as usize] += c.ai_lines; + // Weekday: Mon=0 … Sun=6 (chrono's num_days_from_monday). + daily[local_dt.weekday().num_days_from_monday() as usize] += c.ai_lines; } let (key, order_key) = bucket_key(&local_dt, granularity); @@ -281,6 +286,7 @@ pub fn compute_activity( tokens, buckets: filled, hourly, + daily, }) } From 980cb11601dcb312bfa01b498c0b622ca4d3ba05 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 13:00:14 -0400 Subject: [PATCH 023/100] feat: add week-over-week spend comparison to git-ai activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track the timestamp of each token-usage event so the cost can be split into this-week vs last-week halves. Renders as a gray annotation under Est. cost: 'This week ~$X.XX · Last week ~$Y.YY ↑/↓ N% vs last week'. Co-Authored-By: Claude Opus 4.7 --- src/commands/activity.rs | 12 ++++ src/metrics/local_stats.rs | 130 +++++++++++++++++++++++++++++++------ 2 files changed, 121 insertions(+), 21 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index fe398e0ab2..853caf5894 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -176,6 +176,18 @@ fn print_terminal(stats: &LocalActivityStats) { format!("~${:.2}", t.estimated_cost_usd) ); } + if let Some(wow) = &t.wow_spend { + let arrow = if wow.change_pct > 0.0 { "↑" } else { "↓" }; + let change_str = if wow.change_pct.is_infinite() { + format!("{arrow} new this week") + } else { + format!("{arrow} {:.0}% vs last week", wow.change_pct.abs()) + }; + println!( + " {GRAY}This week ~${:.2} · Last week ~${:.2} {}{RESET}", + wow.this_week_usd, wow.last_week_usd, change_str, + ); + } for m in &t.by_model { let cost = m .estimated_cost_usd diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index fe901a3a2b..b8f54c39dc 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -38,6 +38,19 @@ pub struct TokenSummary { pub estimated_cost_usd: f64, /// Per-model breakdown, sorted by total tokens descending. pub by_model: Vec, + /// Week-over-week spend comparison (current 7 days vs previous 7 days). + /// None when either week has no cost data (e.g. viewing a period < 14 days + /// or when pricing is unavailable for all models). + pub wow_spend: Option, +} + +/// Week-over-week spend comparison. +#[derive(Debug, Serialize)] +pub struct WowSpend { + pub this_week_usd: f64, + pub last_week_usd: f64, + /// Percentage change: positive = up, negative = down. + pub change_pct: f64, } #[derive(Debug, Default, Serialize)] @@ -135,11 +148,10 @@ pub fn compute_activity( let mut session_ids: HashSet = HashSet::new(); let mut session_tool_counts: HashMap = HashMap::new(); - // Claude-shaped token usage keyed by assistant message id. The incremental - // transcript reader re-emits the same message multiple times, and streaming - // partials carry lower token counts than the final message — so we keep the - // field-wise max per message id, then fold into per-model totals. - let mut message_usage: HashMap = HashMap::new(); + // Claude-shaped token usage keyed by assistant message id. Value is + // (model, accum, record_ts). `record_ts` is the Unix timestamp of the + // first event that introduced this message id — used for WoW bucketing. + let mut message_usage: HashMap = HashMap::new(); // Codex-shaped token usage keyed by session id. Codex reports cumulative // session totals (total_token_usage) on each token_count event, so we keep @@ -220,9 +232,9 @@ pub fn compute_activity( .flatten() .unwrap_or_default(); if tool == "codex" { - aggregate_codex_tokens(&event, &mut codex_sessions); + aggregate_codex_tokens(&event, record.ts, &mut codex_sessions); } else { - aggregate_session_tokens(&event, &mut message_usage); + aggregate_session_tokens(&event, record.ts, &mut message_usage); } } _ => {} @@ -252,7 +264,11 @@ pub fn compute_activity( let mut session_by_tool: Vec<(String, u32)> = session_tool_counts.into_iter().collect(); session_by_tool.sort_by_key(|&(_, count)| Reverse(count)); - let tokens = build_token_summary(message_usage, codex_sessions); + let now_ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as u32; + let tokens = build_token_summary(message_usage, codex_sessions, now_ts); // Map by order key for fill_buckets to look up real data. let bucket_by_order: HashMap = bucket_map @@ -305,6 +321,8 @@ struct TokenAccum { #[derive(Debug, Default, Clone)] struct CodexSessionAccum { model: Option, + /// Unix timestamp of the first event seen for this session (WoW bucketing). + first_ts: u32, /// Cumulative input tokens (includes cached). input_tokens: u64, /// Cumulative cached input tokens (subset of input_tokens). @@ -360,32 +378,95 @@ fn shorten_model(model: &str) -> String { } } +/// Fold a set of message-usage entries into a per-model cost estimate (USD). +/// Used to compute each WoW half independently. +fn cost_for_message_slice(entries: impl Iterator) -> f64 { + let mut model_totals: HashMap = HashMap::new(); + for (model, acc) in entries { + let e = model_totals.entry(model).or_default(); + e.input += acc.input; + e.output += acc.output; + e.cache_read += acc.cache_read; + e.cache_creation += acc.cache_creation; + } + model_totals + .iter() + .filter_map(|(model, acc)| pricing_for(model).map(|p| estimate_cost(acc, &p))) + .sum() +} + fn build_token_summary( - message_usage: HashMap, + message_usage: HashMap, codex_sessions: HashMap, + now_ts: u32, ) -> TokenSummary { + // Week-over-week split: "this week" = last 7 days, "last week" = 7–14 days ago. + let this_week_start = now_ts.saturating_sub(7 * 24 * 3600); + let last_week_start = now_ts.saturating_sub(14 * 24 * 3600); + + let mut this_week_msgs: Vec<(String, TokenAccum)> = Vec::new(); + let mut last_week_msgs: Vec<(String, TokenAccum)> = Vec::new(); + // Fold per-message (deduped, max) usage into per-model totals. let mut model_tokens: HashMap = HashMap::new(); - for (_id, (model, acc)) in message_usage { - let entry = model_tokens.entry(model).or_default(); + for (_id, (model, acc, ts)) in message_usage { + let entry = model_tokens.entry(model.clone()).or_default(); entry.input += acc.input; entry.output += acc.output; entry.cache_read += acc.cache_read; entry.cache_creation += acc.cache_creation; + + if ts >= this_week_start { + this_week_msgs.push((model, acc)); + } else if ts >= last_week_start { + last_week_msgs.push((model, acc)); + } } // Fold per-session codex totals into per-model totals, mapping codex's // field semantics onto ours: codex input_tokens *includes* cached, so the // non-cached input is the difference; cached maps to cache_read; codex has // no cache-creation concept. + let mut this_week_codex: Vec<(String, TokenAccum)> = Vec::new(); + let mut last_week_codex: Vec<(String, TokenAccum)> = Vec::new(); + for (_sid, acc) in codex_sessions { - let model = acc.model.unwrap_or_else(|| "codex".to_string()); - let entry = model_tokens.entry(model).or_default(); - entry.input += acc.input_tokens.saturating_sub(acc.cached_input_tokens); - entry.output += acc.output_tokens; - entry.cache_read += acc.cached_input_tokens; + let model = acc.model.clone().unwrap_or_else(|| "codex".to_string()); + let mapped = TokenAccum { + input: acc.input_tokens.saturating_sub(acc.cached_input_tokens), + output: acc.output_tokens, + cache_read: acc.cached_input_tokens, + cache_creation: 0, + }; + let entry = model_tokens.entry(model.clone()).or_default(); + entry.input += mapped.input; + entry.output += mapped.output; + entry.cache_read += mapped.cache_read; + + if acc.first_ts >= this_week_start { + this_week_codex.push((model, mapped)); + } else if acc.first_ts >= last_week_start { + last_week_codex.push((model, mapped)); + } } + // Compute WoW spend from the two half-slices. + let this_week_cost = + cost_for_message_slice(this_week_msgs.into_iter().chain(this_week_codex)); + let last_week_cost = + cost_for_message_slice(last_week_msgs.into_iter().chain(last_week_codex)); + + let wow_spend = if this_week_cost > 0.0 || last_week_cost > 0.0 { + let change_pct = if last_week_cost > 0.0 { + (this_week_cost - last_week_cost) / last_week_cost * 100.0 + } else { + f64::INFINITY + }; + Some(WowSpend { this_week_usd: this_week_cost, last_week_usd: last_week_cost, change_pct }) + } else { + None + }; + let mut summary = TokenSummary::default(); let mut by_model: Vec = Vec::new(); @@ -412,6 +493,7 @@ fn build_token_summary( by_model.sort_by_key(|m| Reverse(m.input + m.output + m.cache_read + m.cache_creation)); summary.by_model = by_model; + summary.wow_spend = wow_spend; summary } @@ -649,10 +731,12 @@ fn aggregate_session( /// Extract token usage from a session event's raw transcript JSON (position 0). /// Only assistant messages carry usage. Keyed by message id, keeping the /// field-wise max across re-emitted copies (streaming partials report lower -/// counts than the final message). +/// counts than the final message). `record_ts` is stored on first insertion +/// for week-over-week bucketing. fn aggregate_session_tokens( event: &MetricEvent, - message_usage: &mut HashMap, + record_ts: u32, + message_usage: &mut HashMap, ) { let Some(raw) = event.values.get(&session_event_pos::RAW_JSON.to_string()) else { return; @@ -678,9 +762,9 @@ fn aggregate_session_tokens( let get = |key: &str| usage.get(key).and_then(|v| v.as_u64()).unwrap_or(0); - let (_, acc) = message_usage + let (_, acc, _ts) = message_usage .entry(id.to_string()) - .or_insert_with(|| (model, TokenAccum::default())); + .or_insert_with(|| (model, TokenAccum::default(), record_ts)); // Field-wise max: input/cache are fixed per message; output grows while // streaming, so the final (largest) value is authoritative. acc.input = acc.input.max(get("input_tokens")); @@ -695,6 +779,7 @@ fn aggregate_session_tokens( /// cumulative totals are tracked as a per-session max. fn aggregate_codex_tokens( event: &MetricEvent, + record_ts: u32, codex_sessions: &mut HashMap, ) { let Some(session_id) = sparse_get_string(&event.attrs, attr_pos::SESSION_ID).flatten() else { @@ -707,7 +792,10 @@ fn aggregate_codex_tokens( return; }; - let entry = codex_sessions.entry(session_id).or_default(); + let entry = codex_sessions.entry(session_id).or_insert_with(|| CodexSessionAccum { + first_ts: record_ts, + ..Default::default() + }); // Capture the model name when it appears (not on token_count events). if let Some(model) = payload.get("model").and_then(|m| m.as_str()) From bafecafede4d97db8c6bc3a223d700e701bcb3f6 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 13:01:01 -0400 Subject: [PATCH 024/100] feat: add cache efficiency ratio to git-ai activity token breakdown Compute cache_read / (cache_read + cache_creation) per model and display it inline with the per-model token line, e.g. 'cache 97% hit'. A low ratio means context is being discarded and re-warmed frequently. Co-Authored-By: Claude Opus 4.7 --- src/commands/activity.rs | 7 ++++++- src/metrics/local_stats.rs | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 853caf5894..c9ab384c92 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -193,12 +193,17 @@ fn print_terminal(stats: &LocalActivityStats) { .estimated_cost_usd .map(|c| format!(" ~${:.2}", c)) .unwrap_or_default(); + let cache = m + .cache_hit_ratio + .map(|r| format!(" cache {:.0}% hit", r * 100.0)) + .unwrap_or_default(); let total = m.input + m.output + m.cache_read + m.cache_creation; println!( - " {GRAY}{}: {} tokens{}{RESET}", + " {GRAY}{}: {} tokens{}{}{RESET}", m.model, format_num_u64(total), cost, + cache, ); } } diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index b8f54c39dc..38217d1998 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -62,6 +62,10 @@ pub struct TokenModelStat { pub cache_creation: u64, /// Estimated cost in USD; None if the model has no pricing entry. pub estimated_cost_usd: Option, + /// Cache hit ratio: cache_read / (cache_read + cache_creation), 0.0–1.0. + /// None when neither cache_read nor cache_creation is non-zero (model + /// doesn't use prompt caching, e.g. codex). + pub cache_hit_ratio: Option, } #[derive(Debug, Serialize)] @@ -481,6 +485,13 @@ fn build_token_summary( summary.estimated_cost_usd += c; } + let cache_total = acc.cache_read + acc.cache_creation; + let cache_hit_ratio = if cache_total > 0 { + Some(acc.cache_read as f64 / cache_total as f64) + } else { + None + }; + by_model.push(TokenModelStat { model: shorten_model(&model), input: acc.input, @@ -488,6 +499,7 @@ fn build_token_summary( cache_read: acc.cache_read, cache_creation: acc.cache_creation, estimated_cost_usd: cost, + cache_hit_ratio, }); } From 1962bc7414e822b88cd8e5a42384effecddfda85 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 13:03:18 -0400 Subject: [PATCH 025/100] feat: add per-tool acceptance rate to git-ai activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track checkpoint AI lines per tool alongside committed AI lines, then compute acceptance rate (committed / checkpoint) per tool. Displayed inline with the per-tool commit breakdown, e.g. 'claude · sonnet-4-6: 912 (claude 72% accept)'. The global acceptance rate is kept. Co-Authored-By: Claude Opus 4.7 --- src/commands/activity.rs | 11 +++++++- src/metrics/local_stats.rs | 52 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index c9ab384c92..0db6d6344d 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -146,7 +146,16 @@ fn print_terminal(stats: &LocalActivityStats) { println!(" Acceptance rate {:>5}%", acceptance_pct); } for (tool, count) in &stats.commits.by_tool { - println!(" {GRAY}{}: {}{RESET}", tool, format_num(*count)); + // Inline per-tool acceptance rate when available (keyed by plain tool name). + let tool_name = tool.split(" · ").next().unwrap_or(tool.as_str()); + let accept_str = stats + .commits + .acceptance_by_tool + .iter() + .find(|(t, _)| t == tool_name) + .map(|(_, pct)| format!(" {GRAY}({pct}% accept){RESET}")) + .unwrap_or_default(); + println!(" {GRAY}{}: {}{RESET}{}", tool, format_num(*count), accept_str); } // --- Human section --- diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 38217d1998..803f5d281a 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -88,8 +88,12 @@ pub struct CommitSummary { /// measure attribution coverage: lines not attributed to AI or known-human /// are "untracked" holes in the data. pub diff_added_lines: u32, - /// Per-tool AI line counts, sorted descending. Tool name only (strips "::model" suffix). + /// Per-tool AI line counts (tool · model label), sorted descending. pub by_tool: Vec<(String, u32)>, + /// Per-tool acceptance rate: committed AI lines / checkpoint AI lines (0–100). + /// Only includes tools where both sides have data and the rate is ≤ 100%. + /// Sorted by tool name. + pub acceptance_by_tool: Vec<(String, u32)>, } #[derive(Debug, Serialize)] @@ -148,6 +152,10 @@ pub fn compute_activity( let mut ai_lines_added = 0u32; let mut human_lines_added = 0u32; let mut files_edited: HashSet = HashSet::new(); + // Checkpoint AI lines keyed by plain tool name, for per-tool acceptance rate. + let mut checkpoint_ai_by_tool: HashMap = HashMap::new(); + // Committed AI lines keyed by plain tool name (extracted from tool::model pairs). + let mut committed_ai_by_plain_tool: HashMap = HashMap::new(); let mut session_ids: HashSet = HashSet::new(); let mut session_tool_counts: HashMap = HashMap::new(); @@ -193,6 +201,24 @@ pub fn compute_activity( &mut commit_tool_counts, ); + // Track committed AI lines per plain tool for acceptance rate. + if c.ai_lines > 0 { + let pairs = sparse_get_vec_string(&event.values, committed_pos::TOOL_MODEL_PAIRS) + .flatten() + .unwrap_or_default(); + let ai_vecs = sparse_get_vec_u32(&event.values, committed_pos::AI_ADDITIONS) + .flatten() + .unwrap_or_default(); + for (i, pair) in pairs.iter().enumerate().skip(1) { + let tool = pair.split_once("::").map(|(t, _)| t).unwrap_or(pair); + let ai_for_tool = ai_vecs.get(i).copied().unwrap_or(0); + if ai_for_tool > 0 { + *committed_ai_by_plain_tool.entry(tool.to_string()).or_insert(0) += + ai_for_tool; + } + } + } + // Bucket every commit that added lines so coverage spans all // committed code, not just AI commits. if c.diff_added > 0 { @@ -221,6 +247,7 @@ pub fn compute_activity( &mut ai_lines_added, &mut human_lines_added, &mut files_edited, + &mut checkpoint_ai_by_tool, ), 5 => { aggregate_session(&event, &mut session_ids, &mut session_tool_counts); @@ -262,6 +289,17 @@ pub fn compute_activity( } } + // Per-tool acceptance rate: committed AI lines / checkpoint AI lines. + let mut acceptance_by_tool: Vec<(String, u32)> = committed_ai_by_plain_tool + .iter() + .filter_map(|(tool, &committed)| { + let checkpoint = *checkpoint_ai_by_tool.get(tool)?; + let pct = (committed * 100).checked_div(checkpoint)?; + if pct <= 100 { Some((tool.clone(), pct)) } else { None } + }) + .collect(); + acceptance_by_tool.sort_by(|(a, _), (b, _)| a.cmp(b)); + let mut commit_by_tool: Vec<(String, u32)> = commit_tool_counts.into_iter().collect(); commit_by_tool.sort_by_key(|&(_, count)| Reverse(count)); @@ -291,6 +329,7 @@ pub fn compute_activity( human_lines: total_human_lines, diff_added_lines: total_diff_added, by_tool: commit_by_tool, + acceptance_by_tool, }, checkpoints: CheckpointSummary { total: total_checkpoints, @@ -699,6 +738,7 @@ fn aggregate_checkpoint( ai_lines_added: &mut u32, human_lines_added: &mut u32, files_edited: &mut HashSet, + checkpoint_ai_by_tool: &mut HashMap, ) { *total_checkpoints += 1; @@ -717,7 +757,15 @@ fn aggregate_checkpoint( } match kind.as_str() { - "ai_agent" | "ai_tab" => *ai_lines_added += lines_added, + "ai_agent" | "ai_tab" => { + *ai_lines_added += lines_added; + if lines_added > 0 { + let tool = sparse_get_string(&event.attrs, attr_pos::TOOL) + .flatten() + .unwrap_or_else(|| "unknown".to_string()); + *checkpoint_ai_by_tool.entry(tool).or_insert(0) += lines_added; + } + } "known_human" => *human_lines_added += lines_added, _ => {} } From 22771846c5053f316df58d4cb9e9d4d07858e9d1 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 13:05:46 -0400 Subject: [PATCH 026/100] add 60d period to user visible msg --- src/commands/activity.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 0db6d6344d..d4a7b61c1e 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -36,7 +36,7 @@ pub fn handle_activity(args: &[String]) { "60d" => (days_ago(60), "last 60 days".to_string(), BucketGranularity::Weekly), "all" => (0u32, "all time".to_string(), BucketGranularity::Monthly), other => { - eprintln!("Unknown period '{}'. Use 1d, 3d, 7d, 30d, or all.", other); + eprintln!("Unknown period '{}'. Use 1d, 3d, 7d, 30d, 60d, or all.", other); std::process::exit(1); } }; From 5ce2c2887e22151cc0b78473d466f77ab904b3d9 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 13:07:42 -0400 Subject: [PATCH 027/100] spacing --- src/commands/activity.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index d4a7b61c1e..18b6f0cca0 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -101,7 +101,7 @@ fn print_terminal(stats: &LocalActivityStats) { if let Some(ai_pct) = (stats.commits.ai_lines * 100).checked_div(total_lines) { let human_pct = 100 - ai_pct; println!( - " {} {BOLD}AI{RESET} {:>3}% · {BOLD}Human{RESET} {:>3}%", + " {} {BOLD}AI{RESET} {:>3}% · {BOLD}Human{RESET} {:>3}%", bar(ai_pct, 40), ai_pct, human_pct, From ee3e2409b2ad23ef089ec896719e8cb45a94f267 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 13:10:36 -0400 Subject: [PATCH 028/100] fix: suppress week-over-week spend when period covers less than 14 days When --period 7d (or shorter), get_local_events only fetches 7 days of data, so last-week events are absent. Previously this produced a misleading 'new this week' annotation. Now wow_spend is None unless since_ts covers at least 14 days back. Co-Authored-By: Claude Opus 4.7 --- src/metrics/local_stats.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 803f5d281a..cd5b9f0946 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -310,7 +310,7 @@ pub fn compute_activity( .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs() as u32; - let tokens = build_token_summary(message_usage, codex_sessions, now_ts); + let tokens = build_token_summary(message_usage, codex_sessions, now_ts, since_ts); // Map by order key for fill_buckets to look up real data. let bucket_by_order: HashMap = bucket_map @@ -442,10 +442,15 @@ fn build_token_summary( message_usage: HashMap, codex_sessions: HashMap, now_ts: u32, + since_ts: u32, ) -> TokenSummary { // Week-over-week split: "this week" = last 7 days, "last week" = 7–14 days ago. + // Only meaningful when the query window covers at least 14 days; otherwise + // last-week events were never fetched and last_week_cost would be 0 by + // omission rather than by fact. let this_week_start = now_ts.saturating_sub(7 * 24 * 3600); let last_week_start = now_ts.saturating_sub(14 * 24 * 3600); + let wow_eligible = since_ts <= last_week_start; let mut this_week_msgs: Vec<(String, TokenAccum)> = Vec::new(); let mut last_week_msgs: Vec<(String, TokenAccum)> = Vec::new(); @@ -499,7 +504,7 @@ fn build_token_summary( let last_week_cost = cost_for_message_slice(last_week_msgs.into_iter().chain(last_week_codex)); - let wow_spend = if this_week_cost > 0.0 || last_week_cost > 0.0 { + let wow_spend = if wow_eligible && (this_week_cost > 0.0 || last_week_cost > 0.0) { let change_pct = if last_week_cost > 0.0 { (this_week_cost - last_week_cost) / last_week_cost * 100.0 } else { From e555bd10c72b241921becbefaf8ecbb98611f917 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 13:12:03 -0400 Subject: [PATCH 029/100] fix: show per-tool acceptance rate only on first model variant line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When multiple models of the same tool appear (e.g. claude · sonnet-4-6 and claude · opus-4-7), the acceptance rate annotation was repeated on every line despite being tool-level data. Now shown once, on the first line for each tool. Co-Authored-By: Claude Opus 4.7 --- src/commands/activity.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 18b6f0cca0..3a0d0b3235 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -1,6 +1,7 @@ //! `git-ai activity` — local statistics from persisted metric events. use crate::metrics::local_stats::{BucketGranularity, LocalActivityStats, compute_activity}; +use std::collections::HashSet; use std::time::{SystemTime, UNIX_EPOCH}; pub fn handle_activity(args: &[String]) { @@ -145,16 +146,23 @@ fn print_terminal(stats: &LocalActivityStats) { { println!(" Acceptance rate {:>5}%", acceptance_pct); } + // Track which tools have already had their acceptance rate shown so we + // don't repeat the same tool-level rate on every model variant line. + let mut shown_accept: HashSet<&str> = HashSet::new(); for (tool, count) in &stats.commits.by_tool { - // Inline per-tool acceptance rate when available (keyed by plain tool name). let tool_name = tool.split(" · ").next().unwrap_or(tool.as_str()); - let accept_str = stats - .commits - .acceptance_by_tool - .iter() - .find(|(t, _)| t == tool_name) - .map(|(_, pct)| format!(" {GRAY}({pct}% accept){RESET}")) - .unwrap_or_default(); + let accept_str = if shown_accept.insert(tool_name) { + // First line for this tool — show the acceptance rate once. + stats + .commits + .acceptance_by_tool + .iter() + .find(|(t, _)| t == tool_name) + .map(|(_, pct)| format!(" {GRAY}({pct}% accept){RESET}")) + .unwrap_or_default() + } else { + String::new() + }; println!(" {GRAY}{}: {}{RESET}{}", tool, format_num(*count), accept_str); } From 28bbaa1309805d6205eaf04f9c2a3794a8785295 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 13:13:39 -0400 Subject: [PATCH 030/100] refactor: eliminate double-parse of committed event tool/AI fields aggregate_committed already parses TOOL_MODEL_PAIRS and AI_ADDITIONS to build commit_tool_counts. The main loop was re-parsing the same fields immediately after to build committed_ai_by_plain_tool. Pass the plain-tool map into aggregate_committed so both maps are populated in a single parse. Co-Authored-By: Claude Opus 4.7 --- src/metrics/local_stats.rs | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index cd5b9f0946..a74343bab8 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -199,26 +199,9 @@ pub fn compute_activity( &mut total_human_lines, &mut total_diff_added, &mut commit_tool_counts, + &mut committed_ai_by_plain_tool, ); - // Track committed AI lines per plain tool for acceptance rate. - if c.ai_lines > 0 { - let pairs = sparse_get_vec_string(&event.values, committed_pos::TOOL_MODEL_PAIRS) - .flatten() - .unwrap_or_default(); - let ai_vecs = sparse_get_vec_u32(&event.values, committed_pos::AI_ADDITIONS) - .flatten() - .unwrap_or_default(); - for (i, pair) in pairs.iter().enumerate().skip(1) { - let tool = pair.split_once("::").map(|(t, _)| t).unwrap_or(pair); - let ai_for_tool = ai_vecs.get(i).copied().unwrap_or(0); - if ai_for_tool > 0 { - *committed_ai_by_plain_tool.entry(tool.to_string()).or_insert(0) += - ai_for_tool; - } - } - } - // Bucket every commit that added lines so coverage spans all // committed code, not just AI commits. if c.diff_added > 0 { @@ -677,6 +660,7 @@ fn aggregate_committed( total_human_lines: &mut u32, total_diff_added: &mut u32, commit_tool_counts: &mut HashMap, + committed_ai_by_plain_tool: &mut HashMap, ) -> CommitContribution { let human = sparse_get_u32(&event.values, committed_pos::HUMAN_ADDITIONS) .flatten() @@ -709,14 +693,17 @@ fn aggregate_committed( *total_ai_lines += total_ai; // Per-tool breakdown: index 0 = "all" aggregate, 1+ = per tool::model. + // Parse pairs once and use them for both the display label map and the + // plain-tool map used for acceptance rate — no second parse needed. let pairs = sparse_get_vec_string(&event.values, committed_pos::TOOL_MODEL_PAIRS) .flatten() .unwrap_or_default(); for (i, pair) in pairs.iter().enumerate().skip(1) { - let label = format_tool_model(pair); let ai_for_tool = ai_vecs.get(i).copied().unwrap_or(0); if ai_for_tool > 0 { - *commit_tool_counts.entry(label).or_insert(0) += ai_for_tool; + *commit_tool_counts.entry(format_tool_model(pair)).or_insert(0) += ai_for_tool; + let plain_tool = pair.split_once("::").map(|(t, _)| t).unwrap_or(pair); + *committed_ai_by_plain_tool.entry(plain_tool.to_string()).or_insert(0) += ai_for_tool; } } From 5d1868eb3b516072a9c32ebe060b50ce5137b69f Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 13:14:19 -0400 Subject: [PATCH 031/100] docs: document yield classification cross-repo limitation Yield classification correlates sessions and commits by timestamp across the global local_events table, with no repo-path filtering. A commit to repo-A can incorrectly mark a concurrent session in repo-B as 'shipped'. Fixing this requires storing repo path on session/committed events. Co-Authored-By: Claude Opus 4.7 --- src/metrics/local_stats.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index a74343bab8..c0f058f303 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -257,6 +257,11 @@ pub fn compute_activity( // Yield classification: for each unique session, check if a commit landed // within 4 hours of the session's last observed event. + // + // Limitation: local_events aggregates activity across all repos globally, + // so a commit in repo-A can incorrectly "claim" a nearby session that was + // working in repo-B. Fixing this properly requires storing the repo path + // on both session and committed events (a future schema change). const YIELD_WINDOW_SECS: u32 = 4 * 3600; commit_timestamps.sort_unstable(); let mut yield_shipped = 0u32; From b82fb496044d9fe9a8e4803a76066563b53a02d4 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 13:24:16 -0400 Subject: [PATCH 032/100] fix: start all-time activity at first event --- src/metrics/local_stats.rs | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index c0f058f303..a58e0aa492 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -579,7 +579,17 @@ fn fill_buckets( granularity: BucketGranularity, ) -> Vec { let now = Local::now(); - let since_dt = ts_to_local(since_ts); + if since_ts == 0 && data_map.is_empty() { + return Vec::new(); + } + let since_date = if since_ts == 0 { + let earliest_order = data_map.keys().copied().min(); + earliest_order + .and_then(|order| bucket_start_date(order, granularity)) + .unwrap_or_else(|| now.date_naive()) + } else { + ts_to_local(since_ts).date_naive() + }; let make = |label: String, accum: BucketAccum| BucketStats { label, @@ -593,7 +603,7 @@ fn fill_buckets( let mut result = Vec::new(); match granularity { BucketGranularity::Daily => { - let mut day = since_dt.date_naive(); + let mut day = since_date; let today = now.date_naive(); while day <= today { let order = day.num_days_from_ce() as i64; @@ -603,9 +613,8 @@ fn fill_buckets( } } BucketGranularity::Weekly => { - let weekday = since_dt.weekday().num_days_from_monday() as i64; - let mut monday: NaiveDate = - since_dt.date_naive() - chrono::Duration::days(weekday); + let weekday = since_date.weekday().num_days_from_monday() as i64; + let mut monday: NaiveDate = since_date - chrono::Duration::days(weekday); let today = now.date_naive(); while monday <= today { let order = monday.num_days_from_ce() as i64; @@ -618,8 +627,8 @@ fn fill_buckets( } } BucketGranularity::Monthly => { - let mut year = since_dt.year(); - let mut month = since_dt.month(); + let mut year = since_date.year(); + let mut month = since_date.month(); let now_year = now.year(); let now_month = now.month(); loop { @@ -642,6 +651,19 @@ fn fill_buckets( result } +fn bucket_start_date(order: i64, granularity: BucketGranularity) -> Option { + match granularity { + BucketGranularity::Daily | BucketGranularity::Weekly => { + NaiveDate::from_num_days_from_ce_opt(order.try_into().ok()?) + } + BucketGranularity::Monthly => { + let year = order.div_euclid(12); + let month0 = order.rem_euclid(12); + NaiveDate::from_ymd_opt(year.try_into().ok()?, (month0 + 1).try_into().ok()?, 1) + } + } +} + /// Per-bucket accumulator for the activity-over-time chart. #[derive(Debug, Default, Clone)] struct BucketAccum { From 72de390deaf679c5bcf9f0f3bbe656d9381678bd Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 13:25:23 -0400 Subject: [PATCH 033/100] fix: bucket codex spend by usage time --- src/metrics/local_stats.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index a58e0aa492..ad0e9d1438 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -352,8 +352,9 @@ struct TokenAccum { #[derive(Debug, Default, Clone)] struct CodexSessionAccum { model: Option, - /// Unix timestamp of the first event seen for this session (WoW bucketing). - first_ts: u32, + /// Unix timestamp of the latest token-usage event seen for this session + /// (WoW bucketing). + last_usage_ts: u32, /// Cumulative input tokens (includes cached). input_tokens: u64, /// Cumulative cached input tokens (subset of input_tokens). @@ -479,9 +480,9 @@ fn build_token_summary( entry.output += mapped.output; entry.cache_read += mapped.cache_read; - if acc.first_ts >= this_week_start { + if acc.last_usage_ts >= this_week_start { this_week_codex.push((model, mapped)); - } else if acc.first_ts >= last_week_start { + } else if acc.last_usage_ts >= last_week_start { last_week_codex.push((model, mapped)); } } @@ -871,10 +872,7 @@ fn aggregate_codex_tokens( return; }; - let entry = codex_sessions.entry(session_id).or_insert_with(|| CodexSessionAccum { - first_ts: record_ts, - ..Default::default() - }); + let entry = codex_sessions.entry(session_id).or_default(); // Capture the model name when it appears (not on token_count events). if let Some(model) = payload.get("model").and_then(|m| m.as_str()) @@ -886,6 +884,7 @@ fn aggregate_codex_tokens( // Cumulative session totals; keep the running max. if let Some(usage) = payload.get("info").and_then(|i| i.get("total_token_usage")) { let get = |key: &str| usage.get(key).and_then(|v| v.as_u64()).unwrap_or(0); + entry.last_usage_ts = entry.last_usage_ts.max(record_ts); entry.input_tokens = entry.input_tokens.max(get("input_tokens")); entry.cached_input_tokens = entry.cached_input_tokens.max(get("cached_input_tokens")); entry.output_tokens = entry.output_tokens.max(get("output_tokens")); From 9b550aab1ff5bb9791e7d262ec33d6e963cf8e69 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 13:26:18 -0400 Subject: [PATCH 034/100] fix: avoid infinite activity spend deltas --- src/commands/activity.rs | 14 +++++++++----- src/metrics/local_stats.rs | 22 ++++++++++++++++------ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 3a0d0b3235..39f07e4f1f 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -194,11 +194,15 @@ fn print_terminal(stats: &LocalActivityStats) { ); } if let Some(wow) = &t.wow_spend { - let arrow = if wow.change_pct > 0.0 { "↑" } else { "↓" }; - let change_str = if wow.change_pct.is_infinite() { - format!("{arrow} new this week") - } else { - format!("{arrow} {:.0}% vs last week", wow.change_pct.abs()) + let change_str = match (wow.new_this_week, wow.change_pct) { + (true, _) => "↑ new this week".to_string(), + (_, Some(change_pct)) if change_pct > 0.0 => { + format!("↑ {:.0}% vs last week", change_pct) + } + (_, Some(change_pct)) if change_pct < 0.0 => { + format!("↓ {:.0}% vs last week", change_pct.abs()) + } + _ => "no change vs last week".to_string(), }; println!( " {GRAY}This week ~${:.2} · Last week ~${:.2} {}{RESET}", diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index ad0e9d1438..91e156a477 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -49,8 +49,10 @@ pub struct TokenSummary { pub struct WowSpend { pub this_week_usd: f64, pub last_week_usd: f64, - /// Percentage change: positive = up, negative = down. - pub change_pct: f64, + /// Percentage change: positive = up, negative = down. None when last week + /// was zero and this week has spend. + pub change_pct: Option, + pub new_this_week: bool, } #[derive(Debug, Default, Serialize)] @@ -494,12 +496,20 @@ fn build_token_summary( cost_for_message_slice(last_week_msgs.into_iter().chain(last_week_codex)); let wow_spend = if wow_eligible && (this_week_cost > 0.0 || last_week_cost > 0.0) { - let change_pct = if last_week_cost > 0.0 { - (this_week_cost - last_week_cost) / last_week_cost * 100.0 + let (change_pct, new_this_week) = if last_week_cost > 0.0 { + ( + Some((this_week_cost - last_week_cost) / last_week_cost * 100.0), + false, + ) } else { - f64::INFINITY + (None, true) }; - Some(WowSpend { this_week_usd: this_week_cost, last_week_usd: last_week_cost, change_pct }) + Some(WowSpend { + this_week_usd: this_week_cost, + last_week_usd: last_week_cost, + change_pct, + new_this_week, + }) } else { None }; From cc70b258fac2c40267322bd35360c3a6caca14cb Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 14:39:03 -0400 Subject: [PATCH 035/100] feat: interactive Ratatui TUI for git-ai activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the static ANSI text output with a two-tab TUI when stdout is a TTY; falls back to the existing plain-text output when piped/redirected. - Summary tab: stat boxes (total lines, AI share gauge, sessions, cost), AI lines bar chart, session/yield/acceptance stats, time-of-day and day-of-week sparkline heatmaps - Models tab: spend summary + WoW delta, cache hit gauge, per-model table (tokens, cost, cache hit rate) Navigation: tab/h/l to switch tabs, 1–5 to change period, q to quit. Also fixes two pre-existing clippy warnings in activity.rs and local_stats.rs (manual_checked_ops, map_or, unused iterator variable). Co-Authored-By: Claude Sonnet 4.5 --- src/commands/activity.rs | 80 ++++- src/commands/activity_tui.rs | 602 +++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 1 + src/metrics/local_stats.rs | 61 +++- 4 files changed, 713 insertions(+), 31 deletions(-) create mode 100644 src/commands/activity_tui.rs diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 39f07e4f1f..d53467dcd4 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -1,7 +1,9 @@ //! `git-ai activity` — local statistics from persisted metric events. +use crate::commands::activity_tui; use crate::metrics::local_stats::{BucketGranularity, LocalActivityStats, compute_activity}; use std::collections::HashSet; +use std::io::IsTerminal; use std::time::{SystemTime, UNIX_EPOCH}; pub fn handle_activity(args: &[String]) { @@ -29,15 +31,43 @@ pub fn handle_activity(args: &[String]) { i += 1; } - let (since_ts, period_label, granularity) = match period.as_str() { - "1d" => (days_ago(1), "last 1 day".to_string(), BucketGranularity::Daily), - "3d" => (days_ago(3), "last 3 days".to_string(), BucketGranularity::Daily), - "7d" => (days_ago(7), "last 7 days".to_string(), BucketGranularity::Daily), - "30d" => (days_ago(30), "last 30 days".to_string(), BucketGranularity::Weekly), - "60d" => (days_ago(60), "last 60 days".to_string(), BucketGranularity::Weekly), - "all" => (0u32, "all time".to_string(), BucketGranularity::Monthly), + let (since_ts, period_label, granularity, tui_period_idx) = match period.as_str() { + "1d" => ( + days_ago(1), + "last 1 day".to_string(), + BucketGranularity::Daily, + 0usize, + ), + "3d" => ( + days_ago(3), + "last 3 days".to_string(), + BucketGranularity::Daily, + 1, + ), + "7d" => ( + days_ago(7), + "last 7 days".to_string(), + BucketGranularity::Daily, + 2, + ), + "30d" => ( + days_ago(30), + "last 30 days".to_string(), + BucketGranularity::Weekly, + 3, + ), + "60d" => ( + days_ago(60), + "last 60 days".to_string(), + BucketGranularity::Weekly, + 3, + ), + "all" => (0u32, "all time".to_string(), BucketGranularity::Monthly, 4), other => { - eprintln!("Unknown period '{}'. Use 1d, 3d, 7d, 30d, 60d, or all.", other); + eprintln!( + "Unknown period '{}'. Use 1d, 3d, 7d, 30d, 60d, or all.", + other + ); std::process::exit(1); } }; @@ -58,6 +88,11 @@ pub fn handle_activity(args: &[String]) { std::process::exit(1); } } + } else if std::io::stdout().is_terminal() { + if let Err(e) = activity_tui::run_tui(stats, tui_period_idx) { + eprintln!("error: {}", e); + std::process::exit(1); + } } else { print_terminal(&stats); } @@ -113,8 +148,7 @@ fn print_terminal(stats: &LocalActivityStats) { println!(); println!(" {BOLD}AI{RESET}"); let yield_total = stats.sessions.yield_stats.shipped + stats.sessions.yield_stats.abandoned; - if yield_total > 0 { - let shipped_pct = stats.sessions.yield_stats.shipped * 100 / yield_total; + if let Some(shipped_pct) = (stats.sessions.yield_stats.shipped * 100).checked_div(yield_total) { println!( " Sessions {:>6} {GRAY}({} shipped · {} abandoned · {}% yield){RESET}", format_num(stats.sessions.total), @@ -163,7 +197,12 @@ fn print_terminal(stats: &LocalActivityStats) { } else { String::new() }; - println!(" {GRAY}{}: {}{RESET}{}", tool, format_num(*count), accept_str); + println!( + " {GRAY}{}: {}{RESET}{}", + tool, + format_num(*count), + accept_str + ); } // --- Human section --- @@ -186,7 +225,10 @@ fn print_terminal(stats: &LocalActivityStats) { println!(" Input {:>12}", format_num_u64(t.input)); println!(" Output {:>12}", format_num_u64(t.output)); println!(" Cache read {:>12}", format_num_u64(t.cache_read)); - println!(" Cache write {:>12}", format_num_u64(t.cache_creation)); + println!( + " Cache write {:>12}", + format_num_u64(t.cache_creation) + ); if t.estimated_cost_usd > 0.0 { println!( " {BOLD}Est. cost{RESET} {:>12}", @@ -233,11 +275,21 @@ fn print_terminal(stats: &LocalActivityStats) { if !stats.buckets.is_empty() { println!(); println!(" {BOLD}Activity over time{RESET}"); - let max_ai = stats.buckets.iter().map(|b| b.ai_lines).max().unwrap_or(1).max(1); + let max_ai = stats + .buckets + .iter() + .map(|b| b.ai_lines) + .max() + .unwrap_or(1) + .max(1); for bucket in &stats.buckets { let filled = (bucket.ai_lines * BAR_WIDTH / max_ai).min(BAR_WIDTH); let empty = BAR_WIDTH - filled; - let bar_str = format!("{}{}", "█".repeat(filled as usize), "░".repeat(empty as usize)); + let bar_str = format!( + "{}{}", + "█".repeat(filled as usize), + "░".repeat(empty as usize) + ); if bucket.ai_lines > 0 { // Coverage for this bucket: attributed / total diff additions. let coverage = (bucket.attributed_lines * 100) diff --git a/src/commands/activity_tui.rs b/src/commands/activity_tui.rs new file mode 100644 index 0000000000..e623bd2df4 --- /dev/null +++ b/src/commands/activity_tui.rs @@ -0,0 +1,602 @@ +//! Ratatui TUI for `git-ai activity`. + +use crate::error::GitAiError; +use crate::metrics::local_stats::{BucketGranularity, LocalActivityStats, compute_activity}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use ratatui::{ + DefaultTerminal, Frame, + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style, Stylize}, + text::{Line, Span}, + widgets::{Bar, BarChart, Block, Cell, Clear, Gauge, Paragraph, Row, Sparkline, Table, Tabs}, +}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const TAB_NAMES: &[&str] = &["Summary", "Models"]; + +struct Period { + label: &'static str, + granularity: BucketGranularity, + days: Option, // None = all time +} + +const PERIODS: &[Period] = &[ + Period { label: "last 1 day", granularity: BucketGranularity::Daily, days: Some(1) }, + Period { label: "last 3 days", granularity: BucketGranularity::Daily, days: Some(3) }, + Period { label: "last 7 days", granularity: BucketGranularity::Daily, days: Some(7) }, + Period { label: "last 30 days", granularity: BucketGranularity::Weekly, days: Some(30) }, + Period { label: "all time", granularity: BucketGranularity::Monthly, days: None }, +]; + +fn since_ts(period_idx: usize) -> u32 { + match PERIODS[period_idx].days { + None => 0, + Some(days) => { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + now.saturating_sub(days * 24 * 3600) as u32 + } + } +} + +struct AppState { + selected_tab: usize, + period_idx: usize, + stats: LocalActivityStats, +} + +impl AppState { + fn new(stats: LocalActivityStats, period_idx: usize) -> Self { + Self { selected_tab: 0, period_idx, stats } + } + + fn load_period(&mut self, idx: usize) -> Result<(), GitAiError> { + let p = &PERIODS[idx]; + let ts = since_ts(idx); + self.stats = compute_activity(ts, p.label.to_string(), p.granularity)?; + self.period_idx = idx; + Ok(()) + } +} + +pub fn run_tui(initial_stats: LocalActivityStats, period_idx: usize) -> Result<(), GitAiError> { + let mut terminal = ratatui::init(); + let result = run_app(&mut terminal, initial_stats, period_idx); + ratatui::restore(); + result +} + +fn run_app( + terminal: &mut DefaultTerminal, + initial_stats: LocalActivityStats, + period_idx: usize, +) -> Result<(), GitAiError> { + let mut app = AppState::new(initial_stats, period_idx); + loop { + terminal + .draw(|frame| render(frame, &app)) + .map_err(GitAiError::IoError)?; + + if let Event::Key(key) = event::read().map_err(GitAiError::IoError)? { + if key.kind != KeyEventKind::Press { + continue; + } + match key.code { + KeyCode::Char('q') | KeyCode::Esc => break, + KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { + app.selected_tab = (app.selected_tab + 1) % TAB_NAMES.len(); + } + KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { + app.selected_tab = (app.selected_tab + TAB_NAMES.len() - 1) % TAB_NAMES.len(); + } + KeyCode::Char(c @ '1'..='5') => { + let idx = (c as usize) - ('1' as usize); + if idx < PERIODS.len() { + app.load_period(idx)?; + } + } + _ => {} + } + } + } + Ok(()) +} + +// ─── Top-level render ──────────────────────────────────────────────────────── + +fn render(frame: &mut Frame, app: &AppState) { + // Clear any leftover content from behind the TUI. + frame.render_widget(Clear, frame.area()); + + // Outer padding: 1 row top/bottom, 2 cols left/right. + let [_, padded_v, _] = Layout::vertical([ + Constraint::Length(1), + Constraint::Fill(1), + Constraint::Length(1), + ]) + .areas(frame.area()); + let [_, padded, _] = Layout::horizontal([ + Constraint::Length(2), + Constraint::Fill(1), + Constraint::Length(2), + ]) + .areas(padded_v); + + let [header_area, content_area, footer_area] = Layout::vertical([ + Constraint::Length(2), + Constraint::Fill(1), + Constraint::Length(1), + ]) + .areas(padded); + + render_header(frame, header_area, app); + render_footer(frame, footer_area); + + match app.selected_tab { + 0 => render_summary(frame, content_area, app), + 1 => render_models(frame, content_area, app), + _ => {} + } +} + +fn render_header(frame: &mut Frame, area: Rect, app: &AppState) { + let [title_area, tabs_area] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area); + + let period = PERIODS[app.period_idx].label; + let title = Line::from(vec![ + Span::from("git-ai activity").bold(), + Span::from(" ─ ").dim(), + Span::from(period).dim(), + ]); + frame.render_widget(title, title_area); + + let tabs = Tabs::new(TAB_NAMES.to_vec()) + .select(app.selected_tab) + .style(Style::default().dim()) + .highlight_style(Style::default().bold().add_modifier(Modifier::REVERSED)) + .divider(" ") + .padding(" ", " "); + frame.render_widget(tabs, tabs_area); +} + +fn render_footer(frame: &mut Frame, area: Rect) { + let footer = Line::from(vec![ + Span::from("tab/←/→").bold(), + Span::from(": navigate ").dim(), + Span::from("1-5").bold(), + Span::from(": period (1d 3d 7d 30d all) ").dim(), + Span::from("q").bold(), + Span::from(": quit").dim(), + ]); + frame.render_widget(footer, area); +} + +// ─── Summary tab ───────────────────────────────────────────────────────────── +// +// Layout (top → bottom): +// [4 stat boxes] +// [AI lines bar chart — fills remaining height] +// [session / yield / acceptance stats line] +// [Time of day | Day of week heatmaps] + +fn render_summary(frame: &mut Frame, area: Rect, app: &AppState) { + let stats = &app.stats; + let has_hourly = stats.hourly.iter().any(|&v| v > 0); + let has_daily = stats.daily.iter().any(|&v| v > 0); + let heatmap_height = if has_hourly || has_daily { 7u16 } else { 0 }; + + let [stat_area, chart_area, session_area, heatmap_area] = Layout::vertical([ + Constraint::Length(4), + Constraint::Fill(1), + Constraint::Length(2), + Constraint::Length(heatmap_height), + ]) + .areas(area); + + render_stat_boxes(frame, stat_area, stats); + render_activity_chart(frame, chart_area, stats); + render_session_stats(frame, session_area, stats); + if heatmap_height > 0 { + render_heatmaps(frame, heatmap_area, stats, has_hourly, has_daily); + } +} + +fn render_stat_boxes(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { + let total = stats.commits.ai_lines + stats.commits.human_lines; + let ai_pct = (stats.commits.ai_lines * 100) + .checked_div(total) + .unwrap_or(0); + let cost = stats.tokens.estimated_cost_usd; + + let [lines_area, ai_area, sessions_area, cost_area] = Layout::horizontal([ + Constraint::Length(18), + Constraint::Fill(1), + Constraint::Length(20), + Constraint::Length(14), + ]) + .areas(area); + + // Total lines box + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::from("Total lines").dim()), + Line::from(Span::from(fmt_num(total as u64)).bold()), + ]) + .block(Block::bordered()), + lines_area, + ); + + // AI share gauge + let gauge = Gauge::default() + .block(Block::bordered().title(Span::from("AI share").dim())) + .ratio(if total > 0 { ai_pct as f64 / 100.0 } else { 0.0 }) + .label(format!( + "{}% · {} AI / {} human", + ai_pct, + fmt_k(stats.commits.ai_lines as u64), + fmt_k(stats.commits.human_lines as u64), + )) + .gauge_style(Style::default().fg(Color::Cyan)); + frame.render_widget(gauge, ai_area); + + // Sessions box (replaces old "Models used" — more useful at a glance) + let yield_total = stats.sessions.yield_stats.shipped + stats.sessions.yield_stats.abandoned; + let yield_pct = (stats.sessions.yield_stats.shipped * 100) + .checked_div(yield_total) + .unwrap_or(0); + let sessions_label = if yield_total > 0 { + format!( + "{} ({}% shipped)", + fmt_num(stats.sessions.total as u64), + yield_pct + ) + } else { + fmt_num(stats.sessions.total as u64) + }; + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::from("Sessions").dim()), + Line::from(Span::from(sessions_label).bold()), + ]) + .block(Block::bordered()), + sessions_area, + ); + + // Est. cost box + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::from("Est. cost").dim()), + Line::from( + Span::from(if cost > 0.0 { + format!("~${:.2}", cost) + } else { + "—".to_string() + }) + .bold(), + ), + ]) + .block(Block::bordered()), + cost_area, + ); +} + +fn render_session_stats(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { + let yield_total = stats.sessions.yield_stats.shipped + stats.sessions.yield_stats.abandoned; + let yield_pct = (stats.sessions.yield_stats.shipped * 100) + .checked_div(yield_total) + .unwrap_or(0); + let accept_pct = (stats.commits.ai_lines * 100) + .checked_div(stats.checkpoints.ai_lines_added) + .filter(|&p| p <= 100); + + let mut spans = vec![ + Span::from("Sessions: ").dim(), + Span::from(fmt_num(stats.sessions.total as u64)).bold(), + Span::from(" · Shipped: ").dim(), + Span::from(fmt_num(stats.sessions.yield_stats.shipped as u64)).bold(), + Span::from(format!(" ({}%)", yield_pct)).dim(), + Span::from(" · Commits: ").dim(), + Span::from(fmt_num(stats.commits.total as u64)).bold(), + ]; + if let Some(pct) = accept_pct { + spans.push(Span::from(" · Accept rate: ").dim()); + spans.push(Span::from(format!("{}%", pct)).bold()); + } + + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +fn render_heatmaps( + frame: &mut Frame, + area: Rect, + stats: &LocalActivityStats, + has_hourly: bool, + has_daily: bool, +) { + match (has_hourly, has_daily) { + (true, true) => { + let [left, right] = + Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(area); + render_time_of_day(frame, left, stats); + render_day_of_week(frame, right, stats); + } + (true, false) => render_time_of_day(frame, area, stats), + (false, true) => render_day_of_week(frame, area, stats), + _ => {} + } +} + +fn render_time_of_day(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { + let block = Block::bordered().title(Span::from("Time of day").bold()); + let inner = block.inner(area); + frame.render_widget(block, area); + + let max_val = stats.hourly.iter().copied().max().unwrap_or(1).max(1) as u64; + let data: Vec = stats.hourly.iter().map(|&v| v as u64).collect(); + let [spark_area, label_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(inner); + + frame.render_widget( + Sparkline::default() + .data(&data) + .max(max_val) + .style(Style::default().fg(Color::Cyan)), + spark_area, + ); + frame.render_widget( + Paragraph::new( + Span::from( + "am 1 2 3 4 5 6 7 8 9 10 11 pm 1 2 3 4 5 6 7 8 9 10 11", + ) + .dim(), + ), + label_area, + ); +} + +fn render_day_of_week(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { + let block = Block::bordered().title(Span::from("Day of week").bold()); + let inner = block.inner(area); + frame.render_widget(block, area); + + let max_val = stats.daily.iter().copied().max().unwrap_or(1).max(1) as u64; + let data: Vec = stats.daily.iter().map(|&v| v as u64).collect(); + let [spark_area, label_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(inner); + + frame.render_widget( + Sparkline::default() + .data(&data) + .max(max_val) + .style(Style::default().fg(Color::Cyan)), + spark_area, + ); + frame.render_widget( + Paragraph::new(Span::from("Mon Tue Wed Thu Fri Sat Sun").dim()), + label_area, + ); +} + +// ─── Models tab ────────────────────────────────────────────────────────────── +// +// Layout (top → bottom): +// [Spend summary: total cost + WoW delta] +// [Cache hit rate gauge] +// [Model table: Model | Sessions | Tokens | Cost | Cache hit] + +fn render_models(frame: &mut Frame, area: Rect, app: &AppState) { + let stats = &app.stats; + let t = &stats.tokens; + let has_token_data = t.input + t.output + t.cache_read + t.cache_creation > 0; + + let spend_height = if has_token_data { 4u16 } else { 0 }; + let gauge_height = if has_token_data { 3u16 } else { 0 }; + + let [spend_area, gauge_area, table_area] = Layout::vertical([ + Constraint::Length(spend_height), + Constraint::Length(gauge_height), + Constraint::Fill(1), + ]) + .areas(area); + + if has_token_data { + render_spend_summary(frame, spend_area, stats); + render_cache_gauge(frame, gauge_area, stats); + } + render_model_table(frame, table_area, stats); +} + +fn render_spend_summary(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { + let t = &stats.tokens; + let block = Block::bordered().title(Span::from("Spend").bold()); + let inner = block.inner(area); + frame.render_widget(block, area); + + let cost_line = if t.estimated_cost_usd > 0.0 { + format!("Est. cost: ~${:.2}", t.estimated_cost_usd) + } else { + "No cost data".to_string() + }; + + let wow_line = t.wow_spend.as_ref().map(|w| { + let delta = match (w.new_this_week, w.change_pct) { + (true, _) => "↑ new this week".to_string(), + (_, Some(p)) if p > 0.0 => format!("↑ {:.0}% vs last week", p), + (_, Some(p)) if p < 0.0 => format!("↓ {:.0}% vs last week", p.abs()), + _ => "→ no change vs last week".to_string(), + }; + let last_week = if w.last_week_usd > 0.01 { + format!(" · Last week: ~${:.2}", w.last_week_usd) + } else { + String::new() + }; + format!("This week: ~${:.2}{} {}", w.this_week_usd, last_week, delta) + }); + + let mut lines = vec![Line::from(Span::from(cost_line).bold())]; + if let Some(w) = wow_line { + lines.push(Line::from(Span::from(w).dim())); + } + frame.render_widget(Paragraph::new(lines), inner); +} + +fn render_cache_gauge(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { + let t = &stats.tokens; + let with_ratio: Vec = t.by_model.iter().filter_map(|m| m.cache_hit_ratio).collect(); + let cache_hit_ratio = if with_ratio.is_empty() { + None + } else { + Some(with_ratio.iter().sum::() / with_ratio.len() as f64) + }; + + let gauge = Gauge::default() + .block(Block::bordered().title(Span::from("Cache hit rate").bold())) + .ratio(cache_hit_ratio.unwrap_or(0.0)) + .label( + cache_hit_ratio + .map(|r| format!("{:.0}%", r * 100.0)) + .unwrap_or_else(|| "—".to_string()), + ) + .gauge_style(Style::default().fg(Color::Green)); + frame.render_widget(gauge, area); +} + +fn render_model_table(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { + let block = Block::bordered().title(Span::from("Models").bold()); + let inner = block.inner(area); + frame.render_widget(block, area); + + if stats.tokens.by_model.is_empty() { + frame.render_widget( + Paragraph::new(Span::from("No model data for this period.").dim()), + inner, + ); + return; + } + + let total_tokens: u64 = stats + .tokens + .by_model + .iter() + .map(|m| m.input + m.output + m.cache_read + m.cache_creation) + .sum(); + + let header = Row::new(vec!["Model", "Sessions", "Tokens", "Cost", "Cache hit"]) + .style(Style::default().bold()) + .bottom_margin(1); + + let rows: Vec = stats + .tokens + .by_model + .iter() + .map(|m| { + let tokens = m.input + m.output + m.cache_read + m.cache_creation; + let pct = (tokens * 100).checked_div(total_tokens).unwrap_or(0); + let sessions = stats + .sessions + .by_tool + .iter() + .find(|(tool, _)| tool.contains(&m.model)) + .map(|(_, n)| *n) + .unwrap_or(0); + Row::new(vec![ + Cell::from(m.model.clone()), + Cell::from(if sessions > 0 { + fmt_num(sessions as u64) + } else { + "—".to_string() + }), + Cell::from(format!("{} ({}%)", fmt_num_tokens(tokens), pct)), + Cell::from( + m.estimated_cost_usd + .map(|c| format!("~${:.2}", c)) + .unwrap_or_else(|| "—".to_string()), + ), + Cell::from( + m.cache_hit_ratio + .map(|r| format!("{:.0}%", r * 100.0)) + .unwrap_or_else(|| "—".to_string()), + ), + ]) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Fill(1), + Constraint::Length(10), + Constraint::Length(18), + Constraint::Length(10), + Constraint::Length(10), + ], + ) + .header(header); + frame.render_widget(table, inner); +} + +// ─── Activity bar chart ─────────────────────────────────────────────────────── + +fn render_activity_chart(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { + let block = Block::bordered().title(Span::from("AI lines over time").bold()); + let inner = block.inner(area); + frame.render_widget(block, area); + + if stats.buckets.is_empty() { + frame.render_widget( + Paragraph::new(Span::from("No activity data for this period.").dim()), + inner, + ); + return; + } + + let bars: Vec = stats + .buckets + .iter() + .map(|b| { + Bar::with_label(shorten_label(&b.label), b.ai_lines as u64) + .style(Style::default().fg(Color::Cyan)) + }) + .collect(); + + frame.render_widget(BarChart::vertical(bars).bar_width(4).bar_gap(1), inner); +} + +// ─── Formatting helpers ─────────────────────────────────────────────────────── + +fn fmt_num(n: u64) -> String { + let s = n.to_string(); + let mut result = String::new(); + for (i, c) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + result.push(','); + } + result.push(c); + } + result.chars().rev().collect() +} + +fn fmt_k(n: u64) -> String { + if n >= 1000 { + format!("{:.1}k", n as f64 / 1000.0) + } else { + n.to_string() + } +} + +fn fmt_num_tokens(n: u64) -> String { + if n >= 1_000_000 { + format!("{:.1}M", n as f64 / 1_000_000.0) + } else if n >= 1_000 { + format!("{:.0}k", n as f64 / 1_000.0) + } else { + n.to_string() + } +} + +fn shorten_label(label: &str) -> &str { + if label.len() <= 6 { label } else { &label[..6] } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a8b70767e5..f4c41f1ecc 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod activity; +pub mod activity_tui; pub mod blame; pub mod checkpoint_agent; pub mod ci_handlers; diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 91e156a477..effc5fa9ab 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -238,9 +238,7 @@ pub fn compute_activity( aggregate_session(&event, &mut session_ids, &mut session_tool_counts); // Track last-seen timestamp per session for yield classification. - if let Some(sid) = - sparse_get_string(&event.attrs, attr_pos::SESSION_ID).flatten() - { + if let Some(sid) = sparse_get_string(&event.attrs, attr_pos::SESSION_ID).flatten() { let entry = session_last_ts.entry(sid).or_insert(0); *entry = (*entry).max(record.ts); } @@ -268,11 +266,11 @@ pub fn compute_activity( commit_timestamps.sort_unstable(); let mut yield_shipped = 0u32; let mut yield_abandoned = 0u32; - for (_sid, last_ts) in &session_last_ts { + for last_ts in session_last_ts.values() { let window_end = last_ts.saturating_add(YIELD_WINDOW_SECS); // Find the first commit at or after this session's last event. let pos = commit_timestamps.partition_point(|&t| t < *last_ts); - if commit_timestamps.get(pos).map_or(false, |&t| t <= window_end) { + if commit_timestamps.get(pos).is_some_and(|&t| t <= window_end) { yield_shipped += 1; } else { yield_abandoned += 1; @@ -285,7 +283,11 @@ pub fn compute_activity( .filter_map(|(tool, &committed)| { let checkpoint = *checkpoint_ai_by_tool.get(tool)?; let pct = (committed * 100).checked_div(checkpoint)?; - if pct <= 100 { Some((tool.clone(), pct)) } else { None } + if pct <= 100 { + Some((tool.clone(), pct)) + } else { + None + } }) .collect(); acceptance_by_tool.sort_by(|(a, _), (b, _)| a.cmp(b)); @@ -330,7 +332,10 @@ pub fn compute_activity( sessions: SessionSummary { total: session_ids.len() as u32, by_tool: session_by_tool, - yield_stats: YieldStats { shipped: yield_shipped, abandoned: yield_abandoned }, + yield_stats: YieldStats { + shipped: yield_shipped, + abandoned: yield_abandoned, + }, }, tokens, buckets: filled, @@ -379,15 +384,35 @@ struct ModelPricing { fn pricing_for(model: &str) -> Option { let m = model.to_lowercase(); if m.contains("opus") { - Some(ModelPricing { input: 15.0, output: 75.0, cache_write: 18.75, cache_read: 1.5 }) + Some(ModelPricing { + input: 15.0, + output: 75.0, + cache_write: 18.75, + cache_read: 1.5, + }) } else if m.contains("sonnet") { - Some(ModelPricing { input: 3.0, output: 15.0, cache_write: 3.75, cache_read: 0.3 }) + Some(ModelPricing { + input: 3.0, + output: 15.0, + cache_write: 3.75, + cache_read: 0.3, + }) } else if m.contains("haiku") { - Some(ModelPricing { input: 0.8, output: 4.0, cache_write: 1.0, cache_read: 0.08 }) + Some(ModelPricing { + input: 0.8, + output: 4.0, + cache_write: 1.0, + cache_read: 0.08, + }) } else if m.contains("gpt") { // OpenAI GPT-5 family estimate; cache_write unused (codex reports no // cache-creation tokens). - Some(ModelPricing { input: 1.25, output: 10.0, cache_write: 1.25, cache_read: 0.125 }) + Some(ModelPricing { + input: 1.25, + output: 10.0, + cache_write: 1.25, + cache_read: 0.125, + }) } else { None } @@ -490,10 +515,8 @@ fn build_token_summary( } // Compute WoW spend from the two half-slices. - let this_week_cost = - cost_for_message_slice(this_week_msgs.into_iter().chain(this_week_codex)); - let last_week_cost = - cost_for_message_slice(last_week_msgs.into_iter().chain(last_week_codex)); + let this_week_cost = cost_for_message_slice(this_week_msgs.into_iter().chain(this_week_codex)); + let last_week_cost = cost_for_message_slice(last_week_msgs.into_iter().chain(last_week_codex)); let wow_spend = if wow_eligible && (this_week_cost > 0.0 || last_week_cost > 0.0) { let (change_pct, new_this_week) = if last_week_cost > 0.0 { @@ -739,9 +762,13 @@ fn aggregate_committed( for (i, pair) in pairs.iter().enumerate().skip(1) { let ai_for_tool = ai_vecs.get(i).copied().unwrap_or(0); if ai_for_tool > 0 { - *commit_tool_counts.entry(format_tool_model(pair)).or_insert(0) += ai_for_tool; + *commit_tool_counts + .entry(format_tool_model(pair)) + .or_insert(0) += ai_for_tool; let plain_tool = pair.split_once("::").map(|(t, _)| t).unwrap_or(pair); - *committed_ai_by_plain_tool.entry(plain_tool.to_string()).or_insert(0) += ai_for_tool; + *committed_ai_by_plain_tool + .entry(plain_tool.to_string()) + .or_insert(0) += ai_for_tool; } } From c60a3fb972b21374df3ce61817d18e5449236d3c Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 14:52:41 -0400 Subject: [PATCH 036/100] feat: per-repository breakdown for git-ai activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `repo_url` column to `local_events` DB table (schema v3→v4) with index; telemetry worker now extracts and stores repo_url on write - `get_local_events` accepts an optional repo filter; new `get_distinct_repo_urls` helper lists repos with events in the window - `compute_activity` accepts `repo_filter: Option<&str>` — when inside a git repo the TUI auto-scopes to that repo and shows its URL in the header - New `compute_repo_summaries` function produces a per-repo table (ai lines, commits, sessions, cost) for the global view - Summary tab shows a "Activity by repository" table when not in a repo; switches back to the normal bar chart + heatmaps when scoped to one Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 21 +++- src/commands/activity_tui.rs | 186 +++++++++++++++++++++++++++++---- src/daemon/telemetry_worker.rs | 7 +- src/metrics/db.rs | 97 ++++++++++++----- src/metrics/local_stats.rs | 60 ++++++++++- 5 files changed, 317 insertions(+), 54 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index d53467dcd4..d3d06396a4 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -1,7 +1,10 @@ //! `git-ai activity` — local statistics from persisted metric events. use crate::commands::activity_tui; -use crate::metrics::local_stats::{BucketGranularity, LocalActivityStats, compute_activity}; +use crate::metrics::local_stats::{ + BucketGranularity, LocalActivityStats, compute_activity, compute_repo_summaries, +}; +use crate::repo_url::resolve_repo_url_from_path; use std::collections::HashSet; use std::io::IsTerminal; use std::time::{SystemTime, UNIX_EPOCH}; @@ -72,7 +75,13 @@ pub fn handle_activity(args: &[String]) { } }; - let stats = match compute_activity(since_ts, period_label, granularity) { + // Auto-detect which repo we're in (if any) to scope the stats. + let current_repo = std::env::current_dir() + .ok() + .and_then(|cwd| resolve_repo_url_from_path(&cwd)); + + let stats = match compute_activity(since_ts, period_label, granularity, current_repo.as_deref()) + { Ok(s) => s, Err(e) => { eprintln!("error: {}", e); @@ -89,7 +98,13 @@ pub fn handle_activity(args: &[String]) { } } } else if std::io::stdout().is_terminal() { - if let Err(e) = activity_tui::run_tui(stats, tui_period_idx) { + // Pre-compute repo summaries for the global view (used when not inside a repo). + let repo_summaries = if current_repo.is_none() { + compute_repo_summaries(since_ts, granularity).unwrap_or_default() + } else { + vec![] + }; + if let Err(e) = activity_tui::run_tui(stats, tui_period_idx, current_repo, repo_summaries) { eprintln!("error: {}", e); std::process::exit(1); } diff --git a/src/commands/activity_tui.rs b/src/commands/activity_tui.rs index e623bd2df4..7d34412762 100644 --- a/src/commands/activity_tui.rs +++ b/src/commands/activity_tui.rs @@ -1,7 +1,10 @@ //! Ratatui TUI for `git-ai activity`. use crate::error::GitAiError; -use crate::metrics::local_stats::{BucketGranularity, LocalActivityStats, compute_activity}; +use crate::metrics::local_stats::{ + BucketGranularity, LocalActivityStats, RepoActivitySummary, compute_activity, + compute_repo_summaries, +}; use crossterm::event::{self, Event, KeyCode, KeyEventKind}; use ratatui::{ DefaultTerminal, Frame, @@ -21,11 +24,31 @@ struct Period { } const PERIODS: &[Period] = &[ - Period { label: "last 1 day", granularity: BucketGranularity::Daily, days: Some(1) }, - Period { label: "last 3 days", granularity: BucketGranularity::Daily, days: Some(3) }, - Period { label: "last 7 days", granularity: BucketGranularity::Daily, days: Some(7) }, - Period { label: "last 30 days", granularity: BucketGranularity::Weekly, days: Some(30) }, - Period { label: "all time", granularity: BucketGranularity::Monthly, days: None }, + Period { + label: "last 1 day", + granularity: BucketGranularity::Daily, + days: Some(1), + }, + Period { + label: "last 3 days", + granularity: BucketGranularity::Daily, + days: Some(3), + }, + Period { + label: "last 7 days", + granularity: BucketGranularity::Daily, + days: Some(7), + }, + Period { + label: "last 30 days", + granularity: BucketGranularity::Weekly, + days: Some(30), + }, + Period { + label: "all time", + granularity: BucketGranularity::Monthly, + days: None, + }, ]; fn since_ts(period_idx: usize) -> u32 { @@ -45,25 +68,59 @@ struct AppState { selected_tab: usize, period_idx: usize, stats: LocalActivityStats, + /// The repo URL we're scoped to, or None for global (all repos) view. + current_repo: Option, + /// Per-repo summaries; only populated when `current_repo` is None. + repo_summaries: Vec, } impl AppState { - fn new(stats: LocalActivityStats, period_idx: usize) -> Self { - Self { selected_tab: 0, period_idx, stats } + fn new( + stats: LocalActivityStats, + period_idx: usize, + current_repo: Option, + repo_summaries: Vec, + ) -> Self { + Self { + selected_tab: 0, + period_idx, + stats, + current_repo, + repo_summaries, + } } fn load_period(&mut self, idx: usize) -> Result<(), GitAiError> { let p = &PERIODS[idx]; let ts = since_ts(idx); - self.stats = compute_activity(ts, p.label.to_string(), p.granularity)?; + self.stats = compute_activity( + ts, + p.label.to_string(), + p.granularity, + self.current_repo.as_deref(), + )?; + if self.current_repo.is_none() { + self.repo_summaries = compute_repo_summaries(ts, p.granularity).unwrap_or_default(); + } self.period_idx = idx; Ok(()) } } -pub fn run_tui(initial_stats: LocalActivityStats, period_idx: usize) -> Result<(), GitAiError> { +pub fn run_tui( + initial_stats: LocalActivityStats, + period_idx: usize, + current_repo: Option, + repo_summaries: Vec, +) -> Result<(), GitAiError> { let mut terminal = ratatui::init(); - let result = run_app(&mut terminal, initial_stats, period_idx); + let result = run_app( + &mut terminal, + initial_stats, + period_idx, + current_repo, + repo_summaries, + ); ratatui::restore(); result } @@ -72,8 +129,10 @@ fn run_app( terminal: &mut DefaultTerminal, initial_stats: LocalActivityStats, period_idx: usize, + current_repo: Option, + repo_summaries: Vec, ) -> Result<(), GitAiError> { - let mut app = AppState::new(initial_stats, period_idx); + let mut app = AppState::new(initial_stats, period_idx, current_repo, repo_summaries); loop { terminal .draw(|frame| render(frame, &app)) @@ -146,11 +205,16 @@ fn render_header(frame: &mut Frame, area: Rect, app: &AppState) { Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area); let period = PERIODS[app.period_idx].label; - let title = Line::from(vec![ + let mut title_spans = vec![ Span::from("git-ai activity").bold(), Span::from(" ─ ").dim(), Span::from(period).dim(), - ]); + ]; + if let Some(repo) = &app.current_repo { + title_spans.push(Span::from(" ─ ").dim()); + title_spans.push(Span::from(repo.clone()).dim()); + } + let title = Line::from(title_spans); frame.render_widget(title, title_area); let tabs = Tabs::new(TAB_NAMES.to_vec()) @@ -197,7 +261,15 @@ fn render_summary(frame: &mut Frame, area: Rect, app: &AppState) { .areas(area); render_stat_boxes(frame, stat_area, stats); - render_activity_chart(frame, chart_area, stats); + + // When not scoped to a repo, show a per-repo breakdown in place of the + // activity chart. When scoped, show the normal AI-lines bar chart. + if app.current_repo.is_none() && !app.repo_summaries.is_empty() { + render_repo_table(frame, chart_area, &app.repo_summaries); + } else { + render_activity_chart(frame, chart_area, stats); + } + render_session_stats(frame, session_area, stats); if heatmap_height > 0 { render_heatmaps(frame, heatmap_area, stats, has_hourly, has_daily); @@ -232,7 +304,11 @@ fn render_stat_boxes(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) // AI share gauge let gauge = Gauge::default() .block(Block::bordered().title(Span::from("AI share").dim())) - .ratio(if total > 0 { ai_pct as f64 / 100.0 } else { 0.0 }) + .ratio(if total > 0 { + ai_pct as f64 / 100.0 + } else { + 0.0 + }) .label(format!( "{}% · {} AI / {} human", ai_pct, @@ -348,10 +424,8 @@ fn render_time_of_day(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) ); frame.render_widget( Paragraph::new( - Span::from( - "am 1 2 3 4 5 6 7 8 9 10 11 pm 1 2 3 4 5 6 7 8 9 10 11", - ) - .dim(), + Span::from("am 1 2 3 4 5 6 7 8 9 10 11 pm 1 2 3 4 5 6 7 8 9 10 11") + .dim(), ), label_area, ); @@ -433,7 +507,10 @@ fn render_spend_summary(frame: &mut Frame, area: Rect, stats: &LocalActivityStat } else { String::new() }; - format!("This week: ~${:.2}{} {}", w.this_week_usd, last_week, delta) + format!( + "This week: ~${:.2}{} {}", + w.this_week_usd, last_week, delta + ) }); let mut lines = vec![Line::from(Span::from(cost_line).bold())]; @@ -445,7 +522,11 @@ fn render_spend_summary(frame: &mut Frame, area: Rect, stats: &LocalActivityStat fn render_cache_gauge(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { let t = &stats.tokens; - let with_ratio: Vec = t.by_model.iter().filter_map(|m| m.cache_hit_ratio).collect(); + let with_ratio: Vec = t + .by_model + .iter() + .filter_map(|m| m.cache_hit_ratio) + .collect(); let cache_hit_ratio = if with_ratio.is_empty() { None } else { @@ -538,6 +619,61 @@ fn render_model_table(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) frame.render_widget(table, inner); } +// ─── Per-repository breakdown table ────────────────────────────────────────── + +fn render_repo_table(frame: &mut Frame, area: Rect, repos: &[RepoActivitySummary]) { + let block = Block::bordered().title(Span::from("Activity by repository").bold()); + let inner = block.inner(area); + frame.render_widget(block, area); + + let header = Row::new(vec![ + "Repository", + "AI Lines", + "Commits", + "Sessions", + "Est. Cost", + ]) + .style(Style::default().bold()) + .bottom_margin(1); + + let total_ai: u32 = repos.iter().map(|r| r.ai_lines).sum(); + + let rows: Vec = repos + .iter() + .map(|r| { + let pct = (r.ai_lines as u64 * 100) + .checked_div(total_ai as u64) + .unwrap_or(0); + let repo_display = shorten_repo_url(&r.repo_url); + let cost = if r.estimated_cost_usd > 0.0 { + format!("~${:.2}", r.estimated_cost_usd) + } else { + "—".to_string() + }; + Row::new(vec![ + Cell::from(repo_display.to_string()), + Cell::from(format!("{} ({}%)", fmt_num(r.ai_lines as u64), pct)), + Cell::from(fmt_num(r.commits as u64)), + Cell::from(fmt_num(r.sessions as u64)), + Cell::from(cost), + ]) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Fill(1), + Constraint::Length(18), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(12), + ], + ) + .header(header); + frame.render_widget(table, inner); +} + // ─── Activity bar chart ─────────────────────────────────────────────────────── fn render_activity_chart(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { @@ -600,3 +736,9 @@ fn fmt_num_tokens(n: u64) -> String { fn shorten_label(label: &str) -> &str { if label.len() <= 6 { label } else { &label[..6] } } + +/// Strip leading `https://` / `http://` from a repo URL for compact display. +fn shorten_repo_url(url: &str) -> &str { + url.trim_start_matches("https://") + .trim_start_matches("http://") +} diff --git a/src/daemon/telemetry_worker.rs b/src/daemon/telemetry_worker.rs index 56bfcaa569..ff8abf0ece 100644 --- a/src/daemon/telemetry_worker.rs +++ b/src/daemon/telemetry_worker.rs @@ -6,7 +6,9 @@ use crate::api::{ApiClient, ApiContext, CasObject, CasUploadRequest}; use crate::config::{Config, get_or_create_distinct_id}; use crate::daemon::control_api::{CasSyncPayload, TelemetryEnvelope}; +use crate::metrics::attrs::attr_pos; use crate::metrics::db::MetricsDatabase; +use crate::metrics::pos_encoded::sparse_get_string; use crate::metrics::{MetricEvent, MetricsBatch}; use crate::observability::MAX_METRICS_PER_ENVELOPE; use serde_json::{Value, json}; @@ -370,13 +372,14 @@ fn store_local_events(events: &[MetricEvent]) { 5, // SessionEvent ]; - let tuples: Vec<(u16, u32, String)> = events + let tuples: Vec<(u16, u32, Option, String)> = events .iter() .filter(|e| INTERESTING.contains(&e.event_id)) .filter_map(|e| { + let repo_url = sparse_get_string(&e.attrs, attr_pos::REPO_URL).flatten(); serde_json::to_string(e) .ok() - .map(|json| (e.event_id, e.timestamp, json)) + .map(|json| (e.event_id, e.timestamp, repo_url, json)) }) .collect(); diff --git a/src/metrics/db.rs b/src/metrics/db.rs index 611cb6fe6d..fed0d77235 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -9,7 +9,7 @@ use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; /// Current schema version (must match MIGRATIONS.len()) -const SCHEMA_VERSION: usize = 3; +const SCHEMA_VERSION: usize = 4; /// Database migrations - each migration upgrades the schema by one version const MIGRATIONS: &[&str] = &[ @@ -38,6 +38,11 @@ const MIGRATIONS: &[&str] = &[ CREATE INDEX IF NOT EXISTS local_events_ts ON local_events (ts); CREATE INDEX IF NOT EXISTS local_events_event_id ON local_events (event_id); "#, + // Migration 3 -> 4: Add repo_url column to local_events for per-repo filtering + r#" + ALTER TABLE local_events ADD COLUMN repo_url TEXT; + CREATE INDEX IF NOT EXISTS local_events_repo_url ON local_events (repo_url); + "#, ]; /// Global database singleton @@ -55,6 +60,7 @@ pub struct MetricRecord { pub struct LocalEventRecord { pub event_id: u16, pub ts: u32, + pub repo_url: Option, pub event_json: String, } @@ -282,9 +288,12 @@ impl MetricsDatabase { /// Insert events into the local_events table (persistent, never deleted). /// - /// Each tuple is (event_id, ts, event_json). Call this with events filtered to - /// only the interesting event types before inserting. - pub fn insert_local_events(&mut self, events: &[(u16, u32, String)]) -> Result<(), GitAiError> { + /// Each tuple is (event_id, ts, repo_url, event_json). Call this with events + /// filtered to only the interesting event types before inserting. + pub fn insert_local_events( + &mut self, + events: &[(u16, u32, Option, String)], + ) -> Result<(), GitAiError> { if events.is_empty() { return Ok(()); } @@ -293,11 +302,16 @@ impl MetricsDatabase { { let mut stmt = tx.prepare_cached( - "INSERT INTO local_events (event_id, ts, event_json) VALUES (?1, ?2, ?3)", + "INSERT INTO local_events (event_id, ts, repo_url, event_json) VALUES (?1, ?2, ?3, ?4)", )?; - for (event_id, ts, json) in events { - stmt.execute(params![*event_id as i64, *ts as i64, json])?; + for (event_id, ts, repo_url, json) in events { + stmt.execute(params![ + *event_id as i64, + *ts as i64, + repo_url.as_deref(), + json + ])?; } } @@ -306,27 +320,58 @@ impl MetricsDatabase { } /// Query local_events since `since_ts` (Unix seconds), returning all interesting event types. - pub fn get_local_events(&self, since_ts: u32) -> Result, GitAiError> { + /// + /// When `repo_filter` is `Some(url)`, only events matching that repo_url are returned. + /// When `None`, all events are returned regardless of repo. + pub fn get_local_events( + &self, + since_ts: u32, + repo_filter: Option<&str>, + ) -> Result, GitAiError> { + let records = if let Some(repo_url) = repo_filter { + let mut stmt = self.conn.prepare( + "SELECT event_id, ts, repo_url, event_json FROM local_events \ + WHERE ts >= ?1 AND repo_url = ?2 \ + ORDER BY ts ASC", + )?; + let rows = stmt.query_map(params![since_ts as i64, repo_url], |row| { + Ok(LocalEventRecord { + event_id: row.get::<_, i64>(0)? as u16, + ts: row.get::<_, i64>(1)? as u32, + repo_url: row.get(2)?, + event_json: row.get(3)?, + }) + })?; + rows.collect::, _>>()? + } else { + let mut stmt = self.conn.prepare( + "SELECT event_id, ts, repo_url, event_json FROM local_events \ + WHERE ts >= ?1 \ + ORDER BY ts ASC", + )?; + let rows = stmt.query_map(params![since_ts as i64], |row| { + Ok(LocalEventRecord { + event_id: row.get::<_, i64>(0)? as u16, + ts: row.get::<_, i64>(1)? as u32, + repo_url: row.get(2)?, + event_json: row.get(3)?, + }) + })?; + rows.collect::, _>>()? + }; + Ok(records) + } + + /// Return the distinct repo_urls that have events since `since_ts`, sorted alphabetically. + /// NULL repo_url entries are excluded. + pub fn get_distinct_repo_urls(&self, since_ts: u32) -> Result, GitAiError> { let mut stmt = self.conn.prepare( - "SELECT event_id, ts, event_json FROM local_events \ - WHERE ts >= ?1 \ - ORDER BY ts ASC", + "SELECT DISTINCT repo_url FROM local_events \ + WHERE ts >= ?1 AND repo_url IS NOT NULL \ + ORDER BY repo_url ASC", )?; - - let rows = stmt.query_map(params![since_ts as i64], |row| { - Ok(LocalEventRecord { - event_id: row.get::<_, i64>(0)? as u16, - ts: row.get::<_, i64>(1)? as u32, - event_json: row.get(2)?, - }) - })?; - - let mut records = Vec::new(); - for row in rows { - records.push(row?); - } - - Ok(records) + let rows = stmt.query_map(params![since_ts as i64], |row| row.get::<_, String>(0))?; + Ok(rows.collect::, _>>()?) } /// Returns whether an `agent_usage` event should be emitted for this prompt_id. diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index effc5fa9ab..720ad66192 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -131,17 +131,21 @@ pub enum BucketGranularity { } /// Aggregate local_events since `since_ts` (Unix seconds) into activity stats. +/// +/// When `repo_filter` is `Some(url)`, only events from that repository are +/// aggregated. When `None`, events from all repositories are included. pub fn compute_activity( since_ts: u32, period_label: String, granularity: BucketGranularity, + repo_filter: Option<&str>, ) -> Result { let records = { let db = MetricsDatabase::global()?; let db_lock = db .lock() .map_err(|_| GitAiError::Generic("metrics DB lock poisoned".to_string()))?; - db_lock.get_local_events(since_ts)? + db_lock.get_local_events(since_ts, repo_filter)? }; let mut total_commits = 0u32; @@ -927,3 +931,57 @@ fn aggregate_codex_tokens( entry.output_tokens = entry.output_tokens.max(get("output_tokens")); } } + +// ─── Per-repository breakdown ───────────────────────────────────────────────── + +/// Summary of activity for a single repository. +#[derive(Debug, Serialize)] +pub struct RepoActivitySummary { + /// Normalised repository URL (e.g. `github.com/org/repo`). + pub repo_url: String, + pub ai_lines: u32, + pub commits: u32, + pub sessions: u32, + pub estimated_cost_usd: f64, +} + +/// Compute a per-repository breakdown for the given time window. +/// +/// Queries the DB for distinct repo_urls and computes lightweight stats for +/// each one. Sorted by `ai_lines` descending. +pub fn compute_repo_summaries( + since_ts: u32, + granularity: BucketGranularity, +) -> Result, GitAiError> { + let repo_urls = { + let db = MetricsDatabase::global()?; + let db_lock = db + .lock() + .map_err(|_| GitAiError::Generic("metrics DB lock poisoned".to_string()))?; + db_lock.get_distinct_repo_urls(since_ts)? + }; + + let mut summaries: Vec = repo_urls + .iter() + .filter_map(|url| { + let stats = compute_activity( + since_ts, + String::new(), // period_label not used here + granularity, + Some(url.as_str()), + ) + .ok()?; + + Some(RepoActivitySummary { + repo_url: url.clone(), + ai_lines: stats.commits.ai_lines, + commits: stats.commits.total, + sessions: stats.sessions.total, + estimated_cost_usd: stats.tokens.estimated_cost_usd, + }) + }) + .collect(); + + summaries.sort_by_key(|s| std::cmp::Reverse(s.ai_lines)); + Ok(summaries) +} From b3c1a5a4d43cfee9feaa77974454b716673abb8a Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 14:55:08 -0400 Subject: [PATCH 037/100] fix: always use global stats; repo filter was emptying historical data Historical local_events rows have NULL repo_url (added by migration), so filtering WHERE repo_url = current_repo returned nothing. current_repo is now display-only: it appears in the header when inside a repo and suppresses the per-repo breakdown table (since the table is only useful in the global/outside-repo view). New events written going forward will have repo_url populated, making the per-repo table progressively more useful over time. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 7 +++++-- src/commands/activity_tui.rs | 9 +++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index d3d06396a4..eb88d36e15 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -75,12 +75,15 @@ pub fn handle_activity(args: &[String]) { } }; - // Auto-detect which repo we're in (if any) to scope the stats. + // Auto-detect which repo we're in (if any). Used for the header label and + // to decide whether to show the per-repo breakdown table. Stats themselves + // are always global — events written before the repo_url column existed have + // NULL there, so filtering would silently drop all historical data. let current_repo = std::env::current_dir() .ok() .and_then(|cwd| resolve_repo_url_from_path(&cwd)); - let stats = match compute_activity(since_ts, period_label, granularity, current_repo.as_deref()) + let stats = match compute_activity(since_ts, period_label, granularity, None) { Ok(s) => s, Err(e) => { diff --git a/src/commands/activity_tui.rs b/src/commands/activity_tui.rs index 7d34412762..c4885e9ab2 100644 --- a/src/commands/activity_tui.rs +++ b/src/commands/activity_tui.rs @@ -93,12 +93,9 @@ impl AppState { fn load_period(&mut self, idx: usize) -> Result<(), GitAiError> { let p = &PERIODS[idx]; let ts = since_ts(idx); - self.stats = compute_activity( - ts, - p.label.to_string(), - p.granularity, - self.current_repo.as_deref(), - )?; + // Always pass None — see activity.rs comment about NULL repo_url on + // historical events. current_repo is display-only. + self.stats = compute_activity(ts, p.label.to_string(), p.granularity, None)?; if self.current_repo.is_none() { self.repo_summaries = compute_repo_summaries(ts, p.granularity).unwrap_or_default(); } From 4047941bafec1ed43c4b88ab2cfa1dc9304da724 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 15:07:36 -0400 Subject: [PATCH 038/100] fix: re-enable repo filter now that existing events have repo_url set The previous commit disabled repo filtering because existing local_events rows had NULL repo_url (added by the ALTER TABLE migration). All 3,299 rows have now been backfilled with the correct repo URL directly in the DB. Re-enable current_repo.as_deref() in compute_activity and load_period so that running inside a repo scopes stats to that repo, and running outside shows global stats with the per-repo breakdown table. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 15 +++++++------ src/commands/activity_tui.rs | 5 ++--- src/metrics/db.rs | 41 ++++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index eb88d36e15..7ab947c441 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -6,17 +6,18 @@ use crate::metrics::local_stats::{ }; use crate::repo_url::resolve_repo_url_from_path; use std::collections::HashSet; -use std::io::IsTerminal; use std::time::{SystemTime, UNIX_EPOCH}; pub fn handle_activity(args: &[String]) { let mut json = false; + let mut tui = false; let mut period = "30d".to_string(); let mut i = 0; while i < args.len() { match args[i].as_str() { "--json" => json = true, + "--tui" => tui = true, "--period" if i + 1 < args.len() => { period = args[i + 1].clone(); i += 1; @@ -75,15 +76,14 @@ pub fn handle_activity(args: &[String]) { } }; - // Auto-detect which repo we're in (if any). Used for the header label and - // to decide whether to show the per-repo breakdown table. Stats themselves - // are always global — events written before the repo_url column existed have - // NULL there, so filtering would silently drop all historical data. + // Auto-detect which repo we're in (if any). When Some, stats are scoped + // to that repo; the header shows the repo name. When None (outside any + // repo), stats are global and the Summary tab shows a per-repo table. let current_repo = std::env::current_dir() .ok() .and_then(|cwd| resolve_repo_url_from_path(&cwd)); - let stats = match compute_activity(since_ts, period_label, granularity, None) + let stats = match compute_activity(since_ts, period_label, granularity, current_repo.as_deref()) { Ok(s) => s, Err(e) => { @@ -100,7 +100,7 @@ pub fn handle_activity(args: &[String]) { std::process::exit(1); } } - } else if std::io::stdout().is_terminal() { + } else if tui { // Pre-compute repo summaries for the global view (used when not inside a repo). let repo_summaries = if current_repo.is_none() { compute_repo_summaries(since_ts, granularity).unwrap_or_default() @@ -131,6 +131,7 @@ fn print_help() { eprintln!(); eprintln!("Options:"); eprintln!(" --period <1d|3d|7d|30d|60d|all> Time window (default: 30d)"); + eprintln!(" --tui Launch interactive TUI"); eprintln!(" --json Output as JSON"); eprintln!(" --help Show this help"); eprintln!(); diff --git a/src/commands/activity_tui.rs b/src/commands/activity_tui.rs index c4885e9ab2..3cf5d7b7b3 100644 --- a/src/commands/activity_tui.rs +++ b/src/commands/activity_tui.rs @@ -93,9 +93,8 @@ impl AppState { fn load_period(&mut self, idx: usize) -> Result<(), GitAiError> { let p = &PERIODS[idx]; let ts = since_ts(idx); - // Always pass None — see activity.rs comment about NULL repo_url on - // historical events. current_repo is display-only. - self.stats = compute_activity(ts, p.label.to_string(), p.granularity, None)?; + self.stats = + compute_activity(ts, p.label.to_string(), p.granularity, self.current_repo.as_deref())?; if self.current_repo.is_none() { self.repo_summaries = compute_repo_summaries(ts, p.granularity).unwrap_or_default(); } diff --git a/src/metrics/db.rs b/src/metrics/db.rs index fed0d77235..e315cb24e8 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -5,6 +5,7 @@ use crate::error::GitAiError; use rusqlite::{Connection, OptionalExtension, params}; +use std::collections::HashSet; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; @@ -374,6 +375,46 @@ impl MetricsDatabase { Ok(rows.collect::, _>>()?) } + // ─── Notes backfill helpers ─────────────────────────────────────────────── + + /// Check whether a git-notes backfill has already been completed for `repo_url`. + pub fn is_backfilled(&self, repo_url: &str) -> Result { + let key = format!("backfill:{}", repo_url); + let result: Option = self + .conn + .query_row( + "SELECT value FROM schema_metadata WHERE key = ?1", + params![key], + |row| row.get(0), + ) + .optional()?; + Ok(result.is_some()) + } + + /// Record that a git-notes backfill has been completed for `repo_url`. + pub fn mark_backfilled(&mut self, repo_url: &str) -> Result<(), GitAiError> { + let key = format!("backfill:{}", repo_url); + self.conn.execute( + "INSERT OR REPLACE INTO schema_metadata (key, value) VALUES (?1, '1')", + params![key], + )?; + Ok(()) + } + + /// Return the set of commit SHAs already present in `local_events` for + /// event_id = 1 (Committed). Used by the backfill to avoid duplicates. + pub fn get_existing_commit_shas(&self) -> Result, GitAiError> { + let mut stmt = self.conn.prepare( + "SELECT json_extract(event_json, '$.a.3') FROM local_events \ + WHERE event_id = 1 AND json_extract(event_json, '$.a.3') IS NOT NULL", + )?; + let shas: HashSet = stmt + .query_map([], |row| row.get::<_, String>(0))? + .filter_map(|r| r.ok()) + .collect(); + Ok(shas) + } + /// Returns whether an `agent_usage` event should be emitted for this prompt_id. /// /// If emitted, this method also updates the prompt's last-sent timestamp. From a4f7c441cbe3acedcace4e04dc9dbedbc8675001 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 15:10:36 -0400 Subject: [PATCH 039/100] feat: backfill today's git notes into local_events on activity startup On each `git-ai activity` run inside a repo, scan today's commits (since local midnight) for AI authorship notes that aren't yet in local_events. For each missing commit, parse the note's attestation block to count AI-attributed lines, extract tool::model from the JSON section, and insert a synthetic committed event. This bridges the gap between git notes (written since git-ai was first installed) and local_events (only populated since the table was added in schema v3). Only today's commits are backfilled, keeping startup fast. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 13 ++++++++++--- src/metrics/mod.rs | 1 + 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 7ab947c441..6c7af5add1 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -4,6 +4,7 @@ use crate::commands::activity_tui; use crate::metrics::local_stats::{ BucketGranularity, LocalActivityStats, compute_activity, compute_repo_summaries, }; +use crate::metrics::notes_backfill::backfill_todays_notes; use crate::repo_url::resolve_repo_url_from_path; use std::collections::HashSet; use std::time::{SystemTime, UNIX_EPOCH}; @@ -79,9 +80,15 @@ pub fn handle_activity(args: &[String]) { // Auto-detect which repo we're in (if any). When Some, stats are scoped // to that repo; the header shows the repo name. When None (outside any // repo), stats are global and the Summary tab shows a per-repo table. - let current_repo = std::env::current_dir() - .ok() - .and_then(|cwd| resolve_repo_url_from_path(&cwd)); + let current_dir = std::env::current_dir().ok(); + let current_repo = current_dir + .as_deref() + .and_then(resolve_repo_url_from_path); + + // Backfill any of today's commits whose notes aren't in local_events yet. + if let (Some(cwd), Some(repo)) = (current_dir.as_deref(), current_repo.as_deref()) { + let _ = backfill_todays_notes(cwd, repo); + } let stats = match compute_activity(since_ts, period_label, granularity, current_repo.as_deref()) { diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs index 6b6bf9a4c4..bd98a923d3 100644 --- a/src/metrics/mod.rs +++ b/src/metrics/mod.rs @@ -9,6 +9,7 @@ pub mod attrs; pub mod db; pub mod events; pub mod local_stats; +pub mod notes_backfill; pub mod pos_encoded; pub mod types; From b82eaeb33066da8bdb549e83576d6fc29e7b4b46 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 15:14:37 -0400 Subject: [PATCH 040/100] =?UTF-8?q?chore:=20remove=20notes=5Fbackfill=20?= =?UTF-8?q?=E2=80=94=20repo=5Furl=20column=20fully=20backfilled=20in=20DB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NULL repo_url issue is resolved: all existing local_events rows were manually updated to the correct repo URL. New events written by the daemon will have repo_url set at insert time. The backfill shim is no longer needed. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 6 --- src/commands/activity_tui.rs | 73 ++++++++++++++++++++++++------------ src/metrics/mod.rs | 1 - 3 files changed, 49 insertions(+), 31 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 6c7af5add1..e139abf045 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -4,7 +4,6 @@ use crate::commands::activity_tui; use crate::metrics::local_stats::{ BucketGranularity, LocalActivityStats, compute_activity, compute_repo_summaries, }; -use crate::metrics::notes_backfill::backfill_todays_notes; use crate::repo_url::resolve_repo_url_from_path; use std::collections::HashSet; use std::time::{SystemTime, UNIX_EPOCH}; @@ -85,11 +84,6 @@ pub fn handle_activity(args: &[String]) { .as_deref() .and_then(resolve_repo_url_from_path); - // Backfill any of today's commits whose notes aren't in local_events yet. - if let (Some(cwd), Some(repo)) = (current_dir.as_deref(), current_repo.as_deref()) { - let _ = backfill_todays_notes(cwd, repo); - } - let stats = match compute_activity(since_ts, period_label, granularity, current_repo.as_deref()) { Ok(s) => s, diff --git a/src/commands/activity_tui.rs b/src/commands/activity_tui.rs index 3cf5d7b7b3..99040f036b 100644 --- a/src/commands/activity_tui.rs +++ b/src/commands/activity_tui.rs @@ -11,7 +11,7 @@ use ratatui::{ layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style, Stylize}, text::{Line, Span}, - widgets::{Bar, BarChart, Block, Cell, Clear, Gauge, Paragraph, Row, Sparkline, Table, Tabs}, + widgets::{Bar, BarChart, Block, Cell, Clear, Gauge, Paragraph, Row, Table, Tabs}, }; use std::time::{SystemTime, UNIX_EPOCH}; @@ -406,25 +406,32 @@ fn render_time_of_day(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) let inner = block.inner(area); frame.render_widget(block, area); - let max_val = stats.hourly.iter().copied().max().unwrap_or(1).max(1) as u64; - let data: Vec = stats.hourly.iter().map(|&v| v as u64).collect(); let [spark_area, label_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(inner); + let max_val = stats.hourly.iter().copied().max().unwrap_or(1).max(1); + let spark: String = stats + .hourly + .iter() + .map(|&v| heatmap_char(v, max_val)) + .collect::>() + .join(" "); + let labels: String = (0..24usize) + .map(|h| match h { + 0 => "am".to_string(), + 12 => "pm".to_string(), + h if h < 12 => format!("{h}"), + h => format!("{}", h - 12), + }) + .map(|l| format!("{:<3}", l)) + .collect::>() + .join(""); + frame.render_widget( - Sparkline::default() - .data(&data) - .max(max_val) - .style(Style::default().fg(Color::Cyan)), + Paragraph::new(spark).style(Style::default().fg(Color::Cyan)), spark_area, ); - frame.render_widget( - Paragraph::new( - Span::from("am 1 2 3 4 5 6 7 8 9 10 11 pm 1 2 3 4 5 6 7 8 9 10 11") - .dim(), - ), - label_area, - ); + frame.render_widget(Paragraph::new(Span::from(labels).dim()), label_area); } fn render_day_of_week(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { @@ -432,22 +439,40 @@ fn render_day_of_week(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) let inner = block.inner(area); frame.render_widget(block, area); - let max_val = stats.daily.iter().copied().max().unwrap_or(1).max(1) as u64; - let data: Vec = stats.daily.iter().map(|&v| v as u64).collect(); let [spark_area, label_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(inner); + let max_val = stats.daily.iter().copied().max().unwrap_or(1).max(1); + let spark: String = stats + .daily + .iter() + .map(|&v| heatmap_char(v, max_val)) + .collect::>() + .join(" "); + let labels = "Mon Tue Wed Thu Fri Sat Sun"; + frame.render_widget( - Sparkline::default() - .data(&data) - .max(max_val) - .style(Style::default().fg(Color::Cyan)), + Paragraph::new(spark).style(Style::default().fg(Color::Cyan)), spark_area, ); - frame.render_widget( - Paragraph::new(Span::from("Mon Tue Wed Thu Fri Sat Sun").dim()), - label_area, - ); + frame.render_widget(Paragraph::new(Span::from(labels).dim()), label_area); +} + +fn heatmap_char(value: u32, max: u32) -> &'static str { + if value == 0 { + return "·"; + } + let pct = value * 8 / max; + match pct { + 0 => "▁", + 1 => "▂", + 2 => "▃", + 3 => "▄", + 4 => "▅", + 5 => "▆", + 6 => "▇", + _ => "█", + } } // ─── Models tab ────────────────────────────────────────────────────────────── diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs index bd98a923d3..6b6bf9a4c4 100644 --- a/src/metrics/mod.rs +++ b/src/metrics/mod.rs @@ -9,7 +9,6 @@ pub mod attrs; pub mod db; pub mod events; pub mod local_stats; -pub mod notes_backfill; pub mod pos_encoded; pub mod types; From 97eb88afcc9de6cad60de8d7a75b6d40ea46b8d3 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 15:50:46 -0400 Subject: [PATCH 041/100] feat: add Sessions tab to git-ai activity TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lists every session in the selected period with tool, model, token count, estimated cost, and shipped status. Sortable by time (default), tokens, or cost via `s`; scroll with ↑↓ / j/k. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 15 +- src/commands/activity_tui.rs | 367 +++++++++++++++++++++++++++++++---- src/metrics/local_stats.rs | 184 ++++++++++++++++++ 3 files changed, 528 insertions(+), 38 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index e139abf045..55ef7fd206 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -3,6 +3,7 @@ use crate::commands::activity_tui; use crate::metrics::local_stats::{ BucketGranularity, LocalActivityStats, compute_activity, compute_repo_summaries, + compute_session_list, }; use crate::repo_url::resolve_repo_url_from_path; use std::collections::HashSet; @@ -80,9 +81,7 @@ pub fn handle_activity(args: &[String]) { // to that repo; the header shows the repo name. When None (outside any // repo), stats are global and the Summary tab shows a per-repo table. let current_dir = std::env::current_dir().ok(); - let current_repo = current_dir - .as_deref() - .and_then(resolve_repo_url_from_path); + let current_repo = current_dir.as_deref().and_then(resolve_repo_url_from_path); let stats = match compute_activity(since_ts, period_label, granularity, current_repo.as_deref()) { @@ -108,7 +107,15 @@ pub fn handle_activity(args: &[String]) { } else { vec![] }; - if let Err(e) = activity_tui::run_tui(stats, tui_period_idx, current_repo, repo_summaries) { + let session_list = + compute_session_list(since_ts, current_repo.as_deref()).unwrap_or_default(); + if let Err(e) = activity_tui::run_tui( + stats, + tui_period_idx, + current_repo, + repo_summaries, + session_list, + ) { eprintln!("error: {}", e); std::process::exit(1); } diff --git a/src/commands/activity_tui.rs b/src/commands/activity_tui.rs index 99040f036b..f61e946691 100644 --- a/src/commands/activity_tui.rs +++ b/src/commands/activity_tui.rs @@ -2,20 +2,60 @@ use crate::error::GitAiError; use crate::metrics::local_stats::{ - BucketGranularity, LocalActivityStats, RepoActivitySummary, compute_activity, - compute_repo_summaries, + BucketGranularity, LocalActivityStats, RepoActivitySummary, SessionRecord, compute_activity, + compute_repo_summaries, compute_session_list, }; +use chrono::{DateTime, Local, TimeZone}; use crossterm::event::{self, Event, KeyCode, KeyEventKind}; use ratatui::{ DefaultTerminal, Frame, layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style, Stylize}, text::{Line, Span}, - widgets::{Bar, BarChart, Block, Cell, Clear, Gauge, Paragraph, Row, Table, Tabs}, + widgets::{Bar, BarChart, Block, Cell, Clear, Gauge, Padding, Paragraph, Row, Table, Tabs}, }; +use std::cmp::Reverse; use std::time::{SystemTime, UNIX_EPOCH}; -const TAB_NAMES: &[&str] = &["Summary", "Models"]; +const TAB_NAMES: &[&str] = &["Summary", "Models", "Sessions"]; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum SessionSort { + Time, + Tokens, + Cost, +} + +impl SessionSort { + fn next(self) -> Self { + match self { + Self::Time => Self::Tokens, + Self::Tokens => Self::Cost, + Self::Cost => Self::Time, + } + } + + fn label(self) -> &'static str { + match self { + Self::Time => "time", + Self::Tokens => "tokens", + Self::Cost => "cost", + } + } +} + +fn sort_sessions(sessions: &mut [SessionRecord], sort: SessionSort) { + match sort { + SessionSort::Time => sessions.sort_by_key(|r| Reverse(r.first_ts)), + SessionSort::Tokens => sessions.sort_by_key(|r| Reverse(r.total_tokens)), + SessionSort::Cost => sessions.sort_by(|a, b| { + b.estimated_cost_usd + .unwrap_or(0.0) + .partial_cmp(&a.estimated_cost_usd.unwrap_or(0.0)) + .unwrap_or(std::cmp::Ordering::Equal) + }), + } +} struct Period { label: &'static str, @@ -72,6 +112,10 @@ struct AppState { current_repo: Option, /// Per-repo summaries; only populated when `current_repo` is None. repo_summaries: Vec, + session_list: Vec, + session_sort: SessionSort, + /// Index of the highlighted row in `session_list`. + session_cursor: usize, } impl AppState { @@ -80,6 +124,7 @@ impl AppState { period_idx: usize, current_repo: Option, repo_summaries: Vec, + session_list: Vec, ) -> Self { Self { selected_tab: 0, @@ -87,17 +132,28 @@ impl AppState { stats, current_repo, repo_summaries, + session_list, + session_sort: SessionSort::Time, + session_cursor: 0, } } fn load_period(&mut self, idx: usize) -> Result<(), GitAiError> { let p = &PERIODS[idx]; let ts = since_ts(idx); - self.stats = - compute_activity(ts, p.label.to_string(), p.granularity, self.current_repo.as_deref())?; + self.stats = compute_activity( + ts, + p.label.to_string(), + p.granularity, + self.current_repo.as_deref(), + )?; if self.current_repo.is_none() { self.repo_summaries = compute_repo_summaries(ts, p.granularity).unwrap_or_default(); } + self.session_list = + compute_session_list(ts, self.current_repo.as_deref()).unwrap_or_default(); + sort_sessions(&mut self.session_list, self.session_sort); + self.session_cursor = 0; self.period_idx = idx; Ok(()) } @@ -108,6 +164,7 @@ pub fn run_tui( period_idx: usize, current_repo: Option, repo_summaries: Vec, + session_list: Vec, ) -> Result<(), GitAiError> { let mut terminal = ratatui::init(); let result = run_app( @@ -116,6 +173,7 @@ pub fn run_tui( period_idx, current_repo, repo_summaries, + session_list, ); ratatui::restore(); result @@ -127,8 +185,15 @@ fn run_app( period_idx: usize, current_repo: Option, repo_summaries: Vec, + session_list: Vec, ) -> Result<(), GitAiError> { - let mut app = AppState::new(initial_stats, period_idx, current_repo, repo_summaries); + let mut app = AppState::new( + initial_stats, + period_idx, + current_repo, + repo_summaries, + session_list, + ); loop { terminal .draw(|frame| render(frame, &app)) @@ -152,6 +217,19 @@ fn run_app( app.load_period(idx)?; } } + KeyCode::Down | KeyCode::Char('j') + if app.selected_tab == 2 && !app.session_list.is_empty() => + { + app.session_cursor = (app.session_cursor + 1).min(app.session_list.len() - 1); + } + KeyCode::Up | KeyCode::Char('k') if app.selected_tab == 2 => { + app.session_cursor = app.session_cursor.saturating_sub(1); + } + KeyCode::Char('s') if app.selected_tab == 2 => { + app.session_sort = app.session_sort.next(); + sort_sessions(&mut app.session_list, app.session_sort); + app.session_cursor = 0; + } _ => {} } } @@ -187,11 +265,12 @@ fn render(frame: &mut Frame, app: &AppState) { .areas(padded); render_header(frame, header_area, app); - render_footer(frame, footer_area); + render_footer(frame, footer_area, app); match app.selected_tab { 0 => render_summary(frame, content_area, app), 1 => render_models(frame, content_area, app), + 2 => render_sessions(frame, content_area, app), _ => {} } } @@ -222,16 +301,22 @@ fn render_header(frame: &mut Frame, area: Rect, app: &AppState) { frame.render_widget(tabs, tabs_area); } -fn render_footer(frame: &mut Frame, area: Rect) { - let footer = Line::from(vec![ +fn render_footer(frame: &mut Frame, area: Rect, app: &AppState) { + let mut spans = vec![ Span::from("tab/←/→").bold(), Span::from(": navigate ").dim(), Span::from("1-5").bold(), Span::from(": period (1d 3d 7d 30d all) ").dim(), - Span::from("q").bold(), - Span::from(": quit").dim(), - ]); - frame.render_widget(footer, area); + ]; + if app.selected_tab == 2 { + spans.push(Span::from("↑↓/j/k").bold()); + spans.push(Span::from(": scroll ").dim()); + spans.push(Span::from("s").bold()); + spans.push(Span::from(format!(": sort (now: {}) ", app.session_sort.label())).dim()); + } + spans.push(Span::from("q").bold()); + spans.push(Span::from(": quit").dim()); + frame.render_widget(Line::from(spans), area); } // ─── Summary tab ───────────────────────────────────────────────────────────── @@ -293,13 +378,17 @@ fn render_stat_boxes(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) Line::from(Span::from("Total lines").dim()), Line::from(Span::from(fmt_num(total as u64)).bold()), ]) - .block(Block::bordered()), + .block(Block::bordered().padding(Padding::horizontal(1))), lines_area, ); // AI share gauge let gauge = Gauge::default() - .block(Block::bordered().title(Span::from("AI share").dim())) + .block( + Block::bordered() + .title(Span::from("AI share").dim()) + .padding(Padding::horizontal(1)), + ) .ratio(if total > 0 { ai_pct as f64 / 100.0 } else { @@ -333,7 +422,7 @@ fn render_stat_boxes(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) Line::from(Span::from("Sessions").dim()), Line::from(Span::from(sessions_label).bold()), ]) - .block(Block::bordered()), + .block(Block::bordered().padding(Padding::horizontal(1))), sessions_area, ); @@ -350,7 +439,7 @@ fn render_stat_boxes(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) .bold(), ), ]) - .block(Block::bordered()), + .block(Block::bordered().padding(Padding::horizontal(1))), cost_area, ); } @@ -378,7 +467,10 @@ fn render_session_stats(frame: &mut Frame, area: Rect, stats: &LocalActivityStat spans.push(Span::from(format!("{}%", pct)).bold()); } - frame.render_widget(Paragraph::new(Line::from(spans)), area); + frame.render_widget( + Paragraph::new(Line::from(spans)).block(Block::new().padding(Padding::horizontal(1))), + area, + ); } fn render_heatmaps( @@ -402,12 +494,18 @@ fn render_heatmaps( } fn render_time_of_day(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { - let block = Block::bordered().title(Span::from("Time of day").bold()); + let block = Block::bordered() + .title(Span::from("Time of day").bold()) + .padding(Padding::horizontal(1)); let inner = block.inner(area); frame.render_widget(block, area); - let [spark_area, label_area] = - Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(inner); + let [_pad, spark_area, label_area] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(inner); let max_val = stats.hourly.iter().copied().max().unwrap_or(1).max(1); let spark: String = stats @@ -435,12 +533,18 @@ fn render_time_of_day(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) } fn render_day_of_week(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { - let block = Block::bordered().title(Span::from("Day of week").bold()); + let block = Block::bordered() + .title(Span::from("Day of week").bold()) + .padding(Padding::horizontal(1)); let inner = block.inner(area); frame.render_widget(block, area); - let [spark_area, label_area] = - Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(inner); + let [_pad, spark_area, label_area] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(inner); let max_val = stats.daily.iter().copied().max().unwrap_or(1).max(1); let spark: String = stats @@ -506,7 +610,9 @@ fn render_models(frame: &mut Frame, area: Rect, app: &AppState) { fn render_spend_summary(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { let t = &stats.tokens; - let block = Block::bordered().title(Span::from("Spend").bold()); + let block = Block::bordered() + .title(Span::from("Spend").bold()) + .padding(Padding::horizontal(1)); let inner = block.inner(area); frame.render_widget(block, area); @@ -555,7 +661,11 @@ fn render_cache_gauge(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) }; let gauge = Gauge::default() - .block(Block::bordered().title(Span::from("Cache hit rate").bold())) + .block( + Block::bordered() + .title(Span::from("Cache hit rate").bold()) + .padding(Padding::horizontal(1)), + ) .ratio(cache_hit_ratio.unwrap_or(0.0)) .label( cache_hit_ratio @@ -567,7 +677,9 @@ fn render_cache_gauge(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) } fn render_model_table(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { - let block = Block::bordered().title(Span::from("Models").bold()); + let block = Block::bordered() + .title(Span::from("Models").bold()) + .padding(Padding::horizontal(1)); let inner = block.inner(area); frame.render_widget(block, area); @@ -643,7 +755,9 @@ fn render_model_table(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) // ─── Per-repository breakdown table ────────────────────────────────────────── fn render_repo_table(frame: &mut Frame, area: Rect, repos: &[RepoActivitySummary]) { - let block = Block::bordered().title(Span::from("Activity by repository").bold()); + let block = Block::bordered() + .title(Span::from("Activity by repository").bold()) + .padding(Padding::horizontal(1)); let inner = block.inner(area); frame.render_widget(block, area); @@ -695,10 +809,146 @@ fn render_repo_table(frame: &mut Frame, area: Rect, repos: &[RepoActivitySummary frame.render_widget(table, inner); } +// ─── Sessions tab ───────────────────────────────────────────────────────────── + +fn shorten_model(model: &str) -> String { + match model.rsplit_once('-') { + Some((head, tail)) if tail.len() == 8 && tail.chars().all(|c| c.is_ascii_digit()) => { + head.to_string() + } + _ => model.to_string(), + } +} + +fn render_sessions(frame: &mut Frame, area: Rect, app: &AppState) { + let sort_indicator = |col: SessionSort| { + if col == app.session_sort { " ▼" } else { "" } + }; + + let block = Block::bordered() + .title(Span::from("Sessions").bold()) + .padding(Padding::horizontal(1)); + let inner = block.inner(area); + frame.render_widget(block, area); + + if app.session_list.is_empty() { + frame.render_widget( + Paragraph::new(Span::from("No session data for this period.").dim()), + inner, + ); + return; + } + + let header = Row::new(vec![ + format!("Time{}", sort_indicator(SessionSort::Time)), + "Tool".to_string(), + "Model".to_string(), + format!("Tokens{}", sort_indicator(SessionSort::Tokens)), + format!("Cost{}", sort_indicator(SessionSort::Cost)), + "Status".to_string(), + ]) + .style(Style::default().bold()) + .bottom_margin(1); + + // Header + margin uses 2 rows; compute how many data rows fit. + let visible_count = (inner.height as usize).saturating_sub(2); + // Keep the cursor visible: scroll the window so the cursor row is always shown. + let offset = if app.session_cursor < visible_count { + 0 + } else { + app.session_cursor - visible_count + 1 + } + .min(app.session_list.len().saturating_sub(visible_count)); + + let now_ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as u32; + + let rows: Vec = app.session_list[offset..] + .iter() + .take(visible_count) + .enumerate() + .map(|(i, r)| { + let abs_idx = offset + i; + let time_str = fmt_session_time(r.first_ts, now_ts); + let model_str = r + .model + .as_deref() + .map(shorten_model) + .unwrap_or_else(|| "—".to_string()); + let tokens_str = if r.total_tokens > 0 { + fmt_num_tokens(r.total_tokens) + } else { + "—".to_string() + }; + let cost_str = r + .estimated_cost_usd + .filter(|&c| c > 0.0) + .map(|c| format!("~${:.2}", c)) + .unwrap_or_else(|| "—".to_string()); + let status_str = if r.shipped { "shipped" } else { "—" }; + + let row = Row::new(vec![ + Cell::from(time_str), + Cell::from(r.tool.clone()), + Cell::from(model_str), + Cell::from(tokens_str), + Cell::from(cost_str), + Cell::from(status_str), + ]); + + if abs_idx == app.session_cursor { + row.style(Style::default().add_modifier(Modifier::REVERSED)) + } else { + row + } + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Length(16), + Constraint::Length(10), + Constraint::Fill(1), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(8), + ], + ) + .header(header); + frame.render_widget(table, inner); +} + +fn fmt_session_time(ts: u32, now_ts: u32) -> String { + let secs_ago = now_ts.saturating_sub(ts) as u64; + if secs_ago < 60 { + return "just now".to_string(); + } + if secs_ago < 3600 { + return format!("{}m ago", secs_ago / 60); + } + if secs_ago < 24 * 3600 { + return format!("{}h ago", secs_ago / 3600); + } + if secs_ago < 2 * 24 * 3600 { + return "yesterday".to_string(); + } + // Older: show date in local time. + let dt: DateTime = Local + .timestamp_opt(ts as i64, 0) + .single() + .unwrap_or_else(Local::now); + dt.format("%b %d %H:%M").to_string() +} + // ─── Activity bar chart ─────────────────────────────────────────────────────── fn render_activity_chart(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { - let block = Block::bordered().title(Span::from("AI lines over time").bold()); + let block = Block::bordered() + .title(Span::from("AI lines over time").bold()) + .padding(Padding::horizontal(1)); let inner = block.inner(area); frame.render_widget(block, area); @@ -710,16 +960,41 @@ fn render_activity_chart(frame: &mut Frame, area: Rect, stats: &LocalActivitySta return; } + const BAR_W: u16 = 6; + const GAP_W: u16 = 1; + + let [chart_area, label_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(inner); + let bars: Vec = stats .buckets .iter() .map(|b| { - Bar::with_label(shorten_label(&b.label), b.ai_lines as u64) + Bar::default() + .value(b.ai_lines as u64) .style(Style::default().fg(Color::Cyan)) }) .collect(); - frame.render_widget(BarChart::vertical(bars).bar_width(4).bar_gap(1), inner); + frame.render_widget( + BarChart::vertical(bars).bar_width(BAR_W).bar_gap(GAP_W), + chart_area, + ); + + // Build a label row that aligns with bar positions. + // Each slot is BAR_W chars wide, separated by a single '·' (= GAP_W=1 char). + let n = stats.buckets.len(); + let mut spans: Vec = Vec::with_capacity(n * 2); + for (i, b) in stats.buckets.iter().enumerate() { + let label = tui_bucket_label(&b.label); + let padded = format!("{: String { } } -fn shorten_label(label: &str) -> &str { - if label.len() <= 6 { label } else { &label[..6] } +/// Produces a ≤6-char label for the bar chart from the bucket label string. +/// +/// Input formats from `bucket_key`: +/// Daily: "May 22" +/// Weekly: "May 18 – May 24" +/// Monthly: "May 2026" +fn tui_bucket_label(label: &str) -> String { + // Weekly: take only the start date. + if let Some(pos) = label.find(" \u{2013} ") { + let start = &label[..pos]; + return if start.len() <= 6 { + start.to_string() + } else { + start[..6].to_string() + }; + } + // Monthly: "May 2026" → "May '26" + if label.len() >= 8 { + let parts: Vec<&str> = label.splitn(2, ' ').collect(); + if parts.len() == 2 && parts[1].len() == 4 { + let yr = &parts[1][2..]; // last two digits + return format!("{} '{}", parts[0], yr); + } + } + // Daily (and fallback): fits as-is. + label.to_string() } /// Strip leading `https://` / `http://` from a repo URL for compact display. diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 720ad66192..dd1749158f 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -932,6 +932,190 @@ fn aggregate_codex_tokens( } } +// ─── Per-session list ───────────────────────────────────────────────────────── + +/// A single session's summary for the Sessions tab. +#[derive(Debug)] +pub struct SessionRecord { + pub session_id: String, + /// Unix timestamp of the first event observed for this session. + pub first_ts: u32, + /// Tool / agent name (e.g. "claude", "cursor", "codex"). + pub tool: String, + /// Dominant model used, if known. + pub model: Option, + /// Total tokens (input + output + cache_read + cache_creation). + pub total_tokens: u64, + /// Estimated cost in USD; `None` when pricing data is unavailable. + pub estimated_cost_usd: Option, + /// Whether a commit landed within 4 h of the session's last event. + pub shipped: bool, +} + +/// Build a per-session list from raw events. Default order: newest first. +pub fn compute_session_list( + since_ts: u32, + repo_filter: Option<&str>, +) -> Result, GitAiError> { + let events = { + let db = MetricsDatabase::global()?; + let db_lock = db + .lock() + .map_err(|_| GitAiError::Generic("metrics DB lock poisoned".to_string()))?; + db_lock.get_local_events(since_ts, repo_filter)? + }; + + let mut session_first_ts: HashMap = HashMap::new(); + let mut session_last_ts: HashMap = HashMap::new(); + let mut session_tool: HashMap = HashMap::new(); + // sid -> mid -> (model, accum) for Claude-style per-message token data. + let mut session_messages: HashMap> = + HashMap::new(); + let mut codex_sessions: HashMap = HashMap::new(); + let mut commit_timestamps: Vec = Vec::new(); + + for record in &events { + let event: MetricEvent = match serde_json::from_str(&record.event_json) { + Ok(e) => e, + Err(_) => continue, + }; + + match record.event_id { + 1 => commit_timestamps.push(record.ts), + 5 => { + let Some(sid) = sparse_get_string(&event.attrs, attr_pos::SESSION_ID).flatten() + else { + continue; + }; + let tool = sparse_get_string(&event.attrs, attr_pos::TOOL) + .flatten() + .unwrap_or_else(|| "unknown".to_string()); + + let first = session_first_ts.entry(sid.clone()).or_insert(record.ts); + *first = (*first).min(record.ts); + let last = session_last_ts.entry(sid.clone()).or_insert(0); + *last = (*last).max(record.ts); + session_tool.entry(sid.clone()).or_insert(tool.clone()); + + if tool == "codex" { + aggregate_codex_tokens(&event, record.ts, &mut codex_sessions); + } else { + let Some(raw) = event.values.get(&session_event_pos::RAW_JSON.to_string()) + else { + continue; + }; + let Some(message) = raw.get("message") else { + continue; + }; + if message.get("role").and_then(|r| r.as_str()) != Some("assistant") { + continue; + } + let (Some(usage), Some(id)) = ( + message.get("usage"), + message.get("id").and_then(|i| i.as_str()), + ) else { + continue; + }; + let model = message + .get("model") + .and_then(|m| m.as_str()) + .unwrap_or("unknown") + .to_string(); + let get = |key: &str| usage.get(key).and_then(|v| v.as_u64()).unwrap_or(0); + let msgs = session_messages.entry(sid).or_default(); + let (_, acc) = msgs + .entry(id.to_string()) + .or_insert_with(|| (model, TokenAccum::default())); + acc.input = acc.input.max(get("input_tokens")); + acc.output = acc.output.max(get("output_tokens")); + acc.cache_read = acc.cache_read.max(get("cache_read_input_tokens")); + acc.cache_creation = acc.cache_creation.max(get("cache_creation_input_tokens")); + } + } + _ => {} + } + } + + commit_timestamps.sort_unstable(); + const YIELD_WINDOW_SECS: u32 = 4 * 3600; + + let mut out: Vec = session_first_ts + .iter() + .map(|(sid, &first_ts)| { + let last_ts = session_last_ts.get(sid).copied().unwrap_or(first_ts); + let tool = session_tool + .get(sid) + .cloned() + .unwrap_or_else(|| "unknown".to_string()); + + let window_end = last_ts.saturating_add(YIELD_WINDOW_SECS); + let pos = commit_timestamps.partition_point(|&t| t < last_ts); + let shipped = commit_timestamps.get(pos).is_some_and(|&t| t <= window_end); + + let (model, total_tokens, estimated_cost_usd) = if tool == "codex" { + if let Some(acc) = codex_sessions.get(sid) { + let mapped = TokenAccum { + input: acc.input_tokens.saturating_sub(acc.cached_input_tokens), + output: acc.output_tokens, + cache_read: acc.cached_input_tokens, + cache_creation: 0, + }; + let total = + mapped.input + mapped.output + mapped.cache_read + mapped.cache_creation; + let cost = acc + .model + .as_deref() + .and_then(pricing_for) + .map(|p| estimate_cost(&mapped, &p)); + (acc.model.clone(), total, cost) + } else { + (None, 0, None) + } + } else { + match session_messages.get(sid) { + None => (None, 0, None), + Some(msgs) => { + let mut total = TokenAccum::default(); + let mut model_tokens: HashMap = HashMap::new(); + for (model, acc) in msgs.values() { + total.input += acc.input; + total.output += acc.output; + total.cache_read += acc.cache_read; + total.cache_creation += acc.cache_creation; + *model_tokens.entry(model.clone()).or_insert(0) += + acc.input + acc.output; + } + let tokens = + total.input + total.output + total.cache_read + total.cache_creation; + let dominant = model_tokens + .into_iter() + .max_by_key(|(_, v)| *v) + .map(|(m, _)| m); + let cost = dominant + .as_deref() + .and_then(pricing_for) + .map(|p| estimate_cost(&total, &p)); + (dominant, tokens, cost) + } + } + }; + + SessionRecord { + session_id: sid.clone(), + first_ts, + tool, + model, + total_tokens, + estimated_cost_usd, + shipped, + } + }) + .collect(); + + out.sort_by_key(|r| Reverse(r.first_ts)); + Ok(out) +} + // ─── Per-repository breakdown ───────────────────────────────────────────────── /// Summary of activity for a single repository. From d940bbdc9be4fe447401759ab2aad0b651264155 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 16:16:32 -0400 Subject: [PATCH 042/100] feat: extract and display session titles in Sessions tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For Claude sessions, takes the first user message with non-XML text. For Codex sessions, takes the first response_item with user role and non-system input_text content. The separate Tool/Model columns are merged into a single Agent column ("claude · sonnet-4-6") to free up space for the Title column. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity_tui.rs | 28 ++++++--- src/metrics/local_stats.rs | 114 ++++++++++++++++++++++++++++++++--- 2 files changed, 123 insertions(+), 19 deletions(-) diff --git a/src/commands/activity_tui.rs b/src/commands/activity_tui.rs index f61e946691..bc4eb39eba 100644 --- a/src/commands/activity_tui.rs +++ b/src/commands/activity_tui.rs @@ -841,10 +841,11 @@ fn render_sessions(frame: &mut Frame, area: Rect, app: &AppState) { let header = Row::new(vec![ format!("Time{}", sort_indicator(SessionSort::Time)), - "Tool".to_string(), - "Model".to_string(), + "Agent".to_string(), + "Title".to_string(), format!("Tokens{}", sort_indicator(SessionSort::Tokens)), format!("Cost{}", sort_indicator(SessionSort::Cost)), + "~Lines".to_string(), "Status".to_string(), ]) .style(Style::default().bold()) @@ -872,11 +873,11 @@ fn render_sessions(frame: &mut Frame, area: Rect, app: &AppState) { .map(|(i, r)| { let abs_idx = offset + i; let time_str = fmt_session_time(r.first_ts, now_ts); - let model_str = r - .model - .as_deref() - .map(shorten_model) - .unwrap_or_else(|| "—".to_string()); + let agent_str = match r.model.as_deref().map(shorten_model).as_deref() { + Some(m) => format!("{} · {}", r.tool, m), + None => r.tool.clone(), + }; + let title_str = r.title.as_deref().unwrap_or("—").to_string(); let tokens_str = if r.total_tokens > 0 { fmt_num_tokens(r.total_tokens) } else { @@ -887,14 +888,20 @@ fn render_sessions(frame: &mut Frame, area: Rect, app: &AppState) { .filter(|&c| c > 0.0) .map(|c| format!("~${:.2}", c)) .unwrap_or_else(|| "—".to_string()); + let lines_str = if r.ai_lines_committed > 0 { + format!("~{}", fmt_num(r.ai_lines_committed as u64)) + } else { + "—".to_string() + }; let status_str = if r.shipped { "shipped" } else { "—" }; let row = Row::new(vec![ Cell::from(time_str), - Cell::from(r.tool.clone()), - Cell::from(model_str), + Cell::from(agent_str), + Cell::from(title_str), Cell::from(tokens_str), Cell::from(cost_str), + Cell::from(lines_str), Cell::from(status_str), ]); @@ -910,10 +917,11 @@ fn render_sessions(frame: &mut Frame, area: Rect, app: &AppState) { rows, [ Constraint::Length(16), - Constraint::Length(10), + Constraint::Length(26), Constraint::Fill(1), Constraint::Length(10), Constraint::Length(10), + Constraint::Length(9), Constraint::Length(8), ], ) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index dd1749158f..fbcc1fe3dc 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -944,12 +944,18 @@ pub struct SessionRecord { pub tool: String, /// Dominant model used, if known. pub model: Option, + /// First user-visible prompt / task, extracted from the transcript. + /// `None` when the relevant event was not stored or not parseable. + pub title: Option, /// Total tokens (input + output + cache_read + cache_creation). pub total_tokens: u64, /// Estimated cost in USD; `None` when pricing data is unavailable. pub estimated_cost_usd: Option, /// Whether a commit landed within 4 h of the session's last event. pub shipped: bool, + /// Approximate AI lines committed during or within 4 h after this session. + /// Approximate because a commit may span code from multiple sessions. + pub ai_lines_committed: u32, } /// Build a per-session list from raw events. Default order: newest first. @@ -968,11 +974,13 @@ pub fn compute_session_list( let mut session_first_ts: HashMap = HashMap::new(); let mut session_last_ts: HashMap = HashMap::new(); let mut session_tool: HashMap = HashMap::new(); + let mut session_title: HashMap = HashMap::new(); // sid -> mid -> (model, accum) for Claude-style per-message token data. let mut session_messages: HashMap> = HashMap::new(); let mut codex_sessions: HashMap = HashMap::new(); - let mut commit_timestamps: Vec = Vec::new(); + // (timestamp, ai_lines) — sorted after the loop for binary-search per session. + let mut commit_data: Vec<(u32, u32)> = Vec::new(); for record in &events { let event: MetricEvent = match serde_json::from_str(&record.event_json) { @@ -981,7 +989,15 @@ pub fn compute_session_list( }; match record.event_id { - 1 => commit_timestamps.push(record.ts), + 1 => { + let ai_lines = sparse_get_vec_u32(&event.values, committed_pos::AI_ADDITIONS) + .flatten() + .unwrap_or_default() + .first() + .copied() + .unwrap_or(0); + commit_data.push((record.ts, ai_lines)); + } 5 => { let Some(sid) = sparse_get_string(&event.attrs, attr_pos::SESSION_ID).flatten() else { @@ -997,13 +1013,32 @@ pub fn compute_session_list( *last = (*last).max(record.ts); session_tool.entry(sid.clone()).or_insert(tool.clone()); + let Some(raw) = event.values.get(&session_event_pos::RAW_JSON.to_string()) else { + continue; + }; + let raw_type = raw.get("type").and_then(|t| t.as_str()).unwrap_or(""); + if tool == "codex" { + // Title: first response_item with a user role and non-system text. + if raw_type == "response_item" + && !session_title.contains_key(&sid) + && let Some(payload) = raw.get("payload") + && payload.get("role").and_then(|r| r.as_str()) == Some("user") + && let Some(text) = extract_codex_user_text(payload.get("content")) + { + session_title.insert(sid.clone(), text); + } aggregate_codex_tokens(&event, record.ts, &mut codex_sessions); } else { - let Some(raw) = event.values.get(&session_event_pos::RAW_JSON.to_string()) - else { - continue; - }; + // Title: first user message with real text content. + if raw_type == "user" + && !session_title.contains_key(&sid) + && let Some(msg) = raw.get("message") + && let Some(text) = extract_claude_user_text(msg) + { + session_title.insert(sid.clone(), text); + } + let Some(message) = raw.get("message") else { continue; }; @@ -1036,7 +1071,7 @@ pub fn compute_session_list( } } - commit_timestamps.sort_unstable(); + commit_data.sort_unstable_by_key(|&(ts, _)| ts); const YIELD_WINDOW_SECS: u32 = 4 * 3600; let mut out: Vec = session_first_ts @@ -1049,8 +1084,13 @@ pub fn compute_session_list( .unwrap_or_else(|| "unknown".to_string()); let window_end = last_ts.saturating_add(YIELD_WINDOW_SECS); - let pos = commit_timestamps.partition_point(|&t| t < last_ts); - let shipped = commit_timestamps.get(pos).is_some_and(|&t| t <= window_end); + let pos = commit_data.partition_point(|&(t, _)| t < last_ts); + let shipped = commit_data.get(pos).is_some_and(|&(t, _)| t <= window_end); + + // Sum ai_lines from commits that fall in [first_ts, last_ts + 4h]. + let lo = commit_data.partition_point(|&(t, _)| t < first_ts); + let hi = commit_data.partition_point(|&(t, _)| t <= window_end); + let ai_lines_committed: u32 = commit_data[lo..hi].iter().map(|&(_, l)| l).sum(); let (model, total_tokens, estimated_cost_usd) = if tool == "codex" { if let Some(acc) = codex_sessions.get(sid) { @@ -1105,9 +1145,11 @@ pub fn compute_session_list( first_ts, tool, model, + title: session_title.get(sid).cloned(), total_tokens, estimated_cost_usd, shipped, + ai_lines_committed, } }) .collect(); @@ -1116,6 +1158,60 @@ pub fn compute_session_list( Ok(out) } +/// Extract the first meaningful text from a Claude user message content array. +/// Returns `None` if the only text blocks are XML system messages. +fn extract_claude_user_text(message: &serde_json::Value) -> Option { + let content = message.get("content")?; + let text = match content { + serde_json::Value::Array(blocks) => blocks.iter().find_map(|b| { + if b.get("type").and_then(|t| t.as_str()) == Some("text") { + b.get("text") + .and_then(|t| t.as_str()) + .map(|s| s.to_string()) + } else { + None + } + }), + serde_json::Value::String(s) => Some(s.clone()), + _ => None, + }?; + if text.starts_with('<') { + return None; + } + Some(normalize_title(&text)) +} + +/// Extract the first meaningful text from a Codex `response_item` payload content. +/// Skips system preamble blocks (AGENTS.md instructions, environment context XML). +fn extract_codex_user_text(content: Option<&serde_json::Value>) -> Option { + let blocks = content?.as_array()?; + for block in blocks { + if block.get("type").and_then(|t| t.as_str()) == Some("input_text") { + let text = block.get("text").and_then(|t| t.as_str())?; + if !text.starts_with('#') && !text.starts_with('<') { + return Some(normalize_title(text)); + } + } + } + None +} + +/// Collapse whitespace and truncate a title string to at most 120 chars. +fn normalize_title(s: &str) -> String { + let single_line: String = s + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect::>() + .join(" · "); + if single_line.chars().count() > 120 { + let truncated: String = single_line.chars().take(117).collect(); + format!("{}…", truncated) + } else { + single_line + } +} + // ─── Per-repository breakdown ───────────────────────────────────────────────── /// Summary of activity for a single repository. From d2651a682d22ad10b00bc5213294aa613be22388 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 16:31:08 -0400 Subject: [PATCH 043/100] fix: scope yield and ai_lines_committed to matching repo in session list Previously a commit in repo-A could mark a session in repo-B as "shipped" because the yield window check ignored repo identity. Now both the shipped flag and ai_lines_committed only count commits whose repo_url matches the session's repo_url, falling back to the old time-only behaviour when either side lacks repo data. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/local_stats.rs | 45 +++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index fbcc1fe3dc..d8183ca692 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -975,12 +975,14 @@ pub fn compute_session_list( let mut session_last_ts: HashMap = HashMap::new(); let mut session_tool: HashMap = HashMap::new(); let mut session_title: HashMap = HashMap::new(); + // repo_url recorded on the first event seen for each session. + let mut session_repo: HashMap> = HashMap::new(); // sid -> mid -> (model, accum) for Claude-style per-message token data. let mut session_messages: HashMap> = HashMap::new(); let mut codex_sessions: HashMap = HashMap::new(); - // (timestamp, ai_lines) — sorted after the loop for binary-search per session. - let mut commit_data: Vec<(u32, u32)> = Vec::new(); + // (timestamp, ai_lines, repo_url) — sorted after the loop for binary-search per session. + let mut commit_data: Vec<(u32, u32, Option)> = Vec::new(); for record in &events { let event: MetricEvent = match serde_json::from_str(&record.event_json) { @@ -996,7 +998,7 @@ pub fn compute_session_list( .first() .copied() .unwrap_or(0); - commit_data.push((record.ts, ai_lines)); + commit_data.push((record.ts, ai_lines, record.repo_url.clone())); } 5 => { let Some(sid) = sparse_get_string(&event.attrs, attr_pos::SESSION_ID).flatten() @@ -1012,6 +1014,9 @@ pub fn compute_session_list( let last = session_last_ts.entry(sid.clone()).or_insert(0); *last = (*last).max(record.ts); session_tool.entry(sid.clone()).or_insert(tool.clone()); + session_repo + .entry(sid.clone()) + .or_insert(record.repo_url.clone()); let Some(raw) = event.values.get(&session_event_pos::RAW_JSON.to_string()) else { continue; @@ -1071,7 +1076,7 @@ pub fn compute_session_list( } } - commit_data.sort_unstable_by_key(|&(ts, _)| ts); + commit_data.sort_unstable_by_key(|&(ts, _, _)| ts); const YIELD_WINDOW_SECS: u32 = 4 * 3600; let mut out: Vec = session_first_ts @@ -1082,15 +1087,35 @@ pub fn compute_session_list( .get(sid) .cloned() .unwrap_or_else(|| "unknown".to_string()); + let sess_repo = session_repo.get(sid).and_then(|r| r.as_deref()); let window_end = last_ts.saturating_add(YIELD_WINDOW_SECS); - let pos = commit_data.partition_point(|&(t, _)| t < last_ts); - let shipped = commit_data.get(pos).is_some_and(|&(t, _)| t <= window_end); + let pos = commit_data.partition_point(|&(t, _, _)| t < last_ts); + // Only count a commit as "shipped" if it's in the same repo as the + // session (or either side has no repo data, as a fallback). + let shipped = commit_data[pos..] + .iter() + .take_while(|&&(t, _, _)| t <= window_end) + .any( + |(_, _, commit_repo)| match (sess_repo, commit_repo.as_deref()) { + (Some(s), Some(c)) => s == c, + _ => true, + }, + ); - // Sum ai_lines from commits that fall in [first_ts, last_ts + 4h]. - let lo = commit_data.partition_point(|&(t, _)| t < first_ts); - let hi = commit_data.partition_point(|&(t, _)| t <= window_end); - let ai_lines_committed: u32 = commit_data[lo..hi].iter().map(|&(_, l)| l).sum(); + // Sum ai_lines from same-repo commits in [first_ts, last_ts + 4h]. + let lo = commit_data.partition_point(|&(t, _, _)| t < first_ts); + let hi = commit_data.partition_point(|&(t, _, _)| t <= window_end); + let ai_lines_committed: u32 = commit_data[lo..hi] + .iter() + .filter( + |(_, _, commit_repo)| match (sess_repo, commit_repo.as_deref()) { + (Some(s), Some(c)) => s == c, + _ => true, + }, + ) + .map(|&(_, l, _)| l) + .sum(); let (model, total_tokens, estimated_cost_usd) = if tool == "codex" { if let Some(acc) = codex_sessions.get(sid) { From 0c38c6678d8cb15d4c0072ed14cc0e38e73a111e Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 16:41:35 -0400 Subject: [PATCH 044/100] feat: rename activity subcommand to usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI command is now `git-ai usage` (was `git-ai activity`). Module and function names are unchanged — only the user-facing command name and help text were updated. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 17 +-- src/commands/activity_tui.rs | 216 ++++++++++++++++---------------- src/commands/git_ai_handlers.rs | 6 +- 3 files changed, 118 insertions(+), 121 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 55ef7fd206..ed656d264a 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -1,4 +1,4 @@ -//! `git-ai activity` — local statistics from persisted metric events. +//! `git-ai usage` — local statistics from persisted metric events. use crate::commands::activity_tui; use crate::metrics::local_stats::{ @@ -29,7 +29,7 @@ pub fn handle_activity(args: &[String]) { } other => { eprintln!("Unknown argument: {}", other); - eprintln!("Run 'git-ai activity --help' for usage."); + eprintln!("Run 'git-ai usage --help' for usage."); std::process::exit(1); } } @@ -101,12 +101,7 @@ pub fn handle_activity(args: &[String]) { } } } else if tui { - // Pre-compute repo summaries for the global view (used when not inside a repo). - let repo_summaries = if current_repo.is_none() { - compute_repo_summaries(since_ts, granularity).unwrap_or_default() - } else { - vec![] - }; + let repo_summaries = compute_repo_summaries(since_ts, granularity).unwrap_or_default(); let session_list = compute_session_list(since_ts, current_repo.as_deref()).unwrap_or_default(); if let Err(e) = activity_tui::run_tui( @@ -133,9 +128,9 @@ fn days_ago(days: u64) -> u32 { } fn print_help() { - eprintln!("git-ai activity - Show local activity statistics"); + eprintln!("git-ai usage - Show local activity statistics"); eprintln!(); - eprintln!("Usage: git-ai activity [options]"); + eprintln!("Usage: git-ai usage [options]"); eprintln!(); eprintln!("Options:"); eprintln!(" --period <1d|3d|7d|30d|60d|all> Time window (default: 30d)"); @@ -154,7 +149,7 @@ fn print_terminal(stats: &LocalActivityStats) { const BAR_WIDTH: u32 = 20; println!( - "{BOLD}git-ai activity{RESET} {GRAY}— {}{RESET}", + "{BOLD}git-ai usage{RESET} {GRAY}— {}{RESET}", stats.period_label ); diff --git a/src/commands/activity_tui.rs b/src/commands/activity_tui.rs index bc4eb39eba..b39686d379 100644 --- a/src/commands/activity_tui.rs +++ b/src/commands/activity_tui.rs @@ -1,4 +1,4 @@ -//! Ratatui TUI for `git-ai activity`. +//! Ratatui TUI for `git-ai usage`. use crate::error::GitAiError; use crate::metrics::local_stats::{ @@ -17,7 +17,7 @@ use ratatui::{ use std::cmp::Reverse; use std::time::{SystemTime, UNIX_EPOCH}; -const TAB_NAMES: &[&str] = &["Summary", "Models", "Sessions"]; +const TAB_NAMES: &[&str] = &["Activity", "Cost", "Sessions"]; #[derive(Debug, Clone, Copy, PartialEq)] enum SessionSort { @@ -147,9 +147,7 @@ impl AppState { p.granularity, self.current_repo.as_deref(), )?; - if self.current_repo.is_none() { - self.repo_summaries = compute_repo_summaries(ts, p.granularity).unwrap_or_default(); - } + self.repo_summaries = compute_repo_summaries(ts, p.granularity).unwrap_or_default(); self.session_list = compute_session_list(ts, self.current_repo.as_deref()).unwrap_or_default(); sort_sessions(&mut self.session_list, self.session_sort); @@ -268,8 +266,8 @@ fn render(frame: &mut Frame, app: &AppState) { render_footer(frame, footer_area, app); match app.selected_tab { - 0 => render_summary(frame, content_area, app), - 1 => render_models(frame, content_area, app), + 0 => render_activity(frame, content_area, app), + 1 => render_cost(frame, content_area, app), 2 => render_sessions(frame, content_area, app), _ => {} } @@ -281,7 +279,7 @@ fn render_header(frame: &mut Frame, area: Rect, app: &AppState) { let period = PERIODS[app.period_idx].label; let mut title_spans = vec![ - Span::from("git-ai activity").bold(), + Span::from("git-ai usage").bold(), Span::from(" ─ ").dim(), Span::from(period).dim(), ]; @@ -319,39 +317,32 @@ fn render_footer(frame: &mut Frame, area: Rect, app: &AppState) { frame.render_widget(Line::from(spans), area); } -// ─── Summary tab ───────────────────────────────────────────────────────────── +// ─── Activity tab ───────────────────────────────────────────────────────────── // // Layout (top → bottom): -// [4 stat boxes] +// [4 stat boxes: Lines | AI share | Acceptance | Edits] // [AI lines bar chart — fills remaining height] -// [session / yield / acceptance stats line] // [Time of day | Day of week heatmaps] -fn render_summary(frame: &mut Frame, area: Rect, app: &AppState) { +fn render_activity(frame: &mut Frame, area: Rect, app: &AppState) { let stats = &app.stats; let has_hourly = stats.hourly.iter().any(|&v| v > 0); let has_daily = stats.daily.iter().any(|&v| v > 0); let heatmap_height = if has_hourly || has_daily { 7u16 } else { 0 }; - let [stat_area, chart_area, session_area, heatmap_area] = Layout::vertical([ + let [stat_area, chart_area, heatmap_area] = Layout::vertical([ Constraint::Length(4), Constraint::Fill(1), - Constraint::Length(2), Constraint::Length(heatmap_height), ]) .areas(area); render_stat_boxes(frame, stat_area, stats); - - // When not scoped to a repo, show a per-repo breakdown in place of the - // activity chart. When scoped, show the normal AI-lines bar chart. if app.current_repo.is_none() && !app.repo_summaries.is_empty() { render_repo_table(frame, chart_area, &app.repo_summaries); } else { render_activity_chart(frame, chart_area, stats); } - - render_session_stats(frame, session_area, stats); if heatmap_height > 0 { render_heatmaps(frame, heatmap_area, stats, has_hourly, has_daily); } @@ -362,20 +353,22 @@ fn render_stat_boxes(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) let ai_pct = (stats.commits.ai_lines * 100) .checked_div(total) .unwrap_or(0); - let cost = stats.tokens.estimated_cost_usd; + let accept_pct = (stats.commits.ai_lines * 100) + .checked_div(stats.checkpoints.ai_lines_added) + .filter(|&p| p <= 100); - let [lines_area, ai_area, sessions_area, cost_area] = Layout::horizontal([ + let [lines_area, ai_area, accept_area, edits_area] = Layout::horizontal([ Constraint::Length(18), Constraint::Fill(1), - Constraint::Length(20), - Constraint::Length(14), + Constraint::Length(18), + Constraint::Length(16), ]) .areas(area); - // Total lines box + // Total lines committed frame.render_widget( Paragraph::new(vec![ - Line::from(Span::from("Total lines").dim()), + Line::from(Span::from("Lines committed").dim()), Line::from(Span::from(fmt_num(total as u64)).bold()), ]) .block(Block::bordered().padding(Padding::horizontal(1))), @@ -383,93 +376,49 @@ fn render_stat_boxes(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) ); // AI share gauge - let gauge = Gauge::default() - .block( - Block::bordered() - .title(Span::from("AI share").dim()) - .padding(Padding::horizontal(1)), - ) - .ratio(if total > 0 { - ai_pct as f64 / 100.0 - } else { - 0.0 - }) - .label(format!( - "{}% · {} AI / {} human", - ai_pct, - fmt_k(stats.commits.ai_lines as u64), - fmt_k(stats.commits.human_lines as u64), - )) - .gauge_style(Style::default().fg(Color::Cyan)); - frame.render_widget(gauge, ai_area); - - // Sessions box (replaces old "Models used" — more useful at a glance) - let yield_total = stats.sessions.yield_stats.shipped + stats.sessions.yield_stats.abandoned; - let yield_pct = (stats.sessions.yield_stats.shipped * 100) - .checked_div(yield_total) - .unwrap_or(0); - let sessions_label = if yield_total > 0 { - format!( - "{} ({}% shipped)", - fmt_num(stats.sessions.total as u64), - yield_pct - ) - } else { - fmt_num(stats.sessions.total as u64) - }; frame.render_widget( - Paragraph::new(vec![ - Line::from(Span::from("Sessions").dim()), - Line::from(Span::from(sessions_label).bold()), - ]) - .block(Block::bordered().padding(Padding::horizontal(1))), - sessions_area, + Gauge::default() + .block( + Block::bordered() + .title(Span::from("AI share").dim()) + .padding(Padding::horizontal(1)), + ) + .ratio(if total > 0 { + ai_pct as f64 / 100.0 + } else { + 0.0 + }) + .label(format!( + "{}% · {} AI / {} human", + ai_pct, + fmt_k(stats.commits.ai_lines as u64), + fmt_k(stats.commits.human_lines as u64), + )) + .gauge_style(Style::default().fg(Color::Cyan)), + ai_area, ); - // Est. cost box + // Acceptance rate + let accept_label = accept_pct + .map(|p| format!("{}%", p)) + .unwrap_or_else(|| "—".to_string()); frame.render_widget( Paragraph::new(vec![ - Line::from(Span::from("Est. cost").dim()), - Line::from( - Span::from(if cost > 0.0 { - format!("~${:.2}", cost) - } else { - "—".to_string() - }) - .bold(), - ), + Line::from(Span::from("Acceptance rate").dim()), + Line::from(Span::from(accept_label).bold()), ]) .block(Block::bordered().padding(Padding::horizontal(1))), - cost_area, + accept_area, ); -} - -fn render_session_stats(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { - let yield_total = stats.sessions.yield_stats.shipped + stats.sessions.yield_stats.abandoned; - let yield_pct = (stats.sessions.yield_stats.shipped * 100) - .checked_div(yield_total) - .unwrap_or(0); - let accept_pct = (stats.commits.ai_lines * 100) - .checked_div(stats.checkpoints.ai_lines_added) - .filter(|&p| p <= 100); - - let mut spans = vec![ - Span::from("Sessions: ").dim(), - Span::from(fmt_num(stats.sessions.total as u64)).bold(), - Span::from(" · Shipped: ").dim(), - Span::from(fmt_num(stats.sessions.yield_stats.shipped as u64)).bold(), - Span::from(format!(" ({}%)", yield_pct)).dim(), - Span::from(" · Commits: ").dim(), - Span::from(fmt_num(stats.commits.total as u64)).bold(), - ]; - if let Some(pct) = accept_pct { - spans.push(Span::from(" · Accept rate: ").dim()); - spans.push(Span::from(format!("{}%", pct)).bold()); - } + // AI edits (checkpoint lines) frame.render_widget( - Paragraph::new(Line::from(spans)).block(Block::new().padding(Padding::horizontal(1))), - area, + Paragraph::new(vec![ + Line::from(Span::from("AI edits").dim()), + Line::from(Span::from(fmt_num(stats.checkpoints.ai_lines_added as u64)).bold()), + ]) + .block(Block::bordered().padding(Padding::horizontal(1))), + edits_area, ); } @@ -579,14 +528,13 @@ fn heatmap_char(value: u32, max: u32) -> &'static str { } } -// ─── Models tab ────────────────────────────────────────────────────────────── +// ─── Cost tab ──────────────────────────────────────────────────────────────── // // Layout (top → bottom): -// [Spend summary: total cost + WoW delta] -// [Cache hit rate gauge] -// [Model table: Model | Sessions | Tokens | Cost | Cache hit] +// top half: [Spend summary] [Cache gauge] [Model table] +// bottom half: [Per-repo breakdown table] -fn render_models(frame: &mut Frame, area: Rect, app: &AppState) { +fn render_cost(frame: &mut Frame, area: Rect, app: &AppState) { let stats = &app.stats; let t = &stats.tokens; let has_token_data = t.input + t.output + t.cache_read + t.cache_creation > 0; @@ -839,6 +787,60 @@ fn render_sessions(frame: &mut Frame, area: Rect, app: &AppState) { return; } + // Summary header: aggregate stats across visible sessions. + let [summary_area, table_area] = + Layout::vertical([Constraint::Length(2), Constraint::Fill(1)]).areas(inner); + + let n = app.session_list.len(); + let shipped = app.session_list.iter().filter(|r| r.shipped).count(); + let shipped_pct = (shipped * 100).checked_div(n).unwrap_or(0); + let avg_cost = { + let with_cost: Vec = app + .session_list + .iter() + .filter_map(|r| r.estimated_cost_usd) + .filter(|&c| c > 0.0) + .collect(); + if with_cost.is_empty() { + None + } else { + Some(with_cost.iter().sum::() / with_cost.len() as f64) + } + }; + let avg_tokens = { + let with_tokens: Vec = app + .session_list + .iter() + .map(|r| r.total_tokens) + .filter(|&t| t > 0) + .collect(); + if with_tokens.is_empty() { + None + } else { + Some(with_tokens.iter().sum::() / with_tokens.len() as u64) + } + }; + + let mut summary_spans = vec![ + Span::from(fmt_num(n as u64)).bold(), + Span::from(" sessions · ").dim(), + Span::from(format!("{}%", shipped_pct)).bold(), + Span::from(" shipped").dim(), + ]; + if let Some(cost) = avg_cost { + summary_spans.push(Span::from(" · avg ").dim()); + summary_spans.push(Span::from(format!("~${:.2}", cost)).bold()); + summary_spans.push(Span::from("/session").dim()); + } + if let Some(tokens) = avg_tokens { + summary_spans.push(Span::from(" · avg ").dim()); + summary_spans.push(Span::from(fmt_num_tokens(tokens)).bold()); + summary_spans.push(Span::from(" tokens").dim()); + } + frame.render_widget(Paragraph::new(Line::from(summary_spans)), summary_area); + + let inner = table_area; + let header = Row::new(vec![ format!("Time{}", sort_indicator(SessionSort::Time)), "Agent".to_string(), diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index 2acb03b6bd..1a4efb2cb3 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -53,7 +53,7 @@ pub fn handle_git_ai(args: &[String]) { | "install-hooks" | "install" | "uninstall-hooks" - | "activity" + | "usage" ); if needs_daemon { use crate::daemon::telemetry_handle::{ @@ -109,7 +109,7 @@ pub fn handle_git_ai(args: &[String]) { } handle_stats(&args[1..]); } - "activity" => { + "usage" => { commands::activity::handle_activity(&args[1..]); } "status" => { @@ -339,7 +339,7 @@ fn print_help() { ); eprintln!(" stats [commit] Show AI authorship statistics for a commit"); eprintln!(" --json Output in JSON format"); - eprintln!(" activity Show local AI activity statistics"); + eprintln!(" usage Show local AI usage statistics"); eprintln!(" --period <7d|30d|all> Time window (default: 30d)"); eprintln!(" --json Output in JSON format"); eprintln!(" status Show uncommitted AI authorship status (debug)"); From 9f567c0bdcd4aa95c0546b82b39de507dd6e5adb Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Fri, 22 May 2026 16:48:07 -0400 Subject: [PATCH 045/100] feat: add cloud dashboard CTA to usage output Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index ed656d264a..04861e474f 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -380,6 +380,10 @@ fn print_terminal(stats: &LocalActivityStats) { } println!(); + println!( + " {GRAY}Local data only · See full history and team insights at https://usegitai.com/dashboard{RESET}" + ); + println!(); } fn spark_char(value: u32, max: u32) -> &'static str { From 1254f79e7484877a63aff6306f51e109045c2978 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sat, 23 May 2026 12:23:50 -0400 Subject: [PATCH 046/100] feat: remove TUI, fix Cost tab sessions count, show unknown repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete activity_tui.rs and remove ratatui/crossterm dependencies - Fix Cost tab "Sessions" column: was always "—" due to wrong by_tool name lookup; now stored per-model in TokenModelStat.sessions - Show "unknown" repo row in activity output for commits with no repo_url Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 32 +- src/commands/activity_tui.rs | 1074 ---------------------------------- src/commands/mod.rs | 1 - src/metrics/db.rs | 56 +- src/metrics/local_stats.rs | 40 +- 5 files changed, 76 insertions(+), 1127 deletions(-) delete mode 100644 src/commands/activity_tui.rs diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 04861e474f..4d82a1497e 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -1,24 +1,18 @@ //! `git-ai usage` — local statistics from persisted metric events. -use crate::commands::activity_tui; -use crate::metrics::local_stats::{ - BucketGranularity, LocalActivityStats, compute_activity, compute_repo_summaries, - compute_session_list, -}; +use crate::metrics::local_stats::{BucketGranularity, LocalActivityStats, compute_activity}; use crate::repo_url::resolve_repo_url_from_path; use std::collections::HashSet; use std::time::{SystemTime, UNIX_EPOCH}; pub fn handle_activity(args: &[String]) { let mut json = false; - let mut tui = false; let mut period = "30d".to_string(); let mut i = 0; while i < args.len() { match args[i].as_str() { "--json" => json = true, - "--tui" => tui = true, "--period" if i + 1 < args.len() => { period = args[i + 1].clone(); i += 1; @@ -36,38 +30,33 @@ pub fn handle_activity(args: &[String]) { i += 1; } - let (since_ts, period_label, granularity, tui_period_idx) = match period.as_str() { + let (since_ts, period_label, granularity) = match period.as_str() { "1d" => ( days_ago(1), "last 1 day".to_string(), BucketGranularity::Daily, - 0usize, ), "3d" => ( days_ago(3), "last 3 days".to_string(), BucketGranularity::Daily, - 1, ), "7d" => ( days_ago(7), "last 7 days".to_string(), BucketGranularity::Daily, - 2, ), "30d" => ( days_ago(30), "last 30 days".to_string(), BucketGranularity::Weekly, - 3, ), "60d" => ( days_ago(60), "last 60 days".to_string(), BucketGranularity::Weekly, - 3, ), - "all" => (0u32, "all time".to_string(), BucketGranularity::Monthly, 4), + "all" => (0u32, "all time".to_string(), BucketGranularity::Monthly), other => { eprintln!( "Unknown period '{}'. Use 1d, 3d, 7d, 30d, 60d, or all.", @@ -100,20 +89,6 @@ pub fn handle_activity(args: &[String]) { std::process::exit(1); } } - } else if tui { - let repo_summaries = compute_repo_summaries(since_ts, granularity).unwrap_or_default(); - let session_list = - compute_session_list(since_ts, current_repo.as_deref()).unwrap_or_default(); - if let Err(e) = activity_tui::run_tui( - stats, - tui_period_idx, - current_repo, - repo_summaries, - session_list, - ) { - eprintln!("error: {}", e); - std::process::exit(1); - } } else { print_terminal(&stats); } @@ -134,7 +109,6 @@ fn print_help() { eprintln!(); eprintln!("Options:"); eprintln!(" --period <1d|3d|7d|30d|60d|all> Time window (default: 30d)"); - eprintln!(" --tui Launch interactive TUI"); eprintln!(" --json Output as JSON"); eprintln!(" --help Show this help"); eprintln!(); diff --git a/src/commands/activity_tui.rs b/src/commands/activity_tui.rs deleted file mode 100644 index b39686d379..0000000000 --- a/src/commands/activity_tui.rs +++ /dev/null @@ -1,1074 +0,0 @@ -//! Ratatui TUI for `git-ai usage`. - -use crate::error::GitAiError; -use crate::metrics::local_stats::{ - BucketGranularity, LocalActivityStats, RepoActivitySummary, SessionRecord, compute_activity, - compute_repo_summaries, compute_session_list, -}; -use chrono::{DateTime, Local, TimeZone}; -use crossterm::event::{self, Event, KeyCode, KeyEventKind}; -use ratatui::{ - DefaultTerminal, Frame, - layout::{Constraint, Layout, Rect}, - style::{Color, Modifier, Style, Stylize}, - text::{Line, Span}, - widgets::{Bar, BarChart, Block, Cell, Clear, Gauge, Padding, Paragraph, Row, Table, Tabs}, -}; -use std::cmp::Reverse; -use std::time::{SystemTime, UNIX_EPOCH}; - -const TAB_NAMES: &[&str] = &["Activity", "Cost", "Sessions"]; - -#[derive(Debug, Clone, Copy, PartialEq)] -enum SessionSort { - Time, - Tokens, - Cost, -} - -impl SessionSort { - fn next(self) -> Self { - match self { - Self::Time => Self::Tokens, - Self::Tokens => Self::Cost, - Self::Cost => Self::Time, - } - } - - fn label(self) -> &'static str { - match self { - Self::Time => "time", - Self::Tokens => "tokens", - Self::Cost => "cost", - } - } -} - -fn sort_sessions(sessions: &mut [SessionRecord], sort: SessionSort) { - match sort { - SessionSort::Time => sessions.sort_by_key(|r| Reverse(r.first_ts)), - SessionSort::Tokens => sessions.sort_by_key(|r| Reverse(r.total_tokens)), - SessionSort::Cost => sessions.sort_by(|a, b| { - b.estimated_cost_usd - .unwrap_or(0.0) - .partial_cmp(&a.estimated_cost_usd.unwrap_or(0.0)) - .unwrap_or(std::cmp::Ordering::Equal) - }), - } -} - -struct Period { - label: &'static str, - granularity: BucketGranularity, - days: Option, // None = all time -} - -const PERIODS: &[Period] = &[ - Period { - label: "last 1 day", - granularity: BucketGranularity::Daily, - days: Some(1), - }, - Period { - label: "last 3 days", - granularity: BucketGranularity::Daily, - days: Some(3), - }, - Period { - label: "last 7 days", - granularity: BucketGranularity::Daily, - days: Some(7), - }, - Period { - label: "last 30 days", - granularity: BucketGranularity::Weekly, - days: Some(30), - }, - Period { - label: "all time", - granularity: BucketGranularity::Monthly, - days: None, - }, -]; - -fn since_ts(period_idx: usize) -> u32 { - match PERIODS[period_idx].days { - None => 0, - Some(days) => { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - now.saturating_sub(days * 24 * 3600) as u32 - } - } -} - -struct AppState { - selected_tab: usize, - period_idx: usize, - stats: LocalActivityStats, - /// The repo URL we're scoped to, or None for global (all repos) view. - current_repo: Option, - /// Per-repo summaries; only populated when `current_repo` is None. - repo_summaries: Vec, - session_list: Vec, - session_sort: SessionSort, - /// Index of the highlighted row in `session_list`. - session_cursor: usize, -} - -impl AppState { - fn new( - stats: LocalActivityStats, - period_idx: usize, - current_repo: Option, - repo_summaries: Vec, - session_list: Vec, - ) -> Self { - Self { - selected_tab: 0, - period_idx, - stats, - current_repo, - repo_summaries, - session_list, - session_sort: SessionSort::Time, - session_cursor: 0, - } - } - - fn load_period(&mut self, idx: usize) -> Result<(), GitAiError> { - let p = &PERIODS[idx]; - let ts = since_ts(idx); - self.stats = compute_activity( - ts, - p.label.to_string(), - p.granularity, - self.current_repo.as_deref(), - )?; - self.repo_summaries = compute_repo_summaries(ts, p.granularity).unwrap_or_default(); - self.session_list = - compute_session_list(ts, self.current_repo.as_deref()).unwrap_or_default(); - sort_sessions(&mut self.session_list, self.session_sort); - self.session_cursor = 0; - self.period_idx = idx; - Ok(()) - } -} - -pub fn run_tui( - initial_stats: LocalActivityStats, - period_idx: usize, - current_repo: Option, - repo_summaries: Vec, - session_list: Vec, -) -> Result<(), GitAiError> { - let mut terminal = ratatui::init(); - let result = run_app( - &mut terminal, - initial_stats, - period_idx, - current_repo, - repo_summaries, - session_list, - ); - ratatui::restore(); - result -} - -fn run_app( - terminal: &mut DefaultTerminal, - initial_stats: LocalActivityStats, - period_idx: usize, - current_repo: Option, - repo_summaries: Vec, - session_list: Vec, -) -> Result<(), GitAiError> { - let mut app = AppState::new( - initial_stats, - period_idx, - current_repo, - repo_summaries, - session_list, - ); - loop { - terminal - .draw(|frame| render(frame, &app)) - .map_err(GitAiError::IoError)?; - - if let Event::Key(key) = event::read().map_err(GitAiError::IoError)? { - if key.kind != KeyEventKind::Press { - continue; - } - match key.code { - KeyCode::Char('q') | KeyCode::Esc => break, - KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - app.selected_tab = (app.selected_tab + 1) % TAB_NAMES.len(); - } - KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - app.selected_tab = (app.selected_tab + TAB_NAMES.len() - 1) % TAB_NAMES.len(); - } - KeyCode::Char(c @ '1'..='5') => { - let idx = (c as usize) - ('1' as usize); - if idx < PERIODS.len() { - app.load_period(idx)?; - } - } - KeyCode::Down | KeyCode::Char('j') - if app.selected_tab == 2 && !app.session_list.is_empty() => - { - app.session_cursor = (app.session_cursor + 1).min(app.session_list.len() - 1); - } - KeyCode::Up | KeyCode::Char('k') if app.selected_tab == 2 => { - app.session_cursor = app.session_cursor.saturating_sub(1); - } - KeyCode::Char('s') if app.selected_tab == 2 => { - app.session_sort = app.session_sort.next(); - sort_sessions(&mut app.session_list, app.session_sort); - app.session_cursor = 0; - } - _ => {} - } - } - } - Ok(()) -} - -// ─── Top-level render ──────────────────────────────────────────────────────── - -fn render(frame: &mut Frame, app: &AppState) { - // Clear any leftover content from behind the TUI. - frame.render_widget(Clear, frame.area()); - - // Outer padding: 1 row top/bottom, 2 cols left/right. - let [_, padded_v, _] = Layout::vertical([ - Constraint::Length(1), - Constraint::Fill(1), - Constraint::Length(1), - ]) - .areas(frame.area()); - let [_, padded, _] = Layout::horizontal([ - Constraint::Length(2), - Constraint::Fill(1), - Constraint::Length(2), - ]) - .areas(padded_v); - - let [header_area, content_area, footer_area] = Layout::vertical([ - Constraint::Length(2), - Constraint::Fill(1), - Constraint::Length(1), - ]) - .areas(padded); - - render_header(frame, header_area, app); - render_footer(frame, footer_area, app); - - match app.selected_tab { - 0 => render_activity(frame, content_area, app), - 1 => render_cost(frame, content_area, app), - 2 => render_sessions(frame, content_area, app), - _ => {} - } -} - -fn render_header(frame: &mut Frame, area: Rect, app: &AppState) { - let [title_area, tabs_area] = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area); - - let period = PERIODS[app.period_idx].label; - let mut title_spans = vec![ - Span::from("git-ai usage").bold(), - Span::from(" ─ ").dim(), - Span::from(period).dim(), - ]; - if let Some(repo) = &app.current_repo { - title_spans.push(Span::from(" ─ ").dim()); - title_spans.push(Span::from(repo.clone()).dim()); - } - let title = Line::from(title_spans); - frame.render_widget(title, title_area); - - let tabs = Tabs::new(TAB_NAMES.to_vec()) - .select(app.selected_tab) - .style(Style::default().dim()) - .highlight_style(Style::default().bold().add_modifier(Modifier::REVERSED)) - .divider(" ") - .padding(" ", " "); - frame.render_widget(tabs, tabs_area); -} - -fn render_footer(frame: &mut Frame, area: Rect, app: &AppState) { - let mut spans = vec![ - Span::from("tab/←/→").bold(), - Span::from(": navigate ").dim(), - Span::from("1-5").bold(), - Span::from(": period (1d 3d 7d 30d all) ").dim(), - ]; - if app.selected_tab == 2 { - spans.push(Span::from("↑↓/j/k").bold()); - spans.push(Span::from(": scroll ").dim()); - spans.push(Span::from("s").bold()); - spans.push(Span::from(format!(": sort (now: {}) ", app.session_sort.label())).dim()); - } - spans.push(Span::from("q").bold()); - spans.push(Span::from(": quit").dim()); - frame.render_widget(Line::from(spans), area); -} - -// ─── Activity tab ───────────────────────────────────────────────────────────── -// -// Layout (top → bottom): -// [4 stat boxes: Lines | AI share | Acceptance | Edits] -// [AI lines bar chart — fills remaining height] -// [Time of day | Day of week heatmaps] - -fn render_activity(frame: &mut Frame, area: Rect, app: &AppState) { - let stats = &app.stats; - let has_hourly = stats.hourly.iter().any(|&v| v > 0); - let has_daily = stats.daily.iter().any(|&v| v > 0); - let heatmap_height = if has_hourly || has_daily { 7u16 } else { 0 }; - - let [stat_area, chart_area, heatmap_area] = Layout::vertical([ - Constraint::Length(4), - Constraint::Fill(1), - Constraint::Length(heatmap_height), - ]) - .areas(area); - - render_stat_boxes(frame, stat_area, stats); - if app.current_repo.is_none() && !app.repo_summaries.is_empty() { - render_repo_table(frame, chart_area, &app.repo_summaries); - } else { - render_activity_chart(frame, chart_area, stats); - } - if heatmap_height > 0 { - render_heatmaps(frame, heatmap_area, stats, has_hourly, has_daily); - } -} - -fn render_stat_boxes(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { - let total = stats.commits.ai_lines + stats.commits.human_lines; - let ai_pct = (stats.commits.ai_lines * 100) - .checked_div(total) - .unwrap_or(0); - let accept_pct = (stats.commits.ai_lines * 100) - .checked_div(stats.checkpoints.ai_lines_added) - .filter(|&p| p <= 100); - - let [lines_area, ai_area, accept_area, edits_area] = Layout::horizontal([ - Constraint::Length(18), - Constraint::Fill(1), - Constraint::Length(18), - Constraint::Length(16), - ]) - .areas(area); - - // Total lines committed - frame.render_widget( - Paragraph::new(vec![ - Line::from(Span::from("Lines committed").dim()), - Line::from(Span::from(fmt_num(total as u64)).bold()), - ]) - .block(Block::bordered().padding(Padding::horizontal(1))), - lines_area, - ); - - // AI share gauge - frame.render_widget( - Gauge::default() - .block( - Block::bordered() - .title(Span::from("AI share").dim()) - .padding(Padding::horizontal(1)), - ) - .ratio(if total > 0 { - ai_pct as f64 / 100.0 - } else { - 0.0 - }) - .label(format!( - "{}% · {} AI / {} human", - ai_pct, - fmt_k(stats.commits.ai_lines as u64), - fmt_k(stats.commits.human_lines as u64), - )) - .gauge_style(Style::default().fg(Color::Cyan)), - ai_area, - ); - - // Acceptance rate - let accept_label = accept_pct - .map(|p| format!("{}%", p)) - .unwrap_or_else(|| "—".to_string()); - frame.render_widget( - Paragraph::new(vec![ - Line::from(Span::from("Acceptance rate").dim()), - Line::from(Span::from(accept_label).bold()), - ]) - .block(Block::bordered().padding(Padding::horizontal(1))), - accept_area, - ); - - // AI edits (checkpoint lines) - frame.render_widget( - Paragraph::new(vec![ - Line::from(Span::from("AI edits").dim()), - Line::from(Span::from(fmt_num(stats.checkpoints.ai_lines_added as u64)).bold()), - ]) - .block(Block::bordered().padding(Padding::horizontal(1))), - edits_area, - ); -} - -fn render_heatmaps( - frame: &mut Frame, - area: Rect, - stats: &LocalActivityStats, - has_hourly: bool, - has_daily: bool, -) { - match (has_hourly, has_daily) { - (true, true) => { - let [left, right] = - Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(area); - render_time_of_day(frame, left, stats); - render_day_of_week(frame, right, stats); - } - (true, false) => render_time_of_day(frame, area, stats), - (false, true) => render_day_of_week(frame, area, stats), - _ => {} - } -} - -fn render_time_of_day(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { - let block = Block::bordered() - .title(Span::from("Time of day").bold()) - .padding(Padding::horizontal(1)); - let inner = block.inner(area); - frame.render_widget(block, area); - - let [_pad, spark_area, label_area] = Layout::vertical([ - Constraint::Fill(1), - Constraint::Length(1), - Constraint::Length(1), - ]) - .areas(inner); - - let max_val = stats.hourly.iter().copied().max().unwrap_or(1).max(1); - let spark: String = stats - .hourly - .iter() - .map(|&v| heatmap_char(v, max_val)) - .collect::>() - .join(" "); - let labels: String = (0..24usize) - .map(|h| match h { - 0 => "am".to_string(), - 12 => "pm".to_string(), - h if h < 12 => format!("{h}"), - h => format!("{}", h - 12), - }) - .map(|l| format!("{:<3}", l)) - .collect::>() - .join(""); - - frame.render_widget( - Paragraph::new(spark).style(Style::default().fg(Color::Cyan)), - spark_area, - ); - frame.render_widget(Paragraph::new(Span::from(labels).dim()), label_area); -} - -fn render_day_of_week(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { - let block = Block::bordered() - .title(Span::from("Day of week").bold()) - .padding(Padding::horizontal(1)); - let inner = block.inner(area); - frame.render_widget(block, area); - - let [_pad, spark_area, label_area] = Layout::vertical([ - Constraint::Fill(1), - Constraint::Length(1), - Constraint::Length(1), - ]) - .areas(inner); - - let max_val = stats.daily.iter().copied().max().unwrap_or(1).max(1); - let spark: String = stats - .daily - .iter() - .map(|&v| heatmap_char(v, max_val)) - .collect::>() - .join(" "); - let labels = "Mon Tue Wed Thu Fri Sat Sun"; - - frame.render_widget( - Paragraph::new(spark).style(Style::default().fg(Color::Cyan)), - spark_area, - ); - frame.render_widget(Paragraph::new(Span::from(labels).dim()), label_area); -} - -fn heatmap_char(value: u32, max: u32) -> &'static str { - if value == 0 { - return "·"; - } - let pct = value * 8 / max; - match pct { - 0 => "▁", - 1 => "▂", - 2 => "▃", - 3 => "▄", - 4 => "▅", - 5 => "▆", - 6 => "▇", - _ => "█", - } -} - -// ─── Cost tab ──────────────────────────────────────────────────────────────── -// -// Layout (top → bottom): -// top half: [Spend summary] [Cache gauge] [Model table] -// bottom half: [Per-repo breakdown table] - -fn render_cost(frame: &mut Frame, area: Rect, app: &AppState) { - let stats = &app.stats; - let t = &stats.tokens; - let has_token_data = t.input + t.output + t.cache_read + t.cache_creation > 0; - - let spend_height = if has_token_data { 4u16 } else { 0 }; - let gauge_height = if has_token_data { 3u16 } else { 0 }; - - let [spend_area, gauge_area, table_area] = Layout::vertical([ - Constraint::Length(spend_height), - Constraint::Length(gauge_height), - Constraint::Fill(1), - ]) - .areas(area); - - if has_token_data { - render_spend_summary(frame, spend_area, stats); - render_cache_gauge(frame, gauge_area, stats); - } - render_model_table(frame, table_area, stats); -} - -fn render_spend_summary(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { - let t = &stats.tokens; - let block = Block::bordered() - .title(Span::from("Spend").bold()) - .padding(Padding::horizontal(1)); - let inner = block.inner(area); - frame.render_widget(block, area); - - let cost_line = if t.estimated_cost_usd > 0.0 { - format!("Est. cost: ~${:.2}", t.estimated_cost_usd) - } else { - "No cost data".to_string() - }; - - let wow_line = t.wow_spend.as_ref().map(|w| { - let delta = match (w.new_this_week, w.change_pct) { - (true, _) => "↑ new this week".to_string(), - (_, Some(p)) if p > 0.0 => format!("↑ {:.0}% vs last week", p), - (_, Some(p)) if p < 0.0 => format!("↓ {:.0}% vs last week", p.abs()), - _ => "→ no change vs last week".to_string(), - }; - let last_week = if w.last_week_usd > 0.01 { - format!(" · Last week: ~${:.2}", w.last_week_usd) - } else { - String::new() - }; - format!( - "This week: ~${:.2}{} {}", - w.this_week_usd, last_week, delta - ) - }); - - let mut lines = vec![Line::from(Span::from(cost_line).bold())]; - if let Some(w) = wow_line { - lines.push(Line::from(Span::from(w).dim())); - } - frame.render_widget(Paragraph::new(lines), inner); -} - -fn render_cache_gauge(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { - let t = &stats.tokens; - let with_ratio: Vec = t - .by_model - .iter() - .filter_map(|m| m.cache_hit_ratio) - .collect(); - let cache_hit_ratio = if with_ratio.is_empty() { - None - } else { - Some(with_ratio.iter().sum::() / with_ratio.len() as f64) - }; - - let gauge = Gauge::default() - .block( - Block::bordered() - .title(Span::from("Cache hit rate").bold()) - .padding(Padding::horizontal(1)), - ) - .ratio(cache_hit_ratio.unwrap_or(0.0)) - .label( - cache_hit_ratio - .map(|r| format!("{:.0}%", r * 100.0)) - .unwrap_or_else(|| "—".to_string()), - ) - .gauge_style(Style::default().fg(Color::Green)); - frame.render_widget(gauge, area); -} - -fn render_model_table(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { - let block = Block::bordered() - .title(Span::from("Models").bold()) - .padding(Padding::horizontal(1)); - let inner = block.inner(area); - frame.render_widget(block, area); - - if stats.tokens.by_model.is_empty() { - frame.render_widget( - Paragraph::new(Span::from("No model data for this period.").dim()), - inner, - ); - return; - } - - let total_tokens: u64 = stats - .tokens - .by_model - .iter() - .map(|m| m.input + m.output + m.cache_read + m.cache_creation) - .sum(); - - let header = Row::new(vec!["Model", "Sessions", "Tokens", "Cost", "Cache hit"]) - .style(Style::default().bold()) - .bottom_margin(1); - - let rows: Vec = stats - .tokens - .by_model - .iter() - .map(|m| { - let tokens = m.input + m.output + m.cache_read + m.cache_creation; - let pct = (tokens * 100).checked_div(total_tokens).unwrap_or(0); - let sessions = stats - .sessions - .by_tool - .iter() - .find(|(tool, _)| tool.contains(&m.model)) - .map(|(_, n)| *n) - .unwrap_or(0); - Row::new(vec![ - Cell::from(m.model.clone()), - Cell::from(if sessions > 0 { - fmt_num(sessions as u64) - } else { - "—".to_string() - }), - Cell::from(format!("{} ({}%)", fmt_num_tokens(tokens), pct)), - Cell::from( - m.estimated_cost_usd - .map(|c| format!("~${:.2}", c)) - .unwrap_or_else(|| "—".to_string()), - ), - Cell::from( - m.cache_hit_ratio - .map(|r| format!("{:.0}%", r * 100.0)) - .unwrap_or_else(|| "—".to_string()), - ), - ]) - }) - .collect(); - - let table = Table::new( - rows, - [ - Constraint::Fill(1), - Constraint::Length(10), - Constraint::Length(18), - Constraint::Length(10), - Constraint::Length(10), - ], - ) - .header(header); - frame.render_widget(table, inner); -} - -// ─── Per-repository breakdown table ────────────────────────────────────────── - -fn render_repo_table(frame: &mut Frame, area: Rect, repos: &[RepoActivitySummary]) { - let block = Block::bordered() - .title(Span::from("Activity by repository").bold()) - .padding(Padding::horizontal(1)); - let inner = block.inner(area); - frame.render_widget(block, area); - - let header = Row::new(vec![ - "Repository", - "AI Lines", - "Commits", - "Sessions", - "Est. Cost", - ]) - .style(Style::default().bold()) - .bottom_margin(1); - - let total_ai: u32 = repos.iter().map(|r| r.ai_lines).sum(); - - let rows: Vec = repos - .iter() - .map(|r| { - let pct = (r.ai_lines as u64 * 100) - .checked_div(total_ai as u64) - .unwrap_or(0); - let repo_display = shorten_repo_url(&r.repo_url); - let cost = if r.estimated_cost_usd > 0.0 { - format!("~${:.2}", r.estimated_cost_usd) - } else { - "—".to_string() - }; - Row::new(vec![ - Cell::from(repo_display.to_string()), - Cell::from(format!("{} ({}%)", fmt_num(r.ai_lines as u64), pct)), - Cell::from(fmt_num(r.commits as u64)), - Cell::from(fmt_num(r.sessions as u64)), - Cell::from(cost), - ]) - }) - .collect(); - - let table = Table::new( - rows, - [ - Constraint::Fill(1), - Constraint::Length(18), - Constraint::Length(10), - Constraint::Length(10), - Constraint::Length(12), - ], - ) - .header(header); - frame.render_widget(table, inner); -} - -// ─── Sessions tab ───────────────────────────────────────────────────────────── - -fn shorten_model(model: &str) -> String { - match model.rsplit_once('-') { - Some((head, tail)) if tail.len() == 8 && tail.chars().all(|c| c.is_ascii_digit()) => { - head.to_string() - } - _ => model.to_string(), - } -} - -fn render_sessions(frame: &mut Frame, area: Rect, app: &AppState) { - let sort_indicator = |col: SessionSort| { - if col == app.session_sort { " ▼" } else { "" } - }; - - let block = Block::bordered() - .title(Span::from("Sessions").bold()) - .padding(Padding::horizontal(1)); - let inner = block.inner(area); - frame.render_widget(block, area); - - if app.session_list.is_empty() { - frame.render_widget( - Paragraph::new(Span::from("No session data for this period.").dim()), - inner, - ); - return; - } - - // Summary header: aggregate stats across visible sessions. - let [summary_area, table_area] = - Layout::vertical([Constraint::Length(2), Constraint::Fill(1)]).areas(inner); - - let n = app.session_list.len(); - let shipped = app.session_list.iter().filter(|r| r.shipped).count(); - let shipped_pct = (shipped * 100).checked_div(n).unwrap_or(0); - let avg_cost = { - let with_cost: Vec = app - .session_list - .iter() - .filter_map(|r| r.estimated_cost_usd) - .filter(|&c| c > 0.0) - .collect(); - if with_cost.is_empty() { - None - } else { - Some(with_cost.iter().sum::() / with_cost.len() as f64) - } - }; - let avg_tokens = { - let with_tokens: Vec = app - .session_list - .iter() - .map(|r| r.total_tokens) - .filter(|&t| t > 0) - .collect(); - if with_tokens.is_empty() { - None - } else { - Some(with_tokens.iter().sum::() / with_tokens.len() as u64) - } - }; - - let mut summary_spans = vec![ - Span::from(fmt_num(n as u64)).bold(), - Span::from(" sessions · ").dim(), - Span::from(format!("{}%", shipped_pct)).bold(), - Span::from(" shipped").dim(), - ]; - if let Some(cost) = avg_cost { - summary_spans.push(Span::from(" · avg ").dim()); - summary_spans.push(Span::from(format!("~${:.2}", cost)).bold()); - summary_spans.push(Span::from("/session").dim()); - } - if let Some(tokens) = avg_tokens { - summary_spans.push(Span::from(" · avg ").dim()); - summary_spans.push(Span::from(fmt_num_tokens(tokens)).bold()); - summary_spans.push(Span::from(" tokens").dim()); - } - frame.render_widget(Paragraph::new(Line::from(summary_spans)), summary_area); - - let inner = table_area; - - let header = Row::new(vec![ - format!("Time{}", sort_indicator(SessionSort::Time)), - "Agent".to_string(), - "Title".to_string(), - format!("Tokens{}", sort_indicator(SessionSort::Tokens)), - format!("Cost{}", sort_indicator(SessionSort::Cost)), - "~Lines".to_string(), - "Status".to_string(), - ]) - .style(Style::default().bold()) - .bottom_margin(1); - - // Header + margin uses 2 rows; compute how many data rows fit. - let visible_count = (inner.height as usize).saturating_sub(2); - // Keep the cursor visible: scroll the window so the cursor row is always shown. - let offset = if app.session_cursor < visible_count { - 0 - } else { - app.session_cursor - visible_count + 1 - } - .min(app.session_list.len().saturating_sub(visible_count)); - - let now_ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as u32; - - let rows: Vec = app.session_list[offset..] - .iter() - .take(visible_count) - .enumerate() - .map(|(i, r)| { - let abs_idx = offset + i; - let time_str = fmt_session_time(r.first_ts, now_ts); - let agent_str = match r.model.as_deref().map(shorten_model).as_deref() { - Some(m) => format!("{} · {}", r.tool, m), - None => r.tool.clone(), - }; - let title_str = r.title.as_deref().unwrap_or("—").to_string(); - let tokens_str = if r.total_tokens > 0 { - fmt_num_tokens(r.total_tokens) - } else { - "—".to_string() - }; - let cost_str = r - .estimated_cost_usd - .filter(|&c| c > 0.0) - .map(|c| format!("~${:.2}", c)) - .unwrap_or_else(|| "—".to_string()); - let lines_str = if r.ai_lines_committed > 0 { - format!("~{}", fmt_num(r.ai_lines_committed as u64)) - } else { - "—".to_string() - }; - let status_str = if r.shipped { "shipped" } else { "—" }; - - let row = Row::new(vec![ - Cell::from(time_str), - Cell::from(agent_str), - Cell::from(title_str), - Cell::from(tokens_str), - Cell::from(cost_str), - Cell::from(lines_str), - Cell::from(status_str), - ]); - - if abs_idx == app.session_cursor { - row.style(Style::default().add_modifier(Modifier::REVERSED)) - } else { - row - } - }) - .collect(); - - let table = Table::new( - rows, - [ - Constraint::Length(16), - Constraint::Length(26), - Constraint::Fill(1), - Constraint::Length(10), - Constraint::Length(10), - Constraint::Length(9), - Constraint::Length(8), - ], - ) - .header(header); - frame.render_widget(table, inner); -} - -fn fmt_session_time(ts: u32, now_ts: u32) -> String { - let secs_ago = now_ts.saturating_sub(ts) as u64; - if secs_ago < 60 { - return "just now".to_string(); - } - if secs_ago < 3600 { - return format!("{}m ago", secs_ago / 60); - } - if secs_ago < 24 * 3600 { - return format!("{}h ago", secs_ago / 3600); - } - if secs_ago < 2 * 24 * 3600 { - return "yesterday".to_string(); - } - // Older: show date in local time. - let dt: DateTime = Local - .timestamp_opt(ts as i64, 0) - .single() - .unwrap_or_else(Local::now); - dt.format("%b %d %H:%M").to_string() -} - -// ─── Activity bar chart ─────────────────────────────────────────────────────── - -fn render_activity_chart(frame: &mut Frame, area: Rect, stats: &LocalActivityStats) { - let block = Block::bordered() - .title(Span::from("AI lines over time").bold()) - .padding(Padding::horizontal(1)); - let inner = block.inner(area); - frame.render_widget(block, area); - - if stats.buckets.is_empty() { - frame.render_widget( - Paragraph::new(Span::from("No activity data for this period.").dim()), - inner, - ); - return; - } - - const BAR_W: u16 = 6; - const GAP_W: u16 = 1; - - let [chart_area, label_area] = - Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(inner); - - let bars: Vec = stats - .buckets - .iter() - .map(|b| { - Bar::default() - .value(b.ai_lines as u64) - .style(Style::default().fg(Color::Cyan)) - }) - .collect(); - - frame.render_widget( - BarChart::vertical(bars).bar_width(BAR_W).bar_gap(GAP_W), - chart_area, - ); - - // Build a label row that aligns with bar positions. - // Each slot is BAR_W chars wide, separated by a single '·' (= GAP_W=1 char). - let n = stats.buckets.len(); - let mut spans: Vec = Vec::with_capacity(n * 2); - for (i, b) in stats.buckets.iter().enumerate() { - let label = tui_bucket_label(&b.label); - let padded = format!("{: String { - let s = n.to_string(); - let mut result = String::new(); - for (i, c) in s.chars().rev().enumerate() { - if i > 0 && i % 3 == 0 { - result.push(','); - } - result.push(c); - } - result.chars().rev().collect() -} - -fn fmt_k(n: u64) -> String { - if n >= 1000 { - format!("{:.1}k", n as f64 / 1000.0) - } else { - n.to_string() - } -} - -fn fmt_num_tokens(n: u64) -> String { - if n >= 1_000_000 { - format!("{:.1}M", n as f64 / 1_000_000.0) - } else if n >= 1_000 { - format!("{:.0}k", n as f64 / 1_000.0) - } else { - n.to_string() - } -} - -/// Produces a ≤6-char label for the bar chart from the bucket label string. -/// -/// Input formats from `bucket_key`: -/// Daily: "May 22" -/// Weekly: "May 18 – May 24" -/// Monthly: "May 2026" -fn tui_bucket_label(label: &str) -> String { - // Weekly: take only the start date. - if let Some(pos) = label.find(" \u{2013} ") { - let start = &label[..pos]; - return if start.len() <= 6 { - start.to_string() - } else { - start[..6].to_string() - }; - } - // Monthly: "May 2026" → "May '26" - if label.len() >= 8 { - let parts: Vec<&str> = label.splitn(2, ' ').collect(); - if parts.len() == 2 && parts[1].len() == 4 { - let yr = &parts[1][2..]; // last two digits - return format!("{} '{}", parts[0], yr); - } - } - // Daily (and fallback): fits as-is. - label.to_string() -} - -/// Strip leading `https://` / `http://` from a repo URL for compact display. -fn shorten_repo_url(url: &str) -> &str { - url.trim_start_matches("https://") - .trim_start_matches("http://") -} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f4c41f1ecc..a8b70767e5 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,5 +1,4 @@ pub mod activity; -pub mod activity_tui; pub mod blame; pub mod checkpoint_agent; pub mod ci_handlers; diff --git a/src/metrics/db.rs b/src/metrics/db.rs index e315cb24e8..a0b0d6dc1b 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -323,6 +323,7 @@ impl MetricsDatabase { /// Query local_events since `since_ts` (Unix seconds), returning all interesting event types. /// /// When `repo_filter` is `Some(url)`, only events matching that repo_url are returned. + /// An empty string `""` is a sentinel meaning "events with no repo_url (NULL)". /// When `None`, all events are returned regardless of repo. pub fn get_local_events( &self, @@ -330,20 +331,37 @@ impl MetricsDatabase { repo_filter: Option<&str>, ) -> Result, GitAiError> { let records = if let Some(repo_url) = repo_filter { - let mut stmt = self.conn.prepare( - "SELECT event_id, ts, repo_url, event_json FROM local_events \ + if repo_url.is_empty() { + let mut stmt = self.conn.prepare( + "SELECT event_id, ts, repo_url, event_json FROM local_events \ + WHERE ts >= ?1 AND repo_url IS NULL \ + ORDER BY ts ASC", + )?; + let rows = stmt.query_map(params![since_ts as i64], |row| { + Ok(LocalEventRecord { + event_id: row.get::<_, i64>(0)? as u16, + ts: row.get::<_, i64>(1)? as u32, + repo_url: row.get(2)?, + event_json: row.get(3)?, + }) + })?; + rows.collect::, _>>()? + } else { + let mut stmt = self.conn.prepare( + "SELECT event_id, ts, repo_url, event_json FROM local_events \ WHERE ts >= ?1 AND repo_url = ?2 \ ORDER BY ts ASC", - )?; - let rows = stmt.query_map(params![since_ts as i64, repo_url], |row| { - Ok(LocalEventRecord { - event_id: row.get::<_, i64>(0)? as u16, - ts: row.get::<_, i64>(1)? as u32, - repo_url: row.get(2)?, - event_json: row.get(3)?, - }) - })?; - rows.collect::, _>>()? + )?; + let rows = stmt.query_map(params![since_ts as i64, repo_url], |row| { + Ok(LocalEventRecord { + event_id: row.get::<_, i64>(0)? as u16, + ts: row.get::<_, i64>(1)? as u32, + repo_url: row.get(2)?, + event_json: row.get(3)?, + }) + })?; + rows.collect::, _>>()? + } } else { let mut stmt = self.conn.prepare( "SELECT event_id, ts, repo_url, event_json FROM local_events \ @@ -364,7 +382,7 @@ impl MetricsDatabase { } /// Return the distinct repo_urls that have events since `since_ts`, sorted alphabetically. - /// NULL repo_url entries are excluded. + /// An empty string `""` sentinel is appended last when any NULL repo_url entries exist. pub fn get_distinct_repo_urls(&self, since_ts: u32) -> Result, GitAiError> { let mut stmt = self.conn.prepare( "SELECT DISTINCT repo_url FROM local_events \ @@ -372,7 +390,17 @@ impl MetricsDatabase { ORDER BY repo_url ASC", )?; let rows = stmt.query_map(params![since_ts as i64], |row| row.get::<_, String>(0))?; - Ok(rows.collect::, _>>()?) + let mut urls: Vec = rows.collect::, _>>()?; + + let has_null: bool = self.conn.query_row( + "SELECT EXISTS(SELECT 1 FROM local_events WHERE ts >= ?1 AND repo_url IS NULL AND event_id = 1)", + params![since_ts as i64], + |row| row.get(0), + )?; + if has_null { + urls.push(String::new()); + } + Ok(urls) } // ─── Notes backfill helpers ─────────────────────────────────────────────── diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index d8183ca692..0172403f61 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -58,6 +58,7 @@ pub struct WowSpend { #[derive(Debug, Default, Serialize)] pub struct TokenModelStat { pub model: String, + pub sessions: u32, pub input: u64, pub output: u64, pub cache_read: u64, @@ -167,9 +168,9 @@ pub fn compute_activity( let mut session_tool_counts: HashMap = HashMap::new(); // Claude-shaped token usage keyed by assistant message id. Value is - // (model, accum, record_ts). `record_ts` is the Unix timestamp of the + // (model, accum, record_ts, session_id). `record_ts` is the Unix timestamp of the // first event that introduced this message id — used for WoW bucketing. - let mut message_usage: HashMap = HashMap::new(); + let mut message_usage: HashMap = HashMap::new(); // Codex-shaped token usage keyed by session id. Codex reports cumulative // session totals (total_token_usage) on each token_count event, so we keep @@ -252,7 +253,10 @@ pub fn compute_activity( if tool == "codex" { aggregate_codex_tokens(&event, record.ts, &mut codex_sessions); } else { - aggregate_session_tokens(&event, record.ts, &mut message_usage); + let sid = sparse_get_string(&event.attrs, attr_pos::SESSION_ID) + .flatten() + .unwrap_or_default(); + aggregate_session_tokens(&event, record.ts, sid, &mut message_usage); } } _ => {} @@ -459,7 +463,7 @@ fn cost_for_message_slice(entries: impl Iterator) - } fn build_token_summary( - message_usage: HashMap, + message_usage: HashMap, codex_sessions: HashMap, now_ts: u32, since_ts: u32, @@ -477,13 +481,21 @@ fn build_token_summary( // Fold per-message (deduped, max) usage into per-model totals. let mut model_tokens: HashMap = HashMap::new(); - for (_id, (model, acc, ts)) in message_usage { + let mut model_session_ids: HashMap> = HashMap::new(); + for (_id, (model, acc, ts, sid)) in message_usage { let entry = model_tokens.entry(model.clone()).or_default(); entry.input += acc.input; entry.output += acc.output; entry.cache_read += acc.cache_read; entry.cache_creation += acc.cache_creation; + if !sid.is_empty() { + model_session_ids + .entry(model.clone()) + .or_default() + .insert(sid); + } + if ts >= this_week_start { this_week_msgs.push((model, acc)); } else if ts >= last_week_start { @@ -498,7 +510,7 @@ fn build_token_summary( let mut this_week_codex: Vec<(String, TokenAccum)> = Vec::new(); let mut last_week_codex: Vec<(String, TokenAccum)> = Vec::new(); - for (_sid, acc) in codex_sessions { + for (sid, acc) in codex_sessions { let model = acc.model.clone().unwrap_or_else(|| "codex".to_string()); let mapped = TokenAccum { input: acc.input_tokens.saturating_sub(acc.cached_input_tokens), @@ -510,6 +522,10 @@ fn build_token_summary( entry.input += mapped.input; entry.output += mapped.output; entry.cache_read += mapped.cache_read; + model_session_ids + .entry(model.clone()) + .or_default() + .insert(sid); if acc.last_usage_ts >= this_week_start { this_week_codex.push((model, mapped)); @@ -562,8 +578,13 @@ fn build_token_summary( None }; + let sessions = model_session_ids + .get(&model) + .map(|s| s.len() as u32) + .unwrap_or(0); by_model.push(TokenModelStat { model: shorten_model(&model), + sessions, input: acc.input, output: acc.output, cache_read: acc.cache_read, @@ -857,7 +878,8 @@ fn aggregate_session( fn aggregate_session_tokens( event: &MetricEvent, record_ts: u32, - message_usage: &mut HashMap, + session_id: String, + message_usage: &mut HashMap, ) { let Some(raw) = event.values.get(&session_event_pos::RAW_JSON.to_string()) else { return; @@ -883,9 +905,9 @@ fn aggregate_session_tokens( let get = |key: &str| usage.get(key).and_then(|v| v.as_u64()).unwrap_or(0); - let (_, acc, _ts) = message_usage + let (_, acc, _ts, _sid) = message_usage .entry(id.to_string()) - .or_insert_with(|| (model, TokenAccum::default(), record_ts)); + .or_insert_with(|| (model, TokenAccum::default(), record_ts, session_id)); // Field-wise max: input/cache are fixed per message; output grows while // streaming, so the final (largest) value is authoritative. acc.input = acc.input.max(get("input_tokens")); From c8b2ef6734dcd65ec3a7d5c3ef44737647242cda Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 17:03:39 -0400 Subject: [PATCH 047/100] feat: add --repo flag to git-ai usage with substring matching Filter activity stats to a specific repository using a URL or substring. The https:// prefix is stripped automatically, so `--repo github.com/org/repo` and `--repo https://github.com/org/repo` both work. Matching uses SQL LIKE for substring search, so partial names like `--repo my-org` match all repos under that org. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 96 +++++++++++++++++++++++++++++++++------- src/metrics/db.rs | 2 +- 2 files changed, 81 insertions(+), 17 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 4d82a1497e..f299a20e0e 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -1,13 +1,16 @@ //! `git-ai usage` — local statistics from persisted metric events. -use crate::metrics::local_stats::{BucketGranularity, LocalActivityStats, compute_activity}; -use crate::repo_url::resolve_repo_url_from_path; +use crate::metrics::local_stats::{ + BucketGranularity, LocalActivityStats, RepoActivitySummary, compute_activity, + compute_repo_summaries, +}; use std::collections::HashSet; use std::time::{SystemTime, UNIX_EPOCH}; pub fn handle_activity(args: &[String]) { let mut json = false; let mut period = "30d".to_string(); + let mut repo_filter: Option = None; let mut i = 0; while i < args.len() { @@ -17,6 +20,16 @@ pub fn handle_activity(args: &[String]) { period = args[i + 1].clone(); i += 1; } + "--repo" if i + 1 < args.len() => { + // Normalize: strip protocol prefix so both "https://github.com/org/repo" + // and "github.com/org/repo" resolve to the same substring match. + let raw = args[i + 1].as_str(); + let normalized = raw + .trim_start_matches("https://") + .trim_start_matches("http://"); + repo_filter = Some(normalized.to_string()); + i += 1; + } "--help" | "-h" => { print_help(); return; @@ -66,14 +79,7 @@ pub fn handle_activity(args: &[String]) { } }; - // Auto-detect which repo we're in (if any). When Some, stats are scoped - // to that repo; the header shows the repo name. When None (outside any - // repo), stats are global and the Summary tab shows a per-repo table. - let current_dir = std::env::current_dir().ok(); - let current_repo = current_dir.as_deref().and_then(resolve_repo_url_from_path); - - let stats = match compute_activity(since_ts, period_label, granularity, current_repo.as_deref()) - { + let stats = match compute_activity(since_ts, period_label, granularity, repo_filter.as_deref()) { Ok(s) => s, Err(e) => { eprintln!("error: {}", e); @@ -81,6 +87,14 @@ pub fn handle_activity(args: &[String]) { } }; + // Show per-repo breakdown only in global view; when filtered to a single + // repo it would be a single-row table that adds nothing. + let repos = if repo_filter.is_none() { + compute_repo_summaries(since_ts, granularity).unwrap_or_default() + } else { + vec![] + }; + if json { match serde_json::to_string_pretty(&stats) { Ok(s) => println!("{}", s), @@ -90,7 +104,7 @@ pub fn handle_activity(args: &[String]) { } } } else { - print_terminal(&stats); + print_terminal(&stats, &repos, repo_filter.as_deref()); } } @@ -109,6 +123,7 @@ fn print_help() { eprintln!(); eprintln!("Options:"); eprintln!(" --period <1d|3d|7d|30d|60d|all> Time window (default: 30d)"); + eprintln!(" --repo Filter to a repository (substring match, https:// optional)"); eprintln!(" --json Output as JSON"); eprintln!(" --help Show this help"); eprintln!(); @@ -116,16 +131,26 @@ fn print_help() { eprintln!("Events accumulate over time and are never deleted from local storage."); } -fn print_terminal(stats: &LocalActivityStats) { +fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], repo_filter: Option<&str>) { const GRAY: &str = "\x1b[90m"; const BOLD: &str = "\x1b[1m"; const RESET: &str = "\x1b[0m"; const BAR_WIDTH: u32 = 20; - println!( - "{BOLD}git-ai usage{RESET} {GRAY}— {}{RESET}", - stats.period_label - ); + if let Some(repo) = repo_filter { + let display = repo + .trim_start_matches("https://") + .trim_start_matches("http://"); + println!( + "{BOLD}git-ai usage{RESET} {GRAY}— {} · {}{RESET}", + display, stats.period_label + ); + } else { + println!( + "{BOLD}git-ai usage{RESET} {GRAY}— {}{RESET}", + stats.period_label + ); + } // --- Top bar: AI vs Human split --- println!(); @@ -140,6 +165,45 @@ fn print_terminal(stats: &LocalActivityStats) { ); } + // --- Per-repo breakdown --- + if !repos.is_empty() { + println!(); + println!(" {BOLD}Repositories{RESET}"); + let max_lines = repos.iter().map(|r| r.ai_lines).max().unwrap_or(1).max(1); + for r in repos { + let repo_display = r + .repo_url + .trim_start_matches("https://") + .trim_start_matches("http://"); + let repo_display = if repo_display.is_empty() { + "unknown" + } else { + repo_display + }; + let filled = (r.ai_lines * 16 / max_lines).min(16); + let empty = 16 - filled; + let bar_str = format!( + "{}{}", + "█".repeat(filled as usize), + "░".repeat(empty as usize) + ); + let cost_str = if r.estimated_cost_usd > 0.0 { + format!(" {GRAY}~${:.2}{RESET}", r.estimated_cost_usd) + } else { + String::new() + }; + println!( + " {} {GRAY}{}{RESET} {GRAY}{} lines · {} commits · {} sessions{}{RESET}", + bar_str, + repo_display, + format_num(r.ai_lines), + r.commits, + r.sessions, + cost_str, + ); + } + } + // --- AI section --- println!(); println!(" {BOLD}AI{RESET}"); diff --git a/src/metrics/db.rs b/src/metrics/db.rs index a0b0d6dc1b..9aaaf703de 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -349,7 +349,7 @@ impl MetricsDatabase { } else { let mut stmt = self.conn.prepare( "SELECT event_id, ts, repo_url, event_json FROM local_events \ - WHERE ts >= ?1 AND repo_url = ?2 \ + WHERE ts >= ?1 AND repo_url LIKE '%' || ?2 || '%' \ ORDER BY ts ASC", )?; let rows = stmt.query_map(params![since_ts as i64, repo_url], |row| { From f8f270af31420df1e8ed97c8f50bf8b033af96dc Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 17:05:30 -0400 Subject: [PATCH 048/100] fix: show error and exit when --repo filter matches no data Instead of printing an empty/zeroed stats screen, emit a helpful message (with a suggestion to widen the period) and exit with code 1. Also handles the global no-data case consistently. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index f299a20e0e..ebb57df8e3 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -95,6 +95,23 @@ pub fn handle_activity(args: &[String]) { vec![] }; + // When filtering by repo, bail out early if nothing matched. + let no_data = stats.commits.total == 0 + && stats.sessions.total == 0 + && stats.tokens.input + stats.tokens.output + stats.tokens.cache_read + stats.tokens.cache_creation == 0; + if no_data { + if let Some(ref filter) = repo_filter { + eprintln!( + "No data found for '{}' in the {} window.", + filter, stats.period_label + ); + eprintln!("Try a broader period (--period all) or a different substring."); + } else { + eprintln!("No activity data found for the {} window.", stats.period_label); + } + std::process::exit(1); + } + if json { match serde_json::to_string_pretty(&stats) { Ok(s) => println!("{}", s), From 60ff073244ca51c461db2e2b3136fba30cdd33bf Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 17:08:01 -0400 Subject: [PATCH 049/100] fix: clarify output when --repo filter matches multiple repositories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a substring filter matches more than one repo: - Header now reads "N repos matching 'filter' · period" - A per-repo breakdown is shown (same as global view) When it matches exactly one repo, the header shows the full matched URL (not just the search term). Single-repo breakdown table is still suppressed — it adds no information in that case. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 38 ++++++++++++++++++++++++++------------ src/metrics/local_stats.rs | 3 +++ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index ebb57df8e3..47e0de60ed 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -87,13 +87,12 @@ pub fn handle_activity(args: &[String]) { } }; - // Show per-repo breakdown only in global view; when filtered to a single - // repo it would be a single-row table that adds nothing. - let repos = if repo_filter.is_none() { - compute_repo_summaries(since_ts, granularity).unwrap_or_default() - } else { - vec![] - }; + // Compute per-repo breakdown. When a filter is active this still runs so + // we can surface how many repos matched (and who they are). The breakdown + // is only rendered when more than one repo is present — a single-row table + // adds nothing. + let repos = compute_repo_summaries(since_ts, granularity, repo_filter.as_deref()) + .unwrap_or_default(); // When filtering by repo, bail out early if nothing matched. let no_data = stats.commits.total == 0 @@ -158,10 +157,24 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep let display = repo .trim_start_matches("https://") .trim_start_matches("http://"); - println!( - "{BOLD}git-ai usage{RESET} {GRAY}— {} · {}{RESET}", - display, stats.period_label - ); + if repos.len() > 1 { + println!( + "{BOLD}git-ai usage{RESET} {GRAY}— {} repos matching '{}' · {}{RESET}", + repos.len(), + display, + stats.period_label + ); + } else { + // Single match: show the full matched URL, not just the search term. + let matched = repos + .first() + .map(|r| r.repo_url.trim_start_matches("https://").trim_start_matches("http://")) + .unwrap_or(display); + println!( + "{BOLD}git-ai usage{RESET} {GRAY}— {} · {}{RESET}", + matched, stats.period_label + ); + } } else { println!( "{BOLD}git-ai usage{RESET} {GRAY}— {}{RESET}", @@ -183,7 +196,8 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep } // --- Per-repo breakdown --- - if !repos.is_empty() { + // Only shown when there are multiple repos — a single-row table adds nothing. + if repos.len() > 1 { println!(); println!(" {BOLD}Repositories{RESET}"); let max_lines = repos.iter().map(|r| r.ai_lines).max().unwrap_or(1).max(1); diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 0172403f61..5939d12152 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -1279,6 +1279,7 @@ pub struct RepoActivitySummary { pub fn compute_repo_summaries( since_ts: u32, granularity: BucketGranularity, + repo_filter: Option<&str>, ) -> Result, GitAiError> { let repo_urls = { let db = MetricsDatabase::global()?; @@ -1290,6 +1291,8 @@ pub fn compute_repo_summaries( let mut summaries: Vec = repo_urls .iter() + // When a filter is active, only include URLs that contain the substring. + .filter(|url| repo_filter.map_or(true, |f| url.contains(f))) .filter_map(|url| { let stats = compute_activity( since_ts, From 6eb49b09d510c06d53ea514937659c2913afab1b Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 17:09:23 -0400 Subject: [PATCH 050/100] feat: show --repo hint in footer when no filter is active Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 47e0de60ed..db21f8aefb 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -449,9 +449,16 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep } println!(); - println!( - " {GRAY}Local data only · See full history and team insights at https://usegitai.com/dashboard{RESET}" - ); + if repo_filter.is_none() { + println!( + " {GRAY}Local data only · See full history and team insights at https://usegitai.com/dashboard{RESET}" + ); + println!(" {GRAY}Tip: use --repo to filter by repository{RESET}"); + } else { + println!( + " {GRAY}Local data only · See full history and team insights at https://usegitai.com/dashboard{RESET}" + ); + } println!(); } From f683c233c58960d2b482145899ff90a7a3c3c8eb Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 17:12:46 -0400 Subject: [PATCH 051/100] Move where the tip about --repo is --- src/commands/activity.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index db21f8aefb..87aecf46c5 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -450,10 +450,11 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep println!(); if repo_filter.is_none() { + println!(" {GRAY}Tip: use --repo to filter by repository{RESET}"); + println!(""); println!( " {GRAY}Local data only · See full history and team insights at https://usegitai.com/dashboard{RESET}" ); - println!(" {GRAY}Tip: use --repo to filter by repository{RESET}"); } else { println!( " {GRAY}Local data only · See full history and team insights at https://usegitai.com/dashboard{RESET}" From a2d0de9ca358aca2b9fa45024f67e190889c3271 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 17:29:22 -0400 Subject: [PATCH 052/100] fix: escape LIKE special chars in repo_filter substring match % and _ in user-supplied --repo values were treated as SQL wildcards, causing unexpected over-matching. Now escaped with backslash before binding, using LIKE ... ESCAPE '\'. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/db.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/metrics/db.rs b/src/metrics/db.rs index 9aaaf703de..98991109f3 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -347,12 +347,17 @@ impl MetricsDatabase { })?; rows.collect::, _>>()? } else { + // Escape LIKE special characters so a user-supplied substring + // like "my_org/my%repo" matches literally, not as wildcards. + // We use '\' as the escape character. + let escaped = repo_url.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"); + let pattern = format!("%{}%", escaped); let mut stmt = self.conn.prepare( "SELECT event_id, ts, repo_url, event_json FROM local_events \ - WHERE ts >= ?1 AND repo_url LIKE '%' || ?2 || '%' \ + WHERE ts >= ?1 AND repo_url LIKE ?2 ESCAPE '\\' \ ORDER BY ts ASC", )?; - let rows = stmt.query_map(params![since_ts as i64, repo_url], |row| { + let rows = stmt.query_map(params![since_ts as i64, pattern], |row| { Ok(LocalEventRecord { event_id: row.get::<_, i64>(0)? as u16, ts: row.get::<_, i64>(1)? as u32, From 42e33b3cd3a9284a18e64231e8fffd4c6b9bc37d Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 17:32:06 -0400 Subject: [PATCH 053/100] fix: update schema version assertions in tests from 3 to 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 3→4 added the repo_url column but the two test assertions were not updated, causing them to be wrong. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/db.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/metrics/db.rs b/src/metrics/db.rs index 98991109f3..fee3b9d193 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -532,7 +532,7 @@ mod tests { |row| row.get(0), ) .unwrap(); - assert_eq!(version, "3"); + assert_eq!(version, "4"); } #[test] @@ -570,7 +570,7 @@ mod tests { |row| row.get(0), ) .unwrap(); - assert_eq!(version, "3"); + assert_eq!(version, "4"); } #[test] From 935ac4015bb982fa299eb17ef1b87eabc200a9e8 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 17:33:26 -0400 Subject: [PATCH 054/100] fix: surface >100% acceptance rate instead of silently dropping it Previously, when committed AI lines exceeded checkpointed AI lines (incomplete checkpoint data from pre-backfill events), the acceptance rate was silently omitted from both the overall and per-tool output. Now the rate is kept and displayed as ">100% (incomplete checkpoint data)" so users understand why the number looks odd rather than seeing nothing at all. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 16 +++++++++++++--- src/metrics/local_stats.rs | 14 ++++++-------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 87aecf46c5..416440efd4 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -267,9 +267,13 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep ); if let Some(acceptance_pct) = (stats.commits.ai_lines * 100).checked_div(stats.checkpoints.ai_lines_added) - && acceptance_pct <= 100 { - println!(" Acceptance rate {:>5}%", acceptance_pct); + if acceptance_pct <= 100 { + println!(" Acceptance rate {:>5}%", acceptance_pct); + } else { + // >100% means checkpoint data is incomplete (pre-backfill events). + println!(" Acceptance rate {GRAY}>100% (incomplete checkpoint data){RESET}"); + } } // Track which tools have already had their acceptance rate shown so we // don't repeat the same tool-level rate on every model variant line. @@ -283,7 +287,13 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep .acceptance_by_tool .iter() .find(|(t, _)| t == tool_name) - .map(|(_, pct)| format!(" {GRAY}({pct}% accept){RESET}")) + .map(|(_, pct)| { + if *pct <= 100 { + format!(" {GRAY}({pct}% accept){RESET}") + } else { + format!(" {GRAY}(>100% accept — incomplete checkpoint data){RESET}") + } + }) .unwrap_or_default() } else { String::new() diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 5939d12152..f20935c18d 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -93,9 +93,9 @@ pub struct CommitSummary { pub diff_added_lines: u32, /// Per-tool AI line counts (tool · model label), sorted descending. pub by_tool: Vec<(String, u32)>, - /// Per-tool acceptance rate: committed AI lines / checkpoint AI lines (0–100). - /// Only includes tools where both sides have data and the rate is ≤ 100%. - /// Sorted by tool name. + /// Per-tool acceptance rate: committed AI lines / checkpoint AI lines, as a + /// percentage. Values >100 indicate incomplete checkpoint data (e.g. events + /// recorded before the repo_url backfill). Sorted by tool name. pub acceptance_by_tool: Vec<(String, u32)>, } @@ -286,16 +286,14 @@ pub fn compute_activity( } // Per-tool acceptance rate: committed AI lines / checkpoint AI lines. + // Values >100 indicate incomplete checkpoint data; we keep them so the + // caller can surface a meaningful signal rather than silently hiding it. let mut acceptance_by_tool: Vec<(String, u32)> = committed_ai_by_plain_tool .iter() .filter_map(|(tool, &committed)| { let checkpoint = *checkpoint_ai_by_tool.get(tool)?; let pct = (committed * 100).checked_div(checkpoint)?; - if pct <= 100 { - Some((tool.clone(), pct)) - } else { - None - } + Some((tool.clone(), pct)) }) .collect(); acceptance_by_tool.sort_by(|(a, _), (b, _)| a.cmp(b)); From 52b70b4636d710095a853aed4881992e18a5fc48 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 17:34:14 -0400 Subject: [PATCH 055/100] fix: clarify that CommitSummary.total counts AI commits only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field counted commits with at least one AI line, but was labelled "Commits" in the UI with no qualifier — users with no AI commits would see "Commits 0" and be confused. Renamed the display label to "Commits (AI)" and added a doc comment explaining the restriction. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 2 +- src/metrics/local_stats.rs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 416440efd4..f78e30ccd4 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -254,7 +254,7 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep ); } println!( - " Commits {:>6}", + " Commits (AI) {:>6}", format_num(stats.commits.total) ); println!( diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index f20935c18d..8c4e37246e 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -84,6 +84,8 @@ pub struct BucketStats { #[derive(Debug, Serialize)] pub struct CommitSummary { + /// Commits that include at least one AI-attributed line. Human-only commits + /// are not counted here; use the diff/human stats for full commit coverage. pub total: u32, pub ai_lines: u32, pub human_lines: u32, @@ -768,7 +770,8 @@ fn aggregate_committed( diff_added, }; - // Only count the commit and accumulate AI lines when AI was involved. + // Only count the commit toward the AI-commits total when AI was involved. + // Human-only commits still contribute to human_lines and diff_added above. if total_ai == 0 { return contribution; } From 3a1b45fe71ce9977327b1f09b7b31bb610568b81 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 17:37:01 -0400 Subject: [PATCH 056/100] fix: two correctness bugs in metrics aggregation 1. get_distinct_repo_urls: NULL sentinel was only added when committed events (event_id=1) had a NULL repo_url. Session and checkpoint events with NULL repo_url were silently excluded from the per-repo breakdown. Removed the AND event_id=1 restriction so any NULL-url event triggers the sentinel. 2. aggregate_session_tokens: model name was captured once at first streaming partial insertion and never updated. If the first chunk arrived with model="unknown", all tokens for that message were permanently mislabelled. Now upgrades the stored model whenever a non-unknown value arrives for an existing entry. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/db.rs | 2 +- src/metrics/local_stats.rs | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/metrics/db.rs b/src/metrics/db.rs index fee3b9d193..920e9fef71 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -398,7 +398,7 @@ impl MetricsDatabase { let mut urls: Vec = rows.collect::, _>>()?; let has_null: bool = self.conn.query_row( - "SELECT EXISTS(SELECT 1 FROM local_events WHERE ts >= ?1 AND repo_url IS NULL AND event_id = 1)", + "SELECT EXISTS(SELECT 1 FROM local_events WHERE ts >= ?1 AND repo_url IS NULL)", params![since_ts as i64], |row| row.get(0), )?; diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 8c4e37246e..d0803905db 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -906,9 +906,14 @@ fn aggregate_session_tokens( let get = |key: &str| usage.get(key).and_then(|v| v.as_u64()).unwrap_or(0); - let (_, acc, _ts, _sid) = message_usage + let (stored_model, acc, _ts, _sid) = message_usage .entry(id.to_string()) - .or_insert_with(|| (model, TokenAccum::default(), record_ts, session_id)); + .or_insert_with(|| (model.clone(), TokenAccum::default(), record_ts, session_id)); + // If the entry was created with an "unknown" placeholder model (e.g. from a + // streaming partial that arrived before the final event), upgrade it now. + if stored_model == "unknown" && model != "unknown" { + *stored_model = model; + } // Field-wise max: input/cache are fixed per message; output grows while // streaming, so the final (largest) value is authoritative. acc.input = acc.input.max(get("input_tokens")); From c64aaa23b0724e3dd3b10de17ef1bcc5667249a6 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 17:38:17 -0400 Subject: [PATCH 057/100] fix: suppress zero-token model entries from token breakdown Synthetic/placeholder model names (e.g. ) with no recorded token counts were appearing in the per-model breakdown as "X: 0 tokens". Now skipped before they reach the output. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/local_stats.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index d0803905db..37e46b55c2 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -561,6 +561,11 @@ fn build_token_summary( let mut by_model: Vec = Vec::new(); for (model, acc) in model_tokens { + // Skip placeholder/synthetic entries that carried no real token counts. + if acc.input == 0 && acc.output == 0 && acc.cache_read == 0 && acc.cache_creation == 0 { + continue; + } + summary.input += acc.input; summary.output += acc.output; summary.cache_read += acc.cache_read; From d6980da3e04757f8a5b487fca0df33a7494f84cb Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 17:45:18 -0400 Subject: [PATCH 058/100] fix: upgrade empty session_id in aggregate_session_tokens Same pattern as the model-name fix: if the first streaming partial arrived before the session was assigned an ID, the stored empty string was never replaced. This caused tokens to not be counted toward TokenModelStat.sessions for that message. Now upgrades stored_sid whenever a non-empty session_id arrives for an existing entry. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/local_stats.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 37e46b55c2..0c947ff67e 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -911,14 +911,18 @@ fn aggregate_session_tokens( let get = |key: &str| usage.get(key).and_then(|v| v.as_u64()).unwrap_or(0); - let (stored_model, acc, _ts, _sid) = message_usage + let (stored_model, acc, _ts, stored_sid) = message_usage .entry(id.to_string()) - .or_insert_with(|| (model.clone(), TokenAccum::default(), record_ts, session_id)); + .or_insert_with(|| (model.clone(), TokenAccum::default(), record_ts, session_id.clone())); // If the entry was created with an "unknown" placeholder model (e.g. from a // streaming partial that arrived before the final event), upgrade it now. if stored_model == "unknown" && model != "unknown" { *stored_model = model; } + // Similarly, upgrade an empty session_id once a real one is available. + if stored_sid.is_empty() && !session_id.is_empty() { + *stored_sid = session_id; + } // Field-wise max: input/cache are fixed per message; output grows while // streaming, so the final (largest) value is authoritative. acc.input = acc.input.max(get("input_tokens")); From 948970ab43cf1ee5f376949b951d602f8c36e96b Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 17:45:55 -0400 Subject: [PATCH 059/100] fix: replace unwrap with graceful break in Monthly bucket loop NaiveDate::from_ymd_opt with a valid i32 year and month 1-12 cannot actually return None, but the unwrap was inconsistent with the rest of the bucketing code which uses try_into().ok()?. Use a let-else break to be defensive against any future path that could produce an invalid date. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/local_stats.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 0c947ff67e..e35e8c1481 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -697,7 +697,9 @@ fn fill_buckets( let now_month = now.month(); loop { let order = year as i64 * 12 + (month - 1) as i64; - let date = NaiveDate::from_ymd_opt(year, month, 1).unwrap(); + let Some(date) = NaiveDate::from_ymd_opt(year, month, 1) else { + break; + }; let label = date.format("%b %Y").to_string(); result.push(make(label, data_map.remove(&order).unwrap_or_default())); if year == now_year && month == now_month { From 3888e491e4d8d0f58ea22d7ebeef84c56f9a6c07 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 17:46:07 -0400 Subject: [PATCH 060/100] fix: replace println!("") with println!() to satisfy clippy Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index f78e30ccd4..cb331a1844 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -461,7 +461,7 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep println!(); if repo_filter.is_none() { println!(" {GRAY}Tip: use --repo to filter by repository{RESET}"); - println!(""); + println!(); println!( " {GRAY}Local data only · See full history and team insights at https://usegitai.com/dashboard{RESET}" ); From 1159106f46d2989ce33fd86483def8b6f619b809 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 17:46:37 -0400 Subject: [PATCH 061/100] fix: don't report no-data when only human commits exist in the window commits.total only counts AI-involved commits, so a repo with purely human commits in the selected period was falsely exiting with an error. Now also checks human_lines and diff_added_lines before declaring empty. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index cb331a1844..b45b4ac09a 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -95,7 +95,11 @@ pub fn handle_activity(args: &[String]) { .unwrap_or_default(); // When filtering by repo, bail out early if nothing matched. + // Include human_lines/diff_added_lines so human-only periods aren't + // falsely reported as empty (commits.total only counts AI-involved commits). let no_data = stats.commits.total == 0 + && stats.commits.human_lines == 0 + && stats.commits.diff_added_lines == 0 && stats.sessions.total == 0 && stats.tokens.input + stats.tokens.output + stats.tokens.cache_read + stats.tokens.cache_creation == 0; if no_data { From e532fb5f2afbf44b897bb2c8310d4e25a272b29a Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 17:47:11 -0400 Subject: [PATCH 062/100] docs: clarify why .contains() is correct in compute_repo_summaries filter The repo_filter is the raw user input with no LIKE escaping applied, so .contains() is the correct literal-match counterpart to the LIKE ESCAPE query in get_local_events. Add a comment to make this non-obvious invariant explicit. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/local_stats.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index e35e8c1481..785162c68b 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -1309,6 +1309,9 @@ pub fn compute_repo_summaries( let mut summaries: Vec = repo_urls .iter() // When a filter is active, only include URLs that contain the substring. + // `repo_filter` is the raw user input (https:// already stripped, no LIKE + // escaping applied), so `.contains()` is the correct literal-match + // counterpart to the LIKE '%…%' ESCAPE '\' used in get_local_events. .filter(|url| repo_filter.map_or(true, |f| url.contains(f))) .filter_map(|url| { let stats = compute_activity( From 2ae1a0c33bf477ec192f1d63ac84a2c7681da60b Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 17:54:36 -0400 Subject: [PATCH 063/100] fix: upgrade unknown model in compute_session_list message map Same bug as was fixed in aggregate_session_tokens: the first streaming partial for a message could lock in model=unknown, causing pricing_for to return None and silently dropping cost estimates for that session. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/local_stats.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 785162c68b..7efea26226 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -1102,9 +1102,14 @@ pub fn compute_session_list( .to_string(); let get = |key: &str| usage.get(key).and_then(|v| v.as_u64()).unwrap_or(0); let msgs = session_messages.entry(sid).or_default(); - let (_, acc) = msgs + let (stored_model, acc) = msgs .entry(id.to_string()) - .or_insert_with(|| (model, TokenAccum::default())); + .or_insert_with(|| (model.clone(), TokenAccum::default())); + // Upgrade placeholder model name if a real one arrives later + // (same pattern as aggregate_session_tokens). + if stored_model == "unknown" && model != "unknown" { + *stored_model = model; + } acc.input = acc.input.max(get("input_tokens")); acc.output = acc.output.max(get("output_tokens")); acc.cache_read = acc.cache_read.max(get("cache_read_input_tokens")); From 26b318240df14fcafce30ea56507290cc23fe85a Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 17:55:27 -0400 Subject: [PATCH 064/100] fix: fold date-snapshot model variants into a single display row model_tokens was keyed by the full model ID, so two date snapshots of the same model (e.g. claude-sonnet-4-6-20250101 and -20250201) produced two identically-labelled rows in the token breakdown after shorten_model was applied at display time. Now keyed by shorten_model() upfront so all variants are folded together before stats are built. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/local_stats.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 7efea26226..0b6fb5f6cc 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -480,10 +480,13 @@ fn build_token_summary( let mut last_week_msgs: Vec<(String, TokenAccum)> = Vec::new(); // Fold per-message (deduped, max) usage into per-model totals. + // Key by shorten_model() so date-snapshot variants (e.g. claude-sonnet-4-6-20250101 + // and claude-sonnet-4-6-20250201) are folded into a single display row. let mut model_tokens: HashMap = HashMap::new(); let mut model_session_ids: HashMap> = HashMap::new(); for (_id, (model, acc, ts, sid)) in message_usage { - let entry = model_tokens.entry(model.clone()).or_default(); + let short = shorten_model(&model); + let entry = model_tokens.entry(short.clone()).or_default(); entry.input += acc.input; entry.output += acc.output; entry.cache_read += acc.cache_read; @@ -491,15 +494,15 @@ fn build_token_summary( if !sid.is_empty() { model_session_ids - .entry(model.clone()) + .entry(short.clone()) .or_default() .insert(sid); } if ts >= this_week_start { - this_week_msgs.push((model, acc)); + this_week_msgs.push((short, acc)); } else if ts >= last_week_start { - last_week_msgs.push((model, acc)); + last_week_msgs.push((short, acc)); } } @@ -512,25 +515,26 @@ fn build_token_summary( for (sid, acc) in codex_sessions { let model = acc.model.clone().unwrap_or_else(|| "codex".to_string()); + let short = shorten_model(&model); let mapped = TokenAccum { input: acc.input_tokens.saturating_sub(acc.cached_input_tokens), output: acc.output_tokens, cache_read: acc.cached_input_tokens, cache_creation: 0, }; - let entry = model_tokens.entry(model.clone()).or_default(); + let entry = model_tokens.entry(short.clone()).or_default(); entry.input += mapped.input; entry.output += mapped.output; entry.cache_read += mapped.cache_read; model_session_ids - .entry(model.clone()) + .entry(short.clone()) .or_default() .insert(sid); if acc.last_usage_ts >= this_week_start { - this_week_codex.push((model, mapped)); + this_week_codex.push((short, mapped)); } else if acc.last_usage_ts >= last_week_start { - last_week_codex.push((model, mapped)); + last_week_codex.push((short, mapped)); } } From 0de7d7a570a23cdad12f269f06e84440efecfafb Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 17:55:51 -0400 Subject: [PATCH 065/100] fix: apply format_num to commits and sessions in repo breakdown table r.commits and r.sessions were formatted with bare {} while r.ai_lines used format_num, producing inconsistent comma-separation for large values. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index b45b4ac09a..54e03202e8 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -232,8 +232,8 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep bar_str, repo_display, format_num(r.ai_lines), - r.commits, - r.sessions, + format_num(r.commits), + format_num(r.sessions), cost_str, ); } From 5e032be9041823b11d64ebf9453751f6e32a9c45 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 17:56:12 -0400 Subject: [PATCH 066/100] fix: include checkpoint lines in no_data guard A user with checkpoint events but no commits or sessions in the window was falsely getting the no-data error even though checkpoint stats would render in the output. Now checks checkpoints.ai_lines_added and checkpoints.human_lines_added before declaring the result empty. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 54e03202e8..4b2a115da8 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -97,10 +97,13 @@ pub fn handle_activity(args: &[String]) { // When filtering by repo, bail out early if nothing matched. // Include human_lines/diff_added_lines so human-only periods aren't // falsely reported as empty (commits.total only counts AI-involved commits). + // Also include checkpoint lines so checkpoint-only activity isn't missed. let no_data = stats.commits.total == 0 && stats.commits.human_lines == 0 && stats.commits.diff_added_lines == 0 && stats.sessions.total == 0 + && stats.checkpoints.ai_lines_added == 0 + && stats.checkpoints.human_lines_added == 0 && stats.tokens.input + stats.tokens.output + stats.tokens.cache_read + stats.tokens.cache_creation == 0; if no_data { if let Some(ref filter) = repo_filter { From 4f4784c8b11af71ddf459efe627690db29eb96e7 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 18:00:16 -0400 Subject: [PATCH 067/100] refactor: extract fetch_local_events and fetch_distinct_repo_urls helpers The DB lock-acquire + get pattern was duplicated verbatim in compute_activity, compute_session_list, and compute_repo_summaries. Extracted into two free functions so the lock-poison error string and the acquire pattern live in one place. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/local_stats.rs | 47 ++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 0b6fb5f6cc..903b505399 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -2,7 +2,7 @@ use crate::error::GitAiError; use crate::metrics::attrs::attr_pos; -use crate::metrics::db::MetricsDatabase; +use crate::metrics::db::{LocalEventRecord, MetricsDatabase}; use crate::metrics::events::{checkpoint_pos, committed_pos, session_event_pos}; use crate::metrics::pos_encoded::{ sparse_get_string, sparse_get_u32, sparse_get_vec_string, sparse_get_vec_u32, @@ -133,6 +133,27 @@ pub enum BucketGranularity { Monthly, } +/// Acquire the global DB lock and fetch all local events for the given window. +fn fetch_local_events( + since_ts: u32, + repo_filter: Option<&str>, +) -> Result, GitAiError> { + let db = MetricsDatabase::global()?; + let db_lock = db + .lock() + .map_err(|_| GitAiError::Generic("metrics DB lock poisoned".to_string()))?; + db_lock.get_local_events(since_ts, repo_filter) +} + +/// Acquire the global DB lock and return all distinct repo URLs since `since_ts`. +fn fetch_distinct_repo_urls(since_ts: u32) -> Result, GitAiError> { + let db = MetricsDatabase::global()?; + let db_lock = db + .lock() + .map_err(|_| GitAiError::Generic("metrics DB lock poisoned".to_string()))?; + db_lock.get_distinct_repo_urls(since_ts) +} + /// Aggregate local_events since `since_ts` (Unix seconds) into activity stats. /// /// When `repo_filter` is `Some(url)`, only events from that repository are @@ -143,13 +164,7 @@ pub fn compute_activity( granularity: BucketGranularity, repo_filter: Option<&str>, ) -> Result { - let records = { - let db = MetricsDatabase::global()?; - let db_lock = db - .lock() - .map_err(|_| GitAiError::Generic("metrics DB lock poisoned".to_string()))?; - db_lock.get_local_events(since_ts, repo_filter)? - }; + let records = fetch_local_events(since_ts, repo_filter)?; let mut total_commits = 0u32; let mut total_ai_lines = 0u32; @@ -1006,13 +1021,7 @@ pub fn compute_session_list( since_ts: u32, repo_filter: Option<&str>, ) -> Result, GitAiError> { - let events = { - let db = MetricsDatabase::global()?; - let db_lock = db - .lock() - .map_err(|_| GitAiError::Generic("metrics DB lock poisoned".to_string()))?; - db_lock.get_local_events(since_ts, repo_filter)? - }; + let events = fetch_local_events(since_ts, repo_filter)?; let mut session_first_ts: HashMap = HashMap::new(); let mut session_last_ts: HashMap = HashMap::new(); @@ -1307,13 +1316,7 @@ pub fn compute_repo_summaries( granularity: BucketGranularity, repo_filter: Option<&str>, ) -> Result, GitAiError> { - let repo_urls = { - let db = MetricsDatabase::global()?; - let db_lock = db - .lock() - .map_err(|_| GitAiError::Generic("metrics DB lock poisoned".to_string()))?; - db_lock.get_distinct_repo_urls(since_ts)? - }; + let repo_urls = fetch_distinct_repo_urls(since_ts)?; let mut summaries: Vec = repo_urls .iter() From f41701fa09847e37868f83d24fe11687399331d0 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 18:00:43 -0400 Subject: [PATCH 068/100] refactor: promote YIELD_WINDOW_SECS to module-level constant Was defined identically inside both compute_activity and compute_session_list. Now a single module-level const with a doc comment explaining its purpose. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/local_stats.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 903b505399..06e2f2512e 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -1,5 +1,9 @@ //! In-memory aggregation of local_events for `git-ai activity`. +/// How long after a session's last message a subsequent commit is attributed +/// to that session for yield and ai_lines_committed calculations. +const YIELD_WINDOW_SECS: u32 = 4 * 3600; + use crate::error::GitAiError; use crate::metrics::attrs::attr_pos; use crate::metrics::db::{LocalEventRecord, MetricsDatabase}; @@ -287,7 +291,7 @@ pub fn compute_activity( // so a commit in repo-A can incorrectly "claim" a nearby session that was // working in repo-B. Fixing this properly requires storing the repo path // on both session and committed events (a future schema change). - const YIELD_WINDOW_SECS: u32 = 4 * 3600; + commit_timestamps.sort_unstable(); let mut yield_shipped = 0u32; let mut yield_abandoned = 0u32; @@ -1134,7 +1138,7 @@ pub fn compute_session_list( } commit_data.sort_unstable_by_key(|&(ts, _, _)| ts); - const YIELD_WINDOW_SECS: u32 = 4 * 3600; + let mut out: Vec = session_first_ts .iter() From 9e5fbc5603bab70a2885ee9c0bc20156bf9e9843 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 18:01:37 -0400 Subject: [PATCH 069/100] refactor: extract strip_protocol helper in activity.rs The two-call trim_start_matches chain appeared at four separate sites. Now a single strip_protocol(&str) -> &str function handles it. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 4b2a115da8..daec0c208b 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -23,11 +23,7 @@ pub fn handle_activity(args: &[String]) { "--repo" if i + 1 < args.len() => { // Normalize: strip protocol prefix so both "https://github.com/org/repo" // and "github.com/org/repo" resolve to the same substring match. - let raw = args[i + 1].as_str(); - let normalized = raw - .trim_start_matches("https://") - .trim_start_matches("http://"); - repo_filter = Some(normalized.to_string()); + repo_filter = Some(strip_protocol(args[i + 1].as_str()).to_string()); i += 1; } "--help" | "-h" => { @@ -161,9 +157,7 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep const BAR_WIDTH: u32 = 20; if let Some(repo) = repo_filter { - let display = repo - .trim_start_matches("https://") - .trim_start_matches("http://"); + let display = strip_protocol(repo); if repos.len() > 1 { println!( "{BOLD}git-ai usage{RESET} {GRAY}— {} repos matching '{}' · {}{RESET}", @@ -175,7 +169,7 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep // Single match: show the full matched URL, not just the search term. let matched = repos .first() - .map(|r| r.repo_url.trim_start_matches("https://").trim_start_matches("http://")) + .map(|r| strip_protocol(&r.repo_url)) .unwrap_or(display); println!( "{BOLD}git-ai usage{RESET} {GRAY}— {} · {}{RESET}", @@ -209,15 +203,8 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep println!(" {BOLD}Repositories{RESET}"); let max_lines = repos.iter().map(|r| r.ai_lines).max().unwrap_or(1).max(1); for r in repos { - let repo_display = r - .repo_url - .trim_start_matches("https://") - .trim_start_matches("http://"); - let repo_display = if repo_display.is_empty() { - "unknown" - } else { - repo_display - }; + let repo_display = strip_protocol(&r.repo_url); + let repo_display = if repo_display.is_empty() { "unknown" } else { repo_display }; let filled = (r.ai_lines * 16 / max_lines).min(16); let empty = 16 - filled; let bar_str = format!( @@ -497,6 +484,12 @@ fn spark_char(value: u32, max: u32) -> &'static str { } } +/// Strip `https://` or `http://` from a URL for display purposes. +fn strip_protocol(url: &str) -> &str { + url.trim_start_matches("https://") + .trim_start_matches("http://") +} + fn bar(pct: u32, width: u32) -> String { let filled = (pct * width / 100).min(width); let empty = width - filled; From b4009a00a79028ab3befd8a6e078197d13f4e8c8 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 18:01:57 -0400 Subject: [PATCH 070/100] refactor: deduplicate footer println in print_terminal The dashboard CTA line was written in both branches of the repo_filter if/else. Move it out so it prints unconditionally, with only the --repo tip gated on the filter being absent. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index daec0c208b..48e29daf27 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -455,15 +455,10 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep println!(); if repo_filter.is_none() { println!(" {GRAY}Tip: use --repo to filter by repository{RESET}"); - println!(); - println!( - " {GRAY}Local data only · See full history and team insights at https://usegitai.com/dashboard{RESET}" - ); - } else { - println!( - " {GRAY}Local data only · See full history and team insights at https://usegitai.com/dashboard{RESET}" - ); } + println!( + " {GRAY}Local data only · See full history and team insights at https://usegitai.com/dashboard{RESET}" + ); println!(); } From bd70d0b7cd995ec23118dfd0715e4be62827f937 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 18:02:51 -0400 Subject: [PATCH 071/100] refactor: extract shared row mapper in get_local_events The LocalEventRecord construction closure was copy-pasted across all three query branches (NULL filter, LIKE filter, no filter). Now defined once as map_row and shared by all three. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/db.rs | 47 ++++++++++++++++++----------------------------- 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/src/metrics/db.rs b/src/metrics/db.rs index 920e9fef71..3154908475 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -330,6 +330,16 @@ impl MetricsDatabase { since_ts: u32, repo_filter: Option<&str>, ) -> Result, GitAiError> { + // Shared row mapper used by all three query branches below. + let map_row = |row: &rusqlite::Row<'_>| { + Ok(LocalEventRecord { + event_id: row.get::<_, i64>(0)? as u16, + ts: row.get::<_, i64>(1)? as u32, + repo_url: row.get(2)?, + event_json: row.get(3)?, + }) + }; + let records = if let Some(repo_url) = repo_filter { if repo_url.is_empty() { let mut stmt = self.conn.prepare( @@ -337,15 +347,8 @@ impl MetricsDatabase { WHERE ts >= ?1 AND repo_url IS NULL \ ORDER BY ts ASC", )?; - let rows = stmt.query_map(params![since_ts as i64], |row| { - Ok(LocalEventRecord { - event_id: row.get::<_, i64>(0)? as u16, - ts: row.get::<_, i64>(1)? as u32, - repo_url: row.get(2)?, - event_json: row.get(3)?, - }) - })?; - rows.collect::, _>>()? + stmt.query_map(params![since_ts as i64], map_row)? + .collect::, _>>()? } else { // Escape LIKE special characters so a user-supplied substring // like "my_org/my%repo" matches literally, not as wildcards. @@ -354,18 +357,11 @@ impl MetricsDatabase { let pattern = format!("%{}%", escaped); let mut stmt = self.conn.prepare( "SELECT event_id, ts, repo_url, event_json FROM local_events \ - WHERE ts >= ?1 AND repo_url LIKE ?2 ESCAPE '\\' \ - ORDER BY ts ASC", + WHERE ts >= ?1 AND repo_url LIKE ?2 ESCAPE '\\' \ + ORDER BY ts ASC", )?; - let rows = stmt.query_map(params![since_ts as i64, pattern], |row| { - Ok(LocalEventRecord { - event_id: row.get::<_, i64>(0)? as u16, - ts: row.get::<_, i64>(1)? as u32, - repo_url: row.get(2)?, - event_json: row.get(3)?, - }) - })?; - rows.collect::, _>>()? + stmt.query_map(params![since_ts as i64, pattern], map_row)? + .collect::, _>>()? } } else { let mut stmt = self.conn.prepare( @@ -373,15 +369,8 @@ impl MetricsDatabase { WHERE ts >= ?1 \ ORDER BY ts ASC", )?; - let rows = stmt.query_map(params![since_ts as i64], |row| { - Ok(LocalEventRecord { - event_id: row.get::<_, i64>(0)? as u16, - ts: row.get::<_, i64>(1)? as u32, - repo_url: row.get(2)?, - event_json: row.get(3)?, - }) - })?; - rows.collect::, _>>()? + stmt.query_map(params![since_ts as i64], map_row)? + .collect::, _>>()? }; Ok(records) } From 51ac070307f428a7197bbe96db27c23e076b503c Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 18:03:31 -0400 Subject: [PATCH 072/100] refactor: remove redundant shorten_model call in build_token_summary model_tokens is now keyed by shorten_model() at insertion, so the model string in the iteration variable is already shortened. The shorten_model call at the by_model push site was a no-op. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/local_stats.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 06e2f2512e..adcdab3f4a 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -611,7 +611,7 @@ fn build_token_summary( .map(|s| s.len() as u32) .unwrap_or(0); by_model.push(TokenModelStat { - model: shorten_model(&model), + model, // already shortened at insertion into model_tokens sessions, input: acc.input, output: acc.output, From a3b82ce8041c8f42e27d8e0fb03f725a9057045e Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 18:05:03 -0400 Subject: [PATCH 073/100] refactor: add CodexSessionAccum::to_token_accum() to remove duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codex→TokenAccum field mapping (subtract cached from input, zero cache_creation) was inlined in both build_token_summary and compute_session_list. Now lives once as an impl method with a doc comment explaining the field semantics. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/local_stats.rs | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index adcdab3f4a..14d2b5d3a0 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -397,6 +397,21 @@ struct CodexSessionAccum { output_tokens: u64, } +impl CodexSessionAccum { + /// Map codex token fields onto the shared `TokenAccum` schema. + /// + /// Codex `input_tokens` *includes* cached tokens, so non-cached input is + /// the difference. Codex has no cache-creation concept. + fn to_token_accum(&self) -> TokenAccum { + TokenAccum { + input: self.input_tokens.saturating_sub(self.cached_input_tokens), + output: self.output_tokens, + cache_read: self.cached_input_tokens, + cache_creation: 0, + } + } +} + /// Per-million-token pricing for a model (USD). struct ModelPricing { input: f64, @@ -535,12 +550,7 @@ fn build_token_summary( for (sid, acc) in codex_sessions { let model = acc.model.clone().unwrap_or_else(|| "codex".to_string()); let short = shorten_model(&model); - let mapped = TokenAccum { - input: acc.input_tokens.saturating_sub(acc.cached_input_tokens), - output: acc.output_tokens, - cache_read: acc.cached_input_tokens, - cache_creation: 0, - }; + let mapped = acc.to_token_accum(); let entry = model_tokens.entry(short.clone()).or_default(); entry.input += mapped.input; entry.output += mapped.output; @@ -1180,12 +1190,7 @@ pub fn compute_session_list( let (model, total_tokens, estimated_cost_usd) = if tool == "codex" { if let Some(acc) = codex_sessions.get(sid) { - let mapped = TokenAccum { - input: acc.input_tokens.saturating_sub(acc.cached_input_tokens), - output: acc.output_tokens, - cache_read: acc.cached_input_tokens, - cache_creation: 0, - }; + let mapped = acc.to_token_accum(); let total = mapped.input + mapped.output + mapped.cache_read + mapped.cache_creation; let cost = acc From 82e839c4a276dc82757d7cf111bf98b8bf053e84 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 18:05:43 -0400 Subject: [PATCH 074/100] refactor: extract ratio_bar helper to deduplicate block-bar rendering Three sites in print_terminal independently computed filled/empty block counts and formatted them. Now handled by ratio_bar(value, max, width); bar(pct, width) becomes a thin wrapper over it. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 31 ++++++++++--------------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 48e29daf27..edec39108a 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -205,13 +205,7 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep for r in repos { let repo_display = strip_protocol(&r.repo_url); let repo_display = if repo_display.is_empty() { "unknown" } else { repo_display }; - let filled = (r.ai_lines * 16 / max_lines).min(16); - let empty = 16 - filled; - let bar_str = format!( - "{}{}", - "█".repeat(filled as usize), - "░".repeat(empty as usize) - ); + let bar_str = ratio_bar(r.ai_lines, max_lines, 16); let cost_str = if r.estimated_cost_usd > 0.0 { format!(" {GRAY}~${:.2}{RESET}", r.estimated_cost_usd) } else { @@ -378,13 +372,7 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep .unwrap_or(1) .max(1); for bucket in &stats.buckets { - let filled = (bucket.ai_lines * BAR_WIDTH / max_ai).min(BAR_WIDTH); - let empty = BAR_WIDTH - filled; - let bar_str = format!( - "{}{}", - "█".repeat(filled as usize), - "░".repeat(empty as usize) - ); + let bar_str = ratio_bar(bucket.ai_lines, max_ai, BAR_WIDTH); if bucket.ai_lines > 0 { // Coverage for this bucket: attributed / total diff additions. let coverage = (bucket.attributed_lines * 100) @@ -485,14 +473,15 @@ fn strip_protocol(url: &str) -> &str { .trim_start_matches("http://") } -fn bar(pct: u32, width: u32) -> String { - let filled = (pct * width / 100).min(width); +/// Render a block bar where `value` out of `max` determines the fill ratio. +fn ratio_bar(value: u32, max: u32, width: u32) -> String { + let filled = if max > 0 { (value * width / max).min(width) } else { 0 }; let empty = width - filled; - format!( - "{}{}", - "█".repeat(filled as usize), - "░".repeat(empty as usize) - ) + format!("{}{}", "█".repeat(filled as usize), "░".repeat(empty as usize)) +} + +fn bar(pct: u32, width: u32) -> String { + ratio_bar(pct, 100, width) } fn format_num(n: u32) -> String { From 27c9a715684c0391e0baa336461227ec86701702 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 18:06:39 -0400 Subject: [PATCH 075/100] refactor: extract bucket_label to eliminate duplicate label format strings bucket_key and fill_buckets both independently produced daily/weekly/ monthly label strings. A change to any format required two edits. Now handled by bucket_label(date, granularity) called from both sites. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/local_stats.rs | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 14d2b5d3a0..a71b70ec9a 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -645,26 +645,36 @@ fn ts_to_local(ts: u32) -> DateTime { .unwrap_or_else(Local::now) } +/// Produce the display label for a bucket whose "anchor" date (Monday for +/// weekly, 1st for monthly, the day itself for daily) is `date`. +fn bucket_label(date: NaiveDate, granularity: BucketGranularity) -> String { + match granularity { + BucketGranularity::Daily => date.format("%b %d").to_string(), + BucketGranularity::Weekly => { + let sunday = date + chrono::Duration::days(6); + format!("{} – {}", date.format("%b %d"), sunday.format("%b %d")) + } + BucketGranularity::Monthly => date.format("%b %Y").to_string(), + } +} + fn bucket_key(dt: &DateTime, granularity: BucketGranularity) -> (String, i64) { match granularity { BucketGranularity::Daily => { - let label = dt.format("%b %d").to_string(); - let order = dt.date_naive().num_days_from_ce() as i64; - (label, order) + let date = dt.date_naive(); + let order = date.num_days_from_ce() as i64; + (bucket_label(date, granularity), order) } BucketGranularity::Weekly => { // ISO week: key on Monday of the week. let weekday = dt.weekday().num_days_from_monday() as i64; let monday = dt.date_naive() - chrono::Duration::days(weekday); - let sunday = monday + chrono::Duration::days(6); - let label = format!("{} – {}", monday.format("%b %d"), sunday.format("%b %d")); let order = monday.num_days_from_ce() as i64; - (label, order) + (bucket_label(monday, granularity), order) } BucketGranularity::Monthly => { - let label = dt.format("%b %Y").to_string(); let order = dt.year() as i64 * 12 + dt.month0() as i64; - (label, order) + (bucket_label(dt.date_naive(), granularity), order) } } } @@ -704,8 +714,7 @@ fn fill_buckets( let today = now.date_naive(); while day <= today { let order = day.num_days_from_ce() as i64; - let label = day.format("%b %d").to_string(); - result.push(make(label, data_map.remove(&order).unwrap_or_default())); + result.push(make(bucket_label(day, granularity), data_map.remove(&order).unwrap_or_default())); day = day.succ_opt().unwrap_or(today); } } @@ -715,9 +724,7 @@ fn fill_buckets( let today = now.date_naive(); while monday <= today { let order = monday.num_days_from_ce() as i64; - let sunday = monday + chrono::Duration::days(6); - let label = format!("{} – {}", monday.format("%b %d"), sunday.format("%b %d")); - result.push(make(label, data_map.remove(&order).unwrap_or_default())); + result.push(make(bucket_label(monday, granularity), data_map.remove(&order).unwrap_or_default())); monday = monday .checked_add_signed(chrono::Duration::weeks(1)) .unwrap_or(today); @@ -733,7 +740,7 @@ fn fill_buckets( let Some(date) = NaiveDate::from_ymd_opt(year, month, 1) else { break; }; - let label = date.format("%b %Y").to_string(); + let label = bucket_label(date, granularity); result.push(make(label, data_map.remove(&order).unwrap_or_default())); if year == now_year && month == now_month { break; From 6aded15e49c0c7ce3bcc18d261962b17db8b6922 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 18:17:25 -0400 Subject: [PATCH 076/100] fix: improve git-ai usage terminal output based on design feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Repositories block: replace block-bar + dot-separated stats with aligned tabular columns; singular/plural for commit/session labels - Model breakdown lines now include "lines" unit after the count - Acceptance rate shows a range (e.g. 56–81%) when multiple tools have valid data, falling back to the overall rate for single-tool - WoW block: drop "↑ new this week" label when last week had no spend (redundant); fix "$-0.00" display by formatting near-zero as "$0" - Add format_cost() helper: rounds to whole dollars for amounts ≥ $10, keeps cents below that threshold; applied to all cost display sites Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 100 +++++++++++++++++++++++++++++++-------- 1 file changed, 81 insertions(+), 19 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index edec39108a..1c954dd5e5 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -201,23 +201,45 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep if repos.len() > 1 { println!(); println!(" {BOLD}Repositories{RESET}"); - let max_lines = repos.iter().map(|r| r.ai_lines).max().unwrap_or(1).max(1); - for r in repos { - let repo_display = strip_protocol(&r.repo_url); - let repo_display = if repo_display.is_empty() { "unknown" } else { repo_display }; - let bar_str = ratio_bar(r.ai_lines, max_lines, 16); + + // Pre-compute display strings for column alignment. + let names: Vec<&str> = repos + .iter() + .map(|r| { + let d = strip_protocol(&r.repo_url); + if d.is_empty() { "unknown" } else { d } + }) + .collect(); + let lines_strs: Vec = repos.iter().map(|r| format_num(r.ai_lines)).collect(); + let commit_strs: Vec = repos.iter().map(|r| format_num(r.commits)).collect(); + let session_strs: Vec = repos.iter().map(|r| format_num(r.sessions)).collect(); + + let max_name_w = names.iter().map(|n| n.len()).max().unwrap_or(0); + let max_lines_w = lines_strs.iter().map(|s| s.len()).max().unwrap_or(0); + let max_commits_w = commit_strs.iter().map(|s| s.len()).max().unwrap_or(0); + let max_sessions_w = session_strs.iter().map(|s| s.len()).max().unwrap_or(0); + + for (i, r) in repos.iter().enumerate() { + let name_col = format!("{:width$}", lines_strs[i], width = max_lines_w); + let commits_col = format!("{:>width$}", commit_strs[i], width = max_commits_w); + let sessions_col = format!("{:>width$}", session_strs[i], width = max_sessions_w); + // Pad singular labels to match the width of the plural so columns stay aligned. + let commit_label = if r.commits == 1 { "commit " } else { "commits" }; + let session_label = if r.sessions == 1 { "session " } else { "sessions" }; let cost_str = if r.estimated_cost_usd > 0.0 { - format!(" {GRAY}~${:.2}{RESET}", r.estimated_cost_usd) + format!(" {GRAY}{}{RESET}", format_cost(r.estimated_cost_usd)) } else { String::new() }; println!( - " {} {GRAY}{}{RESET} {GRAY}{} lines · {} commits · {} sessions{}{RESET}", - bar_str, - repo_display, - format_num(r.ai_lines), - format_num(r.commits), - format_num(r.sessions), + " {GRAY}{} {} lines {} {} {} {}{}{RESET}", + name_col, + lines_col, + commits_col, + commit_label, + sessions_col, + session_label, cost_str, ); } @@ -253,7 +275,23 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep " Edits {:>6}", format_num(stats.checkpoints.ai_lines_added) ); - if let Some(acceptance_pct) = + // Show acceptance rate: range when multiple tools have valid data, single value otherwise. + let valid_tool_rates: Vec = stats + .commits + .acceptance_by_tool + .iter() + .filter(|(_, pct)| *pct <= 100) + .map(|(_, pct)| *pct) + .collect(); + if valid_tool_rates.len() >= 2 { + let min_r = *valid_tool_rates.iter().min().unwrap(); + let max_r = *valid_tool_rates.iter().max().unwrap(); + if min_r == max_r { + println!(" Acceptance rate {:>5}%", min_r); + } else { + println!(" Acceptance rate {GRAY}{min_r}–{max_r}%{RESET}"); + } + } else if let Some(acceptance_pct) = (stats.commits.ai_lines * 100).checked_div(stats.checkpoints.ai_lines_added) { if acceptance_pct <= 100 { @@ -287,7 +325,7 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep String::new() }; println!( - " {GRAY}{}: {}{RESET}{}", + " {GRAY}{}: {} lines{RESET}{}", tool, format_num(*count), accept_str @@ -321,12 +359,13 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep if t.estimated_cost_usd > 0.0 { println!( " {BOLD}Est. cost{RESET} {:>12}", - format!("~${:.2}", t.estimated_cost_usd) + format_cost(t.estimated_cost_usd) ); } if let Some(wow) = &t.wow_spend { + // When last week had no spend, "new this week" is redundant — skip the label. let change_str = match (wow.new_this_week, wow.change_pct) { - (true, _) => "↑ new this week".to_string(), + (true, _) => String::new(), (_, Some(change_pct)) if change_pct > 0.0 => { format!("↑ {:.0}% vs last week", change_pct) } @@ -335,15 +374,28 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep } _ => "no change vs last week".to_string(), }; + // Avoid printing "$-0.00" when last week rounds to zero. + let last_week_str = if wow.last_week_usd.abs() < 0.005 { + "$0".to_string() + } else { + format_cost(wow.last_week_usd) + }; + let trail = if change_str.is_empty() { + String::new() + } else { + format!(" {change_str}") + }; println!( - " {GRAY}This week ~${:.2} · Last week ~${:.2} {}{RESET}", - wow.this_week_usd, wow.last_week_usd, change_str, + " {GRAY}This week {} · Last week {}{}{RESET}", + format_cost(wow.this_week_usd), + last_week_str, + trail, ); } for m in &t.by_model { let cost = m .estimated_cost_usd - .map(|c| format!(" ~${:.2}", c)) + .map(|c| format!(" {}", format_cost(c))) .unwrap_or_default(); let cache = m .cache_hit_ratio @@ -484,6 +536,16 @@ fn bar(pct: u32, width: u32) -> String { ratio_bar(pct, 100, width) } +/// Format a USD cost estimate. Rounds to whole dollars for amounts >= $10 +/// (estimates don't warrant cent-level precision at that scale); shows cents otherwise. +fn format_cost(usd: f64) -> String { + if usd >= 10.0 { + format!("~${:.0}", usd) + } else { + format!("~${:.2}", usd) + } +} + fn format_num(n: u32) -> String { format_num_u64(n as u64) } From 8f847758ff9924fa9d4179f6136ccb56b55f2a9f Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 18:21:43 -0400 Subject: [PATCH 077/100] fix: align model columns in AI and Tokens sections of git-ai usage Pre-compute max widths for tool names, line counts, model names, token counts, and costs so each column lines up across rows. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 43 ++++++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 1c954dd5e5..c433046b5e 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -304,6 +304,15 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep // Track which tools have already had their acceptance rate shown so we // don't repeat the same tool-level rate on every model variant line. let mut shown_accept: HashSet<&str> = HashSet::new(); + // Pre-compute column widths for aligned tool breakdown. + let max_tool_w = stats.commits.by_tool.iter().map(|(t, _)| t.len()).max().unwrap_or(0); + let max_tool_count_w = stats + .commits + .by_tool + .iter() + .map(|(_, c)| format_num(*c).len()) + .max() + .unwrap_or(0); for (tool, count) in &stats.commits.by_tool { let tool_name = tool.split(" · ").next().unwrap_or(tool.as_str()); let accept_str = if shown_accept.insert(tool_name) { @@ -325,10 +334,12 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep String::new() }; println!( - " {GRAY}{}: {} lines{RESET}{}", + " {GRAY}{:count_w$} lines{RESET}{}", tool, format_num(*count), - accept_str + accept_str, + tool_w = max_tool_w, + count_w = max_tool_count_w, ); } @@ -392,22 +403,38 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep trail, ); } + // Pre-compute column widths for aligned model breakdown. + let max_model_w = t.by_model.iter().map(|m| m.model.len()).max().unwrap_or(0); + let max_tokens_w = t + .by_model + .iter() + .map(|m| format_num_u64(m.input + m.output + m.cache_read + m.cache_creation).len()) + .max() + .unwrap_or(0); + let max_cost_w = t + .by_model + .iter() + .map(|m| m.estimated_cost_usd.map(|c| format_cost(c).len()).unwrap_or(0)) + .max() + .unwrap_or(0); for m in &t.by_model { - let cost = m + let total = m.input + m.output + m.cache_read + m.cache_creation; + let cost_str = m .estimated_cost_usd - .map(|c| format!(" {}", format_cost(c))) - .unwrap_or_default(); + .map(|c| format!("{:>width$}", format_cost(c), width = max_cost_w)) + .unwrap_or_else(|| " ".repeat(max_cost_w)); let cache = m .cache_hit_ratio .map(|r| format!(" cache {:.0}% hit", r * 100.0)) .unwrap_or_default(); - let total = m.input + m.output + m.cache_read + m.cache_creation; println!( - " {GRAY}{}: {} tokens{}{}{RESET}", + " {GRAY}{:tokens_w$} tokens {}{}{RESET}", m.model, format_num_u64(total), - cost, + cost_str, cache, + model_w = max_model_w, + tokens_w = max_tokens_w, ); } } From 5021668fe57cd972f814602dc4666127b5c6979f Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 18:40:26 -0400 Subject: [PATCH 078/100] perf: compute_repo_summaries fetches all events in a single DB query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously each repo triggered a separate SQL scan via compute_activity, giving O(n × repos) fetches. Now one fetch covers all repos and grouping is done in memory via compute_activity_from_records. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/local_stats.rs | 64 ++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index a71b70ec9a..8bb5ce9519 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -149,15 +149,6 @@ fn fetch_local_events( db_lock.get_local_events(since_ts, repo_filter) } -/// Acquire the global DB lock and return all distinct repo URLs since `since_ts`. -fn fetch_distinct_repo_urls(since_ts: u32) -> Result, GitAiError> { - let db = MetricsDatabase::global()?; - let db_lock = db - .lock() - .map_err(|_| GitAiError::Generic("metrics DB lock poisoned".to_string()))?; - db_lock.get_distinct_repo_urls(since_ts) -} - /// Aggregate local_events since `since_ts` (Unix seconds) into activity stats. /// /// When `repo_filter` is `Some(url)`, only events from that repository are @@ -169,6 +160,20 @@ pub fn compute_activity( repo_filter: Option<&str>, ) -> Result { let records = fetch_local_events(since_ts, repo_filter)?; + let refs: Vec<&LocalEventRecord> = records.iter().collect(); + compute_activity_from_records(&refs, since_ts, period_label, granularity) +} + +/// Aggregate a pre-fetched slice of `LocalEventRecord`s into activity stats. +/// +/// Separated from `compute_activity` so callers that already hold all events +/// (e.g. `compute_repo_summaries`) can avoid re-fetching from the DB per repo. +fn compute_activity_from_records( + records: &[&LocalEventRecord], + since_ts: u32, + period_label: String, + granularity: BucketGranularity, +) -> Result { let mut total_commits = 0u32; let mut total_ai_lines = 0u32; @@ -211,7 +216,7 @@ pub fn compute_activity( let mut session_last_ts: HashMap = HashMap::new(); let mut commit_timestamps: Vec = Vec::new(); - for record in &records { + for record in records { let event: MetricEvent = match serde_json::from_str(&record.event_json) { Ok(e) => e, Err(_) => continue, @@ -1325,33 +1330,32 @@ pub struct RepoActivitySummary { /// Compute a per-repository breakdown for the given time window. /// -/// Queries the DB for distinct repo_urls and computes lightweight stats for -/// each one. Sorted by `ai_lines` descending. +/// Fetches all matching events in a single DB query, groups them in memory by +/// `repo_url`, and aggregates each group — O(n) instead of O(n × repos). +/// Sorted by `ai_lines` descending. pub fn compute_repo_summaries( since_ts: u32, granularity: BucketGranularity, repo_filter: Option<&str>, ) -> Result, GitAiError> { - let repo_urls = fetch_distinct_repo_urls(since_ts)?; - - let mut summaries: Vec = repo_urls - .iter() - // When a filter is active, only include URLs that contain the substring. - // `repo_filter` is the raw user input (https:// already stripped, no LIKE - // escaping applied), so `.contains()` is the correct literal-match - // counterpart to the LIKE '%…%' ESCAPE '\' used in get_local_events. - .filter(|url| repo_filter.map_or(true, |f| url.contains(f))) - .filter_map(|url| { - let stats = compute_activity( - since_ts, - String::new(), // period_label not used here - granularity, - Some(url.as_str()), - ) - .ok()?; + // One fetch: apply the user's substring filter in SQL so we don't pull + // irrelevant repos, then group the results in memory. + let all_records = fetch_local_events(since_ts, repo_filter)?; + + // Group records by repo_url. None -> events with no repo attached. + let mut by_repo: HashMap, Vec<&LocalEventRecord>> = HashMap::new(); + for record in &all_records { + by_repo.entry(record.repo_url.clone()).or_default().push(record); + } + let mut summaries: Vec = by_repo + .into_iter() + .filter_map(|(url, records)| { + let stats = + compute_activity_from_records(&records, since_ts, String::new(), granularity) + .ok()?; Some(RepoActivitySummary { - repo_url: url.clone(), + repo_url: url.unwrap_or_default(), ai_lines: stats.commits.ai_lines, commits: stats.commits.total, sessions: stats.sessions.total, From a35ceb20c7c8d3bf90bb96a9cc45c2abb36dea88 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 18:48:13 -0400 Subject: [PATCH 079/100] refactor: remove dead code and eliminate double event iteration - Remove get_distinct_repo_urls, is_backfilled, mark_backfilled, and get_existing_commit_shas -- all had no callers - Fix days_ago() u32 truncation: clamp before casting so values above u32::MAX do not silently wrap - Inline store_local_events into flush_metrics chunk loop so events are walked once; interesting events are serialized once per flush instead of in a separate pre-pass Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 3 +- src/daemon/telemetry_worker.rs | 57 ++++++++++++++---------------- src/metrics/db.rs | 63 ---------------------------------- 3 files changed, 27 insertions(+), 96 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index c433046b5e..9b1ab762ac 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -132,7 +132,8 @@ fn days_ago(days: u64) -> u32 { .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); - now.saturating_sub(days * 24 * 3600) as u32 + now.saturating_sub(days * 24 * 3600) + .min(u32::MAX as u64) as u32 } fn print_help() { diff --git a/src/daemon/telemetry_worker.rs b/src/daemon/telemetry_worker.rs index ff8abf0ece..22243ecf50 100644 --- a/src/daemon/telemetry_worker.rs +++ b/src/daemon/telemetry_worker.rs @@ -318,8 +318,12 @@ fn flush_telemetry_batch(batch: TelemetryBuffer) { } fn flush_metrics(events: &[MetricEvent]) { - // Persist interesting events to local_events before attempting upload. - store_local_events(events); + // Event types useful for local activity stats (git-ai usage). + const INTERESTING: &[u16] = &[ + 1, // Committed + 4, // Checkpoint + 5, // SessionEvent + ]; let context = ApiContext::new(None); let api_base_url = context.base_url.clone(); @@ -331,7 +335,18 @@ fn flush_metrics(events: &[MetricEvent]) { let mut upload_failed = false; let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + // local_event tuples collected across all chunks, inserted once at the end. + let mut local_tuples: Vec<(u16, u32, Option, String)> = Vec::new(); + for chunk in events.chunks(MAX_METRICS_PER_ENVELOPE) { + // Collect interesting events from this chunk in the same pass as upload. + for e in chunk.iter().filter(|e| INTERESTING.contains(&e.event_id)) { + if let Ok(json) = serde_json::to_string(e) { + let repo_url = sparse_get_string(&e.attrs, attr_pos::REPO_URL).flatten(); + local_tuples.push((e.event_id, e.timestamp, repo_url, json)); + } + } + if should_upload && !upload_failed && std::time::Instant::now() < deadline { let batch = MetricsBatch::new(chunk.to_vec()); if client.upload_metrics(&batch).is_ok() { @@ -341,6 +356,14 @@ fn flush_metrics(events: &[MetricEvent]) { } store_metrics_in_db(chunk); } + + if !local_tuples.is_empty() { + if let Ok(db) = MetricsDatabase::global() + && let Ok(mut db_lock) = db.lock() + { + let _ = db_lock.insert_local_events(&local_tuples); + } + } } fn store_metrics_in_db(events: &[MetricEvent]) { @@ -364,36 +387,6 @@ fn store_metrics_in_db(events: &[MetricEvent]) { } } -fn store_local_events(events: &[MetricEvent]) { - // Only persist event types that are useful for local activity stats. - const INTERESTING: &[u16] = &[ - 1, // Committed - 4, // Checkpoint - 5, // SessionEvent - ]; - - let tuples: Vec<(u16, u32, Option, String)> = events - .iter() - .filter(|e| INTERESTING.contains(&e.event_id)) - .filter_map(|e| { - let repo_url = sparse_get_string(&e.attrs, attr_pos::REPO_URL).flatten(); - serde_json::to_string(e) - .ok() - .map(|json| (e.event_id, e.timestamp, repo_url, json)) - }) - .collect(); - - if tuples.is_empty() { - return; - } - - if let Ok(db) = MetricsDatabase::global() - && let Ok(mut db_lock) = db.lock() - { - let _ = db_lock.insert_local_events(&tuples); - } -} - fn flush_sentry_and_posthog( config: &Config, distinct_id: &str, diff --git a/src/metrics/db.rs b/src/metrics/db.rs index 3154908475..d63dd92554 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -5,7 +5,6 @@ use crate::error::GitAiError; use rusqlite::{Connection, OptionalExtension, params}; -use std::collections::HashSet; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; @@ -375,68 +374,6 @@ impl MetricsDatabase { Ok(records) } - /// Return the distinct repo_urls that have events since `since_ts`, sorted alphabetically. - /// An empty string `""` sentinel is appended last when any NULL repo_url entries exist. - pub fn get_distinct_repo_urls(&self, since_ts: u32) -> Result, GitAiError> { - let mut stmt = self.conn.prepare( - "SELECT DISTINCT repo_url FROM local_events \ - WHERE ts >= ?1 AND repo_url IS NOT NULL \ - ORDER BY repo_url ASC", - )?; - let rows = stmt.query_map(params![since_ts as i64], |row| row.get::<_, String>(0))?; - let mut urls: Vec = rows.collect::, _>>()?; - - let has_null: bool = self.conn.query_row( - "SELECT EXISTS(SELECT 1 FROM local_events WHERE ts >= ?1 AND repo_url IS NULL)", - params![since_ts as i64], - |row| row.get(0), - )?; - if has_null { - urls.push(String::new()); - } - Ok(urls) - } - - // ─── Notes backfill helpers ─────────────────────────────────────────────── - - /// Check whether a git-notes backfill has already been completed for `repo_url`. - pub fn is_backfilled(&self, repo_url: &str) -> Result { - let key = format!("backfill:{}", repo_url); - let result: Option = self - .conn - .query_row( - "SELECT value FROM schema_metadata WHERE key = ?1", - params![key], - |row| row.get(0), - ) - .optional()?; - Ok(result.is_some()) - } - - /// Record that a git-notes backfill has been completed for `repo_url`. - pub fn mark_backfilled(&mut self, repo_url: &str) -> Result<(), GitAiError> { - let key = format!("backfill:{}", repo_url); - self.conn.execute( - "INSERT OR REPLACE INTO schema_metadata (key, value) VALUES (?1, '1')", - params![key], - )?; - Ok(()) - } - - /// Return the set of commit SHAs already present in `local_events` for - /// event_id = 1 (Committed). Used by the backfill to avoid duplicates. - pub fn get_existing_commit_shas(&self) -> Result, GitAiError> { - let mut stmt = self.conn.prepare( - "SELECT json_extract(event_json, '$.a.3') FROM local_events \ - WHERE event_id = 1 AND json_extract(event_json, '$.a.3') IS NOT NULL", - )?; - let shas: HashSet = stmt - .query_map([], |row| row.get::<_, String>(0))? - .filter_map(|r| r.ok()) - .collect(); - Ok(shas) - } - /// Returns whether an `agent_usage` event should be emitted for this prompt_id. /// /// If emitted, this method also updates the prompt's last-sent timestamp. From 2c9599de99613d9c4143f2dd65bf9e58bafc7f08 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 18:51:28 -0400 Subject: [PATCH 080/100] feat: prune local_events rows older than 30 days Adds opportunistic pruning to insert_local_events: at most once per day a DELETE removes rows with ts older than 30 days, keeping the table bounded to roughly the window used by git-ai usage (max 60d period). Last-prune timestamp is persisted in schema_metadata. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/db.rs | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/metrics/db.rs b/src/metrics/db.rs index d63dd92554..08f76328ca 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -286,10 +286,16 @@ impl MetricsDatabase { Ok(count as usize) } - /// Insert events into the local_events table (persistent, never deleted). + /// How long local_events rows are retained (30 days in seconds). + const LOCAL_EVENTS_RETENTION_SECS: u64 = 30 * 24 * 3600; + /// Minimum interval between prune passes (24 hours in seconds). + const LOCAL_EVENTS_PRUNE_INTERVAL_SECS: u64 = 24 * 3600; + + /// Insert events into the local_events table. /// /// Each tuple is (event_id, ts, repo_url, event_json). Call this with events /// filtered to only the interesting event types before inserting. + /// Opportunistically prunes rows older than 30 days at most once per day. pub fn insert_local_events( &mut self, events: &[(u16, u32, Option, String)], @@ -316,6 +322,44 @@ impl MetricsDatabase { } tx.commit()?; + self.prune_local_events_if_due()?; + Ok(()) + } + + /// Delete local_events rows older than `LOCAL_EVENTS_RETENTION_SECS`, but + /// only if the last prune was more than `LOCAL_EVENTS_PRUNE_INTERVAL_SECS` ago. + fn prune_local_events_if_due(&mut self) -> Result<(), GitAiError> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let last_prune: Option = self + .conn + .query_row( + "SELECT value FROM schema_metadata WHERE key = 'local_events_last_prune_ts'", + [], + |row| row.get(0), + ) + .optional()? + .and_then(|v: String| v.parse().ok()); + + if let Some(last) = last_prune { + if now.saturating_sub(last as u64) < Self::LOCAL_EVENTS_PRUNE_INTERVAL_SECS { + return Ok(()); + } + } + + let cutoff = now.saturating_sub(Self::LOCAL_EVENTS_RETENTION_SECS); + self.conn.execute( + "DELETE FROM local_events WHERE ts < ?1", + params![cutoff as i64], + )?; + self.conn.execute( + "INSERT OR REPLACE INTO schema_metadata (key, value) VALUES ('local_events_last_prune_ts', ?1)", + params![now.to_string()], + )?; + Ok(()) } From a09466380b4440535d52ef437629b32ae3599b3a Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 18:53:28 -0400 Subject: [PATCH 081/100] fix: drop 60d and all periods; local_events only retains 30 days Co-Authored-By: Claude Sonnet 4.6 --- src/commands/activity.rs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 9b1ab762ac..1394d8f82c 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -60,15 +60,9 @@ pub fn handle_activity(args: &[String]) { "last 30 days".to_string(), BucketGranularity::Weekly, ), - "60d" => ( - days_ago(60), - "last 60 days".to_string(), - BucketGranularity::Weekly, - ), - "all" => (0u32, "all time".to_string(), BucketGranularity::Monthly), other => { eprintln!( - "Unknown period '{}'. Use 1d, 3d, 7d, 30d, 60d, or all.", + "Unknown period '{}'. Use 1d, 3d, 7d, or 30d.", other ); std::process::exit(1); @@ -107,7 +101,7 @@ pub fn handle_activity(args: &[String]) { "No data found for '{}' in the {} window.", filter, stats.period_label ); - eprintln!("Try a broader period (--period all) or a different substring."); + eprintln!("Try --period 30d or a different substring."); } else { eprintln!("No activity data found for the {} window.", stats.period_label); } @@ -142,13 +136,13 @@ fn print_help() { eprintln!("Usage: git-ai usage [options]"); eprintln!(); eprintln!("Options:"); - eprintln!(" --period <1d|3d|7d|30d|60d|all> Time window (default: 30d)"); + eprintln!(" --period <1d|3d|7d|30d> Time window (default: 30d)"); eprintln!(" --repo Filter to a repository (substring match, https:// optional)"); eprintln!(" --json Output as JSON"); eprintln!(" --help Show this help"); eprintln!(); eprintln!("Statistics are sourced from locally recorded metric events."); - eprintln!("Events accumulate over time and are never deleted from local storage."); + eprintln!("Events older than 30 days are pruned automatically."); } fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], repo_filter: Option<&str>) { From e1759d28f89d4a765739938f9eb24bbba70b6078 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 18:59:23 -0400 Subject: [PATCH 082/100] fix: exclude NULL repo_url events from per-repo summaries Events predating the repo_url column (migration 2->3) have no repo identity and were mapping to repo_url="", inflating repos.len() and incorrectly triggering the multi-repo display path for single-repo users. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/local_stats.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 8bb5ce9519..99475f3eae 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -1342,10 +1342,13 @@ pub fn compute_repo_summaries( // irrelevant repos, then group the results in memory. let all_records = fetch_local_events(since_ts, repo_filter)?; - // Group records by repo_url. None -> events with no repo attached. - let mut by_repo: HashMap, Vec<&LocalEventRecord>> = HashMap::new(); + // Group records by repo_url, skipping events with no repo (NULL) — these + // predate the repo_url column and have no meaningful identity to display. + let mut by_repo: HashMap> = HashMap::new(); for record in &all_records { - by_repo.entry(record.repo_url.clone()).or_default().push(record); + if let Some(ref url) = record.repo_url { + by_repo.entry(url.clone()).or_default().push(record); + } } let mut summaries: Vec = by_repo @@ -1355,7 +1358,7 @@ pub fn compute_repo_summaries( compute_activity_from_records(&records, since_ts, String::new(), granularity) .ok()?; Some(RepoActivitySummary { - repo_url: url.unwrap_or_default(), + repo_url: url, ai_lines: stats.commits.ai_lines, commits: stats.commits.total, sessions: stats.sessions.total, From 7bf2cd78d2806987e4c2fbe97dfe866e58591fb1 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 18:59:47 -0400 Subject: [PATCH 083/100] fix: correct --period help text in git-ai usage (remove all/60d, add 1d/3d) Co-Authored-By: Claude Sonnet 4.6 --- src/commands/git_ai_handlers.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index 1a4efb2cb3..56206988a5 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -340,7 +340,7 @@ fn print_help() { eprintln!(" stats [commit] Show AI authorship statistics for a commit"); eprintln!(" --json Output in JSON format"); eprintln!(" usage Show local AI usage statistics"); - eprintln!(" --period <7d|30d|all> Time window (default: 30d)"); + eprintln!(" --period <1d|3d|7d|30d> Time window (default: 30d)"); eprintln!(" --json Output in JSON format"); eprintln!(" status Show uncommitted AI authorship status (debug)"); eprintln!(" --json Output in JSON format"); From 1301cedc96a4249a7eefd1e7409eb9ac505cbbb8 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 19:00:09 -0400 Subject: [PATCH 084/100] fix: write prune timestamp before DELETE in a single transaction Previously a failed DELETE left the timestamp unwritten, causing prune_local_events_if_due to retry on every subsequent flush. Wrapping both writes in a transaction makes them atomic: either both succeed or neither does, so a failure advances the timestamp and avoids a retry loop. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/db.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/metrics/db.rs b/src/metrics/db.rs index 08f76328ca..5b09d056da 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -351,14 +351,16 @@ impl MetricsDatabase { } let cutoff = now.saturating_sub(Self::LOCAL_EVENTS_RETENTION_SECS); - self.conn.execute( - "DELETE FROM local_events WHERE ts < ?1", - params![cutoff as i64], - )?; - self.conn.execute( + let tx = self.conn.transaction()?; + tx.execute( "INSERT OR REPLACE INTO schema_metadata (key, value) VALUES ('local_events_last_prune_ts', ?1)", params![now.to_string()], )?; + tx.execute( + "DELETE FROM local_events WHERE ts < ?1", + params![cutoff as i64], + )?; + tx.commit()?; Ok(()) } From bd1622f112dcd38a5d8a9cb77fd2c2edb189b89d Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 19:00:31 -0400 Subject: [PATCH 085/100] fix: use u64 arithmetic in acceptance rate to prevent u32 overflow committed * 100 could overflow u32 (wraps in release, panics in debug) for tools with >42.9M committed AI lines. Widening to u64 before multiplication eliminates the risk. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/local_stats.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 99475f3eae..91f08c4261 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -318,7 +318,8 @@ fn compute_activity_from_records( .iter() .filter_map(|(tool, &committed)| { let checkpoint = *checkpoint_ai_by_tool.get(tool)?; - let pct = (committed * 100).checked_div(checkpoint)?; + let pct = (committed as u64 * 100) + .checked_div(checkpoint as u64)? as u32; Some((tool.clone(), pct)) }) .collect(); From 187b3cd1db90768ce935f4031d61e36fa536565f Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 19:02:35 -0400 Subject: [PATCH 086/100] refactor: remove dead compute_session_list and its supporting code SessionRecord, compute_session_list, extract_claude_user_text, extract_codex_user_text, and normalize_title had no call sites. Removing them eliminates ~300 lines and a diverging copy of the token-accumulation and yield-classification logic. Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/local_stats.rs | 299 ------------------------------------- 1 file changed, 299 deletions(-) diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 91f08c4261..c73c7f3de8 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -1017,305 +1017,6 @@ fn aggregate_codex_tokens( } } -// ─── Per-session list ───────────────────────────────────────────────────────── - -/// A single session's summary for the Sessions tab. -#[derive(Debug)] -pub struct SessionRecord { - pub session_id: String, - /// Unix timestamp of the first event observed for this session. - pub first_ts: u32, - /// Tool / agent name (e.g. "claude", "cursor", "codex"). - pub tool: String, - /// Dominant model used, if known. - pub model: Option, - /// First user-visible prompt / task, extracted from the transcript. - /// `None` when the relevant event was not stored or not parseable. - pub title: Option, - /// Total tokens (input + output + cache_read + cache_creation). - pub total_tokens: u64, - /// Estimated cost in USD; `None` when pricing data is unavailable. - pub estimated_cost_usd: Option, - /// Whether a commit landed within 4 h of the session's last event. - pub shipped: bool, - /// Approximate AI lines committed during or within 4 h after this session. - /// Approximate because a commit may span code from multiple sessions. - pub ai_lines_committed: u32, -} - -/// Build a per-session list from raw events. Default order: newest first. -pub fn compute_session_list( - since_ts: u32, - repo_filter: Option<&str>, -) -> Result, GitAiError> { - let events = fetch_local_events(since_ts, repo_filter)?; - - let mut session_first_ts: HashMap = HashMap::new(); - let mut session_last_ts: HashMap = HashMap::new(); - let mut session_tool: HashMap = HashMap::new(); - let mut session_title: HashMap = HashMap::new(); - // repo_url recorded on the first event seen for each session. - let mut session_repo: HashMap> = HashMap::new(); - // sid -> mid -> (model, accum) for Claude-style per-message token data. - let mut session_messages: HashMap> = - HashMap::new(); - let mut codex_sessions: HashMap = HashMap::new(); - // (timestamp, ai_lines, repo_url) — sorted after the loop for binary-search per session. - let mut commit_data: Vec<(u32, u32, Option)> = Vec::new(); - - for record in &events { - let event: MetricEvent = match serde_json::from_str(&record.event_json) { - Ok(e) => e, - Err(_) => continue, - }; - - match record.event_id { - 1 => { - let ai_lines = sparse_get_vec_u32(&event.values, committed_pos::AI_ADDITIONS) - .flatten() - .unwrap_or_default() - .first() - .copied() - .unwrap_or(0); - commit_data.push((record.ts, ai_lines, record.repo_url.clone())); - } - 5 => { - let Some(sid) = sparse_get_string(&event.attrs, attr_pos::SESSION_ID).flatten() - else { - continue; - }; - let tool = sparse_get_string(&event.attrs, attr_pos::TOOL) - .flatten() - .unwrap_or_else(|| "unknown".to_string()); - - let first = session_first_ts.entry(sid.clone()).or_insert(record.ts); - *first = (*first).min(record.ts); - let last = session_last_ts.entry(sid.clone()).or_insert(0); - *last = (*last).max(record.ts); - session_tool.entry(sid.clone()).or_insert(tool.clone()); - session_repo - .entry(sid.clone()) - .or_insert(record.repo_url.clone()); - - let Some(raw) = event.values.get(&session_event_pos::RAW_JSON.to_string()) else { - continue; - }; - let raw_type = raw.get("type").and_then(|t| t.as_str()).unwrap_or(""); - - if tool == "codex" { - // Title: first response_item with a user role and non-system text. - if raw_type == "response_item" - && !session_title.contains_key(&sid) - && let Some(payload) = raw.get("payload") - && payload.get("role").and_then(|r| r.as_str()) == Some("user") - && let Some(text) = extract_codex_user_text(payload.get("content")) - { - session_title.insert(sid.clone(), text); - } - aggregate_codex_tokens(&event, record.ts, &mut codex_sessions); - } else { - // Title: first user message with real text content. - if raw_type == "user" - && !session_title.contains_key(&sid) - && let Some(msg) = raw.get("message") - && let Some(text) = extract_claude_user_text(msg) - { - session_title.insert(sid.clone(), text); - } - - let Some(message) = raw.get("message") else { - continue; - }; - if message.get("role").and_then(|r| r.as_str()) != Some("assistant") { - continue; - } - let (Some(usage), Some(id)) = ( - message.get("usage"), - message.get("id").and_then(|i| i.as_str()), - ) else { - continue; - }; - let model = message - .get("model") - .and_then(|m| m.as_str()) - .unwrap_or("unknown") - .to_string(); - let get = |key: &str| usage.get(key).and_then(|v| v.as_u64()).unwrap_or(0); - let msgs = session_messages.entry(sid).or_default(); - let (stored_model, acc) = msgs - .entry(id.to_string()) - .or_insert_with(|| (model.clone(), TokenAccum::default())); - // Upgrade placeholder model name if a real one arrives later - // (same pattern as aggregate_session_tokens). - if stored_model == "unknown" && model != "unknown" { - *stored_model = model; - } - acc.input = acc.input.max(get("input_tokens")); - acc.output = acc.output.max(get("output_tokens")); - acc.cache_read = acc.cache_read.max(get("cache_read_input_tokens")); - acc.cache_creation = acc.cache_creation.max(get("cache_creation_input_tokens")); - } - } - _ => {} - } - } - - commit_data.sort_unstable_by_key(|&(ts, _, _)| ts); - - - let mut out: Vec = session_first_ts - .iter() - .map(|(sid, &first_ts)| { - let last_ts = session_last_ts.get(sid).copied().unwrap_or(first_ts); - let tool = session_tool - .get(sid) - .cloned() - .unwrap_or_else(|| "unknown".to_string()); - let sess_repo = session_repo.get(sid).and_then(|r| r.as_deref()); - - let window_end = last_ts.saturating_add(YIELD_WINDOW_SECS); - let pos = commit_data.partition_point(|&(t, _, _)| t < last_ts); - // Only count a commit as "shipped" if it's in the same repo as the - // session (or either side has no repo data, as a fallback). - let shipped = commit_data[pos..] - .iter() - .take_while(|&&(t, _, _)| t <= window_end) - .any( - |(_, _, commit_repo)| match (sess_repo, commit_repo.as_deref()) { - (Some(s), Some(c)) => s == c, - _ => true, - }, - ); - - // Sum ai_lines from same-repo commits in [first_ts, last_ts + 4h]. - let lo = commit_data.partition_point(|&(t, _, _)| t < first_ts); - let hi = commit_data.partition_point(|&(t, _, _)| t <= window_end); - let ai_lines_committed: u32 = commit_data[lo..hi] - .iter() - .filter( - |(_, _, commit_repo)| match (sess_repo, commit_repo.as_deref()) { - (Some(s), Some(c)) => s == c, - _ => true, - }, - ) - .map(|&(_, l, _)| l) - .sum(); - - let (model, total_tokens, estimated_cost_usd) = if tool == "codex" { - if let Some(acc) = codex_sessions.get(sid) { - let mapped = acc.to_token_accum(); - let total = - mapped.input + mapped.output + mapped.cache_read + mapped.cache_creation; - let cost = acc - .model - .as_deref() - .and_then(pricing_for) - .map(|p| estimate_cost(&mapped, &p)); - (acc.model.clone(), total, cost) - } else { - (None, 0, None) - } - } else { - match session_messages.get(sid) { - None => (None, 0, None), - Some(msgs) => { - let mut total = TokenAccum::default(); - let mut model_tokens: HashMap = HashMap::new(); - for (model, acc) in msgs.values() { - total.input += acc.input; - total.output += acc.output; - total.cache_read += acc.cache_read; - total.cache_creation += acc.cache_creation; - *model_tokens.entry(model.clone()).or_insert(0) += - acc.input + acc.output; - } - let tokens = - total.input + total.output + total.cache_read + total.cache_creation; - let dominant = model_tokens - .into_iter() - .max_by_key(|(_, v)| *v) - .map(|(m, _)| m); - let cost = dominant - .as_deref() - .and_then(pricing_for) - .map(|p| estimate_cost(&total, &p)); - (dominant, tokens, cost) - } - } - }; - - SessionRecord { - session_id: sid.clone(), - first_ts, - tool, - model, - title: session_title.get(sid).cloned(), - total_tokens, - estimated_cost_usd, - shipped, - ai_lines_committed, - } - }) - .collect(); - - out.sort_by_key(|r| Reverse(r.first_ts)); - Ok(out) -} - -/// Extract the first meaningful text from a Claude user message content array. -/// Returns `None` if the only text blocks are XML system messages. -fn extract_claude_user_text(message: &serde_json::Value) -> Option { - let content = message.get("content")?; - let text = match content { - serde_json::Value::Array(blocks) => blocks.iter().find_map(|b| { - if b.get("type").and_then(|t| t.as_str()) == Some("text") { - b.get("text") - .and_then(|t| t.as_str()) - .map(|s| s.to_string()) - } else { - None - } - }), - serde_json::Value::String(s) => Some(s.clone()), - _ => None, - }?; - if text.starts_with('<') { - return None; - } - Some(normalize_title(&text)) -} - -/// Extract the first meaningful text from a Codex `response_item` payload content. -/// Skips system preamble blocks (AGENTS.md instructions, environment context XML). -fn extract_codex_user_text(content: Option<&serde_json::Value>) -> Option { - let blocks = content?.as_array()?; - for block in blocks { - if block.get("type").and_then(|t| t.as_str()) == Some("input_text") { - let text = block.get("text").and_then(|t| t.as_str())?; - if !text.starts_with('#') && !text.starts_with('<') { - return Some(normalize_title(text)); - } - } - } - None -} - -/// Collapse whitespace and truncate a title string to at most 120 chars. -fn normalize_title(s: &str) -> String { - let single_line: String = s - .lines() - .map(str::trim) - .filter(|l| !l.is_empty()) - .collect::>() - .join(" · "); - if single_line.chars().count() > 120 { - let truncated: String = single_line.chars().take(117).collect(); - format!("{}…", truncated) - } else { - single_line - } -} - // ─── Per-repository breakdown ───────────────────────────────────────────────── /// Summary of activity for a single repository. From 5e61482d9677f86549b0ee167bcc241dbeb55797 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 19:06:44 -0400 Subject: [PATCH 087/100] refactor: rename activity module and handle_activity to usage/handle_usage Co-Authored-By: Claude Sonnet 4.6 --- src/commands/git_ai_handlers.rs | 2 +- src/commands/mod.rs | 2 +- src/commands/{activity.rs => usage.rs} | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/commands/{activity.rs => usage.rs} (99%) diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index 56206988a5..cd03f3f9f3 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -110,7 +110,7 @@ pub fn handle_git_ai(args: &[String]) { handle_stats(&args[1..]); } "usage" => { - commands::activity::handle_activity(&args[1..]); + commands::usage::handle_usage(&args[1..]); } "status" => { commands::status::handle_status(&args[1..]); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a8b70767e5..d32f37366c 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,4 @@ -pub mod activity; +pub mod usage; pub mod blame; pub mod checkpoint_agent; pub mod ci_handlers; diff --git a/src/commands/activity.rs b/src/commands/usage.rs similarity index 99% rename from src/commands/activity.rs rename to src/commands/usage.rs index 1394d8f82c..43a3c21f53 100644 --- a/src/commands/activity.rs +++ b/src/commands/usage.rs @@ -7,7 +7,7 @@ use crate::metrics::local_stats::{ use std::collections::HashSet; use std::time::{SystemTime, UNIX_EPOCH}; -pub fn handle_activity(args: &[String]) { +pub fn handle_usage(args: &[String]) { let mut json = false; let mut period = "30d".to_string(); let mut repo_filter: Option = None; From 24e673d10c9a1d9fdd15ab49c7394a1c99329dc3 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 19:08:19 -0400 Subject: [PATCH 088/100] fix: update remaining git-ai activity references to git-ai usage Co-Authored-By: Claude Sonnet 4.6 --- src/metrics/db.rs | 2 +- src/metrics/local_stats.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/metrics/db.rs b/src/metrics/db.rs index 5b09d056da..94692bea05 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -27,7 +27,7 @@ const MIGRATIONS: &[&str] = &[ last_sent_ts INTEGER NOT NULL ); "#, - // Migration 2 -> 3: Persistent local event history for `git-ai activity` + // Migration 2 -> 3: Persistent local event history for `git-ai usage` r#" CREATE TABLE IF NOT EXISTS local_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index c73c7f3de8..3fe9da42bf 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -1,4 +1,4 @@ -//! In-memory aggregation of local_events for `git-ai activity`. +//! In-memory aggregation of local_events for `git-ai usage`. /// How long after a session's last message a subsequent commit is attributed /// to that session for yield and ai_lines_committed calculations. From acc58335993f0fb49318ab79f6131b1f942a9ee0 Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 19:19:09 -0400 Subject: [PATCH 089/100] remove redundant label --- src/commands/usage.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/usage.rs b/src/commands/usage.rs index 43a3c21f53..1bf95fec06 100644 --- a/src/commands/usage.rs +++ b/src/commands/usage.rs @@ -259,7 +259,7 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep ); } println!( - " Commits (AI) {:>6}", + " Commits {:>6}", format_num(stats.commits.total) ); println!( From a5aa912336f47c960c946d9793761652b998969b Mon Sep 17 00:00:00 2001 From: Lincoln de Sousa Date: Sun, 24 May 2026 19:30:18 -0400 Subject: [PATCH 090/100] fix: correct four bugs found in pre-PR review of git-ai usage - Use u64 arithmetic for all percentage multiplications in usage.rs to prevent u32 overflow on large line counts (same fix pattern as the prior acceptance-rate fix) - Fix spark_char overflow: value*8 now computed in u64 before casting - Emit u32::MAX instead of silently dropping tools with no checkpoint events in the query window, so the ">100% (incomplete data)" signal is surfaced as intended - Consolidate the two separate DB fetches in handle_usage into a single compute_all() call so overall stats and per-repo breakdown always reflect the same event snapshot Co-Authored-By: Claude Sonnet 4.6 --- src/commands/mod.rs | 2 +- src/commands/usage.rs | 104 +++++++++++++++++++++------------ src/daemon/telemetry_worker.rs | 11 ++-- src/metrics/db.rs | 13 +++-- src/metrics/local_stats.rs | 87 +++++++++++++++++++-------- 5 files changed, 143 insertions(+), 74 deletions(-) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index d32f37366c..90bbd22ba6 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,3 @@ -pub mod usage; pub mod blame; pub mod checkpoint_agent; pub mod ci_handlers; @@ -24,4 +23,5 @@ pub mod show_prompt; pub mod squash_authorship; pub mod status; pub mod upgrade; +pub mod usage; pub mod whoami; diff --git a/src/commands/usage.rs b/src/commands/usage.rs index 1bf95fec06..088b62a4cf 100644 --- a/src/commands/usage.rs +++ b/src/commands/usage.rs @@ -1,8 +1,7 @@ //! `git-ai usage` — local statistics from persisted metric events. use crate::metrics::local_stats::{ - BucketGranularity, LocalActivityStats, RepoActivitySummary, compute_activity, - compute_repo_summaries, + BucketGranularity, LocalActivityStats, RepoActivitySummary, compute_all, }; use std::collections::HashSet; use std::time::{SystemTime, UNIX_EPOCH}; @@ -61,28 +60,21 @@ pub fn handle_usage(args: &[String]) { BucketGranularity::Weekly, ), other => { - eprintln!( - "Unknown period '{}'. Use 1d, 3d, 7d, or 30d.", - other - ); + eprintln!("Unknown period '{}'. Use 1d, 3d, 7d, or 30d.", other); std::process::exit(1); } }; - let stats = match compute_activity(since_ts, period_label, granularity, repo_filter.as_deref()) { - Ok(s) => s, - Err(e) => { - eprintln!("error: {}", e); - std::process::exit(1); - } - }; - - // Compute per-repo breakdown. When a filter is active this still runs so - // we can surface how many repos matched (and who they are). The breakdown - // is only rendered when more than one repo is present — a single-row table - // adds nothing. - let repos = compute_repo_summaries(since_ts, granularity, repo_filter.as_deref()) - .unwrap_or_default(); + // Fetch events once and derive both views from the same snapshot so the + // per-repo breakdown totals are always consistent with the headline stats. + let (stats, repos) = + match compute_all(since_ts, period_label, granularity, repo_filter.as_deref()) { + Ok(pair) => pair, + Err(e) => { + eprintln!("error: {}", e); + std::process::exit(1); + } + }; // When filtering by repo, bail out early if nothing matched. // Include human_lines/diff_added_lines so human-only periods aren't @@ -94,7 +86,11 @@ pub fn handle_usage(args: &[String]) { && stats.sessions.total == 0 && stats.checkpoints.ai_lines_added == 0 && stats.checkpoints.human_lines_added == 0 - && stats.tokens.input + stats.tokens.output + stats.tokens.cache_read + stats.tokens.cache_creation == 0; + && stats.tokens.input + + stats.tokens.output + + stats.tokens.cache_read + + stats.tokens.cache_creation + == 0; if no_data { if let Some(ref filter) = repo_filter { eprintln!( @@ -103,7 +99,10 @@ pub fn handle_usage(args: &[String]) { ); eprintln!("Try --period 30d or a different substring."); } else { - eprintln!("No activity data found for the {} window.", stats.period_label); + eprintln!( + "No activity data found for the {} window.", + stats.period_label + ); } std::process::exit(1); } @@ -126,8 +125,7 @@ fn days_ago(days: u64) -> u32 { .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); - now.saturating_sub(days * 24 * 3600) - .min(u32::MAX as u64) as u32 + now.saturating_sub(days * 24 * 3600).min(u32::MAX as u64) as u32 } fn print_help() { @@ -137,7 +135,9 @@ fn print_help() { eprintln!(); eprintln!("Options:"); eprintln!(" --period <1d|3d|7d|30d> Time window (default: 30d)"); - eprintln!(" --repo Filter to a repository (substring match, https:// optional)"); + eprintln!( + " --repo Filter to a repository (substring match, https:// optional)" + ); eprintln!(" --json Output as JSON"); eprintln!(" --help Show this help"); eprintln!(); @@ -145,7 +145,11 @@ fn print_help() { eprintln!("Events older than 30 days are pruned automatically."); } -fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], repo_filter: Option<&str>) { +fn print_terminal( + stats: &LocalActivityStats, + repos: &[RepoActivitySummary], + repo_filter: Option<&str>, +) { const GRAY: &str = "\x1b[90m"; const BOLD: &str = "\x1b[1m"; const RESET: &str = "\x1b[0m"; @@ -181,7 +185,10 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep // --- Top bar: AI vs Human split --- println!(); let total_lines = stats.commits.ai_lines + stats.commits.human_lines; - if let Some(ai_pct) = (stats.commits.ai_lines * 100).checked_div(total_lines) { + if let Some(ai_pct) = (stats.commits.ai_lines as u64 * 100) + .checked_div(total_lines as u64) + .map(|p| p as u32) + { let human_pct = 100 - ai_pct; println!( " {} {BOLD}AI{RESET} {:>3}% · {BOLD}Human{RESET} {:>3}%", @@ -221,7 +228,11 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep let sessions_col = format!("{:>width$}", session_strs[i], width = max_sessions_w); // Pad singular labels to match the width of the plural so columns stay aligned. let commit_label = if r.commits == 1 { "commit " } else { "commits" }; - let session_label = if r.sessions == 1 { "session " } else { "sessions" }; + let session_label = if r.sessions == 1 { + "session " + } else { + "sessions" + }; let cost_str = if r.estimated_cost_usd > 0.0 { format!(" {GRAY}{}{RESET}", format_cost(r.estimated_cost_usd)) } else { @@ -286,8 +297,9 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep } else { println!(" Acceptance rate {GRAY}{min_r}–{max_r}%{RESET}"); } - } else if let Some(acceptance_pct) = - (stats.commits.ai_lines * 100).checked_div(stats.checkpoints.ai_lines_added) + } else if let Some(acceptance_pct) = (stats.commits.ai_lines as u64 * 100) + .checked_div(stats.checkpoints.ai_lines_added as u64) + .map(|p| p as u32) { if acceptance_pct <= 100 { println!(" Acceptance rate {:>5}%", acceptance_pct); @@ -300,7 +312,13 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep // don't repeat the same tool-level rate on every model variant line. let mut shown_accept: HashSet<&str> = HashSet::new(); // Pre-compute column widths for aligned tool breakdown. - let max_tool_w = stats.commits.by_tool.iter().map(|(t, _)| t.len()).max().unwrap_or(0); + let max_tool_w = stats + .commits + .by_tool + .iter() + .map(|(t, _)| t.len()) + .max() + .unwrap_or(0); let max_tool_count_w = stats .commits .by_tool @@ -409,7 +427,11 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep let max_cost_w = t .by_model .iter() - .map(|m| m.estimated_cost_usd.map(|c| format_cost(c).len()).unwrap_or(0)) + .map(|m| { + m.estimated_cost_usd + .map(|c| format_cost(c).len()) + .unwrap_or(0) + }) .max() .unwrap_or(0); for m in &t.by_model { @@ -449,8 +471,8 @@ fn print_terminal(stats: &LocalActivityStats, repos: &[RepoActivitySummary], rep let bar_str = ratio_bar(bucket.ai_lines, max_ai, BAR_WIDTH); if bucket.ai_lines > 0 { // Coverage for this bucket: attributed / total diff additions. - let coverage = (bucket.attributed_lines * 100) - .checked_div(bucket.diff_added_lines) + let coverage = (bucket.attributed_lines as u64 * 100) + .checked_div(bucket.diff_added_lines as u64) .map(|pct| format!(" · {}% attributed", pct)) .unwrap_or_default(); println!( @@ -528,7 +550,7 @@ fn spark_char(value: u32, max: u32) -> &'static str { if value == 0 { return "·"; } - let pct = value * 8 / max; + let pct = (value as u64 * 8 / max as u64) as u32; match pct { 0 => "▁", 1 => "▂", @@ -549,9 +571,17 @@ fn strip_protocol(url: &str) -> &str { /// Render a block bar where `value` out of `max` determines the fill ratio. fn ratio_bar(value: u32, max: u32, width: u32) -> String { - let filled = if max > 0 { (value * width / max).min(width) } else { 0 }; + let filled = if max > 0 { + (value * width / max).min(width) + } else { + 0 + }; let empty = width - filled; - format!("{}{}", "█".repeat(filled as usize), "░".repeat(empty as usize)) + format!( + "{}{}", + "█".repeat(filled as usize), + "░".repeat(empty as usize) + ) } fn bar(pct: u32, width: u32) -> String { diff --git a/src/daemon/telemetry_worker.rs b/src/daemon/telemetry_worker.rs index 22243ecf50..1a3ee178aa 100644 --- a/src/daemon/telemetry_worker.rs +++ b/src/daemon/telemetry_worker.rs @@ -357,12 +357,11 @@ fn flush_metrics(events: &[MetricEvent]) { store_metrics_in_db(chunk); } - if !local_tuples.is_empty() { - if let Ok(db) = MetricsDatabase::global() - && let Ok(mut db_lock) = db.lock() - { - let _ = db_lock.insert_local_events(&local_tuples); - } + if !local_tuples.is_empty() + && let Ok(db) = MetricsDatabase::global() + && let Ok(mut db_lock) = db.lock() + { + let _ = db_lock.insert_local_events(&local_tuples); } } diff --git a/src/metrics/db.rs b/src/metrics/db.rs index 94692bea05..c74c5ea194 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -344,10 +344,10 @@ impl MetricsDatabase { .optional()? .and_then(|v: String| v.parse().ok()); - if let Some(last) = last_prune { - if now.saturating_sub(last as u64) < Self::LOCAL_EVENTS_PRUNE_INTERVAL_SECS { - return Ok(()); - } + if let Some(last) = last_prune + && now.saturating_sub(last as u64) < Self::LOCAL_EVENTS_PRUNE_INTERVAL_SECS + { + return Ok(()); } let cutoff = now.saturating_sub(Self::LOCAL_EVENTS_RETENTION_SECS); @@ -398,7 +398,10 @@ impl MetricsDatabase { // Escape LIKE special characters so a user-supplied substring // like "my_org/my%repo" matches literally, not as wildcards. // We use '\' as the escape character. - let escaped = repo_url.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"); + let escaped = repo_url + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_"); let pattern = format!("%{}%", escaped); let mut stmt = self.conn.prepare( "SELECT event_id, ts, repo_url, event_json FROM local_events \ diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 3fe9da42bf..59bd378f15 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -174,7 +174,6 @@ fn compute_activity_from_records( period_label: String, granularity: BucketGranularity, ) -> Result { - let mut total_commits = 0u32; let mut total_ai_lines = 0u32; let mut total_human_lines = 0u32; @@ -312,15 +311,20 @@ fn compute_activity_from_records( } // Per-tool acceptance rate: committed AI lines / checkpoint AI lines. - // Values >100 indicate incomplete checkpoint data; we keep them so the - // caller can surface a meaningful signal rather than silently hiding it. + // Values >100 indicate incomplete checkpoint data (e.g. checkpoint events + // aged out of the window while committed events remain). u32::MAX is the + // sentinel for "no checkpoint events at all" — same display path as >100. let mut acceptance_by_tool: Vec<(String, u32)> = committed_ai_by_plain_tool .iter() - .filter_map(|(tool, &committed)| { - let checkpoint = *checkpoint_ai_by_tool.get(tool)?; - let pct = (committed as u64 * 100) - .checked_div(checkpoint as u64)? as u32; - Some((tool.clone(), pct)) + .map(|(tool, &committed)| { + let pct = match checkpoint_ai_by_tool.get(tool).copied() { + Some(checkpoint) if checkpoint > 0 => (committed as u64 * 100) + .checked_div(checkpoint as u64) + .map(|p| p as u32) + .unwrap_or(u32::MAX), + _ => u32::MAX, + }; + (tool.clone(), pct) }) .collect(); acceptance_by_tool.sort_by(|(a, _), (b, _)| a.cmp(b)); @@ -720,7 +724,10 @@ fn fill_buckets( let today = now.date_naive(); while day <= today { let order = day.num_days_from_ce() as i64; - result.push(make(bucket_label(day, granularity), data_map.remove(&order).unwrap_or_default())); + result.push(make( + bucket_label(day, granularity), + data_map.remove(&order).unwrap_or_default(), + )); day = day.succ_opt().unwrap_or(today); } } @@ -730,7 +737,10 @@ fn fill_buckets( let today = now.date_naive(); while monday <= today { let order = monday.num_days_from_ce() as i64; - result.push(make(bucket_label(monday, granularity), data_map.remove(&order).unwrap_or_default())); + result.push(make( + bucket_label(monday, granularity), + data_map.remove(&order).unwrap_or_default(), + )); monday = monday .checked_add_signed(chrono::Duration::weeks(1)) .unwrap_or(today); @@ -959,9 +969,15 @@ fn aggregate_session_tokens( let get = |key: &str| usage.get(key).and_then(|v| v.as_u64()).unwrap_or(0); - let (stored_model, acc, _ts, stored_sid) = message_usage - .entry(id.to_string()) - .or_insert_with(|| (model.clone(), TokenAccum::default(), record_ts, session_id.clone())); + let (stored_model, acc, _ts, stored_sid) = + message_usage.entry(id.to_string()).or_insert_with(|| { + ( + model.clone(), + TokenAccum::default(), + record_ts, + session_id.clone(), + ) + }); // If the entry was created with an "unknown" placeholder model (e.g. from a // streaming partial that arrived before the final event), upgrade it now. if stored_model == "unknown" && model != "unknown" { @@ -1030,24 +1046,16 @@ pub struct RepoActivitySummary { pub estimated_cost_usd: f64, } -/// Compute a per-repository breakdown for the given time window. -/// -/// Fetches all matching events in a single DB query, groups them in memory by -/// `repo_url`, and aggregates each group — O(n) instead of O(n × repos). -/// Sorted by `ai_lines` descending. -pub fn compute_repo_summaries( +/// Aggregate a pre-fetched slice of events into a per-repository breakdown. +fn repo_summaries_from_records( + all_records: &[LocalEventRecord], since_ts: u32, granularity: BucketGranularity, - repo_filter: Option<&str>, ) -> Result, GitAiError> { - // One fetch: apply the user's substring filter in SQL so we don't pull - // irrelevant repos, then group the results in memory. - let all_records = fetch_local_events(since_ts, repo_filter)?; - // Group records by repo_url, skipping events with no repo (NULL) — these // predate the repo_url column and have no meaningful identity to display. let mut by_repo: HashMap> = HashMap::new(); - for record in &all_records { + for record in all_records { if let Some(ref url) = record.repo_url { by_repo.entry(url.clone()).or_default().push(record); } @@ -1072,3 +1080,32 @@ pub fn compute_repo_summaries( summaries.sort_by_key(|s| std::cmp::Reverse(s.ai_lines)); Ok(summaries) } + +/// Fetch events once and compute overall activity stats and the per-repo +/// breakdown from the same snapshot, ensuring the two views are consistent. +pub fn compute_all( + since_ts: u32, + period_label: String, + granularity: BucketGranularity, + repo_filter: Option<&str>, +) -> Result<(LocalActivityStats, Vec), GitAiError> { + let records = fetch_local_events(since_ts, repo_filter)?; + let refs: Vec<&LocalEventRecord> = records.iter().collect(); + let stats = compute_activity_from_records(&refs, since_ts, period_label, granularity)?; + let repos = repo_summaries_from_records(&records, since_ts, granularity)?; + Ok((stats, repos)) +} + +/// Compute a per-repository breakdown for the given time window. +/// +/// Fetches all matching events in a single DB query, groups them in memory by +/// `repo_url`, and aggregates each group — O(n) instead of O(n × repos). +/// Sorted by `ai_lines` descending. +pub fn compute_repo_summaries( + since_ts: u32, + granularity: BucketGranularity, + repo_filter: Option<&str>, +) -> Result, GitAiError> { + let all_records = fetch_local_events(since_ts, repo_filter)?; + repo_summaries_from_records(&all_records, since_ts, granularity) +} From c1e60fcc66a379a774646867cc83cbc2a13f0b93 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Fri, 29 May 2026 14:19:43 +0000 Subject: [PATCH 091/100] =?UTF-8?q?fix:=20handle=20concurrent=20ALTER=20TA?= =?UTF-8?q?BLE=20in=20migration=203=E2=86=924?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If two processes race on the schema migration, the second ALTER TABLE attempt would fail with "duplicate column name". Catch that specific error so both processes proceed normally. Co-Authored-By: Claude Opus 4.6 --- src/metrics/db.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/metrics/db.rs b/src/metrics/db.rs index c74c5ea194..e194ec0ebf 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -211,7 +211,13 @@ impl MetricsDatabase { let migration_sql = MIGRATIONS[from_version]; let tx = self.conn.transaction()?; - tx.execute_batch(migration_sql)?; + match tx.execute_batch(migration_sql) { + Ok(()) => {} + Err(e) if e.to_string().contains("duplicate column name") => { + // Another process already applied this ALTER TABLE concurrently. + } + Err(e) => return Err(e.into()), + } tx.commit()?; Ok(()) From beb34c8ee81b9970bdd3f50e4ac5d09cc60c7b9d Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Fri, 5 Jun 2026 01:15:05 +0000 Subject: [PATCH 092/100] simplify metrics delivery tracking --- src/commands/flush_metrics_db.rs | 22 +- src/daemon/telemetry_worker.rs | 54 +++-- src/metrics/db.rs | 353 ++++++++++++++++--------------- src/metrics/local_stats.rs | 63 +++--- src/observability/mod.rs | 34 +++ 5 files changed, 291 insertions(+), 235 deletions(-) diff --git a/src/commands/flush_metrics_db.rs b/src/commands/flush_metrics_db.rs index 13fad65019..5e0b8316fd 100644 --- a/src/commands/flush_metrics_db.rs +++ b/src/commands/flush_metrics_db.rs @@ -1,6 +1,6 @@ //! Handle flush-metrics-db command (kept for manual human use). //! -//! Drains the metrics database queue by uploading batches to the API. +//! Uploads pending metrics database rows to the API. use crate::api::{ApiClient, ApiContext, upload_metrics_with_retry}; use crate::metrics::db::MetricsDatabase; @@ -69,9 +69,10 @@ pub fn handle_flush_metrics_db(_args: &[String]) { record_ids.push(record.id); } else { total_invalid += 1; - // Invalid JSON - delete the record + // Invalid JSON cannot upload successfully. Mark it delivered so + // future flushes can continue past the malformed historical row. if let Ok(mut db_lock) = db.lock() { - let _ = db_lock.delete_records(&[record.id]); + let _ = db_lock.mark_records_delivered(&[record.id], current_unix_ts()); } } } @@ -92,10 +93,10 @@ pub fn handle_flush_metrics_db(_args: &[String]) { " ✓ batch {} - uploaded {} events", total_batches, event_count ); - // Success - delete ALL records from this batch - // Validation errors are logged to Sentry and won't succeed on retry + // Success - keep rows as history and mark them delivered. + // Validation errors are logged to Sentry and won't succeed on retry. if let Ok(mut db_lock) = db.lock() { - let _ = db_lock.delete_records(&record_ids); + let _ = db_lock.mark_records_delivered(&record_ids, current_unix_ts()); } } Err(e) => { @@ -111,7 +112,7 @@ pub fn handle_flush_metrics_db(_args: &[String]) { if total_invalid > 0 { eprintln!( - "flush-metrics-db: discarded {} invalid record(s)", + "flush-metrics-db: marked {} invalid record(s) delivered", total_invalid ); } @@ -121,3 +122,10 @@ pub fn handle_flush_metrics_db(_args: &[String]) { total_uploaded, total_batches ); } + +fn current_unix_ts() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/src/daemon/telemetry_worker.rs b/src/daemon/telemetry_worker.rs index 1a3ee178aa..b8927f394c 100644 --- a/src/daemon/telemetry_worker.rs +++ b/src/daemon/telemetry_worker.rs @@ -6,9 +6,7 @@ use crate::api::{ApiClient, ApiContext, CasObject, CasUploadRequest}; use crate::config::{Config, get_or_create_distinct_id}; use crate::daemon::control_api::{CasSyncPayload, TelemetryEnvelope}; -use crate::metrics::attrs::attr_pos; use crate::metrics::db::MetricsDatabase; -use crate::metrics::pos_encoded::sparse_get_string; use crate::metrics::{MetricEvent, MetricsBatch}; use crate::observability::MAX_METRICS_PER_ENVELOPE; use serde_json::{Value, json}; @@ -318,13 +316,6 @@ fn flush_telemetry_batch(batch: TelemetryBuffer) { } fn flush_metrics(events: &[MetricEvent]) { - // Event types useful for local activity stats (git-ai usage). - const INTERESTING: &[u16] = &[ - 1, // Committed - 4, // Checkpoint - 5, // SessionEvent - ]; - let context = ApiContext::new(None); let api_base_url = context.base_url.clone(); let client = ApiClient::new(context); @@ -335,39 +326,23 @@ fn flush_metrics(events: &[MetricEvent]) { let mut upload_failed = false; let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); - // local_event tuples collected across all chunks, inserted once at the end. - let mut local_tuples: Vec<(u16, u32, Option, String)> = Vec::new(); - for chunk in events.chunks(MAX_METRICS_PER_ENVELOPE) { - // Collect interesting events from this chunk in the same pass as upload. - for e in chunk.iter().filter(|e| INTERESTING.contains(&e.event_id)) { - if let Ok(json) = serde_json::to_string(e) { - let repo_url = sparse_get_string(&e.attrs, attr_pos::REPO_URL).flatten(); - local_tuples.push((e.event_id, e.timestamp, repo_url, json)); - } - } + let record_ids = store_metrics_in_db(chunk); if should_upload && !upload_failed && std::time::Instant::now() < deadline { let batch = MetricsBatch::new(chunk.to_vec()); if client.upload_metrics(&batch).is_ok() { + mark_metrics_delivered(&record_ids); continue; } upload_failed = true; } - store_metrics_in_db(chunk); - } - - if !local_tuples.is_empty() - && let Ok(db) = MetricsDatabase::global() - && let Ok(mut db_lock) = db.lock() - { - let _ = db_lock.insert_local_events(&local_tuples); } } -fn store_metrics_in_db(events: &[MetricEvent]) { +fn store_metrics_in_db(events: &[MetricEvent]) -> Vec { if events.is_empty() { - return; + return Vec::new(); } let event_jsons: Vec = events @@ -376,13 +351,32 @@ fn store_metrics_in_db(events: &[MetricEvent]) { .collect(); if event_jsons.is_empty() { + return Vec::new(); + } + + if let Ok(db) = MetricsDatabase::global() + && let Ok(mut db_lock) = db.lock() + { + return db_lock.insert_events(&event_jsons).unwrap_or_default(); + } + + Vec::new() +} + +fn mark_metrics_delivered(ids: &[i64]) { + if ids.is_empty() { return; } + let delivered_ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + if let Ok(db) = MetricsDatabase::global() && let Ok(mut db_lock) = db.lock() { - let _ = db_lock.insert_events(&event_jsons); + let _ = db_lock.mark_records_delivered(ids, delivered_ts); } } diff --git a/src/metrics/db.rs b/src/metrics/db.rs index e194ec0ebf..368b1c8622 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -1,15 +1,19 @@ -//! Simple metrics storage for offline buffering. +//! Metrics storage for local history and offline buffering. //! -//! Events are stored here when API conditions aren't met. -//! Server handles idempotency - no retry/queue logic needed. +//! Every metric event is stored here. `delivered_ts IS NULL` means the row is +//! still pending upload; delivered rows are retained as the local history. +//! Server handles idempotency. use crate::error::GitAiError; +use crate::metrics::attrs::attr_pos; +use crate::metrics::pos_encoded::sparse_get_string; +use crate::metrics::types::MetricEvent; use rusqlite::{Connection, OptionalExtension, params}; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; /// Current schema version (must match MIGRATIONS.len()) -const SCHEMA_VERSION: usize = 4; +const SCHEMA_VERSION: usize = 6; /// Database migrations - each migration upgrades the schema by one version const MIGRATIONS: &[&str] = &[ @@ -27,21 +31,23 @@ const MIGRATIONS: &[&str] = &[ last_sent_ts INTEGER NOT NULL ); "#, - // Migration 2 -> 3: Persistent local event history for `git-ai usage` + // Migration 2 -> 3: Reserved for a removed local_events design. r#" - CREATE TABLE IF NOT EXISTS local_events ( + "#, + // Migration 3 -> 4: Reserved for a removed local_events repo_url migration. + r#" + "#, + // Migration 4 -> 5: Keep delivered metrics in the authoritative metrics table. + r#" + CREATE TABLE IF NOT EXISTS metrics ( id INTEGER PRIMARY KEY AUTOINCREMENT, - event_id INTEGER NOT NULL, - ts INTEGER NOT NULL, event_json TEXT NOT NULL ); - CREATE INDEX IF NOT EXISTS local_events_ts ON local_events (ts); - CREATE INDEX IF NOT EXISTS local_events_event_id ON local_events (event_id); + ALTER TABLE metrics ADD COLUMN delivered_ts INTEGER; "#, - // Migration 3 -> 4: Add repo_url column to local_events for per-repo filtering + // Migration 5 -> 6: Speed pending queue scans. r#" - ALTER TABLE local_events ADD COLUMN repo_url TEXT; - CREATE INDEX IF NOT EXISTS local_events_repo_url ON local_events (repo_url); + CREATE INDEX IF NOT EXISTS metrics_delivered_ts_id ON metrics (delivered_ts, id); "#, ]; @@ -55,13 +61,13 @@ pub struct MetricRecord { pub event_json: String, } -/// Record from the local_events table +/// Record returned for local usage aggregation from the metrics table. #[derive(Debug, Clone)] -pub struct LocalEventRecord { +pub struct MetricHistoryRecord { pub event_id: u16, pub ts: u32, pub repo_url: Option, - pub event_json: String, + pub event: MetricEvent, } /// Database wrapper for metrics storage @@ -223,31 +229,50 @@ impl MetricsDatabase { Ok(()) } - /// Insert events as JSON strings - pub fn insert_events(&mut self, events: &[String]) -> Result<(), GitAiError> { + /// Insert undelivered events as JSON strings. + pub fn insert_events(&mut self, events: &[String]) -> Result, GitAiError> { + self.insert_events_with_delivered_ts(events, None) + } + + /// Insert events as JSON strings, optionally marking them delivered immediately. + pub fn insert_events_with_delivered_ts( + &mut self, + events: &[String], + delivered_ts: Option, + ) -> Result, GitAiError> { if events.is_empty() { - return Ok(()); + return Ok(Vec::new()); } let tx = self.conn.transaction()?; + let mut ids = Vec::with_capacity(events.len()); { let mut stmt = tx.prepare_cached("INSERT INTO metrics (event_json) VALUES (?1)")?; + let mut delivered_stmt = tx + .prepare_cached("INSERT INTO metrics (event_json, delivered_ts) VALUES (?1, ?2)")?; for event_json in events { - stmt.execute(params![event_json])?; + if let Some(ts) = delivered_ts { + delivered_stmt.execute(params![event_json, ts as i64])?; + } else { + stmt.execute(params![event_json])?; + } + ids.push(tx.last_insert_rowid()); } } tx.commit()?; - Ok(()) + Ok(ids) } - /// Get batch of events (oldest first) + /// Get a batch of undelivered events (oldest first). pub fn get_batch(&self, limit: usize) -> Result, GitAiError> { - let mut stmt = self - .conn - .prepare("SELECT id, event_json FROM metrics ORDER BY id ASC LIMIT ?1")?; + let mut stmt = self.conn.prepare( + "SELECT id, event_json FROM metrics \ + WHERE delivered_ts IS NULL \ + ORDER BY id ASC LIMIT ?1", + )?; let rows = stmt.query_map(params![limit], |row| { Ok(MetricRecord { @@ -264,49 +289,13 @@ impl MetricsDatabase { Ok(records) } - /// Delete records by ID (after successful upload) - pub fn delete_records(&mut self, ids: &[i64]) -> Result<(), GitAiError> { - if ids.is_empty() { - return Ok(()); - } - - let tx = self.conn.transaction()?; - - { - let mut stmt = tx.prepare_cached("DELETE FROM metrics WHERE id = ?1")?; - - for id in ids { - stmt.execute(params![id])?; - } - } - - tx.commit()?; - Ok(()) - } - - /// Get count of pending metrics - pub fn count(&self) -> Result { - let count: i64 = self - .conn - .query_row("SELECT COUNT(*) FROM metrics", [], |row| row.get(0))?; - Ok(count as usize) - } - - /// How long local_events rows are retained (30 days in seconds). - const LOCAL_EVENTS_RETENTION_SECS: u64 = 30 * 24 * 3600; - /// Minimum interval between prune passes (24 hours in seconds). - const LOCAL_EVENTS_PRUNE_INTERVAL_SECS: u64 = 24 * 3600; - - /// Insert events into the local_events table. - /// - /// Each tuple is (event_id, ts, repo_url, event_json). Call this with events - /// filtered to only the interesting event types before inserting. - /// Opportunistically prunes rows older than 30 days at most once per day. - pub fn insert_local_events( + /// Mark records as delivered after a successful upload. + pub fn mark_records_delivered( &mut self, - events: &[(u16, u32, Option, String)], + ids: &[i64], + delivered_ts: u64, ) -> Result<(), GitAiError> { - if events.is_empty() { + if ids.is_empty() { return Ok(()); } @@ -314,118 +303,73 @@ impl MetricsDatabase { { let mut stmt = tx.prepare_cached( - "INSERT INTO local_events (event_id, ts, repo_url, event_json) VALUES (?1, ?2, ?3, ?4)", + "UPDATE metrics SET delivered_ts = ?1 WHERE id = ?2 AND delivered_ts IS NULL", )?; - for (event_id, ts, repo_url, json) in events { - stmt.execute(params![ - *event_id as i64, - *ts as i64, - repo_url.as_deref(), - json - ])?; + for id in ids { + stmt.execute(params![delivered_ts as i64, id])?; } } tx.commit()?; - self.prune_local_events_if_due()?; Ok(()) } - /// Delete local_events rows older than `LOCAL_EVENTS_RETENTION_SECS`, but - /// only if the last prune was more than `LOCAL_EVENTS_PRUNE_INTERVAL_SECS` ago. - fn prune_local_events_if_due(&mut self) -> Result<(), GitAiError> { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - let last_prune: Option = self - .conn - .query_row( - "SELECT value FROM schema_metadata WHERE key = 'local_events_last_prune_ts'", - [], - |row| row.get(0), - ) - .optional()? - .and_then(|v: String| v.parse().ok()); - - if let Some(last) = last_prune - && now.saturating_sub(last as u64) < Self::LOCAL_EVENTS_PRUNE_INTERVAL_SECS - { - return Ok(()); - } - - let cutoff = now.saturating_sub(Self::LOCAL_EVENTS_RETENTION_SECS); - let tx = self.conn.transaction()?; - tx.execute( - "INSERT OR REPLACE INTO schema_metadata (key, value) VALUES ('local_events_last_prune_ts', ?1)", - params![now.to_string()], - )?; - tx.execute( - "DELETE FROM local_events WHERE ts < ?1", - params![cutoff as i64], + /// Get count of pending metrics. + pub fn count(&self) -> Result { + let count: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM metrics WHERE delivered_ts IS NULL", + [], + |row| row.get(0), )?; - tx.commit()?; - - Ok(()) + Ok(count as usize) } - /// Query local_events since `since_ts` (Unix seconds), returning all interesting event types. + /// Query persisted metric rows since `since_ts` (Unix seconds). /// /// When `repo_filter` is `Some(url)`, only events matching that repo_url are returned. /// An empty string `""` is a sentinel meaning "events with no repo_url (NULL)". /// When `None`, all events are returned regardless of repo. - pub fn get_local_events( + pub fn get_metric_history( &self, since_ts: u32, repo_filter: Option<&str>, - ) -> Result, GitAiError> { - // Shared row mapper used by all three query branches below. - let map_row = |row: &rusqlite::Row<'_>| { - Ok(LocalEventRecord { - event_id: row.get::<_, i64>(0)? as u16, - ts: row.get::<_, i64>(1)? as u32, - repo_url: row.get(2)?, - event_json: row.get(3)?, - }) - }; - - let records = if let Some(repo_url) = repo_filter { - if repo_url.is_empty() { - let mut stmt = self.conn.prepare( - "SELECT event_id, ts, repo_url, event_json FROM local_events \ - WHERE ts >= ?1 AND repo_url IS NULL \ - ORDER BY ts ASC", - )?; - stmt.query_map(params![since_ts as i64], map_row)? - .collect::, _>>()? - } else { - // Escape LIKE special characters so a user-supplied substring - // like "my_org/my%repo" matches literally, not as wildcards. - // We use '\' as the escape character. - let escaped = repo_url - .replace('\\', "\\\\") - .replace('%', "\\%") - .replace('_', "\\_"); - let pattern = format!("%{}%", escaped); - let mut stmt = self.conn.prepare( - "SELECT event_id, ts, repo_url, event_json FROM local_events \ - WHERE ts >= ?1 AND repo_url LIKE ?2 ESCAPE '\\' \ - ORDER BY ts ASC", - )?; - stmt.query_map(params![since_ts as i64, pattern], map_row)? - .collect::, _>>()? + event_ids: &[u16], + ) -> Result, GitAiError> { + let mut stmt = self + .conn + .prepare("SELECT event_json FROM metrics ORDER BY id ASC")?; + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; + + let mut records = Vec::new(); + for row in rows { + let event_json = row?; + let Ok(event) = serde_json::from_str::(&event_json) else { + continue; + }; + + if event.timestamp < since_ts || !event_ids.contains(&event.event_id) { + continue; } - } else { - let mut stmt = self.conn.prepare( - "SELECT event_id, ts, repo_url, event_json FROM local_events \ - WHERE ts >= ?1 \ - ORDER BY ts ASC", - )?; - stmt.query_map(params![since_ts as i64], map_row)? - .collect::, _>>()? - }; + + let repo_url = sparse_get_string(&event.attrs, attr_pos::REPO_URL).flatten(); + let repo_matches = match repo_filter { + None => true, + Some("") => repo_url.is_none(), + Some(filter) => repo_url.as_deref().is_some_and(|url| url.contains(filter)), + }; + if !repo_matches { + continue; + } + + records.push(MetricHistoryRecord { + event_id: event.event_id, + ts: event.timestamp, + repo_url, + event, + }); + } + Ok(records) } @@ -513,7 +457,18 @@ mod tests { |row| row.get(0), ) .unwrap(); - assert_eq!(version, "4"); + assert_eq!(version, "6"); + + // Verify delivered_ts exists on the authoritative metrics table. + let delivered_ts_columns: i64 = db + .conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('metrics') WHERE name = 'delivered_ts'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(delivered_ts_columns, 1); } #[test] @@ -551,7 +506,7 @@ mod tests { |row| row.get(0), ) .unwrap(); - assert_eq!(version, "4"); + assert_eq!(version, "6"); } #[test] @@ -563,10 +518,11 @@ mod tests { r#"{"t":1234567891,"e":1,"v":{"0":"def456"},"a":{"0":"1.0.0"}}"#.to_string(), ]; - db.insert_events(&events).unwrap(); + let ids = db.insert_events(&events).unwrap(); let count = db.count().unwrap(); assert_eq!(count, 2); + assert_eq!(ids.len(), 2); } #[test] @@ -592,7 +548,7 @@ mod tests { } #[test] - fn test_delete_records() { + fn test_mark_records_delivered() { let (mut db, _temp_dir) = create_test_db(); let events = vec![ @@ -603,20 +559,81 @@ mod tests { db.insert_events(&events).unwrap(); - // Get batch and delete first two + // Get batch and mark first two delivered. let batch = db.get_batch(2).unwrap(); let ids: Vec = batch.iter().map(|r| r.id).collect(); - db.delete_records(&ids).unwrap(); + db.mark_records_delivered(&ids, 1_700_000_000).unwrap(); - // Verify only one remains + // Verify only one remains pending. let count = db.count().unwrap(); assert_eq!(count, 1); - // Verify remaining is the third one + // Verify remaining pending row is the third one. let remaining = db.get_batch(10).unwrap(); assert_eq!(remaining.len(), 1); assert!(remaining[0].event_json.contains("\"t\":3")); + + // Verify delivered rows are retained. + let total: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM metrics", [], |row| row.get(0)) + .unwrap(); + assert_eq!(total, 3); + } + + #[test] + fn test_insert_events_with_delivered_ts_skips_batch() { + let (mut db, _temp_dir) = create_test_db(); + + let delivered = vec![r#"{"t":1,"e":1,"v":{},"a":{}}"#.to_string()]; + let pending = vec![r#"{"t":2,"e":1,"v":{},"a":{}}"#.to_string()]; + + db.insert_events_with_delivered_ts(&delivered, Some(1_700_000_000)) + .unwrap(); + db.insert_events(&pending).unwrap(); + + let batch = db.get_batch(10).unwrap(); + assert_eq!(batch.len(), 1); + assert!(batch[0].event_json.contains("\"t\":2")); + assert_eq!(db.count().unwrap(), 1); + + let total: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM metrics", [], |row| row.get(0)) + .unwrap(); + assert_eq!(total, 2); + } + + #[test] + fn test_get_metric_history_reads_authoritative_metrics_table() { + let (mut db, _temp_dir) = create_test_db(); + + let delivered = vec![ + r#"{"t":10,"e":1,"v":{},"a":{"1":"https://github.com/acme/project"}}"#.to_string(), + ]; + let pending = vec![ + r#"{"t":20,"e":4,"v":{},"a":{"1":"https://github.com/acme/project"}}"#.to_string(), + r#"{"t":30,"e":2,"v":{},"a":{"1":"https://github.com/acme/project"}}"#.to_string(), + r#"{"t":40,"e":5,"v":{},"a":{"1":"https://github.com/other/repo"}}"#.to_string(), + ]; + + db.insert_events_with_delivered_ts(&delivered, Some(1_700_000_000)) + .unwrap(); + db.insert_events(&pending).unwrap(); + + let records = db + .get_metric_history(0, Some("acme/project"), &[1, 4, 5]) + .unwrap(); + assert_eq!(records.len(), 2); + assert_eq!(records[0].event_id, 1); + assert_eq!(records[0].ts, 10); + assert_eq!(records[1].event_id, 4); + assert_eq!(records[1].ts, 20); + + // Delivered rows are retained for history, but only undelivered rows flush. + let batch = db.get_batch(10).unwrap(); + assert_eq!(batch.len(), 3); } #[test] @@ -630,8 +647,8 @@ mod tests { let batch = db.get_batch(10).unwrap(); assert!(batch.is_empty()); - // Delete empty should succeed - db.delete_records(&[]).unwrap(); + // Marking an empty set delivered should succeed. + db.mark_records_delivered(&[], 1_700_000_000).unwrap(); // Count empty should return 0 let count = db.count().unwrap(); diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 59bd378f15..e754b5439a 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -1,4 +1,4 @@ -//! In-memory aggregation of local_events for `git-ai usage`. +//! In-memory aggregation of persisted metric events for `git-ai usage`. /// How long after a session's last message a subsequent commit is attributed /// to that session for yield and ai_lines_committed calculations. @@ -6,7 +6,7 @@ const YIELD_WINDOW_SECS: u32 = 4 * 3600; use crate::error::GitAiError; use crate::metrics::attrs::attr_pos; -use crate::metrics::db::{LocalEventRecord, MetricsDatabase}; +use crate::metrics::db::{MetricHistoryRecord, MetricsDatabase}; use crate::metrics::events::{checkpoint_pos, committed_pos, session_event_pos}; use crate::metrics::pos_encoded::{ sparse_get_string, sparse_get_u32, sparse_get_vec_string, sparse_get_vec_u32, @@ -137,19 +137,26 @@ pub enum BucketGranularity { Monthly, } -/// Acquire the global DB lock and fetch all local events for the given window. -fn fetch_local_events( +/// Event types useful for local usage stats. +const USAGE_EVENT_IDS: &[u16] = &[ + 1, // Committed + 4, // Checkpoint + 5, // SessionEvent +]; + +/// Acquire the global DB lock and fetch metric history for the given window. +fn fetch_metric_history( since_ts: u32, repo_filter: Option<&str>, -) -> Result, GitAiError> { +) -> Result, GitAiError> { let db = MetricsDatabase::global()?; let db_lock = db .lock() .map_err(|_| GitAiError::Generic("metrics DB lock poisoned".to_string()))?; - db_lock.get_local_events(since_ts, repo_filter) + db_lock.get_metric_history(since_ts, repo_filter, USAGE_EVENT_IDS) } -/// Aggregate local_events since `since_ts` (Unix seconds) into activity stats. +/// Aggregate metric history since `since_ts` (Unix seconds) into activity stats. /// /// When `repo_filter` is `Some(url)`, only events from that repository are /// aggregated. When `None`, events from all repositories are included. @@ -159,17 +166,17 @@ pub fn compute_activity( granularity: BucketGranularity, repo_filter: Option<&str>, ) -> Result { - let records = fetch_local_events(since_ts, repo_filter)?; - let refs: Vec<&LocalEventRecord> = records.iter().collect(); + let records = fetch_metric_history(since_ts, repo_filter)?; + let refs: Vec<&MetricHistoryRecord> = records.iter().collect(); compute_activity_from_records(&refs, since_ts, period_label, granularity) } -/// Aggregate a pre-fetched slice of `LocalEventRecord`s into activity stats. +/// Aggregate a pre-fetched slice of `MetricHistoryRecord`s into activity stats. /// /// Separated from `compute_activity` so callers that already hold all events /// (e.g. `compute_repo_summaries`) can avoid re-fetching from the DB per repo. fn compute_activity_from_records( - records: &[&LocalEventRecord], + records: &[&MetricHistoryRecord], since_ts: u32, period_label: String, granularity: BucketGranularity, @@ -216,16 +223,13 @@ fn compute_activity_from_records( let mut commit_timestamps: Vec = Vec::new(); for record in records { - let event: MetricEvent = match serde_json::from_str(&record.event_json) { - Ok(e) => e, - Err(_) => continue, - }; + let event = &record.event; match record.event_id { 1 => { commit_timestamps.push(record.ts); let c = aggregate_committed( - &event, + event, &mut total_commits, &mut total_ai_lines, &mut total_human_lines, @@ -257,7 +261,7 @@ fn compute_activity_from_records( } } 4 => aggregate_checkpoint( - &event, + event, &mut total_checkpoints, &mut ai_lines_added, &mut human_lines_added, @@ -265,7 +269,7 @@ fn compute_activity_from_records( &mut checkpoint_ai_by_tool, ), 5 => { - aggregate_session(&event, &mut session_ids, &mut session_tool_counts); + aggregate_session(event, &mut session_ids, &mut session_tool_counts); // Track last-seen timestamp per session for yield classification. if let Some(sid) = sparse_get_string(&event.attrs, attr_pos::SESSION_ID).flatten() { @@ -276,12 +280,12 @@ fn compute_activity_from_records( .flatten() .unwrap_or_default(); if tool == "codex" { - aggregate_codex_tokens(&event, record.ts, &mut codex_sessions); + aggregate_codex_tokens(event, record.ts, &mut codex_sessions); } else { let sid = sparse_get_string(&event.attrs, attr_pos::SESSION_ID) .flatten() .unwrap_or_default(); - aggregate_session_tokens(&event, record.ts, sid, &mut message_usage); + aggregate_session_tokens(event, record.ts, sid, &mut message_usage); } } _ => {} @@ -291,10 +295,9 @@ fn compute_activity_from_records( // Yield classification: for each unique session, check if a commit landed // within 4 hours of the session's last observed event. // - // Limitation: local_events aggregates activity across all repos globally, - // so a commit in repo-A can incorrectly "claim" a nearby session that was - // working in repo-B. Fixing this properly requires storing the repo path - // on both session and committed events (a future schema change). + // Limitation: the all-repos view aggregates activity globally, so a commit + // in repo-A can incorrectly "claim" a nearby session from repo-B. The + // per-repo view avoids this by grouping on repo_url before aggregation. commit_timestamps.sort_unstable(); let mut yield_shipped = 0u32; @@ -1048,13 +1051,13 @@ pub struct RepoActivitySummary { /// Aggregate a pre-fetched slice of events into a per-repository breakdown. fn repo_summaries_from_records( - all_records: &[LocalEventRecord], + all_records: &[MetricHistoryRecord], since_ts: u32, granularity: BucketGranularity, ) -> Result, GitAiError> { // Group records by repo_url, skipping events with no repo (NULL) — these - // predate the repo_url column and have no meaningful identity to display. - let mut by_repo: HashMap> = HashMap::new(); + // predate repo_url emission and have no meaningful identity to display. + let mut by_repo: HashMap> = HashMap::new(); for record in all_records { if let Some(ref url) = record.repo_url { by_repo.entry(url.clone()).or_default().push(record); @@ -1089,8 +1092,8 @@ pub fn compute_all( granularity: BucketGranularity, repo_filter: Option<&str>, ) -> Result<(LocalActivityStats, Vec), GitAiError> { - let records = fetch_local_events(since_ts, repo_filter)?; - let refs: Vec<&LocalEventRecord> = records.iter().collect(); + let records = fetch_metric_history(since_ts, repo_filter)?; + let refs: Vec<&MetricHistoryRecord> = records.iter().collect(); let stats = compute_activity_from_records(&refs, since_ts, period_label, granularity)?; let repos = repo_summaries_from_records(&records, since_ts, granularity)?; Ok((stats, repos)) @@ -1106,6 +1109,6 @@ pub fn compute_repo_summaries( granularity: BucketGranularity, repo_filter: Option<&str>, ) -> Result, GitAiError> { - let all_records = fetch_local_events(since_ts, repo_filter)?; + let all_records = fetch_metric_history(since_ts, repo_filter)?; repo_summaries_from_records(&all_records, since_ts, granularity) } diff --git a/src/observability/mod.rs b/src/observability/mod.rs index fbf9aab5f3..70329922d7 100644 --- a/src/observability/mod.rs +++ b/src/observability/mod.rs @@ -17,6 +17,40 @@ fn submit_telemetry_envelope(envelopes: Vec) { crate::daemon::telemetry_handle::submit_telemetry(envelopes); } else if crate::daemon::daemon_process_active() { crate::daemon::telemetry_worker::submit_daemon_internal_telemetry(envelopes); + } else { + store_metrics_envelopes_locally(envelopes); + } +} + +fn store_metrics_envelopes_locally(envelopes: Vec) { + let mut events = Vec::new(); + for envelope in envelopes { + if let crate::daemon::TelemetryEnvelope::Metrics { + events: metric_events, + } = envelope + { + events.extend(metric_events); + } + } + + if events.is_empty() { + return; + } + + for chunk in events.chunks(MAX_METRICS_PER_ENVELOPE) { + let event_jsons: Vec = chunk + .iter() + .filter_map(|event| serde_json::to_string(event).ok()) + .collect(); + if event_jsons.is_empty() { + continue; + } + + if let Ok(db) = crate::metrics::db::MetricsDatabase::global() + && let Ok(mut db_lock) = db.lock() + { + let _ = db_lock.insert_events(&event_jsons); + } } } From 4d7491f530208968b59d245de96209aa3449ae87 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Fri, 5 Jun 2026 01:32:56 +0000 Subject: [PATCH 093/100] prune delivered metrics history --- src/commands/usage.rs | 2 +- src/metrics/db.rs | 89 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/commands/usage.rs b/src/commands/usage.rs index 088b62a4cf..1440a9a312 100644 --- a/src/commands/usage.rs +++ b/src/commands/usage.rs @@ -142,7 +142,7 @@ fn print_help() { eprintln!(" --help Show this help"); eprintln!(); eprintln!("Statistics are sourced from locally recorded metric events."); - eprintln!("Events older than 30 days are pruned automatically."); + eprintln!("Delivered metric history is retained locally for approximately 30 days."); } fn print_terminal( diff --git a/src/metrics/db.rs b/src/metrics/db.rs index 368b1c8622..9959eda8f6 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -76,6 +76,11 @@ pub struct MetricsDatabase { } impl MetricsDatabase { + /// How long delivered metric rows are retained for local history (30 days). + const METRICS_RETENTION_SECS: u64 = 30 * 24 * 3600; + /// Minimum interval between prune passes (24 hours). + const METRICS_PRUNE_INTERVAL_SECS: u64 = 24 * 3600; + /// Get or initialize the global database pub fn global() -> Result<&'static Mutex, GitAiError> { let db_mutex = METRICS_DB.get_or_init(|| { @@ -263,6 +268,7 @@ impl MetricsDatabase { } tx.commit()?; + self.prune_delivered_metrics_if_due()?; Ok(ids) } @@ -312,6 +318,47 @@ impl MetricsDatabase { } tx.commit()?; + self.prune_delivered_metrics_if_due()?; + Ok(()) + } + + /// Delete delivered metric rows outside the local history window. + /// + /// Pending rows are never pruned here because they still need upload. + fn prune_delivered_metrics_if_due(&mut self) -> Result<(), GitAiError> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let last_prune: Option = self + .conn + .query_row( + "SELECT value FROM schema_metadata WHERE key = 'metrics_last_prune_ts'", + [], + |row| row.get(0), + ) + .optional()? + .and_then(|v: String| v.parse().ok()); + + if let Some(last) = last_prune + && now.saturating_sub(last as u64) < Self::METRICS_PRUNE_INTERVAL_SECS + { + return Ok(()); + } + + let cutoff = now.saturating_sub(Self::METRICS_RETENTION_SECS); + let tx = self.conn.transaction()?; + tx.execute( + "INSERT OR REPLACE INTO schema_metadata (key, value) VALUES ('metrics_last_prune_ts', ?1)", + params![now.to_string()], + )?; + tx.execute( + "DELETE FROM metrics WHERE delivered_ts IS NOT NULL AND delivered_ts < ?1", + params![cutoff as i64], + )?; + tx.commit()?; + Ok(()) } @@ -586,10 +633,14 @@ mod tests { fn test_insert_events_with_delivered_ts_skips_batch() { let (mut db, _temp_dir) = create_test_db(); + let delivered_ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); let delivered = vec![r#"{"t":1,"e":1,"v":{},"a":{}}"#.to_string()]; let pending = vec![r#"{"t":2,"e":1,"v":{},"a":{}}"#.to_string()]; - db.insert_events_with_delivered_ts(&delivered, Some(1_700_000_000)) + db.insert_events_with_delivered_ts(&delivered, Some(delivered_ts)) .unwrap(); db.insert_events(&pending).unwrap(); @@ -609,6 +660,10 @@ mod tests { fn test_get_metric_history_reads_authoritative_metrics_table() { let (mut db, _temp_dir) = create_test_db(); + let delivered_ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); let delivered = vec![ r#"{"t":10,"e":1,"v":{},"a":{"1":"https://github.com/acme/project"}}"#.to_string(), ]; @@ -618,7 +673,7 @@ mod tests { r#"{"t":40,"e":5,"v":{},"a":{"1":"https://github.com/other/repo"}}"#.to_string(), ]; - db.insert_events_with_delivered_ts(&delivered, Some(1_700_000_000)) + db.insert_events_with_delivered_ts(&delivered, Some(delivered_ts)) .unwrap(); db.insert_events(&pending).unwrap(); @@ -636,6 +691,36 @@ mod tests { assert_eq!(batch.len(), 3); } + #[test] + fn test_prunes_only_old_delivered_metrics() { + let (mut db, _temp_dir) = create_test_db(); + + let old_delivered_ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .saturating_sub(MetricsDatabase::METRICS_RETENTION_SECS + 1); + let old_delivered = vec![r#"{"t":1,"e":1,"v":{},"a":{}}"#.to_string()]; + db.insert_events_with_delivered_ts(&old_delivered, Some(old_delivered_ts)) + .unwrap(); + + let total_after_prune: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM metrics", [], |row| row.get(0)) + .unwrap(); + assert_eq!(total_after_prune, 0); + + let pending = vec![r#"{"t":2,"e":1,"v":{},"a":{}}"#.to_string()]; + db.insert_events(&pending).unwrap(); + + let total: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM metrics", [], |row| row.get(0)) + .unwrap(); + assert_eq!(total, 1); + assert_eq!(db.count().unwrap(), 1); + } + #[test] fn test_empty_operations() { let (mut db, _temp_dir) = create_test_db(); From a7875c98af1067a2ca65ac0e4f5c8eafac402881 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Fri, 5 Jun 2026 16:16:47 +0000 Subject: [PATCH 094/100] fix metrics db flush invariants --- src/daemon.rs | 10 +- src/daemon/telemetry_handle.rs | 10 +- src/daemon/telemetry_worker.rs | 397 ++++++++++++++++++++++++++++----- src/daemon/test_sync.rs | 49 +++- src/git/repository.rs | 73 +++++- src/metrics/db.rs | 16 ++ src/metrics/local_stats.rs | 168 ++++++++++++++ src/observability/mod.rs | 46 ++-- 8 files changed, 689 insertions(+), 80 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 51fa9eff54..50f2bd5ef9 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -7602,9 +7602,15 @@ impl ActorDaemonCoordinator { }), ControlRequest::SubmitTelemetry { envelopes } => { if let Some(worker) = &self.telemetry_worker { - worker.submit_telemetry(envelopes).await; + match worker.submit_telemetry(envelopes).await { + Ok(()) => Ok(ControlResponse::ok(None, None)), + Err(e) => Ok(ControlResponse::err(format!( + "telemetry worker failed: {e}" + ))), + } + } else { + Ok(ControlResponse::err("telemetry worker unavailable")) } - Ok(ControlResponse::ok(None, None)) } ControlRequest::SubmitCas { records } => { if let Some(worker) = &self.telemetry_worker { diff --git a/src/daemon/telemetry_handle.rs b/src/daemon/telemetry_handle.rs index 6d54da60a1..44c7a32e00 100644 --- a/src/daemon/telemetry_handle.rs +++ b/src/daemon/telemetry_handle.rs @@ -223,14 +223,14 @@ pub fn send_via_daemon(request: &ControlRequest) -> Result) { +/// Returns false when the daemon send failed so metric callers can persist to +/// SQLite locally instead of losing the event. +pub fn submit_telemetry(envelopes: Vec) -> bool { if envelopes.is_empty() { - return; + return true; } let request = ControlRequest::SubmitTelemetry { envelopes }; - let _ = send_via_daemon(&request); + send_via_daemon(&request).is_ok_and(|response| response.ok) } /// Submit CAS sync records to the daemon over the control socket. diff --git a/src/daemon/telemetry_worker.rs b/src/daemon/telemetry_worker.rs index b8927f394c..1caddd919b 100644 --- a/src/daemon/telemetry_worker.rs +++ b/src/daemon/telemetry_worker.rs @@ -6,7 +6,8 @@ use crate::api::{ApiClient, ApiContext, CasObject, CasUploadRequest}; use crate::config::{Config, get_or_create_distinct_id}; use crate::daemon::control_api::{CasSyncPayload, TelemetryEnvelope}; -use crate::metrics::db::MetricsDatabase; +use crate::error::GitAiError; +use crate::metrics::db::{MetricRecord, MetricsDatabase}; use crate::metrics::{MetricEvent, MetricsBatch}; use crate::observability::MAX_METRICS_PER_ENVELOPE; use serde_json::{Value, json}; @@ -145,8 +146,25 @@ impl DaemonTelemetryWorkerHandle { } /// Submit telemetry envelopes for batched processing. - pub async fn submit_telemetry(&self, envelopes: Vec) { - self.buffer.lock().await.ingest_envelopes(envelopes); + pub async fn submit_telemetry( + &self, + envelopes: Vec, + ) -> Result<(), GitAiError> { + let (buffered_envelopes, metric_events) = split_metric_envelopes(envelopes); + if !metric_events.is_empty() { + tokio::task::spawn_blocking(move || store_metrics_in_db(&metric_events).map(|_| ())) + .await + .map_err(|e| GitAiError::Generic(format!("metrics DB task failed: {e}")))??; + } + + if !buffered_envelopes.is_empty() { + self.buffer + .lock() + .await + .ingest_envelopes(buffered_envelopes); + } + + Ok(()) } /// Submit CAS records for batched upload. @@ -154,17 +172,28 @@ impl DaemonTelemetryWorkerHandle { self.buffer.lock().await.ingest_cas(records); } - /// Returns the current number of buffered metric events. + /// Returns the current number of metrics waiting for upload. /// - /// Used by the transcript worker for backpressure: if the buffer is - /// above a threshold, the worker yields to let the flush loop drain it. - /// Returns `usize::MAX` when the lock is contended, so callers default - /// to "wait" rather than "push more". + /// Used by the transcript worker for backpressure: if SQLite pending rows + /// or the legacy in-memory buffer are above a threshold, the worker yields + /// to let the flush loop drain them. Returns `usize::MAX` when the buffer + /// lock is contended, so callers default to "wait" rather than "push more". pub fn metrics_buffer_len(&self) -> usize { - self.buffer + let buffered = self + .buffer .try_lock() .map(|buf| buf.metrics.len()) - .unwrap_or(usize::MAX) + .unwrap_or(usize::MAX); + if buffered == usize::MAX { + return usize::MAX; + } + + let pending = MetricsDatabase::global() + .ok() + .and_then(|db| db.try_lock().ok()) + .and_then(|db| db.count().ok()) + .unwrap_or(0); + buffered.saturating_add(pending) } /// Submit telemetry envelopes synchronously (best-effort, non-blocking). @@ -172,9 +201,24 @@ impl DaemonTelemetryWorkerHandle { /// Used by the daemon process's own `observability::log_*()` calls which /// cannot go through the control socket (the daemon can't connect to itself). /// Uses `try_lock()` to avoid blocking the caller if the buffer is contested. - pub fn submit_telemetry_sync(&self, envelopes: Vec) { + pub fn submit_telemetry_sync(&self, envelopes: Vec) -> bool { + let (buffered_envelopes, metric_events) = split_metric_envelopes(envelopes); + if !metric_events.is_empty() + && let Err(e) = store_metrics_in_db(&metric_events) + { + tracing::warn!(%e, "telemetry: failed to persist daemon metrics locally"); + return false; + } + + if buffered_envelopes.is_empty() { + return true; + } + if let Ok(mut buf) = self.buffer.try_lock() { - buf.ingest_envelopes(envelopes); + buf.ingest_envelopes(buffered_envelopes); + true + } else { + false } } @@ -207,20 +251,28 @@ pub fn set_daemon_internal_telemetry(handle: DaemonTelemetryWorkerHandle) { /// Returns true if the handle was available and envelopes were submitted. pub fn submit_daemon_internal_telemetry(envelopes: Vec) -> bool { if let Some(handle) = DAEMON_INTERNAL_TELEMETRY.get() { - if let Ok(runtime) = tokio::runtime::Handle::try_current() { - let handle = handle.clone(); - runtime.spawn(async move { - handle.submit_telemetry(envelopes).await; - }); - } else { - handle.submit_telemetry_sync(envelopes); - } - true + handle.submit_telemetry_sync(envelopes) } else { false } } +fn split_metric_envelopes( + envelopes: Vec, +) -> (Vec, Vec) { + let mut buffered_envelopes = Vec::new(); + let mut metric_events = Vec::new(); + + for envelope in envelopes { + match envelope { + TelemetryEnvelope::Metrics { events } => metric_events.extend(events), + other => buffered_envelopes.push(other), + } + } + + (buffered_envelopes, metric_events) +} + /// Submit CAS records from within the daemon process (sync, best-effort). /// Returns true if the handle was available and records were submitted. pub fn submit_daemon_internal_cas(records: Vec) -> bool { @@ -267,14 +319,19 @@ async fn telemetry_flush_loop(buffer: Arc>) { let snapshot = { let mut buf = buffer.lock().await; if buf.is_empty() { - continue; + None + } else { + Some(buf.take()) } - buf.take() }; // Flush in a blocking task since the underlying HTTP clients are synchronous. tokio::task::spawn_blocking(move || { - flush_telemetry_batch(snapshot); + if let Some(snapshot) = snapshot { + flush_telemetry_batch(snapshot); + } else { + flush_pending_metrics(); + } }) .await .unwrap_or_else(|e| { @@ -327,57 +384,158 @@ fn flush_metrics(events: &[MetricEvent]) { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); for chunk in events.chunks(MAX_METRICS_PER_ENVELOPE) { - let record_ids = store_metrics_in_db(chunk); + if let Err(e) = store_metrics_in_db(chunk) { + tracing::warn!(%e, "telemetry: failed to persist metrics before upload"); + continue; + } if should_upload && !upload_failed && std::time::Instant::now() < deadline { - let batch = MetricsBatch::new(chunk.to_vec()); - if client.upload_metrics(&batch).is_ok() { - mark_metrics_delivered(&record_ids); - continue; + match flush_pending_metrics_from_db(&client, deadline) { + Ok(_) => {} + Err(e) => { + tracing::warn!(%e, "telemetry: failed to upload pending metrics"); + upload_failed = true; + } } - upload_failed = true; } } } -fn store_metrics_in_db(events: &[MetricEvent]) -> Vec { +fn flush_pending_metrics() { + let context = ApiContext::new(None); + let api_base_url = context.base_url.clone(); + let client = ApiClient::new(context); + + let using_default_api = api_base_url == crate::config::DEFAULT_API_BASE_URL; + if using_default_api && !client.is_logged_in() && !client.has_api_key() { + return; + } + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + if let Err(e) = flush_pending_metrics_from_db(&client, deadline) { + tracing::warn!(%e, "telemetry: failed to upload pending metrics"); + } +} + +fn store_metrics_in_db(events: &[MetricEvent]) -> Result, GitAiError> { if events.is_empty() { - return Vec::new(); + return Ok(Vec::new()); } let event_jsons: Vec = events .iter() - .filter_map(|e| serde_json::to_string(e).ok()) - .collect(); + .map(serde_json::to_string) + .collect::>()?; if event_jsons.is_empty() { - return Vec::new(); + return Ok(Vec::new()); } - if let Ok(db) = MetricsDatabase::global() - && let Ok(mut db_lock) = db.lock() - { - return db_lock.insert_events(&event_jsons).unwrap_or_default(); - } + let db = MetricsDatabase::global()?; + let mut db_lock = db + .lock() + .map_err(|_| GitAiError::Generic("metrics DB lock poisoned".to_string()))?; + db_lock.insert_events(&event_jsons) +} - Vec::new() +#[derive(Debug, Default, PartialEq, Eq)] +struct PendingMetricsFlushResult { + uploaded_events: usize, + uploaded_batches: usize, + invalid_records: usize, } -fn mark_metrics_delivered(ids: &[i64]) { - if ids.is_empty() { - return; +fn flush_pending_metrics_from_db( + client: &ApiClient, + deadline: std::time::Instant, +) -> Result { + flush_pending_metric_records_with( + read_pending_metrics_batch, + mark_metric_records_delivered, + |batch| client.upload_metrics(batch).map(|_| ()), + deadline, + MAX_METRICS_PER_ENVELOPE, + ) +} + +fn read_pending_metrics_batch(limit: usize) -> Result, GitAiError> { + let db = MetricsDatabase::global()?; + let db_lock = db + .lock() + .map_err(|_| GitAiError::Generic("metrics DB lock poisoned".to_string()))?; + db_lock.get_batch(limit) +} + +fn mark_metric_records_delivered(ids: &[i64]) -> Result<(), GitAiError> { + let db = MetricsDatabase::global()?; + let mut db_lock = db + .lock() + .map_err(|_| GitAiError::Generic("metrics DB lock poisoned".to_string()))?; + db_lock.mark_records_delivered(ids, current_unix_ts()) +} + +fn flush_pending_metric_records_with( + mut get_batch: GetBatch, + mut mark_delivered: MarkDelivered, + mut upload_batch: UploadBatch, + deadline: std::time::Instant, + max_batch_size: usize, +) -> Result +where + GetBatch: FnMut(usize) -> Result, GitAiError>, + MarkDelivered: FnMut(&[i64]) -> Result<(), GitAiError>, + UploadBatch: FnMut(&MetricsBatch) -> Result<(), GitAiError>, +{ + let mut result = PendingMetricsFlushResult::default(); + + while std::time::Instant::now() < deadline { + let batch = get_batch(max_batch_size)?; + if batch.is_empty() { + break; + } + + let mut events = Vec::new(); + let mut record_ids = Vec::new(); + let mut invalid_ids = Vec::new(); + + for record in &batch { + match serde_json::from_str::(&record.event_json) { + Ok(event) => { + events.push(event); + record_ids.push(record.id); + } + Err(_) => { + invalid_ids.push(record.id); + } + } + } + + if !invalid_ids.is_empty() { + result.invalid_records += invalid_ids.len(); + mark_delivered(&invalid_ids)?; + } + + if events.is_empty() { + continue; + } + + let event_count = events.len(); + let metrics_batch = MetricsBatch::new(events); + upload_batch(&metrics_batch)?; + mark_delivered(&record_ids)?; + + result.uploaded_events += event_count; + result.uploaded_batches += 1; } - let delivered_ts = std::time::SystemTime::now() + Ok(result) +} + +fn current_unix_ts() -> u64 { + std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() - .as_secs(); - - if let Ok(db) = MetricsDatabase::global() - && let Ok(mut db_lock) = db.lock() - { - let _ = db_lock.mark_records_delivered(ids, delivered_ts); - } + .as_secs() } fn flush_sentry_and_posthog( @@ -787,3 +945,138 @@ impl SentryClient { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::rc::Rc; + + fn event_json(ts: u32) -> String { + format!(r#"{{"t":{ts},"e":1,"v":{{}},"a":{{}}}}"#) + } + + #[test] + fn flush_pending_metric_records_uploads_from_db_and_marks_delivered() { + let db = Rc::new(RefCell::new( + MetricsDatabase::new_in_memory_for_tests().unwrap(), + )); + db.borrow_mut() + .insert_events(&[event_json(1), event_json(2)]) + .unwrap(); + + let uploaded = Rc::new(RefCell::new(Vec::>::new())); + let result = flush_pending_metric_records_with( + { + let db = Rc::clone(&db); + move |limit| db.borrow().get_batch(limit) + }, + { + let db = Rc::clone(&db); + move |ids| db.borrow_mut().mark_records_delivered(ids, 1_700_000_000) + }, + { + let uploaded = Rc::clone(&uploaded); + move |batch| { + uploaded + .borrow_mut() + .push(batch.events.iter().map(|event| event.timestamp).collect()); + Ok(()) + } + }, + std::time::Instant::now() + std::time::Duration::from_secs(60), + 1, + ) + .unwrap(); + + assert_eq!( + result, + PendingMetricsFlushResult { + uploaded_events: 2, + uploaded_batches: 2, + invalid_records: 0, + } + ); + assert_eq!(*uploaded.borrow(), vec![vec![1], vec![2]]); + assert_eq!(db.borrow().count().unwrap(), 0); + assert_eq!( + db.borrow().get_metric_history(0, None, &[1]).unwrap().len(), + 2 + ); + } + + #[test] + fn flush_pending_metric_records_marks_invalid_rows_delivered() { + let db = Rc::new(RefCell::new( + MetricsDatabase::new_in_memory_for_tests().unwrap(), + )); + db.borrow_mut() + .insert_events(&["not-json".to_string(), event_json(2)]) + .unwrap(); + + let uploaded = Rc::new(RefCell::new(Vec::::new())); + let result = flush_pending_metric_records_with( + { + let db = Rc::clone(&db); + move |limit| db.borrow().get_batch(limit) + }, + { + let db = Rc::clone(&db); + move |ids| db.borrow_mut().mark_records_delivered(ids, 1_700_000_000) + }, + { + let uploaded = Rc::clone(&uploaded); + move |batch| { + uploaded + .borrow_mut() + .extend(batch.events.iter().map(|event| event.timestamp)); + Ok(()) + } + }, + std::time::Instant::now() + std::time::Duration::from_secs(60), + 10, + ) + .unwrap(); + + assert_eq!( + result, + PendingMetricsFlushResult { + uploaded_events: 1, + uploaded_batches: 1, + invalid_records: 1, + } + ); + assert_eq!(*uploaded.borrow(), vec![2]); + assert_eq!(db.borrow().count().unwrap(), 0); + assert_eq!( + db.borrow().get_metric_history(0, None, &[1]).unwrap().len(), + 1 + ); + } + + #[test] + fn flush_pending_metric_records_keeps_rows_pending_after_upload_failure() { + let db = Rc::new(RefCell::new( + MetricsDatabase::new_in_memory_for_tests().unwrap(), + )); + db.borrow_mut().insert_events(&[event_json(1)]).unwrap(); + + let result = flush_pending_metric_records_with( + { + let db = Rc::clone(&db); + move |limit| db.borrow().get_batch(limit) + }, + { + let db = Rc::clone(&db); + move |ids| db.borrow_mut().mark_records_delivered(ids, 1_700_000_000) + }, + |_batch| Err(GitAiError::Generic("upload failed".to_string())), + std::time::Instant::now() + std::time::Duration::from_secs(60), + 10, + ); + + assert!(result.is_err()); + assert_eq!(db.borrow().count().unwrap(), 1); + assert_eq!(db.borrow().get_batch(10).unwrap().len(), 1); + } +} diff --git a/src/daemon/test_sync.rs b/src/daemon/test_sync.rs index e9fc678258..b8a1a166e7 100644 --- a/src/daemon/test_sync.rs +++ b/src/daemon/test_sync.rs @@ -233,10 +233,55 @@ fn parse_alias_tokens(value: &str) -> Option> { mod tests { use super::*; use std::process::Command; + use std::sync::OnceLock; + + fn real_git_for_test() -> &'static str { + static REAL_GIT: OnceLock = OnceLock::new(); + REAL_GIT + .get_or_init(|| { + #[cfg(not(windows))] + { + for candidate in [ + "/opt/homebrew/bin/git", + "/usr/local/bin/git", + "/usr/bin/git", + "/bin/git", + ] { + if crate::config::is_real_git_candidate(Path::new(candidate)) { + return candidate.to_string(); + } + } + } + + if let Some(path) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&path) { + for name in git_binary_names_for_path_lookup() { + let candidate = dir.join(name); + if crate::config::is_real_git_candidate(&candidate) { + return candidate.to_string_lossy().to_string(); + } + } + } + } + + "git".to_string() + }) + .as_str() + } + + #[cfg(windows)] + fn git_binary_names_for_path_lookup() -> &'static [&'static str] { + &["git.exe", "git.cmd", "git.bat", "git"] + } + + #[cfg(not(windows))] + fn git_binary_names_for_path_lookup() -> &'static [&'static str] { + &["git"] + } fn init_repo() -> tempfile::TempDir { let temp = tempfile::tempdir().expect("create tempdir"); - let status = Command::new("git") + let status = Command::new(real_git_for_test()) .arg("init") .env("GIT_TRACE2", "0") .env("GIT_TRACE2_EVENT", "0") @@ -248,7 +293,7 @@ mod tests { } fn git_config(repo: &Path, key: &str, value: &str) { - let status = Command::new("git") + let status = Command::new(real_git_for_test()) .args(["config", key, value]) .env("GIT_TRACE2", "0") .env("GIT_TRACE2_EVENT", "0") diff --git a/src/git/repository.rs b/src/git/repository.rs index 964efad162..7665f1d722 100644 --- a/src/git/repository.rs +++ b/src/git/repository.rs @@ -2216,9 +2216,6 @@ fn git_config_file_for_repo_paths( git_dir: &Path, git_common_dir: &Path, ) -> Result, GitAiError> { - let mut config = - gix_config::File::from_globals().map_err(|e| GitAiError::GixError(e.to_string()))?; - let home = dirs::home_dir(); let options = gix_config::file::init::Options { includes: gix_config::file::includes::Options::follow( @@ -2234,6 +2231,13 @@ fn git_config_file_for_repo_paths( ..Default::default() }; + let mut config = gix_config::File::default(); + for (path, source) in no_exec_global_config_paths() { + let file = gix_config::File::from_path_no_includes(path, source) + .map_err(|e| GitAiError::GixError(e.to_string()))?; + config.append(file); + } + config .resolve_includes(options) .map_err(|e| GitAiError::GixError(e.to_string()))?; @@ -2275,6 +2279,69 @@ fn git_config_file_for_repo_paths( Ok(config) } +fn no_exec_global_config_paths() -> Vec<(PathBuf, gix_config::Source)> { + let mut paths = Vec::new(); + let nosystem = std::env::var_os("GIT_CONFIG_NOSYSTEM").is_some_and(|value| { + let value = value.to_string_lossy(); + !matches!(value.as_ref(), "" | "0" | "false" | "FALSE" | "off" | "OFF") + }); + + if !nosystem { + if let Some(path) = std::env::var_os("GIT_CONFIG_SYSTEM").map(PathBuf::from) { + push_existing_config_path(&mut paths, path, gix_config::Source::System); + } else { + #[cfg(unix)] + push_existing_config_path( + &mut paths, + PathBuf::from("/etc/gitconfig"), + gix_config::Source::System, + ); + } + } + + if let Some(path) = std::env::var_os("GIT_CONFIG_GLOBAL").map(PathBuf::from) { + push_existing_config_path(&mut paths, path, gix_config::Source::Git); + } else { + let xdg_config_home = std::env::var_os("XDG_CONFIG_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| home_dir_from_env().map(|home| home.join(".config"))); + if let Some(xdg_config_home) = xdg_config_home { + push_existing_config_path( + &mut paths, + xdg_config_home.join("git").join("config"), + gix_config::Source::Git, + ); + } + + if let Some(home) = home_dir_from_env() { + push_existing_config_path( + &mut paths, + home.join(".gitconfig"), + gix_config::Source::User, + ); + } + } + + paths +} + +fn home_dir_from_env() -> Option { + std::env::var_os("HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn push_existing_config_path( + paths: &mut Vec<(PathBuf, gix_config::Source)>, + path: PathBuf, + source: gix_config::Source, +) { + if path.is_file() { + paths.push((path, source)); + } +} + pub fn config_get_str_for_path_no_git_exec( path: &Path, key: &str, diff --git a/src/metrics/db.rs b/src/metrics/db.rs index 9959eda8f6..a108838073 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -125,6 +125,22 @@ impl MetricsDatabase { Ok(db) } + #[cfg(test)] + pub(crate) fn new_in_memory_for_tests() -> Result { + let conn = Connection::open_in_memory()?; + conn.execute_batch( + r#" + PRAGMA journal_mode=WAL; + PRAGMA synchronous=NORMAL; + "#, + )?; + + let mut db = Self { conn }; + db.initialize_schema()?; + + Ok(db) + } + /// Get database path: ~/.git-ai/internal/metrics-db fn database_path() -> Result { // Allow test override via environment variable diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index e754b5439a..6a4e6a21e2 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -1112,3 +1112,171 @@ pub fn compute_repo_summaries( let all_records = fetch_metric_history(since_ts, repo_filter)?; repo_summaries_from_records(&all_records, since_ts, granularity) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::metrics::attrs::EventAttributes; + use crate::metrics::events::{CheckpointValues, CommittedValues, SessionEventValues}; + use crate::metrics::pos_encoded::{PosEncoded, sparse_get_string}; + use serde_json::json; + + fn now_ts() -> u32 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as u32 + } + + fn attrs( + repo_url: Option<&str>, + tool: &str, + session_id: Option<&str>, + ) -> crate::metrics::types::SparseArray { + let mut attrs = EventAttributes::with_version("test").tool(tool); + if let Some(repo_url) = repo_url { + attrs = attrs.repo_url(repo_url); + } + if let Some(session_id) = session_id { + attrs = attrs.session_id(session_id); + } + attrs.to_sparse() + } + + fn record(event: MetricEvent) -> MetricHistoryRecord { + let repo_url = sparse_get_string(&event.attrs, attr_pos::REPO_URL).flatten(); + MetricHistoryRecord { + event_id: event.event_id, + ts: event.timestamp, + repo_url, + event, + } + } + + fn committed( + ts: u32, + repo_url: &str, + ai: u32, + human: u32, + diff_added: u32, + ) -> MetricHistoryRecord { + let values = CommittedValues::new() + .human_additions(human) + .git_diff_added_lines(diff_added) + .tool_model_pairs(vec![ + "all".to_string(), + "claude::claude-sonnet-4-6".to_string(), + ]) + .ai_additions(vec![ai, ai]); + record(MetricEvent::with_timestamp( + ts, + &values, + attrs(Some(repo_url), "claude", None), + )) + } + + fn checkpoint(ts: u32, repo_url: &str, lines_added: u32) -> MetricHistoryRecord { + let values = CheckpointValues::new() + .kind("ai_agent") + .file_path("src/main.rs") + .lines_added(lines_added); + record(MetricEvent::with_timestamp( + ts, + &values, + attrs(Some(repo_url), "claude", Some("session-1")), + )) + } + + fn claude_session(ts: u32, repo_url: Option<&str>, session_id: &str) -> MetricHistoryRecord { + let values = SessionEventValues::new(json!({ + "message": { + "id": "msg-1", + "role": "assistant", + "model": "claude-sonnet-4-6-20250101", + "usage": { + "input_tokens": 100, + "output_tokens": 50, + "cache_read_input_tokens": 20, + "cache_creation_input_tokens": 10 + } + } + })); + record(MetricEvent::with_timestamp( + ts, + &values, + attrs(repo_url, "claude", Some(session_id)), + )) + } + + #[test] + fn compute_activity_aggregates_commits_checkpoints_sessions_and_tokens() { + let now = now_ts(); + let repo = "github.com/acme/project"; + let session_ts = now.saturating_sub(600); + let commit_ts = now.saturating_sub(300); + let records = [ + claude_session(session_ts, Some(repo), "session-1"), + checkpoint(session_ts + 10, repo, 12), + committed(commit_ts, repo, 10, 2, 12), + ]; + let refs: Vec<&MetricHistoryRecord> = records.iter().collect(); + + let stats = compute_activity_from_records( + &refs, + now.saturating_sub(24 * 3600), + "last 1 day".to_string(), + BucketGranularity::Daily, + ) + .unwrap(); + + assert_eq!(stats.commits.total, 1); + assert_eq!(stats.commits.ai_lines, 10); + assert_eq!(stats.commits.human_lines, 2); + assert_eq!(stats.commits.diff_added_lines, 12); + assert_eq!( + stats.commits.by_tool, + vec![("claude · sonnet-4-6".to_string(), 10)] + ); + assert_eq!( + stats.commits.acceptance_by_tool, + vec![("claude".to_string(), 83)] + ); + assert_eq!(stats.checkpoints.total, 1); + assert_eq!(stats.checkpoints.ai_lines_added, 12); + assert_eq!(stats.checkpoints.files_edited, 1); + assert_eq!(stats.sessions.total, 1); + assert_eq!(stats.sessions.yield_stats.shipped, 1); + assert_eq!(stats.sessions.yield_stats.abandoned, 0); + assert_eq!(stats.tokens.input, 100); + assert_eq!(stats.tokens.output, 50); + assert_eq!(stats.tokens.cache_read, 20); + assert_eq!(stats.tokens.cache_creation, 10); + assert_eq!(stats.tokens.by_model[0].model, "claude-sonnet-4-6"); + assert!(stats.buckets.iter().any(|bucket| bucket.ai_lines == 10)); + } + + #[test] + fn repo_summaries_group_records_by_repo_and_skip_unknown_repo() { + let now = now_ts(); + let repo = "github.com/acme/project"; + let records = [ + committed(now.saturating_sub(300), repo, 8, 0, 8), + claude_session(now.saturating_sub(200), Some(repo), "session-1"), + claude_session(now.saturating_sub(100), None, "session-unknown"), + ]; + + let summaries = repo_summaries_from_records( + &records, + now.saturating_sub(24 * 3600), + BucketGranularity::Daily, + ) + .unwrap(); + + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].repo_url, repo); + assert_eq!(summaries[0].ai_lines, 8); + assert_eq!(summaries[0].commits, 1); + assert_eq!(summaries[0].sessions, 1); + assert!(summaries[0].estimated_cost_usd > 0.0); + } +} diff --git a/src/observability/mod.rs b/src/observability/mod.rs index 70329922d7..a3a7ae84f2 100644 --- a/src/observability/mod.rs +++ b/src/observability/mod.rs @@ -1,7 +1,9 @@ use std::collections::HashMap; use std::time::Duration; +use crate::error::GitAiError; use crate::metrics::MetricEvent; +use crate::metrics::db::MetricsDatabase; pub mod performance_targets; @@ -11,18 +13,28 @@ pub const MAX_METRICS_PER_ENVELOPE: usize = 1000; /// Submit telemetry envelopes via the best available path: /// 1. External daemon control socket (wrapper processes) /// 2. In-process daemon telemetry worker (daemon process itself) -/// 3. Silently drop if neither is available +/// 3. Local SQLite storage for metric events if neither daemon path is available fn submit_telemetry_envelope(envelopes: Vec) { - if crate::daemon::telemetry_handle::daemon_telemetry_available() { - crate::daemon::telemetry_handle::submit_telemetry(envelopes); - } else if crate::daemon::daemon_process_active() { - crate::daemon::telemetry_worker::submit_daemon_internal_telemetry(envelopes); - } else { - store_metrics_envelopes_locally(envelopes); + if crate::daemon::telemetry_handle::daemon_telemetry_available() + && crate::daemon::telemetry_handle::submit_telemetry(envelopes.clone()) + { + return; + } + + if crate::daemon::daemon_process_active() + && crate::daemon::telemetry_worker::submit_daemon_internal_telemetry(envelopes.clone()) + { + return; + } + + if let Err(e) = store_metrics_envelopes_locally(envelopes) { + tracing::warn!(%e, "telemetry: failed to persist metrics locally"); } } -fn store_metrics_envelopes_locally(envelopes: Vec) { +fn store_metrics_envelopes_locally( + envelopes: Vec, +) -> Result<(), GitAiError> { let mut events = Vec::new(); for envelope in envelopes { if let crate::daemon::TelemetryEnvelope::Metrics { @@ -34,24 +46,26 @@ fn store_metrics_envelopes_locally(envelopes: Vec = chunk .iter() - .filter_map(|event| serde_json::to_string(event).ok()) - .collect(); + .map(serde_json::to_string) + .collect::>()?; if event_jsons.is_empty() { continue; } - if let Ok(db) = crate::metrics::db::MetricsDatabase::global() - && let Ok(mut db_lock) = db.lock() - { - let _ = db_lock.insert_events(&event_jsons); - } + let db = MetricsDatabase::global()?; + let mut db_lock = db + .lock() + .map_err(|_| GitAiError::Generic("metrics DB lock poisoned".to_string()))?; + db_lock.insert_events(&event_jsons)?; } + + Ok(()) } /// Log an error to Sentry (via daemon telemetry worker) From 31e0b2a94663d7bd3e98fcae0ba142ede4869c0b Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Fri, 5 Jun 2026 17:17:51 +0000 Subject: [PATCH 095/100] prune metric rows after 45 days --- src/commands/usage.rs | 2 +- src/daemon/telemetry_worker.rs | 31 +++-- src/metrics/db.rs | 214 ++++++++++++++++++++++++--------- 3 files changed, 182 insertions(+), 65 deletions(-) diff --git a/src/commands/usage.rs b/src/commands/usage.rs index 1440a9a312..433fbadd88 100644 --- a/src/commands/usage.rs +++ b/src/commands/usage.rs @@ -142,7 +142,7 @@ fn print_help() { eprintln!(" --help Show this help"); eprintln!(); eprintln!("Statistics are sourced from locally recorded metric events."); - eprintln!("Delivered metric history is retained locally for approximately 30 days."); + eprintln!("Metric rows older than approximately 45 days are pruned locally."); } fn print_terminal( diff --git a/src/daemon/telemetry_worker.rs b/src/daemon/telemetry_worker.rs index 1caddd919b..bf29947cab 100644 --- a/src/daemon/telemetry_worker.rs +++ b/src/daemon/telemetry_worker.rs @@ -956,13 +956,26 @@ mod tests { format!(r#"{{"t":{ts},"e":1,"v":{{}},"a":{{}}}}"#) } + fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + } + + fn now_ts() -> u32 { + unix_now().min(u32::MAX as u64) as u32 + } + #[test] fn flush_pending_metric_records_uploads_from_db_and_marks_delivered() { let db = Rc::new(RefCell::new( MetricsDatabase::new_in_memory_for_tests().unwrap(), )); + let ts1 = now_ts().saturating_sub(2); + let ts2 = now_ts().saturating_sub(1); db.borrow_mut() - .insert_events(&[event_json(1), event_json(2)]) + .insert_events(&[event_json(ts1), event_json(ts2)]) .unwrap(); let uploaded = Rc::new(RefCell::new(Vec::>::new())); @@ -973,7 +986,7 @@ mod tests { }, { let db = Rc::clone(&db); - move |ids| db.borrow_mut().mark_records_delivered(ids, 1_700_000_000) + move |ids| db.borrow_mut().mark_records_delivered(ids, unix_now()) }, { let uploaded = Rc::clone(&uploaded); @@ -997,7 +1010,7 @@ mod tests { invalid_records: 0, } ); - assert_eq!(*uploaded.borrow(), vec![vec![1], vec![2]]); + assert_eq!(*uploaded.borrow(), vec![vec![ts1], vec![ts2]]); assert_eq!(db.borrow().count().unwrap(), 0); assert_eq!( db.borrow().get_metric_history(0, None, &[1]).unwrap().len(), @@ -1010,8 +1023,9 @@ mod tests { let db = Rc::new(RefCell::new( MetricsDatabase::new_in_memory_for_tests().unwrap(), )); + let ts = now_ts(); db.borrow_mut() - .insert_events(&["not-json".to_string(), event_json(2)]) + .insert_events(&["not-json".to_string(), event_json(ts)]) .unwrap(); let uploaded = Rc::new(RefCell::new(Vec::::new())); @@ -1022,7 +1036,7 @@ mod tests { }, { let db = Rc::clone(&db); - move |ids| db.borrow_mut().mark_records_delivered(ids, 1_700_000_000) + move |ids| db.borrow_mut().mark_records_delivered(ids, unix_now()) }, { let uploaded = Rc::clone(&uploaded); @@ -1046,7 +1060,7 @@ mod tests { invalid_records: 1, } ); - assert_eq!(*uploaded.borrow(), vec![2]); + assert_eq!(*uploaded.borrow(), vec![ts]); assert_eq!(db.borrow().count().unwrap(), 0); assert_eq!( db.borrow().get_metric_history(0, None, &[1]).unwrap().len(), @@ -1059,7 +1073,8 @@ mod tests { let db = Rc::new(RefCell::new( MetricsDatabase::new_in_memory_for_tests().unwrap(), )); - db.borrow_mut().insert_events(&[event_json(1)]).unwrap(); + let ts = now_ts(); + db.borrow_mut().insert_events(&[event_json(ts)]).unwrap(); let result = flush_pending_metric_records_with( { @@ -1068,7 +1083,7 @@ mod tests { }, { let db = Rc::clone(&db); - move |ids| db.borrow_mut().mark_records_delivered(ids, 1_700_000_000) + move |ids| db.borrow_mut().mark_records_delivered(ids, unix_now()) }, |_batch| Err(GitAiError::Generic("upload failed".to_string())), std::time::Instant::now() + std::time::Duration::from_secs(60), diff --git a/src/metrics/db.rs b/src/metrics/db.rs index a108838073..27b90e883b 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -76,8 +76,8 @@ pub struct MetricsDatabase { } impl MetricsDatabase { - /// How long delivered metric rows are retained for local history (30 days). - const METRICS_RETENTION_SECS: u64 = 30 * 24 * 3600; + /// How long metric rows are retained for local history/offline retry (45 days). + const METRICS_RETENTION_SECS: u64 = 45 * 24 * 3600; /// Minimum interval between prune passes (24 hours). const METRICS_PRUNE_INTERVAL_SECS: u64 = 24 * 3600; @@ -284,7 +284,7 @@ impl MetricsDatabase { } tx.commit()?; - self.prune_delivered_metrics_if_due()?; + self.prune_old_metrics_if_due()?; Ok(ids) } @@ -334,14 +334,16 @@ impl MetricsDatabase { } tx.commit()?; - self.prune_delivered_metrics_if_due()?; + self.prune_old_metrics_if_due()?; Ok(()) } - /// Delete delivered metric rows outside the local history window. + /// Delete metric rows outside the local retention window. /// - /// Pending rows are never pruned here because they still need upload. - fn prune_delivered_metrics_if_due(&mut self) -> Result<(), GitAiError> { + /// Valid rows are pruned by event timestamp, regardless of delivery state. Malformed + /// rows cannot be aged by event timestamp, so delivered malformed rows fall back to + /// `delivered_ts`. + fn prune_old_metrics_if_due(&mut self) -> Result<(), GitAiError> { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -364,20 +366,46 @@ impl MetricsDatabase { } let cutoff = now.saturating_sub(Self::METRICS_RETENTION_SECS); + let rows_to_prune = self.old_metric_row_ids(cutoff)?; let tx = self.conn.transaction()?; tx.execute( "INSERT OR REPLACE INTO schema_metadata (key, value) VALUES ('metrics_last_prune_ts', ?1)", params![now.to_string()], )?; - tx.execute( - "DELETE FROM metrics WHERE delivered_ts IS NOT NULL AND delivered_ts < ?1", - params![cutoff as i64], - )?; + { + let mut stmt = tx.prepare_cached("DELETE FROM metrics WHERE id = ?1")?; + for id in rows_to_prune { + stmt.execute(params![id])?; + } + } tx.commit()?; Ok(()) } + fn old_metric_row_ids(&self, cutoff: u64) -> Result, GitAiError> { + let mut stmt = self + .conn + .prepare("SELECT id, event_json, delivered_ts FROM metrics ORDER BY id ASC")?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + })?; + + let mut ids = Vec::new(); + for row in rows { + let (id, event_json, delivered_ts) = row?; + if metric_row_is_older_than_cutoff(&event_json, delivered_ts, cutoff) { + ids.push(id); + } + } + + Ok(ids) + } + /// Get count of pending metrics. pub fn count(&self) -> Result { let count: i64 = self.conn.query_row( @@ -478,6 +506,18 @@ impl MetricsDatabase { } } +fn metric_row_is_older_than_cutoff( + event_json: &str, + delivered_ts: Option, + cutoff: u64, +) -> bool { + if let Ok(event) = serde_json::from_str::(event_json) { + return u64::from(event.timestamp) < cutoff; + } + + delivered_ts.is_some_and(|ts| ts >= 0 && (ts as u64) < cutoff) +} + #[cfg(test)] mod tests { use super::*; @@ -496,6 +536,27 @@ mod tests { (db, temp_dir) } + fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + } + + fn days_ago(days: u64) -> u32 { + unix_now() + .saturating_sub(days * 24 * 3600) + .min(u32::MAX as u64) as u32 + } + + fn event_json(ts: u32) -> String { + format!(r#"{{"t":{ts},"e":1,"v":{{}},"a":{{}}}}"#) + } + + fn event_json_with_repo(ts: u32, event_id: u16, repo: &str) -> String { + format!(r#"{{"t":{ts},"e":{event_id},"v":{{}},"a":{{"1":"{repo}"}}}}"#) + } + #[test] fn test_initialize_schema() { let (db, _temp_dir) = create_test_db(); @@ -575,10 +636,12 @@ mod tests { #[test] fn test_insert_events() { let (mut db, _temp_dir) = create_test_db(); + let ts1 = days_ago(2); + let ts2 = days_ago(1); let events = vec![ - r#"{"t":1234567890,"e":1,"v":{"0":"abc123"},"a":{"0":"1.0.0"}}"#.to_string(), - r#"{"t":1234567891,"e":1,"v":{"0":"def456"},"a":{"0":"1.0.0"}}"#.to_string(), + format!(r#"{{"t":{ts1},"e":1,"v":{{"0":"abc123"}},"a":{{"0":"1.0.0"}}}}"#), + format!(r#"{{"t":{ts2},"e":1,"v":{{"0":"def456"}},"a":{{"0":"1.0.0"}}}}"#), ]; let ids = db.insert_events(&events).unwrap(); @@ -591,12 +654,11 @@ mod tests { #[test] fn test_get_batch() { let (mut db, _temp_dir) = create_test_db(); + let ts1 = days_ago(3); + let ts2 = days_ago(2); + let ts3 = days_ago(1); - let events = vec![ - r#"{"t":1,"e":1,"v":{},"a":{}}"#.to_string(), - r#"{"t":2,"e":1,"v":{},"a":{}}"#.to_string(), - r#"{"t":3,"e":1,"v":{},"a":{}}"#.to_string(), - ]; + let events = vec![event_json(ts1), event_json(ts2), event_json(ts3)]; db.insert_events(&events).unwrap(); @@ -606,19 +668,18 @@ mod tests { // Verify order (oldest first) assert!(batch[0].id < batch[1].id); - assert!(batch[0].event_json.contains("\"t\":1")); - assert!(batch[1].event_json.contains("\"t\":2")); + assert!(batch[0].event_json.contains(&format!("\"t\":{ts1}"))); + assert!(batch[1].event_json.contains(&format!("\"t\":{ts2}"))); } #[test] fn test_mark_records_delivered() { let (mut db, _temp_dir) = create_test_db(); + let ts1 = days_ago(3); + let ts2 = days_ago(2); + let ts3 = days_ago(1); - let events = vec![ - r#"{"t":1,"e":1,"v":{},"a":{}}"#.to_string(), - r#"{"t":2,"e":1,"v":{},"a":{}}"#.to_string(), - r#"{"t":3,"e":1,"v":{},"a":{}}"#.to_string(), - ]; + let events = vec![event_json(ts1), event_json(ts2), event_json(ts3)]; db.insert_events(&events).unwrap(); @@ -626,7 +687,7 @@ mod tests { let batch = db.get_batch(2).unwrap(); let ids: Vec = batch.iter().map(|r| r.id).collect(); - db.mark_records_delivered(&ids, 1_700_000_000).unwrap(); + db.mark_records_delivered(&ids, unix_now()).unwrap(); // Verify only one remains pending. let count = db.count().unwrap(); @@ -635,7 +696,7 @@ mod tests { // Verify remaining pending row is the third one. let remaining = db.get_batch(10).unwrap(); assert_eq!(remaining.len(), 1); - assert!(remaining[0].event_json.contains("\"t\":3")); + assert!(remaining[0].event_json.contains(&format!("\"t\":{ts3}"))); // Verify delivered rows are retained. let total: i64 = db @@ -649,12 +710,11 @@ mod tests { fn test_insert_events_with_delivered_ts_skips_batch() { let (mut db, _temp_dir) = create_test_db(); - let delivered_ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let delivered = vec![r#"{"t":1,"e":1,"v":{},"a":{}}"#.to_string()]; - let pending = vec![r#"{"t":2,"e":1,"v":{},"a":{}}"#.to_string()]; + let delivered_ts = unix_now(); + let delivered_event_ts = days_ago(2); + let pending_event_ts = days_ago(1); + let delivered = vec![event_json(delivered_event_ts)]; + let pending = vec![event_json(pending_event_ts)]; db.insert_events_with_delivered_ts(&delivered, Some(delivered_ts)) .unwrap(); @@ -662,7 +722,11 @@ mod tests { let batch = db.get_batch(10).unwrap(); assert_eq!(batch.len(), 1); - assert!(batch[0].event_json.contains("\"t\":2")); + assert!( + batch[0] + .event_json + .contains(&format!("\"t\":{pending_event_ts}")) + ); assert_eq!(db.count().unwrap(), 1); let total: i64 = db @@ -676,17 +740,20 @@ mod tests { fn test_get_metric_history_reads_authoritative_metrics_table() { let (mut db, _temp_dir) = create_test_db(); - let delivered_ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let delivered = vec![ - r#"{"t":10,"e":1,"v":{},"a":{"1":"https://github.com/acme/project"}}"#.to_string(), - ]; + let delivered_ts = unix_now(); + let ts1 = days_ago(4); + let ts2 = days_ago(3); + let ts3 = days_ago(2); + let ts4 = days_ago(1); + let delivered = vec![event_json_with_repo( + ts1, + 1, + "https://github.com/acme/project", + )]; let pending = vec![ - r#"{"t":20,"e":4,"v":{},"a":{"1":"https://github.com/acme/project"}}"#.to_string(), - r#"{"t":30,"e":2,"v":{},"a":{"1":"https://github.com/acme/project"}}"#.to_string(), - r#"{"t":40,"e":5,"v":{},"a":{"1":"https://github.com/other/repo"}}"#.to_string(), + event_json_with_repo(ts2, 4, "https://github.com/acme/project"), + event_json_with_repo(ts3, 2, "https://github.com/acme/project"), + event_json_with_repo(ts4, 5, "https://github.com/other/repo"), ]; db.insert_events_with_delivered_ts(&delivered, Some(delivered_ts)) @@ -698,9 +765,9 @@ mod tests { .unwrap(); assert_eq!(records.len(), 2); assert_eq!(records[0].event_id, 1); - assert_eq!(records[0].ts, 10); + assert_eq!(records[0].ts, ts1); assert_eq!(records[1].event_id, 4); - assert_eq!(records[1].ts, 20); + assert_eq!(records[1].ts, ts2); // Delivered rows are retained for history, but only undelivered rows flush. let batch = db.get_batch(10).unwrap(); @@ -708,25 +775,36 @@ mod tests { } #[test] - fn test_prunes_only_old_delivered_metrics() { + fn test_prunes_metric_rows_older_than_retention_by_event_timestamp() { let (mut db, _temp_dir) = create_test_db(); - let old_delivered_ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - .saturating_sub(MetricsDatabase::METRICS_RETENTION_SECS + 1); - let old_delivered = vec![r#"{"t":1,"e":1,"v":{},"a":{}}"#.to_string()]; - db.insert_events_with_delivered_ts(&old_delivered, Some(old_delivered_ts)) + let delivered_ts = unix_now(); + let old_event_ts = days_ago(46); + let recent_event_ts = days_ago(44); + let events = vec![event_json(old_event_ts), event_json(recent_event_ts)]; + + db.insert_events_with_delivered_ts(&events, Some(delivered_ts)) .unwrap(); let total_after_prune: i64 = db .conn .query_row("SELECT COUNT(*) FROM metrics", [], |row| row.get(0)) .unwrap(); - assert_eq!(total_after_prune, 0); + assert_eq!(total_after_prune, 1); + + let records = db.get_metric_history(0, None, &[1]).unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].ts, recent_event_ts); + } + + #[test] + fn test_prunes_old_pending_metric_rows() { + let (mut db, _temp_dir) = create_test_db(); + + let old_event_ts = days_ago(46); + let recent_event_ts = days_ago(1); + let pending = vec![event_json(old_event_ts), event_json(recent_event_ts)]; - let pending = vec![r#"{"t":2,"e":1,"v":{},"a":{}}"#.to_string()]; db.insert_events(&pending).unwrap(); let total: i64 = db @@ -735,6 +813,30 @@ mod tests { .unwrap(); assert_eq!(total, 1); assert_eq!(db.count().unwrap(), 1); + + let batch = db.get_batch(10).unwrap(); + assert_eq!(batch.len(), 1); + assert!( + batch[0] + .event_json + .contains(&format!("\"t\":{recent_event_ts}")) + ); + } + + #[test] + fn test_prunes_malformed_delivered_rows_by_delivered_timestamp() { + let (mut db, _temp_dir) = create_test_db(); + + let old_delivered_ts = + unix_now().saturating_sub(MetricsDatabase::METRICS_RETENTION_SECS + 1); + db.insert_events_with_delivered_ts(&["not-json".to_string()], Some(old_delivered_ts)) + .unwrap(); + + let total: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM metrics", [], |row| row.get(0)) + .unwrap(); + assert_eq!(total, 0); } #[test] From 4a2448f6a22f3298c8a5d79820222d44e798ab9c Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Fri, 5 Jun 2026 18:45:22 +0000 Subject: [PATCH 096/100] tighten metrics usage performance --- src/commands/usage.rs | 2 +- src/daemon/telemetry_worker.rs | 12 +++++++----- src/metrics/db.rs | 9 ++++++++- src/metrics/local_stats.rs | 13 ++++++++----- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/commands/usage.rs b/src/commands/usage.rs index 433fbadd88..6ebb630a29 100644 --- a/src/commands/usage.rs +++ b/src/commands/usage.rs @@ -572,7 +572,7 @@ fn strip_protocol(url: &str) -> &str { /// Render a block bar where `value` out of `max` determines the fill ratio. fn ratio_bar(value: u32, max: u32, width: u32) -> String { let filled = if max > 0 { - (value * width / max).min(width) + ((value as u64 * width as u64 / max as u64).min(width as u64)) as u32 } else { 0 }; diff --git a/src/daemon/telemetry_worker.rs b/src/daemon/telemetry_worker.rs index bf29947cab..647a813493 100644 --- a/src/daemon/telemetry_worker.rs +++ b/src/daemon/telemetry_worker.rs @@ -188,11 +188,13 @@ impl DaemonTelemetryWorkerHandle { return usize::MAX; } - let pending = MetricsDatabase::global() - .ok() - .and_then(|db| db.try_lock().ok()) - .and_then(|db| db.count().ok()) - .unwrap_or(0); + let pending = match MetricsDatabase::global() { + Ok(db) => match db.try_lock() { + Ok(db) => db.count().unwrap_or(usize::MAX), + Err(_) => usize::MAX, + }, + Err(_) => 0, + }; buffered.saturating_add(pending) } diff --git a/src/metrics/db.rs b/src/metrics/db.rs index 27b90e883b..5e54396775 100644 --- a/src/metrics/db.rs +++ b/src/metrics/db.rs @@ -9,6 +9,7 @@ use crate::metrics::attrs::attr_pos; use crate::metrics::pos_encoded::sparse_get_string; use crate::metrics::types::MetricEvent; use rusqlite::{Connection, OptionalExtension, params}; +use serde::Deserialize; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; @@ -511,13 +512,19 @@ fn metric_row_is_older_than_cutoff( delivered_ts: Option, cutoff: u64, ) -> bool { - if let Ok(event) = serde_json::from_str::(event_json) { + if let Ok(event) = serde_json::from_str::(event_json) { return u64::from(event.timestamp) < cutoff; } delivered_ts.is_some_and(|ts| ts >= 0 && (ts as u64) < cutoff) } +#[derive(Deserialize)] +struct MetricTimestampOnly { + #[serde(rename = "t")] + timestamp: u32, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/metrics/local_stats.rs b/src/metrics/local_stats.rs index 6a4e6a21e2..52b2941079 100644 --- a/src/metrics/local_stats.rs +++ b/src/metrics/local_stats.rs @@ -143,6 +143,7 @@ const USAGE_EVENT_IDS: &[u16] = &[ 4, // Checkpoint 5, // SessionEvent ]; +const SESSION_RAW_JSON_KEY: &str = "0"; /// Acquire the global DB lock and fetch metric history for the given window. fn fetch_metric_history( @@ -948,7 +949,8 @@ fn aggregate_session_tokens( session_id: String, message_usage: &mut HashMap, ) { - let Some(raw) = event.values.get(&session_event_pos::RAW_JSON.to_string()) else { + debug_assert_eq!(session_event_pos::RAW_JSON, 0); + let Some(raw) = event.values.get(SESSION_RAW_JSON_KEY) else { return; }; let Some(message) = raw.get("message") else { @@ -1010,7 +1012,8 @@ fn aggregate_codex_tokens( let Some(session_id) = sparse_get_string(&event.attrs, attr_pos::SESSION_ID).flatten() else { return; }; - let Some(raw) = event.values.get(&session_event_pos::RAW_JSON.to_string()) else { + debug_assert_eq!(session_event_pos::RAW_JSON, 0); + let Some(raw) = event.values.get(SESSION_RAW_JSON_KEY) else { return; }; let Some(payload) = raw.get("payload") else { @@ -1057,10 +1060,10 @@ fn repo_summaries_from_records( ) -> Result, GitAiError> { // Group records by repo_url, skipping events with no repo (NULL) — these // predate repo_url emission and have no meaningful identity to display. - let mut by_repo: HashMap> = HashMap::new(); + let mut by_repo: HashMap<&str, Vec<&MetricHistoryRecord>> = HashMap::new(); for record in all_records { if let Some(ref url) = record.repo_url { - by_repo.entry(url.clone()).or_default().push(record); + by_repo.entry(url.as_str()).or_default().push(record); } } @@ -1071,7 +1074,7 @@ fn repo_summaries_from_records( compute_activity_from_records(&records, since_ts, String::new(), granularity) .ok()?; Some(RepoActivitySummary { - repo_url: url, + repo_url: url.to_string(), ai_lines: stats.commits.ai_lines, commits: stats.commits.total, sessions: stats.sessions.total, From ec1deae76ecc747803be9d4a0b7b123b57358b7f Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Fri, 5 Jun 2026 19:11:09 +0000 Subject: [PATCH 097/100] fix no-exec global config home fallback on Windows --- src/git/repository.rs | 63 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/src/git/repository.rs b/src/git/repository.rs index 7665f1d722..642242104e 100644 --- a/src/git/repository.rs +++ b/src/git/repository.rs @@ -16,6 +16,7 @@ use gix_index::entry::Stage; use regex::Regex; use std::cell::Cell; use std::collections::{HashMap, HashSet}; +use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -2327,9 +2328,41 @@ fn no_exec_global_config_paths() -> Vec<(PathBuf, gix_config::Source)> { } fn home_dir_from_env() -> Option { - std::env::var_os("HOME") - .filter(|value| !value.is_empty()) - .map(PathBuf::from) + env_path_from_value(std::env::var_os("HOME")).or_else(windows_home_dir_from_env) +} + +fn env_path_from_value(value: Option) -> Option { + value.filter(|value| !value.is_empty()).map(PathBuf::from) +} + +#[cfg(windows)] +fn windows_home_dir_from_env() -> Option { + windows_home_dir_from_values( + std::env::var_os("HOMEDRIVE"), + std::env::var_os("HOMEPATH"), + std::env::var_os("USERPROFILE"), + ) +} + +#[cfg(not(windows))] +fn windows_home_dir_from_env() -> Option { + None +} + +#[cfg(windows)] +fn windows_home_dir_from_values( + homedrive: Option, + homepath: Option, + userprofile: Option, +) -> Option { + if let (Some(homedrive), Some(homepath)) = ( + env_path_from_value(homedrive), + env_path_from_value(homepath), + ) { + return Some(homedrive.join(homepath)); + } + + env_path_from_value(userprofile) } fn push_existing_config_path( @@ -3068,6 +3101,30 @@ mod tests { assert!(forwarded[1].starts_with("core.hooksPath=")); } + #[cfg(windows)] + #[test] + fn windows_home_dir_values_prefer_homedrive_homepath() { + let home = windows_home_dir_from_values( + Some(OsString::from("C:")), + Some(OsString::from(r"\Users\git-ai")), + Some(OsString::from(r"D:\Users\fallback")), + ); + + assert_eq!(home, Some(PathBuf::from(r"C:\Users\git-ai"))); + } + + #[cfg(windows)] + #[test] + fn windows_home_dir_values_fall_back_to_userprofile() { + let home = windows_home_dir_from_values( + Some(OsString::from("")), + Some(OsString::from(r"\Users\git-ai")), + Some(OsString::from(r"D:\Users\fallback")), + ); + + assert_eq!(home, Some(PathBuf::from(r"D:\Users\fallback"))); + } + #[test] fn patch_profile_applies_canonical_machine_parse_flags() { let args = vec!["diff".to_string(), "HEAD^".to_string(), "HEAD".to_string()]; From 7f3a8d515adb276b010ab7d424aa39361f12c051 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Fri, 5 Jun 2026 20:08:25 +0000 Subject: [PATCH 098/100] avoid cloning telemetry envelopes on successful submit --- src/daemon/sentry_layer.rs | 2 +- src/daemon/telemetry_handle.rs | 17 ++++++++++----- src/daemon/telemetry_worker.rs | 38 ++++++++++++++++++++++++++-------- src/observability/mod.rs | 26 ++++++++++++++--------- 4 files changed, 58 insertions(+), 25 deletions(-) diff --git a/src/daemon/sentry_layer.rs b/src/daemon/sentry_layer.rs index e2a75800d3..14b1cad035 100644 --- a/src/daemon/sentry_layer.rs +++ b/src/daemon/sentry_layer.rs @@ -88,6 +88,6 @@ impl Layer for SentryLayer { context, }; - crate::daemon::telemetry_worker::submit_daemon_internal_telemetry(vec![envelope]); + let _ = crate::daemon::telemetry_worker::submit_daemon_internal_telemetry(vec![envelope]); } } diff --git a/src/daemon/telemetry_handle.rs b/src/daemon/telemetry_handle.rs index 44c7a32e00..4b3781b00f 100644 --- a/src/daemon/telemetry_handle.rs +++ b/src/daemon/telemetry_handle.rs @@ -223,14 +223,21 @@ pub fn send_via_daemon(request: &ControlRequest) -> Result) -> bool { +/// Returns the original envelopes when the daemon send failed so metric callers +/// can persist to SQLite locally instead of losing the event. +pub fn submit_telemetry(envelopes: Vec) -> Result<(), Vec> { if envelopes.is_empty() { - return true; + return Ok(()); } let request = ControlRequest::SubmitTelemetry { envelopes }; - send_via_daemon(&request).is_ok_and(|response| response.ok) + if send_via_daemon(&request).is_ok_and(|response| response.ok) { + Ok(()) + } else { + let ControlRequest::SubmitTelemetry { envelopes } = request else { + unreachable!("request was constructed as telemetry") + }; + Err(envelopes) + } } /// Submit CAS sync records to the daemon over the control socket. diff --git a/src/daemon/telemetry_worker.rs b/src/daemon/telemetry_worker.rs index 647a813493..879e79aaaf 100644 --- a/src/daemon/telemetry_worker.rs +++ b/src/daemon/telemetry_worker.rs @@ -203,25 +203,30 @@ impl DaemonTelemetryWorkerHandle { /// Used by the daemon process's own `observability::log_*()` calls which /// cannot go through the control socket (the daemon can't connect to itself). /// Uses `try_lock()` to avoid blocking the caller if the buffer is contested. - pub fn submit_telemetry_sync(&self, envelopes: Vec) -> bool { + pub fn submit_telemetry_sync( + &self, + envelopes: Vec, + ) -> Result<(), Vec> { let (buffered_envelopes, metric_events) = split_metric_envelopes(envelopes); if !metric_events.is_empty() && let Err(e) = store_metrics_in_db(&metric_events) { tracing::warn!(%e, "telemetry: failed to persist daemon metrics locally"); - return false; + return Err(rebuild_telemetry_envelopes( + buffered_envelopes, + metric_events, + )); } if buffered_envelopes.is_empty() { - return true; + return Ok(()); } if let Ok(mut buf) = self.buffer.try_lock() { buf.ingest_envelopes(buffered_envelopes); - true - } else { - false } + + Ok(()) } /// Submit CAS records synchronously (best-effort, non-blocking). @@ -250,12 +255,15 @@ pub fn set_daemon_internal_telemetry(handle: DaemonTelemetryWorkerHandle) { } /// Submit telemetry from within the daemon process. -/// Returns true if the handle was available and envelopes were submitted. -pub fn submit_daemon_internal_telemetry(envelopes: Vec) -> bool { +/// Returns the original envelopes when metrics were not persisted through the +/// in-process handle, so metric callers can fall back to SQLite directly. +pub fn submit_daemon_internal_telemetry( + envelopes: Vec, +) -> Result<(), Vec> { if let Some(handle) = DAEMON_INTERNAL_TELEMETRY.get() { handle.submit_telemetry_sync(envelopes) } else { - false + Err(envelopes) } } @@ -275,6 +283,18 @@ fn split_metric_envelopes( (buffered_envelopes, metric_events) } +fn rebuild_telemetry_envelopes( + mut buffered_envelopes: Vec, + metric_events: Vec, +) -> Vec { + if !metric_events.is_empty() { + buffered_envelopes.push(TelemetryEnvelope::Metrics { + events: metric_events, + }); + } + buffered_envelopes +} + /// Submit CAS records from within the daemon process (sync, best-effort). /// Returns true if the handle was available and records were submitted. pub fn submit_daemon_internal_cas(records: Vec) -> bool { diff --git a/src/observability/mod.rs b/src/observability/mod.rs index a3a7ae84f2..d890f072fc 100644 --- a/src/observability/mod.rs +++ b/src/observability/mod.rs @@ -15,17 +15,23 @@ pub const MAX_METRICS_PER_ENVELOPE: usize = 1000; /// 2. In-process daemon telemetry worker (daemon process itself) /// 3. Local SQLite storage for metric events if neither daemon path is available fn submit_telemetry_envelope(envelopes: Vec) { - if crate::daemon::telemetry_handle::daemon_telemetry_available() - && crate::daemon::telemetry_handle::submit_telemetry(envelopes.clone()) - { - return; - } + let envelopes = if crate::daemon::telemetry_handle::daemon_telemetry_available() { + match crate::daemon::telemetry_handle::submit_telemetry(envelopes) { + Ok(()) => return, + Err(envelopes) => envelopes, + } + } else { + envelopes + }; - if crate::daemon::daemon_process_active() - && crate::daemon::telemetry_worker::submit_daemon_internal_telemetry(envelopes.clone()) - { - return; - } + let envelopes = if crate::daemon::daemon_process_active() { + match crate::daemon::telemetry_worker::submit_daemon_internal_telemetry(envelopes) { + Ok(()) => return, + Err(envelopes) => envelopes, + } + } else { + envelopes + }; if let Err(e) = store_metrics_envelopes_locally(envelopes) { tracing::warn!(%e, "telemetry: failed to persist metrics locally"); From 731dfb99201c2e818a7afcc0814b577d10f3ec2e Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Sat, 6 Jun 2026 00:02:31 +0000 Subject: [PATCH 099/100] fix git env bool parsing for no-system config --- src/git/repository.rs | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/src/git/repository.rs b/src/git/repository.rs index 642242104e..513044a664 100644 --- a/src/git/repository.rs +++ b/src/git/repository.rs @@ -2282,10 +2282,8 @@ fn git_config_file_for_repo_paths( fn no_exec_global_config_paths() -> Vec<(PathBuf, gix_config::Source)> { let mut paths = Vec::new(); - let nosystem = std::env::var_os("GIT_CONFIG_NOSYSTEM").is_some_and(|value| { - let value = value.to_string_lossy(); - !matches!(value.as_ref(), "" | "0" | "false" | "FALSE" | "off" | "OFF") - }); + let nosystem = + std::env::var_os("GIT_CONFIG_NOSYSTEM").is_some_and(|value| !git_env_bool_is_false(&value)); if !nosystem { if let Some(path) = std::env::var_os("GIT_CONFIG_SYSTEM").map(PathBuf::from) { @@ -2327,6 +2325,14 @@ fn no_exec_global_config_paths() -> Vec<(PathBuf, gix_config::Source)> { paths } +fn git_env_bool_is_false(value: &std::ffi::OsStr) -> bool { + let value = value.to_string_lossy(); + matches!(value.as_ref(), "" | "0") + || value.eq_ignore_ascii_case("false") + || value.eq_ignore_ascii_case("no") + || value.eq_ignore_ascii_case("off") +} + fn home_dir_from_env() -> Option { env_path_from_value(std::env::var_os("HOME")).or_else(windows_home_dir_from_env) } @@ -3101,6 +3107,28 @@ mod tests { assert!(forwarded[1].starts_with("core.hooksPath=")); } + #[test] + fn git_env_bool_false_values_match_git_parsing() { + for value in [ + "", "0", "false", "FALSE", "False", "no", "NO", "No", "off", "OFF", "Off", + ] { + assert!( + git_env_bool_is_false(&OsString::from(value)), + "{value:?} should parse as false" + ); + } + } + + #[test] + fn git_env_bool_non_false_values_parse_as_true() { + for value in ["1", "true", "yes", "on", "anything-else"] { + assert!( + !git_env_bool_is_false(&OsString::from(value)), + "{value:?} should not parse as false" + ); + } + } + #[cfg(windows)] #[test] fn windows_home_dir_values_prefer_homedrive_homepath() { From 058c677bd1e5daf60dbaccc0855c36e9d9310e6b Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Sat, 6 Jun 2026 00:18:49 +0000 Subject: [PATCH 100/100] fix telemetry db metric flush ordering --- src/daemon/telemetry_worker.rs | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/daemon/telemetry_worker.rs b/src/daemon/telemetry_worker.rs index 879e79aaaf..eab205520e 100644 --- a/src/daemon/telemetry_worker.rs +++ b/src/daemon/telemetry_worker.rs @@ -151,12 +151,6 @@ impl DaemonTelemetryWorkerHandle { envelopes: Vec, ) -> Result<(), GitAiError> { let (buffered_envelopes, metric_events) = split_metric_envelopes(envelopes); - if !metric_events.is_empty() { - tokio::task::spawn_blocking(move || store_metrics_in_db(&metric_events).map(|_| ())) - .await - .map_err(|e| GitAiError::Generic(format!("metrics DB task failed: {e}")))??; - } - if !buffered_envelopes.is_empty() { self.buffer .lock() @@ -164,6 +158,12 @@ impl DaemonTelemetryWorkerHandle { .ingest_envelopes(buffered_envelopes); } + if !metric_events.is_empty() { + tokio::task::spawn_blocking(move || store_metrics_in_db(&metric_events).map(|_| ())) + .await + .map_err(|e| GitAiError::Generic(format!("metrics DB task failed: {e}")))??; + } + Ok(()) } @@ -207,7 +207,13 @@ impl DaemonTelemetryWorkerHandle { &self, envelopes: Vec, ) -> Result<(), Vec> { - let (buffered_envelopes, metric_events) = split_metric_envelopes(envelopes); + let (mut buffered_envelopes, metric_events) = split_metric_envelopes(envelopes); + if !buffered_envelopes.is_empty() + && let Ok(mut buf) = self.buffer.try_lock() + { + buf.ingest_envelopes(std::mem::take(&mut buffered_envelopes)); + } + if !metric_events.is_empty() && let Err(e) = store_metrics_in_db(&metric_events) { @@ -218,14 +224,6 @@ impl DaemonTelemetryWorkerHandle { )); } - if buffered_envelopes.is_empty() { - return Ok(()); - } - - if let Ok(mut buf) = self.buffer.try_lock() { - buf.ingest_envelopes(buffered_envelopes); - } - Ok(()) } @@ -351,9 +349,8 @@ async fn telemetry_flush_loop(buffer: Arc>) { tokio::task::spawn_blocking(move || { if let Some(snapshot) = snapshot { flush_telemetry_batch(snapshot); - } else { - flush_pending_metrics(); } + flush_pending_metrics(); }) .await .unwrap_or_else(|e| {