use std::borrow::Cow;
use std::mem;
use line_clipping::cohen_sutherland::clip_line;
use line_clipping::{LineSegment, Point, Window};
use ratatui::prelude::*;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use vtcode_commons::ansi_codes::ESC_CHAR;
use crate::utils::ansi_parser::strip_ansi;
pub fn strip_ansi_codes(text: &str) -> Cow<'_, str> {
if !text.contains(ESC_CHAR) {
return Cow::Borrowed(text);
}
Cow::Owned(strip_ansi(text))
}
#[allow(dead_code)]
pub fn simplify_tool_display(text: &str) -> String {
let simplified = if text.starts_with("file ") {
text.replacen("file ", "accessing ", 1)
} else if text.starts_with("path: ") {
text.replacen("path: ", "file: ", 1)
} else if text.contains(" → file ") {
text.replace(" → file ", " → ")
} else if text.starts_with("grep ") {
text.replacen("grep ", "searching for ", 1)
} else if text.starts_with("find ") {
text.replacen("find ", "finding ", 1)
} else if text.starts_with("list ") {
text.replacen("list ", "listing ", 1)
} else {
text.to_owned()
};
format_tool_parameters(&simplified)
}
#[allow(dead_code)]
pub fn format_tool_parameters(text: &str) -> String {
let mut formatted = text.to_owned();
if formatted.contains("pattern: ") {
formatted = formatted.replace("pattern: ", "matching '");
if formatted.contains(" · ") {
formatted = formatted.replacen(" · ", "' · ", 1);
} else if formatted.contains(" ") {
formatted = formatted.replacen(" ", "' ", 1);
} else {
formatted.push('\'');
}
}
if formatted.contains("path: ") {
formatted = formatted.replace("path: ", "in '");
if formatted.contains(" · ") {
formatted = formatted.replacen(" · ", "' · ", 1);
} else if formatted.contains(" ") {
formatted = formatted.replacen(" ", "' ", 1);
} else {
formatted.push('\'');
}
}
formatted
}
pub(super) fn pty_wrapped_continuation_prefix(base_prefix: &str, line_text: &str) -> String {
let stripped = strip_ansi_codes(line_text);
let hang_width = if stripped.starts_with(" └ ")
|| stripped.starts_with(" │ ")
|| stripped.starts_with(" ")
{
4
} else if stripped.starts_with("• Ran ") {
"• Ran ".chars().count()
} else {
0
};
format!("{}{}", base_prefix, " ".repeat(hang_width))
}
pub fn wrap_line(line: Line<'static>, max_width: usize) -> Vec<Line<'static>> {
wrap_line_internal(line, max_width, "", true)
}
pub(super) fn wrap_line_with_hanging_prefix(
line: Line<'static>,
max_width: usize,
continuation_prefix: &str,
) -> Vec<Line<'static>> {
wrap_line_internal(line, max_width, continuation_prefix, false)
}
fn wrap_line_internal(
mut line: Line<'static>,
max_width: usize,
continuation_prefix: &str,
prefer_word_boundaries: bool,
) -> Vec<Line<'static>> {
if max_width == 0 {
return vec![Line::default()];
}
line.spans = coalesce_adjacent_spans(line.spans);
let derived_continuation_prefix = if prefer_word_boundaries && continuation_prefix.is_empty() {
wrapped_continuation_prefix(&line)
} else {
String::new()
};
let continuation_prefix = if continuation_prefix.is_empty() {
derived_continuation_prefix.as_str()
} else {
continuation_prefix
};
fn push_span(spans: &mut Vec<Span<'static>>, style: &Style, text: &str) {
if text.is_empty() {
return;
}
if let Some(last) = spans.last_mut().filter(|last| last.style == *style) {
last.content.to_mut().push_str(text);
return;
}
spans.push(Span::styled(text.to_owned(), *style));
}
fn trim_trailing_wrap_whitespace(spans: &mut Vec<Span<'static>>) {
while let Some(last) = spans.last_mut() {
let trimmed_len = last.content.trim_end_matches(char::is_whitespace).len();
if trimmed_len == last.content.len() {
break;
}
if trimmed_len == 0 {
spans.pop();
continue;
}
last.content.to_mut().truncate(trimmed_len);
break;
}
}
let continuation_width = UnicodeWidthStr::width(continuation_prefix);
let use_continuation_prefix =
!continuation_prefix.is_empty() && continuation_width > 0 && continuation_width < max_width;
let mut rows = Vec::new();
let mut current_spans: Vec<Span<'static>> = Vec::new();
let mut current_width = 0usize;
let window = Window::new(0.0, max_width as f64, -1.0, 1.0);
let flush_current = |spans: &mut Vec<Span<'static>>, rows: &mut Vec<Line<'static>>| {
if spans.is_empty() {
rows.push(Line::default());
} else {
if prefer_word_boundaries {
trim_trailing_wrap_whitespace(spans);
}
rows.push(Line::from(mem::take(spans)));
}
};
let ensure_continuation_prefix =
|spans: &mut Vec<Span<'static>>, current_width: &mut usize, rows: &[Line<'static>]| {
if use_continuation_prefix && spans.is_empty() && !rows.is_empty() {
push_span(spans, &Style::default(), continuation_prefix);
*current_width = continuation_width;
}
};
let line_start_width = |rows: &[Line<'static>]| -> usize {
if use_continuation_prefix && !rows.is_empty() {
continuation_width
} else {
0
}
};
let push_wrapped_token = |token: &str,
style: &Style,
current_spans: &mut Vec<Span<'static>>,
current_width: &mut usize,
rows: &mut Vec<Line<'static>>| {
for grapheme in UnicodeSegmentation::graphemes(token, true) {
if grapheme.is_empty() {
continue;
}
let width = UnicodeWidthStr::width(grapheme);
if width == 0 {
ensure_continuation_prefix(current_spans, current_width, rows);
push_span(current_spans, style, grapheme);
continue;
}
let mut attempts = 0usize;
loop {
ensure_continuation_prefix(current_spans, current_width, rows);
let segment = LineSegment::new(
Point::new(*current_width as f64, 0.0),
Point::new((*current_width + width) as f64, 0.0),
);
match clip_line(segment, window) {
Some(clipped) => {
let visible = (clipped.p2.x - clipped.p1.x).round() as usize;
if visible == width {
push_span(current_spans, style, grapheme);
*current_width += width;
break;
}
if *current_width == 0 {
push_span(current_spans, style, grapheme);
*current_width += width;
break;
}
flush_current(current_spans, rows);
*current_width = 0;
}
None => {
if *current_width == 0 {
push_span(current_spans, style, grapheme);
*current_width += width;
break;
}
flush_current(current_spans, rows);
*current_width = 0;
}
}
attempts += 1;
if attempts > 4 {
push_span(current_spans, style, grapheme);
*current_width += width;
break;
}
}
if *current_width >= max_width {
flush_current(current_spans, rows);
*current_width = 0;
}
}
};
for span in line.spans.into_iter() {
let style = span.style;
let content = span.content.into_owned();
if content.is_empty() {
continue;
}
for piece in content.split_inclusive('\n') {
let mut text = piece;
let mut had_newline = false;
if let Some(stripped) = text.strip_suffix('\n') {
text = stripped;
had_newline = true;
if let Some(without_carriage) = text.strip_suffix('\r') {
text = without_carriage;
}
}
if !text.is_empty() {
if prefer_word_boundaries {
for token in UnicodeSegmentation::split_word_bounds(text) {
if token.is_empty() {
continue;
}
let token_width = UnicodeWidthStr::width(token);
if token_width == 0 {
ensure_continuation_prefix(
&mut current_spans,
&mut current_width,
&rows,
);
push_span(&mut current_spans, &style, token);
continue;
}
let token_is_whitespace = token.chars().all(char::is_whitespace);
let line_start = line_start_width(&rows);
let has_content = current_width > line_start;
if token_is_whitespace && !rows.is_empty() && !has_content {
continue;
}
ensure_continuation_prefix(&mut current_spans, &mut current_width, &rows);
if current_width + token_width <= max_width {
push_span(&mut current_spans, &style, token);
current_width += token_width;
continue;
}
if token_is_whitespace {
if has_content {
flush_current(&mut current_spans, &mut rows);
current_width = 0;
}
continue;
}
if token_width <= max_width {
if has_content {
flush_current(&mut current_spans, &mut rows);
current_width = 0;
ensure_continuation_prefix(
&mut current_spans,
&mut current_width,
&rows,
);
}
push_span(&mut current_spans, &style, token);
current_width += token_width;
continue;
}
push_wrapped_token(
token,
&style,
&mut current_spans,
&mut current_width,
&mut rows,
);
}
} else {
push_wrapped_token(
text,
&style,
&mut current_spans,
&mut current_width,
&mut rows,
);
}
}
if had_newline {
flush_current(&mut current_spans, &mut rows);
current_width = 0;
}
}
}
if !current_spans.is_empty() {
flush_current(&mut current_spans, &mut rows);
} else if rows.is_empty() {
rows.push(Line::default());
}
rows
}
fn coalesce_adjacent_spans(spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
let mut merged: Vec<Span<'static>> = Vec::with_capacity(spans.len());
for span in spans {
if span.content.is_empty() {
continue;
}
if let Some(last) = merged.last_mut().filter(|last| last.style == span.style) {
last.content.to_mut().push_str(span.content.as_ref());
} else {
merged.push(span);
}
}
merged
}
fn wrapped_continuation_prefix(line: &Line<'static>) -> String {
let text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
structural_continuation_prefix(&text)
}
fn structural_continuation_prefix(text: &str) -> String {
let stripped = strip_ansi_codes(text);
let text = stripped.as_ref();
let bytes = text.as_bytes();
let mut index = 0usize;
let mut width = 0usize;
while index < bytes.len() {
let Some(ch) = text[index..].chars().next() else {
break;
};
if !ch.is_whitespace() || ch == '\n' || ch == '\r' {
break;
}
width += UnicodeWidthStr::width(ch.encode_utf8(&mut [0u8; 4]) as &str);
index += ch.len_utf8();
}
while text[index..].starts_with("│ ") {
width += UnicodeWidthStr::width("│ ");
index += "│ ".len();
}
let remaining = &text[index..];
let marker_width = if remaining.starts_with("- ") {
Some(UnicodeWidthStr::width("- "))
} else if remaining.starts_with("* ") {
Some(UnicodeWidthStr::width("* "))
} else if remaining.starts_with("+ ") {
Some(UnicodeWidthStr::width("+ "))
} else if remaining.starts_with("• ") {
Some(UnicodeWidthStr::width("• "))
} else if remaining.starts_with("◦ ") {
Some(UnicodeWidthStr::width("◦ "))
} else if remaining.starts_with("▪ ") {
Some(UnicodeWidthStr::width("▪ "))
} else {
numbered_list_marker_width(remaining)
};
if let Some(marker_width) = marker_width {
return " ".repeat(width + marker_width);
}
if width > 0 {
" ".repeat(width)
} else {
String::new()
}
}
fn numbered_list_marker_width(text: &str) -> Option<usize> {
let mut chars = text.char_indices().peekable();
let mut end_after_head = None;
while let Some((idx, ch)) = chars.peek().copied() {
if ch.is_ascii_digit() || ch.is_ascii_alphabetic() {
end_after_head = Some(idx + ch.len_utf8());
chars.next();
} else {
break;
}
}
end_after_head?;
let (idx, separator) = chars.next()?;
if separator != '.' && separator != ')' {
return None;
}
let end_after_separator = idx + separator.len_utf8();
let (idx, space) = chars.next()?;
if !space.is_whitespace() {
return None;
}
let end = idx + space.len_utf8();
Some(UnicodeWidthStr::width(
&text[..end.max(end_after_separator)],
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TodoState {
Pending,
Completed,
None,
}
pub fn detect_todo_state(text: &str) -> TodoState {
let trimmed = text.trim_start();
let patterns_pending = ["- [ ]", "* [ ]", "+ [ ]", "[ ]"];
let patterns_completed = [
"- [x]", "- [X]", "* [x]", "* [X]", "+ [x]", "+ [X]", "[x]", "[X]",
];
for pattern in patterns_completed {
if trimmed.starts_with(pattern) {
return TodoState::Completed;
}
}
for pattern in patterns_pending {
if trimmed.starts_with(pattern) {
return TodoState::Pending;
}
}
if trimmed.starts_with("~~") && trimmed.contains("~~") {
return TodoState::Completed;
}
TodoState::None
}
#[allow(dead_code)]
pub fn is_list_item(text: &str) -> bool {
let trimmed = text.trim_start();
if trimmed.starts_with("- ")
|| trimmed.starts_with("* ")
|| trimmed.starts_with("+ ")
|| trimmed.starts_with("• ")
{
return true;
}
let mut chars = trimmed.chars();
if let Some(first) = chars.next()
&& (first.is_ascii_digit() || first.is_ascii_alphabetic())
&& let Some(second) = chars.next()
&& (second == '.' || second == ')')
&& let Some(third) = chars.next()
{
return third == ' ';
}
false
}
pub fn justify_plain_text(text: &str, max_width: usize) -> Option<String> {
let trimmed = text.trim();
let words: Vec<&str> = trimmed.split_whitespace().collect();
if words.len() <= 1 {
return None;
}
let total_word_width: usize = words.iter().map(|word| UnicodeWidthStr::width(*word)).sum();
if total_word_width >= max_width {
return None;
}
let gaps = words.len() - 1;
let spaces_needed = max_width.saturating_sub(total_word_width);
if spaces_needed <= gaps {
return None;
}
let base_space = spaces_needed / gaps;
if base_space == 0 {
return None;
}
let extra = spaces_needed % gaps;
let mut output = String::with_capacity(max_width + gaps);
for (index, word) in words.iter().enumerate() {
output.push_str(word);
if index < gaps {
let mut count = base_space;
if index < extra {
count += 1;
}
for _ in 0..count {
output.push(' ');
}
}
}
Some(output)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_ansi_codes() {
assert_eq!(strip_ansi_codes("\x1b[31mRed text\x1b[0m"), "Red text");
assert_eq!(strip_ansi_codes("No codes here"), "No codes here");
assert_eq!(
strip_ansi_codes("\x1b[1;32mBold green\x1b[0m"),
"Bold green"
);
}
#[test]
fn test_simplify_tool_display() {
assert_eq!(
simplify_tool_display("file path/to/file"),
"accessing path/to/file"
);
assert_eq!(
simplify_tool_display("path: path/to/file"),
"file: path/to/file"
);
assert_eq!(
simplify_tool_display("grep pattern"),
"searching for pattern"
);
}
#[test]
fn test_justify_plain_text() {
let result = justify_plain_text("Hello world", 15);
assert!(result.is_some());
assert_eq!(result.unwrap().len(), 15);
assert_eq!(justify_plain_text("Short", 10), None);
assert_eq!(justify_plain_text("Very long text string", 10), None);
}
#[test]
fn test_detect_todo_state_pending() {
assert_eq!(detect_todo_state("- [ ] Task"), TodoState::Pending);
assert_eq!(detect_todo_state("* [ ] Task"), TodoState::Pending);
assert_eq!(detect_todo_state("+ [ ] Task"), TodoState::Pending);
assert_eq!(detect_todo_state("[ ] Task"), TodoState::Pending);
assert_eq!(
detect_todo_state(" - [ ] Indented task"),
TodoState::Pending
);
}
#[test]
fn test_detect_todo_state_completed() {
assert_eq!(detect_todo_state("- [x] Done"), TodoState::Completed);
assert_eq!(detect_todo_state("- [X] Done"), TodoState::Completed);
assert_eq!(detect_todo_state("* [x] Done"), TodoState::Completed);
assert_eq!(detect_todo_state("[x] Done"), TodoState::Completed);
assert_eq!(
detect_todo_state(" - [x] Indented done"),
TodoState::Completed
);
assert_eq!(
detect_todo_state("~~Strikethrough text~~"),
TodoState::Completed
);
}
#[test]
fn test_detect_todo_state_none() {
assert_eq!(detect_todo_state("Regular text"), TodoState::None);
assert_eq!(detect_todo_state("- Regular list item"), TodoState::None);
assert_eq!(detect_todo_state("* Bullet point"), TodoState::None);
assert_eq!(detect_todo_state("1. Numbered item"), TodoState::None);
}
#[test]
fn test_is_list_item() {
assert!(is_list_item("- Item"));
assert!(is_list_item("* Item"));
assert!(is_list_item("+ Item"));
assert!(is_list_item("• Item"));
assert!(is_list_item("1. Item"));
assert!(is_list_item("a) Item"));
assert!(is_list_item(" - Indented"));
assert!(!is_list_item("Regular text"));
assert!(!is_list_item(""));
}
#[test]
fn test_pty_wrapped_continuation_prefix() {
assert_eq!(
pty_wrapped_continuation_prefix(" ", " └ cargo check"),
" "
);
assert_eq!(
pty_wrapped_continuation_prefix(" ", "\u{1b}[32m• Ran cargo check -p vtcode\u{1b}[0m",),
" "
);
assert_eq!(
pty_wrapped_continuation_prefix(" ", "• Ran cargo check -p vtcode"),
" "
);
assert_eq!(pty_wrapped_continuation_prefix(" ", "plain output"), " ");
}
}