initial commit

This commit is contained in:
2026-02-18 16:56:59 -05:00
commit 5cedfd1aca
10 changed files with 5494 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

113
AGENTS.md Normal file
View File

@@ -0,0 +1,113 @@
# claude-stats - Agent Interface Guide
CLI for Claude Code usage statistics. Designed for both human and AI agent use.
## Quick Start (Agents)
```bash
# JSON output (auto-enabled when piped)
claude-stats --json # Summary with tokens, costs, efficiency
claude-stats daily --json # Daily breakdown
claude-stats efficiency --json # Cache hit rate, cost analysis
```
## Commands
| Command | Purpose | Example |
|---------|---------|---------|
| (none) | Summary stats | `claude-stats --json` |
| `daily` | Daily breakdown | `claude-stats daily -n 7 --json` |
| `weekly` | Weekly breakdown | `claude-stats weekly -n 4 --json` |
| `sessions` | Recent sessions | `claude-stats sessions -n 10 --json` |
| `projects` | By project | `claude-stats projects --json` |
| `hourly` | By hour | `claude-stats hourly --json` |
| `models` | Model usage | `claude-stats models --json` |
| `efficiency` | Cache/cost metrics | `claude-stats efficiency --json` |
| `all` | Everything | `claude-stats all --json` |
## Flags
| Flag | Purpose |
|------|---------|
| `--json` | JSON output (auto when piped) |
| `-n N` | Limit/days count |
| `-q` | Quiet (no progress) |
| `-d PATH` | Custom data dir |
## JSON Response Format
```json
{
"ok": true,
"data": { ... },
"meta": {
"sessions_parsed": 2478,
"files_scanned": 2478,
"period_days": 30
}
}
```
## Error Format
```json
{
"ok": false,
"error": {
"code": 2,
"message": "Unknown command 'daytime'",
"suggestions": [
"claude-stats daily -n 7",
"claude-stats --help"
]
}
}
```
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | No data found |
| 2 | Invalid arguments |
| 3 | Data directory not found |
| 4 | Parse error |
## Error Tolerance
The CLI is forgiving of minor syntax issues:
| What You Type | Interpreted As |
|---------------|----------------|
| `daly`, `dayly` | `daily` |
| `session` | `sessions` |
| `mod`, `model` | `models` |
| `eff`, `efficency` | `efficiency` |
| `stats`, `summary` | `all` |
| `-json`, `-J` | `--json` |
| `-l`, `--limit` | `-n` |
| `-n7` | `-n 7` |
| `--days=7` | `--days 7` |
## Example Workflows
### Get token usage for analysis
```bash
claude-stats --json | jq '.data.tokens.total_billed'
```
### Check cache efficiency
```bash
claude-stats efficiency --json | jq '.data.cache_hit_rate'
```
### Daily usage trend
```bash
claude-stats daily -n 7 --json | jq '.data[] | {date, tokens}'
```
### Find highest usage project
```bash
claude-stats projects --json | jq '.data[0]'
```

1721
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

49
Cargo.toml Normal file
View File

@@ -0,0 +1,49 @@
[package]
name = "claude-stats"
version = "0.2.0"
edition = "2021"
description = "Detailed usage statistics for Claude Code - TUI & CLI"
[dependencies]
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Time handling
chrono = { version = "0.4", features = ["serde"] }
# CLI parsing
clap = { version = "4.4", features = ["derive"] }
# File system
walkdir = "2.4"
dirs = "5.0"
# Parallel processing
rayon = "1.8"
# Progress indication (CLI mode)
indicatif = { version = "0.17", features = ["rayon"] }
# CLI output
comfy-table = "7.1"
colored = "2.1"
num-format = "0.4"
# Error handling
anyhow = "1.0"
# TUI framework
ratatui = "0.29"
crossterm = "0.28"
# Clipboard
arboard = "3.4"
base64 = "0.22"
# Unicode width for proper text rendering
unicode-width = "0.2"
[profile.release]
opt-level = 3
lto = true

464
src/data.rs Normal file
View File

