use std::fmt::Write as _;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthChar;
pub fn wrap_lines(text: &str, width: usize) -> Vec<Line<'_>> {
let mut wrapped = Vec::new();
for line in text.split('\n') {
let mut current_line = String::new();
let mut current_width = 0;
let words: Vec<&str> = line.split_whitespace().collect();
if words.is_empty() {
wrapped.push(Line::from(""));
continue;
}
for word in words {
let word_len = word.chars().count();
let space_len = usize::from(current_width != 0);
if current_width + space_len + word_len > width && !current_line.is_empty() {
wrapped.push(Line::from(current_line));
current_line = String::new();
current_width = 0;
}
if current_width > 0 {
current_line.push(' ');
current_width += 1;
}
current_line.push_str(word);
current_width += word_len;
}
if !current_line.is_empty() {
wrapped.push(Line::from(current_line));
}
}
wrapped
}
pub fn truncate_with_ellipsis(text: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
let text_width = text.chars().count();
if text_width <= max_width {
return text.to_string();
}
if max_width <= 3 {
return ".".repeat(max_width);
}
let visible_width = max_width - 3;
let truncated: String = text.chars().take(visible_width).collect();
format!("{truncated}...")
}
pub fn truncate_spans_with_ellipsis(
spans: Vec<Span<'static>>,
max_width: usize,
) -> Vec<Span<'static>> {
if max_width == 0 {
return vec![Span::raw(String::new())];
}
let total_width: usize = spans
.iter()
.map(|span| span_display_width(&span.content))
.sum();
if total_width <= max_width {
return spans;
}
if max_width <= 3 {
return vec![Span::raw(".".repeat(max_width))];
}
let visible_width = max_width - 3;
let mut remaining = visible_width;
let mut result: Vec<Span<'static>> = Vec::new();
let mut last_style = Style::default();
for span in spans {
if remaining == 0 {
break;
}
last_style = span.style;
let width = span_display_width(&span.content);
if width <= remaining {
remaining -= width;
result.push(span);
} else {
let truncated = take_columns(&span.content, remaining);
result.push(Span::styled(truncated, span.style));
remaining = 0;
}
}
result.push(Span::styled("...".to_string(), last_style));
result
}
fn span_display_width(text: &str) -> usize {
text.chars()
.map(|character| UnicodeWidthChar::width(character).unwrap_or(0))
.sum()
}
fn take_columns(text: &str, columns: usize) -> String {
let mut remaining = columns;
let mut result = String::new();
for character in text.chars() {
let width = UnicodeWidthChar::width(character).unwrap_or(0);
if width > remaining {
break;
}
remaining -= width;
result.push(character);
}
result
}
pub fn inline_text(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub fn wrap_styled_line(spans: Vec<Span<'static>>, width: usize) -> Vec<Line<'static>> {
if width == 0 {
return vec![Line::from(spans)];
}
let mut wrapped_lines: Vec<Line<'static>> = Vec::new();
let mut current_spans: Vec<Span<'static>> = Vec::new();
let mut current_width: usize = 0;
let mut needs_space = false;
for span in spans {
let style = span.style;
let content = span.content.into_owned();
for word in content.split_whitespace() {
let word_len = word.chars().count();
let additional_space_width = usize::from(needs_space && current_width > 0);
if current_width + additional_space_width + word_len > width
&& !current_spans.is_empty()
{
wrapped_lines.push(Line::from(std::mem::take(&mut current_spans)));
current_width = 0;
needs_space = false;
}
if needs_space && current_width > 0 {
current_spans.push(Span::styled(" ".to_string(), style));
current_width += 1;
}
current_spans.push(Span::styled(word.to_string(), style));
current_width += word_len;
needs_space = true;
}
}
if !current_spans.is_empty() {
wrapped_lines.push(Line::from(current_spans));
}
if wrapped_lines.is_empty() {
wrapped_lines.push(Line::from(""));
}
wrapped_lines
}
pub fn wrapped_line_count(lines: &[Line<'static>], width: u16) -> u16 {
lines
.iter()
.map(|line| {
u16::try_from(wrap_styled_line(line.spans.clone(), usize::from(width.max(1))).len())
.unwrap_or(u16::MAX)
})
.fold(0u16, |accumulator, row_count| {
accumulator.saturating_add(row_count)
})
}
pub fn wrap_lines_to_rows(lines: Vec<Line<'static>>, width: u16) -> Vec<Line<'static>> {
let wrap_width = usize::from(width.max(1));
let mut wrapped_rows: Vec<Line<'static>> = Vec::with_capacity(lines.len());
for line in lines {
if line.spans.is_empty() {
wrapped_rows.push(line);
continue;
}
wrapped_rows.extend(wrap_styled_line(line.spans, wrap_width));
}
wrapped_rows
}
pub fn format_token_count(count: u64) -> String {
if count >= 1_000_000 {
return format_scaled_token_count(count, 1_000_000, "M");
}
if count >= 1_000 {
return format_scaled_token_count(count, 1_000, "k");
}
count.to_string()
}
pub fn format_duration_compact(duration_seconds: i64) -> String {
if duration_seconds <= 0 {
return "0s".to_string();
}
let duration_seconds = u64::try_from(duration_seconds).unwrap_or(0);
let hour_count = duration_seconds / 3_600;
let minute_count = (duration_seconds % 3_600) / 60;
let second_count = duration_seconds % 60;
let mut label = String::new();
if hour_count > 0 {
let _ = write!(label, "{hour_count}h");
}
if minute_count > 0 {
let _ = write!(label, "{minute_count}m");
}
let _ = write!(label, "{second_count}s");
label
}
pub fn split_trailing_line_block<'a>(
text: &'a str,
line_prefixes: &[&str],
) -> (&'a str, Option<&'a str>) {
let trimmed_text = text.trim_end_matches('\n');
if trimmed_text.is_empty() {
return (text, None);
}
let mut search_end = trimmed_text.len();
let mut trailing_block_start = None;
loop {
let paragraph_start = trimmed_text[..search_end]
.rfind("\n\n")
.map_or(0, |index| index + 2);
let paragraph = &trimmed_text[paragraph_start..search_end];
let first_line = paragraph
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or_default();
if !line_prefixes
.iter()
.any(|line_prefix| first_line.starts_with(line_prefix))
{
break;
}
trailing_block_start = Some(paragraph_start);
if paragraph_start == 0 {
break;
}
search_end = trimmed_text[..paragraph_start].trim_end_matches('\n').len();
if search_end == 0 {
break;
}
}
if let Some(block_start) = trailing_block_start {
return (&text[..block_start], Some(&text[block_start..]));
}
let line_start = trimmed_text.rfind('\n').map_or(0, |index| index + 1);
let trailing_line = &trimmed_text[line_start..];
if !line_prefixes
.iter()
.any(|line_prefix| trailing_line.starts_with(line_prefix))
{
return (text, None);
}
(&text[..line_start], Some(&text[line_start..]))
}
fn format_scaled_token_count(count: u64, divisor: u64, suffix: &str) -> String {
let scaled_tenths =
((u128::from(count) * 10) + (u128::from(divisor) / 2)) / u128::from(divisor);
let whole = scaled_tenths / 10;
let decimal = scaled_tenths % 10;
format!("{whole}.{decimal}{suffix}")
}
#[cfg(test)]
mod tests {
use ratatui::style::{Color, Modifier, Style};
use super::*;
#[test]
fn test_wrap_lines_basic() {
let text = "hello world";
let width = 20;
let wrapped = wrap_lines(text, width);
assert_eq!(wrapped.len(), 1);
assert_eq!(wrapped[0].to_string(), "hello world");
}
#[test]
fn test_wrap_lines_wrapping() {
let text = "hello world";
let width = 5;
let wrapped = wrap_lines(text, width);
assert_eq!(wrapped.len(), 2);
assert_eq!(wrapped[0].to_string(), "hello");
assert_eq!(wrapped[1].to_string(), "world");
}
#[test]
fn test_wrap_styled_line_wraps_and_preserves_style() {
let style = Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD);
let spans = vec![Span::styled("hello world".to_string(), style)];
let wrapped = wrap_styled_line(spans, 5);
assert_eq!(wrapped.len(), 2);
assert_eq!(wrapped[0].to_string(), "hello");
assert_eq!(wrapped[1].to_string(), "world");
assert_eq!(wrapped[0].spans[0].style, style);
assert_eq!(wrapped[1].spans[0].style, style);
}
#[test]
fn test_wrap_styled_line_zero_width_returns_original_line() {
let style = Style::default().fg(Color::Blue);
let spans = vec![Span::styled("one two".to_string(), style)];
let wrapped = wrap_styled_line(spans, 0);
assert_eq!(wrapped.len(), 1);
assert_eq!(wrapped[0].to_string(), "one two");
assert_eq!(wrapped[0].spans[0].style, style);
}
#[test]
fn test_wrap_styled_line_collapses_extra_whitespace() {
let spans = vec![
Span::styled("hello ".to_string(), Style::default().fg(Color::Green)),
Span::styled(" world".to_string(), Style::default().fg(Color::Red)),
];
let wrapped = wrap_styled_line(spans, 20);
assert_eq!(wrapped.len(), 1);
assert_eq!(wrapped[0].to_string(), "hello world");
}
#[test]
fn test_wrapped_line_count_sums_wrapped_rows() {
let lines = vec![
Line::from(vec![Span::raw("hello world".to_string())]),
Line::from(vec![Span::raw("hi".to_string())]),
];
let total = wrapped_line_count(&lines, 5);
assert_eq!(total, 3);
}
#[test]
fn test_wrap_lines_to_rows_flattens_wrapped_content_and_keeps_blank_lines() {
let lines = vec![
Line::from(vec![Span::raw("hello world".to_string())]),
Line::default(),
Line::from(vec![Span::raw("last".to_string())]),
];
let rows = wrap_lines_to_rows(lines, 5);
assert_eq!(rows.len(), 4);
assert_eq!(rows[0].to_string(), "hello");
assert_eq!(rows[1].to_string(), "world");
assert!(rows[2].spans.is_empty());
assert_eq!(rows[3].to_string(), "last");
}
#[test]
fn test_wrapped_line_count_zero_width_treats_each_line_as_one_row() {
let lines = vec![
Line::from(vec![Span::raw("alpha".to_string())]),
Line::from(vec![Span::raw("beta".to_string())]),
];
let total = wrapped_line_count(&lines, 0);
assert!(total >= 2);
}
#[test]
fn test_truncate_with_ellipsis_keeps_full_text_when_it_fits() {
let text = "short title";
let truncated = truncate_with_ellipsis(text, 20);
assert_eq!(truncated, "short title");
}
#[test]
fn test_truncate_with_ellipsis_adds_three_dots_when_text_overflows() {
let text = "1234567890";
let truncated = truncate_with_ellipsis(text, 8);
assert_eq!(truncated, "12345...");
}
#[test]
fn test_truncate_with_ellipsis_uses_only_dots_for_tiny_widths() {
let text = "overflow";
let width_three = truncate_with_ellipsis(text, 3);
let width_two = truncate_with_ellipsis(text, 2);
let width_zero = truncate_with_ellipsis(text, 0);
assert_eq!(width_three, "...");
assert_eq!(width_two, "..");
assert_eq!(width_zero, "");
}
#[test]
fn test_inline_text_collapses_multiline_whitespace_runs() {
let text = "First draft\n\nSecond\t draft";
let inline = inline_text(text);
assert_eq!(inline, "First draft Second draft");
}
#[test]
fn test_format_token_count_small() {
assert_eq!(format_token_count(0), "0");
assert_eq!(format_token_count(500), "500");
assert_eq!(format_token_count(999), "999");
}
#[test]
fn test_format_token_count_thousands() {
assert_eq!(format_token_count(1000), "1.0k");
assert_eq!(format_token_count(1500), "1.5k");
assert_eq!(format_token_count(12345), "12.3k");
assert_eq!(format_token_count(999_999), "1000.0k");
}
#[test]
fn test_format_token_count_millions() {
assert_eq!(format_token_count(1_000_000), "1.0M");
assert_eq!(format_token_count(1_500_000), "1.5M");
assert_eq!(format_token_count(12_345_678), "12.3M");
}
#[test]
fn test_format_token_count() {
assert_eq!(format_token_count(0), "0");
assert_eq!(format_token_count(500), "500");
assert_eq!(format_token_count(1500), "1.5k");
assert_eq!(format_token_count(1_500_000), "1.5M");
}
#[test]
fn test_format_duration_compact() {
let zero = format_duration_compact(0);
let less_than_one_minute = format_duration_compact(59);
let one_minute = format_duration_compact(60);
let two_minutes = format_duration_compact(120);
let one_hour = format_duration_compact(3_600);
let one_hour_one_minute_one_second = format_duration_compact(3_661);
let one_day_one_hour_one_minute_one_second = format_duration_compact(90_061);
assert_eq!(zero, "0s");
assert_eq!(less_than_one_minute, "59s");
assert_eq!(one_minute, "1m0s");
assert_eq!(two_minutes, "2m0s");
assert_eq!(one_hour, "1h0s");
assert_eq!(one_hour_one_minute_one_second, "1h1m1s");
assert_eq!(one_day_one_hour_one_minute_one_second, "25h1m1s");
}
#[test]
fn test_split_trailing_line_block_returns_matching_footer() {
let text = "Implemented the change.\n\n[Commit] committed with hash `abc1234`\n";
let (body, footer) = split_trailing_line_block(text, &["[Commit]", "[Commit Error]"]);
assert_eq!(body, "Implemented the change.\n\n");
assert_eq!(footer, Some("[Commit] committed with hash `abc1234`\n"));
}
#[test]
fn test_split_trailing_line_block_returns_matching_multiline_status_block() {
let text = "Implemented the change.\n\n[Commit] committed with hash `abc1234`\n\n[Sync \
Assist] Attempt 1/3. Resolving conflicts in:\n- crates/agentty/src/app.rs\n";
let (body, footer) = split_trailing_line_block(text, &["[Commit]", "[Sync Assist]"]);
assert_eq!(body, "Implemented the change.\n\n");
assert_eq!(
footer,
Some(
"[Commit] committed with hash `abc1234`\n\n[Sync Assist] Attempt 1/3. Resolving \
conflicts in:\n- crates/agentty/src/app.rs\n"
)
);
}
#[test]
fn test_split_trailing_line_block_allows_leading_blank_lines_before_prefix() {
let text = "Implemented the change.\n\n\n[Sync Assist] Attempt 1/3.\n- src/main.rs\n";
let (body, footer) = split_trailing_line_block(text, &["[Sync Assist]"]);
assert_eq!(body, "Implemented the change.\n\n\n");
assert_eq!(footer, Some("[Sync Assist] Attempt 1/3.\n- src/main.rs\n"));
}
#[test]
fn test_split_trailing_line_block_ignores_nonmatching_last_line() {
let text = "[Commit] committed with hash `abc1234`\n\n › Follow up\n";
let (body, footer) = split_trailing_line_block(text, &["[Commit]", "[Commit Error]"]);
assert_eq!(body, text);
assert_eq!(footer, None);
}
#[test]
fn test_truncate_spans_with_ellipsis_keeps_spans_when_they_fit() {
let spans = vec![
Span::styled("hello".to_string(), Style::default().fg(Color::Green)),
Span::styled(" world".to_string(), Style::default()),
];
let result = truncate_spans_with_ellipsis(spans.clone(), 20);
assert_eq!(result.len(), 2);
assert_eq!(result[0].content.as_ref(), "hello");
assert_eq!(result[1].content.as_ref(), " world");
}
#[test]
fn test_truncate_spans_with_ellipsis_truncates_within_span() {
let bold = Style::default().add_modifier(Modifier::BOLD);
let spans = vec![
Span::styled("There are ".to_string(), Style::default()),
Span::styled("4 items".to_string(), bold),
Span::styled(" in Ready Now".to_string(), Style::default()),
];
let result = truncate_spans_with_ellipsis(spans, 20);
let text: String = result.iter().map(|span| span.content.as_ref()).collect();
assert_eq!(text, "There are 4 items...");
assert_eq!(result[1].style, bold);
}
#[test]
fn test_truncate_spans_with_ellipsis_returns_dots_for_tiny_widths() {
let spans = vec![Span::raw("overflow".to_string())];
let width_three = truncate_spans_with_ellipsis(spans.clone(), 3);
let width_two = truncate_spans_with_ellipsis(spans.clone(), 2);
let width_zero = truncate_spans_with_ellipsis(spans, 0);
assert_eq!(width_three[0].content.as_ref(), "...");
assert_eq!(width_two[0].content.as_ref(), "..");
assert_eq!(width_zero[0].content.as_ref(), "");
}
#[test]
fn test_truncate_spans_with_ellipsis_preserves_style_on_ellipsis() {
let bold = Style::default().add_modifier(Modifier::BOLD);
let spans = vec![Span::styled("long bold text".to_string(), bold)];
let result = truncate_spans_with_ellipsis(spans, 8);
let text: String = result.iter().map(|span| span.content.as_ref()).collect();
assert_eq!(text, "long ...");
assert_eq!(result[result.len() - 1].style, bold);
}
#[test]
fn test_truncate_spans_with_ellipsis_cuts_at_span_boundary() {
let green = Style::default().fg(Color::Green);
let red = Style::default().fg(Color::Red);
let spans = vec![
Span::styled("abcd".to_string(), green),
Span::styled("efgh".to_string(), red),
];
let result = truncate_spans_with_ellipsis(spans, 7);
let text: String = result.iter().map(|span| span.content.as_ref()).collect();
assert_eq!(text, "abcd...");
assert_eq!(result[0].style, green);
assert_eq!(result[1].style, green);
}
#[test]
fn test_truncate_spans_with_ellipsis_accounts_for_cjk_double_width() {
let spans = vec![Span::raw("表示テスト".to_string())];
let result = truncate_spans_with_ellipsis(spans, 9);
let text: String = result.iter().map(|span| span.content.as_ref()).collect();
assert_eq!(text, "表示テ...");
}
#[test]
fn test_truncate_spans_with_ellipsis_does_not_split_wide_character() {
let spans = vec![Span::raw("表示end".to_string())];
let result = truncate_spans_with_ellipsis(spans, 6);
let text: String = result.iter().map(|span| span.content.as_ref()).collect();
assert_eq!(text, "表...");
}
#[test]
fn test_truncate_spans_with_ellipsis_fits_wide_chars_without_truncation() {
let spans = vec![Span::raw("表示".to_string())];
let result = truncate_spans_with_ellipsis(spans, 4);
assert_eq!(result.len(), 1);
assert_eq!(result[0].content.as_ref(), "表示");
}
#[test]
fn test_truncate_spans_with_ellipsis_handles_emoji() {
let spans = vec![Span::raw("🚀🎉hello".to_string())];
let result = truncate_spans_with_ellipsis(spans, 8);
let text: String = result.iter().map(|span| span.content.as_ref()).collect();
assert_eq!(text, "🚀🎉h...");
}
#[test]
fn test_truncate_spans_with_ellipsis_handles_combining_characters() {
let spans = vec![Span::raw("e\u{0301}abcdef".to_string())];
let result = truncate_spans_with_ellipsis(spans, 7);
assert_eq!(result.len(), 1);
assert_eq!(result[0].content.as_ref(), "e\u{0301}abcdef");
}
#[test]
fn test_truncate_spans_with_ellipsis_cjk_across_styled_spans() {
let bold = Style::default().add_modifier(Modifier::BOLD);
let spans = vec![
Span::styled("表示".to_string(), bold),
Span::raw("テスト".to_string()),
];
let result = truncate_spans_with_ellipsis(spans, 9);
let text: String = result.iter().map(|span| span.content.as_ref()).collect();
assert_eq!(text, "表示テ...");
assert_eq!(result[0].style, bold);
}
}