claude-code-status-line 1.2.9

A configurable status line for Claude Code with powerline arrows, context tracking, and quota monitoring
Documentation
use std::path::PathBuf;

use crate::config::Config;
use unicode_width::UnicodeWidthChar;

/// ANSI reset code
pub const RESET: &str = "\x1b[0m";

/// Format duration in seconds to human-readable string (e.g., "2d 5h", "3h 15m", "45m 30s")
pub fn format_duration_secs(total_seconds: u64, show_seconds: bool) -> String {
    let total_minutes = total_seconds / 60;
    let total_hours = total_minutes / 60;
    let total_days = total_hours / 24;

    if total_days >= 1 {
        format!("{}d {}h", total_days, total_hours % 24)
    } else if total_hours >= 1 {
        format!("{}h {}m", total_hours, total_minutes % 60)
    } else if show_seconds {
        if total_minutes >= 1 {
            format!("{}m {}s", total_minutes, total_seconds % 60)
        } else {
            format!("{}s", total_seconds.max(1))
        }
    } else {
        format!("{}m", total_minutes.max(1))
    }
}

/// Format duration in milliseconds to human-readable string
pub fn format_duration_ms(ms: u64) -> String {
    format_duration_secs(ms / 1000, true)
}

/// Get home directory path (cross-platform)
pub fn get_home_dir() -> Option<PathBuf> {
    #[cfg(unix)]
    let home = std::env::var_os("HOME");
    #[cfg(windows)]
    let home = std::env::var_os("USERPROFILE");

    home.map(PathBuf::from)
}

/// Calculate visible width in terminal columns (strips ANSI and measures Unicode width)
pub fn visible_len(s: &str) -> usize {
    let mut width = 0;
    let mut chars = s.chars().peekable();
    while let Some(c) = chars.next() {
        if c == '\x1b' {
            if let Some(&next) = chars.peek() {
                if next == '[' {
                    // CSI: ESC [ ... <final byte 0x40–0x7E>
                    chars.next(); // consume '['
                    for c2 in chars.by_ref() {
                        if ('@'..='~').contains(&c2) {
                            break;
                        }
                    }
                }
            }
        } else {
            width += c.width().unwrap_or(0);
        }
    }
    width
}

/// Truncate string with ellipsis while preserving ANSI codes
pub fn truncate_with_ellipsis(s: &str, max_width: usize) -> String {
    if visible_len(s) <= max_width {
        return s.to_string();
    }

    // Use "…" (UTF-8) as ellipsis. Visual width is typically 1
    const ELLIPSIS: &str = "";
    const ELLIPSIS_WIDTH: usize = 1;

    if max_width <= ELLIPSIS_WIDTH {
        return ELLIPSIS.chars().take(max_width).collect();
    }

    let target_width = max_width - ELLIPSIS_WIDTH;
    let mut current_width = 0;
    let mut result = String::with_capacity(s.len());
    let mut chars = s.chars().peekable();

    while let Some(c) = chars.next() {
        if c == '\x1b' {
            result.push(c);
            if let Some(&next) = chars.peek() {
                if next == '[' {
                    // CSI sequence - preserve but don't count toward width
                    result.push(chars.next().unwrap()); // '['
                    for c2 in chars.by_ref() {
                        result.push(c2);
                        if ('@'..='~').contains(&c2) {
                            break;
                        }
                    }
                }
            }
        } else {
            // Calculate width of this character (no allocation)
            let char_width = c.width().unwrap_or(0);
            if current_width + char_width > target_width {
                break;
            }
            result.push(c);
            current_width += char_width;
        }
    }

    result.push_str(ELLIPSIS);
    result.push_str(RESET);
    result
}

/// Get terminal width, returns default from config as fallback
pub fn get_terminal_width(config: &Config) -> usize {
    if let Ok(w_str) = std::env::var("CLAUDE_TERMINAL_WIDTH") {
        if let Ok(w) = w_str.parse::<usize>() {
            return w;
        }
    }

    if let Ok(w_str) = std::env::var("COLUMNS") {
        if let Ok(w) = w_str.parse::<usize>() {
            return w;
        }
    }

    if let Some((w, _)) = terminal_size::terminal_size() {
        return w.0 as usize;
    }

    #[cfg(unix)]
    {
        if let Ok(f) = std::fs::File::open("/dev/tty") {
            if let Some((w, _)) = terminal_size::terminal_size_of(&f) {
                return w.0 as usize;
            }
        }
    }

    config.display.default_terminal_width
}

/// Get current username from environment
pub fn get_username() -> Option<String> {
    std::env::var("USER")
        .or_else(|_| std::env::var("USERNAME"))
        .ok()
}

#[derive(serde::Deserialize)]
struct ClaudeSettings {
    #[serde(rename = "alwaysThinkingEnabled")]
    always_thinking_enabled: Option<bool>,
    #[serde(rename = "effortLevel")]
    effort_level: Option<String>,
}

fn parse_claude_settings(content: &str) -> Option<ClaudeSettings> {
    serde_json::from_str(content).ok()
}