@@ -0,0 +1,464 @@
// Data structures and parsing logic for Claude Code statistics
use anyhow::{Context, Result};
use chrono::{DateTime, Datelike, Duration, IsoWeek, Local, NaiveDate, Timelike, Utc};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use walkdir::WalkDir;
// ═══════════════════════════════════════════════════════════════════════════════
// DATA STRUCTURES
// ═══════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Deserialize)]
pub struct SessionEntry {
#[serde(rename = "type")]
pub entry_type: Option<String>,
pub timestamp: Option<String>,
#[serde(rename = "sessionId")]
pub session_id: Option<String>,
pub message: Option<Message>,
pub cwd: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct Message {
pub role: Option<String>,
pub model: Option<String>,
pub usage: Option<Usage>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Usage {
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cache_creation_input_tokens: Option<u64>,
pub cache_read_input_tokens: Option<u64>,
}
#[derive(Debug, Default, Clone)]
pub struct SessionStats {
pub session_id: String,
pub project: String,
pub start_time: Option<DateTime<Utc>>,
pub end_time: Option<DateTime<Utc>>,
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_creation_tokens: u64,
pub cache_read_tokens: u64,
pub user_messages: u64,
pub assistant_messages: u64,
pub models: HashMap<String, u64>,
}
impl SessionStats {
pub fn total_tokens(&self) -> u64 {
self.input_tokens + self.output_tokens + self.cache_creation_tokens
}
pub fn duration_minutes(&self) -> Option<i64> {
match (self.start_time, self.end_time) {
(Some(start), Some(end)) => Some((end - start).num_minutes()),
_ => None,
}
}
}
#[derive(Debug, Default, Clone)]
pub struct DailyStats {
pub date: NaiveDate,
pub sessions: u64,
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_creation_tokens: u64,
pub cache_read_tokens: u64,
pub user_messages: u64,
pub assistant_messages: u64,
pub total_minutes: i64,
}
impl DailyStats {
pub fn total_tokens(&self) -> u64 {
self.input_tokens + self.output_tokens + self.cache_creation_tokens
}
}
#[derive(Debug, Clone)]
pub struct WeeklyStats {
pub sessions: u64,
pub total_tokens: u64,
pub user_messages: u64,
pub total_minutes: i64,
}
#[derive(Debug, Default, Clone)]
pub struct ProjectStats {
pub name: String,
pub sessions: u64,
pub total_tokens: u64,
pub total_messages: u64,
}
#[derive(Debug, Default, Clone)]
pub struct ModelStats {
pub name: String,
pub requests: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct SummaryStats {
pub total_sessions: u64,
pub total_prompts: u64,
pub total_responses: u64,
pub total_minutes: i64,
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_creation_tokens: u64,
pub cache_read_tokens: u64,
pub total_tokens: u64,
pub unique_days: usize,
pub sessions_per_day: f64,
pub prompts_per_day: f64,
pub tokens_per_day: u64,
pub minutes_per_day: i64,
pub tokens_per_prompt: u64,
pub output_per_prompt: u64,
pub cache_hit_rate: f64,
pub responses_per_prompt: f64,
pub input_cost: f64,
pub output_cost: f64,
pub cache_write_cost: f64,
pub cache_read_cost: f64,
pub total_cost: f64,
pub cache_savings: f64,
}
// ═══════════════════════════════════════════════════════════════════════════════
// DATA LOADING
// ═══════════════════════════════════════════════════════════════════════════════
pub fn get_claude_dir(custom_path: Option<PathBuf>) -> Result<PathBuf> {
if let Some(path) = custom_path {
return Ok(path);
}
dirs::home_dir()
.map(|h| h.join(".claude"))
.context("Could not find home directory")
}
pub fn find_session_files(claude_dir: &PathBuf) -> Vec<PathBuf> {
let projects_dir = claude_dir.join("projects");
WalkDir::new(&projects_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.is_some_and(|ext| ext == "jsonl")
})
.map(|e| e.path().to_path_buf())
.collect()
}
pub fn parse_session_file(path: &PathBuf) -> Result<SessionStats> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut stats = SessionStats::default();
if let Some(stem) = path.file_stem() {
stats.session_id = stem.to_string_lossy().to_string();
}
if let Some(parent) = path.parent() {
if let Some(name) = parent.file_name() {
let project_name = name.to_string_lossy().to_string();
stats.project = project_name.replace('-', "/");
if stats.project.starts_with('/') {
stats.project = stats.project[1..].to_string();
}
}
}
let mut timestamps: Vec<DateTime<Utc>> = Vec::new();
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
let entry: SessionEntry = match serde_json::from_str(&line) {
Ok(e) => e,
Err(_) => continue,
};
if let Some(ts_str) = &entry.timestamp {
if let Ok(ts) = DateTime::parse_from_rfc3339(ts_str) {
timestamps.push(ts.with_timezone(&Utc));
}
}
match entry.entry_type.as_deref() {
Some("user") => {
if entry.message.as_ref().is_some_and(|m| m.role.as_deref() == Some("user")) {
stats.user_messages += 1;
}
}
Some("assistant") => {
stats.assistant_messages += 1;
if let Some(msg) = &entry.message {
if let Some(model) = &msg.model {
*stats.models.entry(model.clone()).or_insert(0) += 1;
}
if let Some(usage) = &msg.usage {
stats.input_tokens += usage.input_tokens.unwrap_or(0);
stats.output_tokens += usage.output_tokens.unwrap_or(0);
stats.cache_creation_tokens +=
usage.cache_creation_input_tokens.unwrap_or(0);
stats.cache_read_tokens += usage.cache_read_input_tokens.unwrap_or(0);
}
}
}
_ => {}
}
}
if !timestamps.is_empty() {
stats.start_time = timestamps.iter().min().copied();
stats.end_time = timestamps.iter().max().copied();
}
Ok(stats)
}
pub fn load_all_sessions(claude_dir: &PathBuf) -> Result<(Vec<SessionStats>, usize)> {
let session_files = find_session_files(claude_dir);
let file_count = session_files.len();
let sessions: Vec<SessionStats> = session_files
.par_iter()
.filter_map(|path| parse_session_file(path).ok())
.collect();
Ok((sessions, file_count))
}
// ═══════════════════════════════════════════════════════════════════════════════
// AGGREGATION FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════════════
pub fn compute_summary(sessions: &[SessionStats], days: u32) -> SummaryStats {
let cutoff = Local::now().with_timezone(&Utc) - Duration::days(days as i64);
let recent: Vec<_> = sessions
.iter()
.filter(|s| s.start_time.is_some_and(|t| t >= cutoff))
.collect();
let total_input: u64 = recent.iter().map(|s| s.input_tokens).sum();
let total_output: u64 = recent.iter().map(|s| s.output_tokens).sum();
let total_cache_creation: u64 = recent.iter().map(|s| s.cache_creation_tokens).sum();
let total_cache_read: u64 = recent.iter().map(|s| s.cache_read_tokens).sum();
let total_user_msgs: u64 = recent.iter().map(|s| s.user_messages).sum();
let total_assistant_msgs: u64 = recent.iter().map(|s| s.assistant_messages).sum();
let total_minutes: i64 = recent.iter().filter_map(|s| s.duration_minutes()).sum();
let total_tokens = total_input + total_output + total_cache_creation;
let unique_days = recent
.iter()
.filter_map(|s| s.start_time)
.map(|t| t.with_timezone(&Local).date_naive())
.collect::<HashSet<_>>()
.len()
.max(1);
let cache_hit_rate = if total_cache_creation + total_cache_read > 0 {
(total_cache_read as f64 / (total_cache_creation + total_cache_read) as f64) * 100.0
} else {
0.0
};
let input_cost = total_input as f64 * 0.000015;
let output_cost = total_output as f64 * 0.000075;
let cache_write_cost = total_cache_creation as f64 * 0.00001875;
let cache_read_cost = total_cache_read as f64 * 0.0000015;
let cache_savings = total_cache_read as f64 * (0.000015 - 0.0000015);
SummaryStats {
total_sessions: recent.len() as u64,
total_prompts: total_user_msgs,
total_responses: total_assistant_msgs,
total_minutes,
input_tokens: total_input,
output_tokens: total_output,
cache_creation_tokens: total_cache_creation,
cache_read_tokens: total_cache_read,
total_tokens,
unique_days,
sessions_per_day: recent.len() as f64 / unique_days as f64,
prompts_per_day: total_user_msgs as f64 / unique_days as f64,
tokens_per_day: total_tokens / unique_days as u64,
minutes_per_day: total_minutes / unique_days as i64,
tokens_per_prompt: if total_user_msgs > 0 { total_tokens / total_user_msgs } else { 0 },
output_per_prompt: if total_user_msgs > 0 { total_output / total_user_msgs } else { 0 },
cache_hit_rate,
responses_per_prompt: if total_user_msgs > 0 { total_assistant_msgs as f64 / total_user_msgs as f64 } else { 0.0 },
input_cost,
output_cost,
cache_write_cost,
cache_read_cost,
total_cost: input_cost + output_cost + cache_write_cost + cache_read_cost,
cache_savings,
}
}
pub fn aggregate_daily(sessions: &[SessionStats], days: u32) -> BTreeMap<NaiveDate, DailyStats> {
let cutoff = Local::now().with_timezone(&Utc) - Duration::days(days as i64);
let mut daily: BTreeMap<NaiveDate, DailyStats> = BTreeMap::new();
for session in sessions {
if let Some(start) = session.start_time {
if start < cutoff {
continue;
}
let date = start.with_timezone(&Local).date_naive();
let entry = daily.entry(date).or_insert_with(|| DailyStats {
date,
..Default::default()
});
entry.sessions += 1;
entry.input_tokens += session.input_tokens;
entry.output_tokens += session.output_tokens;
entry.cache_creation_tokens += session.cache_creation_tokens;
entry.cache_read_tokens += session.cache_read_tokens;
entry.user_messages += session.user_messages;
entry.assistant_messages += session.assistant_messages;
if let Some(mins) = session.duration_minutes() {
entry.total_minutes += mins;
}
}
}
daily
}
pub fn aggregate_weekly(sessions: &[SessionStats], weeks: u32) -> BTreeMap<IsoWeek, WeeklyStats> {
let cutoff = Local::now().with_timezone(&Utc) - Duration::weeks(weeks as i64);
let mut weekly: BTreeMap<IsoWeek, WeeklyStats> = BTreeMap::new();
for session in sessions {
if let Some(start) = session.start_time {
if start < cutoff {
continue;
}
let week = start.with_timezone(&Local).date_naive().iso_week();
let entry = weekly.entry(week).or_insert_with(|| WeeklyStats {
sessions: 0,
total_tokens: 0,
user_messages: 0,
total_minutes: 0,
});
entry.sessions += 1;
entry.total_tokens += session.total_tokens();
entry.user_messages += session.user_messages;
if let Some(mins) = session.duration_minutes() {
entry.total_minutes += mins;
}
}
}
weekly
}
pub fn aggregate_hourly(sessions: &[SessionStats], days: u32) -> [u64; 24] {
let cutoff = Local::now().with_timezone(&Utc) - Duration::days(days as i64);
let mut hourly = [0u64; 24];
for session in sessions {
if let Some(start) = session.start_time {
if start >= cutoff {
let local: DateTime<Local> = start.into();
let hour = local.hour() as usize;
hourly[hour] += session.user_messages;
}
}
}
hourly
}
pub fn aggregate_projects(sessions: &[SessionStats]) -> Vec<ProjectStats> {
let mut projects: HashMap<String, ProjectStats> = HashMap::new();
for session in sessions {
let entry = projects
.entry(session.project.clone())
.or_insert_with(|| ProjectStats {
name: session.project.clone(),
..Default::default()
});
entry.sessions += 1;
entry.total_tokens += session.total_tokens();
entry.total_messages += session.user_messages + session.assistant_messages;
}
let mut result: Vec<_> = projects.into_values().collect();
result.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens));
result
}
pub fn aggregate_models(sessions: &[SessionStats], days: u32) -> Vec<ModelStats> {
let cutoff = Local::now().with_timezone(&Utc) - Duration::days(days as i64);
let mut models: HashMap<String, u64> = HashMap::new();
for session in sessions {
if session.start_time.is_some_and(|t| t >= cutoff) {
for (model, count) in &session.models {
*models.entry(model.clone()).or_insert(0) += count;
}
}
}
let mut result: Vec<_> = models
.into_iter()
.map(|(name, requests)| ModelStats { name, requests })
.collect();
result.sort_by(|a, b| b.requests.cmp(&a.requests));
result
}
// ═══════════════════════════════════════════════════════════════════════════════
// FORMATTING HELPERS
// ═══════════════════════════════════════════════════════════════════════════════
pub fn format_tokens(tokens: u64) -> String {
if tokens >= 1_000_000_000 {
format!("{:.2}B", tokens as f64 / 1_000_000_000.0)
} else if tokens >= 1_000_000 {
format!("{:.2}M", tokens as f64 / 1_000_000.0)
} else if tokens >= 1_000 {
format!("{:.1}K", tokens as f64 / 1_000.0)
} else {
tokens.to_string()
}
}
pub fn format_duration(minutes: i64) -> String {
if minutes >= 60 {
format!("{}h {}m", minutes / 60, minutes % 60)
} else {
format!("{}m", minutes)
}
}
pub fn format_number(n: u64) -> String {
use num_format::{Locale, ToFormattedString};
n.to_formatted_string(&Locale::en)
}

