use chrono::Datelike;
use clap::{Parser, Subcommand};
use serde::Deserialize;
use std::fmt::Write;
use std::io::Read;
use std::process::Command;
mod db;
#[derive(Parser)]
#[command(name = "paddington", about = "Status line renderer for Claude Code")]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Stats {
#[arg(long)]
month: Option<String>,
},
}
#[derive(Deserialize, Default)]
struct Input {
session_id: Option<String>,
model: Option<Model>,
cwd: Option<String>,
workspace: Option<Workspace>,
worktree: Option<Worktree>,
pr: Option<PullRequest>,
session_name: Option<String>,
context_window: Option<ContextWindow>,
cost: Option<Cost>,
}
#[derive(Deserialize, Default)]
struct Model {
id: Option<String>,
display_name: Option<String>,
}
#[derive(Deserialize, Default)]
struct Workspace {
project_dir: Option<String>,
repo: Option<Repo>,
}
#[derive(Deserialize, Default)]
struct Repo {
owner: Option<String>,
name: Option<String>,
}
#[derive(Deserialize, Default)]
struct Worktree {
name: Option<String>,
}
#[derive(Deserialize, Default)]
struct PullRequest {
number: Option<serde_json::Value>,
review_state: Option<String>,
}
#[derive(Deserialize, Default)]
struct ContextWindow {
total_input_tokens: Option<u64>,
total_output_tokens: Option<u64>,
context_window_size: Option<u64>,
used_percentage: Option<f64>,
}
#[derive(Deserialize, Default)]
struct Cost {
total_cost_usd: Option<f64>,
total_duration_ms: Option<u64>,
total_lines_added: Option<u64>,
total_lines_removed: Option<u64>,
}
const BLUE: &str = "\x1b[34m";
const YELLOW: &str = "\x1b[33m";
const GREEN: &str = "\x1b[32m";
const RED: &str = "\x1b[31m";
const MAGENTA: &str = "\x1b[35m";
const GRAY: &str = "\x1b[90m";
const RESET: &str = "\x1b[0m";
fn git_branch(project_dir: &str) -> Option<String> {
let try_symbolic = Command::new("git")
.args(["--no-optional-locks", "symbolic-ref", "--short", "HEAD"])
.current_dir(project_dir)
.output()
.ok()?;
if try_symbolic.status.success() {
return Some(
String::from_utf8_lossy(&try_symbolic.stdout)
.trim()
.to_string(),
);
}
let try_rev = Command::new("git")
.args(["--no-optional-locks", "rev-parse", "--short", "HEAD"])
.current_dir(project_dir)
.output()
.ok()?;
if try_rev.status.success() {
return Some(String::from_utf8_lossy(&try_rev.stdout).trim().to_string());
}
None
}
fn format_duration(ms: u64) -> String {
let total_secs = ms / 1000;
let hrs = total_secs / 3600;
let mins = (total_secs % 3600) / 60;
let secs = total_secs % 60;
if hrs > 0 {
format!("{hrs}h {mins}m")
} else if mins > 0 {
format!("{mins}m {secs}s")
} else {
format!("{secs}s")
}
}
fn main() {
match Cli::try_parse() {
Ok(cli) => match cli.command {
None => render_status_line(),
Some(Commands::Stats { month }) => show_stats(month),
},
Err(_) => render_status_line(),
}
}
fn render_status_line() {
let mut raw = String::new();
std::io::stdin().read_to_string(&mut raw).unwrap();
let input: Input = serde_json::from_str(&raw).unwrap_or_default();
let model = input
.model
.as_ref()
.and_then(|m| m.display_name.as_deref())
.unwrap_or("Claude");
let cwd = input.cwd.as_deref().unwrap_or("");
let project_dir = input
.workspace
.as_ref()
.and_then(|w| w.project_dir.as_deref())
.unwrap_or("");
let repo_owner = input
.workspace
.as_ref()
.and_then(|w| w.repo.as_ref())
.and_then(|r| r.owner.as_deref())
.unwrap_or("");
let repo_name = input
.workspace
.as_ref()
.and_then(|w| w.repo.as_ref())
.and_then(|r| r.name.as_deref())
.unwrap_or("");
let worktree_name = input
.worktree
.as_ref()
.and_then(|w| w.name.as_deref())
.unwrap_or("");
let session_name = input.session_name.as_deref().unwrap_or("");
let monthly_total: Option<f64> = (|| -> Option<f64> {
let session_id = input.session_id.as_deref()?;
let cost_data = input.cost.as_ref()?;
let conn = db::open_db().ok()?;
let record = db::SessionRecord {
session_id: session_id.to_string(),
project_dir: input.workspace.as_ref().and_then(|w| w.project_dir.clone()),
model_id: input.model.as_ref().and_then(|m| m.id.clone()),
model_name: input.model.as_ref().and_then(|m| m.display_name.clone()),
cost_usd: cost_data.total_cost_usd.unwrap_or(0.0),
duration_ms: cost_data.total_duration_ms.unwrap_or(0),
lines_added: cost_data.total_lines_added.unwrap_or(0),
lines_removed: cost_data.total_lines_removed.unwrap_or(0),
};
db::upsert_session(&conn, &record).ok()?;
let now = chrono::Utc::now();
db::get_monthly_total(&conn, now.year(), now.month()).ok()
})();
let branch =
if !project_dir.is_empty() && std::path::Path::new(project_dir).join(".git").exists() {
git_branch(project_dir).unwrap_or_default()
} else {
String::new()
};
let mut line1 = String::new();
if !project_dir.is_empty() && cwd.starts_with(project_dir) {
let rel = &cwd[project_dir.len()..];
let base = std::path::Path::new(project_dir)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(project_dir);
let display = if rel.is_empty() {
base.to_string()
} else {
format!("{base}{rel}")
};
write!(line1, "{BLUE}{display}{RESET}").unwrap();
} else {
let home = std::env::var("HOME").unwrap_or_default();
let display = if !home.is_empty() && cwd.starts_with(&home) {
format!("~{}", &cwd[home.len()..])
} else {
cwd.to_string()
};
write!(line1, "{BLUE}{display}{RESET}").unwrap();
}
let mut git_info = String::new();
if !repo_owner.is_empty() && !repo_name.is_empty() {
write!(git_info, "{YELLOW}{repo_owner}/{repo_name}").unwrap();
if !branch.is_empty() {
write!(git_info, ":{branch}").unwrap();
}
write!(git_info, "{RESET}").unwrap();
} else if !branch.is_empty() {
write!(git_info, "{YELLOW}{branch}{RESET}").unwrap();
}
if !worktree_name.is_empty() {
write!(git_info, " {BLUE}[wt:{worktree_name}]{RESET}").unwrap();
}
if let Some(pr) = &input.pr
&& let Some(num) = &pr.number
{
let num_str = match num {
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => s.clone(),
_ => String::new(),
};
if !num_str.is_empty() {
let suffix = match pr.review_state.as_deref() {
Some("approved") => "✓",
Some("changes_requested") => "✗",
Some("draft") => "~",
_ => "",
};
write!(git_info, " {MAGENTA}[PR#{num_str}{suffix}]{RESET}").unwrap();
}
}
if !git_info.is_empty() {
write!(line1, " {git_info}").unwrap();
}
if !session_name.is_empty() {
write!(line1, " {GRAY}|{RESET} {MAGENTA}{session_name}{RESET}").unwrap();
}
let mut line2 = String::new();
write!(line2, "{GREEN}{model}{RESET}").unwrap();
if let Some(ctx) = &input.context_window {
let ctx_input = ctx.total_input_tokens.unwrap_or(0);
let ctx_output = ctx.total_output_tokens.unwrap_or(0);
let ctx_size = ctx.context_window_size.unwrap_or(0);
if ctx_size > 0
&& let Some(used_pct) = ctx.used_percentage
{
let current_k = (ctx_input + ctx_output) / 1000;
let max_k = ctx_size / 1000;
write!(
line2,
" {GRAY}[{current_k}k/{max_k}k ({:.0}% used)]{RESET}",
used_pct
)
.unwrap();
}
}
let mut line3 = String::new();
if let Some(cost) = &input.cost {
if let Some(usd) = cost.total_cost_usd {
write!(line3, "{YELLOW}${usd:.2}{RESET}").unwrap();
if let Some(monthly) = monthly_total {
write!(line3, " {GRAY}(${monthly:.2}/mo){RESET}").unwrap();
}
}
if let Some(duration_ms) = cost.total_duration_ms {
let dur = format_duration(duration_ms);
write!(line3, " {GRAY}· {dur}{RESET}").unwrap();
}
let added = cost.total_lines_added.unwrap_or(0);
let removed = cost.total_lines_removed.unwrap_or(0);
if added > 0 || removed > 0 {
write!(
line3,
" {GRAY}·{RESET} {GREEN}+{added}{RESET}{GRAY}/{RESET}{RED}-{removed}{RESET} {GRAY}lines{RESET}"
)
.unwrap();
}
}
print!("{line1}\n{line2}\n{line3}");
}
fn show_stats(month_arg: Option<String>) {
let (year, month) = match month_arg.as_deref() {
Some(s) => parse_month_arg(s),
None => {
let now = chrono::Utc::now();
(now.year(), now.month())
}
};
let conn = match db::open_db() {
Ok(c) => c,
Err(e) => {
eprintln!("Failed to open database: {e}");
std::process::exit(1);
}
};
let total = db::get_monthly_total(&conn, year, month).unwrap_or(0.0);
let count = db::monthly_session_count(&conn, year, month).unwrap_or(0);
if count == 0 {
println!("No session data recorded yet.");
return;
}
let month_full = month_name_full(month);
let month_short = month_name_short(month);
println!(
"{YELLOW}Monthly Cost: ${total:.2}{RESET} ({month_full} {year}, {count} session{})\n",
if count == 1 { "" } else { "s" }
);
if let Ok(days) = db::daily_breakdown(&conn, year, month) {
let max_cost = days.iter().map(|(_, c, _)| *c).fold(0.0_f64, f64::max);
println!("{GREEN}By Day:{RESET}");
for (day, cost, sessions) in &days {
let bar = render_bar(*cost, max_cost, 10);
println!(
" {month_short} {day:<2} {YELLOW}${cost:.2}{RESET} {bar} {GRAY}({sessions} session{}){RESET}",
if *sessions == 1 { "" } else { "s" }
);
}
println!();
}
if let Ok(models) = db::model_breakdown(&conn, year, month) {
println!("{GREEN}By Model:{RESET}");
let name_width = models.iter().map(|(n, _)| n.len()).max().unwrap_or(0);
for (name, cost) in &models {
let pct = if total > 0.0 {
cost / total * 100.0
} else {
0.0
};
println!(" {name:<name_width$} {YELLOW}${cost:.2}{RESET} {GRAY}({pct:.0}%){RESET}");
}
println!();
}
if let Ok(projects) = db::project_breakdown(&conn, year, month) {
println!("{GREEN}By Project:{RESET}");
let name_width = projects.iter().map(|(n, _)| n.len()).max().unwrap_or(0);
for (name, cost) in &projects {
let pct = if total > 0.0 {
cost / total * 100.0
} else {
0.0
};
println!(" {name:<name_width$} {YELLOW}${cost:.2}{RESET} {GRAY}({pct:.0}%){RESET}");
}
}
}
fn parse_month_arg(s: &str) -> (i32, u32) {
let parts: Vec<&str> = s.split('-').collect();
if parts.len() == 2
&& let (Ok(y), Ok(m)) = (parts[0].parse::<i32>(), parts[1].parse::<u32>())
&& (1..=12).contains(&m)
{
return (y, m);
}
eprintln!("Invalid month format '{s}', expected YYYY-MM");
std::process::exit(1);
}
fn month_name_short(month: u32) -> &'static str {
match month {
1 => "Jan",
2 => "Feb",
3 => "Mar",
4 => "Apr",
5 => "May",
6 => "Jun",
7 => "Jul",
8 => "Aug",
9 => "Sep",
10 => "Oct",
11 => "Nov",
12 => "Dec",
_ => "???",
}
}
fn month_name_full(month: u32) -> &'static str {
match month {
1 => "January",
2 => "February",
3 => "March",
4 => "April",
5 => "May",
6 => "June",
7 => "July",
8 => "August",
9 => "September",
10 => "October",
11 => "November",
12 => "December",
_ => "???",
}
}
fn render_bar(value: f64, max: f64, width: usize) -> String {
let filled = if max > 0.0 {
(value / max * width as f64).round() as usize
} else {
0
};
let empty = width.saturating_sub(filled);
format!(
"{GREEN}{}{GRAY}{}{RESET}",
"█".repeat(filled),
"░".repeat(empty)
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_duration() {
assert_eq!(format_duration(0), "0s");
assert_eq!(format_duration(5_000), "5s");
assert_eq!(format_duration(65_000), "1m 5s");
assert_eq!(format_duration(3_661_000), "1h 1m");
}
}