Skip to main content

vtcode_core/tools/registry/
labels.rs

1use std::borrow::Cow;
2
3use serde_json::Value;
4
5use crate::config::constants::tools as tool_names;
6use crate::tools::mcp::legacy_mcp_tool_name;
7use crate::tools::tool_intent;
8
9pub fn tool_action_label(tool_name: &str, args: &Value) -> Cow<'static, str> {
10    if tool_intent::is_command_run_tool_call(tool_name, args) {
11        return Cow::Borrowed("Run command");
12    }
13
14    let actual_tool_name = normalize_tool_name(tool_name);
15
16    match actual_tool_name {
17        name if name == tool_names::EXEC_COMMAND => Cow::Borrowed("Run command"),
18        name if name == tool_names::WRITE_STDIN => Cow::Borrowed(write_stdin_action_label(args)),
19        name if name == tool_names::RUN_PTY_CMD => Cow::Borrowed("Run command"),
20        name if name == tool_names::EXECUTE_CODE => Cow::Borrowed("Run code"),
21        name if name == tool_names::GET_ERRORS => Cow::Borrowed("List errors"),
22        name if name == tool_names::MCP => Cow::Borrowed("MCP discovery"),
23        name if name == tool_names::MCP_CONNECT_SERVER => Cow::Borrowed("Connect MCP server"),
24        name if name == tool_names::MCP_DISCONNECT_SERVER => Cow::Borrowed("Disconnect MCP server"),
25        name if name == tool_names::LIST_SKILLS => Cow::Borrowed("List skills"),
26        name if name == tool_names::LOAD_SKILL => Cow::Borrowed("Load skill"),
27        name if name == tool_names::LOAD_SKILL_RESOURCE => Cow::Borrowed("Load skill resource"),
28        name if name == tool_names::READ_FILE => Cow::Borrowed("Read file"),
29        name if name == tool_names::WRITE_FILE => Cow::Borrowed("Write file"),
30        name if name == tool_names::EDIT_FILE => Cow::Borrowed("Edit file"),
31        name if name == tool_names::CREATE_FILE => Cow::Borrowed("Create file"),
32        name if name == tool_names::DELETE_FILE => Cow::Borrowed("Delete file"),
33        name if name == tool_names::APPLY_PATCH => Cow::Borrowed("Apply patch"),
34        name if name == tool_names::SEARCH_REPLACE => Cow::Borrowed("Search/replace"),
35        name if name == tool_names::CREATE_PTY_SESSION => Cow::Borrowed("Create command session"),
36        name if name == tool_names::READ_PTY_SESSION => Cow::Borrowed("Read command session"),
37        name if name == tool_names::LIST_PTY_SESSIONS => Cow::Borrowed("List command sessions"),
38        name if name == tool_names::SEND_PTY_INPUT => Cow::Borrowed("Send command input"),
39        name if name == tool_names::CLOSE_PTY_SESSION => Cow::Borrowed("Close command session"),
40        name if name == tool_names::RESIZE_PTY_SESSION => Cow::Borrowed("Resize command session"),
41        name if name == tool_names::UNIFIED_EXEC => Cow::Borrowed(unified_exec_action_label(args)),
42        name if name == tool_names::CODE_SEARCH => Cow::Borrowed("Search code"),
43        name if name == tool_names::UNIFIED_FILE => match tool_intent::file_operation_action(args).unwrap_or("read") {
44            "read" => Cow::Borrowed("Read file"),
45            "write" => Cow::Borrowed("Write file"),
46            "edit" => Cow::Borrowed("Edit file"),
47            "patch" | tool_names::APPLY_PATCH => Cow::Borrowed("Apply patch"),
48            "delete" => Cow::Borrowed("Delete file"),
49            "move" => Cow::Borrowed("Move file"),
50            "copy" => Cow::Borrowed("Copy file"),
51            _ => Cow::Borrowed("File operation"),
52        },
53        "fetch" | tool_names::WEB_FETCH | tool_names::FETCH_URL | tool_names::DEFUDDLE_FETCH => Cow::Borrowed("Fetch"),
54        _ => Cow::Owned(humanize_tool_name(actual_tool_name)),
55    }
56}
57
58pub fn unified_exec_action_label(args: &Value) -> &'static str {
59    let action = tool_intent::command_session_action(args).unwrap_or("run");
60    if action.eq_ignore_ascii_case("run") {
61        "Run command"
62    } else if action.eq_ignore_ascii_case("write") {
63        "Send command input"
64    } else if action.eq_ignore_ascii_case("poll") {
65        "Read command session"
66    } else if action.eq_ignore_ascii_case("wait") {
67        "Wait for command session"
68    } else if action.eq_ignore_ascii_case("continue") {
69        "Continue command session"
70    } else if action.eq_ignore_ascii_case("inspect") {
71        "Inspect command output"
72    } else if action.eq_ignore_ascii_case("list") {
73        "List command sessions"
74    } else if action.eq_ignore_ascii_case("close") {
75        "Close command session"
76    } else if action.eq_ignore_ascii_case("code") {
77        "Run code"
78    } else {
79        "Exec action"
80    }
81}
82
83pub fn write_stdin_action_label(args: &Value) -> &'static str {
84    if let Some(action) = args.get("action").and_then(Value::as_str) {
85        if action.eq_ignore_ascii_case("wait") {
86            return "Wait for command session";
87        }
88        if action.eq_ignore_ascii_case("poll") {
89            return "Read command session";
90        }
91        if action.eq_ignore_ascii_case("inspect") {
92            return "Inspect command output";
93        }
94        if action.eq_ignore_ascii_case("close") {
95            return "Close command session";
96        }
97        if action.eq_ignore_ascii_case("terminate") {
98            return "Terminate command session";
99        }
100        if action.eq_ignore_ascii_case("write") {
101            // Match `write_stdin_dispatch`: explicit write with empty/missing
102            // input polls instead of sending, so label it as a read.
103            return if has_session_input(args) {
104                "Send command input"
105            } else {
106                "Read command session"
107            };
108        }
109    }
110    if has_session_input(args) {
111        "Send command input"
112    } else {
113        // A session_id-only follow-up polls output; calling it "Send" misleads
114        // (screenshot 2026-10-02: pure waits rendered as sends).
115        "Read command session"
116    }
117}
118
119fn has_session_input(args: &Value) -> bool {
120    ["chars", "input", "text"]
121        .iter()
122        .any(|key| args.get(key).and_then(Value::as_str).is_some_and(|s| !s.is_empty()))
123}
124
125fn normalize_tool_name(tool_name: &str) -> &str {
126    if let Some(stripped) = legacy_mcp_tool_name(tool_name) {
127        return stripped;
128    }
129    if tool_name.starts_with("mcp__") {
130        return tool_name.split("__").last().unwrap_or(tool_name);
131    }
132    if tool_name.starts_with("mcp::") {
133        return tool_name.split("::").last().unwrap_or(tool_name);
134    }
135    tool_name
136}
137
138fn humanize_tool_name(name: &str) -> String {
139    let replaced = name.replace('_', " ");
140    if replaced.is_empty() {
141        return replaced;
142    }
143    let mut chars = replaced.chars();
144    let Some(first) = chars.next() else {
145        return String::new();
146    };
147    let mut result = first.to_uppercase().collect::<String>();
148    result.push_str(&chars.collect::<String>());
149    result
150}
151
152#[cfg(test)]
153mod tests {
154    use super::tool_action_label;
155    use crate::config::constants::tools;
156    use serde_json::json;
157
158    #[test]
159    fn code_search_uses_stable_label() {
160        assert_eq!(tool_action_label(tools::CODE_SEARCH, &json!({"query": "Widget"})), "Search code");
161    }
162
163    #[test]
164    fn write_stdin_distinguishes_wait_from_send() {
165        assert_eq!(
166            tool_action_label(tools::WRITE_STDIN, &json!({"session_id": "run-wait-1", "action": "wait"})),
167            "Wait for command session"
168        );
169        assert_eq!(
170            tool_action_label(tools::WRITE_STDIN, &json!({"session_id": "run-send-2", "chars": "y\n"})),
171            "Send command input"
172        );
173        assert_eq!(tool_action_label(tools::WRITE_STDIN, &json!({"session_id": "run-poll-3"})), "Read command session");
174    }
175
176    #[test]
177    fn unified_exec_wait_is_case_insensitive() {
178        for action in ["wait", "Wait", "WAIT", "WaIt"] {
179            assert_eq!(
180                tool_action_label(tools::UNIFIED_EXEC, &json!({"session_id": "run-abc", "action": action})),
181                "Wait for command session",
182                "action {action} must map case-insensitively"
183            );
184        }
185    }
186
187    #[test]
188    fn write_stdin_explicit_action_wins_over_chars() {
189        let chars = "y\n";
190        assert_eq!(
191            tool_action_label(
192                tools::WRITE_STDIN,
193                &json!({"session_id": "run-explicit-wait", "action": "wait", "chars": chars})
194            ),
195            "Wait for command session"
196        );
197        assert_eq!(
198            tool_action_label(
199                tools::WRITE_STDIN,
200                &json!({"session_id": "run-explicit-write", "action": "write", "chars": chars})
201            ),
202            "Send command input"
203        );
204    }
205
206    #[test]
207    fn write_stdin_empty_vs_whitespace_chars_boundary() {
208        assert_eq!(
209            tool_action_label(tools::WRITE_STDIN, &json!({"session_id": "run-empty", "chars": ""})),
210            "Read command session"
211        );
212        assert_eq!(
213            tool_action_label(tools::WRITE_STDIN, &json!({"session_id": "run-space", "chars": "   "})),
214            "Send command input",
215            "whitespace-only chars are still stdin bytes"
216        );
217    }
218
219    #[test]
220    fn write_stdin_explicit_write_without_input_reads_as_poll() {
221        // Matches `write_stdin_dispatch`: explicit write with empty/missing
222        // input executes as Poll, so the label must not claim a send.
223        assert_eq!(
224            tool_action_label(
225                tools::WRITE_STDIN,
226                &json!({"session_id": "run-write-empty", "action": "write", "chars": ""})
227            ),
228            "Read command session"
229        );
230        assert_eq!(
231            tool_action_label(tools::WRITE_STDIN, &json!({"session_id": "run-write-none", "action": "write"})),
232            "Read command session"
233        );
234    }
235}