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()
};
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();
}
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();
}
}
}
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}");
}