use std::io::{self, Write};
use std::sync::OnceLock;
use std::time::Duration;
static COLOR_DISABLED: OnceLock<bool> = OnceLock::new();
static BELL_DISABLED: OnceLock<bool> = OnceLock::new();
pub fn disable_bell() {
let _ = BELL_DISABLED.set(true);
}
pub fn bell_enabled() -> bool {
!*BELL_DISABLED.get_or_init(|| std::env::var("YOYO_NO_BELL").is_ok())
}
pub fn maybe_ring_bell(elapsed: Duration) {
if bell_enabled() && elapsed.as_secs() >= 3 {
let _ = io::stdout().write_all(b"\x07");
let _ = io::stdout().flush();
}
}
pub fn disable_color() {
let _ = COLOR_DISABLED.set(true);
}
fn color_enabled() -> bool {
!*COLOR_DISABLED.get_or_init(|| std::env::var("NO_COLOR").is_ok())
}
pub struct Color(pub &'static str);
impl std::fmt::Display for Color {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if color_enabled() {
f.write_str(self.0)
} else {
Ok(())
}
}
}
pub static RESET: Color = Color("\x1b[0m");
pub static BOLD: Color = Color("\x1b[1m");
pub static DIM: Color = Color("\x1b[2m");
pub static GREEN: Color = Color("\x1b[32m");
pub static YELLOW: Color = Color("\x1b[33m");
pub static CYAN: Color = Color("\x1b[36m");
pub static RED: Color = Color("\x1b[31m");
pub static MAGENTA: Color = Color("\x1b[35m");
pub static ITALIC: Color = Color("\x1b[3m");
pub static BOLD_ITALIC: Color = Color("\x1b[1;3m");
pub static BOLD_CYAN: Color = Color("\x1b[1;36m");
pub static BOLD_YELLOW: Color = Color("\x1b[1;33m");
mod cost;
mod highlight;
mod markdown;
mod tools;
pub use cost::*;
pub use highlight::*;
pub use markdown::*;
pub use tools::*;
pub fn truncate_with_ellipsis(s: &str, max: usize) -> String {
match s.char_indices().nth(max) {
Some((idx, _)) => format!("{}…", &s[..idx]),
None => s.to_string(),
}
}
pub fn decode_html_entities(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
let s = s
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
.replace(" ", " ")
.replace("'", "'")
.replace("—", "—")
.replace("–", "–")
.replace("…", "…")
.replace("©", "©")
.replace("®", "®");
let mut decoded = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '&' && chars.peek() == Some(&'#') {
let mut entity = String::from("&#");
chars.next(); while let Some(&nc) = chars.peek() {
if nc == ';' {
chars.next();
break;
}
entity.push(nc);
chars.next();
}
let num_str = &entity[2..];
let parsed = if let Some(hex) = num_str.strip_prefix('x').or(num_str.strip_prefix('X'))
{
u32::from_str_radix(hex, 16).ok()
} else {
num_str.parse::<u32>().ok()
};
if let Some(ch) = parsed.and_then(char::from_u32) {
decoded.push(ch);
} else {
decoded.push_str(&entity);
decoded.push(';');
}
} else {
decoded.push(c);
}
}
decoded
}
pub const TOOL_OUTPUT_MAX_CHARS: usize = 30_000;
pub const TOOL_OUTPUT_MAX_CHARS_PIPED: usize = 15_000;
const TRUNCATION_HEAD_LINES: usize = 100;
const TRUNCATION_TAIL_LINES: usize = 50;
const COLLAPSE_MIN_LINES: usize = 4;
const CATEGORY_PREFIX_MAX: usize = 20;
pub fn compress_tool_output(output: &str) -> String {
if output.is_empty() {
return String::new();
}
let stripped = strip_ansi_codes(output);
collapse_repetitive_lines(&stripped)
}
fn strip_ansi_codes(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next(); while let Some(&p) = chars.peek() {
if p.is_ascii_digit() || p == ';' {
chars.next();
} else {
break;
}
}
if let Some(&f) = chars.peek() {
if f.is_ascii_alphabetic() {
chars.next();
}
}
}
} else {
result.push(c);
}
}
result
}
fn line_category(line: &str) -> &str {
let trimmed = line.trim_start();
if trimmed.is_empty() {
return "";
}
let first_word_end = trimmed
.find(|c: char| c.is_whitespace())
.unwrap_or(trimmed.len());
let prefix_len = (line.len() - trimmed.len()) + first_word_end;
let mut end = prefix_len.min(CATEGORY_PREFIX_MAX).min(line.len());
while end > 0 && !line.is_char_boundary(end) {
end -= 1;
}
&line[..end]
}
fn collapse_repetitive_lines(s: &str) -> String {
let lines: Vec<&str> = s.lines().collect();
if lines.len() < COLLAPSE_MIN_LINES {
return s.to_string();
}
let mut result = Vec::with_capacity(lines.len());
let mut i = 0;
while i < lines.len() {
let cat = line_category(lines[i]);
if !cat.is_empty() {
let mut run_end = i + 1;
while run_end < lines.len() && line_category(lines[run_end]) == cat {
run_end += 1;
}
let run_len = run_end - i;
if run_len >= COLLAPSE_MIN_LINES {
result.push(lines[i].to_string());
let collapsed = run_len - 2; result.push(format!("... ({collapsed} more similar lines)"));
result.push(lines[run_end - 1].to_string());
i = run_end;
continue;
}
}
result.push(lines[i].to_string());
i += 1;
}
result.join("\n")
}
pub fn truncate_tool_output(output: &str, max_chars: usize) -> String {
let compressed = compress_tool_output(output);
if compressed.len() <= max_chars {
return compressed;
}
let lines: Vec<&str> = compressed.lines().collect();
let total_lines = lines.len();
if total_lines <= TRUNCATION_HEAD_LINES + TRUNCATION_TAIL_LINES {
return compressed;
}
let head = &lines[..TRUNCATION_HEAD_LINES];
let tail = &lines[total_lines - TRUNCATION_TAIL_LINES..];
let omitted = total_lines - TRUNCATION_HEAD_LINES - TRUNCATION_TAIL_LINES;
let mut result = String::with_capacity(max_chars);
for line in head {
result.push_str(line);
result.push('\n');
}
result.push_str(&format!(
"\n[... truncated {omitted} {} ...]\n\n",
pluralize(omitted, "line", "lines")
));
for (i, line) in tail.iter().enumerate() {
result.push_str(line);
if i < tail.len() - 1 {
result.push('\n');
}
}
result
}
fn terminal_width() -> usize {
std::env::var("COLUMNS")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(80)
}
pub fn format_tool_batch_summary(
total: usize,
succeeded: usize,
failed: usize,
total_duration: std::time::Duration,
) -> String {
if total <= 1 {
return String::new();
}
let dur = format_duration(total_duration);
let tool_word = pluralize(total, "tool", "tools");
let status = if failed == 0 {
format!("{succeeded} {GREEN}✓{RESET}")
} else {
format!("{succeeded} {GREEN}✓{RESET}, {failed} {RED}✗{RESET}")
};
format!("{DIM} {total} {tool_word} completed in {dur}{RESET} ({status})")
}
pub fn indent_tool_output(output: &str) -> String {
if output.is_empty() {
return String::new();
}
output
.lines()
.map(|line| format!("{DIM} │ {RESET}{line}"))
.collect::<Vec<_>>()
.join("\n")
}
pub fn turn_boundary(turn_number: usize) -> String {
let width = terminal_width();
let label = format!(" Turn {turn_number} ");
let prefix = " ╭─";
let suffix = "╮";
let used = prefix.len() + label.len() + suffix.len();
let fill = width.saturating_sub(used);
let trail = "─".repeat(fill);
format!("{DIM}{prefix}{label}{trail}{suffix}{RESET}")
}
pub fn section_header(label: &str) -> String {
let width = terminal_width();
if label.is_empty() {
return section_divider();
}
let prefix = "── ";
let separator = " ";
let used = prefix.len() + label.len() + separator.len();
let remaining = width.saturating_sub(used);
let trail = "─".repeat(remaining);
format!("{DIM}{prefix}{label}{separator}{trail}{RESET}")
}
pub fn section_divider() -> String {
let width = terminal_width();
format!("{DIM}{}{RESET}", "─".repeat(width))
}
const MAX_DIFF_LINES: usize = 20;
pub fn format_edit_diff(old_text: &str, new_text: &str) -> String {
let mut lines: Vec<String> = Vec::new();
if !old_text.is_empty() {
for line in old_text.lines() {
lines.push(format!("{RED} - {line}{RESET}"));
}
}
if !new_text.is_empty() {
for line in new_text.lines() {
lines.push(format!("{GREEN} + {line}{RESET}"));
}
}
if lines.is_empty() {
return String::new();
}
if lines.len() > MAX_DIFF_LINES {
let remaining = lines.len() - MAX_DIFF_LINES;
lines.truncate(MAX_DIFF_LINES);
lines.push(format!("{DIM} ... ({remaining} more lines){RESET}"));
}
lines.join("\n")
}
pub fn format_tool_summary(tool_name: &str, args: &serde_json::Value) -> String {
match tool_name {
"bash" => {
let cmd = args
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("...");
let line_count = cmd.lines().count();
let first_line = cmd.lines().next().unwrap_or("...");
if line_count > 1 {
format!(
"$ {} ({line_count} lines)",
truncate_with_ellipsis(first_line, 60)
)
} else {
format!("$ {}", truncate_with_ellipsis(cmd, 80))
}
}
"read_file" => {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("?");
let offset = args.get("offset").and_then(|v| v.as_u64());
let limit = args.get("limit").and_then(|v| v.as_u64());
match (offset, limit) {
(Some(off), Some(lim)) => {
format!("read {path}:{off}..{}", off + lim)
}
(Some(off), None) => {
format!("read {path}:{off}..")
}
(None, Some(lim)) => {
let word = pluralize(lim as usize, "line", "lines");
format!("read {path} ({lim} {word})")
}
(None, None) => {
format!("read {path}")
}
}
}
"write_file" => {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("?");
let line_info = args
.get("content")
.and_then(|v| v.as_str())
.map(|c| {
let count = c.lines().count();
let word = pluralize(count, "line", "lines");
format!(" ({count} {word})")
})
.unwrap_or_default();
format!("write {path}{line_info}")
}
"edit_file" => {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("?");
let old_text = args.get("old_text").and_then(|v| v.as_str());
let new_text = args.get("new_text").and_then(|v| v.as_str());
match (old_text, new_text) {
(Some(old), Some(new)) => {
let old_lines = old.lines().count();
let new_lines = new.lines().count();
format!("edit {path} ({old_lines} → {new_lines} lines)")
}
_ => format!("edit {path}"),
}
}
"list_files" => {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let pattern = args.get("pattern").and_then(|v| v.as_str());
match pattern {
Some(pat) => format!("ls {path} ({pat})"),
None => format!("ls {path}"),
}
}
"search" => {
let pat = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("?");
let search_path = args.get("path").and_then(|v| v.as_str());
let include = args.get("include").and_then(|v| v.as_str());
let mut summary = format!("search '{}'", truncate_with_ellipsis(pat, 60));
if let Some(p) = search_path {
summary.push_str(&format!(" in {p}"));
}
if let Some(inc) = include {
summary.push_str(&format!(" ({inc})"));
}
summary
}
_ => tool_name.to_string(),
}
}
pub fn format_usage_line(
usage: &yoagent::Usage,
total: &yoagent::Usage,
model: &str,
elapsed: std::time::Duration,
verbose: bool,
) -> Option<String> {
if usage.input == 0 && usage.output == 0 {
return None;
}
let elapsed_str = format_duration(elapsed);
if verbose {
let cache_info = if usage.cache_read > 0 || usage.cache_write > 0 {
format!(
" [cache: {} read, {} write]",
usage.cache_read, usage.cache_write
)
} else {
String::new()
};
let cost_info = estimate_cost(usage, model)
.map(|c| format!(" cost: {}", format_cost(c)))
.unwrap_or_default();
let total_cost_info = estimate_cost(total, model)
.map(|c| format!(" total: {}", format_cost(c)))
.unwrap_or_default();
Some(format!(
"tokens: {} in / {} out{cache_info} (session: {} in / {} out){cost_info}{total_cost_info} ⏱ {elapsed_str}",
usage.input, usage.output, total.input, total.output
))
} else {
let cost_suffix = estimate_cost(usage, model)
.map(|c| format!(" · {}", format_cost(c)))
.unwrap_or_default();
Some(format!(
"↳ {elapsed_str} · {}→{} tokens{cost_suffix}",
usage.input, usage.output
))
}
}
pub fn print_usage(
usage: &yoagent::Usage,
total: &yoagent::Usage,
model: &str,
elapsed: std::time::Duration,
) {
if let Some(line) = format_usage_line(usage, total, model, elapsed, crate::cli::is_verbose()) {
println!("\n{DIM} {line}{RESET}");
}
}
pub fn context_usage_color(pct: u32) -> &'static Color {
if pct > 80 {
&RED
} else if pct > 50 {
&YELLOW
} else {
&GREEN
}
}
pub fn print_context_usage(used_tokens: u64, max_tokens: u64) {
if max_tokens == 0 {
return;
}
let pct = ((used_tokens as f64 / max_tokens as f64) * 100.0).min(100.0) as u32;
let color = context_usage_color(pct);
println!("{DIM} {color}⬤{RESET}{DIM} {pct}% of context window used{RESET}");
}
#[cfg(test)]
pub fn truncate(s: &str, max: usize) -> &str {
match s.char_indices().nth(max) {
Some((idx, _)) => &s[..idx],
None => s,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_truncate_short_string() {
assert_eq!(truncate("hello", 10), "hello");
}
#[test]
fn test_truncate_exact_length() {
assert_eq!(truncate("hello", 5), "hello");
}
#[test]
fn test_truncate_long_string() {
assert_eq!(truncate("hello world", 5), "hello");
}
#[test]
fn test_truncate_unicode() {
assert_eq!(truncate("héllo wörld", 5), "héllo");
}
#[test]
fn test_truncate_empty() {
assert_eq!(truncate("", 5), "");
}
#[test]
fn test_truncate_adds_ellipsis() {
assert_eq!(truncate_with_ellipsis("hello world", 5), "hello…");
assert_eq!(truncate_with_ellipsis("hi", 5), "hi");
assert_eq!(truncate_with_ellipsis("hello", 5), "hello");
}
#[test]
fn test_format_tool_summary_bash() {
let args = serde_json::json!({"command": "echo hello"});
assert_eq!(format_tool_summary("bash", &args), "$ echo hello");
}
#[test]
fn test_format_tool_summary_bash_long_command() {
let long_cmd = "a".repeat(100);
let args = serde_json::json!({"command": long_cmd});
let result = format_tool_summary("bash", &args);
assert!(result.starts_with("$ "));
assert!(result.ends_with('…'));
assert!(result.len() < 100);
}
#[test]
fn test_format_tool_summary_read_file() {
let args = serde_json::json!({"path": "src/main.rs"});
assert_eq!(format_tool_summary("read_file", &args), "read src/main.rs");
}
#[test]
fn test_format_tool_summary_write_file() {
let args = serde_json::json!({"path": "out.txt"});
assert_eq!(format_tool_summary("write_file", &args), "write out.txt");
}
#[test]
fn test_format_tool_summary_edit_file() {
let args = serde_json::json!({"path": "foo.rs"});
assert_eq!(format_tool_summary("edit_file", &args), "edit foo.rs");
}
#[test]
fn test_format_tool_summary_list_files() {
let args = serde_json::json!({"path": "src/"});
assert_eq!(format_tool_summary("list_files", &args), "ls src/");
}
#[test]
fn test_format_tool_summary_list_files_no_path() {
let args = serde_json::json!({});
assert_eq!(format_tool_summary("list_files", &args), "ls .");
}
#[test]
fn test_format_tool_summary_search() {
let args = serde_json::json!({"pattern": "TODO"});
assert_eq!(format_tool_summary("search", &args), "search 'TODO'");
}
#[test]
fn test_format_tool_summary_unknown_tool() {
let args = serde_json::json!({});
assert_eq!(format_tool_summary("custom_tool", &args), "custom_tool");
}
#[test]
fn test_color_struct_display_outputs_ansi() {
let c = Color("\x1b[1m");
let formatted = format!("{c}");
assert!(formatted == "\x1b[1m" || formatted.is_empty());
}
#[test]
fn test_format_edit_diff_single_line_change() {
let diff = format_edit_diff("old line", "new line");
assert!(diff.contains("- old line"));
assert!(diff.contains("+ new line"));
assert!(diff.contains(&format!("{RED}")));
assert!(diff.contains(&format!("{GREEN}")));
}
#[test]
fn test_format_edit_diff_multi_line_change() {
let old = "line 1\nline 2\nline 3";
let new = "line A\nline B";
let diff = format_edit_diff(old, new);
assert!(diff.contains("- line 1"));
assert!(diff.contains("- line 2"));
assert!(diff.contains("- line 3"));
assert!(diff.contains("+ line A"));
assert!(diff.contains("+ line B"));
}
#[test]
fn test_format_edit_diff_addition_only() {
let diff = format_edit_diff("", "new content\nmore content");
assert!(!diff.contains("- "));
assert!(diff.contains("+ new content"));
assert!(diff.contains("+ more content"));
}
#[test]
fn test_format_edit_diff_deletion_only() {
let diff = format_edit_diff("old content\nmore old", "");
assert!(diff.contains("- old content"));
assert!(diff.contains("- more old"));
assert!(!diff.contains("+ "));
}
#[test]
fn test_format_edit_diff_long_diff_truncation() {
let old_lines: Vec<&str> = (0..15).map(|_| "old").collect();
let new_lines: Vec<&str> = (0..15).map(|_| "new").collect();
let old = old_lines.join("\n");
let new = new_lines.join("\n");
let diff = format_edit_diff(&old, &new);
assert!(diff.contains("more lines)"));
}
#[test]
fn test_format_edit_diff_empty_both() {
let diff = format_edit_diff("", "");
assert!(diff.is_empty());
}
#[test]
fn test_format_edit_diff_empty_old_text_new_file_section() {
let diff = format_edit_diff("", "fn new_function() {\n println!(\"hello\");\n}");
assert!(!diff.contains("- "));
assert!(diff.contains("+ fn new_function()"));
assert!(diff.contains("+ }"));
}
#[test]
fn test_format_edit_diff_short_diff_not_truncated() {
let diff = format_edit_diff("a", "b");
assert!(!diff.contains("more lines"));
}
#[test]
fn test_format_tool_summary_write_file_with_content() {
let args = serde_json::json!({"path": "out.txt", "content": "line1\nline2\nline3"});
let result = format_tool_summary("write_file", &args);
assert_eq!(result, "write out.txt (3 lines)");
}
#[test]
fn test_format_tool_summary_write_file_single_line() {
let args = serde_json::json!({"path": "out.txt", "content": "hello"});
let result = format_tool_summary("write_file", &args);
assert_eq!(result, "write out.txt (1 line)");
}
#[test]
fn test_format_tool_summary_write_file_no_content() {
let args = serde_json::json!({"path": "out.txt"});
let result = format_tool_summary("write_file", &args);
assert_eq!(result, "write out.txt");
}
#[test]
fn test_format_tool_summary_read_file_with_offset_and_limit() {
let args = serde_json::json!({"path": "src/main.rs", "offset": 10, "limit": 50});
let result = format_tool_summary("read_file", &args);
assert_eq!(result, "read src/main.rs:10..60");
}
#[test]
fn test_format_tool_summary_read_file_with_offset_only() {
let args = serde_json::json!({"path": "src/main.rs", "offset": 100});
let result = format_tool_summary("read_file", &args);
assert_eq!(result, "read src/main.rs:100..");
}
#[test]
fn test_format_tool_summary_read_file_with_limit_only() {
let args = serde_json::json!({"path": "src/main.rs", "limit": 25});
let result = format_tool_summary("read_file", &args);
assert_eq!(result, "read src/main.rs (25 lines)");
}
#[test]
fn test_format_tool_summary_read_file_no_extras() {
let args = serde_json::json!({"path": "src/main.rs"});
let result = format_tool_summary("read_file", &args);
assert_eq!(result, "read src/main.rs");
}
#[test]
fn test_format_tool_summary_edit_file_with_text() {
let args = serde_json::json!({
"path": "foo.rs",
"old_text": "fn old() {\n}\n",
"new_text": "fn new() {\n // improved\n do_stuff();\n}\n"
});
let result = format_tool_summary("edit_file", &args);
assert_eq!(result, "edit foo.rs (2 → 4 lines)");
}
#[test]
fn test_format_tool_summary_edit_file_no_text() {
let args = serde_json::json!({"path": "foo.rs"});
let result = format_tool_summary("edit_file", &args);
assert_eq!(result, "edit foo.rs");
}
#[test]
fn test_format_tool_summary_edit_file_same_lines() {
let args = serde_json::json!({
"path": "foo.rs",
"old_text": "let x = 1;",
"new_text": "let x = 2;"
});
let result = format_tool_summary("edit_file", &args);
assert_eq!(result, "edit foo.rs (1 → 1 lines)");
}
#[test]
fn test_format_tool_summary_search_with_path() {
let args = serde_json::json!({"pattern": "TODO", "path": "src/"});
let result = format_tool_summary("search", &args);
assert_eq!(result, "search 'TODO' in src/");
}
#[test]
fn test_format_tool_summary_search_with_include() {
let args = serde_json::json!({"pattern": "fn main", "include": "*.rs"});
let result = format_tool_summary("search", &args);
assert_eq!(result, "search 'fn main' (*.rs)");
}
#[test]
fn test_format_tool_summary_search_with_path_and_include() {
let args = serde_json::json!({"pattern": "test", "path": "src/", "include": "*.rs"});
let result = format_tool_summary("search", &args);
assert_eq!(result, "search 'test' in src/ (*.rs)");
}
#[test]
fn test_format_tool_summary_search_pattern_only() {
let args = serde_json::json!({"pattern": "TODO"});
let result = format_tool_summary("search", &args);
assert_eq!(result, "search 'TODO'");
}
#[test]
fn test_format_tool_summary_list_files_with_pattern() {
let args = serde_json::json!({"path": "src/", "pattern": "*.rs"});
let result = format_tool_summary("list_files", &args);
assert_eq!(result, "ls src/ (*.rs)");
}
#[test]
fn test_format_tool_summary_list_files_pattern_no_path() {
let args = serde_json::json!({"pattern": "*.toml"});
let result = format_tool_summary("list_files", &args);
assert_eq!(result, "ls . (*.toml)");
}
#[test]
fn test_format_tool_summary_bash_multiline_shows_first_line() {
let args = serde_json::json!({"command": "cd src\ngrep -r 'test' ."});
let result = format_tool_summary("bash", &args);
assert!(
result.starts_with("$ cd src"),
"Should show first line: {result}"
);
assert!(
result.contains("(2 lines)"),
"Should indicate line count: {result}"
);
}
#[test]
fn test_truncate_tool_output_under_threshold_unchanged() {
let short = "hello world\nsecond line\nthird line";
let result = truncate_tool_output(short, 30_000);
assert_eq!(result, short);
}
#[test]
fn test_truncate_tool_output_empty_string() {
let result = truncate_tool_output("", 30_000);
assert_eq!(result, "");
}
#[test]
fn test_truncate_tool_output_exactly_at_threshold() {
let lines: Vec<String> = (0..300)
.map(|i| format!("L{i} {}", "x".repeat(100)))
.collect();
let output = lines.join("\n");
let result = truncate_tool_output(&output, output.len());
assert_eq!(result, output);
}
#[test]
fn test_truncate_tool_output_over_threshold_has_marker() {
let line = "x".repeat(200);
let lines: Vec<String> = (0..200).map(|i| format!("line{i}: {line}")).collect();
let output = lines.join("\n");
assert!(output.len() > 30_000);
let result = truncate_tool_output(&output, 30_000);
assert!(result.contains("[... truncated"));
assert!(result.contains("lines ...]"));
assert!(result.contains("line0:"));
assert!(result.contains("line99:"));
assert!(result.contains("line199:"));
assert!(result.contains("line150:"));
assert!(!result.contains("line100:"));
assert!(!result.contains("line120:"));
}
#[test]
fn test_truncate_tool_output_preserves_head_and_tail_count() {
let lines: Vec<String> = (0..300).map(|i| format!("U{i} {:>200}", i)).collect();
let output = lines.join("\n");
let result = truncate_tool_output(&output, 30_000);
let _result_lines: Vec<&str> = result.lines().collect();
for i in 0..100 {
let expected = format!("U{i} {:>200}", i);
assert!(result.contains(&expected), "Missing head line {i}");
}
for i in 250..300 {
let expected = format!("U{i} {:>200}", i);
assert!(result.contains(&expected), "Missing tail line {i}");
}
assert!(!result.contains(&format!("U150 {:>200}", 150)));
assert!(result.contains("[... truncated 150 lines ...]"));
assert!(result.len() < output.len());
}
#[test]
fn test_truncate_tool_output_few_long_lines_not_truncated() {
let lines: Vec<String> = (0..140)
.map(|i| format!("L{i} {}", "x".repeat(500)))
.collect();
let output = lines.join("\n");
assert!(output.len() > 30_000);
let result = truncate_tool_output(&output, 30_000);
assert_eq!(
result, output,
"Too few lines to truncate, should be unchanged"
);
}
#[test]
fn test_truncate_tool_output_single_truncated_line_in_marker() {
let lines: Vec<String> = (0..151)
.map(|i| format!("L{i} {}", "x".repeat(300)))
.collect();
let output = lines.join("\n");
assert!(output.len() > 30_000);
let result = truncate_tool_output(&output, 30_000);
assert!(result.contains("[... truncated 1 line ...]"));
}
#[test]
fn test_truncate_tool_output_default_threshold_constant() {
assert_eq!(TOOL_OUTPUT_MAX_CHARS, 30_000);
}
#[test]
fn test_tool_output_max_chars_piped_smaller() {
const _: () = assert!(TOOL_OUTPUT_MAX_CHARS_PIPED < TOOL_OUTPUT_MAX_CHARS);
}
#[test]
fn test_tool_output_max_chars_piped_value() {
assert_eq!(TOOL_OUTPUT_MAX_CHARS_PIPED, 15_000);
}
#[test]
fn test_truncate_tool_output_with_custom_limit() {
let output = (0..200)
.map(|i| format!("W{i} data"))
.collect::<Vec<_>>()
.join("\n");
let result = truncate_tool_output(&output, 100);
assert!(
result.contains("[... truncated"),
"Should be truncated with 100-char limit, got length {}",
result.len()
);
}
#[test]
fn test_truncate_tool_output_respects_limit_parameter() {
let output = (0..200)
.map(|i| format!("R{i} data"))
.collect::<Vec<_>>()
.join("\n");
let large_limit_result = truncate_tool_output(&output, 1_000_000);
let small_limit_result = truncate_tool_output(&output, 100);
assert_eq!(
large_limit_result, output,
"Large limit should return output unchanged"
);
assert_ne!(
small_limit_result, output,
"Small limit should truncate the output"
);
}
#[test]
fn test_decode_html_entities_named() {
assert_eq!(decode_html_entities("&"), "&");
assert_eq!(decode_html_entities("<"), "<");
assert_eq!(decode_html_entities(">"), ">");
assert_eq!(decode_html_entities("""), "\"");
assert_eq!(decode_html_entities("'"), "'");
assert_eq!(decode_html_entities("'"), "'");
assert_eq!(decode_html_entities(" "), " ");
assert_eq!(decode_html_entities("'"), "'");
assert_eq!(decode_html_entities("—"), "—");
assert_eq!(decode_html_entities("–"), "–");
assert_eq!(decode_html_entities("…"), "…");
assert_eq!(decode_html_entities("©"), "©");
assert_eq!(decode_html_entities("®"), "®");
}
#[test]
fn test_decode_html_entities_numeric_decimal() {
assert_eq!(decode_html_entities("A"), "A");
assert_eq!(decode_html_entities("—"), "—");
}
#[test]
fn test_decode_html_entities_numeric_hex() {
assert_eq!(decode_html_entities("A"), "A");
assert_eq!(decode_html_entities("—"), "—");
}
#[test]
fn test_decode_html_entities_mixed() {
assert_eq!(
decode_html_entities("hello & world <3 — done"),
"hello & world <3 — done"
);
}
#[test]
fn test_decode_html_entities_no_entities() {
assert_eq!(decode_html_entities("plain text"), "plain text");
}
#[test]
fn test_decode_html_entities_invalid_numeric() {
assert_eq!(decode_html_entities("&#xZZZZ;"), "&#xZZZZ;");
assert_eq!(decode_html_entities("&#abc;"), "&#abc;");
}
#[test]
fn test_decode_html_entities_incomplete() {
assert_eq!(decode_html_entities("a & b"), "a & b");
}
#[test]
fn test_section_header_contains_label_and_line_chars() {
let header = section_header("Thinking");
assert!(
header.contains("Thinking"),
"header should contain the label"
);
assert!(
header.contains("─"),
"header should contain box-drawing chars"
);
}
#[test]
fn test_section_header_empty_label_produces_divider() {
let header = section_header("");
let divider = section_divider();
assert_eq!(header, divider);
}
#[test]
fn test_section_divider_nonempty_with_line_chars() {
let divider = section_divider();
assert!(!divider.is_empty(), "divider should not be empty");
assert!(
divider.contains("─"),
"divider should contain box-drawing chars"
);
}
#[test]
fn test_section_header_no_color() {
let header = section_header("Tools");
assert!(header.contains("Tools"));
assert!(header.contains("─"));
}
#[test]
fn test_section_divider_no_color() {
let divider = section_divider();
assert!(divider.contains("─"));
}
#[test]
fn test_terminal_width_default() {
let width = terminal_width();
assert!(width > 0, "terminal width should be positive");
}
#[test]
fn test_section_header_with_various_labels() {
for label in &[
"Thinking",
"Response",
"A",
"Very Long Section Label For Testing",
] {
let header = section_header(label);
assert!(header.contains(label), "header should contain '{}'", label);
assert!(header.contains("──"), "header should have line prefix");
}
}
#[test]
fn test_tool_batch_summary_single_tool_returns_empty() {
let result = format_tool_batch_summary(1, 1, 0, Duration::from_millis(500));
assert!(
result.is_empty(),
"single tool batch should not produce summary"
);
}
#[test]
fn test_tool_batch_summary_zero_tools_returns_empty() {
let result = format_tool_batch_summary(0, 0, 0, Duration::from_millis(0));
assert!(result.is_empty(), "zero tools should not produce summary");
}
#[test]
fn test_tool_batch_summary_all_succeed() {
let result = format_tool_batch_summary(3, 3, 0, Duration::from_millis(1200));
assert!(result.contains("3 tools"), "should show tool count");
assert!(result.contains("1.2s"), "should show duration");
assert!(result.contains("3"), "should show success count");
assert!(result.contains("✓"), "should show success marker");
assert!(
!result.contains("✗"),
"should not show failure marker when all succeed"
);
}
#[test]
fn test_tool_batch_summary_with_failures() {
let result = format_tool_batch_summary(4, 3, 1, Duration::from_millis(2500));
assert!(result.contains("4 tools"), "should show total count");
assert!(result.contains("2.5s"), "should show duration");
assert!(result.contains("3"), "should show success count");
assert!(result.contains("✓"), "should show success marker");
assert!(result.contains("1"), "should show failure count");
assert!(result.contains("✗"), "should show failure marker");
}
#[test]
fn test_tool_batch_summary_two_tools_plural() {
let result = format_tool_batch_summary(2, 2, 0, Duration::from_millis(800));
assert!(result.contains("2 tools"), "should pluralize 'tools'");
assert!(result.contains("800ms"), "should show ms for sub-second");
}
#[test]
fn test_indent_tool_output_empty() {
assert_eq!(indent_tool_output(""), "");
}
#[test]
fn test_indent_tool_output_single_line() {
let result = indent_tool_output("hello world");
assert!(result.contains("│"), "should have indent marker");
assert!(result.contains("hello world"), "should preserve content");
}
#[test]
fn test_indent_tool_output_multiline() {
let result = indent_tool_output("line 1\nline 2\nline 3");
let lines: Vec<&str> = result.lines().collect();
assert_eq!(lines.len(), 3, "should preserve line count");
for line in &lines {
assert!(line.contains("│"), "each line should have indent marker");
}
assert!(lines[0].contains("line 1"));
assert!(lines[1].contains("line 2"));
assert!(lines[2].contains("line 3"));
}
#[test]
fn test_turn_boundary_contains_number() {
let result = turn_boundary(1);
assert!(result.contains("Turn 1"), "should show turn number");
assert!(result.contains("╭"), "should have box-drawing start");
assert!(result.contains("╮"), "should have box-drawing end");
}
#[test]
fn test_turn_boundary_different_numbers() {
for n in [1, 5, 10, 99] {
let result = turn_boundary(n);
assert!(
result.contains(&format!("Turn {n}")),
"should contain Turn {n}"
);
}
}
#[test]
fn test_turn_boundary_has_fill_characters() {
let result = turn_boundary(1);
assert!(result.contains("─"), "should have fill characters");
}
#[test]
fn test_bell_enabled_default() {
let _result = bell_enabled();
}
#[test]
fn test_maybe_ring_bell_short_duration_no_bell() {
maybe_ring_bell(Duration::from_secs(0));
maybe_ring_bell(Duration::from_secs(1));
maybe_ring_bell(Duration::from_secs(2));
}
#[test]
fn test_maybe_ring_bell_long_duration_no_panic() {
maybe_ring_bell(Duration::from_secs(3));
maybe_ring_bell(Duration::from_secs(60));
}
#[test]
fn test_format_usage_compact() {
let usage = yoagent::Usage {
input: 1119,
output: 47,
cache_read: 0,
cache_write: 0,
total_tokens: 0,
};
let total = yoagent::Usage {
input: 1119,
output: 47,
cache_read: 0,
cache_write: 0,
total_tokens: 0,
};
let elapsed = Duration::from_secs_f64(1.0);
let line = format_usage_line(&usage, &total, "claude-sonnet-4-20250514", elapsed, false)
.expect("should produce output");
assert!(line.starts_with("↳ 1.0s"), "got: {line}");
assert!(line.contains("1119→47 tokens"), "got: {line}");
assert!(!line.contains("session:"), "got: {line}");
assert!(!line.contains("in /"), "got: {line}");
}
#[test]
fn test_format_usage_verbose() {
let usage = yoagent::Usage {
input: 500,
output: 100,
cache_read: 0,
cache_write: 0,
total_tokens: 0,
};
let total = yoagent::Usage {
input: 2000,
output: 400,
cache_read: 0,
cache_write: 0,
total_tokens: 0,
};
let elapsed = Duration::from_secs(3);
let line = format_usage_line(&usage, &total, "claude-sonnet-4-20250514", elapsed, true)
.expect("should produce output");
assert!(line.contains("tokens: 500 in / 100 out"), "got: {line}");
assert!(line.contains("session: 2000 in / 400 out"), "got: {line}");
assert!(line.contains("⏱"), "got: {line}");
}
#[test]
fn test_format_usage_zero_tokens_returns_none() {
let usage = yoagent::Usage {
input: 0,
output: 0,
cache_read: 0,
cache_write: 0,
total_tokens: 0,
};
let total = usage.clone();
let elapsed = Duration::from_secs(1);
assert!(
format_usage_line(&usage, &total, "claude-sonnet-4-20250514", elapsed, false).is_none()
);
assert!(
format_usage_line(&usage, &total, "claude-sonnet-4-20250514", elapsed, true).is_none()
);
}
#[test]
fn test_format_usage_verbose_with_cache() {
let usage = yoagent::Usage {
input: 1000,
output: 200,
cache_read: 500,
cache_write: 100,
total_tokens: 0,
};
let total = usage.clone();
let elapsed = Duration::from_secs(2);
let line = format_usage_line(&usage, &total, "claude-sonnet-4-20250514", elapsed, true)
.expect("should produce output");
assert!(line.contains("[cache: 500 read, 100 write]"), "got: {line}");
}
#[test]
fn test_format_usage_compact_includes_cost() {
let usage = yoagent::Usage {
input: 1_000_000,
output: 1000,
cache_read: 0,
cache_write: 0,
total_tokens: 0,
};
let total = usage.clone();
let elapsed = Duration::from_secs(5);
let line = format_usage_line(&usage, &total, "claude-sonnet-4-20250514", elapsed, false)
.expect("should produce output");
assert!(line.contains(" · $"), "compact should include cost: {line}");
}
#[test]
fn test_format_usage_compact_unknown_model_no_cost() {
let usage = yoagent::Usage {
input: 100,
output: 50,
cache_read: 0,
cache_write: 0,
total_tokens: 0,
};
let total = usage.clone();
let elapsed = Duration::from_millis(500);
let line = format_usage_line(&usage, &total, "unknown-model-xyz", elapsed, false)
.expect("should produce output");
assert!(
!line.contains("$"),
"unknown model should have no cost: {line}"
);
assert!(line.contains("100→50 tokens"), "got: {line}");
}
#[test]
fn test_context_usage_color_green_at_zero() {
let color = context_usage_color(0);
assert_eq!(color.0, GREEN.0);
}
#[test]
fn test_context_usage_color_green_at_50() {
let color = context_usage_color(50);
assert_eq!(color.0, GREEN.0);
}
#[test]
fn test_context_usage_color_yellow_at_51() {
let color = context_usage_color(51);
assert_eq!(color.0, YELLOW.0);
}
#[test]
fn test_context_usage_color_yellow_at_80() {
let color = context_usage_color(80);
assert_eq!(color.0, YELLOW.0);
}
#[test]
fn test_context_usage_color_red_at_81() {
let color = context_usage_color(81);
assert_eq!(color.0, RED.0);
}
#[test]
fn test_context_usage_color_red_at_100() {
let color = context_usage_color(100);
assert_eq!(color.0, RED.0);
}
#[test]
fn test_compress_strips_ansi_codes() {
let input = "\x1b[31merror\x1b[0m: something \x1b[1;33mwent\x1b[0m wrong";
let result = compress_tool_output(input);
assert_eq!(result, "error: something went wrong");
assert!(!result.contains("\x1b"));
}
#[test]
fn test_compress_strips_various_ansi_sequences() {
let input = "\x1b[32mgreen\x1b[0m \x1b[2Kclear \x1b[1Aup \x1b[38;5;196mcolor256\x1b[0m";
let result = compress_tool_output(input);
assert!(!result.contains("\x1b"), "still has ANSI: {result}");
assert!(result.contains("green"));
assert!(result.contains("color256"));
}
#[test]
fn test_compress_collapses_repetitive_lines() {
let mut lines = Vec::new();
for i in 0..10 {
lines.push(format!(" Compiling foo-{i} v1.0.{i}"));
}
let input = lines.join("\n");
let result = compress_tool_output(&input);
let result_lines: Vec<&str> = result.lines().collect();
assert_eq!(result_lines.len(), 3, "got: {result}");
assert!(
result_lines[0].contains("foo-0"),
"first: {}",
result_lines[0]
);
assert!(
result_lines[1].contains("8 more similar"),
"marker: {}",
result_lines[1]
);
assert!(
result_lines[2].contains("foo-9"),
"last: {}",
result_lines[2]
);
}
#[test]
fn test_compress_preserves_non_repetitive_output() {
let input = "line one\nline two\nline three\nsomething different";
let result = compress_tool_output(input);
assert_eq!(result, input);
}
#[test]
fn test_compress_short_output_unchanged() {
let input = " Compiling a v1.0\n Compiling b v1.0\n Compiling c v1.0";
let result = compress_tool_output(input);
assert_eq!(result, input);
}
#[test]
fn test_compress_mixed_repetitive_blocks() {
let mut lines = Vec::new();
for i in 0..5 {
lines.push(format!(" Compiling crate-{i} v0.1.0"));
}
lines.push("warning: unused variable".to_string());
lines.push(" --> src/main.rs:10:5".to_string());
for i in 0..6 {
lines.push(format!(" Downloading dep-{i} v2.0.0"));
}
let input = lines.join("\n");
let result = compress_tool_output(&input);
assert!(
result.contains("3 more similar"),
"compiling block: {result}"
);
assert!(
result.contains("4 more similar"),
"downloading block: {result}"
);
assert!(result.contains("warning: unused variable"));
assert!(result.contains("--> src/main.rs:10:5"));
}
#[test]
fn test_truncate_uses_compression() {
let input = "\x1b[32mhello\x1b[0m world";
let result = truncate_tool_output(input, 100_000);
assert!(!result.contains("\x1b"), "ANSI not stripped: {result}");
assert!(result.contains("hello world"));
}
#[test]
fn test_compress_exact_threshold_four_lines() {
let input = " Compiling a v1\n Compiling b v1\n Compiling c v1\n Compiling d v1";
let result = compress_tool_output(input);
let result_lines: Vec<&str> = result.lines().collect();
assert_eq!(result_lines.len(), 3, "got: {result}");
assert!(result_lines[1].contains("2 more similar"));
}
#[test]
fn test_compress_empty_input() {
assert_eq!(compress_tool_output(""), "");
}
#[test]
fn test_compress_pip_install_pattern() {
let mut lines = Vec::new();
for i in 0..8 {
lines.push(format!("Installing package-{i}==1.0.{i}"));
}
let input = lines.join("\n");
let result = compress_tool_output(&input);
let result_lines: Vec<&str> = result.lines().collect();
assert_eq!(result_lines.len(), 3, "got: {result}");
assert!(result_lines[1].contains("6 more similar"));
}
#[test]
fn test_strip_ansi_preserves_multibyte_utf8() {
let input = "\x1b[32m✓\x1b[0m passed: 日本語テスト";
let result = strip_ansi_codes(input);
assert_eq!(result, "✓ passed: 日本語テスト");
}
#[test]
fn test_strip_ansi_preserves_emoji() {
let input = "\x1b[1m🦀 Rust\x1b[0m is 🔥";
let result = strip_ansi_codes(input);
assert_eq!(result, "🦀 Rust is 🔥");
}
#[test]
fn test_strip_ansi_preserves_accented_chars() {
let input = "\x1b[33mcafé\x1b[0m résumé";
let result = strip_ansi_codes(input);
assert_eq!(result, "café résumé");
}
#[test]
fn test_compress_multibyte_content() {
let input = "\x1b[32m✓\x1b[0m テスト完了";
let result = compress_tool_output(input);
assert_eq!(result, "✓ テスト完了");
}
#[test]
fn test_line_category_multibyte_prefix() {
let line = "日本語テストの結";
let _cat = line_category(line); }
#[test]
fn test_line_category_multibyte_short_word() {
let line = "café something";
let cat = line_category(line);
assert_eq!(cat, "café");
}
#[test]
fn test_collapse_repetitive_multibyte_lines() {
let mut lines = Vec::new();
for i in 0..6 {
lines.push(format!("コンパイル中 パッケージ-{i} v1.0"));
}
let input = lines.join("\n");
let result = collapse_repetitive_lines(&input);
let result_lines: Vec<&str> = result.lines().collect();
assert_eq!(result_lines.len(), 3, "got: {result}");
assert!(result_lines[1].contains("4 more similar"));
}
}