initial commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
113
AGENTS.md
Normal file
113
AGENTS.md
Normal 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
1721
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
Cargo.toml
Normal file
49
Cargo.toml
Normal 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
464
src/data.rs
Normal 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
1897
src/main.rs
Normal file
File diff suppressed because it is too large
Load Diff
443
src/tui/app.rs
Normal file
443
src/tui/app.rs
Normal 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
7
src/tui/mod.rs
Normal 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
204
src/tui/report.rs
Normal 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
595
src/tui/views.rs
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user