Skip to main content

vtcode_commons/
tool_headlines.rs

1//! Shared tool-call headlines and transcript detail lines.
2//!
3//! Single home for the display phrases both transcript surfaces render — the
4//! CLI (`src/agent/runloop/unified/tool_summary*`) and the desktop app
5//! (`vtcode-desktop/src/desktop/transcript/`) — so a headline or skip list can
6//! never drift between them. String-based by design (same contract as
7//! [`crate::tracker_title`]): callers walk their own payload shapes and
8//! converge here for the humanization, truncation, and fallback rules.
9//!
10//! Surface composition stays local: the CLI keeps its `(headline, used)`
11//! dispatcher, `MCP ` prefix policy, and TUI renderers; the desktop keeps its
12//! verb categories, single-line collapse, and GPUI renderers. Only the pure
13//! phrase builders live here.
14
15use 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/// First non-blank string under a single key, trimmed.
23#[must_use]
24pub fn lookup_string(args: &Value, key: &str) -> Option<String> {
25    lookup_first_string(args, &[key])
26}
27
28/// First non-blank string under the given keys, trimmed.
29///
30/// Blank and mistyped values are skipped so a headline never phrases
31/// whitespace: trimming here (not at each caller) keeps every describer
32/// total over messy agent payloads.
33#[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/// Display a path relative to the workspace root when it lies within it.
44///
45/// Returns the path unchanged when `workspace_root` is `None`, the path is
46/// not absolute, or it does not lie within the root (including the
47/// degenerate path-equals-root case, kept in its original form).
48#[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/// Keys whose values are file-system paths, shown relative to the workspace.
65#[must_use]
66pub fn is_path_key(key: &str) -> bool {
67    matches!(key, "path" | "file_path" | "filename" | "destination" | "source")
68}
69
70/// Humanize a parameter key (`timeout_secs` → `Timeout secs`).
71///
72/// Only the first letter is capitalized: detail lines read as labels, not
73/// titles. Tool *names* take [`humanize_tool_name`] instead, which splits
74/// word boundaries per word.
75#[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/// Human fallback for tool names the headline readers cannot phrase.
89///
90/// Strips MCP prefixes, then splits `snake_case` and `PascalCase`/`camelCase`
91/// boundaries into capitalized words (`DiagramCommand` → `Diagram Command`,
92/// `diagram_render` → `Diagram Render`). Only the first letter of each word
93/// is touched, so acronyms (`MCP`) and versions (`v2`) survive. Never
94/// returns blank: a name with no word characters falls back to itself.
95#[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    // Single char walk, no slicing: `_` always breaks, and a new word starts
102    // before an uppercase letter that follows a lowercase letter or digit.
103    // Acronym runs (`MCPTool`) stay whole rather than guessing.
104    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        // Only the first letter is touched, so acronyms (`MCP`) and versions
126        // (`v2`) survive; the rest keeps its original casing.
127        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/// Bare tool name from an MCP-qualified one (`None` for non-MCP names).
138///
139/// Accepts the `mcp__server__tool`, `mcp::provider::tool`, and legacy
140/// `mcp_tool` spellings. Canonical MCP name parser — do not fork the
141/// prefix/segment handling per call site, so every surface labels the same
142/// tool identically. Surfaces that drop the prefix unconditionally take
143/// [`strip_mcp_prefix`] instead.
144#[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/// MCP prefixes stripped, or the name itself when it carries none.
159///
160/// Total form of [`mcp_tool_display_name`] for surfaces that never show the
161/// prefix. The legacy single-underscore arm runs last, so it cannot misfire
162/// on the qualified `mcp__` form.
163#[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/// Summarize a string list to two items plus an overflow count.
169#[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/// Raw content keys too verbose for a summary row (commands, file bodies).
183#[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
201/// Whether `text` is an accepted query alias for this tool rather than a body.
202///
203/// `code_search` phrases `text` as a last-resort query alias, so an
204/// unconsumed `text` value shows instead of hiding as verbose content;
205/// everywhere else (edits, unknown tools) `text` stays a hidden body.
206fn text_is_search_query(tool_name: &str, key: &str) -> bool {
207    key == "text" && strip_mcp_prefix(tool_name) == "code_search"
208}
209
210/// Parameter keys that are infrastructure/plumbing noise, omitted from the
211/// human-facing summary. Session plumbing (`session_id`, `chars`, wait
212/// deadlines) is not noise: [`exec_session_param_detail`] folds it into one
213/// identity row instead. Search strings (`query`) are content, never
214/// plumbing: they hide only when the headline consumed them.
215#[must_use]
216pub fn is_noise_param(key: &str) -> bool {
217    matches!(
218        key,
219        // Timeouts and size limits
220        "timeout_secs"
221            | "timeout"
222            | "max_bytes"
223            | "max_matches"
224            // Search plumbing
225            | "debug_query"
226            | "strictness"
227            | "case_sensitive"
228            | "literal"
229            | "context_lines"
230            // Execution plumbing
231            | "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            // Tool identity / routing
243            | "type"
244            | "tool_call_id"
245            | "call_type"
246            // Redundant with summary headline (e.g., "Read file" already implies action=read)
247            | "action"
248            // Output caps, same plumbing class as the size limits above.
249            | "max_output_tokens"
250            | "max_tokens"
251    )
252}
253
254/// Human `Key: value` lines for the arguments a headline does not consume.
255///
256/// Verbose content keys, plumbing noise, and the caller-supplied `skipped`
257/// keys (headline-consumed) stay out; strings truncate to 60 columns, paths
258/// relativize against the workspace, lists summarize to two items, and only
259/// `true` flags and non-zero numbers render. Key order follows
260/// `serde_json::Map` iteration. An empty `skipped` slice shows every
261/// displayable key — no separate include-all flag needed. `tool_name` (MCP
262/// prefixes accepted) decides the `text` exemption below.
263#[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                // Render file-system path values relative to the workspace root.
282                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                // Skip zero-valued numbers — they are defaults and add no information.
301                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/// One row for an exec-session follow-up: session identity plus an explicit
313/// wait deadline.
314///
315/// Output-token caps, yield windows, and the raw stdin payload are
316/// deliberately dropped: the model receives the arguments unchanged, and the
317/// reader only needs the session at a glance. Returns `None` when the call
318/// carries neither, so non-session tools keep their normal detail rows.
319#[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/// Headline for `list_files`: location first, then the name and content
333/// pattern fallbacks. Reports exactly the keys its headline consumed, so
334/// detail collection hides if and only if the title phrased them.
335#[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/// Headline for `grep_file`: the pattern is required, the scope joins it
356/// when present. Reports exactly the keys its headline consumed.
357#[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/// Headline for `code_search` over the query and its alias keys.
376///
377/// The schema requires `query`, but common aliases are accepted defensively
378/// so a valid search never degrades to a generic header. Records the actual
379/// matched key so detail collection and headline highlights stay in sync (a
380/// hardcoded "query" would leak e.g. `Pattern: …` as a duplicate line).
381#[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/// Headline for the fetch tools over the full target URL.
398///
399/// Approval surfaces must show the full URL (not just the domain) so the
400/// reader reviews the exact target. Falls back to a bare verb when no URL
401/// is present, consuming no keys.
402#[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
410/// Exact flag tokens that introduce an inline script for a runner.
411/// Token-exact (not substring) so `grep -c` / `--code-review` never match.
412const SCRIPT_RUNNER_FLAGS: &[&str] = &["-c", "-e", "--code"];
413
414/// Compact single-line preview of a command for `• Ran …` headers.
415///
416/// Multi-line commands preview their first non-empty line; long commands
417/// are head-truncated at a word boundary with a trailing ellipsis. The
418/// mid-string ellipsis of `truncate_middle` is avoided on purpose: cutting
419/// `checkpoints/turn_1032` into `tur…ool_calls` reads as a rendering bug.
420///
421/// When the first line is only a script-runner prefix (`python3 -c "`,
422/// bare `python3 -c`, or an unclosed quote on a `-c`/`-e`/`--code` runner),
423/// the following lines are folded in so the header shows real script content
424/// (`python3 -c "import json …`) instead of a dangling `python3 -c "`.
425#[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        // Bounded: only fold enough continuation lines to fill the preview
437        // budget instead of materializing a 100-line script into one String.
438        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                // `python3 -c "` + `import json` -> `python3 -c "import json`
456                // (no extra space: the newline after the opening quote is
457                // insignificant, and `collapse_whitespace` would leave a
458                // distracting `" import` gap).
459                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        // Only honor the word boundary when it keeps at least half the budget,
474        // so a late first space does not collapse the preview to almost nothing.
475        if last_space >= budget / 2 {
476            head.truncate(last_space);
477        }
478    }
479    format!("{}…", head.trim_end())
480}
481
482/// Full-length variant of [`preview_command`] for transcript surfaces.
483///
484/// Keeps the first-line selection and script-runner prefix folding (so
485/// `python3 -c "` still pulls in script content) but applies no length cap:
486/// the selected line shows in full, with viewport-aware wrapping owning the
487/// overflow instead of a `…` truncation.
488#[must_use]
489pub fn preview_full_command(command: &str) -> String {
490    preview_command(command, usize::MAX)
491}
492
493/// Whether `program` names a known inline-script runner (`python3`, `bash`,
494/// `node`, …). Compared against the path basename so `/usr/bin/python3` and
495/// Windows-style `C:\Python\python.exe` both match, keeping the check
496/// token-exact instead of substring-based.
497fn 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
506/// Whether the first preview line is just a script-runner prefix with no
507/// usable script content (e.g. `python3 -c "` or bare `python3 -c`).
508///
509/// Rendering such a prefix alone reads as broken (`• Ran python3 -c "`).
510/// Token-exact on both program and flag so `grep -c`, `grep -e`, and
511/// `--code-review` never match. Unclosed quoting is detected with the
512/// canonical `shell_words` parser (not raw `'` counting) so contractions
513/// inside a balanced script (`python3 -c "don't …"`) do not misfire.
514fn 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    // Bare flag with no script yet: `python3 -c`.
526    let token_count = 1 + tokens.clone().count();
527    if token_count == 2 {
528        return true;
529    }
530    // Unclosed quoting means the script continues on later lines, e.g.
531    // `python3 -c "` or `python3 -c "import json`.
532    shell_words::split(first_line).is_err()
533}
534
535/// Action-aware label for `command_session_internal` follow-up calls.
536///
537/// Canonical home of the session labels: `vtcode-core` re-exports this, and
538/// transcript surfaces call it directly. Launches take `Run command` /
539/// `Run code`; callers that phrase launches from the command itself (rather
540/// than a generic label) resolve [`crate::tool_intent::command_session_action`]
541/// first and skip the `run` action.
542#[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/// Verb-led running callout for any tool call (`Read main.rs`, `Ran cargo
569/// check`): the surface-neutral form of the headline an already-running
570/// call shows so the live status agrees with the completed summary row.
571///
572/// Falls back to the prettified tool name ([`humanize_tool_name`]) when the
573/// call has no ownable headline — never a raw identifier. `command` preview:
574/// first line, full length, via [`preview_full_command`], matching the
575/// completed-row preview. `workspace_root` relativizes `list_files` and
576/// `grep_file` path headlines exactly as those describers do.
577#[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    // Session follow-ups take the action-aware label first; a `run` launch
581    // falls through to the argument gist (the command itself).
582    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/// Action-aware title for session follow-up calls; `None` falls through to the
649/// argument gist (`run` launches show the command text instead).
650#[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/// Search headline per tool: `code_search` takes the shared describer;
665/// anything else phrases a file search over the first of pattern/query.
666#[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
680/// Basename of the path argument, in service display order.
681fn 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
688/// Command text (string or array form) previewed like completed rows.
689fn 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/// otherwise input sniffing decides send vs read so pure waits never
700/// misrender as sends.
701#[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            // Match `write_stdin_dispatch`: explicit write with empty/missing
721            // input polls instead of sending, so label it as a read.
722            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        // A session_id-only follow-up polls output; calling it "Send" misleads
733        // (screenshot 2026-10-02: pure waits rendered as sends).
734        "Read command session"
735    }
736}
737
738/// Whether the call carries a non-empty stdin payload under the input keys.
739#[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/// Whether a harness event belongs in the user-visible transcript.
747///
748/// Routine telemetry — latency records, lifecycle starts and passes,
749/// per-turn snapshots, delegation observations, mailbox plumbing — stays out:
750/// the transcript is a conversation surface, and the canonical event still
751/// carries the complete evidence for forensics. Failures, blocks, losses
752/// (including an expired undelivered mailbox message), recoveries, rewinds,
753/// background completions, and limit grants stay: the user can act on them.
754/// Exhaustive on purpose — a new `HarnessEventKind` must make a conscious
755/// display choice here at compile time instead of silently inheriting
756/// visibility.
757///
758/// Conversational surfaces (CLI transcript, desktop transcript) filter
759/// through this; forensic surfaces (log viewers, event exports) keep the
760/// complete stream.
761#[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;