use anyhow::Result;
use serde_json::Value;
use vtcode_commons::diff_paths::{language_hint_from_path, looks_like_diff_content};
use vtcode_commons::preview;
use vtcode_core::config::ToolOutputMode;
use vtcode_core::config::constants::tools;
use vtcode_core::utils::ansi::{AnsiRenderer, MessageStyle};
use super::streams::{build_markdown_code_block, render_diff_content_block};
use super::styles::{GitStyles, LsStyles};
pub(crate) use vtcode_commons::diff_preview::format_numbered_unified_diff as format_diff_content_lines_with_numbers;
const MAX_DISPLAYED_FILES: usize = 100;
fn get_string<'a>(val: &'a Value, key: &str) -> Option<&'a str> {
val.get(key).and_then(|v| v.as_str())
}
fn get_bool(val: &Value, key: &str) -> bool {
val.get(key).and_then(|v| v.as_bool()).unwrap_or(false)
}
fn get_u64(val: &Value, key: &str) -> Option<u64> {
val.get(key).and_then(|v| v.as_u64())
}
pub(crate) fn render_write_file_preview(
renderer: &mut AnsiRenderer,
payload: &Value,
git_styles: &GitStyles,
ls_styles: &LsStyles,
) -> Result<()> {
if get_bool(payload, "created") {
renderer.line(MessageStyle::ToolDetail, "File created")?;
}
if let Some(encoding) = get_string(payload, "encoding") {
renderer.line(MessageStyle::ToolDetail, &format!("encoding: {}", encoding))?;
}
let diff_value = match payload.get("diff_preview") {
Some(value) => value,
None => return Ok(()),
};
if get_bool(diff_value, "skipped") {
let reason = get_string(diff_value, "reason").unwrap_or("skipped");
if let Some(detail) = get_string(diff_value, "detail") {
renderer.line(
MessageStyle::ToolDetail,
&format!("diff: {} ({})", reason, detail),
)?;
} else {
renderer.line(MessageStyle::ToolDetail, &format!("diff: {}", reason))?;
}
return Ok(());
}
let diff_content = get_string(diff_value, "content").unwrap_or("");
if diff_content.is_empty() && get_bool(diff_value, "is_empty") {
renderer.line(MessageStyle::ToolDetail, "(no changes)")?;
return Ok(());
}
if !diff_content.is_empty() {
renderer.line(MessageStyle::ToolDetail, "")?;
render_diff_content(renderer, diff_content, git_styles, ls_styles)?;
}
if get_bool(diff_value, "truncated") {
if let Some(omitted) = get_u64(diff_value, "omitted_line_count") {
renderer.line(
MessageStyle::ToolDetail,
&format!("… +{} lines (use unified_file for full view)", omitted),
)?;
} else {
renderer.line(MessageStyle::ToolDetail, "… diff truncated")?;
}
}
Ok(())
}
pub(crate) fn render_list_dir_output(
renderer: &mut AnsiRenderer,
val: &Value,
_ls_styles: &LsStyles,
) -> Result<()> {
let count = get_u64(val, "count").unwrap_or(0);
let total = get_u64(val, "total").unwrap_or(0);
let page = get_u64(val, "page").unwrap_or(1);
let _has_more = get_bool(val, "has_more");
let per_page = get_u64(val, "per_page").unwrap_or(20);
if let Some(path) = get_string(val, "path") {
let display_path = if path.is_empty() { "/" } else { path };
renderer.line(
MessageStyle::ToolDetail,
&format!(
"{}{}",
display_path,
if !path.is_empty() { "/" } else { "" }
),
)?;
}
if count > 0 || total > 0 {
let start_idx = (page - 1) * per_page + 1;
let _end_idx = start_idx + count - 1;
let summary = if total > count {
format!("Showing {} of {} items", count, total)
} else {
format!("{} items total", count)
};
renderer.line(MessageStyle::ToolDetail, &summary)?;
}
if let Some(items) = val.get("items").and_then(|v| v.as_array()) {
if items.is_empty() {
renderer.line(MessageStyle::ToolDetail, "(empty)")?;
} else {
let mut directories = Vec::new();
let mut files = Vec::new();
for item in items.iter().take(MAX_DISPLAYED_FILES) {
if let Some(name) = get_string(item, "name") {
let item_type = get_string(item, "type").unwrap_or("file");
let size = get_u64(item, "size");
if item_type == "directory" {
directories.push((name.to_string(), size));
} else {
files.push((name.to_string(), size));
}
}
}
let sort_order = get_string(val, "sort").unwrap_or("name");
match sort_order {
"size" => {
directories.sort_by(|a, b| b.1.unwrap_or(0).cmp(&a.1.unwrap_or(0)));
files.sort_by(|a, b| b.1.unwrap_or(0).cmp(&a.1.unwrap_or(0)));
}
"name" => {
directories.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
files.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
}
"type" => {
directories.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
files.sort_by(|a, b| {
let ext_a = std::path::Path::new(&a.0)
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
let ext_b = std::path::Path::new(&b.0)
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
ext_a
.cmp(&ext_b)
.then(a.0.to_lowercase().cmp(&b.0.to_lowercase()))
});
}
_ => {
directories.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
files.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
}
}
let max_name_width = if !directories.is_empty() || !files.is_empty() {
let dir_max_width = directories
.iter()
.map(|(name, _)| preview::display_width(name) + 1) .max()
.unwrap_or(10)
.min(40);
let file_max_width = files
.iter()
.map(|(name, _)| preview::display_width(name))
.max()
.unwrap_or(10)
.min(40);
dir_max_width.max(file_max_width)
} else {
10 };
if !directories.is_empty() {
renderer.line(MessageStyle::ToolDetail, "[Directories]")?;
for (name, _size) in &directories {
let name_with_slash = format!("{}/", name);
let display =
preview::pad_to_display_width(&name_with_slash, max_name_width, ' ');
renderer.line(MessageStyle::ToolDetail, &display)?;
}
if !files.is_empty() {
renderer.line(MessageStyle::ToolDetail, "")?; }
}
if !files.is_empty() {
renderer.line(MessageStyle::ToolDetail, "[Files]")?;
for (name, _size) in &files {
let display = preview::pad_to_display_width(name, max_name_width, ' ');
renderer.line(MessageStyle::ToolDetail, &display)?;
}
}
let omitted = items.len().saturating_sub(MAX_DISPLAYED_FILES);
if omitted > 0 {
renderer.line(
MessageStyle::ToolDetail,
&format!("+ {} more items not shown", omitted),
)?;
}
}
}
Ok(())
}
pub(crate) fn render_read_file_output(renderer: &mut AnsiRenderer, val: &Value) -> Result<()> {
if let Some(items) = val.get("items").and_then(Value::as_array) {
let files_read = get_u64(val, "files_read").unwrap_or(items.len() as u64);
let files_ok = get_u64(val, "files_succeeded").unwrap_or(0);
let failed = files_read.saturating_sub(files_ok);
let mut summary = format!(
"{} file{} read",
files_ok,
if files_ok == 1 { "" } else { "s" }
);
if failed > 0 {
summary.push_str(&format!(", {} failed", failed));
}
renderer.line(MessageStyle::ToolDetail, &summary)?;
for item in items.iter().take(MAX_BATCH_DISPLAY_FILES) {
if let Some(fp) = item.get("file_path").and_then(Value::as_str) {
let short = shorten_path(fp, 60);
if item.get("error").is_some() {
renderer.line(MessageStyle::ToolError, &format!(" ✗ {}", short))?;
} else {
let lines_info = item
.get("ranges")
.and_then(Value::as_array)
.map(|ranges| {
let total_lines: u64 = ranges
.iter()
.filter_map(|r| r.get("lines_read").and_then(Value::as_u64))
.sum();
format!(" ({} lines)", total_lines)
})
.unwrap_or_default();
renderer.line(
MessageStyle::ToolDetail,
&format!(" ✓ {}{}", short, lines_info),
)?;
}
}
}
if items.len() > MAX_BATCH_DISPLAY_FILES {
renderer.line(
MessageStyle::ToolDetail,
&format!(" … +{} more", items.len() - MAX_BATCH_DISPLAY_FILES),
)?;
}
return Ok(());
}
if let Some(start) = get_u64(val, "start_line")
&& let Some(end) = get_u64(val, "end_line")
{
let count = end.saturating_sub(start) + 1;
renderer.line(
MessageStyle::ToolDetail,
&format!("lines {}-{} ({} lines)", start, end, count),
)?;
}
if let Some(content) = get_string(val, "content") {
if looks_like_diff_content(content) {
let git_styles = GitStyles::new();
let ls_styles = LsStyles::from_env();
renderer.line(MessageStyle::ToolDetail, "")?;
render_diff_content(renderer, content, &git_styles, &ls_styles)?;
} else {
let file_path = get_string(val, "file_path").or_else(|| get_string(val, "path"));
render_content_preview(renderer, content, file_path)?;
}
}
Ok(())
}
const MAX_BATCH_DISPLAY_FILES: usize = 10;
const MAX_PREVIEW_LINES: usize = 12;
const MAX_FULL_RENDER_LINES: usize = 80;
fn shorten_path(path: &str, max_len: usize) -> String {
if preview::display_width(path) <= max_len {
return path.to_string();
}
if let Some(name) = std::path::Path::new(path).file_name() {
let name_str = name.to_string_lossy();
if let Some(parent) = std::path::Path::new(path).parent() {
let parent_str = parent.to_string_lossy();
let reserved = preview::display_width(name_str.as_ref()) + 2; let budget = max_len.saturating_sub(reserved);
if budget > 0 && preview::display_width(parent_str.as_ref()) > budget {
let parent_tail = preview::suffix_for_display_width(parent_str.as_ref(), budget);
return format!("…{}/{}", parent_tail, name_str);
}
}
return name_str.to_string();
}
preview::truncate_to_display_width(path, max_len).to_string()
}
fn strip_line_number(line: &str) -> &str {
let trimmed = line.trim_start();
if let Some(pos) = trimmed.find(':') {
let prefix = &trimmed[..pos];
if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()) {
let rest = &trimmed[pos + 1..];
return rest.strip_prefix(' ').unwrap_or(rest);
}
}
line
}
fn render_content_preview(
renderer: &mut AnsiRenderer,
content: &str,
file_path: Option<&str>,
) -> Result<()> {
let all_lines: Vec<&str> = content.lines().map(strip_line_number).collect();
if all_lines.is_empty() || all_lines.iter().all(|line| line.trim().is_empty()) {
return Ok(());
}
let show_all = all_lines.len() <= MAX_FULL_RENDER_LINES;
let display_lines: &[&str] = if show_all {
&all_lines
} else {
&all_lines[..MAX_PREVIEW_LINES.min(all_lines.len())]
};
renderer.line(MessageStyle::ToolDetail, "")?;
let language = file_path.and_then(language_hint_from_path);
let markdown = build_markdown_code_block(display_lines, language.as_deref(), false);
renderer.render_markdown_output(MessageStyle::ToolOutput, &markdown)?;
if !show_all {
renderer.line_with_override_style(
MessageStyle::ToolDetail,
MessageStyle::ToolDetail.style().dimmed(),
&format!("… +{} more lines", all_lines.len() - display_lines.len()),
)?;
}
Ok(())
}
fn render_diff_content(
renderer: &mut AnsiRenderer,
diff_content: &str,
git_styles: &GitStyles,
ls_styles: &LsStyles,
) -> Result<()> {
render_diff_content_block(
renderer,
diff_content,
Some(tools::WRITE_FILE),
git_styles,
ls_styles,
MessageStyle::ToolDetail,
ToolOutputMode::Compact,
usize::MAX,
)
}
pub(super) fn colorize_diff_summary_line(line: &str, _supports_color: bool) -> Option<String> {
let trimmed = line.trim_start();
let is_summary = trimmed.contains(" file changed")
|| trimmed.contains(" files changed")
|| trimmed.contains(" insertion(+)")
|| trimmed.contains(" insertions(+)")
|| trimmed.contains(" deletion(-)")
|| trimmed.contains(" deletions(-)");
if is_summary {
Some(line.to_string())
} else {
None
}
}
#[cfg(test)]
#[path = "files_tests.rs"]
mod tests;