claude_code_status_line/
utils.rs1use std::path::PathBuf;
2
3use crate::config::Config;
4use unicode_width::UnicodeWidthChar;
5
6pub const RESET: &str = "\x1b[0m";
8
9pub 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
30pub fn format_duration_ms(ms: u64) -> String {
32 format_duration_secs(ms / 1000, true)
33}
34
35pub 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
45pub 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 chars.next(); 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
69pub 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 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 result.push(chars.next().unwrap()); for c2 in chars.by_ref() {
96 result.push(c2);
97 if ('@'..='~').contains(&c2) {
98 break;
99 }
100 }
101 }
102 }
103 } else {
104 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
119pub 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
149pub 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
193pub 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}