use hashbrown::HashSet;
use serde_json::Value;
use std::borrow::Cow;
use std::path::Path;
pub(super) use vtcode_commons::formatting::truncate_path_middle;
use vtcode_commons::formatting::{collapse_whitespace, truncate_middle};
use vtcode_core::tools::command_args;
pub(super) fn humanize_tool_name(name: &str) -> String {
humanize_key(name)
}
pub(super) fn describe_fetch_action(_args: &Value) -> (String, HashSet<String>) {
("Use Fetch".into(), HashSet::new())
}
fn extract_command(args: &Value) -> Option<(String, &'static str)> {
command_args::extract_command_text_with_key(args)
}
pub(super) const SUMMARY_PREVIEW_LEN: usize = 70;
pub(super) const COMPACT_PREVIEW_LEN: usize = 120;
const SCRIPT_RUNNER_FLAGS: &[&str] = &["-c", "-e", "--code"];
pub(super) fn describe_shell_command(args: &Value) -> Option<(String, HashSet<String>)> {
let (command, key) = extract_command(args)?;
let mut used = HashSet::new();
used.insert(key.to_string());
Some((preview_command(&command, SUMMARY_PREVIEW_LEN), used))
}
fn display_join_words(words: &[String]) -> String {
words
.iter()
.map(|word| collapse_whitespace(word))
.filter(|collapsed| !collapsed.is_empty())
.map(|collapsed| {
if collapsed.chars().any(char::is_whitespace) {
if !collapsed.contains('\'') {
format!("'{collapsed}'")
} else if !collapsed.contains('"') {
format!("\"{collapsed}\"")
} else {
format!("'{collapsed}'")
}
} else {
collapsed
}
})
.collect::<Vec<_>>()
.join(" ")
}
pub(super) fn display_command_text(args: &Value) -> Option<String> {
if let Some(words) = command_args::command_words(args).ok().flatten() {
let joined = display_join_words(&words);
if !joined.is_empty() {
return Some(joined);
}
}
let (raw, _) = extract_command(args)?;
let mut base = collapse_whitespace(&raw);
if base.is_empty() {
return None;
}
if let Some(extra) = args.get("args").and_then(Value::as_array) {
let extra_words: Vec<String> = extra
.iter()
.filter_map(Value::as_str)
.map(collapse_whitespace)
.filter(|word| !word.is_empty())
.collect();
if !extra_words.is_empty() {
base.push(' ');
base.push_str(&display_join_words(&extra_words));
}
}
Some(base)
}
fn is_script_runner_program(program: &str) -> bool {
let base = program.rsplit(['/', '\\']).next().unwrap_or(program).to_ascii_lowercase();
base.starts_with("python")
|| matches!(
base.as_str(),
"bash" | "sh" | "zsh" | "node" | "ruby" | "perl" | "php" | "deno" | "bun" | "pwsh" | "powershell"
)
}
fn is_script_prefix_only(first_line: &str) -> bool {
let mut tokens = first_line.split_whitespace();
let Some(program) = tokens.next() else {
return false;
};
if !is_script_runner_program(program) {
return false;
}
if !tokens.clone().any(|token| SCRIPT_RUNNER_FLAGS.contains(&token)) {
return false;
}
let token_count = 1 + tokens.clone().count();
if token_count == 2 {
return true;
}
shell_words::split(first_line).is_err()
}
pub(super) fn preview_command(command: &str, max_len: usize) -> String {
if max_len == 0 {
return String::new();
}
let lines: Vec<&str> = command.lines().map(str::trim).filter(|line| !line.is_empty()).collect();
if lines.is_empty() {
return String::new();
}
let mut first_line = collapse_whitespace(lines[0]);
if is_script_prefix_only(&first_line) && lines.len() > 1 {
let need = max_len.saturating_sub(first_line.chars().count()).saturating_add(16);
let mut rest_parts = Vec::new();
let mut rest_len = 0usize;
for line in &lines[1..] {
let collapsed = collapse_whitespace(line);
if collapsed.is_empty() {
continue;
}
rest_len += collapsed.chars().count() + 1;
rest_parts.push(collapsed);
if rest_len >= need {
break;
}
}
if !rest_parts.is_empty() {
let rest = rest_parts.join(" ");
if first_line.ends_with('"') || first_line.ends_with('\'') {
first_line.push_str(&rest);
} else {
first_line.push(' ');
first_line.push_str(&rest);
}
}
}
if first_line.chars().count() <= max_len {
return first_line;
}
let budget = max_len.saturating_sub(1);
let mut head: String = first_line.chars().take(budget).collect();
if let Some(last_space) = head.rfind(char::is_whitespace) {
if last_space >= budget / 2 {
head.truncate(last_space);
}
}
format!("{}…", head.trim_end())
}
pub(super) fn describe_list_files(args: &Value, workspace_root: Option<&Path>) -> Option<(String, HashSet<String>)> {
if let Some(path) = lookup_string(args, "path") {
let mut used = HashSet::new();
used.insert("path".to_string());
let location = if path == "." {
"workspace root".to_string()
} else {
let rel = relativize_to_workspace(&path, workspace_root);
truncate_path_middle(&rel, 60)
};
return Some((format!("List files in {location}"), used));
}
if let Some(pattern) = lookup_string(args, "name_pattern") {
let mut used = HashSet::new();
used.insert("name_pattern".to_string());
return Some((format!("Find files named {}", truncate_middle(&pattern, 40)), used));
}
if let Some(pattern) = lookup_string(args, "content_pattern") {
let mut used = HashSet::new();
used.insert("content_pattern".to_string());
return Some((format!("Search files for {}", truncate_middle(&pattern, 40)), used));
}
None
}
pub(super) fn describe_grep_file(args: &Value, workspace_root: Option<&Path>) -> Option<(String, HashSet<String>)> {
let pattern = lookup_string(args, "pattern");
let path = lookup_string(args, "path");
match (pattern, path) {
(Some(pat), Some(path)) => {
let mut used = HashSet::new();
used.insert("pattern".to_string());
used.insert("path".to_string());
Some((
format!(
"Grep {} in {}",
truncate_middle(&pat, 40),
truncate_path_middle(&relativize_to_workspace(&path, workspace_root), 40)
),
used,
))
}
(Some(pat), None) => {
let mut used = HashSet::new();
used.insert("pattern".to_string());
Some((format!("Grep {}", truncate_middle(&pat, 40)), used))
}
_ => None,
}
}
pub(super) fn describe_code_search(args: &Value) -> Option<(String, HashSet<String>)> {
for key in ["query", "pattern", "q", "text"] {
if let Some(query) = lookup_string(args, key) {
let mut used = HashSet::new();
used.insert(key.to_string());
return Some((format!("Search code for {}", truncate_middle(&query, 40)), used));
}
}
None
}
pub(super) fn describe_path_action(
args: &Value,
verb: &str,
keys: &[&str],
workspace_root: Option<&Path>,
) -> Option<(String, HashSet<String>)> {
for key in keys {
if let Some(value) = lookup_string(args, key) {
let mut used = HashSet::new();
used.insert((*key).to_string());
let rel = relativize_to_workspace(&value, workspace_root);
let summary = truncate_path_middle(&rel, 60);
let annotated_summary = annotate_skill_doc_summary(rel.as_ref(), summary);
return Some((format!("{verb} {annotated_summary}"), used));
}
}
None
}
fn annotate_skill_doc_summary(raw_path: &str, summary: String) -> String {
let path = Path::new(raw_path.trim());
let is_skill_doc = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md"));
if !is_skill_doc {
return summary;
}
let Some(skill_name) = path
.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
else {
return summary;
};
format!("{summary} ({skill_name} skill)")
}
pub(super) fn lookup_string(args: &Value, key: &str) -> Option<String> {
args.as_object()
.and_then(|map| map.get(key))
.and_then(|value| value.as_str())
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
}
fn is_path_key(key: &str) -> bool {
matches!(key, "path" | "file_path" | "filename" | "destination" | "source")
}
pub(super) fn relativize_to_workspace<'a>(path: &'a str, workspace_root: Option<&Path>) -> Cow<'a, str> {
let Some(root) = workspace_root else {
return Cow::Borrowed(path);
};
let p = Path::new(path);
if p.is_absolute() {
if let Ok(rel) = p.strip_prefix(root) {
if !rel.as_os_str().is_empty() {
return Cow::Owned(rel.to_string_lossy().into_owned());
}
}
}
Cow::Borrowed(path)
}
pub(super) fn relativize_command_paths(command: &str, workspace_root: Option<&Path>) -> String {
let Some(root) = workspace_root else {
return command.to_owned();
};
command
.split(' ')
.filter(|word| !word.is_empty())
.map(|word| {
let p = Path::new(word);
if p.is_absolute() {
if let Ok(rel) = p.strip_prefix(root) {
if !rel.as_os_str().is_empty() {
return rel.to_string_lossy().into_owned();
}
}
}
word.to_owned()
})
.collect::<Vec<_>>()
.join(" ")
}
pub(super) fn humanize_key(key: &str) -> String {
let replaced = key.replace('_', " ");
if replaced.is_empty() {
return replaced;
}
let mut chars = replaced.chars();
let first = chars.next().unwrap_or_default();
let mut result = first.to_uppercase().collect::<String>();
result.push_str(&chars.collect::<String>());
result
}
pub(super) fn collect_param_details(
args: &Value,
keys: &HashSet<String>,
workspace_root: Option<&Path>,
) -> Vec<String> {
let mut details = Vec::new();
let Some(map) = args.as_object() else {
return details;
};
let include_all = keys.is_empty();
for (key, value) in map {
if matches!(
key.as_str(),
"command"
| "raw_command"
| "bash_command"
| "cmd"
| "old_str"
| "new_str"
| "content"
| "new_content"
| "text"
| "patch"
| "code"
) {
continue;
}
if is_noise_param(key) {
continue;
}
if !include_all && keys.contains(key) {
continue;
}
match value {
Value::String(s) if !s.is_empty() => {
let display: Cow<'_, str> = if is_path_key(key) {
relativize_to_workspace(s, workspace_root)
} else {
Cow::Borrowed(s.as_str())
};
details.push(format!("{}: {}", humanize_key(key), truncate_middle(&display, 60)))
}
Value::Bool(true) => {
details.push(humanize_key(key));
}
Value::Array(items) => {
let strings: Vec<String> =
items.iter().filter_map(|item| item.as_str().map(|s| s.to_string())).collect();
if !strings.is_empty() {
details.push(format!("{}: {}", humanize_key(key), summarize_list(&strings, 2, 60)));
}
}
Value::Number(num) => {
if num.as_f64().is_some_and(|n| n == 0.0) {
continue;
}
details.push(format!("{}: {}", humanize_key(key), num));
}
_ => {}
}
}
details
}
fn is_noise_param(key: &str) -> bool {
matches!(
key,
"timeout_secs"
| "timeout"
| "max_bytes"
| "max_matches"
| "debug_query"
| "strictness"
| "case_sensitive"
| "literal"
| "context_lines"
| "shell"
| "login"
| "tty"
| "sandbox_permissions"
| "additional_permissions"
| "justification"
| "prefix_rule"
| "workdir"
| "cwd"
| "language"
| "spool_path"
| "query"
| "type"
| "tool_call_id"
| "call_type"
| "action"
)
}
pub(super) fn should_render_command_line(highlights: &HashSet<String>) -> bool {
highlights.is_empty()
|| (!highlights.contains("command")
&& !highlights.contains("raw_command")
&& !highlights.contains("bash_command")
&& !highlights.contains("cmd"))
}
pub(super) fn command_line_for_args(args: &Value) -> Option<String> {
let (command, _) = extract_command(args)?;
let trimmed = command.trim();
if trimmed.is_empty() {
return None;
}
Some(truncate_middle(trimmed, 120))
}
pub(super) fn highlight_texts_for_summary(
args: &Value,
highlights: &HashSet<String>,
workspace_root: Option<&Path>,
) -> Vec<String> {
let mut values = Vec::new();
for key in highlights {
if let Some(value) = lookup_string(args, key) {
let limit = match key.as_str() {
"pattern" | "name_pattern" | "content_pattern" => 40,
"command" | "raw_command" | "bash_command" => 70,
_ => 60,
};
let display: Cow<'_, str> = if is_path_key(key) {
relativize_to_workspace(&value, workspace_root)
} else {
Cow::Borrowed(&value)
};
values.push(truncate_middle(&display, limit));
}
}
values
}
pub(super) fn summarize_list(items: &[String], max_items: usize, max_len: usize) -> String {
if items.is_empty() {
return String::new();
}
let shown: Vec<String> = items.iter().take(max_items).map(|s| truncate_middle(s, max_len)).collect();
if items.len() > max_items {
format!("{} +{} more", shown.join(", "), items.len() - max_items)
} else {
shown.join(", ")
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_describe_shell_command_new_format() {
let args = json!({
"command": ["bash", "-lc", "ls -R"]
});
let result = describe_shell_command(&args);
assert!(result.is_some());
let (description, _used) = result.unwrap();
assert_eq!(description, "bash -lc ls -R");
}
#[test]
fn test_describe_shell_command_bash_command_format() {
let args = json!({
"bash_command": "pwd"
});
let result = describe_shell_command(&args);
assert!(result.is_some());
let (description, _used) = result.unwrap();
assert_eq!(description, "pwd");
}
#[test]
fn test_describe_shell_command_truncation() {
let long_command = "a".repeat(100);
let args = json!({
"command": [long_command]
});
let result = describe_shell_command(&args);
assert!(result.is_some());
let (description, _used) = result.unwrap();
assert!(description.contains("…"));
}
#[test]
fn test_describe_shell_command_string_format() {
let args = json!({
"command": "cargo check -p vtcode"
});
let result = describe_shell_command(&args);
assert!(result.is_some());
let (description, _used) = result.unwrap();
assert_eq!(description, "cargo check -p vtcode");
}
#[test]
fn test_describe_shell_command_raw_command_fallback() {
let args = json!({
"raw_command": "cargo test -- --nocapture"
});
let result = describe_shell_command(&args);
assert!(result.is_some());
let (description, _used) = result.unwrap();
assert_eq!(description, "cargo test -- --nocapture");
}
#[test]
fn collect_param_details_skips_noise_params() {
let args = json!({
"action": "grep",
"pattern": "agent loop",
"strictness": "relaxed",
"debug_query": "pattern",
"detail_level": "full",
"max_results": 20,
"context_lines": 2,
"scope": "repo",
"max_bytes": 6000,
"timeout_secs": 120
});
let mut keys = HashSet::new();
keys.insert("pattern".to_string());
let details = collect_param_details(&args, &keys, None);
for detail in &details {
assert!(
!detail.contains("Timeout")
&& !detail.contains("Max bytes")
&& !detail.contains("Debug query")
&& !detail.contains("Strictness")
&& !detail.contains("Context lines")
&& !detail.contains("Action"),
"Noise param leaked through: {detail}"
);
}
}
#[test]
fn collect_param_details_skips_zero_numbers() {
let args = json!({
"action": "read",
"path": "src/main.rs",
"start_line": 1,
"end_line": 200,
"offset": 0,
"limit": 0
});
let mut keys = HashSet::new();
keys.insert("path".to_string());
let details = collect_param_details(&args, &keys, None);
for detail in &details {
assert!(
!detail.contains("Offset") && !detail.contains("Limit"),
"Zero-valued param leaked through: {detail}"
);
}
assert!(details.iter().any(|d| d.contains("Start line: 1")));
assert!(details.iter().any(|d| d.contains("End line: 200")));
}
#[test]
fn is_noise_param_matches_expected_keys() {
assert!(is_noise_param("timeout_secs"));
assert!(is_noise_param("max_bytes"));
assert!(is_noise_param("debug_query"));
assert!(is_noise_param("strictness"));
assert!(is_noise_param("case_sensitive"));
assert!(is_noise_param("context_lines"));
assert!(is_noise_param("shell"));
assert!(is_noise_param("sandbox_permissions"));
assert!(is_noise_param("action")); assert!(!is_noise_param("offset"));
assert!(!is_noise_param("limit"));
assert!(!is_noise_param("head_lines"));
assert!(!is_noise_param("tail_lines"));
assert!(!is_noise_param("start_line"));
assert!(!is_noise_param("end_line"));
assert!(!is_noise_param("pattern"));
assert!(!is_noise_param("path"));
assert!(!is_noise_param("mode"));
}
#[test]
fn truncate_path_middle_breaks_at_separator() {
let path = "/Users/vinhnguyenxuan/Developer/learn-by-doing/vtcode/hello/src/main.rs";
let truncated = truncate_path_middle(path, 40);
assert!(truncated.contains("…"));
if let Some(char_idx) = truncated.char_indices().find(|(_, c)| *c == '…') {
let after: String = truncated[char_idx.0 + '…'.len_utf8()..].chars().collect();
assert!(
after.starts_with('/') || after.starts_with('h') || after.starts_with('s'),
"Expected path break after ellipsis, got: {after}"
);
}
}
#[test]
fn truncate_path_middle_short_path_not_truncated() {
let path = "src/main.rs";
let truncated = truncate_path_middle(path, 40);
assert_eq!(truncated, "src/main.rs");
}
#[test]
fn preview_command_multiline_script_folds_in_content() {
let command = "python3 -c \"\nimport json\nwith open('.vtcode/checkpoints/turn_1032.json') as f:\n pass\n\"";
let preview = preview_command(command, 70);
assert!(
preview.starts_with("python3 -c \"import json"),
"prefix-only line should fold in script, got: {preview}"
);
assert!(!preview.contains("tur…ool"), "mid-string ellipsis leaked: {preview}");
assert_ne!(preview, "python3 -c \"");
}
#[test]
fn preview_command_unclosed_first_line_appends_continuation() {
let command = "python3 -c \"import json\nwith open('a.json') as f: pass\"";
let preview = preview_command(command, 70);
assert!(preview.contains("import json"), "got: {preview}");
assert!(preview.contains("with open"), "got: {preview}");
}
#[test]
fn preview_command_windows_style_runner_folds_in_content() {
let command = "C:\\Python\\python.exe -c \"\nimport json\nprint('hi')\n\"";
let preview = preview_command(command, 70);
assert!(
preview.starts_with("C:\\Python\\python.exe -c \"import json"),
"windows runner should fold in script, got: {preview}"
);
}
#[test]
fn preview_command_plain_multiline_keeps_first_line() {
assert_eq!(preview_command("ls -R\npwd", 70), "ls -R");
}
#[test]
fn preview_command_ignores_contractions_and_non_runners() {
assert_eq!(preview_command("echo don't\nnext-line", 70), "echo don't");
assert_eq!(preview_command("grep -c 'pat\nnext-line", 70), "grep -c 'pat");
assert_eq!(preview_command("tool --code-review\nnext-line", 70), "tool --code-review");
assert_eq!(preview_command("python3 -c \"print('hi') # don't\"", 70), "python3 -c \"print('hi') # don't\"");
}
#[test]
fn preview_command_long_command_head_truncates_at_word_boundary() {
let command = "echo alpha beta gamma delta epsilon zeta eta theta iota";
assert_eq!(preview_command(command, 30), "echo alpha beta gamma delta…");
}
#[test]
fn preview_command_keeps_half_budget_when_first_space_is_early() {
let command = "echo alpha-beta-gamma-delta-epsilon zeta";
assert_eq!(preview_command(command, 30), "echo alpha-beta-gamma-delta-e…");
}
#[test]
fn preview_command_short_command_unchanged() {
assert_eq!(preview_command("git status --short", 70), "git status --short");
assert_eq!(preview_command(" ", 70), "");
assert_eq!(preview_command("echo hi", 0), "");
}
#[test]
fn display_command_text_leaves_operators_unquoted() {
let args = json!({
"command": ["cat", "docs/guides/agent-loop-contract.md", "2>/dev/null", "|", "head", "-120", ";", "echo", "---"]
});
let display = display_command_text(&args).expect("command display text");
assert_eq!(display, "cat docs/guides/agent-loop-contract.md 2>/dev/null | head -120 ; echo ---");
}
#[test]
fn display_command_text_quotes_only_whitespace_words() {
let args = json!({ "command": ["echo", "hello world", "|", "tr", "a-z", "A-Z"] });
let display = display_command_text(&args).expect("command display text");
assert_eq!(display, "echo 'hello world' | tr a-z A-Z");
}
#[test]
fn describe_shell_command_no_mid_string_ellipsis() {
let args = json!({
"command": "python3 -c \"\nimport json\nwith open('.vtcode/checkpoints/turn_1032.json') as f: d = json.load(f)\""
});
let (summary, used) = describe_shell_command(&args).expect("shell command summary");
assert_eq!(used.iter().collect::<Vec<_>>(), ["command"]);
assert!(!summary.contains("tur…ool"), "mid-string ellipsis leaked: {summary}");
assert!(summary.starts_with("python3"));
assert!(summary.contains("import json"), "script content folded in: {summary}");
assert_ne!(summary, "python3 -c \"");
}
#[test]
fn display_command_text_collapses_script_newlines_without_nesting() {
let args = json!({
"command": "python3 -c \"import json\nwith open('.vtcode/x.json') as f: pass\""
});
let display = display_command_text(&args).expect("command display text");
assert!(!display.contains('\n'), "newlines leaked: {display:?}");
assert!(display.starts_with("python3 -c "), "got: {display}");
assert!(display.contains("import json"), "got: {display}");
assert!(!display.contains("'import json"), "nested single quotes: {display}");
}
#[test]
fn display_command_text_falls_back_to_bash_command() {
let args = json!({ "bash_command": "cargo check -p vtcode" });
assert_eq!(display_command_text(&args).as_deref(), Some("cargo check -p vtcode"));
let args = json!({ "raw_command": "cargo check -p vtcode", "bash_command": "cargo test" });
assert_eq!(display_command_text(&args).as_deref(), Some("cargo check -p vtcode"));
}
#[test]
fn display_command_text_skips_blank_words_and_keeps_args() {
let args = json!({ "command": ["echo", " "] });
assert_eq!(display_command_text(&args).as_deref(), Some("echo"));
let args = json!({ "command": "echo 'unclosed", "args": ["extra"] });
assert_eq!(display_command_text(&args).as_deref(), Some("echo 'unclosed extra"));
}
#[test]
fn describe_code_search_marks_query_used() {
let args = json!({ "query": "agent loop implementation", "max_results": 15 });
let (summary, used) = describe_code_search(&args).expect("code search summary");
assert_eq!(summary, "Search code for agent loop implementation");
assert!(used.contains("query"));
assert!(!used.contains("max_results"));
}
#[test]
fn describe_code_search_accepts_query_aliases() {
for key in ["pattern", "q", "text"] {
let args = json!({ key: "core agent loop", "file_types": ["rs"] });
let (summary, used) = describe_code_search(&args).expect("alias query should describe");
assert_eq!(summary, "Search code for core agent loop");
assert!(used.contains(key), "used should record the matched key, got {used:?}");
}
}
}