fn normalize_effort_level(level: &str) -> Option<String> {
    let normalized = level.trim().to_ascii_lowercase();
    match normalized.as_str() {
        "low" | "medium" | "high" => Some(normalized),
        "max" => Some("high".to_string()),
        _ => None,
    }
}

fn reasoning_mode_label_from_settings(settings: &ClaudeSettings) -> Option<String> {
    if let Some(level) = settings
        .effort_level
        .as_deref()
        .and_then(normalize_effort_level)
    {
        return Some(format!("effort: {}", level));
    }

    if settings.always_thinking_enabled == Some(true) {
        return Some("thinking".to_string());
    }

    None
}

/// Read reasoning mode details from ~/.claude/settings.json
pub fn get_reasoning_mode_label() -> Option<String> {
    let home = get_home_dir()?;

    let settings_path = home.join(".claude").join("settings.json");

    let content = std::fs::read_to_string(&settings_path).ok()?;

    let settings = parse_claude_settings(&content)?;
    reasoning_mode_label_from_settings(&settings)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_visible_len() {
        assert_eq!(visible_len("Hello"), 5);
        assert_eq!(visible_len("\x1b[31mHello\x1b[0m"), 5);
        assert_eq!(visible_len("\x1b[38;2;10;20;30mHello"), 5);
        assert_eq!(visible_len(""), 0);
        assert_eq!(visible_len("foo bar"), 7);
        assert_eq!(visible_len("\x1b[1;34mBoldBlue"), 8);
    }

    #[test]
    fn test_truncate_with_ellipsis() {
        assert_eq!(truncate_with_ellipsis("Hello World", 11), "Hello World");
        assert_eq!(truncate_with_ellipsis("Hello World", 5), "Hell…\x1b[0m");
        let s = "\x1b[31mHello World\x1b[0m";
        assert_eq!(truncate_with_ellipsis(s, 5), "\x1b[31mHell…\x1b[0m");
    }

    #[test]
    fn test_visible_len_wide_glyphs() {
        assert_eq!(visible_len("🧪"), 2);
        assert_eq!(visible_len("Test🧪"), 6);
        assert_eq!(visible_len("🧪🔬"), 4);
        assert_eq!(visible_len("日本語"), 6);
        assert_eq!(visible_len("Hello日本"), 9);
        assert_eq!(visible_len("Test🧪日本"), 10);
        assert_eq!(visible_len("\x1b[31m日本語\x1b[0m"), 6);
        assert_eq!(visible_len("\x1b[31m🧪Test\x1b[0m"), 6);
    }

    #[test]
    fn test_truncate_wide_glyphs() {
        let emoji_str = "Test🧪Data";
        assert_eq!(truncate_with_ellipsis(emoji_str, 7), "Test🧪…\x1b[0m");
        assert_eq!(truncate_with_ellipsis(emoji_str, 6), "Test…\x1b[0m");

        let cjk_str = "日本語";
        assert_eq!(truncate_with_ellipsis(cjk_str, 6), "日本語");
        assert_eq!(truncate_with_ellipsis(cjk_str, 5), "日本…\x1b[0m");
        assert_eq!(truncate_with_ellipsis(cjk_str, 3), "日…\x1b[0m");

        let mixed = "Test日本語";
        assert_eq!(truncate_with_ellipsis(mixed, 7), "Test日…\x1b[0m");
        assert_eq!(truncate_with_ellipsis(mixed, 10), "Test日本語");

        let path = "/home/user/日本語/test";
        let truncated = truncate_with_ellipsis(path, 15);
        assert!(visible_len(&truncated) <= 15);
    }

    #[test]
    fn test_truncate_wide_boundary() {
        let s = "abc日";
        assert_eq!(truncate_with_ellipsis(s, 5), "abc日");
        assert_eq!(truncate_with_ellipsis(s, 4), "abc…\x1b[0m");
    }

    #[test]
    fn test_normalize_effort_level() {
        assert_eq!(normalize_effort_level("low"), Some("low".to_string()));
        assert_eq!(
            normalize_effort_level(" Medium "),
            Some("medium".to_string())
        );
        assert_eq!(normalize_effort_level("max"), Some("high".to_string()));
        assert_eq!(normalize_effort_level("AUTO"), None);
    }

    #[test]
    fn test_reasoning_mode_label_prefers_effort() {
        let settings =
            parse_claude_settings(r#"{"alwaysThinkingEnabled":true,"effortLevel":"high"}"#)
                .unwrap();

        assert_eq!(
            reasoning_mode_label_from_settings(&settings),
            Some("effort: high".to_string())
        );
    }

    #[test]
    fn test_reasoning_mode_label_uses_thinking_flag() {
        let settings = parse_claude_settings(r#"{"alwaysThinkingEnabled":true}"#).unwrap();

        assert_eq!(
            reasoning_mode_label_from_settings(&settings),
            Some("thinking".to_string())
        );
    }

    #[test]
    fn test_reasoning_mode_label_returns_none_when_disabled() {
        let settings = parse_claude_settings(r#"{"alwaysThinkingEnabled":false}"#).unwrap();

        assert_eq!(reasoning_mode_label_from_settings(&settings), None);
    }
}