use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use serde_json::Value;
use unicode_width::UnicodeWidthStr;
use crate::text::truncate_to_width;
use crate::theme::ThemeStyles;
const RESULT_PREVIEW_LINES: usize = 5;
const DIFF_PREVIEW_LINES: usize = 8;
const READ_PREVIEW_LINES: usize = 4;
const GENERIC_PREVIEW_LINES: usize = 4;
pub fn shorten_path(path: &str) -> String {
if let Some(home) = dirs::home_dir() {
let home_str: String = home.to_string_lossy().into_owned();
if let Some(rest) = path.strip_prefix(&*home_str) {
return format!("~{}", rest);
}
}
path.to_string()
}
pub fn parse_tool_args(arguments: &str) -> Value {
serde_json::from_str(arguments).unwrap_or(Value::Null)
}
pub fn get_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
args.get(key)?.as_str()
}
pub fn get_path(args: &Value) -> Option<String> {
args.get("path")
.or_else(|| args.get("file_path"))
.and_then(|v| v.as_str())
.map(shorten_path)
}
pub fn get_int(args: &Value, key: &str) -> Option<i64> {
args.get(key)?.as_i64()
}
pub fn looks_like_diff(text: &str) -> bool {
text.lines().take(5).any(|l| {
l.starts_with("@@ -")
|| (l.starts_with('-') && !l.starts_with("--"))
|| (l.starts_with('+') && !l.starts_with("++"))
})
}
pub fn has_exit_status(text: &str) -> bool {
text.contains("Command exited with code")
|| text.contains("Command timed out")
|| text.contains("Command aborted")
}
pub fn count_diff_stats(diff: &str) -> (u32, u32) {
let mut added = 0u32;
let mut removed = 0u32;
for line in diff.lines() {
if line.starts_with('+') && !line.starts_with("++") {
added += 1;
} else if line.starts_with('-') && !line.starts_with("--") {
removed += 1;
}
}
(added, removed)
}
fn format_call_header(name: &str, extra: &str, styles: &ThemeStyles) -> Line<'static> {
let name_style = styles.accent.add_modifier(Modifier::BOLD);
let extra_style = styles.muted;
Line::from(vec![
Span::styled(format!("{} ", name), name_style),
Span::styled(extra.to_string(), extra_style),
])
}
pub fn format_edit_call(args: &Value, styles: &ThemeStyles) -> Vec<Line<'static>> {
let path = get_path(args);
let path_display = path.unwrap_or_else(|| "?".to_string());
let edit_count = args
.get("edits")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or_else(|| {
if args.get("oldText").or(args.get("oldText")).is_some() {
1
} else {
0
}
});
let extra = if edit_count > 0 {
format!(
"{} ({} replacement{})",
path_display,
edit_count,
if edit_count == 1 { "" } else { "s" }
)
} else {
path_display
};
vec![format_call_header("edit", &extra, styles)]
}
pub fn format_bash_call(
args: &Value,
max_width: usize,
styles: &ThemeStyles,
) -> Vec<Line<'static>> {
let command = get_str(args, "command").unwrap_or("...");
let command_display = truncate_to_width(command, max_width.saturating_sub(2).max(20));
let mut lines = vec![Line::from(vec![
Span::styled("$ ", styles.accent.add_modifier(Modifier::BOLD)),
Span::styled(command_display, styles.normal),
])];
if let Some(timeout) = get_int(args, "timeout") {
lines.push(Line::from(Span::styled(
format!(" (timeout {}s)", timeout),
styles.muted,
)));
}
lines
}
pub fn format_read_call(args: &Value, styles: &ThemeStyles) -> Vec<Line<'static>> {
let path = get_path(args).unwrap_or_else(|| "?".to_string());
let offset = get_int(args, "offset");
let limit = get_int(args, "limit");
let extra = match (offset, limit) {
(Some(o), Some(l)) => format!("{}:{}-{}", path, o, o + l - 1),
(Some(o), None) => format!("{}:{}", path, o),
(None, Some(l)) => format!("{}:1-{}", path, l),
(None, None) => path,
};
vec![format_call_header("read", &extra, styles)]
}
pub fn format_write_call(args: &Value, styles: &ThemeStyles) -> Vec<Line<'static>> {
let path = get_path(args).unwrap_or_else(|| "?".to_string());
let extra = format!("{} (new)", path);
vec![format_call_header("write", &extra, styles)]
}
pub fn format_search_call(name: &str, args: &Value, styles: &ThemeStyles) -> Vec<Line<'static>> {
let icon = match name {
"grep" => "[G]",
"find" => "[F]",
"ls" => "[D]",
_ => "○",
};
let path = get_path(args).unwrap_or_else(|| ".".to_string());
let pattern = get_str(args, "pattern")
.or_else(|| get_str(args, "query"))
.map(|p| format!(" \"{}\"", truncate_to_width(p, 30)))
.unwrap_or_default();
let extra = format!("{}{} {}", icon, pattern, path);
vec![Line::from(vec![
Span::styled(
format!("{} ", name),
styles.accent.add_modifier(Modifier::BOLD),
),
Span::styled(extra, styles.muted),
])]
}
pub fn format_generic_call(
name: &str,
args: &Value,
max_width: usize,
styles: &ThemeStyles,
) -> Vec<Line<'static>> {
let name_style = styles.accent.add_modifier(Modifier::BOLD);
let mut lines = vec![Line::from(vec![Span::styled(
format!("{} ", name),
name_style,
)])];
if let Some(obj) = args.as_object() {
for (key, v) in obj.iter().take(3) {
let val_str = match v {
Value::String(s) => s.clone(),
other => other.to_string(),
};
let prefix_len = 2 + UnicodeWidthStr::width(key.as_str()) + 2;
let avail = max_width.saturating_sub(prefix_len);
let display = truncate_to_width(&val_str, avail);
lines.push(Line::from(vec![
Span::styled(format!(" {}", key), styles.muted),
Span::styled(": ", styles.muted),
Span::styled(display, styles.normal),
]));
}
}
lines
}
pub fn format_tool_call(
name: &str,
arguments: &str,
max_width: usize,
styles: &ThemeStyles,
) -> Vec<Line<'static>> {
let args = parse_tool_args(arguments);
match name {
"edit" => format_edit_call(&args, styles),
"bash" => format_bash_call(&args, max_width, styles),
"read" => format_read_call(&args, styles),
"write" => format_write_call(&args, styles),
"grep" | "find" | "ls" => format_search_call(name, &args, styles),
_ => format_generic_call(name, &args, max_width, styles),
}
}
pub fn format_error_result(
error: &str,
max_width: usize,
styles: &ThemeStyles,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for line in error.lines().take(4) {
let display = truncate_to_width(line, max_width);
lines.push(Line::from(Span::styled(
format!(" {}", display),
styles.error,
)));
}
if error.lines().count() > 4 {
lines.push(Line::from(Span::styled(" \u{2026}", styles.muted)));
}
lines
}
pub fn format_diff_result(
diff: &str,
max_width: usize,
styles: &ThemeStyles,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let total_lines = diff.lines().count();
for raw_line in diff.lines().take(DIFF_PREVIEW_LINES) {
let line = if raw_line.starts_with("@@") {
Line::from(Span::styled(
truncate_to_width(raw_line, max_width),
styles.muted,
))
} else if raw_line.starts_with('-') && !raw_line.starts_with("--") {
Line::from(Span::styled(
format!(
" {}",
truncate_to_width(raw_line, max_width.saturating_sub(1))
),
styles.error,
))
} else if raw_line.starts_with('+') && !raw_line.starts_with("++") {
Line::from(Span::styled(
format!(
" {}",
truncate_to_width(raw_line, max_width.saturating_sub(1))
),
styles.success,
))
} else {
Line::from(Span::styled(
format!(
" {}",
truncate_to_width(raw_line, max_width.saturating_sub(1))
),
styles.muted,
))
};
lines.push(line);
}
let (added, removed) = count_diff_stats(diff);
if total_lines > DIFF_PREVIEW_LINES {
lines.push(Line::from(Span::styled(
format!(
" \u{2026} ({} more lines)",
total_lines - DIFF_PREVIEW_LINES
),
styles.muted,
)));
} else if added > 0 || removed > 0 {
lines.push(Line::from(Span::styled(
format!(" [+{} / -{}]", added, removed),
styles.muted,
)));
}
lines
}
pub fn format_bash_result(
result: &str,
max_width: usize,
styles: &ThemeStyles,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let all_lines: Vec<&str> = result.lines().collect();
let _preview_lines = if all_lines.len() > RESULT_PREVIEW_LINES {
let start = all_lines.len() - RESULT_PREVIEW_LINES;
for line in &all_lines[start..] {
let display = truncate_to_width(line, max_width.saturating_sub(2));
lines.push(Line::from(Span::styled(
format!(" {}", display),
styles.normal,
)));
}
if start > 0 {
lines.insert(
0,
Line::from(Span::styled(
format!(" … ({} earlier lines)", start),
styles.muted,
)),
);
}
all_lines.len()
} else {
for line in &all_lines {
let display = truncate_to_width(line, max_width.saturating_sub(2));
lines.push(Line::from(Span::styled(
format!(" {}", display),
styles.normal,
)));
}
all_lines.len()
};
lines
}
pub fn format_read_result(
result: &str,
max_width: usize,
styles: &ThemeStyles,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let all_lines: Vec<&str> = result.lines().collect();
let total = all_lines.len();
for (i, line) in all_lines.iter().take(READ_PREVIEW_LINES).enumerate() {
let line_num = i + 1;
let display = truncate_to_width(line, max_width.saturating_sub(10));
lines.push(Line::from(vec![
Span::styled(format!("{:4} ", line_num), styles.muted),
Span::styled(display, styles.normal),
]));
}
if total > READ_PREVIEW_LINES {
let remaining = total - READ_PREVIEW_LINES;
lines.push(Line::from(Span::styled(
format!(" \u{2026} ({} more lines, {} total)", remaining, total),
styles.muted,
)));
} else if total > 0 {
lines.push(Line::from(Span::styled(
format!(" ({} lines)", total),
styles.muted,
)));
}
lines
}
pub fn format_generic_result(
result: &str,
max_width: usize,
styles: &ThemeStyles,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let all_lines: Vec<&str> = result.lines().collect();
for line in all_lines.iter().take(GENERIC_PREVIEW_LINES) {
let display = truncate_to_width(line, max_width.saturating_sub(2));
lines.push(Line::from(Span::styled(
format!(" {}", display),
styles.normal,
)));
}
if all_lines.len() > GENERIC_PREVIEW_LINES {
lines.push(Line::from(Span::styled(
format!(
" \u{2026} ({} more lines)",
all_lines.len() - GENERIC_PREVIEW_LINES
),
styles.muted,
)));
}
lines
}
pub fn format_tool_result(
name: &str,
result: &str,
is_error: bool,
max_width: usize,
styles: &ThemeStyles,
) -> Vec<Line<'static>> {
if is_error {
return format_error_result(result, max_width, styles);
}
if looks_like_diff(result) {
return format_diff_result(result, max_width, styles);
}
match name {
"edit" => format_diff_result(result, max_width, styles),
"bash" => format_bash_result(result, max_width, styles),
"read" => format_read_result(result, max_width, styles),
_ => format_generic_result(result, max_width, styles),
}
}
pub fn measure_call_height(name: &str, arguments: &str, max_width: usize) -> u16 {
let args = parse_tool_args(arguments);
match name {
"edit" => format_edit_call(&args, &ThemeStyles::default()).len() as u16,
"bash" => format_bash_call(&args, max_width, &ThemeStyles::default()).len() as u16,
"read" => 1, "write" => 1,
"grep" | "find" | "ls" => 1,
_ => {
let args_count = args.as_object().map(|o| o.len().min(3)).unwrap_or(0);
1 + args_count as u16
}
}
}
pub fn measure_result_height(name: &str, result: &str, is_error: bool) -> u16 {
if is_error {
let total = result.lines().count();
let shown = total.min(4);
return shown as u16 + if total > 4 { 1 } else { 0 };
}
if looks_like_diff(result) {
let total = result.lines().count();
let shown = total.min(DIFF_PREVIEW_LINES);
let extra = if total > DIFF_PREVIEW_LINES
|| count_diff_stats(result).0 > 0
|| count_diff_stats(result).1 > 0
{
1
} else {
0
};
return shown as u16 + extra;
}
match name {
"edit" => {
let total = result.lines().count();
let shown = total.min(DIFF_PREVIEW_LINES);
let has_more = total > DIFF_PREVIEW_LINES;
let has_stats =
!has_more && (count_diff_stats(result).0 > 0 || count_diff_stats(result).1 > 0);
let extra = if has_more || has_stats { 1 } else { 0 };
shown as u16 + extra
}
"bash" => {
let total = result.lines().count();
let shown = total.min(RESULT_PREVIEW_LINES);
let extra = if total > RESULT_PREVIEW_LINES { 1 } else { 0 };
shown as u16 + extra
}
"read" => {
let total = result.lines().count();
let shown = total.min(READ_PREVIEW_LINES);
let extra: u16 = 1; shown as u16 + extra
}
_ => {
let total = result.lines().count();
let shown = total.min(GENERIC_PREVIEW_LINES);
let extra = if total > GENERIC_PREVIEW_LINES { 1 } else { 0 };
shown as u16 + extra
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_shorten_path() {
let home = dirs::home_dir().unwrap().to_string_lossy().to_string();
let path = format!("{}/src/main.rs", home);
assert_eq!(shorten_path(&path), "~/src/main.rs");
}
#[test]
fn test_parse_args() {
let home = dirs::home_dir().unwrap();
let path = format!("{}/file.rs", home.to_string_lossy());
let args = format!(r#"{{"path":"{}","offset":10,"limit":50}}"#, path);
let parsed = parse_tool_args(&args);
assert_eq!(get_path(&parsed), Some("~/file.rs".to_string()));
assert_eq!(get_int(&parsed, "offset"), Some(10));
assert_eq!(get_int(&parsed, "limit"), Some(50));
}
#[test]
fn test_looks_like_diff() {
assert!(looks_like_diff("@@ -10,3 +10,4 @@\n-old\n+new"));
assert!(looks_like_diff("-removed\n+added"));
assert!(!looks_like_diff("hello world\njust text"));
}
#[test]
fn test_count_diff_stats() {
let diff = "-removed\n-old\n+added\n+new";
let (added, removed) = count_diff_stats(diff);
assert_eq!(added, 2);
assert_eq!(removed, 2);
}
#[test]
fn test_truncate_to_width() {
let text = "hello world";
assert_eq!(truncate_to_width(text, 100), "hello world");
assert_eq!(truncate_to_width(text, 5), "hell…");
assert_eq!(truncate_to_width(text, 0), "");
}
#[test]
fn test_format_edit_call() {
let args = serde_json::json!({
"path": "/home/user/src/main.rs",
"edits": [{"oldText": "foo", "newText": "bar"}]
});
let lines = format_edit_call(&args, &ThemeStyles::default());
assert!(lines[0].to_string().contains("edit"));
assert!(lines[0].to_string().contains("main.rs"));
}
#[test]
fn test_format_bash_call() {
let args = serde_json::json!({
"command": "cargo build --release",
"timeout": 120
});
let lines = format_bash_call(&args, 80, &ThemeStyles::default());
assert!(lines[0].to_string().contains("$ cargo build"));
}
#[test]
fn test_format_read_call() {
let args = serde_json::json!({
"path": "/home/user/src/main.rs",
"offset": 10,
"limit": 50
});
let lines = format_read_call(&args, &ThemeStyles::default());
assert!(lines[0].to_string().contains("read"));
assert!(lines[0].to_string().contains("main.rs:10-59"));
}
#[test]
fn test_format_diff_result() {
let diff = "@@ -10,3 +10,4 @@\n-old line\n context\n+new line\n+extra line";
let lines = format_diff_result(diff, 80, &ThemeStyles::default());
assert!(!lines.is_empty());
}
#[test]
fn test_format_bash_result() {
let output = "Compiling mycrate v0.1.0\nFinished release [optimized]";
let lines = format_bash_result(output, 80, &ThemeStyles::default());
assert!(!lines.is_empty());
assert!(lines[0].to_string().contains("Compiling"));
}
#[test]
fn test_format_read_result() {
let content = "use std::io;\n\nfn main() {\n println!(\"hello\");\n}\n";
let lines = format_read_result(content, 80, &ThemeStyles::default());
assert!(lines[0].to_string().contains("1"));
assert!(lines[0].to_string().contains("use std"));
}
#[test]
fn test_format_generic_call() {
let args = serde_json::json!({
"param1": "value1",
"param2": "value2"
});
let lines = format_generic_call("my_tool", &args, 80, &ThemeStyles::default());
assert!(!lines.is_empty());
assert!(lines[0].to_string().contains("my_tool"));
}
#[test]
fn test_has_exit_status() {
assert!(has_exit_status("Command exited with code 1"));
assert!(has_exit_status("Command timed out after 120 seconds"));
assert!(!has_exit_status("Everything is fine"));
}
}