1897
src/main.rs Normal file

File diff suppressed because it is too large Load Diff

443
src/tui/app.rs Normal file
View File

@@ -0,0 +1,443 @@
// TUI Application state and event handling
use crate::data::*;
use crate::tui::report::generate_report;
use crate::tui::views::*;
use anyhow::Result;
use arboard::Clipboard;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Tabs, Clear},
Frame, Terminal,
};
use std::io;
use std::path::PathBuf;
use std::time::Duration;
// ═══════════════════════════════════════════════════════════════════════════════
// APP STATE
// ═══════════════════════════════════════════════════════════════════════════════
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum View {
Summary,
Daily,
Weekly,
Projects,
Models,
Hourly,
Report,
}
impl View {
fn all() -> Vec<View> {
vec![
View::Summary,
View::Daily,
View::Weekly,
View::Projects,
View::Models,
View::Hourly,
View::Report,
]
}
fn title(&self) -> &'static str {
match self {
View::Summary => "Summary",
View::Daily => "Daily",
View::Weekly => "Weekly",
View::Projects => "Projects",
View::Models => "Models",
View::Hourly => "Hourly",
View::Report => "Report",
}
}
fn index(&self) -> usize {
match self {
View::Summary => 0,
View::Daily => 1,
View::Weekly => 2,
View::Projects => 3,
View::Models => 4,
View::Hourly => 5,
View::Report => 6,
}
}
}
pub struct App {
pub sessions: Vec<SessionStats>,
pub file_count: usize,
pub current_view: View,
pub days: u32,
pub scroll_offset: usize,
pub show_help: bool,
pub status_message: Option<(String, std::time::Instant)>,
pub report_content: String,
}
impl App {
pub fn new(sessions: Vec<SessionStats>, file_count: usize, days: u32) -> Self {
Self {
sessions,
file_count,
current_view: View::Summary,
days,
scroll_offset: 0,
show_help: false,
status_message: None,
report_content: String::new(),
}
}
pub fn next_view(&mut self) {
let views = View::all();
let current_idx = self.current_view.index();
self.current_view = views[(current_idx + 1) % views.len()];
self.scroll_offset = 0;
}
pub fn prev_view(&mut self) {
let views = View::all();
let current_idx = self.current_view.index();
self.current_view = views[(current_idx + views.len() - 1) % views.len()];
self.scroll_offset = 0;
}
pub fn scroll_up(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_add(1);
}
pub fn page_up(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_sub(10);
}
pub fn page_down(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_add(10);
}
pub fn increase_days(&mut self) {
self.days = (self.days + 7).min(365);
}
pub fn decrease_days(&mut self) {
self.days = self.days.saturating_sub(7).max(1);
}
pub fn set_status(&mut self, msg: &str) {
self.status_message = Some((msg.to_string(), std::time::Instant::now()));
}
pub fn copy_to_clipboard(&mut self) -> Result<()> {
use base64::Engine;
use std::io::Write;
let report = generate_report(&self.sessions, self.days);
self.report_content = report.clone();
// Try OSC 52 first (works over SSH with modern terminals like WezTerm)
let osc52_result = (|| -> Result<()> {
let encoded = base64::engine::general_purpose::STANDARD.encode(&report);
let mut stdout = std::io::stdout();
// OSC 52 format: ESC ] 52 ; c ; <base64> BEL
write!(stdout, "\x1b]52;c;{}\x07", encoded)?;
stdout.flush()?;
Ok(())
})();
if osc52_result.is_ok() {
self.set_status("✓ Report copied via OSC 52!");
return Ok(());
}
// Fall back to arboard (X11/Wayland)
match Clipboard::new() {
Ok(mut clipboard) => {
clipboard.set_text(&report)?;
self.set_status("✓ Report copied to clipboard!");
Ok(())
}
Err(e) => {
self.set_status(&format!("✗ Clipboard error: {}", e));
Err(e.into())
}
}
}
pub fn generate_report(&mut self) {
self.report_content = generate_report(&self.sessions, self.days);
self.current_view = View::Report;
self.scroll_offset = 0;
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// TUI RUNNER
// ═══════════════════════════════════════════════════════════════════════════════
pub fn run_tui(claude_dir: PathBuf, days: u32) -> Result<()> {
// Load data
let (sessions, file_count) = load_all_sessions(&claude_dir)?;
if sessions.is_empty() {
anyhow::bail!("No sessions found in {:?}", claude_dir);
}
// Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Create app
let mut app = App::new(sessions, file_count, days);
// Run event loop
let result = run_app(&mut terminal, &mut app);
// Restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
result
}
fn run_app<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
) -> Result<()> {
loop {
terminal.draw(|f| draw_ui(f, app))?;
// Clear expired status messages
if let Some((_, time)) = &app.status_message {
if time.elapsed() > Duration::from_secs(3) {
app.status_message = None;
}
}
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
// Handle help overlay first
if app.show_help {
app.show_help = false;
continue;
}
match key.code {
// Quit
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return Ok(())
}
// Navigation
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => app.next_view(),
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => app.prev_view(),
// Direct view access
KeyCode::Char('1') => app.current_view = View::Summary,
KeyCode::Char('2') => app.current_view = View::Daily,
KeyCode::Char('3') => app.current_view = View::Weekly,
KeyCode::Char('4') => app.current_view = View::Projects,
KeyCode::Char('5') => app.current_view = View::Models,
KeyCode::Char('6') => app.current_view = View::Hourly,
KeyCode::Char('7') => app.current_view = View::Report,
// Scrolling
KeyCode::Up | KeyCode::Char('k') => app.scroll_up(),
KeyCode::Down | KeyCode::Char('j') => app.scroll_down(),
KeyCode::PageUp => app.page_up(),
KeyCode::PageDown => app.page_down(),
KeyCode::Home => app.scroll_offset = 0,
// Time range
KeyCode::Char('+') | KeyCode::Char('=') => app.increase_days(),
KeyCode::Char('-') | KeyCode::Char('_') => app.decrease_days(),
// Actions
KeyCode::Char('c') => {
let _ = app.copy_to_clipboard();
}
KeyCode::Char('r') => app.generate_report(),
KeyCode::Char('?') => app.show_help = true,
_ => {}
}
}
}
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// UI DRAWING
// ═══════════════════════════════════════════════════════════════════════════════
fn draw_ui(f: &mut Frame, app: &App) {
let size = f.area();
// Main layout: header, content, footer
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header/tabs
Constraint::Min(10), // Content
Constraint::Length(3), // Footer
])
.split(size);
// Draw header with tabs
draw_header(f, app, chunks[0]);
// Draw main content
draw_content(f, app, chunks[1]);
// Draw footer
draw_footer(f, app, chunks[2]);
// Draw help overlay if active
if app.show_help {
draw_help_overlay(f, size);
}
}
fn draw_header(f: &mut Frame, app: &App, area: Rect) {
let titles: Vec<Line> = View::all()
.iter()
.enumerate()
.map(|(i, v)| {
let title = format!(" {} {} ", i + 1, v.title());
Line::from(title)
})
.collect();
let tabs = Tabs::new(titles)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(" Claude Code Stats ")
.title_style(Style::default().fg(Color::Cyan).bold()),
)
.select(app.current_view.index())
.style(Style::default().fg(Color::Gray))
.highlight_style(
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
f.render_widget(tabs, area);
}
fn draw_content(f: &mut Frame, app: &App, area: Rect) {
match app.current_view {
View::Summary => draw_summary_view(f, app, area),
View::Daily => draw_daily_view(f, app, area),
View::Weekly => draw_weekly_view(f, app, area),
View::Projects => draw_projects_view(f, app, area),
View::Models => draw_models_view(f, app, area),
View::Hourly => draw_hourly_view(f, app, area),
View::Report => draw_report_view(f, app, area),
}
}
fn draw_footer(f: &mut Frame, app: &App, area: Rect) {
let status_text = if let Some((msg, _)) = &app.status_message {
msg.clone()
} else {
format!(
" {} days │ {} sessions │ Tab/←→: navigate │ c: copy │ r: report │ +/-: days │ ?: help │ q: quit",
app.days,
format_number(app.sessions.len() as u64)
)
};
let status_style = if app.status_message.is_some() {
Style::default().fg(Color::Green).bold()
} else {
Style::default().fg(Color::DarkGray)
};
let footer = Paragraph::new(status_text)
.style(status_style)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(footer, area);
}
fn draw_help_overlay(f: &mut Frame, area: Rect) {
// Center the help popup
let popup_width = 60.min(area.width - 4);
let popup_height = 20.min(area.height - 4);
let popup_x = (area.width - popup_width) / 2;
let popup_y = (area.height - popup_height) / 2;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
// Clear background
f.render_widget(Clear, popup_area);
let help_text = vec![
Line::from(Span::styled("KEYBOARD SHORTCUTS", Style::default().bold().fg(Color::Cyan))),
Line::from(""),
Line::from(vec![
Span::styled("Navigation", Style::default().fg(Color::Yellow)),
]),
Line::from(" Tab/→/l Next view"),
Line::from(" Shift+Tab/←/h Previous view"),
Line::from(" 1-7 Jump to view"),
Line::from(" ↑/k ↓/j Scroll up/down"),
Line::from(" PgUp/PgDn Page up/down"),
Line::from(""),
Line::from(vec![
Span::styled("Actions", Style::default().fg(Color::Yellow)),
]),
Line::from(" c Copy report to clipboard"),
Line::from(" r Generate report view"),
Line::from(" +/- Increase/decrease days"),
Line::from(""),
Line::from(vec![
Span::styled("General", Style::default().fg(Color::Yellow)),
]),
Line::from(" ? Show this help"),
Line::from(" q/Esc Quit"),
];
let help = Paragraph::new(help_text)
.block(
Block::default()
.title(" Help ")
.title_style(Style::default().fg(Color::Cyan).bold())
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
)
.style(Style::default().fg(Color::White));
f.render_widget(help, popup_area);
}

