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
156pub fn get_thinking_mode_enabled() -> bool {
158 use serde::Deserialize;
159
160 #[derive(Deserialize)]
161 struct ClaudeSettings {
162 #[serde(rename = "alwaysThinkingEnabled")]
163 always_thinking_enabled: Option<bool>,
164 }
165
166 let home = match get_home_dir() {
167 Some(h) => h,
168 None => return false,
169 };
170
171 let settings_path = home.join(".claude").join("settings.json");
172
173 let content = match std::fs::read_to_string(&settings_path) {
174 Ok(c) => c,
175 Err(_) => return false,
176 };
177
178 let settings: ClaudeSettings = match serde_json::from_str(&content) {
179 Ok(s) => s,
180 Err(_) => return false,
181 };
182
183 settings.always_thinking_enabled.unwrap_or(true)
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 #[test]
191 fn test_visible_len() {
192 assert_eq!(visible_len("Hello"), 5);
193 assert_eq!(visible_len("\x1b[31mHello\x1b[0m"), 5);
194 assert_eq!(visible_len("\x1b[38;2;10;20;30mHello"), 5);
195 assert_eq!(visible_len(""), 0);
196 assert_eq!(visible_len("foo bar"), 7);
197 assert_eq!(visible_len("\x1b[1;34mBoldBlue"), 8);
198 }
199
200 #[test]
201 fn test_truncate_with_ellipsis() {
202 assert_eq!(truncate_with_ellipsis("Hello World", 11), "Hello World");
203 assert_eq!(truncate_with_ellipsis("Hello World", 5), "Hell…\x1b[0m");
204 let s = "\x1b[31mHello World\x1b[0m";
205 assert_eq!(truncate_with_ellipsis(s, 5), "\x1b[31mHell…\x1b[0m");
206 }
207
208 #[test]
209 fn test_visible_len_wide_glyphs() {
210 assert_eq!(visible_len("🧪"), 2);
211 assert_eq!(visible_len("Test🧪"), 6);
212 assert_eq!(visible_len("🧪🔬"), 4);
213 assert_eq!(visible_len("日本語"), 6);
214 assert_eq!(visible_len("Hello日本"), 9);
215 assert_eq!(visible_len("Test🧪日本"), 10);
216 assert_eq!(visible_len("\x1b[31m日本語\x1b[0m"), 6);
217 assert_eq!(visible_len("\x1b[31m🧪Test\x1b[0m"), 6);
218 }
219
220 #[test]
221 fn test_truncate_wide_glyphs() {
222 let emoji_str = "Test🧪Data";
223 assert_eq!(truncate_with_ellipsis(emoji_str, 7), "Test🧪…\x1b[0m");
224 assert_eq!(truncate_with_ellipsis(emoji_str, 6), "Test…\x1b[0m");
225
226 let cjk_str = "日本語";
227 assert_eq!(truncate_with_ellipsis(cjk_str, 6), "日本語");
228 assert_eq!(truncate_with_ellipsis(cjk_str, 5), "日本…\x1b[0m");
229 assert_eq!(truncate_with_ellipsis(cjk_str, 3), "日…\x1b[0m");
230
231 let mixed = "Test日本語";
232 assert_eq!(truncate_with_ellipsis(mixed, 7), "Test日…\x1b[0m");
233 assert_eq!(truncate_with_ellipsis(mixed, 10), "Test日本語");
234
235 let path = "/home/user/日本語/test";
236 let truncated = truncate_with_ellipsis(path, 15);
237 assert!(visible_len(&truncated) <= 15);
238 }
239
240 #[test]
241 fn test_truncate_wide_boundary() {
242 let s = "abc日";
243 assert_eq!(truncate_with_ellipsis(s, 5), "abc日");
244 assert_eq!(truncate_with_ellipsis(s, 4), "abc…\x1b[0m");
245 }
246}