1use std::borrow::Cow;
16use std::path::Path;
17
18use serde_json::Value;
19
20use crate::formatting::{collapse_whitespace, truncate_middle, truncate_path_middle};
21
22#[must_use]
24pub fn lookup_string(args: &Value, key: &str) -> Option<String> {
25 lookup_first_string(args, &[key])
26}
27
28#[must_use]
34pub fn lookup_first_string(args: &Value, keys: &[&str]) -> Option<String> {
35 keys.iter()
36 .filter_map(|key| args.get(key).and_then(Value::as_str))
37 .find_map(|text| {
38 let trimmed = text.trim();
39 (!trimmed.is_empty()).then(|| trimmed.to_string())
40 })
41}
42
43#[must_use]
49pub fn relativize_to_workspace<'a>(path: &'a str, workspace_root: Option<&Path>) -> Cow<'a, str> {
50 let Some(root) = workspace_root else {
51 return Cow::Borrowed(path);
52 };
53 let candidate = Path::new(path);
54 if candidate.is_absolute() {
55 if let Ok(rel) = candidate.strip_prefix(root) {
56 if !rel.as_os_str().is_empty() {
57 return Cow::Owned(rel.to_string_lossy().into_owned());
58 }
59 }
60 }
61 Cow::Borrowed(path)
62}
63
64#[must_use]
66pub fn is_path_key(key: &str) -> bool {
67 matches!(key, "path" | "file_path" | "filename" | "destination" | "source")
68}
69
70#[must_use]
76pub fn humanize_key(key: &str) -> String {
77 let replaced = key.replace('_', " ");
78 if replaced.is_empty() {
79 return replaced;
80 }
81 let mut chars = replaced.chars();
82 let first = chars.next().unwrap_or_default();
83 let mut result = first.to_uppercase().collect::<String>();
84 result.push_str(&chars.collect::<String>());
85 result
86}
87
88#[must_use]
96pub fn humanize_tool_name(tool_name: &str) -> String {
97 let bare = strip_mcp_prefix(tool_name);
98 let mut words: Vec<String> = Vec::new();
99 let mut current = String::new();
100 let mut previous: Option<char> = None;
101 for c in bare.chars() {
105 if c == '_' {
106 if !current.is_empty() {
107 words.push(std::mem::take(&mut current));
108 }
109 } else {
110 let boundary = previous.is_some_and(|prev| c.is_uppercase() && (prev.is_lowercase() || prev.is_numeric()));
111 if boundary && !current.is_empty() {
112 words.push(std::mem::take(&mut current));
113 }
114 current.push(c);
115 }
116 previous = Some(c);
117 }
118 if !current.is_empty() {
119 words.push(current);
120 }
121 if words.is_empty() {
122 return tool_name.to_string();
123 }
124 for word in &mut words {
125 let mut chars = word.chars();
128 if let Some(first) = chars.next() {
129 let mut pretty = first.to_uppercase().collect::<String>();
130 pretty.push_str(chars.as_str());
131 *word = pretty;
132 }
133 }
134 words.join(" ")
135}
136
137#[must_use]
145pub fn mcp_tool_display_name(tool_name: &str) -> Option<&str> {
146 if tool_name.starts_with("mcp__") {
147 return Some(tool_name.split("__").last().unwrap_or(tool_name));
148 }
149 if tool_name.starts_with("mcp::") {
150 return Some(tool_name.split("::").last().unwrap_or(tool_name));
151 }
152 if let Some(rest) = tool_name.strip_prefix("mcp_") {
153 return Some(rest);
154 }
155 None
156}
157
158#[must_use]
164pub fn strip_mcp_prefix(tool_name: &str) -> &str {
165 mcp_tool_display_name(tool_name).unwrap_or(tool_name)
166}
167
168#[must_use]
170pub fn summarize_list(items: &[String]) -> String {
171 if items.is_empty() {
172 return String::new();
173 }
174 let shown: Vec<String> = items.iter().take(2).map(|s| truncate_middle(s, 60)).collect();
175 if items.len() > 2 {
176 format!("{} +{} more", shown.join(", "), items.len() - 2)
177 } else {
178 shown.join(", ")
179 }
180}
181
182#[must_use]
184pub fn is_verbose_key(key: &str) -> bool {
185 matches!(
186 key,
187 "command"
188 | "raw_command"
189 | "bash_command"
190 | "cmd"
191 | "old_str"
192 | "new_str"
193 | "content"
194 | "new_content"
195 | "text"
196 | "patch"
197 | "code"
198 )
199}
200
201fn text_is_search_query(tool_name: &str, key: &str) -> bool {
207 key == "text" && strip_mcp_prefix(tool_name) == "code_search"
208}
209
210#[must_use]
216pub fn is_noise_param(key: &str) -> bool {
217 matches!(
218 key,
219 "timeout_secs"
221 | "timeout"
222 | "max_bytes"
223 | "max_matches"
224 | "debug_query"
226 | "strictness"
227 | "case_sensitive"
228 | "literal"
229 | "context_lines"
230 | "shell"
232 | "login"
233 | "tty"
234 | "sandbox_permissions"
235 | "additional_permissions"
236 | "justification"
237 | "prefix_rule"
238 | "workdir"
239 | "cwd"
240 | "language"
241 | "spool_path"
242 | "type"
244 | "tool_call_id"
245 | "call_type"
246 | "action"
248 | "max_output_tokens"
250 | "max_tokens"
251 )
252}
253
254#[must_use]
264pub fn collect_param_details(
265 tool_name: &str,
266 args: &Value,
267 skipped: &[&str],
268 workspace_root: Option<&Path>,
269) -> Vec<String> {
270 let mut details = Vec::new();
271 let Some(map) = args.as_object() else {
272 return details;
273 };
274 for (key, value) in map {
275 let verbose_hidden = is_verbose_key(key) && !text_is_search_query(tool_name, key);
276 if is_noise_param(key) || verbose_hidden || skipped.contains(&key.as_str()) {
277 continue;
278 }
279 match value {
280 Value::String(s) if !s.is_empty() => {
281 let display: Cow<'_, str> = if is_path_key(key) {
283 relativize_to_workspace(s, workspace_root)
284 } else {
285 Cow::Borrowed(s.as_str())
286 };
287 details.push(format!("{}: {}", humanize_key(key), truncate_middle(&display, 60)))
288 }
289 Value::Bool(true) => {
290 details.push(humanize_key(key));
291 }
292 Value::Array(items) => {
293 let strings: Vec<String> =
294 items.iter().filter_map(|item| item.as_str().map(|s| s.to_string())).collect();
295 if !strings.is_empty() {
296 details.push(format!("{}: {}", humanize_key(key), summarize_list(&strings)));
297 }
298 }
299 Value::Number(num) => {
300 if num.as_f64().is_some_and(|n| n == 0.0) {
302 continue;
303 }
304 details.push(format!("{}: {}", humanize_key(key), num));
305 }
306 _ => {}
307 }
308 }
309 details
310}
311
312#[must_use]
320pub fn exec_session_param_detail(args: &Value) -> Option<String> {
321 let session = lookup_string(args, "session_id").map(|id| format!("Session {id}"));
322 let wait = ["wait_timeout_seconds", "timeout_seconds"]
323 .iter()
324 .find_map(|key| args.get(key).and_then(Value::as_u64))
325 .filter(|secs| *secs > 0)
326 .map(|secs| format!("wait up to {secs}s"));
327
328 let parts = [session, wait].into_iter().flatten().collect::<Vec<_>>();
329 (!parts.is_empty()).then(|| parts.join(" · "))
330}
331
332#[must_use]
336pub fn describe_list_files(args: &Value, workspace_root: Option<&Path>) -> Option<(String, &'static [&'static str])> {
337 if let Some(path) = lookup_string(args, "path") {
338 let location = if path == "." {
339 "workspace root".to_string()
340 } else {
341 let rel = relativize_to_workspace(&path, workspace_root);
342 truncate_path_middle(&rel, 60)
343 };
344 return Some((format!("List files in {location}"), &["path"]));
345 }
346 if let Some(pattern) = lookup_string(args, "name_pattern") {
347 return Some((format!("Find files named {}", truncate_middle(&pattern, 40)), &["name_pattern"]));
348 }
349 if let Some(pattern) = lookup_string(args, "content_pattern") {
350 return Some((format!("Search files for {}", truncate_middle(&pattern, 40)), &["content_pattern"]));
351 }
352 None
353}
354
355#[must_use]
358pub fn describe_grep_file(args: &Value, workspace_root: Option<&Path>) -> Option<(String, &'static [&'static str])> {
359 let pattern = lookup_string(args, "pattern");
360 let path = lookup_string(args, "path");
361 match (pattern, path) {
362 (Some(pat), Some(path)) => Some((
363 format!(
364 "Grep {} in {}",
365 truncate_middle(&pat, 40),
366 truncate_path_middle(&relativize_to_workspace(&path, workspace_root), 40)
367 ),
368 &["pattern", "path"],
369 )),
370 (Some(pat), None) => Some((format!("Grep {}", truncate_middle(&pat, 40)), &["pattern"])),
371 _ => None,
372 }
373}
374
375#[must_use]
382pub fn describe_code_search(args: &Value) -> Option<(String, &'static [&'static str])> {
383 for key in ["query", "pattern", "q", "text"] {
384 if let Some(query) = lookup_string(args, key) {
385 let keys: &'static [&'static str] = match key {
386 "query" => &["query"],
387 "pattern" => &["pattern"],
388 "q" => &["q"],
389 _ => &["text"],
390 };
391 return Some((format!("Search code for {}", truncate_middle(&query, 40)), keys));
392 }
393 }
394 None
395}
396
397#[must_use]
403pub fn describe_fetch_action(args: &Value) -> (String, &'static [&'static str]) {
404 if let Some(url) = lookup_string(args, "url") {
405 return (format!("Fetch {url}"), &["url"]);
406 }
407 ("Use Fetch".to_string(), &[])
408}
409
410const SCRIPT_RUNNER_FLAGS: &[&str] = &["-c", "-e", "--code"];
413
414#[must_use]
426pub fn preview_command(command: &str, max_len: usize) -> String {
427 if max_len == 0 {
428 return String::new();
429 }
430 let lines: Vec<&str> = command.lines().map(str::trim).filter(|line| !line.is_empty()).collect();
431 let Some((first, rest)) = lines.split_first() else {
432 return String::new();
433 };
434 let mut first_line = collapse_whitespace(first);
435 if is_script_prefix_only(&first_line) && !rest.is_empty() {
436 let need = max_len.saturating_sub(first_line.chars().count()).saturating_add(16);
439 let mut rest_parts = Vec::new();
440 let mut rest_len = 0usize;
441 for line in rest {
442 let collapsed = collapse_whitespace(line);
443 if collapsed.is_empty() {
444 continue;
445 }
446 rest_len += collapsed.chars().count() + 1;
447 rest_parts.push(collapsed);
448 if rest_len >= need {
449 break;
450 }
451 }
452 if !rest_parts.is_empty() {
453 let rest = rest_parts.join(" ");
454 if first_line.ends_with('"') || first_line.ends_with('\'') {
455 first_line.push_str(&rest);
460 } else {
461 first_line.push(' ');
462 first_line.push_str(&rest);
463 }
464 }
465 }
466 if first_line.chars().count() <= max_len {
467 return first_line;
468 }
469
470 let budget = max_len.saturating_sub(1);
471 let mut head: String = first_line.chars().take(budget).collect();
472 if let Some(last_space) = head.rfind(char::is_whitespace) {
473 if last_space >= budget / 2 {
476 head.truncate(last_space);
477 }
478 }
479 format!("{}…", head.trim_end())
480}
481
482#[must_use]
489pub fn preview_full_command(command: &str) -> String {
490 preview_command(command, usize::MAX)
491}
492
493fn is_script_runner_program(program: &str) -> bool {
498 let base = program.rsplit(['/', '\\']).next().unwrap_or(program).to_ascii_lowercase();
499 base.starts_with("python")
500 || matches!(
501 base.as_str(),
502 "bash" | "sh" | "zsh" | "node" | "ruby" | "perl" | "php" | "deno" | "bun" | "pwsh" | "powershell"
503 )
504}
505
506fn is_script_prefix_only(first_line: &str) -> bool {
515 let mut tokens = first_line.split_whitespace();
516 let Some(program) = tokens.next() else {
517 return false;
518 };
519 if !is_script_runner_program(program) {
520 return false;
521 }
522 if !tokens.clone().any(|token| SCRIPT_RUNNER_FLAGS.contains(&token)) {
523 return false;
524 }
525 let token_count = 1 + tokens.clone().count();
527 if token_count == 2 {
528 return true;
529 }
530 shell_words::split(first_line).is_err()
533}
534
535#[must_use]
543pub fn unified_exec_action_label(args: &Value) -> &'static str {
544 let action = crate::tool_intent::command_session_action(args).unwrap_or("run");
545 if action.eq_ignore_ascii_case("run") {
546 "Run command"
547 } else if action.eq_ignore_ascii_case("write") {
548 "Send command input"
549 } else if action.eq_ignore_ascii_case("poll") {
550 "Read command session"
551 } else if action.eq_ignore_ascii_case("wait") {
552 "Wait for command session"
553 } else if action.eq_ignore_ascii_case("continue") {
554 "Continue command session"
555 } else if action.eq_ignore_ascii_case("inspect") {
556 "Inspect command output"
557 } else if action.eq_ignore_ascii_case("list") {
558 "List command sessions"
559 } else if action.eq_ignore_ascii_case("close") {
560 "Close command session"
561 } else if action.eq_ignore_ascii_case("code") {
562 "Run code"
563 } else {
564 "Exec action"
565 }
566}
567
568#[must_use]
578pub fn verb_callout_headline(tool_name: &str, args: &Value, workspace_root: Option<&Path>) -> String {
579 let name = strip_mcp_prefix(tool_name);
580 if let Some(label) = session_followup_title(name, args) {
583 return label;
584 }
585 if name == "list_files" {
586 if let Some((title, _)) = describe_list_files(args, workspace_root) {
587 return title;
588 }
589 } else if name == "grep_file" {
590 if let Some((title, _)) = describe_grep_file(args, workspace_root) {
591 return title;
592 }
593 }
594 match name {
595 "read_file" | "read_pty_session" | "web_fetch" | "fetch_url" | "defuddle_fetch" | "fetch" | "get_errors"
596 | "list_files" => {
597 if let Some(base) = headline_basename(args) {
598 return format!("Read {base}");
599 }
600 if lookup_first_string(args, &["url"]).is_some() {
601 let (title, _) = describe_fetch_action(args);
602 return title;
603 }
604 }
605 "edit_file" | "write_file" | "create_file" | "delete_file" | "apply_patch" | "search_replace" => {
606 if let Some(base) = headline_basename(args) {
607 return format!("Edited {base}");
608 }
609 }
610 "exec_command" | "run_pty_cmd" | "create_pty_session" | "shell" | "bash" => {
611 if let Some(command) = command_text(args) {
612 return format!("Ran {command}");
613 }
614 }
615 "code_search" | "grep" | "grep_file" => {
616 if let Some((title, _)) = search_headline(name, args) {
617 return title;
618 }
619 }
620 "command_session_internal" => {
621 let action = crate::tool_intent::command_session_action(args).unwrap_or("run");
622 if !action.eq_ignore_ascii_case("run") {
623 return unified_exec_action_label(args).to_string();
624 }
625 if let Some(command) = command_text(args) {
626 return format!("Ran {command}");
627 }
628 }
629 "file_operation_internal" => {
630 let action = args.get("action").and_then(Value::as_str).unwrap_or("read");
631 if action.eq_ignore_ascii_case("read") {
632 if let Some(base) = headline_basename(args) {
633 return format!("Read {base}");
634 }
635 } else if ["write", "edit", "patch", "delete", "move", "copy"]
636 .iter()
637 .any(|known| action.eq_ignore_ascii_case(known))
638 && let Some(base) = headline_basename(args)
639 {
640 return format!("Edited {base}");
641 }
642 }
643 _ => {}
644 }
645 humanize_tool_name(tool_name)
646}
647
648#[must_use]
651pub fn session_followup_title(name: &str, args: &Value) -> Option<String> {
652 match name {
653 "send_pty_input" => Some("Send command input".to_string()),
654 "read_pty_session" => Some("Read command session".to_string()),
655 "write_stdin" => Some(write_stdin_action_label(args).to_string()),
656 "command_session_internal" => {
657 let action = crate::tool_intent::command_session_action(args).unwrap_or("run");
658 (!action.eq_ignore_ascii_case("run")).then(|| unified_exec_action_label(args).to_string())
659 }
660 _ => None,
661 }
662}
663
664#[must_use]
667pub fn search_headline(name: &str, args: &Value) -> Option<(String, &'static [&'static str])> {
668 if name == "code_search" {
669 return describe_code_search(args);
670 }
671 for key in ["pattern", "query"] {
672 if let Some(pattern) = lookup_string(args, key) {
673 let keys: &'static [&'static str] = if key == "pattern" { &["pattern"] } else { &["query"] };
674 return Some((format!("Search files for {}", truncate_middle(&pattern, 40)), keys));
675 }
676 }
677 None
678}
679
680fn headline_basename(args: &Value) -> Option<String> {
682 lookup_first_string(args, &["path", "file_path", "filepath", "target_path"])
683 .as_deref()
684 .and_then(|path| path.split(['/', '\\']).rfind(|segment| !segment.is_empty()))
685 .map(str::to_string)
686}
687
688fn command_text(args: &Value) -> Option<String> {
690 let text = if let Some(text) = lookup_first_string(args, &["command", "cmd", "raw_command", "bash_command"]) {
691 text
692 } else {
693 let parts: Vec<&str> = args.get("command")?.as_array()?.iter().filter_map(Value::as_str).collect();
694 let joined = parts.join(" ");
695 (!joined.trim().is_empty()).then(|| joined.trim().to_string())?
696 };
697 Some(preview_full_command(&text))
698}
699#[must_use]
702pub fn write_stdin_action_label(args: &Value) -> &'static str {
703 if let Some(action) = args.get("action").and_then(Value::as_str) {
704 if action.eq_ignore_ascii_case("wait") {
705 return "Wait for command session";
706 }
707 if action.eq_ignore_ascii_case("poll") {
708 return "Read command session";
709 }
710 if action.eq_ignore_ascii_case("inspect") {
711 return "Inspect command output";
712 }
713 if action.eq_ignore_ascii_case("close") {
714 return "Close command session";
715 }
716 if action.eq_ignore_ascii_case("terminate") {
717 return "Terminate command session";
718 }
719 if action.eq_ignore_ascii_case("write") {
720 return if has_session_input(args) {
723 "Send command input"
724 } else {
725 "Read command session"
726 };
727 }
728 }
729 if has_session_input(args) {
730 "Send command input"
731 } else {
732 "Read command session"
735 }
736}
737
738#[must_use]
740pub fn has_session_input(args: &Value) -> bool {
741 ["chars", "input", "text"]
742 .iter()
743 .any(|key| args.get(key).and_then(Value::as_str).is_some_and(|s| !s.is_empty()))
744}
745
746#[must_use]
762pub fn harness_is_user_visible(kind: &vtcode_exec_events::HarnessEventKind) -> bool {
763 use vtcode_exec_events::HarnessEventKind::*;
764 match kind {
765 ToolLatencyRecorded
766 | PlanningStarted
767 | PlanningCompleted
768 | ContinuationStarted
769 | ContinuationSkipped
770 | EvaluationStarted
771 | EvaluationPassed
772 | RevisionStarted
773 | VerificationStarted
774 | VerificationPassed
775 | SnapshotCreated
776 | DelegatedAgentStatus
777 | SessionMessageSent
778 | SessionMessageReceived => false,
779 SessionMessageExpired
780 | TurnBlocked
781 | BlockedRecoveryStarted
782 | BlockedRecoveryFinished
783 | BlockedHandoffWritten
784 | BlockedHandoffResolved
785 | EvaluationFailed
786 | EscalationTriggered
787 | EscalationBypassed
788 | VerificationFailed
789 | ErrorRecovered
790 | ToolRetryAttempted
791 | SnapshotRestored
792 | SessionToolLimitIncreased
793 | ToolLoopLimitIncreased
794 | BackgroundSubprocessCompleted => true,
795 }
796}
797
798#[cfg(test)]
799mod tests;