7
src/tui/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
// TUI module for claude-stats
mod app;
mod views;
mod report;
pub use app::run_tui;

204
src/tui/report.rs Normal file
View File

@@ -0,0 +1,204 @@
// Report generation for clipboard export
use crate::data::*;
use chrono::Local;
/// Generate a well-formatted text report suitable for clipboard
pub fn generate_report(sessions: &[SessionStats], days: u32) -> String {
let summary = compute_summary(sessions, days);
let daily = aggregate_daily(sessions, days.min(14));
let models = aggregate_models(sessions, days);
let projects = aggregate_projects(sessions);
let now = Local::now();
let mut report = String::new();
// Header
report.push_str(&format!(
"# Claude Code Usage Report\n\
Generated: {}\n\
Period: {} days\n\n",
now.format("%Y-%m-%d %H:%M"),
days
));
// Summary section
report.push_str("## Summary\n\n");
report.push_str(&format!(
"┌─────────────────────────────────────────────────┐\n\
│ Sessions: {:>12}\n\
│ Prompts: {:>12}\n\
│ Responses: {:>12}\n\
│ Active Days: {:>12}\n\
│ Total Time: {:>12}\n\
└─────────────────────────────────────────────────┘\n\n",
format_number(summary.total_sessions),
format_number(summary.total_prompts),
format_number(summary.total_responses),
summary.unique_days,
format_duration(summary.total_minutes),
));
// Token usage
report.push_str("## Token Usage\n\n");
report.push_str(&format!(
"┌─────────────────────────────────────────────────┐\n\
│ Input Tokens: {:>12}\n\
│ Output Tokens: {:>12}\n\
│ Cache Write: {:>12}\n\
│ Cache Read: {:>12}\n\
├─────────────────────────────────────────────────┤\n\
│ TOTAL BILLED: {:>12}\n\
└─────────────────────────────────────────────────┘\n\n",
format_tokens(summary.input_tokens),
format_tokens(summary.output_tokens),
format_tokens(summary.cache_creation_tokens),
format_tokens(summary.cache_read_tokens),
format_tokens(summary.total_tokens),
));
// Daily averages
report.push_str("## Daily Averages\n\n");
report.push_str(&format!(
" Sessions/day: {:.1}\n\
Prompts/day: {:.1}\n\
Tokens/day: {}\n\
Time/day: {}\n\n",
summary.sessions_per_day,
summary.prompts_per_day,
format_tokens(summary.tokens_per_day),
format_duration(summary.minutes_per_day),
));
// Efficiency metrics
report.push_str("## Efficiency\n\n");
report.push_str(&format!(
" Tokens/prompt: {}\n\
Output/prompt: {}\n\
Responses/prompt: {:.2}\n\
Cache hit rate: {:.1}%\n\n",
format_tokens(summary.tokens_per_prompt),
format_tokens(summary.output_per_prompt),
summary.responses_per_prompt,
summary.cache_hit_rate,
));
// Cost estimate
report.push_str("## API Cost Equivalent (Opus 4.5 Pricing)\n\n");
report.push_str(&format!(
"┌─────────────────────────────────────────────────┐\n\
│ Input: ${:>10.2}\n\
│ Output: ${:>10.2}\n\
│ Cache Write: ${:>10.2}\n\
│ Cache Read: ${:>10.2}\n\
├─────────────────────────────────────────────────┤\n\
│ TOTAL: ${:>10.2}\n\
│ Cache Savings: ${:>10.2}\n\
└─────────────────────────────────────────────────┘\n\n",
summary.input_cost,
summary.output_cost,
summary.cache_write_cost,
summary.cache_read_cost,
summary.total_cost,
summary.cache_savings,
));
// Daily breakdown (last 14 days max)
report.push_str("## Daily Breakdown\n\n");
report.push_str(" Date │ Sessions │ Prompts │ Tokens │ Time\n");
report.push_str(" ────────────┼──────────┼─────────┼──────────┼─────────\n");
for (date, stats) in daily.iter().rev().take(14) {
report.push_str(&format!(
" {}{:>8}{:>7}{:>8}{}\n",
date.format("%Y-%m-%d"),
stats.sessions,
stats.user_messages,
format_tokens(stats.total_tokens()),
format_duration(stats.total_minutes),
));
}
report.push('\n');
// Model usage
if !models.is_empty() {
let total_requests: u64 = models.iter().map(|m| m.requests).sum();
report.push_str("## Model Usage\n\n");
report.push_str(" Model │ Requests │ %\n");
report.push_str(" ───────────────────────────┼──────────┼────────\n");
for model in models.iter().take(5) {
let pct = if total_requests > 0 {
(model.requests as f64 / total_requests as f64) * 100.0
} else {
0.0
};
let short_name = model.name
.replace("claude-", "")
.replace("-20251101", "")
.replace("-20250929", "")
.replace("-20251001", "")
.replace("-20250514", "");
report.push_str(&format!(
" {:<26}{:>8}{:>5.1}%\n",
short_name,
format_number(model.requests),
pct,
));
}
report.push('\n');
}
// Top projects
if !projects.is_empty() {
let total_tokens: u64 = projects.iter().map(|p| p.total_tokens).sum();
report.push_str("## Top Projects\n\n");
report.push_str(" Project │ Sessions │ Tokens │ %\n");
report.push_str(" ──────────────────────────────────────┼──────────┼──────────┼────────\n");
for project in projects.iter().take(10) {
let pct = if total_tokens > 0 {
(project.total_tokens as f64 / total_tokens as f64) * 100.0
} else {
0.0
};
let name = if project.name.len() > 38 {
format!("...{}", &project.name[project.name.len() - 35..])
} else {
project.name.clone()
};
report.push_str(&format!(
" {:<38}{:>8}{:>8}{:>5.1}%\n",
name,
project.sessions,
format_tokens(project.total_tokens),
pct,
));
}
report.push('\n');
}
// Footer
report.push_str("---\n");
report.push_str("Generated by claude-stats v0.2.0\n");
report
}
/// Generate a compact single-line summary
pub fn generate_compact_summary(sessions: &[SessionStats], days: u32) -> String {
let summary = compute_summary(sessions, days);
format!(
"{}d: {} sessions, {} prompts, {} tokens, ${:.2} equiv, {:.1}% cache",
days,
format_number(summary.total_sessions),
format_number(summary.total_prompts),
format_tokens(summary.total_tokens),
summary.total_cost,
summary.cache_hit_rate,
)
}

