1use std::collections::HashSet;
2
3use chrono::{DateTime, Utc};
4
5use crate::config::Config;
6use crate::context::{calculate_context, format_ctx_full, format_ctx_short};
7use crate::git::get_git_info;
8use crate::quota::{format_quota_compact, format_quota_display, format_time_remaining, get_quota};
9use crate::render::get_details_and_fg_codes;
10use crate::statusline::build_statusline;
11use crate::types::{self, ClaudeInput, GitInfo, Section};
12use crate::utils::{
13 format_duration_ms, get_reasoning_mode_label, get_terminal_width, get_username,
14};
15
16pub fn parse_claude_input(input: &str) -> Result<ClaudeInput, serde_json::Error> {
17 serde_json::from_str(input)
18}
19
20pub fn render_statusline(input: &ClaudeInput, config: &Config) -> Vec<String> {
21 render_statusline_with_width(input, config, get_terminal_width(config))
22}
23
24pub fn render_statusline_with_width(
25 input: &ClaudeInput,
26 config: &Config,
27 term_width: usize,
28) -> Vec<String> {
29 let cwd = resolve_cwd(input);
30
31 let git_info = if config.sections.git.enabled {
32 get_git_info(&cwd)
33 } else {
34 None
35 };
36
37 let cwd_display = format_cwd_display(input, &cwd, config, git_info.is_none());
38
39 let git_display = if config.sections.git.enabled {
40 git_info
41 .as_ref()
42 .map(|info| format_git_display(input, info, config))
43 } else {
44 None
45 };
46
47 let model_display = format_model_display(input, config);
48
49 let quota = if config.sections.quota.enabled {
50 get_quota(config)
51 } else {
52 None
53 };
54
55 let ctx = if config.sections.context.enabled {
56 calculate_context(input.context_window.as_ref(), config)
57 } else {
58 None
59 };
60
61 let cost_display = if config.sections.cost.enabled {
62 format_cost_display(input.cost.as_ref(), config)
63 } else {
64 None
65 };
66
67 let mut sections = Vec::new();
68 let mut priority = 0u16;
69
70 if config.sections.cwd.enabled {
71 sections.push(Section::new(cwd_display, priority, config.theme.cwd));
72 priority += 1;
73 }
74
75 if config.sections.git.enabled {
76 if let Some(display) = git_display {
77 sections.push(Section::new(display, priority, config.theme.git));
78 }
79 priority += 1;
80 }
81
82 if config.sections.model.enabled {
83 sections.push(Section::new(model_display, priority, config.theme.model));
84 priority += 1;
85 }
86
87 if config.sections.context.enabled {
88 match ctx {
89 Some(info) => {
90 sections.push(Section::new_context_compact(
91 format_ctx_short(&info, config),
92 priority,
93 config.theme.context,
94 ));
95 sections.push(Section::new_context_detailed(
96 format_ctx_full(&info, &config.theme.context, config),
97 priority + 10,
98 config.theme.context,
99 ));
100 priority += 20;
101 }
102 None => {
103 sections.push(Section::new(
104 "ctx: -".to_string(),
105 priority,
106 config.theme.context,
107 ));
108 priority += 1;
109 }
110 }
111 }
112
113 if config.sections.quota.enabled {
114 let (quota_sections, next_priority) =
115 build_quota_sections(quota.as_ref(), config, priority, Utc::now());
116 sections.extend(quota_sections);
117 priority = next_priority;
118 }
119
120 if let Some(cost) = cost_display {
121 sections.push(Section::new(cost, priority, config.theme.cost));
122 }
123
124 build_statusline(sections, term_width, config)
125}
126
127fn resolve_cwd(input: &ClaudeInput) -> String {
128 input
129 .workspace
130 .as_ref()
131 .and_then(|w| w.current_dir.clone())
132 .or_else(|| input.cwd.clone())
133 .unwrap_or_else(|| ".".to_string())
134}
135
136fn format_cwd_display(
137 input: &ClaudeInput,
138 cwd: &str,
139 config: &Config,
140 include_worktree_detail: bool,
141) -> String {
142 let cwd_path = if config.sections.cwd.full_path {
143 if let Some(home) = std::env::var_os("HOME") {
144 let home_str = home.to_string_lossy();
145 if cwd.starts_with(home_str.as_ref()) {
146 cwd.replacen(home_str.as_ref(), "~", 1)
147 } else {
148 cwd.to_string()
149 }
150 } else {
151 cwd.to_string()
152 }
153 } else {
154 std::path::Path::new(cwd)
155 .file_name()
156 .map(|n| n.to_string_lossy().into_owned())
157 .unwrap_or_else(|| cwd.to_string())
158 };
159
160 let base = if config.sections.cwd.show_username {
161 if let Some(username) = get_username() {
162 let (details, fg) = get_details_and_fg_codes(&config.theme.cwd);
163 format!("{}{}@{}{}", details, username, fg, cwd_path)
164 } else {
165 cwd_path
166 }
167 } else {
168 cwd_path
169 };
170
171 let mut detail_parts = workspace_scope_details(input);
172 if include_worktree_detail {
173 if let Some(worktree_detail) = worktree_detail(input, None) {
174 detail_parts.push(worktree_detail);
175 }
176 }
177
178 append_details(base, detail_parts, &config.theme.cwd, config)
179}
180
181fn format_git_display(input: &ClaudeInput, info: &GitInfo, config: &Config) -> String {
182 let mut parts = Vec::new();
183
184 if config.sections.git.show_repo_name {
185 if let Some(ref repo_name) = info.repo_name {
186 parts.push(repo_name.clone());
187 }
188 }
189
190 let branch_icon = if config.display.use_powerline {
191 " \u{E0A0}"
192 } else {
193 ""
194 };
195 parts.push(format!("[{}]{}", info.branch, branch_icon));
196
197 let mut result = parts.join(" ");
198 if info.is_dirty {
199 result.push_str(" *");
200 }
201
202 let mut detail_parts = Vec::new();
203 if let Some(worktree_detail) = worktree_detail(input, Some(info)) {
204 detail_parts.push(worktree_detail);
205 }
206 if config.sections.git.show_diff_stats && (info.lines_added > 0 || info.lines_removed > 0) {
207 detail_parts.push(format!("+{}", info.lines_added));
208 detail_parts.push(format!("-{}", info.lines_removed));
209 }
210
211 append_details(result, detail_parts, &config.theme.git, config)
212}
213
214fn format_model_display(input: &ClaudeInput, config: &Config) -> String {
215 let model_name = input
216 .model
217 .as_ref()
218 .and_then(|m| m.display_name.clone().or_else(|| m.id.clone()))
219 .unwrap_or_else(|| "Claude".to_string());
220
221 let mut detail_parts = Vec::new();
222 if config.sections.model.show_output_style {
223 if let Some(style_name) = input
224 .output_style
225 .as_ref()
226 .and_then(|style| style.name.as_ref())
227 .map(|name| name.trim())
228 .filter(|name| !name.is_empty())
229 {
230 detail_parts.push(style_name.to_string());
231 }
232 }
233
234 if config.sections.model.show_thinking_mode {
235 if let Some(label) = get_reasoning_mode_label() {
236 detail_parts.push(label);
237 }
238 }
239
240 append_details(model_name, detail_parts, &config.theme.model, config)
241}
242
243fn build_quota_sections(
244 quota: Option<&types::QuotaData>,
245 config: &Config,
246 start_priority: u16,
247 now: DateTime<Utc>,
248) -> (Vec<Section>, u16) {
249 let mut priority = start_priority;
250 let mut sections = Vec::new();
251
252 if let Some(q) = quota {
253 let five_hr_reset = q
254 .five_hour_resets_at
255 .as_ref()
256 .map(|r| format_time_remaining(r, now))
257 .unwrap_or_default();
258
259 let seven_day_reset = q
260 .seven_day_resets_at
261 .as_ref()
262 .map(|r| format_time_remaining(r, now))
263 .unwrap_or_default();
264
265 sections.push(Section::new_quota_compact(
266 "5h",
267 format_quota_compact("5h", q.five_hour_pct),
268 priority,
269 config.theme.quota_5h,
270 ));
271 if config.sections.quota.show_time_remaining && !five_hr_reset.is_empty() {
272 sections.push(Section::new_quota_detailed(
273 "5h",
274 format_quota_display(
275 "5h",
276 q.five_hour_pct,
277 &five_hr_reset,
278 &config.theme.quota_5h,
279 ),
280 priority + 10,
281 config.theme.quota_5h,
282 ));
283 }
284 priority += 20;
285
286 sections.push(Section::new_quota_compact(
287 "7d",
288 format_quota_compact("7d", q.seven_day_pct),
289 priority,
290 config.theme.quota_7d,
291 ));
292 if config.sections.quota.show_time_remaining && !seven_day_reset.is_empty() {
293 sections.push(Section::new_quota_detailed(
294 "7d",
295 format_quota_display(
296 "7d",
297 q.seven_day_pct,
298 &seven_day_reset,
299 &config.theme.quota_7d,
300 ),
301 priority + 10,
302 config.theme.quota_7d,
303 ));
304 }
305 priority += 20;
306 } else {
307 sections.push(Section::new(
308 "5h: -".to_string(),
309 priority,
310 config.theme.quota_5h,
311 ));
312 priority += 20;
313 sections.push(Section::new(
314 "7d: -".to_string(),
315 priority,
316 config.theme.quota_7d,
317 ));
318 priority += 20;
319 }
320
321 (sections, priority)
322}
323
324fn format_cost_display(cost: Option<&types::Cost>, config: &Config) -> Option<String> {
325 cost.and_then(|c| c.total_cost_usd)
326 .filter(|&usd| usd > 0.0)
327 .map(|usd| {
328 let mut result = format!("${:.2}", usd);
329
330 if config.sections.cost.show_durations {
331 let mut details_parts = Vec::new();
332
333 if let Some(total_ms) = cost.and_then(|c| c.total_duration_ms) {
334 details_parts.push(format_duration_ms(total_ms));
335 }
336
337 if let Some(api_ms) = cost.and_then(|c| c.total_api_duration_ms) {
338 details_parts.push(format!("api: {}", format_duration_ms(api_ms)));
339 }
340
341 if !details_parts.is_empty() {
342 let (details, fg) = get_details_and_fg_codes(&config.theme.cost);
343 result.push_str(&format!(
344 " {}({}){}",
345 details,
346 details_parts.join(&config.display.details_separator),
347 fg
348 ));
349 }
350 }
351
352 result
353 })
354}
355
356fn append_details(
357 base: String,
358 details_parts: Vec<String>,
359 colors: &crate::colors::SectionColors,
360 config: &Config,
361) -> String {
362 if details_parts.is_empty() {
363 return base;
364 }
365
366 let (details, fg) = get_details_and_fg_codes(colors);
367 format!(
368 "{} {}({}){}",
369 base,
370 details,
371 details_parts.join(&config.display.details_separator),
372 fg
373 )
374}
375
376fn workspace_scope_details(input: &ClaudeInput) -> Vec<String> {
377 let workspace = match input.workspace.as_ref() {
378 Some(workspace) => workspace,
379 None => return Vec::new(),
380 };
381
382 let current_dir = workspace.current_dir.as_deref();
383 let project_dir = workspace.project_dir.as_deref();
384 let mut unique_dirs = HashSet::new();
385
386 for dir in workspace
387 .added_dirs
388 .as_ref()
389 .into_iter()
390 .flatten()
391 .map(|dir| dir.trim())
392 .filter(|dir| !dir.is_empty())
393 {
394 if Some(dir) == current_dir || Some(dir) == project_dir {
395 continue;
396 }
397 unique_dirs.insert(dir.to_string());
398 }
399
400 if unique_dirs.is_empty() {
401 Vec::new()
402 } else {
403 vec![format!(
404 "+{} dir{}",
405 unique_dirs.len(),
406 if unique_dirs.len() == 1 { "" } else { "s" }
407 )]
408 }
409}
410
411fn worktree_detail(input: &ClaudeInput, git_info: Option<&GitInfo>) -> Option<String> {
412 let worktree = input.worktree.as_ref()?;
413
414 if let Some(name) = worktree
415 .name
416 .as_deref()
417 .map(str::trim)
418 .filter(|name| !name.is_empty())
419 {
420 if git_info.map(|git| git.branch != name).unwrap_or(true) {
421 return Some(format!("wt: {}", name));
422 }
423 }
424
425 if let Some(branch) = worktree
426 .branch
427 .as_deref()
428 .map(str::trim)
429 .filter(|branch| !branch.is_empty())
430 {
431 if git_info.map(|git| git.branch != branch).unwrap_or(true) {
432 return Some(format!("wt: {}", branch));
433 }
434 }
435
436 None
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use crate::types::{
443 ContextWindow, Cost, Model, OutputStyle, QuotaData, SectionKind, Workspace, Worktree,
444 };
445 use chrono::Duration;
446
447 fn base_input() -> ClaudeInput {
448 ClaudeInput {
449 cwd: None,
450 workspace: Some(Workspace {
451 current_dir: Some("/tmp/project".to_string()),
452 project_dir: Some("/tmp/project".to_string()),
453 added_dirs: None,
454 }),
455 model: Some(Model {
456 id: Some("claude-sonnet-4-6".to_string()),
457 display_name: Some("Sonnet 4.6".to_string()),
458 }),
459 context_window: Some(ContextWindow {
460 context_window_size: Some(200_000),
461 total_input_tokens: None,
462 total_output_tokens: None,
463 current_usage: None,
464 used_percentage: Some(12.0),
465 remaining_percentage: Some(88.0),
466 }),
467 cost: Some(Cost {
468 total_cost_usd: Some(0.42),
469 total_duration_ms: Some(1234),
470 total_api_duration_ms: Some(456),
471 total_lines_added: None,
472 total_lines_removed: None,
473 }),
474 output_style: Some(OutputStyle {
475 name: Some("Learning".to_string()),
476 }),
477 worktree: None,
478 version: Some("2.1.76".to_string()),
479 }
480 }
481
482 #[test]
483 fn test_workspace_scope_details_counts_unique_dirs() {
484 let mut input = base_input();
485 input.workspace = Some(Workspace {
486 current_dir: Some("/tmp/project".to_string()),
487 project_dir: Some("/tmp/project".to_string()),
488 added_dirs: Some(vec![
489 "/tmp/project/docs".to_string(),
490 "/tmp/project/scripts".to_string(),
491 "/tmp/project/docs".to_string(),
492 "/tmp/project".to_string(),
493 ]),
494 });
495
496 assert_eq!(workspace_scope_details(&input), vec!["+2 dirs".to_string()]);
497 }
498
499 #[test]
500 fn test_worktree_detail_prefers_name() {
501 let mut input = base_input();
502 input.worktree = Some(Worktree {
503 name: Some("feature-a".to_string()),
504 path: None,
505 branch: Some("worktree-feature-a".to_string()),
506 original_cwd: None,
507 original_branch: None,
508 });
509
510 assert_eq!(
511 worktree_detail(&input, None),
512 Some("wt: feature-a".to_string())
513 );
514 }
515
516 #[test]
517 fn test_build_quota_sections_respects_timer_setting() {
518 let mut config = Config::default();
519 config.sections.quota.show_time_remaining = false;
520 let now = Utc::now();
521 let quota = QuotaData {
522 five_hour_pct: Some(45.0),
523 five_hour_resets_at: Some((now + Duration::hours(2)).to_rfc3339()),
524 seven_day_pct: Some(12.0),
525 seven_day_resets_at: Some((now + Duration::days(3)).to_rfc3339()),
526 };
527
528 let (sections, _) = build_quota_sections(Some("a), &config, 0, now);
529 assert_eq!(sections.len(), 2);
530 assert!(sections
531 .iter()
532 .all(|section| { matches!(section.kind, SectionKind::QuotaCompact(_)) }));
533 }
534
535 #[test]
536 fn test_render_statusline_with_width_uses_output_style_label() {
537 let mut config = Config::default();
538 config.sections.git.enabled = false;
539 config.sections.quota.enabled = false;
540 config.sections.model.show_thinking_mode = false;
541 config.sections.model.show_output_style = true;
542 config.display.show_background = false;
543
544 let lines = render_statusline_with_width(&base_input(), &config, 120);
545 let joined = lines.join(" ");
546 assert!(joined.contains("Learning"));
547 }
548}