use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use unicode_width::UnicodeWidthChar;
const SHOW_CWD: bool = true;
const CWD_FULL_PATH: bool = true; const SHOW_GIT: bool = true;
const SHOW_MODEL: bool = true;
const SHOW_CONTEXT: bool = true;
const CONTEXT_USE_FLOAT: bool = false; const SHOW_QUOTA: bool = true; const SHOW_COST: bool = false; const QUOTA_CACHE_TTL: u64 = 0; const DEFAULT_TERMINAL_WIDTH: usize = 120; const RIGHT_MARGIN: usize = 20;
const USE_DARK_THEME: bool = true;
const USE_POWERLINE: bool = true; const POWERLINE_ARROW: char = '\u{E0B0}';
const DARK_BG: (u8, u8, u8) = (217, 119, 87); const DARK_FG: (u8, u8, u8) = (38, 38, 36); const DARK_SEP: (u8, u8, u8) = (38, 38, 36); const DARK_MUTED: (u8, u8, u8) = (65, 65, 62);
const LIGHT_BG: (u8, u8, u8) = (217, 119, 87); const LIGHT_FG: (u8, u8, u8) = (250, 249, 245); const LIGHT_SEP: (u8, u8, u8) = (250, 249, 245); const LIGHT_MUTED: (u8, u8, u8) = (130, 129, 122);
const SHOW_BACKGROUND: bool = true;
const SEPARATOR_CHAR: char = '|';
#[derive(Clone, Copy)]
struct SectionColors {
bg: (u8, u8, u8),
fg: (u8, u8, u8),
muted: (u8, u8, u8),
}
const CWD_COLORS: SectionColors = SectionColors {
bg: (217, 119, 87), fg: (38, 38, 36), muted: (65, 65, 62), };
const GIT_COLORS: SectionColors = SectionColors {
bg: (38, 38, 36), fg: (217, 119, 87), muted: (130, 129, 122), };
const MODEL_COLORS: SectionColors = SectionColors {
bg: (217, 119, 87), fg: (38, 38, 36), muted: (65, 65, 62), };
const CONTEXT_COLORS: SectionColors = SectionColors {
bg: (38, 38, 36), fg: (217, 119, 87), muted: (130, 129, 122), };
const QUOTA_5H_COLORS: SectionColors = SectionColors {
bg: (217, 119, 87), fg: (38, 38, 36), muted: (65, 65, 62), };
const QUOTA_7D_COLORS: SectionColors = SectionColors {
bg: (38, 38, 36), fg: (217, 119, 87), muted: (130, 129, 122), };
const COST_COLORS: SectionColors = SectionColors {
bg: (217, 119, 87), fg: (38, 38, 36), muted: (65, 65, 62), };
const DEFAULT_CONTEXT_WINDOW: u64 = 200_000;
const RESET: &str = "\x1b[0m";
#[derive(Clone, PartialEq, Debug)]
enum SectionKind {
Generic,
QuotaShort(String),
QuotaFull(String),
}
#[derive(Clone)]
struct Section {
kind: SectionKind,
content: String,
priority: u16, colors: SectionColors, width: usize, }
impl Section {
fn new(content: String, priority: u16, colors: SectionColors) -> Self {
let width = visible_len(&content);
Section {
kind: SectionKind::Generic,
content,
priority,
colors,
width,
}
}
fn new_quota_short(label: &str, content: String, priority: u16, colors: SectionColors) -> Self {
let width = visible_len(&content);
Section {
kind: SectionKind::QuotaShort(label.to_string()),
content,
priority,
colors,
width,
}
}
fn new_quota_full(label: &str, content: String, priority: u16, colors: SectionColors) -> Self {
let width = visible_len(&content);
Section {
kind: SectionKind::QuotaFull(label.to_string()),
content,
priority,
colors,
width,
}
}
}
fn visible_len(s: &str) -> usize {
let mut width = 0;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if let Some(&next) = chars.peek() {
if next == '[' {
chars.next(); for c2 in chars.by_ref() {
if ('@'..='~').contains(&c2) {
break;
}
}
}
}
} else {
width += c.width().unwrap_or(0);
}
}
width
}
fn truncate_with_ellipsis(s: &str, max_width: usize) -> String {
if visible_len(s) <= max_width {
return s.to_string();
}
const ELLIPSIS: &str = "…";
const ELLIPSIS_WIDTH: usize = 1;
if max_width <= ELLIPSIS_WIDTH {
return ELLIPSIS.chars().take(max_width).collect();
}
let target_width = max_width - ELLIPSIS_WIDTH;
let mut current_width = 0;
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
result.push(c);
if let Some(&next) = chars.peek() {
if next == '[' {
result.push(chars.next().unwrap()); for c2 in chars.by_ref() {
result.push(c2);
if ('@'..='~').contains(&c2) {
break;
}
}
}
}
} else {
let char_width = c.width().unwrap_or(0);
if current_width + char_width > target_width {
break;
}
result.push(c);
current_width += char_width;
}
}
result.push_str(ELLIPSIS);
result.push_str(RESET);
result
}
fn get_terminal_width() -> usize {
if let Ok(w_str) = std::env::var("CLAUDE_TERMINAL_WIDTH") {
if let Ok(w) = w_str.parse::<usize>() {
return w;
}
}
if let Ok(w_str) = std::env::var("COLUMNS") {
if let Ok(w) = w_str.parse::<usize>() {
return w;
}
}
if let Some((w, _)) = terminal_size::terminal_size() {
return w.0 as usize;
}
#[cfg(unix)]
{
if let Ok(f) = std::fs::File::open("/dev/tty") {
if let Some((w, _)) = terminal_size::terminal_size_of(&f) {
return w.0 as usize;
}
}
}
DEFAULT_TERMINAL_WIDTH
}
fn build_statusline(sections: Vec<Section>, max_width: usize) -> String {
if sections.is_empty() {
return String::new();
}
let mut sorted = sections;
sorted.sort_by_key(|s| s.priority);
while sorted.len() > 1 {
let mut deduped: Vec<&Section> = Vec::with_capacity(sorted.len());
let full_labels: std::collections::HashSet<&str> = sorted
.iter()
.filter_map(|s| match &s.kind {
SectionKind::QuotaFull(l) => Some(l.as_str()),
_ => None,
})
.collect();
for section in sorted.iter() {
match §ion.kind {
SectionKind::QuotaShort(label) => {
if !full_labels.contains(label.as_str()) {
deduped.push(section);
}
}
_ => deduped.push(section),
}
}
let content_width: usize = deduped.iter().map(|s| s.width).sum();
let count = deduped.len();
let total_width = if USE_POWERLINE {
content_width + count * 3
} else {
if count > 0 {
content_width + 2 + (count - 1) * 3
} else {
0
}
};
if total_width <= max_width {
return if USE_POWERLINE {
render_with_powerline(&deduped)
} else {
render_with_separator(&deduped)
};
}
sorted.pop();
}
if let Some(last) = sorted.first() {
let rendered = if USE_POWERLINE {
render_with_powerline(&[last])
} else {
render_with_separator(&[last])
};
let visible = visible_len(&rendered);
if visible > max_width {
return truncate_with_ellipsis(&rendered, max_width);
}
return rendered;
}
String::new()
}
fn render_with_powerline(sections: &[&Section]) -> String {
let mut result = String::new();
for (i, section) in sections.iter().enumerate() {
result.push_str(&render_section(§ion.content, §ion.colors));
if i < sections.len() - 1 {
let next = sections[i + 1];
result.push_str(&make_powerline_arrow(section.colors.bg, next.colors.bg));
}
}
if let Some(last) = sections.last() {
result.push_str(&format!(
"\x1b[38;2;{};{};{}m\x1b[49m{}",
last.colors.bg.0, last.colors.bg.1, last.colors.bg.2, POWERLINE_ARROW
));
}
result.push_str(RESET);
result
}
fn render_with_separator(sections: &[&Section]) -> String {
let separator = make_separator();
let color_start = make_color_start();
let mut result = String::with_capacity(sections.len() * 30 + 50);
result.push_str(&color_start);
result.push(' ');
for (i, section) in sections.iter().enumerate() {
if i > 0 {
result.push_str(&separator);
}
result.push_str(§ion.content);
}
result.push(' ');
result.push_str(RESET);
result
}
fn render_section(content: &str, colors: &SectionColors) -> String {
format!(
"\x1b[48;2;{};{};{}m\x1b[38;2;{};{};{}m {} ",
colors.bg.0, colors.bg.1, colors.bg.2, colors.fg.0, colors.fg.1, colors.fg.2, content
)
}
fn make_powerline_arrow(left_bg: (u8, u8, u8), right_bg: (u8, u8, u8)) -> String {
format!(
"\x1b[38;2;{};{};{}m\x1b[48;2;{};{};{}m{}",
left_bg.0, left_bg.1, left_bg.2, right_bg.0, right_bg.1, right_bg.2, POWERLINE_ARROW
)
}
fn make_muted_from_section(colors: &SectionColors) -> String {
format!(
"\x1b[38;2;{};{};{}m",
colors.muted.0, colors.muted.1, colors.muted.2
)
}
fn restore_fg_from_section(colors: &SectionColors) -> String {
format!("\x1b[38;2;{};{};{}m", colors.fg.0, colors.fg.1, colors.fg.2)
}
fn get_muted_and_fg_codes(colors: &SectionColors) -> (String, String) {
if USE_POWERLINE {
(
make_muted_from_section(colors),
restore_fg_from_section(colors),
)
} else {
let muted = make_muted_color();
let fg_color = if USE_DARK_THEME { DARK_FG } else { LIGHT_FG };
let fg = format!("\x1b[38;2;{};{};{}m", fg_color.0, fg_color.1, fg_color.2);
(muted, fg)
}
}
fn make_color_start() -> String {
let (fg_r, fg_g, fg_b) = if USE_DARK_THEME { DARK_FG } else { LIGHT_FG };
if SHOW_BACKGROUND {
let (bg_r, bg_g, bg_b) = if USE_DARK_THEME { DARK_BG } else { LIGHT_BG };
format!(
"\x1b[48;2;{};{};{}m\x1b[38;2;{};{};{}m",
bg_r, bg_g, bg_b, fg_r, fg_g, fg_b
)
} else {
format!("\x1b[38;2;{};{};{}m", fg_r, fg_g, fg_b)
}
}
fn make_separator() -> String {
let (sep_r, sep_g, sep_b) = if USE_DARK_THEME { DARK_SEP } else { LIGHT_SEP };
let (fg_r, fg_g, fg_b) = if USE_DARK_THEME { DARK_FG } else { LIGHT_FG };
format!(
" \x1b[38;2;{};{};{}m{}\x1b[38;2;{};{};{}m ",
sep_r, sep_g, sep_b, SEPARATOR_CHAR, fg_r, fg_g, fg_b
)
}
fn make_muted_color() -> String {
let (r, g, b) = if USE_DARK_THEME {
DARK_MUTED
} else {
LIGHT_MUTED
};
format!("\x1b[38;2;{};{};{}m", r, g, b)
}
#[derive(Debug, Deserialize)]
struct ClaudeInput {
workspace: Option<Workspace>,
model: Option<Model>,
context_window: Option<ContextWindow>,
cost: Option<Cost>,
}
#[derive(Debug, Deserialize)]
struct Workspace {
current_dir: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Model {
display_name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ContextWindow {
context_window_size: Option<u64>,
current_usage: Option<CurrentUsage>,
}
#[derive(Debug, Deserialize)]
struct CurrentUsage {
input_tokens: Option<u64>,
_output_tokens: Option<u64>,
cache_creation_input_tokens: Option<u64>,
cache_read_input_tokens: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct Cost {
total_cost_usd: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct QuotaResponse {
five_hour: Option<QuotaLimit>,
seven_day: Option<QuotaLimit>,
}
#[derive(Debug, Deserialize)]
struct QuotaLimit {
utilization: f64,
resets_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct KeychainCredentials {
#[serde(rename = "claudeAiOauth")]
claude_ai_oauth: Option<OAuthCredentials>,
}
#[derive(Debug, Deserialize)]
struct OAuthCredentials {
#[serde(rename = "accessToken")]
access_token: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct QuotaData {
five_hour_pct: Option<f64>,
five_hour_resets_at: Option<String>,
seven_day_pct: Option<f64>,
seven_day_resets_at: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct CachedQuota {
timestamp: u64,
data: QuotaData,
}
fn get_oauth_token() -> Option<String> {
if let Ok(token) = std::env::var("CLAUDE_OAUTH_TOKEN") {
if token.contains('\r') || token.contains('\n') {
return None;
}
return Some(token);
}
#[cfg(target_os = "macos")]
{
let output = Command::new("security")
.args([
"find-generic-password",
"-s",
"Claude Code-credentials",
"-w",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let creds_json = String::from_utf8(output.stdout).ok()?;
let creds: KeychainCredentials = serde_json::from_str(&creds_json).ok()?;
let token = creds.claude_ai_oauth?.access_token;
if token.contains('\r') || token.contains('\n') {
return None;
}
Some(token)
}
#[cfg(not(target_os = "macos"))]
{
#[cfg(unix)]
let home_dir = std::env::var_os("HOME");
#[cfg(windows)]
let home_dir = std::env::var_os("USERPROFILE");
if let Some(home) = home_dir {
let creds_path = std::path::Path::new(&home)
.join(".claude")
.join(".credentials.json");
if let Ok(creds_json) = fs::read_to_string(creds_path) {
if let Ok(creds) = serde_json::from_str::<KeychainCredentials>(&creds_json) {
if let Some(oauth) = creds.claude_ai_oauth {
let token = oauth.access_token;
if !token.contains('\r') && !token.contains('\n') {
return Some(token);
}
}
}
}
}
let username = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "default".to_string());
let entry = keyring::Entry::new("Claude Code-credentials", &username).ok()?;
let creds_json = entry.get_password().ok()?;
let creds: KeychainCredentials = serde_json::from_str(&creds_json).ok()?;
let token = creds.claude_ai_oauth?.access_token;
if token.contains('\r') || token.contains('\n') {
return None;
}
Some(token)
}
}
fn fetch_quota_from_api_safe(token: &str) -> Option<QuotaData> {
let output = match Command::new("curl")
.args([
"-s", "-f", "-m",
"1", "-H",
"Accept: application/json",
"-H",
"Content-Type: application/json",
"-H",
"User-Agent: claude-code/2.0.31",
"-H",
&format!("Authorization: Bearer {}", token),
"-H",
"anthropic-beta: oauth-2025-04-20",
"https://api.anthropic.com/api/oauth/usage",
])
.output()
{
Ok(o) => o,
Err(e) => {
if std::env::var("STATUSLINE_DEBUG").is_ok() {
eprintln!("statusline warning: curl not available: {}", e);
}
return None;
}
};
if !output.status.success() {
if std::env::var("STATUSLINE_DEBUG").is_ok() {
eprintln!(
"statusline warning: quota fetch failed with status {:?}",
output.status
);
}
return None;
}
let q: QuotaResponse = serde_json::from_slice(&output.stdout).ok()?;
Some(QuotaData {
five_hour_pct: q.five_hour.as_ref().map(|x| x.utilization),
five_hour_resets_at: q.five_hour.and_then(|x| x.resets_at),
seven_day_pct: q.seven_day.as_ref().map(|x| x.utilization),
seven_day_resets_at: q.seven_day.and_then(|x| x.resets_at),
})
}
fn get_cache_path() -> PathBuf {
let temp_dir = std::env::temp_dir();
#[cfg(unix)]
let uid = unsafe { libc::getuid() };
#[cfg(not(unix))]
let uid = std::env::var("USERNAME")
.or_else(|_| std::env::var("USER"))
.unwrap_or_else(|_| "default".to_string());
temp_dir.join(format!("claude-statusline-quota-{}.json", uid))
}
fn write_cache_atomic(path: &Path, content: &str) -> std::io::Result<()> {
use std::io::Write;
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let mut f = tempfile::NamedTempFile::new_in(dir)?;
f.write_all(content.as_bytes())?;
f.persist(path).map(|_| ()).map_err(|e| e.error)
}
#[allow(clippy::absurd_extreme_comparisons)]
fn get_quota() -> Option<QuotaData> {
let cache_path = get_cache_path();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let cached_full = fs::read_to_string(&cache_path)
.ok()
.and_then(|content| serde_json::from_str::<CachedQuota>(&content).ok());
let is_fresh = if QUOTA_CACHE_TTL == 0 {
false
} else if let Some(cache) = &cached_full {
now.saturating_sub(cache.timestamp) < QUOTA_CACHE_TTL
} else {
false
};
if is_fresh {
return cached_full.map(|c| c.data);
}
if let Some(token) = get_oauth_token() {
if let Some(fresh_data) = fetch_quota_from_api_safe(&token) {
let cache = CachedQuota {
timestamp: now,
data: fresh_data.clone(),
};
if let Ok(json) = serde_json::to_string(&cache) {
let _ = write_cache_atomic(&cache_path, &json);
}
return Some(fresh_data);
}
}
cached_full.map(|c| c.data)
}
struct GitInfo {
branch: String,
is_dirty: bool,
}
fn get_git_info(dir: &str) -> Option<GitInfo> {
let output = match Command::new("git")
.args(["-C", dir, "status", "--porcelain", "-b"])
.output()
{
Ok(o) => o,
Err(e) => {
if std::env::var("STATUSLINE_DEBUG").is_ok() {
eprintln!("statusline warning: git not available: {}", e);
}
return None;
}
};
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
let lines: Vec<&str> = stdout.lines().collect();
if lines.is_empty() {
return None;
}
let branch_line = lines[0];
let branch = if let Some(raw) = branch_line.strip_prefix("## ") {
if let Some(idx) = raw.find("...") {
raw[..idx].to_string()
} else if let Some(stripped) = raw.strip_prefix("No commits yet on ") {
stripped.to_string()
} else if raw.starts_with("HEAD (no branch)") {
"HEAD".to_string()
} else {
raw.to_string()
}
} else {
return None;
};
let is_dirty = lines.len() > 1;
Some(GitInfo { branch, is_dirty })
}
fn format_quota_display(
label: &str,
pct: Option<f64>,
reset: &str,
colors: &SectionColors,
) -> String {
let pct_str = match pct {
Some(p) => format!("{}%", p),
None => "-".to_string(),
};
if reset.is_empty() {
format!("{}: {}", label, pct_str)
} else {
let (muted, fg) = get_muted_and_fg_codes(colors);
format!("{}: {} {}({}){}", label, pct_str, muted, reset, fg)
}
}
fn format_quota_short(label: &str, pct: Option<f64>) -> String {
match pct {
Some(p) => format!("{}: {}%", label, p),
None => format!("{}: -", label),
}
}
fn calculate_context(context_window: Option<&ContextWindow>) -> Option<f64> {
let cw = context_window?;
let total = cw.context_window_size.unwrap_or(DEFAULT_CONTEXT_WINDOW);
if total == 0 {
return None;
}
let usage = cw.current_usage.as_ref()?;
let content_tokens = usage.input_tokens.unwrap_or(0)
+ usage.cache_creation_input_tokens.unwrap_or(0)
+ usage.cache_read_input_tokens.unwrap_or(0);
let buffer = (total as f64 * 0.225) as u64;
let total_used = content_tokens + buffer;
let percentage = (total_used as f64 / total as f64 * 100.0).min(100.0);
Some(percentage)
}
fn format_ctx(pct: f64) -> String {
if CONTEXT_USE_FLOAT {
format!("ctx: {:.1}%", pct)
} else {
format!("ctx: {}%", pct as u64)
}
}
fn format_cost_display(cost: Option<&Cost>) -> Option<String> {
cost.and_then(|c| c.total_cost_usd)
.filter(|&usd| usd > 0.0)
.map(|usd| format!("${:.2}", usd))
}
fn format_time_remaining(resets_at: &str, now: DateTime<Utc>) -> String {
let reset_time = match DateTime::parse_from_rfc3339(resets_at) {
Ok(t) => t.with_timezone(&Utc),
Err(_) => return String::new(),
};
let duration = reset_time.signed_duration_since(now);
if duration <= Duration::zero() {
return "now".to_string();
}
let total_minutes = duration.num_minutes();
let total_hours = duration.num_hours();
let total_days = duration.num_days();
if total_days >= 1 {
let hours = total_hours % 24;
format!("{}d {}h", total_days, hours)
} else if total_hours >= 1 {
let minutes = total_minutes % 60;
format!("{}h {}m", total_hours, minutes)
} else {
format!("{}m", total_minutes.max(1))
}
}
fn main() {
const VERSION: &str = env!("CARGO_PKG_VERSION");
if std::env::args().any(|a| a == "--version" || a == "-V") {
println!("claude-statusline {}", VERSION);
return;
}
if std::env::args().any(|a| a == "--help" || a == "-h") {
println!("Claude Code statusline binary");
println!("Usage: Receives JSON on stdin, outputs statusline to stdout");
println!("\nOptions:");
println!(" --version, -V Show version");
println!(" --help, -h Show this help");
return;
}
let mut input_str = String::new();
if io::stdin().read_to_string(&mut input_str).is_err() {
eprintln!("statusline error: failed to read stdin");
println!(); return;
}
let input: ClaudeInput = match serde_json::from_str(&input_str) {
Ok(i) => i,
Err(_) => {
eprintln!("statusline error: invalid JSON");
println!();
return;
}
};
let cwd = input
.workspace
.and_then(|w| w.current_dir)
.unwrap_or_else(|| ".".to_string());
let cwd_display = if CWD_FULL_PATH {
if let Some(home) = std::env::var_os("HOME") {
let home_str = home.to_string_lossy();
if cwd.starts_with(home_str.as_ref()) {
cwd.replacen(home_str.as_ref(), "~", 1)
} else {
cwd.clone()
}
} else {
cwd.clone()
}
} else {
std::path::Path::new(&cwd)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| cwd.clone())
};
let git_info = get_git_info(&cwd);
let make_git_display = |git_info: &Option<GitInfo>, colors: &SectionColors| -> String {
match git_info {
Some(info) => {
if info.is_dirty {
let (muted, fg) = get_muted_and_fg_codes(colors);
format!("({}) {}*{}", info.branch, muted, fg)
} else {
format!("({})", info.branch)
}
}
None => "-".to_string(),
}
};
let git_display = make_git_display(&git_info, &GIT_COLORS);
let model = input
.model
.and_then(|m| m.display_name)
.unwrap_or_else(|| "Sonnet".to_string());
let ctx = calculate_context(input.context_window.as_ref());
let quota = if SHOW_QUOTA { get_quota() } else { None };
let cost_display = if SHOW_COST {
format_cost_display(input.cost.as_ref())
} else {
None
};
let mut sections = Vec::new();
let mut priority = 0u16;
if SHOW_CWD {
sections.push(Section::new(cwd_display, priority, CWD_COLORS));
priority += 1;
}
if SHOW_GIT {
sections.push(Section::new(git_display, priority, GIT_COLORS));
priority += 1;
}
if SHOW_MODEL {
sections.push(Section::new(model, priority, MODEL_COLORS));
priority += 1;
}
if SHOW_CONTEXT {
match ctx {
Some(pct) => {
sections.push(Section::new(format_ctx(pct), priority, CONTEXT_COLORS));
}
None => {
sections.push(Section::new("ctx: -".to_string(), priority, CONTEXT_COLORS));
}
}
priority += 1;
}
if SHOW_QUOTA {
if let Some(q) = quota {
let now = Utc::now();
let five_hr_reset = q
.five_hour_resets_at
.as_ref()
.map(|r| format_time_remaining(r, now))
.unwrap_or_default();
let seven_day_reset = q
.seven_day_resets_at
.as_ref()
.map(|r| format_time_remaining(r, now))
.unwrap_or_default();
sections.push(Section::new_quota_short(
"5h",
format_quota_short("5h", q.five_hour_pct),
priority,
QUOTA_5H_COLORS,
));
if !five_hr_reset.is_empty() {
sections.push(Section::new_quota_full(
"5h",
format_quota_display("5h", q.five_hour_pct, &five_hr_reset, "A_5H_COLORS),
priority + 10,
QUOTA_5H_COLORS,
));
}
priority += 20;
sections.push(Section::new_quota_short(
"7d",
format_quota_short("7d", q.seven_day_pct),
priority,
QUOTA_7D_COLORS,
));
if !seven_day_reset.is_empty() {
sections.push(Section::new_quota_full(
"7d",
format_quota_display("7d", q.seven_day_pct, &seven_day_reset, "A_7D_COLORS),
priority + 10,
QUOTA_7D_COLORS,
));
}
priority += 20;
} else {
sections.push(Section::new("5h: -".to_string(), priority, QUOTA_5H_COLORS));
priority += 20;
sections.push(Section::new("7d: -".to_string(), priority, QUOTA_7D_COLORS));
priority += 20;
}
}
if let Some(cost) = cost_display {
sections.push(Section::new(cost, priority, COST_COLORS));
}
let raw_width = get_terminal_width();
let term_width = raw_width.saturating_sub(RIGHT_MARGIN);
if std::env::var("STATUSLINE_DEBUG").is_ok() {
eprintln!(
"Debug: term_width detected as {} (raw {} - margin {})",
term_width, raw_width, RIGHT_MARGIN
);
}
let content = build_statusline(sections, term_width);
println!("{}", content);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_visible_len() {
assert_eq!(visible_len("Hello"), 5);
assert_eq!(visible_len("\x1b[31mHello\x1b[0m"), 5);
assert_eq!(visible_len("\x1b[38;2;10;20;30mHello"), 5);
assert_eq!(visible_len(""), 0);
assert_eq!(visible_len("foo bar"), 7);
assert_eq!(visible_len("\x1b[1;34mBoldBlue"), 8);
}
#[test]
fn test_truncate_with_ellipsis() {
assert_eq!(truncate_with_ellipsis("Hello World", 11), "Hello World");
assert_eq!(truncate_with_ellipsis("Hello World", 5), "Hell…\x1b[0m");
let s = "\x1b[31mHello World\x1b[0m";
assert_eq!(truncate_with_ellipsis(s, 5), "\x1b[31mHell…\x1b[0m");
}
#[test]
fn test_visible_len_wide_glyphs() {
assert_eq!(visible_len("🧪"), 2);
assert_eq!(visible_len("Test🧪"), 6);
assert_eq!(visible_len("🧪🔬"), 4);
assert_eq!(visible_len("日本語"), 6);
assert_eq!(visible_len("Hello日本"), 9);
assert_eq!(visible_len("Test🧪日本"), 10);
assert_eq!(visible_len("\x1b[31m日本語\x1b[0m"), 6);
assert_eq!(visible_len("\x1b[31m🧪Test\x1b[0m"), 6);
}
#[test]
fn test_truncate_wide_glyphs() {
let emoji_str = "Test🧪Data";
assert_eq!(truncate_with_ellipsis(emoji_str, 7), "Test🧪…\x1b[0m");
assert_eq!(truncate_with_ellipsis(emoji_str, 6), "Test…\x1b[0m");
let cjk_str = "日本語";
assert_eq!(truncate_with_ellipsis(cjk_str, 6), "日本語");
assert_eq!(truncate_with_ellipsis(cjk_str, 5), "日本…\x1b[0m");
assert_eq!(truncate_with_ellipsis(cjk_str, 3), "日…\x1b[0m");
let mixed = "Test日本語";
assert_eq!(truncate_with_ellipsis(mixed, 7), "Test日…\x1b[0m");
assert_eq!(truncate_with_ellipsis(mixed, 10), "Test日本語");
let path = "/home/user/日本語/test";
let truncated = truncate_with_ellipsis(path, 15);
assert!(visible_len(&truncated) <= 15);
}
#[test]
fn test_truncate_wide_boundary() {
let s = "abc日";
assert_eq!(truncate_with_ellipsis(s, 5), "abc日");
assert_eq!(truncate_with_ellipsis(s, 4), "abc…\x1b[0m");
}
#[test]
fn test_format_time_remaining_now() {
let now = Utc::now();
let past = now - Duration::minutes(5);
assert_eq!(format_time_remaining(&past.to_rfc3339(), now), "now");
}
#[test]
fn test_format_time_remaining_minutes() {
let now = Utc::now();
let future = now + Duration::minutes(45);
let result = format_time_remaining(&future.to_rfc3339(), now);
assert!(result.ends_with('m'));
assert!(!result.contains('h'));
assert!(!result.contains('d'));
}
#[test]
fn test_format_time_remaining_hours() {
let now = Utc::now();
let future = now + Duration::hours(5) + Duration::minutes(30);
let result = format_time_remaining(&future.to_rfc3339(), now);
assert!(result.contains('h'));
assert!(result.contains('m'));
assert!(!result.contains('d'));
}
#[test]
fn test_format_time_remaining_days() {
let now = Utc::now();
let future = now + Duration::days(3) + Duration::hours(12);
let result = format_time_remaining(&future.to_rfc3339(), now);
assert!(result.contains('d'));
assert!(result.contains('h'));
}
#[test]
fn test_section_dedup() {
let sections = vec![
Section::new_quota_short("5h", "5h: 10%".to_string(), 1, QUOTA_5H_COLORS),
Section::new_quota_full("5h", "5h: 10% (1h)".to_string(), 101, QUOTA_5H_COLORS),
];
let result = build_statusline(sections.clone(), 100);
assert!(result.contains("(1h)"));
let result_narrow = build_statusline(sections, 5);
assert!(result_narrow.contains("5h"));
}
}