595
src/tui/views.rs Normal file
View File

@@ -0,0 +1,595 @@
// TUI view rendering
use crate::data::*;
use crate::tui::app::App;
use chrono::Datelike;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style, Stylize},
text::{Line, Span},
widgets::{
Bar, BarChart, BarGroup, Block, Borders, Cell, Paragraph, Row, Scrollbar,
ScrollbarOrientation, ScrollbarState, Table, Wrap,
},
Frame,
};
// ═══════════════════════════════════════════════════════════════════════════════
// SUMMARY VIEW
// ═══════════════════════════════════════════════════════════════════════════════
pub fn draw_summary_view(f: &mut Frame, app: &App, area: Rect) {
let summary = compute_summary(&app.sessions, app.days);
// Split into two columns
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
// Left column: Overview metrics
let left_rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(8), // Activity
Constraint::Length(9), // Tokens
Constraint::Min(6), // Averages
])
.split(columns[0]);
// Right column: Cost and efficiency
let right_rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(10), // Cost breakdown
Constraint::Min(7), // Efficiency
])
.split(columns[1]);
// Activity panel
let activity_content = vec![
Line::from(vec![
Span::raw(" Sessions: "),
Span::styled(format_number(summary.total_sessions), Style::default().fg(Color::Green).bold()),
]),
Line::from(vec![
Span::raw(" Prompts: "),
Span::styled(format_number(summary.total_prompts), Style::default().fg(Color::Green).bold()),
]),
Line::from(vec![
Span::raw(" Responses: "),
Span::styled(format_number(summary.total_responses), Style::default().fg(Color::Green)),
]),
Line::from(vec![
Span::raw(" Total Time: "),
Span::styled(format_duration(summary.total_minutes), Style::default().fg(Color::Yellow)),
]),
Line::from(vec![
Span::raw(" Active Days: "),
Span::styled(format!("{}", summary.unique_days), Style::default().fg(Color::Cyan)),
]),
];
let activity_title = format!(" Activity ({} days) ", app.days);
let activity = Paragraph::new(activity_content)
.block(create_block(&activity_title));
f.render_widget(activity, left_rows[0]);
// Tokens panel
let tokens_content = vec![
Line::from(vec![
Span::raw(" Input: "),
Span::styled(format_tokens(summary.input_tokens), Style::default().fg(Color::Magenta)),
]),
Line::from(vec![
Span::raw(" Output: "),
Span::styled(format_tokens(summary.output_tokens), Style::default().fg(Color::Magenta)),
]),
Line::from(vec![
Span::raw(" Cache Write: "),
Span::styled(format_tokens(summary.cache_creation_tokens), Style::default().fg(Color::Magenta)),
]),
Line::from(vec![
Span::raw(" Cache Read: "),
Span::styled(format_tokens(summary.cache_read_tokens), Style::default().fg(Color::Blue)),
]),
Line::from(""),
Line::from(vec![
Span::raw(" TOTAL: "),
Span::styled(format_tokens(summary.total_tokens), Style::default().fg(Color::Yellow).bold()),
]),
];
let tokens = Paragraph::new(tokens_content)
.block(create_block(" Tokens "));
f.render_widget(tokens, left_rows[1]);
// Averages panel
let averages_content = vec![
Line::from(vec![
Span::raw(" Sessions/day: "),
Span::styled(format!("{:.1}", summary.sessions_per_day), Style::default().fg(Color::Green)),
]),
Line::from(vec![
Span::raw(" Prompts/day: "),
Span::styled(format!("{:.1}", summary.prompts_per_day), Style::default().fg(Color::Green)),
]),
Line::from(vec![
Span::raw(" Tokens/day: "),
Span::styled(format_tokens(summary.tokens_per_day), Style::default().fg(Color::Magenta)),
]),
Line::from(vec![
Span::raw(" Time/day: "),
Span::styled(format_duration(summary.minutes_per_day), Style::default().fg(Color::Yellow)),
]),
];
let averages = Paragraph::new(averages_content)
.block(create_block(" Daily Averages "));
f.render_widget(averages, left_rows[2]);
// Cost panel
let cost_content = vec![
Line::from(vec![
Span::raw(" Input: "),
Span::styled(format!("${:.2}", summary.input_cost), Style::default().fg(Color::Red)),
]),
Line::from(vec![
Span::raw(" Output: "),
Span::styled(format!("${:.2}", summary.output_cost), Style::default().fg(Color::Red)),
]),
Line::from(vec![
Span::raw(" Cache Write: "),
Span::styled(format!("${:.2}", summary.cache_write_cost), Style::default().fg(Color::Red)),
]),
Line::from(vec![
Span::raw(" Cache Read: "),
Span::styled(format!("${:.2}", summary.cache_read_cost), Style::default().fg(Color::Blue)),
]),
Line::from(""),
Line::from(vec![
Span::raw(" TOTAL: "),
Span::styled(format!("${:.2}", summary.total_cost), Style::default().fg(Color::Yellow).bold()),
]),
Line::from(vec![
Span::raw(" Savings: "),
Span::styled(format!("${:.2}", summary.cache_savings), Style::default().fg(Color::Green).bold()),
]),
];
let cost = Paragraph::new(cost_content)
.block(create_block(" API Cost Equivalent (Opus 4.5) "));
f.render_widget(cost, right_rows[0]);
// Efficiency panel
let cache_bar = create_percentage_bar(summary.cache_hit_rate, "Cache Hit");
let efficiency_content = vec![
Line::from(vec![
Span::raw(" Tokens/prompt: "),
Span::styled(format_tokens(summary.tokens_per_prompt), Style::default().fg(Color::Magenta)),
]),
Line::from(vec![
Span::raw(" Output/prompt: "),
Span::styled(format_tokens(summary.output_per_prompt), Style::default().fg(Color::Magenta)),
]),
Line::from(vec![
Span::raw(" Responses/prompt: "),
Span::styled(format!("{:.2}", summary.responses_per_prompt), Style::default().fg(Color::Cyan)),
]),
Line::from(""),
Line::from(vec![
Span::raw(" Cache hit rate: "),
Span::styled(format!("{:.1}%", summary.cache_hit_rate), Style::default().fg(Color::Green).bold()),
]),
Line::from(cache_bar),
];
let efficiency = Paragraph::new(efficiency_content)
.block(create_block(" Efficiency "));
f.render_widget(efficiency, right_rows[1]);
}
// ═══════════════════════════════════════════════════════════════════════════════
// DAILY VIEW
// ═══════════════════════════════════════════════════════════════════════════════
pub fn draw_daily_view(f: &mut Frame, app: &App, area: Rect) {
let daily = aggregate_daily(&app.sessions, app.days);
let header = Row::new(vec![
Cell::from("Date").style(Style::default().fg(Color::Cyan).bold()),
Cell::from("Day").style(Style::default().fg(Color::Cyan).bold()),
Cell::from("Sessions").style(Style::default().fg(Color::Cyan).bold()),
Cell::from("Prompts").style(Style::default().fg(Color::Cyan).bold()),
Cell::from("Tokens").style(Style::default().fg(Color::Cyan).bold()),
Cell::from("Time").style(Style::default().fg(Color::Cyan).bold()),
])
.height(1)
.bottom_margin(1);
let rows: Vec<Row> = daily
.iter()
.rev()
.skip(app.scroll_offset)
.map(|(date, stats)| {
let day_name = date.weekday().to_string();
let day_short = day_name[..3].to_string();
let is_weekend = date.weekday().num_days_from_monday() >= 5;
let day_style = if is_weekend {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
};
Row::new(vec![
Cell::from(date.format("%Y-%m-%d").to_string()),
Cell::from(day_short).style(day_style),
Cell::from(format_number(stats.sessions)).style(Style::default().fg(Color::Green)),
Cell::from(format_number(stats.user_messages)).style(Style::default().fg(Color::Green)),
Cell::from(format_tokens(stats.total_tokens())).style(Style::default().fg(Color::Magenta)),
Cell::from(format_duration(stats.total_minutes)).style(Style::default().fg(Color::Yellow)),
])
})
.collect();
let widths = [
Constraint::Length(12),
Constraint::Length(5),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(10),
];
let daily_title = format!(" Daily Breakdown ({} days) ", app.days);
let table = Table::new(rows, widths)
.header(header)
.block(create_block(&daily_title))
.row_highlight_style(Style::default().bg(Color::DarkGray));
f.render_widget(table, area);
// Scrollbar
let total_items = daily.len();
render_scrollbar(f, area, app.scroll_offset, total_items);
}
// ═══════════════════════════════════════════════════════════════════════════════
// WEEKLY VIEW
// ═══════════════════════════════════════════════════════════════════════════════
pub fn draw_weekly_view(f: &mut Frame, app: &App, area: Rect) {
let weeks = (app.days / 7).max(1);
let weekly = aggregate_weekly(&app.sessions, weeks);
let header = Row::new(vec![
Cell::from("Week").style(Style::default().fg(Color::Cyan).bold()),
Cell::from("Sessions").style(Style::default().fg(Color::Cyan).bold()),
Cell::from("Prompts").style(Style::default().fg(Color::Cyan).bold()),
Cell::from("Tokens").style(Style::default().fg(Color::Cyan).bold()),
Cell::from("Time").style(Style::default().fg(Color::Cyan).bold()),
])
.height(1)
.bottom_margin(1);
let rows: Vec<Row> = weekly
.iter()
.rev()
.skip(app.scroll_offset)
.map(|(week, stats)| {
Row::new(vec![
Cell::from(format!("{}-W{:02}", week.year(), week.week())),
Cell::from(format_number(stats.sessions)).style(Style::default().fg(Color::Green)),
Cell::from(format_number(stats.user_messages)).style(Style::default().fg(Color::Green)),
Cell::from(format_tokens(stats.total_tokens)).style(Style::default().fg(Color::Magenta)),
Cell::from(format_duration(stats.total_minutes)).style(Style::default().fg(Color::Yellow)),
])
})
.collect();
let widths = [
Constraint::Length(12),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(12),
Constraint::Length(10),
];
let weekly_title = format!(" Weekly Breakdown ({} weeks) ", weeks);
let table = Table::new(rows, widths)
.header(header)
.block(create_block(&weekly_title));
f.render_widget(table, area);
}
// ═══════════════════════════════════════════════════════════════════════════════
// PROJECTS VIEW
// ═══════════════════════════════════════════════════════════════════════════════
pub fn draw_projects_view(f: &mut Frame, app: &App, area: Rect) {
let projects = aggregate_projects(&app.sessions);
let total_tokens: u64 = projects.iter().map(|p| p.total_tokens).sum();
let header = Row::new(vec![
Cell::from("Project").style(Style::default().fg(Color::Cyan).bold()),
Cell::from("Sessions").style(Style::default().fg(Color::Cyan).bold()),
Cell::from("Messages").style(Style::default().fg(Color::Cyan).bold()),
Cell::from("Tokens").style(Style::default().fg(Color::Cyan).bold()),
Cell::from("%").style(Style::default().fg(Color::Cyan).bold()),
])
.height(1)
.bottom_margin(1);
let rows: Vec<Row> = projects
.iter()
.skip(app.scroll_offset)
.take(area.height.saturating_sub(4) as usize)
.map(|p| {
let pct = if total_tokens > 0 {
(p.total_tokens as f64 / total_tokens as f64) * 100.0
} else {
0.0
};
// Truncate project name
let name = if p.name.len() > 40 {
format!("...{}", &p.name[p.name.len() - 37..])
} else {
p.name.clone()
};
Row::new(vec![
Cell::from(name),
Cell::from(format_number(p.sessions)).style(Style::default().fg(Color::Green)),
Cell::from(format_number(p.total_messages)).style(Style::default().fg(Color::Green)),
Cell::from(format_tokens(p.total_tokens)).style(Style::default().fg(Color::Magenta)),
Cell::from(format!("{:.1}%", pct)).style(Style::default().fg(Color::Yellow)),
])
})
.collect();
let widths = [
Constraint::Min(30),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(8),
];
let projects_title = format!(" Projects ({} total) ", projects.len());
let table = Table::new(rows, widths)
.header(header)
.block(create_block(&projects_title));
f.render_widget(table, area);
render_scrollbar(f, area, app.scroll_offset, projects.len());
}
// ═══════════════════════════════════════════════════════════════════════════════
// MODELS VIEW
// ═══════════════════════════════════════════════════════════════════════════════
pub fn draw_models_view(f: &mut Frame, app: &App, area: Rect) {
let models = aggregate_models(&app.sessions, app.days);
let total: u64 = models.iter().map(|m| m.requests).sum();
// Split view: table on left, chart on right
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
// Table
let header = Row::new(vec![
Cell::from("Model").style(Style::default().fg(Color::Cyan).bold()),
Cell::from("Requests").style(Style::default().fg(Color::Cyan).bold()),
Cell::from("%").style(Style::default().fg(Color::Cyan).bold()),
])
.height(1)
.bottom_margin(1);
let rows: Vec<Row> = models
.iter()
.take(10)
.map(|m| {
let pct = if total > 0 {
(m.requests as f64 / total as f64) * 100.0
} else {
0.0
};
// Shorten model name
let short_name = m.name
.replace("claude-", "")
.replace("-20251101", "")
.replace("-20250929", "")
.replace("-20251001", "")
.replace("-20250514", "");
Row::new(vec![
Cell::from(short_name),
Cell::from(format_number(m.requests)).style(Style::default().fg(Color::Green)),
Cell::from(format!("{:.1}%", pct)).style(Style::default().fg(Color::Yellow)),
])
})
.collect();
let widths = [
Constraint::Min(20),
Constraint::Length(12),
Constraint::Length(8),
];
let table = Table::new(rows, widths)
.header(header)
.block(create_block(" Model Usage "));
f.render_widget(table, chunks[0]);
// Bar chart
let bar_data: Vec<(&str, u64)> = models
.iter()
.take(5)
.map(|m| {
let short = m.name
.replace("claude-", "")
.replace("-20251101", "")
.replace("-20250929", "")
.replace("-20251001", "")
.replace("-20250514", "");
// Leak the string to get a static reference (acceptable for TUI lifetime)
(Box::leak(short.into_boxed_str()) as &str, m.requests)
})
.collect();
let bars: Vec<Bar> = bar_data
.iter()
.map(|(label, value)| {
Bar::default()
.value(*value)
.label(Line::from(*label))
.style(Style::default().fg(Color::Cyan))
})
.collect();
let chart = BarChart::default()
.block(create_block(" Distribution "))
.data(BarGroup::default().bars(&bars))
.bar_width(8)
.bar_gap(2)
.bar_style(Style::default().fg(Color::Cyan))
.value_style(Style::default().fg(Color::White));
f.render_widget(chart, chunks[1]);
}
// ═══════════════════════════════════════════════════════════════════════════════
// HOURLY VIEW
// ═══════════════════════════════════════════════════════════════════════════════
pub fn draw_hourly_view(f: &mut Frame, app: &App, area: Rect) {
let hourly = aggregate_hourly(&app.sessions, app.days);
let max_activity = *hourly.iter().max().unwrap_or(&1);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(""));
for (hour, &count) in hourly.iter().enumerate() {
let bar_width = if max_activity > 0 {
((count as f64 / max_activity as f64) * 40.0) as usize
} else {
0
};
let bar = "".repeat(bar_width);
let bar_style = if hour >= 9 && hour < 17 {
Style::default().fg(Color::Green) // Work hours
} else if hour >= 6 && hour < 22 {
Style::default().fg(Color::Yellow) // Waking hours
} else {
Style::default().fg(Color::Red) // Late night
};
lines.push(Line::from(vec![
Span::styled(format!(" {:02}:00 │ ", hour), Style::default().fg(Color::DarkGray)),
Span::styled(format!("{:>6}", format_number(count)), Style::default().fg(Color::Cyan)),
Span::styled(bar, bar_style),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" Legend: ", Style::default().fg(Color::DarkGray)),
Span::styled("", Style::default().fg(Color::Green)),
Span::raw(" Work (9-17) "),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::raw(" Day (6-22) "),
Span::styled("", Style::default().fg(Color::Red)),
Span::raw(" Night"),
]));
let hourly_title = format!(" Activity by Hour ({} days) ", app.days);
let para = Paragraph::new(lines)
.block(create_block(&hourly_title))
.scroll((app.scroll_offset as u16, 0));
f.render_widget(para, area);
}
// ═══════════════════════════════════════════════════════════════════════════════
// REPORT VIEW
// ═══════════════════════════════════════════════════════════════════════════════
pub fn draw_report_view(f: &mut Frame, app: &App, area: Rect) {
let content = if app.report_content.is_empty() {
"Press 'r' to generate a report, or 'c' to copy to clipboard.".to_string()
} else {
app.report_content.clone()
};
let lines: Vec<Line> = content
.lines()
.skip(app.scroll_offset)
.map(|line| {
// Style headers
if line.starts_with('#') {
Line::from(Span::styled(line, Style::default().fg(Color::Cyan).bold()))
} else if line.starts_with("") || line.starts_with("") || line.starts_with("") || line.starts_with("") || line.starts_with("") || line.starts_with("") {
Line::from(Span::styled(line, Style::default().fg(Color::DarkGray)))
} else if line.contains(':') && !line.starts_with(' ') {
Line::from(Span::styled(line, Style::default().fg(Color::Yellow)))
} else {
Line::from(line)
}
})
.collect();
let para = Paragraph::new(lines)
.block(create_block(" Report (c to copy) "))
.wrap(Wrap { trim: false });
f.render_widget(para, area);
let line_count = content.lines().count();
render_scrollbar(f, area, app.scroll_offset, line_count);
}
// ═══════════════════════════════════════════════════════════════════════════════
// HELPERS
// ═══════════════════════════════════════════════════════════════════════════════
fn create_block(title: &str) -> Block<'_> {
Block::default()
.title(title)
.title_style(Style::default().fg(Color::Cyan).bold())
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
}
fn create_percentage_bar(percentage: f64, _label: &str) -> Line<'static> {
let filled = ((percentage / 100.0) * 30.0) as usize;
let empty = 30 - filled;
Line::from(vec![
Span::raw(" ["),
Span::styled("".repeat(filled), Style::default().fg(Color::Green)),
Span::styled("".repeat(empty), Style::default().fg(Color::DarkGray)),
Span::raw("]"),
])
}
fn render_scrollbar(f: &mut Frame, area: Rect, offset: usize, total: usize) {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some(""))
.end_symbol(Some(""));
let mut scrollbar_state = ScrollbarState::new(total).position(offset);
f.render_stateful_widget(
scrollbar,
area.inner(ratatui::layout::Margin { horizontal: 0, vertical: 1 }),
&mut scrollbar_state,
);
}