claude_code_status_line/
utils.rs

1use crate::config::Config;
2use unicode_width::UnicodeWidthChar;
3
4/// ANSI reset code
5pub const RESET: &str = "\x1b[0m";
6
7/// Calculate visible width in terminal columns (strips ANSI and measures Unicode width)
8pub fn visible_len(s: &str) -> usize {
9    let mut width = 0;
10    let mut chars = s.chars().peekable();
11    while let Some(c) = chars.next() {
12        if c == '\x1b' {
13            if let Some(&next) = chars.peek() {
14                if next == '[' {
15                    // CSI: ESC [ ... <final byte 0x40–0x7E>
16                    chars.next(); // consume '['
17                    for c2 in chars.by_ref() {
18                        if ('@'..='~').contains(&c2) {
19                            break;
20                        }
21                    }
22                }
23            }
24        } else {
25            width += c.width().unwrap_or(0);
26        }
27    }
28    width
29}
30
31/// Truncate string with ellipsis while preserving ANSI codes
32pub fn truncate_with_ellipsis(s: &str, max_width: usize) -> String {
33    if visible_len(s) <= max_width {
34        return s.to_string();
35    }
36
37    // Use "…" (UTF-8) as ellipsis. Visual width is typically 1
38    const ELLIPSIS: &str = "…";
39    const ELLIPSIS_WIDTH: usize = 1;
40
41    if max_width <= ELLIPSIS_WIDTH {
42        return ELLIPSIS.chars().take(max_width).collect();
43    }
44
45    let target_width = max_width - ELLIPSIS_WIDTH;
46    let mut current_width = 0;
47    let mut result = String::with_capacity(s.len());
48    let mut chars = s.chars().peekable();
49
50    while let Some(c) = chars.next() {
51        if c == '\x1b' {
52            result.push(c);
53            if let Some(&next) = chars.peek() {
54                if next == '[' {
55                    // CSI sequence - preserve but don't count toward width
56                    result.push(chars.next().unwrap()); // '['
57                    for c2 in chars.by_ref() {
58                        result.push(c2);
59                        if ('@'..='~').contains(&c2) {
60                            break;
61                        }
62                    }
63                }
64            }
65        } else {
66            // Calculate width of this character (no allocation)
67            let char_width = c.width().unwrap_or(0);
68            if current_width + char_width > target_width {
69                break;
70            }
71            result.push(c);
72            current_width += char_width;
73        }
74    }
75
76    result.push_str(ELLIPSIS);
77    result.push_str(RESET);
78    result
79}
80
81/// Get terminal width, returns default from config as fallback
82pub fn get_terminal_width(config: &Config) -> usize {
83    if let Ok(w_str) = std::env::var("CLAUDE_TERMINAL_WIDTH") {
84        if let Ok(w) = w_str.parse::<usize>() {
85            return w;
86        }
87    }
88
89    if let Ok(w_str) = std::env::var("COLUMNS") {
90        if let Ok(w) = w_str.parse::<usize>() {
91            return w;
92        }
93    }
94
95    if let Some((w, _)) = terminal_size::terminal_size() {
96        return w.0 as usize;
97    }
98
99    #[cfg(unix)]
100    {
101        if let Ok(f) = std::fs::File::open("/dev/tty") {
102            if let Some((w, _)) = terminal_size::terminal_size_of(&f) {
103                return w.0 as usize;
104            }
105        }
106    }
107
108    config.display.default_terminal_width
109}
110
111/// Get current username from environment
112pub fn get_username() -> Option<String> {
113    std::env::var("USER")
114        .or_else(|_| std::env::var("USERNAME"))
115        .ok()
116}
117
118/// Read thinking mode setting from ~/.claude/settings.json
119pub fn get_thinking_mode_enabled() -> bool {
120    use serde::Deserialize;
121
122    #[derive(Deserialize)]
123    struct ClaudeSettings {
124        #[serde(rename = "alwaysThinkingEnabled")]
125        always_thinking_enabled: Option<bool>,
126    }
127
128    #[cfg(unix)]
129    let home = std::env::var_os("HOME");
130    #[cfg(windows)]
131    let home = std::env::var_os("USERPROFILE");
132
133    let home = match home {
134        Some(h) => h,
135        None => return false,
136    };
137
138    let settings_path = std::path::Path::new(&home)
139        .join(".claude")
140        .join("settings.json");
141
142    let content = match std::fs::read_to_string(&settings_path) {
143        Ok(c) => c,
144        Err(_) => return false,
145    };
146
147    let settings: ClaudeSettings = match serde_json::from_str(&content) {
148        Ok(s) => s,
149        Err(_) => return false,
150    };
151
152    settings.always_thinking_enabled.unwrap_or(true)
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_visible_len() {
161        assert_eq!(visible_len("Hello"), 5);
162        assert_eq!(visible_len("\x1b[31mHello\x1b[0m"), 5);
163        assert_eq!(visible_len("\x1b[38;2;10;20;30mHello"), 5);
164        assert_eq!(visible_len(""), 0);
165        assert_eq!(visible_len("foo bar"), 7);
166        assert_eq!(visible_len("\x1b[1;34mBoldBlue"), 8);
167    }
168
169    #[test]
170    fn test_truncate_with_ellipsis() {
171        assert_eq!(truncate_with_ellipsis("Hello World", 11), "Hello World");
172        assert_eq!(truncate_with_ellipsis("Hello World", 5), "Hell…\x1b[0m");
173        let s = "\x1b[31mHello World\x1b[0m";
174        assert_eq!(truncate_with_ellipsis(s, 5), "\x1b[31mHell…\x1b[0m");
175    }
176
177    #[test]
178    fn test_visible_len_wide_glyphs() {
179        assert_eq!(visible_len("🧪"), 2);
180        assert_eq!(visible_len("Test🧪"), 6);
181        assert_eq!(visible_len("🧪🔬"), 4);
182        assert_eq!(visible_len("日本語"), 6);
183        assert_eq!(visible_len("Hello日本"), 9);
184        assert_eq!(visible_len("Test🧪日本"), 10);
185        assert_eq!(visible_len("\x1b[31m日本語\x1b[0m"), 6);
186        assert_eq!(visible_len("\x1b[31m🧪Test\x1b[0m"), 6);
187    }
188
189    #[test]
190    fn test_truncate_wide_glyphs() {
191        let emoji_str = "Test🧪Data";
192        assert_eq!(truncate_with_ellipsis(emoji_str, 7), "Test🧪…\x1b[0m");
193        assert_eq!(truncate_with_ellipsis(emoji_str, 6), "Test…\x1b[0m");
194
195        let cjk_str = "日本語";
196        assert_eq!(truncate_with_ellipsis(cjk_str, 6), "日本語");
197        assert_eq!(truncate_with_ellipsis(cjk_str, 5), "日本…\x1b[0m");
198        assert_eq!(truncate_with_ellipsis(cjk_str, 3), "日…\x1b[0m");
199
200        let mixed = "Test日本語";
201        assert_eq!(truncate_with_ellipsis(mixed, 7), "Test日…\x1b[0m");
202        assert_eq!(truncate_with_ellipsis(mixed, 10), "Test日本語");
203
204        let path = "/home/user/日本語/test";
205        let truncated = truncate_with_ellipsis(path, 15);
206        assert!(visible_len(&truncated) <= 15);
207    }
208
209    #[test]
210    fn test_truncate_wide_boundary() {
211        let s = "abc日";
212        assert_eq!(truncate_with_ellipsis(s, 5), "abc日");
213        assert_eq!(truncate_with_ellipsis(s, 4), "abc…\x1b[0m");
214    }
215}