Skip to main content

claude_code_status_line/
utils.rs

1use std::path::PathBuf;
2
3use crate::config::Config;
4use unicode_width::UnicodeWidthChar;
5
6/// ANSI reset code
7pub const RESET: &str = "\x1b[0m";
8
9/// Format duration in seconds to human-readable string (e.g., "2d 5h", "3h 15m", "45m 30s")
10pub fn format_duration_secs(total_seconds: u64, show_seconds: bool) -> String {
11    let total_minutes = total_seconds / 60;
12    let total_hours = total_minutes / 60;
13    let total_days = total_hours / 24;
14
15    if total_days >= 1 {
16        format!("{}d {}h", total_days, total_hours % 24)
17    } else if total_hours >= 1 {
18        format!("{}h {}m", total_hours, total_minutes % 60)
19    } else if show_seconds {
20        if total_minutes >= 1 {
21            format!("{}m {}s", total_minutes, total_seconds % 60)
22        } else {
23            format!("{}s", total_seconds.max(1))
24        }
25    } else {
26        format!("{}m", total_minutes.max(1))
27    }
28}
29
30/// Format duration in milliseconds to human-readable string
31pub fn format_duration_ms(ms: u64) -> String {
32    format_duration_secs(ms / 1000, true)
33}
34
35/// Get home directory path (cross-platform)
36pub fn get_home_dir() -> Option<PathBuf> {
37    #[cfg(unix)]
38    let home = std::env::var_os("HOME");
39    #[cfg(windows)]
40    let home = std::env::var_os("USERPROFILE");
41
42    home.map(PathBuf::from)
43}
44
45/// Claude Code's config directory: `CLAUDE_CONFIG_DIR` when set, else `~/.claude`
46pub fn get_claude_config_dir() -> Option<PathBuf> {
47    match std::env::var_os("CLAUDE_CONFIG_DIR").filter(|dir| !dir.is_empty()) {
48        Some(dir) => Some(PathBuf::from(dir)),
49        None => Some(get_home_dir()?.join(".claude")),
50    }
51}
52
53/// Calculate visible width in terminal columns (strips ANSI and measures Unicode width)
54pub fn visible_len(s: &str) -> usize {
55    let mut width = 0;
56    let mut chars = s.chars().peekable();
57    while let Some(c) = chars.next() {
58        if c == '\x1b' {
59            if let Some(&next) = chars.peek() {
60                if next == '[' {
61                    // CSI: ESC [ ... <final byte 0x40–0x7E>
62                    chars.next(); // consume '['
63                    for c2 in chars.by_ref() {
64                        if ('@'..='~').contains(&c2) {
65                            break;
66                        }
67                    }
68                }
69            }
70        } else {
71            width += c.width().unwrap_or(0);
72        }
73    }
74    width
75}
76
77/// Truncate string with ellipsis while preserving ANSI codes
78pub fn truncate_with_ellipsis(s: &str, max_width: usize) -> String {
79    if visible_len(s) <= max_width {
80        return s.to_string();
81    }
82
83    // Use "…" (UTF-8) as ellipsis. Visual width is typically 1
84    const ELLIPSIS: &str = "…";
85    const ELLIPSIS_WIDTH: usize = 1;
86
87    if max_width <= ELLIPSIS_WIDTH {
88        return ELLIPSIS.chars().take(max_width).collect();
89    }
90
91    let target_width = max_width - ELLIPSIS_WIDTH;
92    let mut current_width = 0;
93    let mut result = String::with_capacity(s.len());
94    let mut chars = s.chars().peekable();
95
96    while let Some(c) = chars.next() {
97        if c == '\x1b' {
98            result.push(c);
99            if let Some(&next) = chars.peek() {
100                if next == '[' {
101                    // CSI sequence - preserve but don't count toward width
102                    result.push(chars.next().unwrap()); // '['
103                    for c2 in chars.by_ref() {
104                        result.push(c2);
105                        if ('@'..='~').contains(&c2) {
106                            break;
107                        }
108                    }
109                }
110            }
111        } else {
112            // Calculate width of this character (no allocation)
113            let char_width = c.width().unwrap_or(0);
114            if current_width + char_width > target_width {
115                break;
116            }
117            result.push(c);
118            current_width += char_width;
119        }
120    }
121
122    result.push_str(ELLIPSIS);
123    result.push_str(RESET);
124    result
125}
126
127/// Get terminal width, returns default from config as fallback
128pub fn get_terminal_width(config: &Config) -> usize {
129    if let Ok(w_str) = std::env::var("CLAUDE_TERMINAL_WIDTH") {
130        if let Ok(w) = w_str.parse::<usize>() {
131            return w;
132        }
133    }
134
135    if let Ok(w_str) = std::env::var("COLUMNS") {
136        if let Ok(w) = w_str.parse::<usize>() {
137            return w;
138        }
139    }
140
141    if let Some((w, _)) = terminal_size::terminal_size() {
142        return w.0 as usize;
143    }
144
145    #[cfg(unix)]
146    {
147        if let Ok(f) = std::fs::File::open("/dev/tty") {
148            if let Some((w, _)) = terminal_size::terminal_size_of(&f) {
149                return w.0 as usize;
150            }
151        }
152    }
153
154    config.display.default_terminal_width
155}
156
157/// Get current username from environment
158pub fn get_username() -> Option<String> {
159    std::env::var("USER")
160        .or_else(|_| std::env::var("USERNAME"))
161        .ok()
162}
163
164/// Build a reasoning-mode label from the Claude Code payload (`effort.level`,
165/// `thinking.enabled`). Prefers the live `effort.level` value, falling back to
166/// the `thinking.enabled` flag.
167pub fn reasoning_label_from_payload(
168    effort: Option<&crate::types::Effort>,
169    thinking: Option<&crate::types::Thinking>,
170) -> Option<String> {
171    if let Some(level) = effort
172        .and_then(|effort| effort.level.as_deref())
173        .map(str::trim)
174        .filter(|level| !level.is_empty())
175        .map(|level| level.to_ascii_lowercase())
176    {
177        return Some(format!("effort: {}", level));
178    }
179
180    if thinking
181        .and_then(|thinking| thinking.enabled)
182        .unwrap_or(false)
183    {
184        return Some("thinking".to_string());
185    }
186
187    None
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_visible_len() {
196        assert_eq!(visible_len("Hello"), 5);
197        assert_eq!(visible_len("\x1b[31mHello\x1b[0m"), 5);
198        assert_eq!(visible_len("\x1b[38;2;10;20;30mHello"), 5);
199        assert_eq!(visible_len(""), 0);
200        assert_eq!(visible_len("foo bar"), 7);
201        assert_eq!(visible_len("\x1b[1;34mBoldBlue"), 8);
202    }
203
204    #[test]
205    fn test_truncate_with_ellipsis() {
206        assert_eq!(truncate_with_ellipsis("Hello World", 11), "Hello World");
207        assert_eq!(truncate_with_ellipsis("Hello World", 5), "Hell…\x1b[0m");
208        let s = "\x1b[31mHello World\x1b[0m";
209        assert_eq!(truncate_with_ellipsis(s, 5), "\x1b[31mHell…\x1b[0m");
210    }
211
212    #[test]
213    fn test_visible_len_wide_glyphs() {
214        assert_eq!(visible_len("🧪"), 2);
215        assert_eq!(visible_len("Test🧪"), 6);
216        assert_eq!(visible_len("🧪🔬"), 4);
217        assert_eq!(visible_len("日本語"), 6);
218        assert_eq!(visible_len("Hello日本"), 9);
219        assert_eq!(visible_len("Test🧪日本"), 10);
220        assert_eq!(visible_len("\x1b[31m日本語\x1b[0m"), 6);
221        assert_eq!(visible_len("\x1b[31m🧪Test\x1b[0m"), 6);
222    }
223
224    #[test]
225    fn test_truncate_wide_glyphs() {
226        let emoji_str = "Test🧪Data";
227        assert_eq!(truncate_with_ellipsis(emoji_str, 7), "Test🧪…\x1b[0m");
228        assert_eq!(truncate_with_ellipsis(emoji_str, 6), "Test…\x1b[0m");
229
230        let cjk_str = "日本語";
231        assert_eq!(truncate_with_ellipsis(cjk_str, 6), "日本語");
232        assert_eq!(truncate_with_ellipsis(cjk_str, 5), "日本…\x1b[0m");
233        assert_eq!(truncate_with_ellipsis(cjk_str, 3), "日…\x1b[0m");
234
235        let mixed = "Test日本語";
236        assert_eq!(truncate_with_ellipsis(mixed, 7), "Test日…\x1b[0m");
237        assert_eq!(truncate_with_ellipsis(mixed, 10), "Test日本語");
238
239        let path = "/home/user/日本語/test";
240        let truncated = truncate_with_ellipsis(path, 15);
241        assert!(visible_len(&truncated) <= 15);
242    }
243
244    #[test]
245    fn test_truncate_wide_boundary() {
246        let s = "abc日";
247        assert_eq!(truncate_with_ellipsis(s, 5), "abc日");
248        assert_eq!(truncate_with_ellipsis(s, 4), "abc…\x1b[0m");
249    }
250
251    #[test]
252    fn test_reasoning_label_from_payload_prefers_effort() {
253        let effort = crate::types::Effort {
254            level: Some("xhigh".to_string()),
255        };
256        let thinking = crate::types::Thinking {
257            enabled: Some(true),
258        };
259
260        assert_eq!(
261            reasoning_label_from_payload(Some(&effort), Some(&thinking)),
262            Some("effort: xhigh".to_string())
263        );
264    }
265
266    #[test]
267    fn test_reasoning_label_from_payload_thinking_fallback() {
268        let thinking = crate::types::Thinking {
269            enabled: Some(true),
270        };
271
272        assert_eq!(
273            reasoning_label_from_payload(None, Some(&thinking)),
274            Some("thinking".to_string())
275        );
276    }
277
278    #[test]
279    fn test_reasoning_label_from_payload_none() {
280        let thinking = crate::types::Thinking {
281            enabled: Some(false),
282        };
283
284        assert_eq!(reasoning_label_from_payload(None, Some(&thinking)), None);
285        assert_eq!(reasoning_label_from_payload(None, None), None);
286    }
287}