use hashbrown::HashSet;
use anstyle::Color;
use anyhow::Result;
use serde_json::Value;
use vtcode_core::config::constants::tools as tool_names;
use vtcode_core::tools::registry::labels::tool_action_label;
use vtcode_core::tools::tool_intent;
use vtcode_core::ui::theme;
use vtcode_core::utils::ansi::{AnsiRenderer, MessageStyle};
use vtcode_core::utils::style_helpers::{ColorPalette, render_styled};
use crate::agent::runloop::unified::tool_summary_helpers::{
collect_param_details, command_line_for_args, describe_fetch_action, describe_grep_file,
describe_list_files, describe_path_action, describe_shell_command, highlight_texts_for_summary,
should_render_command_line, truncate_middle,
};
const RUN_SUMMARY_FIRST_WIDTH: usize = 62;
const RUN_SUMMARY_CONTINUATION_WIDTH: usize = 58;
pub(crate) fn render_file_operation_indicator(
renderer: &mut AnsiRenderer,
tool_name: &str,
args: &Value,
) -> Result<()> {
let palette = ColorPalette::default();
let (indicator_icon, action_verb) = match tool_name {
name if name == tool_names::WRITE_FILE || name == tool_names::CREATE_FILE => {
("❋", "Writing")
}
name if name == tool_names::EDIT_FILE => ("❋", "Editing"),
name if name == tool_names::APPLY_PATCH => ("❋", "Applying patch to"),
name if name == tool_names::SEARCH_REPLACE => ("❋", "Search/replace in"),
name if name == tool_names::DELETE_FILE => ("❋", "Deleting"),
name if name == tool_names::UNIFIED_FILE => {
let action = args
.get("action")
.and_then(Value::as_str)
.or_else(|| {
if args.get("old_str").is_some() {
Some("edit")
} else if args.get("patch").is_some() {
Some("patch")
} else if args.get("content").is_some() {
Some("write")
} else {
None
}
})
.unwrap_or("read");
match action {
"write" | "create" => ("❋", "Writing"),
"edit" => ("❋", "Editing"),
"patch" | "apply_patch" => ("❋", "Applying patch to"),
"delete" => ("❋", "Deleting"),
_ => return Ok(()), }
}
_ => return Ok(()), };
let file_path = args
.get("path")
.or_else(|| args.get("file_path"))
.or_else(|| args.get("filename"))
.and_then(Value::as_str)
.map(|p| truncate_middle(p, 60))
.unwrap_or_else(|| "file".to_string());
let mut line = String::new();
line.push_str(indicator_icon);
line.push(' ');
line.push_str(&render_styled(action_verb, palette.info, None));
line.push(' ');
line.push_str(&render_styled(&file_path, palette.muted, None));
line.push_str(&render_styled("...", palette.muted, None));
renderer.line(MessageStyle::Info, &line)?;
Ok(())
}
pub(crate) fn is_file_modification_tool(tool_name: &str, args: &Value) -> bool {
match tool_name {
name if name == tool_names::WRITE_FILE
|| name == tool_names::CREATE_FILE
|| name == tool_names::EDIT_FILE
|| name == tool_names::APPLY_PATCH
|| name == tool_names::SEARCH_REPLACE
|| name == tool_names::DELETE_FILE =>
{
true
}
name if name == tool_names::UNIFIED_FILE => {
let action = args
.get("action")
.and_then(Value::as_str)
.or_else(|| {
if args.get("old_str").is_some() {
Some("edit")
} else if args.get("patch").is_some() {
Some("patch")
} else if args.get("content").is_some() {
Some("write")
} else {
None
}
})
.unwrap_or("read");
matches!(
action,
"write" | "create" | "edit" | "patch" | "apply_patch" | "delete"
)
}
_ => false,
}
}
pub(crate) fn render_tool_call_summary(
renderer: &mut AnsiRenderer,
tool_name: &str,
args: &Value,
stream_label: Option<&str>,
) -> Result<()> {
let (headline, highlights) = describe_tool_action(tool_name, args);
let command_line_candidate = command_line_for_args(args);
let summary_highlights = highlight_texts_for_summary(args, &highlights);
let palette = ColorPalette::default();
let action_label = tool_action_label(tool_name, args);
let is_run_command = action_label == "Run command";
let details = if is_run_command {
Vec::new()
} else {
collect_param_details(args, &highlights)
};
let mut summary = build_tool_summary(&action_label, &headline);
if is_run_command {
summary = command_line_candidate
.as_ref()
.map(|command| format!("Ran {}", command))
.unwrap_or(summary);
}
if is_run_command && run_summary_is_placeholder(&summary) {
summary = "Ran command".to_string();
}
let command_line = if is_run_command {
None
} else {
command_line_candidate.filter(|_| should_render_command_line(&highlights))
};
let theme_styles = theme::active_styles();
let main_color = theme_styles
.primary
.get_fg_color()
.unwrap_or(Color::Ansi(anstyle::AnsiColor::Green));
let mut line = String::new();
line.push_str(&render_styled("•", palette.muted, Some("dim".to_string())));
line.push(' ');
let mut wrapped_run_segments: Option<Vec<String>> = None;
if let Some(command) = summary.strip_prefix("Ran ") {
let wrapped = wrap_text_words(
command,
RUN_SUMMARY_FIRST_WIDTH,
RUN_SUMMARY_CONTINUATION_WIDTH,
);
let first_segment = wrapped
.first()
.cloned()
.unwrap_or_else(|| "command".to_string());
wrapped_run_segments = Some(wrapped);
line.push_str(&render_styled(
"Ran",
palette.accent,
Some("bold".to_string()),
));
line.push(' ');
line.push_str(&render_run_command_segment(
&first_segment,
main_color,
palette.muted,
));
} else {
line.push_str(&render_summary_with_highlights(
&summary,
&summary_highlights,
main_color,
palette.accent,
palette.muted,
));
}
let stream_label = if summary.starts_with("Ran ") {
None
} else {
stream_label
};
if let Some(stream) = stream_label {
line.push(' ');
line.push_str(&render_styled(stream, palette.info, None));
}
renderer.line(MessageStyle::Info, &line)?;
if let Some(wrapped) = wrapped_run_segments {
for segment in wrapped.into_iter().skip(1) {
let mut continuation = String::new();
continuation.push_str(" ");
continuation.push_str(&render_styled("│", palette.muted, Some("dim".to_string())));
continuation.push(' ');
continuation.push_str(&render_styled(&segment, palette.muted, None));
renderer.line(MessageStyle::Info, &continuation)?;
}
}
if let Some(command_line) = command_line {
let mut styled = String::new();
styled.push_str(" ");
styled.push_str(&render_styled("└", palette.muted, Some("dim".to_string())));
styled.push(' ');
styled.push_str(&render_styled("$", palette.accent, None));
styled.push(' ');
styled.push_str(&render_styled(&command_line, palette.muted, None));
renderer.line(MessageStyle::Info, &styled)?;
}
for detail in details {
let mut styled = String::new();
styled.push_str(" ");
styled.push_str(&render_styled("└", palette.muted, Some("dim".to_string())));
styled.push(' ');
styled.push_str(&render_styled(&detail, palette.muted, None));
renderer.line(MessageStyle::Info, &styled)?;
}
Ok(())
}
fn render_summary_with_highlights(
summary: &str,
highlights: &[String],
main_color: Color,
accent_color: Color,
muted_color: Color,
) -> String {
if highlights.is_empty() {
return render_styled(summary, main_color, None);
}
let mut ranges: Vec<(usize, usize)> = highlights
.iter()
.filter_map(|text| {
if text.is_empty() {
return None;
}
summary.find(text).map(|start| (start, start + text.len()))
})
.collect();
if ranges.is_empty() {
return render_styled(summary, main_color, None);
}
ranges.sort_by_key(|(start, _)| *start);
let mut rendered = String::new();
let mut cursor = 0usize;
for (start, end) in ranges {
if start < cursor || start >= summary.len() || end > summary.len() {
continue;
}
if cursor < start {
rendered.push_str(&render_styled(&summary[cursor..start], muted_color, None));
}
rendered.push_str(&render_styled(&summary[start..end], accent_color, None));
cursor = end;
}
if cursor < summary.len() {
rendered.push_str(&render_styled(&summary[cursor..], muted_color, None));
}
rendered
}
fn render_run_command_segment(segment: &str, command_color: Color, args_color: Color) -> String {
let (command, args) = split_command_and_args(segment);
if command.is_empty() {
return render_styled(segment, args_color, None);
}
let mut rendered = String::new();
rendered.push_str(&render_styled(command, command_color, None));
if !args.is_empty() {
rendered.push_str(&render_styled(&format!(" {}", args), args_color, None));
}
rendered
}
fn split_command_and_args(text: &str) -> (&str, &str) {
let trimmed = text.trim();
if trimmed.is_empty() {
return ("", "");
}
for (idx, ch) in trimmed.char_indices() {
if ch.is_whitespace() {
let command = &trimmed[..idx];
let args = trimmed[idx..].trim_start();
return (command, args);
}
}
(trimmed, "")
}
fn build_tool_summary(action_label: &str, headline: &str) -> String {
let normalized = headline.trim().trim_start_matches("MCP ").trim();
if action_label == "Run command" {
if normalized.is_empty() {
return "Ran command".to_string();
}
return format!("Ran {}", normalized);
}
if normalized.is_empty() {
return action_label.to_string();
}
if normalized == action_label {
return normalized.to_string();
}
if normalized.starts_with(action_label) {
return normalized.to_string();
}
if let Some(stripped) = normalized.strip_prefix("Use ")
&& stripped == action_label
{
return action_label.to_string();
}
format!("{} {}", action_label, normalized)
}
fn run_summary_is_placeholder(summary: &str) -> bool {
let Some(command) = summary.strip_prefix("Ran ") else {
return false;
};
let normalized = command.trim().to_ascii_lowercase();
matches!(
normalized.as_str(),
"command"
| "bash"
| "run pty cmd"
| "unified exec"
| "use unified exec"
| "use run pty cmd"
| "use bash"
)
}
fn wrap_text_words(text: &str, first_width: usize, continuation_width: usize) -> Vec<String> {
let trimmed = text.trim();
if trimmed.is_empty() {
return Vec::new();
}
let mut result = Vec::new();
let mut remaining = trimmed;
let mut width = first_width.max(1);
while char_count(remaining) > width {
let split = split_at_word_boundary(remaining, width);
let (head, tail) = remaining.split_at(split);
let head = head.trim();
if head.is_empty() {
break;
}
result.push(head.to_string());
remaining = tail.trim_start();
if remaining.is_empty() {
break;
}
width = continuation_width.max(1);
}
if !remaining.is_empty() {
result.push(remaining.to_string());
}
result
}
fn split_at_word_boundary(input: &str, width: usize) -> usize {
let capped = byte_index_for_char_count(input, width);
let candidate = &input[..capped];
if let Some(boundary) = candidate.rfind(char::is_whitespace) {
boundary
} else {
capped
}
}
fn byte_index_for_char_count(input: &str, chars: usize) -> usize {
if chars == 0 {
return 0;
}
let mut seen = 0usize;
for (idx, ch) in input.char_indices() {
seen += 1;
if seen == chars {
return idx + ch.len_utf8();
}
}
input.len()
}
fn char_count(input: &str) -> usize {
input.chars().count()
}
pub(crate) fn stream_label_from_output(
output: &Value,
command_success: bool,
) -> Option<&'static str> {
let has_output = output
.get("output")
.and_then(Value::as_str)
.is_some_and(|s| !s.is_empty());
let has_stdout = output
.get("stdout")
.and_then(Value::as_str)
.is_some_and(|s| !s.is_empty());
let has_stderr = output
.get("stderr")
.and_then(Value::as_str)
.is_some_and(|s| !s.is_empty());
let has_error = output.get("error").is_some() || output.get("error_type").is_some();
if has_output {
return Some("output");
}
match (has_stdout, has_stderr) {
(true, true) => Some("stdio"),
(true, false) => Some("stdout"),
(false, true) => Some("stderr"),
(false, false) => {
if has_error || !command_success {
Some("error")
} else {
None
}
}
}
}
pub(crate) fn describe_tool_action(tool_name: &str, args: &Value) -> (String, HashSet<String>) {
let is_mcp_tool =
tool_name.starts_with("mcp::") || tool_name.starts_with("mcp_") || tool_name == "fetch";
let actual_tool_name = if tool_name.starts_with("mcp__") {
tool_name.split("__").last().unwrap_or(tool_name)
} else if let Some(stripped) = tool_name.strip_prefix("mcp_") {
stripped
} else if tool_name.starts_with("mcp::") {
tool_name.split("::").last().unwrap_or(tool_name)
} else {
tool_name
};
match actual_tool_name {
actual_name if actual_name == tool_names::RUN_PTY_CMD => describe_shell_command(args)
.map(|(desc, used)| {
(
format!("{}{}", if is_mcp_tool { "MCP " } else { "" }, desc),
used,
)
})
.unwrap_or_else(|| {
(
format!("{}command", if is_mcp_tool { "MCP " } else { "" }),
HashSet::new(),
)
}),
actual_name if actual_name == tool_names::UNIFIED_EXEC => {
match tool_intent::unified_exec_action(args).unwrap_or("run") {
"run" => describe_shell_command(args)
.map(|(desc, used)| {
(
format!("{}{}", if is_mcp_tool { "MCP " } else { "" }, desc),
used,
)
})
.unwrap_or_else(|| {
(
format!("{}command", if is_mcp_tool { "MCP " } else { "" }),
HashSet::new(),
)
}),
"write" => (
format!(
"{}Send command input",
if is_mcp_tool { "MCP " } else { "" }
),
HashSet::new(),
),
"poll" => (
format!(
"{}Read command session",
if is_mcp_tool { "MCP " } else { "" }
),
HashSet::new(),
),
"continue" => (
format!(
"{}Continue command session",
if is_mcp_tool { "MCP " } else { "" }
),
HashSet::new(),
),
"inspect" => (
format!(
"{}Inspect command output",
if is_mcp_tool { "MCP " } else { "" }
),
HashSet::new(),
),
"list" => (
format!(
"{}List command sessions",
if is_mcp_tool { "MCP " } else { "" }
),
HashSet::new(),
),
"close" => (
format!(
"{}Close command session",
if is_mcp_tool { "MCP " } else { "" }
),
HashSet::new(),
),
"code" => (
format!("{}Run code", if is_mcp_tool { "MCP " } else { "" }),
HashSet::new(),
),
_ => (
format!("{}unified_exec", if is_mcp_tool { "MCP " } else { "" }),
HashSet::new(),
),
}
}
actual_name if actual_name == tool_names::LIST_FILES => describe_list_files(args)
.map(|(desc, used)| {
(
format!("{}{}", if is_mcp_tool { "MCP " } else { "" }, desc),
used,
)
})
.unwrap_or_else(|| {
(
format!("{}List files", if is_mcp_tool { "MCP " } else { "" }),
HashSet::new(),
)
}),
actual_name if actual_name == tool_names::GREP_FILE => describe_grep_file(args)
.map(|(desc, used)| {
(
format!("{}{}", if is_mcp_tool { "MCP " } else { "" }, desc),
used,
)
})
.unwrap_or_else(|| {
(
format!("{}Search with grep", if is_mcp_tool { "MCP " } else { "" }),
HashSet::new(),
)
}),
actual_name if actual_name == tool_names::READ_FILE => {
describe_path_action(args, "Read file", &["path"])
.map(|(desc, used)| {
(
format!("{}{}", if is_mcp_tool { "MCP " } else { "" }, desc),
used,
)
})
.unwrap_or_else(|| {
(
format!("{}Read file", if is_mcp_tool { "MCP " } else { "" }),
HashSet::new(),
)
})
}
actual_name if actual_name == tool_names::WRITE_FILE => {
describe_path_action(args, "Write file", &["path"])
.map(|(desc, used)| {
(
format!("{}{}", if is_mcp_tool { "MCP " } else { "" }, desc),
used,
)
})
.unwrap_or_else(|| {
(
format!("{}Write file", if is_mcp_tool { "MCP " } else { "" }),
HashSet::new(),
)
})
}
actual_name if actual_name == tool_names::EDIT_FILE => {
describe_path_action(args, "Edit file", &["path"])
.map(|(desc, used)| {
(
format!("{}{}", if is_mcp_tool { "MCP " } else { "" }, desc),
used,
)
})
.unwrap_or_else(|| {
(
format!("{}Edit file", if is_mcp_tool { "MCP " } else { "" }),
HashSet::new(),
)
})
}
actual_name if actual_name == tool_names::CREATE_FILE => {
describe_path_action(args, "Create file", &["path"])
.map(|(desc, used)| {
(
format!("{}{}", if is_mcp_tool { "MCP " } else { "" }, desc),
used,
)
})
.unwrap_or_else(|| {
(
format!("{}Create file", if is_mcp_tool { "MCP " } else { "" }),
HashSet::new(),
)
})
}
actual_name if actual_name == tool_names::UNIFIED_FILE => {
let action = args
.get("action")
.and_then(Value::as_str)
.or_else(|| {
if args.get("old_str").is_some() {
Some("edit")
} else if args.get("patch").is_some() {
Some("patch")
} else if args.get("content").is_some() {
Some("write")
} else if args.get("destination").is_some() {
Some("move")
} else {
Some("read")
}
})
.unwrap_or("read");
let (verb, keys): (&str, &[&str]) = match action {
"read" => ("Read file", &["path", "file_path", "target_path"]),
"write" => ("Write file", &["path", "file_path", "target_path"]),
"edit" => ("Edit file", &["path", "file_path", "target_path"]),
"patch" => ("Apply patch", &["path", "file_path", "target_path"]),
"delete" => ("Delete file", &["path", "file_path", "target_path"]),
"move" => ("Move file", &["path", "file_path", "target_path"]),
"copy" => ("Copy file", &["path", "file_path", "target_path"]),
_ => ("File operation", &["path", "file_path", "target_path"]),
};
describe_path_action(args, verb, keys)
.map(|(desc, used)| {
(
format!("{}{}", if is_mcp_tool { "MCP " } else { "" }, desc),
used,
)
})
.unwrap_or_else(|| {
(
format!("{}{}", if is_mcp_tool { "MCP " } else { "" }, verb),
HashSet::new(),
)
})
}
actual_name if actual_name == tool_names::DELETE_FILE => {
describe_path_action(args, "Delete file", &["path"])
.map(|(desc, used)| {
(
format!("{}{}", if is_mcp_tool { "MCP " } else { "" }, desc),
used,
)
})
.unwrap_or_else(|| {
(
format!("{}Delete file", if is_mcp_tool { "MCP " } else { "" }),
HashSet::new(),
)
})
}
actual_name if actual_name == tool_names::APPLY_PATCH => (
format!(
"{}Apply workspace patch",
if is_mcp_tool { "MCP " } else { "" }
),
HashSet::new(),
),
"fetch" | tool_names::WEB_FETCH => {
let (desc, used) = describe_fetch_action(args);
(
format!("{}{}", if is_mcp_tool { "MCP " } else { "" }, desc),
used,
)
}
_ => (
format!(
"{}Use {}",
if is_mcp_tool { "MCP " } else { "" },
humanize_tool_name(actual_tool_name)
),
HashSet::new(),
),
}
}
pub(crate) fn humanize_tool_name(name: &str) -> String {
crate::agent::runloop::unified::tool_summary_helpers::humanize_tool_name(name)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use vtcode_core::config::constants::tools as tool_names;
use super::{
build_tool_summary, describe_tool_action, run_summary_is_placeholder, wrap_text_words,
};
#[test]
fn build_tool_summary_formats_run_command_as_ran() {
assert_eq!(
build_tool_summary("Run command", "cargo check -p vtcode"),
"Ran cargo check -p vtcode"
);
}
#[test]
fn describe_tool_action_handles_unified_exec_run_command() {
let (description, used_keys) = describe_tool_action(
tool_names::UNIFIED_EXEC,
&json!({
"action": "run",
"command": "cargo check -p vtcode"
}),
);
assert_eq!(description, "cargo check -p vtcode");
assert!(used_keys.contains("command"));
}
#[test]
fn describe_tool_action_annotates_skill_doc_reads_with_skill_name() {
let (description, used_keys) = describe_tool_action(
tool_names::READ_FILE,
&json!({
"path": "/tmp/pr-babysitter/SKILL.md"
}),
);
assert_eq!(
description,
"Read file /tmp/pr-babysitter/SKILL.md (pr-babysitter skill)"
);
assert!(used_keys.contains("path"));
}
#[test]
fn describe_tool_action_annotates_unified_file_skill_doc_reads_with_skill_name() {
let (description, used_keys) = describe_tool_action(
tool_names::UNIFIED_FILE,
&json!({
"action": "read",
"path": "skills/code-review-skill/SKILL.md"
}),
);
assert_eq!(
description,
"Read file skills/code-review-skill/SKILL.md (code-review-skill skill)"
);
assert!(used_keys.contains("path"));
}
#[test]
fn run_summary_placeholder_detection_catches_generic_labels() {
assert!(run_summary_is_placeholder("Ran Use Unified exec"));
assert!(run_summary_is_placeholder("Ran command"));
assert!(run_summary_is_placeholder("Ran bash"));
assert!(!run_summary_is_placeholder("Ran cargo check -p vtcode"));
}
#[test]
fn wrap_text_words_wraps_long_command_summary() {
let text = "cargo test -p vtcode run_command_preview_ build_tool_summary_formats_run_command_as_ran";
let wrapped = wrap_text_words(text, 62, 58);
assert_eq!(wrapped.len(), 2);
assert_eq!(wrapped[0], "cargo test -p vtcode run_command_preview_");
assert_eq!(wrapped[1], "build_tool_summary_formats_run_command_as_ran");
}
}