paddington 0.1.0

A fast, minimal status line renderer for Claude Code
use serde::Deserialize;
use std::fmt::Write;
use std::io::Read;
use std::process::Command;

#[derive(Deserialize, Default)]
struct Input {
    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 {
    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() {
    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 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()
    };

    // ── Line 1: path + git + session ──
    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 {
        if 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();
    }

    // ── Line 2: model + context window ──
    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 {
            if 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();
            }
        }
    }

    // ── Line 3: cost + duration + lines changed ──
    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(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}");
}