use std::path::PathBuf;
use crate::config::Config;
use unicode_width::UnicodeWidthChar;
pub const RESET: &str = "\x1b[0m";
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))
}
}
pub fn format_duration_ms(ms: u64) -> String {
format_duration_secs(ms / 1000, true)
}
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)
}
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 == '[' {
chars.next(); for c2 in chars.by_ref() {
if ('@'..='~').contains(&c2) {
break;
}
}
}
}
} else {
width += c.width().unwrap_or(0);
}
}
width
}
pub fn truncate_with_ellipsis(s: &str, max_width: usize) -> String {
if visible_len(s) <= max_width {
return s.to_string();
}
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 == '[' {
result.push(chars.next().unwrap()); for c2 in chars.by_ref() {
result.push(c2);
if ('@'..='~').contains(&c2) {
break;
}
}
}
}
} else {
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
}
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
}
pub fn get_username() -> Option<String> {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.ok()
}
pub fn reasoning_label_from_payload(
effort: Option<&crate::types::Effort>,
thinking: Option<&crate::types::Thinking>,
) -> Option<String> {
if let Some(level) = effort
.and_then(|effort| effort.level.as_deref())
.map(str::trim)
.filter(|level| !level.is_empty())
.map(|level| level.to_ascii_lowercase())
{
return Some(format!("effort: {}", level));
}
if thinking
.and_then(|thinking| thinking.enabled)
.unwrap_or(false)
{
return Some("thinking".to_string());
}
None
}
#[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_reasoning_label_from_payload_prefers_effort() {
let effort = crate::types::Effort {
level: Some("xhigh".to_string()),
};
let thinking = crate::types::Thinking {
enabled: Some(true),
};
assert_eq!(
reasoning_label_from_payload(Some(&effort), Some(&thinking)),
Some("effort: xhigh".to_string())
);
}
#[test]
fn test_reasoning_label_from_payload_thinking_fallback() {
let thinking = crate::types::Thinking {
enabled: Some(true),
};
assert_eq!(
reasoning_label_from_payload(None, Some(&thinking)),
Some("thinking".to_string())
);
}
#[test]
fn test_reasoning_label_from_payload_none() {
let thinking = crate::types::Thinking {
enabled: Some(false),
};
assert_eq!(reasoning_label_from_payload(None, Some(&thinking)), None);
assert_eq!(reasoning_label_from_payload(None, None), None);
}
}