From 06206e9386b3b1ede4053bcd581d5dad50ff4922 Mon Sep 17 00:00:00 2001 From: Karel Rank Date: Sun, 21 Jun 2026 16:03:30 -0700 Subject: [PATCH 1/4] feat(tracking): add session_id column and per-session gain query - Add current_session_id() helper reading CLAUDE_CODE_SESSION_ID env var - Migrate commands table: session_id TEXT DEFAULT '' + index - Add SessionStat struct for per-session aggregation - Add get_by_session(filter: Option<&str>) with LIKE prefix matching - 6 unit tests: grouping, empty-id exclusion, ordering, empty db, avg pct, prefix filter - Backward compatible: ALTER TABLE ignored on existing column, old rows get empty default --- src/core/tracking.rs | 250 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 245 insertions(+), 5 deletions(-) diff --git a/src/core/tracking.rs b/src/core/tracking.rs index 359e4f688..63e2c545e 100644 --- a/src/core/tracking.rs +++ b/src/core/tracking.rs @@ -48,6 +48,11 @@ fn current_project_path_string() -> String { .unwrap_or_default() } +/// Get the current Claude Code session ID from the environment. +fn current_session_id() -> String { + std::env::var("CLAUDE_CODE_SESSION_ID").unwrap_or_default() +} + /// Build SQL filter params for project-scoped queries. /// Returns (exact_match, glob_prefix) for WHERE clause. /// Uses GLOB instead of LIKE to avoid `_` and `%` in paths acting as wildcards. // changed: GLOB @@ -133,6 +138,24 @@ pub struct GainSummary { pub by_day: Vec<(String, usize)>, } +/// Per-session statistics for token savings. +/// +/// Groups all commands recorded under the same `CLAUDE_CODE_SESSION_ID` +/// so users can see how much each Claude Code session saved. +#[derive(Debug)] +pub struct SessionStat { + /// Short session ID (first 8 chars of the UUID) + pub session_id: String, + /// UTC timestamp of the most recent command in this session + pub last_seen: DateTime, + /// Number of commands recorded for this session + pub commands: usize, + /// Total tokens saved across all commands in this session + pub saved_tokens: usize, + /// Average savings percentage across all commands in this session + pub avg_savings_pct: f64, +} + /// Daily statistics for token savings and execution metrics. /// /// Serializable to JSON for export via `rtk gain --daily --format json`. @@ -307,6 +330,15 @@ impl Tracker { "CREATE INDEX IF NOT EXISTS idx_project_path_timestamp ON commands(project_path, timestamp)", [], ); + // Migration: add session_id column for per-session gain tracking + let _ = conn.execute( + "ALTER TABLE commands ADD COLUMN session_id TEXT DEFAULT ''", + [], + ); + let _ = conn.execute( + "CREATE INDEX IF NOT EXISTS idx_session_id ON commands(session_id)", + [], + ); conn.execute( "CREATE TABLE IF NOT EXISTS parse_failures ( @@ -348,7 +380,8 @@ impl Tracker { saved_tokens INTEGER NOT NULL, savings_pct REAL NOT NULL, exec_time_ms INTEGER DEFAULT 0, - project_path TEXT DEFAULT '' + project_path TEXT DEFAULT '', + session_id TEXT DEFAULT '' )", [], )?; @@ -360,6 +393,10 @@ impl Tracker { "CREATE INDEX IF NOT EXISTS idx_project_path_timestamp ON commands(project_path, timestamp)", [], )?; + self.conn.execute( + "CREATE INDEX IF NOT EXISTS idx_session_id ON commands(session_id)", + [], + )?; self.conn.execute( "CREATE TABLE IF NOT EXISTS parse_failures ( id INTEGER PRIMARY KEY, @@ -414,16 +451,18 @@ impl Tracker { 0.0 }; - let project_path = current_project_path_string(); // added: record cwd + let project_path = current_project_path_string(); + let session_id = current_session_id(); self.conn.execute( - "INSERT INTO commands (timestamp, original_cmd, rtk_cmd, project_path, input_tokens, output_tokens, saved_tokens, savings_pct, exec_time_ms) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", // added: project_path + "INSERT INTO commands (timestamp, original_cmd, rtk_cmd, project_path, session_id, input_tokens, output_tokens, saved_tokens, savings_pct, exec_time_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![ Utc::now().to_rfc3339(), original_cmd, rtk_cmd, - project_path, // added + project_path, + session_id, input_tokens as i64, output_tokens as i64, saved as i64, @@ -961,6 +1000,44 @@ impl Tracker { Ok(rows.collect::, _>>()?) } + /// Get per-session token savings, most recent session first. + /// + /// Only returns sessions with a non-empty `session_id` (i.e. commands + /// recorded while `CLAUDE_CODE_SESSION_ID` was set in the environment). + /// Limited to the 20 most recent sessions. + /// Get per-session token savings, most recent session first. + /// + /// When `filter` is `Some(prefix)`, only sessions whose ID starts with + /// that prefix are returned (useful for short IDs like `"2a35ea7f"`). + /// When `filter` is `None`, returns up to 20 most recent sessions. + pub fn get_by_session(&self, filter: Option<&str>) -> Result> { + let prefix_filter = filter.map(|f| format!("{f}%")); + let mut stmt = self.conn.prepare( + "SELECT session_id, MAX(timestamp), COUNT(*), SUM(saved_tokens), AVG(savings_pct) + FROM commands + WHERE session_id != '' + AND (?1 IS NULL OR session_id LIKE ?1) + GROUP BY session_id + ORDER BY MAX(timestamp) DESC + LIMIT 20", + )?; + + let rows = stmt.query_map(params![prefix_filter], |row| { + let last_seen = DateTime::parse_from_rfc3339(&row.get::<_, String>(1)?) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()); + Ok(SessionStat { + session_id: row.get(0)?, + last_seen, + commands: row.get::<_, i64>(2)? as usize, + saved_tokens: row.get::<_, i64>(3)? as usize, + avg_savings_pct: row.get(4)?, + }) + })?; + + Ok(rows.collect::, _>>()?) + } + /// Count commands since a given timestamp (for telemetry). pub fn count_commands_since(&self, since: chrono::DateTime) -> Result { let ts = since.format("%Y-%m-%dT%H:%M:%S").to_string(); @@ -1686,4 +1763,167 @@ mod tests { "parse_failures table should be empty after reset" ); } + + // ── get_by_session tests ────────────────────────────────────────────────── + + fn insert_with_session( + tracker: &Tracker, + original_cmd: &str, + rtk_cmd: &str, + input_tokens: usize, + output_tokens: usize, + session_id: &str, + ) { + let saved = input_tokens.saturating_sub(output_tokens); + let pct = if input_tokens > 0 { + saved as f64 / input_tokens as f64 * 100.0 + } else { + 0.0 + }; + tracker + .conn + .execute( + "INSERT INTO commands (timestamp, original_cmd, rtk_cmd, project_path, session_id, + input_tokens, output_tokens, saved_tokens, savings_pct, exec_time_ms) + VALUES (datetime('now'), ?1, ?2, '', ?3, ?4, ?5, ?6, ?7, 10)", + rusqlite::params![ + original_cmd, + rtk_cmd, + session_id, + input_tokens as i64, + output_tokens as i64, + saved as i64, + pct, + ], + ) + .expect("insert failed"); + } + + #[test] + fn test_get_by_session_groups_correctly() { + let tracker = Tracker::new_in_memory().expect("in-memory tracker"); + + insert_with_session(&tracker, "git log", "rtk git log", 1000, 100, "sess-aaa"); + insert_with_session(&tracker, "git status", "rtk git status", 500, 50, "sess-aaa"); + insert_with_session(&tracker, "cargo test", "rtk cargo test", 2000, 200, "sess-bbb"); + + let sessions = tracker.get_by_session(None).expect("get_by_session failed"); + assert_eq!(sessions.len(), 2, "should have 2 distinct sessions"); + + let aaa = sessions.iter().find(|s| s.session_id == "sess-aaa").unwrap(); + assert_eq!(aaa.commands, 2); + assert_eq!(aaa.saved_tokens, 1350); // (1000-100) + (500-50) + + let bbb = sessions.iter().find(|s| s.session_id == "sess-bbb").unwrap(); + assert_eq!(bbb.commands, 1); + assert_eq!(bbb.saved_tokens, 1800); + } + + #[test] + fn test_get_by_session_excludes_empty_session_id() { + let tracker = Tracker::new_in_memory().expect("in-memory tracker"); + + // Insert directly with explicit empty session_id (simulates pre-session-tracking records) + tracker + .conn + .execute( + "INSERT INTO commands (timestamp, original_cmd, rtk_cmd, project_path, session_id, + input_tokens, output_tokens, saved_tokens, savings_pct, exec_time_ms) + VALUES (datetime('now'), 'git status', 'rtk git status', '', '', + 500, 50, 450, 90.0, 10)", + [], + ) + .expect("insert failed"); + // command with a session ID + insert_with_session(&tracker, "git log", "rtk git log", 1000, 100, "sess-aaa"); + + let sessions = tracker.get_by_session(None).expect("get_by_session failed"); + assert_eq!(sessions.len(), 1, "empty session_id must be excluded"); + assert_eq!(sessions[0].session_id, "sess-aaa"); + } + + #[test] + fn test_get_by_session_orders_most_recent_first() { + let tracker = Tracker::new_in_memory().expect("in-memory tracker"); + + // Insert older session first + tracker + .conn + .execute( + "INSERT INTO commands (timestamp, original_cmd, rtk_cmd, project_path, session_id, + input_tokens, output_tokens, saved_tokens, savings_pct, exec_time_ms) + VALUES ('2026-01-01T00:00:00Z', 'git log', 'rtk git log', '', 'sess-old', + 1000, 100, 900, 90.0, 10)", + [], + ) + .expect("insert failed"); + tracker + .conn + .execute( + "INSERT INTO commands (timestamp, original_cmd, rtk_cmd, project_path, session_id, + input_tokens, output_tokens, saved_tokens, savings_pct, exec_time_ms) + VALUES ('2026-06-01T00:00:00Z', 'cargo test', 'rtk cargo test', '', 'sess-new', + 2000, 200, 1800, 90.0, 10)", + [], + ) + .expect("insert failed"); + + let sessions = tracker.get_by_session(None).expect("get_by_session failed"); + assert_eq!(sessions[0].session_id, "sess-new", "newest session must be first"); + assert_eq!(sessions[1].session_id, "sess-old"); + } + + #[test] + fn test_get_by_session_empty_db() { + let tracker = Tracker::new_in_memory().expect("in-memory tracker"); + let sessions = tracker.get_by_session(None).expect("get_by_session failed"); + assert!(sessions.is_empty(), "empty db should return empty vec"); + } + + #[test] + fn test_get_by_session_prefix_filter() { + let tracker = Tracker::new_in_memory().expect("in-memory tracker"); + + insert_with_session(&tracker, "git log", "rtk git log", 1000, 100, "aaaa-1111"); + insert_with_session(&tracker, "git status", "rtk git status", 500, 50, "aaaa-2222"); + insert_with_session(&tracker, "cargo test", "rtk cargo test", 2000, 200, "bbbb-3333"); + + // filter by "aaaa" prefix matches both aaaa-* sessions + let filtered = tracker + .get_by_session(Some("aaaa")) + .expect("get_by_session failed"); + assert_eq!(filtered.len(), 2, "prefix 'aaaa' should match 2 sessions"); + assert!(filtered.iter().all(|s| s.session_id.starts_with("aaaa"))); + + // filter by full ID matches exactly one + let exact = tracker + .get_by_session(Some("aaaa-1111")) + .expect("get_by_session failed"); + assert_eq!(exact.len(), 1); + assert_eq!(exact[0].session_id, "aaaa-1111"); + + // filter by non-matching prefix returns empty + let none = tracker + .get_by_session(Some("zzzz")) + .expect("get_by_session failed"); + assert!(none.is_empty()); + } + + #[test] + fn test_get_by_session_savings_pct() { + let tracker = Tracker::new_in_memory().expect("in-memory tracker"); + + // 80% savings + insert_with_session(&tracker, "git log", "rtk git log", 1000, 200, "sess-x"); + // 60% savings + insert_with_session(&tracker, "git status", "rtk git status", 1000, 400, "sess-x"); + + let sessions = tracker.get_by_session(None).expect("get_by_session failed"); + assert_eq!(sessions.len(), 1); + let pct = sessions[0].avg_savings_pct; + assert!( + (pct - 70.0).abs() < 1.0, + "avg savings pct should be ~70%, got {pct:.1}%" + ); + } } From ccc3d3279e08b5af384efcc4e4c5ced3e5f7e0d6 Mon Sep 17 00:00:00 2001 From: Karel Rank Date: Sun, 21 Jun 2026 16:03:38 -0700 Subject: [PATCH 2/4] feat(gain): add --session flag for per-session token savings view - rtk gain --session show all tracked sessions (most recent first) - rtk gain --session filter by session ID prefix (e.g. '2a35ea7f') - show_session_view(): table with short ID, relative age, cmds, saved, avg% - Empty-state message for installs predating session tracking - Uses clap num_args=0..=1 + default_missing_value='' for optional arg --- src/analytics/gain.rs | 83 +++++++++++++++++++++++++++++++++++++++++-- src/main.rs | 9 +++-- 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/src/analytics/gain.rs b/src/analytics/gain.rs index ac61dd9b5..d99014881 100644 --- a/src/analytics/gain.rs +++ b/src/analytics/gain.rs @@ -1,11 +1,11 @@ //! Shows users how many tokens RTK has saved them over time. use crate::core::display_helpers::{format_duration, print_period_table}; -use crate::core::tracking::{DayStats, MonthStats, Tracker, WeekStats}; +use crate::core::tracking::{DayStats, MonthStats, SessionStat, Tracker, WeekStats}; use crate::core::utils::format_tokens; use crate::hooks::hook_check; use anyhow::{Context, Result}; -use chrono::Local; +use chrono::{Local, Utc}; use colored::Colorize; use serde::Serialize; use std::io::IsTerminal; @@ -13,9 +13,10 @@ use std::path::PathBuf; #[allow(clippy::too_many_arguments)] pub fn run( - project: bool, // added: per-project scope flag + project: bool, graph: bool, history: bool, + session: Option<&str>, quota: bool, tier: &str, daily: bool, @@ -47,6 +48,15 @@ pub fn run( return show_failures(&tracker); } + if let Some(session_filter) = session { + // "" means bare --session (show all); non-empty means filter by prefix + let filter = if session_filter.is_empty() { None } else { Some(session_filter) }; + let sessions = tracker + .get_by_session(filter) + .context("Failed to load session data from database")?; + return show_session_view(&sessions, filter); + } + // Handle export formats match format { "json" => { @@ -684,6 +694,73 @@ fn check_rtk_disabled_bypass() -> Option { } } +fn show_session_view(sessions: &[SessionStat], filter: Option<&str>) -> Result<()> { + if sessions.is_empty() { + if let Some(f) = filter { + println!("No session found matching '{f}'."); + } else { + println!("No session data yet."); + println!( + "Session tracking requires CLAUDE_CODE_SESSION_ID to be set (Claude Code ≥ v2.x)." + ); + } + return Ok(()); + } + + let title = if filter.is_some() { + format!("RTK Session Savings — {}", sessions[0].session_id) + } else { + "RTK Session Savings".to_string() + }; + println!("{}", styled(&title, true)); + println!("{}", "─".repeat(62)); + println!( + "{:<10} {:<12} {:>5} {:>8} {:>6}", + "Session", "Last Seen", "Cmds", "Saved", "Avg%" + ); + println!("{}", "─".repeat(62)); + + for s in sessions { + let age = { + let secs = (Utc::now() - s.last_seen).num_seconds().max(0) as u64; + if secs < 60 { + "just now".to_string() + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86400 { + format!("{}h ago", secs / 3600) + } else { + format!("{}d ago", secs / 86400) + } + }; + let short_id = if s.session_id.len() >= 8 { + &s.session_id[..8] + } else { + &s.session_id + }; + let pct_cell = colorize_pct_cell(s.avg_savings_pct, &format!("{:.1}%", s.avg_savings_pct)); + println!( + "{:<10} {:<12} {:>5} {:>8} {}", + short_id, + age, + s.commands, + format_tokens(s.saved_tokens), + pct_cell, + ); + } + + println!("{}", "─".repeat(62)); + let total_cmds: usize = sessions.iter().map(|s| s.commands).sum(); + let total_saved: usize = sessions.iter().map(|s| s.saved_tokens).sum(); + println!( + "Total: {} sessions • {} commands • {} saved", + sessions.len(), + total_cmds, + format_tokens(total_saved), + ); + Ok(()) +} + fn show_failures(tracker: &Tracker) -> Result<()> { let summary = tracker .get_parse_failure_summary() diff --git a/src/main.rs b/src/main.rs index c55751dfc..6f22ca1d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -430,6 +430,9 @@ enum Commands { /// Output format: text, json, csv #[arg(short, long, default_value = "text")] format: String, + /// Show per-session token savings; optionally filter to a specific session ID prefix + #[arg(short = 'S', long, value_name = "SESSION_ID", num_args = 0..=1, default_missing_value = "")] + session: Option, /// Show parse failure log (commands that fell back to raw execution) #[arg(short = 'F', long)] failures: bool, @@ -1990,9 +1993,10 @@ fn run_cli() -> Result { Commands::Wc { args } => wc_cmd::run(&args, cli.verbose)?, Commands::Gain { - project, // added + project, graph, history, + session, quota, tier, daily, @@ -2005,9 +2009,10 @@ fn run_cli() -> Result { yes, } => { analytics::gain::run( - project, // added: pass project flag + project, graph, history, + session.as_deref(), quota, &tier, daily, From 8c8154e139c22c740ce86844d5831d3a96ab1bdd Mon Sep 17 00:00:00 2001 From: Karel Rank Date: Sun, 21 Jun 2026 16:13:49 -0700 Subject: [PATCH 3/4] fix(gain): review fixes + --session shows full gain detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LIKE → GLOB for session prefix filter (matches project_path convention) - COALESCE(SUM/AVG) guards against NULL on empty groups - LIMIT only applies to bare --session; prefix filter returns all matches - show_session_view: title shows prefix not first session ID - show_session_view: chars().take(8) instead of byte-index slice - --session : show full rtk gain detail view scoped to that session (by-command table, efficiency meter, exec time) via get_summary_for_session() --- src/analytics/gain.rs | 141 +++++++++++++++++++++++++++++++++++++----- src/core/tracking.rs | 115 +++++++++++++++++++++++++++++++--- 2 files changed, 231 insertions(+), 25 deletions(-) diff --git a/src/analytics/gain.rs b/src/analytics/gain.rs index d99014881..3c6958ab7 100644 --- a/src/analytics/gain.rs +++ b/src/analytics/gain.rs @@ -1,7 +1,7 @@ //! Shows users how many tokens RTK has saved them over time. use crate::core::display_helpers::{format_duration, print_period_table}; -use crate::core::tracking::{DayStats, MonthStats, SessionStat, Tracker, WeekStats}; +use crate::core::tracking::{DayStats, GainSummary, MonthStats, SessionStat, Tracker, WeekStats}; use crate::core::utils::format_tokens; use crate::hooks::hook_check; use anyhow::{Context, Result}; @@ -49,12 +49,19 @@ pub fn run( } if let Some(session_filter) = session { - // "" means bare --session (show all); non-empty means filter by prefix - let filter = if session_filter.is_empty() { None } else { Some(session_filter) }; - let sessions = tracker - .get_by_session(filter) - .context("Failed to load session data from database")?; - return show_session_view(&sessions, filter); + if session_filter.is_empty() { + // bare --session: show session list + let sessions = tracker + .get_by_session(None) + .context("Failed to load session data from database")?; + return show_session_view(&sessions, None); + } else { + // --session : show full gain detail scoped to this session + let summary = tracker + .get_summary_for_session(session_filter) + .context("Failed to load session summary from database")?; + return show_session_detail(session_filter, &summary); + } } // Handle export formats @@ -707,10 +714,9 @@ fn show_session_view(sessions: &[SessionStat], filter: Option<&str>) -> Result<( return Ok(()); } - let title = if filter.is_some() { - format!("RTK Session Savings — {}", sessions[0].session_id) - } else { - "RTK Session Savings".to_string() + let title = match filter { + Some(f) => format!("RTK Session Savings — prefix '{f}'"), + None => "RTK Session Savings".to_string(), }; println!("{}", styled(&title, true)); println!("{}", "─".repeat(62)); @@ -733,11 +739,7 @@ fn show_session_view(sessions: &[SessionStat], filter: Option<&str>) -> Result<( format!("{}d ago", secs / 86400) } }; - let short_id = if s.session_id.len() >= 8 { - &s.session_id[..8] - } else { - &s.session_id - }; + let short_id: String = s.session_id.chars().take(8).collect(); let pct_cell = colorize_pct_cell(s.avg_savings_pct, &format!("{:.1}%", s.avg_savings_pct)); println!( "{:<10} {:<12} {:>5} {:>8} {}", @@ -761,6 +763,113 @@ fn show_session_view(sessions: &[SessionStat], filter: Option<&str>) -> Result<( Ok(()) } +fn show_session_detail(session_filter: &str, summary: &GainSummary) -> Result<()> { + if summary.total_commands == 0 { + println!("No data found for session '{session_filter}'."); + return Ok(()); + } + + println!( + "{}", + styled( + &format!("RTK Token Savings — Session {session_filter}"), + true + ) + ); + println!("{}", "═".repeat(60)); + println!(); + + print_kpi("Total commands", summary.total_commands.to_string()); + print_kpi("Input tokens", format_tokens(summary.total_input)); + print_kpi("Output tokens", format_tokens(summary.total_output)); + print_kpi( + "Tokens saved", + format!( + "{} ({:.1}%)", + format_tokens(summary.total_saved), + summary.avg_savings_pct + ), + ); + print_kpi( + "Total exec time", + format!( + "{} (avg {})", + format_duration(summary.total_time_ms), + format_duration(summary.avg_time_ms) + ), + ); + print_efficiency_meter(summary.avg_savings_pct); + println!(); + + if !summary.by_command.is_empty() { + println!("{}", styled("By Command", true)); + + let cmd_width = 24usize; + let impact_width = 10usize; + let count_width = summary + .by_command + .iter() + .map(|(_, count, _, _, _)| count.to_string().len()) + .max() + .unwrap_or(5) + .max(5); + let saved_width = summary + .by_command + .iter() + .map(|(_, _, saved, _, _)| format_tokens(*saved).len()) + .max() + .unwrap_or(5) + .max(5); + let time_width = summary + .by_command + .iter() + .map(|(_, _, _, _, avg_time)| format_duration(*avg_time).len()) + .max() + .unwrap_or(6) + .max(6); + + let table_width = + 3 + 2 + cmd_width + 2 + count_width + 2 + saved_width + 2 + 6 + 2 + time_width + 2 + impact_width; + println!("{}", "─".repeat(table_width)); + println!( + "{:>3} {:count_width$} {:>saved_width$} {:>6} {:>time_width$} {:2}.", idx + 1); + let cmd_cell = style_command_cell(&truncate_for_column(cmd, cmd_width)); + let count_cell = format!("{:>count_width$}", count, count_width = count_width); + let saved_cell = + format!("{:>saved_width$}", format_tokens(*saved), saved_width = saved_width); + let pct_plain = format!("{:>6}", format!("{pct:.1}%")); + let pct_cell = colorize_pct_cell(*pct, &pct_plain); + let time_cell = + format!("{:>time_width$}", format_duration(*avg_time), time_width = time_width); + let impact = mini_bar(*saved, max_saved, impact_width); + println!( + "{} {} {} {} {} {} {}", + row_idx, cmd_cell, count_cell, saved_cell, pct_cell, time_cell, impact + ); + } + println!("{}", "─".repeat(table_width)); + println!(); + } + + Ok(()) +} + fn show_failures(tracker: &Tracker) -> Result<()> { let summary = tracker .get_parse_failure_summary() diff --git a/src/core/tracking.rs b/src/core/tracking.rs index 63e2c545e..a9adf90a3 100644 --- a/src/core/tracking.rs +++ b/src/core/tracking.rs @@ -1000,31 +1000,128 @@ impl Tracker { Ok(rows.collect::, _>>()?) } - /// Get per-session token savings, most recent session first. + /// Get full gain summary scoped to sessions whose ID starts with `prefix`. /// - /// Only returns sessions with a non-empty `session_id` (i.e. commands - /// recorded while `CLAUDE_CODE_SESSION_ID` was set in the environment). - /// Limited to the 20 most recent sessions. + /// Mirrors `get_summary_filtered` but filters by `session_id GLOB prefix*` + /// instead of project path. Used by `rtk gain --session `. + pub fn get_summary_for_session(&self, prefix: &str) -> Result { + let glob = format!("{prefix}*"); + let mut total_commands = 0usize; + let mut total_input = 0usize; + let mut total_output = 0usize; + let mut total_saved = 0usize; + let mut total_time_ms = 0u64; + + let mut stmt = self.conn.prepare( + "SELECT input_tokens, output_tokens, saved_tokens, exec_time_ms + FROM commands + WHERE session_id GLOB ?1", + )?; + let rows = stmt.query_map(params![glob], |row| { + Ok(( + row.get::<_, i64>(0)? as usize, + row.get::<_, i64>(1)? as usize, + row.get::<_, i64>(2)? as usize, + row.get::<_, i64>(3)? as u64, + )) + })?; + for row in rows { + let (input, output, saved, time_ms) = row?; + total_commands += 1; + total_input += input; + total_output += output; + total_saved += saved; + total_time_ms += time_ms; + } + + let avg_savings_pct = if total_input > 0 { + (total_saved as f64 / total_input as f64) * 100.0 + } else { + 0.0 + }; + let avg_time_ms = if total_commands > 0 { + total_time_ms / total_commands as u64 + } else { + 0 + }; + + let by_command = { + let mut stmt = self.conn.prepare( + "SELECT rtk_cmd, COUNT(*), SUM(saved_tokens), AVG(savings_pct), AVG(exec_time_ms) + FROM commands + WHERE session_id GLOB ?1 + GROUP BY rtk_cmd + ORDER BY SUM(saved_tokens) DESC + LIMIT 10", + )?; + let rows = stmt.query_map(params![glob], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)? as usize, + row.get::<_, i64>(2)? as usize, + row.get::<_, f64>(3)?, + row.get::<_, f64>(4)? as u64, + )) + })?; + rows.collect::, _>>()? + }; + + let by_day = { + let mut stmt = self.conn.prepare( + "SELECT DATE(timestamp), SUM(saved_tokens) + FROM commands + WHERE session_id GLOB ?1 + GROUP BY DATE(timestamp) + ORDER BY DATE(timestamp) ASC + LIMIT 30", + )?; + let rows = stmt.query_map(params![glob], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)) + })?; + rows.collect::, _>>()? + }; + + Ok(GainSummary { + total_commands, + total_input, + total_output, + total_saved, + avg_savings_pct, + total_time_ms, + avg_time_ms, + by_command, + by_day, + }) + } + /// Get per-session token savings, most recent session first. /// /// When `filter` is `Some(prefix)`, only sessions whose ID starts with /// that prefix are returned (useful for short IDs like `"2a35ea7f"`). /// When `filter` is `None`, returns up to 20 most recent sessions. + /// Rows with empty `session_id` (recorded outside Claude Code) are excluded. pub fn get_by_session(&self, filter: Option<&str>) -> Result> { - let prefix_filter = filter.map(|f| format!("{f}%")); + // Use GLOB (not LIKE) to match project convention — avoids _ and % in user input + // acting as wildcards. GLOB uses * for any-sequence, consistent with project_path filter. + let prefix_filter = filter.map(|f| format!("{f}*")); + // LIMIT only applies to the "show all" view; prefix searches return all matches. + let limit: i64 = if filter.is_some() { i64::MAX } else { 20 }; let mut stmt = self.conn.prepare( - "SELECT session_id, MAX(timestamp), COUNT(*), SUM(saved_tokens), AVG(savings_pct) + "SELECT session_id, MAX(timestamp), COUNT(*), + COALESCE(SUM(saved_tokens), 0), COALESCE(AVG(savings_pct), 0.0) FROM commands WHERE session_id != '' - AND (?1 IS NULL OR session_id LIKE ?1) + AND (?1 IS NULL OR session_id GLOB ?1) GROUP BY session_id ORDER BY MAX(timestamp) DESC - LIMIT 20", + LIMIT ?2", )?; - let rows = stmt.query_map(params![prefix_filter], |row| { + let rows = stmt.query_map(params![prefix_filter, limit], |row| { let last_seen = DateTime::parse_from_rfc3339(&row.get::<_, String>(1)?) .map(|dt| dt.with_timezone(&Utc)) + // Deliberate fallback: a corrupt timestamp floats to top rather than + // failing the entire query — consistent with get_recent_filtered. .unwrap_or_else(|_| Utc::now()); Ok(SessionStat { session_id: row.get(0)?, From 83214834a8ca203247e27597ec91d7b6457b0c8b Mon Sep 17 00:00:00 2001 From: Karel Rank Date: Sun, 21 Jun 2026 16:20:58 -0700 Subject: [PATCH 4/4] refactor(gain): extract print_summary_kpis and print_by_command_table helpers Eliminates the duplicated table-rendering code between run() and show_session_detail() by factoring it into two shared private functions. Co-Authored-By: Claude Sonnet 4.6 --- src/analytics/gain.rs | 286 +++++++++++++++--------------------------- 1 file changed, 102 insertions(+), 184 deletions(-) diff --git a/src/analytics/gain.rs b/src/analytics/gain.rs index 3c6958ab7..ecabbef5e 100644 --- a/src/analytics/gain.rs +++ b/src/analytics/gain.rs @@ -115,28 +115,7 @@ pub fn run( } println!(); - // added: KPI-style aligned output - print_kpi("Total commands", summary.total_commands.to_string()); - print_kpi("Input tokens", format_tokens(summary.total_input)); - print_kpi("Output tokens", format_tokens(summary.total_output)); - print_kpi( - "Tokens saved", - format!( - "{} ({:.1}%)", - format_tokens(summary.total_saved), - summary.avg_savings_pct - ), - ); - print_kpi( - "Total exec time", - format!( - "{} (avg {})", - format_duration(summary.total_time_ms), - format_duration(summary.avg_time_ms) - ), - ); - print_efficiency_meter(summary.avg_savings_pct); - println!(); + print_summary_kpis(&summary); // Warn about hook issues that silently kill savings (stderr, not stdout) match hook_check::status() { @@ -164,90 +143,7 @@ pub fn run( eprintln!(); } - if !summary.by_command.is_empty() { - // added: styled section header - println!("{}", styled("By Command", true)); - - // added: dynamic column widths for clean alignment - let cmd_width = 24usize; - let impact_width = 10usize; - let count_width = summary - .by_command - .iter() - .map(|(_, count, _, _, _)| count.to_string().len()) - .max() - .unwrap_or(5) - .max(5); - let saved_width = summary - .by_command - .iter() - .map(|(_, _, saved, _, _)| format_tokens(*saved).len()) - .max() - .unwrap_or(5) - .max(5); - let time_width = summary - .by_command - .iter() - .map(|(_, _, _, _, avg_time)| format_duration(*avg_time).len()) - .max() - .unwrap_or(6) - .max(6); - - let table_width = 3 - + 2 - + cmd_width - + 2 - + count_width - + 2 - + saved_width - + 2 - + 6 - + 2 - + time_width - + 2 - + impact_width; - println!("{}", "─".repeat(table_width)); - println!( - "{:>3} {:count_width$} {:>saved_width$} {:>6} {:>time_width$} {:2}.", idx + 1); - let cmd_cell = style_command_cell(&truncate_for_column(cmd, cmd_width)); // added: colored command - let count_cell = format!("{:>count_width$}", count, count_width = count_width); - let saved_cell = format!( - "{:>saved_width$}", - format_tokens(*saved), - saved_width = saved_width - ); - let pct_plain = format!("{:>6}", format!("{pct:.1}%")); - let pct_cell = colorize_pct_cell(*pct, &pct_plain); // added: color-coded percentage - let time_cell = format!( - "{:>time_width$}", - format_duration(*avg_time), - time_width = time_width - ); - let impact = mini_bar(*saved, max_saved, impact_width); // added: impact bar - println!( - "{} {} {} {} {} {} {}", - row_idx, cmd_cell, count_cell, saved_cell, pct_cell, time_cell, impact - ); - } - println!("{}", "─".repeat(table_width)); - println!(); - } + print_by_command_table(&summary.by_command); if graph && !summary.by_day.is_empty() { println!("{}", styled("Daily Savings (last 30 days)", true)); // added: styled header @@ -763,22 +659,7 @@ fn show_session_view(sessions: &[SessionStat], filter: Option<&str>) -> Result<( Ok(()) } -fn show_session_detail(session_filter: &str, summary: &GainSummary) -> Result<()> { - if summary.total_commands == 0 { - println!("No data found for session '{session_filter}'."); - return Ok(()); - } - - println!( - "{}", - styled( - &format!("RTK Token Savings — Session {session_filter}"), - true - ) - ); - println!("{}", "═".repeat(60)); - println!(); - +fn print_summary_kpis(summary: &GainSummary) { print_kpi("Total commands", summary.total_commands.to_string()); print_kpi("Input tokens", format_tokens(summary.total_input)); print_kpi("Output tokens", format_tokens(summary.total_output)); @@ -800,72 +681,109 @@ fn show_session_detail(session_filter: &str, summary: &GainSummary) -> Result<() ); print_efficiency_meter(summary.avg_savings_pct); println!(); +} - if !summary.by_command.is_empty() { - println!("{}", styled("By Command", true)); - - let cmd_width = 24usize; - let impact_width = 10usize; - let count_width = summary - .by_command - .iter() - .map(|(_, count, _, _, _)| count.to_string().len()) - .max() - .unwrap_or(5) - .max(5); - let saved_width = summary - .by_command - .iter() - .map(|(_, _, saved, _, _)| format_tokens(*saved).len()) - .max() - .unwrap_or(5) - .max(5); - let time_width = summary - .by_command - .iter() - .map(|(_, _, _, _, avg_time)| format_duration(*avg_time).len()) - .max() - .unwrap_or(6) - .max(6); - - let table_width = - 3 + 2 + cmd_width + 2 + count_width + 2 + saved_width + 2 + 6 + 2 + time_width + 2 + impact_width; - println!("{}", "─".repeat(table_width)); +fn print_by_command_table(by_command: &[(String, usize, usize, f64, u64)]) { + if by_command.is_empty() { + return; + } + println!("{}", styled("By Command", true)); + + let cmd_width = 24usize; + let impact_width = 10usize; + let count_width = by_command + .iter() + .map(|(_, count, _, _, _)| count.to_string().len()) + .max() + .unwrap_or(5) + .max(5); + let saved_width = by_command + .iter() + .map(|(_, _, saved, _, _)| format_tokens(*saved).len()) + .max() + .unwrap_or(5) + .max(5); + let time_width = by_command + .iter() + .map(|(_, _, _, _, avg_time)| format_duration(*avg_time).len()) + .max() + .unwrap_or(6) + .max(6); + + let table_width = 3 + + 2 + + cmd_width + + 2 + + count_width + + 2 + + saved_width + + 2 + + 6 + + 2 + + time_width + + 2 + + impact_width; + println!("{}", "─".repeat(table_width)); + println!( + "{:>3} {:count_width$} {:>saved_width$} {:>6} {:>time_width$} {:2}.", idx + 1); + let cmd_cell = style_command_cell(&truncate_for_column(cmd, cmd_width)); + let count_cell = format!("{:>count_width$}", count, count_width = count_width); + let saved_cell = format!( + "{:>saved_width$}", + format_tokens(*saved), + saved_width = saved_width + ); + let pct_plain = format!("{:>6}", format!("{pct:.1}%")); + let pct_cell = colorize_pct_cell(*pct, &pct_plain); + let time_cell = format!( + "{:>time_width$}", + format_duration(*avg_time), + time_width = time_width + ); + let impact = mini_bar(*saved, max_saved, impact_width); println!( - "{:>3} {:count_width$} {:>saved_width$} {:>6} {:>time_width$} {:2}.", idx + 1); - let cmd_cell = style_command_cell(&truncate_for_column(cmd, cmd_width)); - let count_cell = format!("{:>count_width$}", count, count_width = count_width); - let saved_cell = - format!("{:>saved_width$}", format_tokens(*saved), saved_width = saved_width); - let pct_plain = format!("{:>6}", format!("{pct:.1}%")); - let pct_cell = colorize_pct_cell(*pct, &pct_plain); - let time_cell = - format!("{:>time_width$}", format_duration(*avg_time), time_width = time_width); - let impact = mini_bar(*saved, max_saved, impact_width); - println!( - "{} {} {} {} {} {} {}", - row_idx, cmd_cell, count_cell, saved_cell, pct_cell, time_cell, impact - ); - } - println!("{}", "─".repeat(table_width)); - println!(); } + println!("{}", "─".repeat(table_width)); + println!(); +} + +fn show_session_detail(session_filter: &str, summary: &GainSummary) -> Result<()> { + if summary.total_commands == 0 { + println!("No data found for session '{session_filter}'."); + return Ok(()); + } + + println!( + "{}", + styled( + &format!("RTK Token Savings — Session {session_filter}"), + true + ) + ); + println!("{}", "═".repeat(60)); + println!(); + + print_summary_kpis(summary); + + print_by_command_table(&summary.by_command); Ok(()) }