use std::borrow::Cow;
use std::path::Path;
use serde_json::Value;
use crate::formatting::{collapse_whitespace, truncate_middle, truncate_path_middle};
#[must_use]
pub fn lookup_string(args: &Value, key: &str) -> Option<String> {
lookup_first_string(args, &[key])
}
#[must_use]
pub fn lookup_first_string(args: &Value, keys: &[&str]) -> Option<String> {
keys.iter()
.filter_map(|key| args.get(key).and_then(Value::as_str))
.find_map(|text| {
let trimmed = text.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})
}
#[must_use]
pub 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 candidate = Path::new(path);
if candidate.is_absolute() {
if let Ok(rel) = candidate.strip_prefix(root) {
if !rel.as_os_str().is_empty() {
return Cow::Owned(rel.to_string_lossy().into_owned());
}
}
}
Cow::Borrowed(path)
}
#[must_use]
pub fn is_path_key(key: &str) -> bool {
matches!(key, "path" | "file_path" | "filename" | "destination" | "source")
}
#[must_use]
pub 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
}
#[must_use]
pub fn humanize_tool_name(tool_name: &str) -> String {
let bare = strip_mcp_prefix(tool_name);
let mut words: Vec<String> = Vec::new();
let mut current = String::new();
let mut previous: Option<char> = None;
for c in bare.chars() {
if c == '_' {
if !current.is_empty() {
words.push(std::mem::take(&mut current));
}
} else {
let boundary = previous.is_some_and(|prev| c.is_uppercase() && (prev.is_lowercase() || prev.is_numeric()));
if boundary && !current.is_empty() {
words.push(std::mem::take(&mut current));
}
current.push(c);
}
previous = Some(c);
}
if !current.is_empty() {
words.push(current);
}
if words.is_empty() {
return tool_name.to_string();
}
for word in &mut words {
let mut chars = word.chars();
if let Some(first) = chars.next() {
let mut pretty = first.to_uppercase().collect::<String>();
pretty.push_str(chars.as_str());
*word = pretty;
}
}
words.join(" ")
}
#[must_use]
pub fn mcp_tool_display_name(tool_name: &str) -> Option<&str> {
if tool_name.starts_with("mcp__") {
return Some(tool_name.split("__").last().unwrap_or(tool_name));
}
if tool_name.starts_with("mcp::") {
return Some(tool_name.split("::").last().unwrap_or(tool_name));
}
if let Some(rest) = tool_name.strip_prefix("mcp_") {
return Some(rest);
}
None
}
#[must_use]
pub fn strip_mcp_prefix(tool_name: &str) -> &str {
mcp_tool_display_name(tool_name).unwrap_or(tool_name)
}
#[must_use]
pub fn summarize_list(items: &[String]) -> String {
if items.is_empty() {
return String::new();
}
let shown: Vec<String> = items.iter().take(2).map(|s| truncate_middle(s, 60)).collect();
if items.len() > 2 {
format!("{} +{} more", shown.join(", "), items.len() - 2)
} else {
shown.join(", ")
}
}
#[must_use]
pub fn is_verbose_key(key: &str) -> bool {
matches!(
key,
"command"
| "raw_command"
| "bash_command"
| "cmd"
| "old_str"
| "new_str"
| "content"
| "new_content"
| "text"
| "patch"
| "code"
)
}
fn text_is_search_query(tool_name: &str, key: &str) -> bool {
key == "text" && strip_mcp_prefix(tool_name) == "code_search"
}
#[must_use]
pub 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"
| "type"
| "tool_call_id"
| "call_type"
| "action"
| "max_output_tokens"
| "max_tokens"
)
}
#[must_use]
pub fn collect_param_details(
tool_name: &str,
args: &Value,
skipped: &[&str],
workspace_root: Option<&Path>,
) -> Vec<String> {
let mut details = Vec::new();
let Some(map) = args.as_object() else {
return details;
};
for (key, value) in map {
let verbose_hidden = is_verbose_key(key) && !text_is_search_query(tool_name, key);
if is_noise_param(key) || verbose_hidden || skipped.contains(&key.as_str()) {
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)));
}
}
Value::Number(num) => {
if num.as_f64().is_some_and(|n| n == 0.0) {
continue;
}
details.push(format!("{}: {}", humanize_key(key), num));
}
_ => {}
}
}
details
}
#[must_use]
pub fn exec_session_param_detail(args: &Value) -> Option<String> {
let session = lookup_string(args, "session_id").map(|id| format!("Session {id}"));
let wait = ["wait_timeout_seconds", "timeout_seconds"]
.iter()
.find_map(|key| args.get(key).and_then(Value::as_u64))
.filter(|secs| *secs > 0)
.map(|secs| format!("wait up to {secs}s"));
let parts = [session, wait].into_iter().flatten().collect::<Vec<_>>();
(!parts.is_empty()).then(|| parts.join(" · "))
}
#[must_use]
pub fn describe_list_files(args: &Value, workspace_root: Option<&Path>) -> Option<(String, &'static [&'static str])> {
if let Some(path) = lookup_string(args, "path") {
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}"), &["path"]));
}
if let Some(pattern) = lookup_string(args, "name_pattern") {
return Some((format!("Find files named {}", truncate_middle(&pattern, 40)), &["name_pattern"]));
}
if let Some(pattern) = lookup_string(args, "content_pattern") {
return Some((format!("Search files for {}", truncate_middle(&pattern, 40)), &["content_pattern"]));
}
None
}
#[must_use]
pub fn describe_grep_file(args: &Value, workspace_root: Option<&Path>) -> Option<(String, &'static [&'static str])> {
let pattern = lookup_string(args, "pattern");
let path = lookup_string(args, "path");
match (pattern, path) {
(Some(pat), Some(path)) => Some((
format!(
"Grep {} in {}",
truncate_middle(&pat, 40),
truncate_path_middle(&relativize_to_workspace(&path, workspace_root), 40)
),
&["pattern", "path"],
)),
(Some(pat), None) => Some((format!("Grep {}", truncate_middle(&pat, 40)), &["pattern"])),
_ => None,
}
}
#[must_use]
pub fn describe_code_search(args: &Value) -> Option<(String, &'static [&'static str])> {
for key in ["query", "pattern", "q", "text"] {
if let Some(query) = lookup_string(args, key) {
let keys: &'static [&'static str] = match key {
"query" => &["query"],
"pattern" => &["pattern"],
"q" => &["q"],
_ => &["text"],
};
return Some((format!("Search code for {}", truncate_middle(&query, 40)), keys));
}
}
None
}
#[must_use]
pub fn describe_fetch_action(args: &Value) -> (String, &'static [&'static str]) {
if let Some(url) = lookup_string(args, "url") {
return (format!("Fetch {url}"), &["url"]);
}
("Use Fetch".to_string(), &[])
}
const SCRIPT_RUNNER_FLAGS: &[&str] = &["-c", "-e", "--code"];
#[must_use]
pub 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();
let Some((first, rest)) = lines.split_first() else {
return String::new();
};
let mut first_line = collapse_whitespace(first);
if is_script_prefix_only(&first_line) && !rest.is_empty() {
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 rest {
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())
}
#[must_use]
pub fn preview_full_command(command: &str) -> String {
preview_command(command, usize::MAX)
}
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()
}
#[must_use]
pub fn unified_exec_action_label(args: &Value) -> &'static str {
let action = crate::tool_intent::command_session_action(args).unwrap_or("run");
if action.eq_ignore_ascii_case("run") {
"Run command"
} else if action.eq_ignore_ascii_case("write") {
"Send command input"
} else if action.eq_ignore_ascii_case("poll") {
"Read command session"
} else if action.eq_ignore_ascii_case("wait") {
"Wait for command session"
} else if action.eq_ignore_ascii_case("continue") {
"Continue command session"
} else if action.eq_ignore_ascii_case("inspect") {
"Inspect command output"
} else if action.eq_ignore_ascii_case("list") {
"List command sessions"
} else if action.eq_ignore_ascii_case("close") {
"Close command session"
} else if action.eq_ignore_ascii_case("code") {
"Run code"
} else {
"Exec action"
}
}
#[must_use]
pub fn verb_callout_headline(tool_name: &str, args: &Value, workspace_root: Option<&Path>) -> String {
let name = strip_mcp_prefix(tool_name);
if let Some(label) = session_followup_title(name, args) {
return label;
}
if name == "list_files" {
if let Some((title, _)) = describe_list_files(args, workspace_root) {
return title;
}
} else if name == "grep_file" {
if let Some((title, _)) = describe_grep_file(args, workspace_root) {
return title;
}
}
match name {
"read_file" | "read_pty_session" | "web_fetch" | "fetch_url" | "defuddle_fetch" | "fetch" | "get_errors"
| "list_files" => {
if let Some(base) = headline_basename(args) {
return format!("Read {base}");
}
if lookup_first_string(args, &["url"]).is_some() {
let (title, _) = describe_fetch_action(args);
return title;
}
}
"edit_file" | "write_file" | "create_file" | "delete_file" | "apply_patch" | "search_replace" => {
if let Some(base) = headline_basename(args) {
return format!("Edited {base}");
}
}
"exec_command" | "run_pty_cmd" | "create_pty_session" | "shell" | "bash" => {
if let Some(command) = command_text(args) {
return format!("Ran {command}");
}
}
"code_search" | "grep" | "grep_file" => {
if let Some((title, _)) = search_headline(name, args) {
return title;
}
}
"command_session_internal" => {
let action = crate::tool_intent::command_session_action(args).unwrap_or("run");
if !action.eq_ignore_ascii_case("run") {
return unified_exec_action_label(args).to_string();
}
if let Some(command) = command_text(args) {
return format!("Ran {command}");
}
}
"file_operation_internal" => {
let action = args.get("action").and_then(Value::as_str).unwrap_or("read");
if action.eq_ignore_ascii_case("read") {
if let Some(base) = headline_basename(args) {
return format!("Read {base}");
}
} else if ["write", "edit", "patch", "delete", "move", "copy"]
.iter()
.any(|known| action.eq_ignore_ascii_case(known))
&& let Some(base) = headline_basename(args)
{
return format!("Edited {base}");
}
}
_ => {}
}
humanize_tool_name(tool_name)
}
#[must_use]
pub fn session_followup_title(name: &str, args: &Value) -> Option<String> {
match name {
"send_pty_input" => Some("Send command input".to_string()),
"read_pty_session" => Some("Read command session".to_string()),
"write_stdin" => Some(write_stdin_action_label(args).to_string()),
"command_session_internal" => {
let action = crate::tool_intent::command_session_action(args).unwrap_or("run");
(!action.eq_ignore_ascii_case("run")).then(|| unified_exec_action_label(args).to_string())
}
_ => None,
}
}
#[must_use]
pub fn search_headline(name: &str, args: &Value) -> Option<(String, &'static [&'static str])> {
if name == "code_search" {
return describe_code_search(args);
}
for key in ["pattern", "query"] {
if let Some(pattern) = lookup_string(args, key) {
let keys: &'static [&'static str] = if key == "pattern" { &["pattern"] } else { &["query"] };
return Some((format!("Search files for {}", truncate_middle(&pattern, 40)), keys));
}
}
None
}
fn headline_basename(args: &Value) -> Option<String> {
lookup_first_string(args, &["path", "file_path", "filepath", "target_path"])
.as_deref()
.and_then(|path| path.split(['/', '\\']).rfind(|segment| !segment.is_empty()))
.map(str::to_string)
}
fn command_text(args: &Value) -> Option<String> {
let text = if let Some(text) = lookup_first_string(args, &["command", "cmd", "raw_command", "bash_command"]) {
text
} else {
let parts: Vec<&str> = args.get("command")?.as_array()?.iter().filter_map(Value::as_str).collect();
let joined = parts.join(" ");
(!joined.trim().is_empty()).then(|| joined.trim().to_string())?
};
Some(preview_full_command(&text))
}
#[must_use]
pub fn write_stdin_action_label(args: &Value) -> &'static str {
if let Some(action) = args.get("action").and_then(Value::as_str) {
if action.eq_ignore_ascii_case("wait") {
return "Wait for command session";
}
if action.eq_ignore_ascii_case("poll") {
return "Read command session";
}
if action.eq_ignore_ascii_case("inspect") {
return "Inspect command output";
}
if action.eq_ignore_ascii_case("close") {
return "Close command session";
}
if action.eq_ignore_ascii_case("terminate") {
return "Terminate command session";
}
if action.eq_ignore_ascii_case("write") {
return if has_session_input(args) {
"Send command input"
} else {
"Read command session"
};
}
}
if has_session_input(args) {
"Send command input"
} else {
"Read command session"
}
}
#[must_use]
pub fn has_session_input(args: &Value) -> bool {
["chars", "input", "text"]
.iter()
.any(|key| args.get(key).and_then(Value::as_str).is_some_and(|s| !s.is_empty()))
}
#[must_use]
pub fn harness_is_user_visible(kind: &vtcode_exec_events::HarnessEventKind) -> bool {
use vtcode_exec_events::HarnessEventKind::*;
match kind {
ToolLatencyRecorded
| PlanningStarted
| PlanningCompleted
| ContinuationStarted
| ContinuationSkipped
| EvaluationStarted
| EvaluationPassed
| RevisionStarted
| VerificationStarted
| VerificationPassed
| SnapshotCreated
| DelegatedAgentStatus
| SessionMessageSent
| SessionMessageReceived => false,
SessionMessageExpired
| TurnBlocked
| BlockedRecoveryStarted
| BlockedRecoveryFinished
| BlockedHandoffWritten
| BlockedHandoffResolved
| EvaluationFailed
| EscalationTriggered
| EscalationBypassed
| VerificationFailed
| ErrorRecovered
| ToolRetryAttempted
| SnapshotRestored
| SessionToolLimitIncreased
| ToolLoopLimitIncreased
| BackgroundSubprocessCompleted => true,
}
}
#[cfg(test)]
mod tests;