Skip to main content

gglib_core/domain/agent/
tool_display.rs

1//! Shared tool display formatting — single source of truth for all surfaces.
2//!
3//! These pure functions convert raw tool names and arguments into
4//! human-readable display strings.  The agentic loop populates
5//! [`AgentEvent`] payloads with pre-formatted fields computed here,
6//! so CLI, `WebUI` (Axum SSE), and GUI (Tauri) all render identical labels
7//! without duplicating formatting logic.
8//!
9//! [`AgentEvent`]: super::events::AgentEvent
10
11/// Strip the routing prefix from a qualified tool name.
12///
13/// Tool names carry a `"builtin:"` or `"{server_id}:"` prefix for
14/// O(1) dispatch routing in `CombinedToolExecutor`.  This function
15/// removes that prefix for display purposes only.
16///
17/// ```
18/// use gglib_core::domain::agent::tool_display::strip_tool_prefix;
19///
20/// assert_eq!(strip_tool_prefix("builtin:read_file"), "read_file");
21/// assert_eq!(strip_tool_prefix("3:some_tool"), "some_tool");
22/// assert_eq!(strip_tool_prefix("plain_name"), "plain_name");
23/// ```
24pub fn strip_tool_prefix(name: &str) -> &str {
25    name.find(':').map_or(name, |pos| &name[pos + 1..])
26}
27
28/// Convert a raw tool name into a human-readable "Title Case" label.
29///
30/// Splits on hyphens, underscores, dots, and whitespace, then title-cases
31/// each word.  This replaces the frontend `formatToolDisplayName` function
32/// and is the single Rust source of truth used by all surfaces.
33///
34/// ```
35/// use gglib_core::domain::agent::tool_display::format_tool_display_name;
36///
37/// assert_eq!(format_tool_display_name("read_file"), "Read File");
38/// assert_eq!(format_tool_display_name("get-weather"), "Get Weather");
39/// assert_eq!(format_tool_display_name("file.read"), "File Read");
40/// assert_eq!(format_tool_display_name("get_current_time"), "Get Current Time");
41/// assert_eq!(format_tool_display_name("Already Good"), "Already Good");
42/// ```
43pub fn format_tool_display_name(raw: &str) -> String {
44    raw.split(|c: char| c == '-' || c == '_' || c == '.' || c.is_whitespace())
45        .filter(|w| !w.is_empty())
46        .map(title_case_word)
47        .collect::<Vec<_>>()
48        .join(" ")
49}
50
51/// Extract a one-line argument summary from a tool call's `arguments` JSON.
52///
53/// Known builtins get tool-specific summaries (e.g. `read_file → path`).
54/// Unknown tools show the first string-valued key, truncated to 60 chars.
55/// Returns `None` when no meaningful summary can be extracted.
56///
57/// ```
58/// use gglib_core::domain::agent::tool_display::format_tool_args_summary;
59/// use serde_json::json;
60///
61/// let args = json!({"path": "/src/main.rs", "line_range": [1, 50]});
62/// assert_eq!(
63///     format_tool_args_summary("read_file", &args),
64///     Some("/src/main.rs".to_string()),
65/// );
66///
67/// let args = json!({"pattern": "TODO", "path": "/src"});
68/// assert_eq!(
69///     format_tool_args_summary("grep_search", &args),
70///     Some("\"TODO\" in /src".to_string()),
71/// );
72/// ```
73pub fn format_tool_args_summary(bare_name: &str, arguments: &serde_json::Value) -> Option<String> {
74    let obj = arguments.as_object()?;
75
76    match bare_name {
77        "read_file" => obj
78            .get("path")
79            .and_then(|v| v.as_str())
80            .map(|s| truncate(s, 60).to_string()),
81
82        "list_directory" => obj
83            .get("path")
84            .and_then(|v| v.as_str())
85            .map(|s| truncate(s, 60).to_string()),
86
87        "grep_search" => {
88            let pattern = obj.get("pattern").and_then(|v| v.as_str())?;
89            let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or(".");
90            Some(format!(
91                "\"{}\" in {}",
92                truncate(pattern, 30),
93                truncate(path, 30)
94            ))
95        }
96
97        "get_current_time" => obj
98            .get("timezone")
99            .and_then(|v| v.as_str())
100            .map(std::string::ToString::to_string),
101
102        // Generic fallback: show the first string-valued argument.
103        _ => obj
104            .values()
105            .find_map(|v| v.as_str())
106            .map(|s| truncate(s, 60).to_string()),
107    }
108}
109
110// =============================================================================
111// Helpers
112// =============================================================================
113
114/// Title-case a single word (first char uppercase, rest lowercase).
115fn title_case_word(word: &str) -> String {
116    let mut chars = word.chars();
117    chars.next().map_or_else(String::new, |c| {
118        let upper: String = c.to_uppercase().collect();
119        upper + chars.as_str()
120    })
121}
122
123/// Truncate a string to at most `max_len` bytes, cutting at a char boundary.
124///
125/// No ellipsis: these are argument summaries that already sit inside quotes or
126/// a label, and a `…` reads as part of the path.
127/// [`crate::utils::text::truncate_with_ellipsis`] is the variant that marks
128/// the cut.
129fn truncate(s: &str, max_len: usize) -> &str {
130    crate::utils::text::truncate_at_char_boundary(s, max_len)
131}
132
133// =============================================================================
134// Tests
135// =============================================================================
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use serde_json::json;
141
142    // ── strip_tool_prefix ────────────────────────────────────────────
143
144    #[test]
145    fn strip_builtin_prefix() {
146        assert_eq!(strip_tool_prefix("builtin:read_file"), "read_file");
147    }
148
149    #[test]
150    fn strip_numeric_server_prefix() {
151        assert_eq!(strip_tool_prefix("3:some_tool"), "some_tool");
152    }
153
154    #[test]
155    fn strip_no_prefix() {
156        assert_eq!(strip_tool_prefix("plain_name"), "plain_name");
157    }
158
159    // ── format_tool_display_name ─────────────────────────────────────
160
161    #[test]
162    fn display_name_underscore() {
163        assert_eq!(format_tool_display_name("read_file"), "Read File");
164    }
165
166    #[test]
167    fn display_name_hyphen() {
168        assert_eq!(format_tool_display_name("get-weather"), "Get Weather");
169    }
170
171    #[test]
172    fn display_name_dot() {
173        assert_eq!(format_tool_display_name("file.read"), "File Read");
174    }
175
176    #[test]
177    fn display_name_mixed_separators() {
178        assert_eq!(
179            format_tool_display_name("my-tool_name.here"),
180            "My Tool Name Here"
181        );
182    }
183
184    #[test]
185    fn display_name_consecutive_separators() {
186        assert_eq!(format_tool_display_name("a..b"), "A B");
187        assert_eq!(format_tool_display_name("a--b"), "A B");
188    }
189
190    #[test]
191    fn display_name_single_word() {
192        assert_eq!(format_tool_display_name("weather"), "Weather");
193    }
194
195    #[test]
196    fn display_name_already_title_case() {
197        assert_eq!(format_tool_display_name("Get Weather"), "Get Weather");
198    }
199
200    // ── format_tool_args_summary ─────────────────────────────────────
201
202    #[test]
203    fn args_summary_read_file() {
204        let args = json!({"path": "/src/main.rs"});
205        assert_eq!(
206            format_tool_args_summary("read_file", &args),
207            Some("/src/main.rs".into())
208        );
209    }
210
211    #[test]
212    fn args_summary_grep_search() {
213        let args = json!({"pattern": "TODO", "path": "/src"});
214        assert_eq!(
215            format_tool_args_summary("grep_search", &args),
216            Some("\"TODO\" in /src".into())
217        );
218    }
219
220    #[test]
221    fn args_summary_grep_search_no_path() {
222        let args = json!({"pattern": "TODO"});
223        assert_eq!(
224            format_tool_args_summary("grep_search", &args),
225            Some("\"TODO\" in .".into())
226        );
227    }
228
229    #[test]
230    fn args_summary_list_directory() {
231        let args = json!({"path": "/src"});
232        assert_eq!(
233            format_tool_args_summary("list_directory", &args),
234            Some("/src".into())
235        );
236    }
237
238    #[test]
239    fn args_summary_get_current_time() {
240        let args = json!({"timezone": "UTC"});
241        assert_eq!(
242            format_tool_args_summary("get_current_time", &args),
243            Some("UTC".into())
244        );
245    }
246
247    #[test]
248    fn args_summary_generic_fallback() {
249        let args = json!({"query": "rust async"});
250        assert_eq!(
251            format_tool_args_summary("custom_search", &args),
252            Some("rust async".into())
253        );
254    }
255
256    #[test]
257    fn args_summary_no_string_values() {
258        let args = json!({"count": 5});
259        assert_eq!(format_tool_args_summary("some_tool", &args), None);
260    }
261
262    #[test]
263    fn args_summary_null_args() {
264        assert_eq!(
265            format_tool_args_summary("some_tool", &serde_json::Value::Null),
266            None
267        );
268    }
269}