use std::collections::HashSet;
use chrono::{DateTime, Utc};
use crate::config::Config;
use crate::context::{calculate_context, format_ctx_full, format_ctx_short};
use crate::git::get_git_info;
use crate::quota::{format_quota_compact, format_quota_display, format_time_remaining, get_quota};
use crate::render::get_details_and_fg_codes;
use crate::statusline::build_statusline;
use crate::types::{self, ClaudeInput, GitInfo, Section};
use crate::utils::{
format_duration_ms, get_reasoning_mode_label, get_terminal_width, get_username,
};
pub fn parse_claude_input(input: &str) -> Result<ClaudeInput, serde_json::Error> {
serde_json::from_str(input)
}
pub fn render_statusline(input: &ClaudeInput, config: &Config) -> Vec<String> {
render_statusline_with_width(input, config, get_terminal_width(config))
}
pub fn render_statusline_with_width(
input: &ClaudeInput,
config: &Config,
term_width: usize,
) -> Vec<String> {
let cwd = resolve_cwd(input);
let git_info = if config.sections.git.enabled {
get_git_info(&cwd)
} else {
None
};
let cwd_display = format_cwd_display(input, &cwd, config, git_info.is_none());
let git_display = if config.sections.git.enabled {
git_info
.as_ref()
.map(|info| format_git_display(input, info, config))
} else {
None
};
let model_display = format_model_display(input, config);
let quota = if config.sections.quota.enabled {
get_quota(config)
} else {
None
};
let ctx = if config.sections.context.enabled {
calculate_context(input.context_window.as_ref(), config)
} else {
None
};
let cost_display = if config.sections.cost.enabled {
format_cost_display(input.cost.as_ref(), config)
} else {
None
};
let mut sections = Vec::new();
let mut priority = 0u16;
if config.sections.cwd.enabled {
sections.push(Section::new(cwd_display, priority, config.theme.cwd));
priority += 1;
}
if config.sections.git.enabled {
if let Some(display) = git_display {
sections.push(Section::new(display, priority, config.theme.git));
}
priority += 1;
}
if config.sections.model.enabled {
sections.push(Section::new(model_display, priority, config.theme.model));
priority += 1;
}
if config.sections.context.enabled {
match ctx {
Some(info) => {
sections.push(Section::new_context_compact(
format_ctx_short(&info, config),
priority,
config.theme.context,
));
sections.push(Section::new_context_detailed(
format_ctx_full(&info, &config.theme.context, config),
priority + 10,
config.theme.context,
));
priority += 20;
}
None => {
sections.push(Section::new(
"ctx: -".to_string(),
priority,
config.theme.context,
));
priority += 1;
}
}
}
if config.sections.quota.enabled {
let (quota_sections, next_priority) =
build_quota_sections(quota.as_ref(), config, priority, Utc::now());
sections.extend(quota_sections);
priority = next_priority;
}
if let Some(cost) = cost_display {
sections.push(Section::new(cost, priority, config.theme.cost));
}
build_statusline(sections, term_width, config)
}
fn resolve_cwd(input: &ClaudeInput) -> String {
input
.workspace
.as_ref()
.and_then(|w| w.current_dir.clone())
.or_else(|| input.cwd.clone())
.unwrap_or_else(|| ".".to_string())
}
fn format_cwd_display(
input: &ClaudeInput,
cwd: &str,
config: &Config,
include_worktree_detail: bool,
) -> String {
let cwd_path = if config.sections.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.to_string()
}
} else {
cwd.to_string()
}
} else {
std::path::Path::new(cwd)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| cwd.to_string())
};
let base = if config.sections.cwd.show_username {
if let Some(username) = get_username() {
let (details, fg) = get_details_and_fg_codes(&config.theme.cwd);
format!("{}{}@{}{}", details, username, fg, cwd_path)
} else {
cwd_path
}
} else {
cwd_path
};
let mut detail_parts = workspace_scope_details(input);
if include_worktree_detail {
if let Some(worktree_detail) = worktree_detail(input, None) {
detail_parts.push(worktree_detail);
}
}
append_details(base, detail_parts, &config.theme.cwd, config)
}
fn format_git_display(input: &ClaudeInput, info: &GitInfo, config: &Config) -> String {
let mut parts = Vec::new();
if config.sections.git.show_repo_name {
if let Some(ref repo_name) = info.repo_name {
parts.push(repo_name.clone());
}
}
let branch_icon = if config.display.use_powerline {
" \u{E0A0}"
} else {
""
};
parts.push(format!("[{}]{}", info.branch, branch_icon));
let mut result = parts.join(" ");
if info.is_dirty {
result.push_str(" *");
}
let mut detail_parts = Vec::new();
if let Some(worktree_detail) = worktree_detail(input, Some(info)) {
detail_parts.push(worktree_detail);
}
if config.sections.git.show_diff_stats && (info.lines_added > 0 || info.lines_removed > 0) {
detail_parts.push(format!("+{}", info.lines_added));
detail_parts.push(format!("-{}", info.lines_removed));
}
append_details(result, detail_parts, &config.theme.git, config)
}
fn format_model_display(input: &ClaudeInput, config: &Config) -> String {
let model_name = input
.model
.as_ref()
.and_then(|m| m.display_name.clone().or_else(|| m.id.clone()))
.unwrap_or_else(|| "Claude".to_string());
let mut detail_parts = Vec::new();
if config.sections.model.show_output_style {
if let Some(style_name) = input
.output_style
.as_ref()
.and_then(|style| style.name.as_ref())
.map(|name| name.trim())
.filter(|name| !name.is_empty())
{
detail_parts.push(style_name.to_string());
}
}
if config.sections.model.show_thinking_mode {
if let Some(label) = get_reasoning_mode_label() {
detail_parts.push(label);
}
}
append_details(model_name, detail_parts, &config.theme.model, config)
}
fn build_quota_sections(
quota: Option<&types::QuotaData>,
config: &Config,
start_priority: u16,
now: DateTime<Utc>,
) -> (Vec<Section>, u16) {
let mut priority = start_priority;
let mut sections = Vec::new();
if let Some(q) = quota {
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_compact(
"5h",
format_quota_compact("5h", q.five_hour_pct),
priority,
config.theme.quota_5h,
));
if config.sections.quota.show_time_remaining && !five_hr_reset.is_empty() {
sections.push(Section::new_quota_detailed(
"5h",
format_quota_display(
"5h",
q.five_hour_pct,
&five_hr_reset,
&config.theme.quota_5h,
),
priority + 10,
config.theme.quota_5h,
));
}
priority += 20;
sections.push(Section::new_quota_compact(
"7d",
format_quota_compact("7d", q.seven_day_pct),
priority,
config.theme.quota_7d,
));
if config.sections.quota.show_time_remaining && !seven_day_reset.is_empty() {
sections.push(Section::new_quota_detailed(
"7d",
format_quota_display(
"7d",
q.seven_day_pct,
&seven_day_reset,
&config.theme.quota_7d,
),
priority + 10,
config.theme.quota_7d,
));
}
priority += 20;
} else {
sections.push(Section::new(
"5h: -".to_string(),
priority,
config.theme.quota_5h,
));
priority += 20;
sections.push(Section::new(
"7d: -".to_string(),
priority,
config.theme.quota_7d,
));
priority += 20;
}
(sections, priority)
}
fn format_cost_display(cost: Option<&types::Cost>, config: &Config) -> Option<String> {
cost.and_then(|c| c.total_cost_usd)
.filter(|&usd| usd > 0.0)
.map(|usd| {
let mut result = format!("${:.2}", usd);
if config.sections.cost.show_durations {
let mut details_parts = Vec::new();
if let Some(total_ms) = cost.and_then(|c| c.total_duration_ms) {
details_parts.push(format_duration_ms(total_ms));
}
if let Some(api_ms) = cost.and_then(|c| c.total_api_duration_ms) {
details_parts.push(format!("api: {}", format_duration_ms(api_ms)));
}
if !details_parts.is_empty() {
let (details, fg) = get_details_and_fg_codes(&config.theme.cost);
result.push_str(&format!(
" {}({}){}",
details,
details_parts.join(&config.display.details_separator),
fg
));
}
}
result
})
}
fn append_details(
base: String,
details_parts: Vec<String>,
colors: &crate::colors::SectionColors,
config: &Config,
) -> String {
if details_parts.is_empty() {
return base;
}
let (details, fg) = get_details_and_fg_codes(colors);
format!(
"{} {}({}){}",
base,
details,
details_parts.join(&config.display.details_separator),
fg
)
}
fn workspace_scope_details(input: &ClaudeInput) -> Vec<String> {
let workspace = match input.workspace.as_ref() {
Some(workspace) => workspace,
None => return Vec::new(),
};
let current_dir = workspace.current_dir.as_deref();
let project_dir = workspace.project_dir.as_deref();
let mut unique_dirs = HashSet::new();
for dir in workspace
.added_dirs
.as_ref()
.into_iter()
.flatten()
.map(|dir| dir.trim())
.filter(|dir| !dir.is_empty())
{
if Some(dir) == current_dir || Some(dir) == project_dir {
continue;
}
unique_dirs.insert(dir.to_string());
}
if unique_dirs.is_empty() {
Vec::new()
} else {
vec![format!(
"+{} dir{}",
unique_dirs.len(),
if unique_dirs.len() == 1 { "" } else { "s" }
)]
}
}
fn worktree_detail(input: &ClaudeInput, git_info: Option<&GitInfo>) -> Option<String> {
let worktree = input.worktree.as_ref()?;
if let Some(name) = worktree
.name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
{
if git_info.map(|git| git.branch != name).unwrap_or(true) {
return Some(format!("wt: {}", name));
}
}
if let Some(branch) = worktree
.branch
.as_deref()
.map(str::trim)
.filter(|branch| !branch.is_empty())
{
if git_info.map(|git| git.branch != branch).unwrap_or(true) {
return Some(format!("wt: {}", branch));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{
ContextWindow, Cost, Model, OutputStyle, QuotaData, SectionKind, Workspace, Worktree,
};
use chrono::Duration;
fn base_input() -> ClaudeInput {
ClaudeInput {
cwd: None,
workspace: Some(Workspace {
current_dir: Some("/tmp/project".to_string()),
project_dir: Some("/tmp/project".to_string()),
added_dirs: None,
}),
model: Some(Model {
id: Some("claude-sonnet-4-6".to_string()),
display_name: Some("Sonnet 4.6".to_string()),
}),
context_window: Some(ContextWindow {
context_window_size: Some(200_000),
total_input_tokens: None,
total_output_tokens: None,
current_usage: None,
used_percentage: Some(12.0),
remaining_percentage: Some(88.0),
}),
cost: Some(Cost {
total_cost_usd: Some(0.42),
total_duration_ms: Some(1234),
total_api_duration_ms: Some(456),
total_lines_added: None,
total_lines_removed: None,
}),
output_style: Some(OutputStyle {
name: Some("Learning".to_string()),
}),
worktree: None,
version: Some("2.1.76".to_string()),
}
}
#[test]
fn test_workspace_scope_details_counts_unique_dirs() {
let mut input = base_input();
input.workspace = Some(Workspace {
current_dir: Some("/tmp/project".to_string()),
project_dir: Some("/tmp/project".to_string()),
added_dirs: Some(vec![
"/tmp/project/docs".to_string(),
"/tmp/project/scripts".to_string(),
"/tmp/project/docs".to_string(),
"/tmp/project".to_string(),
]),
});
assert_eq!(workspace_scope_details(&input), vec!["+2 dirs".to_string()]);
}
#[test]
fn test_worktree_detail_prefers_name() {
let mut input = base_input();
input.worktree = Some(Worktree {
name: Some("feature-a".to_string()),
path: None,
branch: Some("worktree-feature-a".to_string()),
original_cwd: None,
original_branch: None,
});
assert_eq!(
worktree_detail(&input, None),
Some("wt: feature-a".to_string())
);
}
#[test]
fn test_build_quota_sections_respects_timer_setting() {
let mut config = Config::default();
config.sections.quota.show_time_remaining = false;
let now = Utc::now();
let quota = QuotaData {
five_hour_pct: Some(45.0),
five_hour_resets_at: Some((now + Duration::hours(2)).to_rfc3339()),
seven_day_pct: Some(12.0),
seven_day_resets_at: Some((now + Duration::days(3)).to_rfc3339()),
};
let (sections, _) = build_quota_sections(Some("a), &config, 0, now);
assert_eq!(sections.len(), 2);
assert!(sections
.iter()
.all(|section| { matches!(section.kind, SectionKind::QuotaCompact(_)) }));
}
#[test]
fn test_render_statusline_with_width_uses_output_style_label() {
let mut config = Config::default();
config.sections.git.enabled = false;
config.sections.quota.enabled = false;
config.sections.model.show_thinking_mode = false;
config.sections.model.show_output_style = true;
config.display.show_background = false;
let lines = render_statusline_with_width(&base_input(), &config, 120);
let joined = lines.join(" ");
assert!(joined.contains("Learning"));
}
}