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 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
53pub 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 chars.next(); 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
77pub 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 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 result.push(chars.next().unwrap()); for c2 in chars.by_ref() {
104 result.push(c2);
105 if ('@'..='~').contains(&c2) {
106 break;
107 }
108 }
109 }
110 }
111 } else {
112 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
127pub 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
157pub fn get_username() -> Option<String> {
159 std::env::var("USER")
160 .or_else(|_| std::env::var("USERNAME"))
161 .ok()
162}
163
164pub 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}