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}