Skip to main content

claude_code_status_line/
app.rs

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(&quota), &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}