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/// Calculate visible width in terminal columns (strips ANSI and measures Unicode width)
46pub fn visible_len(s: &str) -> usize {
47    let mut width = 0;
48    let mut chars = s.chars().peekable();
49    while let Some(c) = chars.next() {
50        if c == '\x1b' {
51            if let Some(&next) = chars.peek() {
52                if next == '[' {
53                    // CSI: ESC [ ... <final byte 0x40–0x7E>
54                    chars.next(); // consume '['
55                    for c2 in chars.by_ref() {
56                        if ('@'..='~').contains(&c2) {
57                            break;
58                        }
59                    }
60                }
61            }
62        } else {
63            width += c.width().unwrap_or(0);
64        }
65    }
66    width
67}
68
69/// Truncate string with ellipsis while preserving ANSI codes
70pub fn truncate_with_ellipsis(s: &str, max_width: usize) -> String {
71    if visible_len(s) <= max_width {
72        return s.to_string();
73    }
74
75    // Use "…" (UTF-8) as ellipsis. Visual width is typically 1
76    const ELLIPSIS: &str = "…";
77    const ELLIPSIS_WIDTH: usize = 1;
78
79    if max_width <= ELLIPSIS_WIDTH {
80        return ELLIPSIS.chars().take(max_width).collect();
81    }
82
83    let target_width = max_width - ELLIPSIS_WIDTH;
84    let mut current_width = 0;
85    let mut result = String::with_capacity(s.len());
86    let mut chars = s.chars().peekable();
87
88    while let Some(c) = chars.next() {
89        if c == '\x1b' {
90            result.push(c);
91            if let Some(&next) = chars.peek() {
92                if next == '[' {
93                    // CSI sequence - preserve but don't count toward width
94                    result.push(chars.next().unwrap()); // '['
95                    for c2 in chars.by_ref() {
96                        result.push(c2);
97                        if ('@'..='~').contains(&c2) {
98                            break;
99                        }
100                    }
101                }
102            }
103        } else {
104            // Calculate width of this character (no allocation)
105            let char_width = c.width().unwrap_or(0);
106            if current_width + char_width > target_width {
107                break;
108            }
109            result.push(c);
110            current_width += char_width;
111        }
112    }
113
114    result.push_str(ELLIPSIS);
115    result.push_str(RESET);
116    result
117}
118
119/// Get terminal width, returns default from config as fallback
120pub fn get_terminal_width(config: &Config) -> usize {
121    if let Ok(w_str) = std::env::var("CLAUDE_TERMINAL_WIDTH") {
122        if let Ok(w) = w_str.parse::<usize>() {
123            return w;
124        }
125    }
126
127    if let Ok(w_str) = std::env::var("COLUMNS") {
128        if let Ok(w) = w_str.parse::<usize>() {
129            return w;
130        }
131    }
132
133    if let Some((w, _)) = terminal_size::terminal_size() {
134        return w.0 as usize;
135    }
136
137    #[cfg(unix)]
138    {
139        if let Ok(f) = std::fs::File::open("/dev/tty") {
140            if let Some((w, _)) = terminal_size::terminal_size_of(&f) {
141                return w.0 as usize;
142            }
143        }
144    }
145
146    config.display.default_terminal_width
147}
148
149/// Get current username from environment
150pub fn get_username() -> Option<String> {
151    std::env::var("USER")
152        .or_else(|_| std::env::var("USERNAME"))
153        .ok()
154}
155
156#[derive(serde::Deserialize)]
157struct ClaudeSettings {
158    #[serde(rename = "alwaysThinkingEnabled")]
159    always_thinking_enabled: Option<bool>,
160    #[serde(rename = "effortLevel")]
161    effort_level: Option<String>,
162}
163
164fn parse_claude_settings(content: &str) -> Option<ClaudeSettings> {
165    serde_json::from_str(content).ok()
166}
167
168fn normalize_effort_level(level: &str) -> Option<String> {
169    let normalized = level.trim().to_ascii_lowercase();
170    match normalized.as_str() {
171        "low" | "medium" | "high" => Some(normalized),
172        "max" => Some("high".to_string()),
173        _ => None,
174    }
175}
176
177fn reasoning_mode_label_from_settings(settings: &ClaudeSettings) -> Option<String> {
178    if let Some(level) = settings
179        .effort_level
180        .as_deref()
181        .and_then(normalize_effort_level)
182    {
183        return Some(format!("effort: {}", level));
184    }
185
186    if settings.always_thinking_enabled == Some(true) {
187        return Some("thinking".to_string());
188    }
189
190    None
191}
192
193/// Read reasoning mode details from ~/.claude/settings.json
194pub fn get_reasoning_mode_label() -> Option<String> {
195    let home = get_home_dir()?;
196
197    let settings_path = home.join(".claude").join("settings.json");
198
199    let content = std::fs::read_to_string(&settings_path).ok()?;
200
201    let settings = parse_claude_settings(&content)?;
202    reasoning_mode_label_from_settings(&settings)
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_visible_len() {
211        assert_eq!(visible_len("Hello"), 5);
212        assert_eq!(visible_len("\x1b[31mHello\x1b[0m"), 5);
213        assert_eq!(visible_len("\x1b[38;2;10;20;30mHello"), 5);
214        assert_eq!(visible_len(""), 0);
215        assert_eq!(visible_len("foo bar"), 7);
216        assert_eq!(visible_len("\x1b[1;34mBoldBlue"), 8);
217    }
218
219    #[test]
220    fn test_truncate_with_ellipsis() {
221        assert_eq!(truncate_with_ellipsis("Hello World", 11), "Hello World");
222        assert_eq!(truncate_with_ellipsis("Hello World", 5), "Hell…\x1b[0m");
223        let s = "\x1b[31mHello World\x1b[0m";
224        assert_eq!(truncate_with_ellipsis(s, 5), "\x1b[31mHell…\x1b[0m");
225    }
226
227    #[test]
228    fn test_visible_len_wide_glyphs() {
229        assert_eq!(visible_len("🧪"), 2);
230        assert_eq!(visible_len("Test🧪"), 6);
231        assert_eq!(visible_len("🧪🔬"), 4);
232        assert_eq!(visible_len("日本語"), 6);
233        assert_eq!(visible_len("Hello日本"), 9);
234        assert_eq!(visible_len("Test🧪日本"), 10);
235        assert_eq!(visible_len("\x1b[31m日本語\x1b[0m"), 6);
236        assert_eq!(visible_len("\x1b[31m🧪Test\x1b[0m"), 6);
237    }
238
239    #[test]
240    fn test_truncate_wide_glyphs() {
241        let emoji_str = "Test🧪Data";
242        assert_eq!(truncate_with_ellipsis(emoji_str, 7), "Test🧪…\x1b[0m");
243        assert_eq!(truncate_with_ellipsis(emoji_str, 6), "Test…\x1b[0m");
244
245        let cjk_str = "日本語";
246        assert_eq!(truncate_with_ellipsis(cjk_str, 6), "日本語");
247        assert_eq!(truncate_with_ellipsis(cjk_str, 5), "日本…\x1b[0m");
248        assert_eq!(truncate_with_ellipsis(cjk_str, 3), "日…\x1b[0m");
249
250        let mixed = "Test日本語";
251        assert_eq!(truncate_with_ellipsis(mixed, 7), "Test日…\x1b[0m");
252        assert_eq!(truncate_with_ellipsis(mixed, 10), "Test日本語");
253
254        let path = "/home/user/日本語/test";
255        let truncated = truncate_with_ellipsis(path, 15);
256        assert!(visible_len(&truncated) <= 15);
257    }
258
259    #[test]
260    fn test_truncate_wide_boundary() {
261        let s = "abc日";
262        assert_eq!(truncate_with_ellipsis(s, 5), "abc日");
263        assert_eq!(truncate_with_ellipsis(s, 4), "abc…\x1b[0m");
264    }
265
266    #[test]
267    fn test_normalize_effort_level() {
268        assert_eq!(normalize_effort_level("low"), Some("low".to_string()));
269        assert_eq!(
270            normalize_effort_level(" Medium "),
271            Some("medium".to_string())
272        );
273        assert_eq!(normalize_effort_level("max"), Some("high".to_string()));
274        assert_eq!(normalize_effort_level("AUTO"), None);
275    }
276
277    #[test]
278    fn test_reasoning_mode_label_prefers_effort() {
279        let settings =
280            parse_claude_settings(r#"{"alwaysThinkingEnabled":true,"effortLevel":"high"}"#)
281                .unwrap();
282
283        assert_eq!(
284            reasoning_mode_label_from_settings(&settings),
285            Some("effort: high".to_string())
286        );
287    }
288
289    #[test]
290    fn test_reasoning_mode_label_uses_thinking_flag() {
291        let settings = parse_claude_settings(r#"{"alwaysThinkingEnabled":true}"#).unwrap();
292
293        assert_eq!(
294            reasoning_mode_label_from_settings(&settings),
295            Some("thinking".to_string())
296        );
297    }
298
299    #[test]
300    fn test_reasoning_mode_label_returns_none_when_disabled() {
301        let settings = parse_claude_settings(r#"{"alwaysThinkingEnabled":false}"#).unwrap();
302
303        assert_eq!(reasoning_mode_label_from_settings(&settings), None);
304    }
305}