syncable_cli/agent/ui/
tool_display.rs

1//! Tool call display for visible tool execution feedback
2//!
3//! Shows tool calls with status indicators, names, descriptions, and results.
4//! Includes forge-style output with formatted arguments and tree-like status.
5
6use crate::agent::ui::colors::{ansi, icons};
7use colored::Colorize;
8use std::io::{self, Write};
9
10/// Status of a tool call
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ToolCallStatus {
13    Pending,
14    Executing,
15    Success,
16    Error,
17    Canceled,
18}
19
20impl ToolCallStatus {
21    /// Get the icon for this status
22    pub fn icon(&self) -> &'static str {
23        match self {
24            ToolCallStatus::Pending => icons::PENDING,
25            ToolCallStatus::Executing => icons::EXECUTING,
26            ToolCallStatus::Success => icons::SUCCESS,
27            ToolCallStatus::Error => icons::ERROR,
28            ToolCallStatus::Canceled => icons::CANCELED,
29        }
30    }
31
32    /// Get the color code for this status
33    pub fn color(&self) -> &'static str {
34        match self {
35            ToolCallStatus::Pending => ansi::GRAY,
36            ToolCallStatus::Executing => ansi::CYAN,
37            ToolCallStatus::Success => "\x1b[32m", // Green
38            ToolCallStatus::Error => "\x1b[31m",   // Red
39            ToolCallStatus::Canceled => ansi::GRAY,
40        }
41    }
42}
43
44/// Represents a tool call for display
45#[derive(Debug, Clone)]
46pub struct ToolCallInfo {
47    pub name: String,
48    pub description: String,
49    pub status: ToolCallStatus,
50    pub result: Option<String>,
51    pub error: Option<String>,
52}
53
54impl ToolCallInfo {
55    pub fn new(name: &str, description: &str) -> Self {
56        Self {
57            name: name.to_string(),
58            description: description.to_string(),
59            status: ToolCallStatus::Pending,
60            result: None,
61            error: None,
62        }
63    }
64
65    pub fn executing(mut self) -> Self {
66        self.status = ToolCallStatus::Executing;
67        self
68    }
69
70    pub fn success(mut self, result: Option<String>) -> Self {
71        self.status = ToolCallStatus::Success;
72        self.result = result;
73        self
74    }
75
76    pub fn error(mut self, error: String) -> Self {
77        self.status = ToolCallStatus::Error;
78        self.error = Some(error);
79        self
80    }
81}
82
83/// Display manager for tool calls
84pub struct ToolCallDisplay;
85
86impl ToolCallDisplay {
87    /// Print a tool call start message
88    pub fn print_start(name: &str, description: &str) {
89        println!(
90            "\n{} {} {}",
91            icons::TOOL.cyan(),
92            name.cyan().bold(),
93            description.dimmed()
94        );
95        let _ = io::stdout().flush();
96    }
97
98    /// Print a tool call with status
99    pub fn print_status(info: &ToolCallInfo) {
100        let status_icon = info.status.icon();
101        let color = info.status.color();
102
103        print!(
104            "{}{}{} {} {} {}{}",
105            ansi::CLEAR_LINE,
106            color,
107            status_icon,
108            ansi::RESET,
109            info.name.cyan().bold(),
110            info.description.dimmed(),
111            ansi::RESET
112        );
113
114        match info.status {
115            ToolCallStatus::Success => {
116                println!(" {}", "[done]".green());
117            }
118            ToolCallStatus::Error => {
119                if let Some(ref err) = info.error {
120                    println!(" {} {}", "[error]".red(), err.red());
121                } else {
122                    println!(" {}", "[error]".red());
123                }
124            }
125            ToolCallStatus::Canceled => {
126                println!(" {}", "[canceled]".yellow());
127            }
128            _ => {
129                println!();
130            }
131        }
132
133        let _ = io::stdout().flush();
134    }
135
136    /// Print a tool call result (for verbose output)
137    pub fn print_result(name: &str, result: &str, truncate: bool) {
138        let display_result = if truncate && result.chars().count() > 200 {
139            let truncated: String = result.chars().take(200).collect();
140            format!("{}... (truncated)", truncated)
141        } else {
142            result.to_string()
143        };
144
145        println!(
146            "  {} {} {}",
147            icons::ARROW.dimmed(),
148            name.cyan(),
149            display_result.dimmed()
150        );
151        let _ = io::stdout().flush();
152    }
153
154    /// Print a summary of tool calls
155    pub fn print_summary(tools: &[ToolCallInfo]) {
156        if tools.is_empty() {
157            return;
158        }
159
160        let success_count = tools
161            .iter()
162            .filter(|t| t.status == ToolCallStatus::Success)
163            .count();
164        let error_count = tools
165            .iter()
166            .filter(|t| t.status == ToolCallStatus::Error)
167            .count();
168
169        println!();
170        if error_count == 0 {
171            println!(
172                "{} {} tool{} executed successfully",
173                icons::SUCCESS.green(),
174                success_count,
175                if success_count == 1 { "" } else { "s" }
176            );
177        } else {
178            println!(
179                "{} {}/{} tools succeeded, {} failed",
180                icons::ERROR.red(),
181                success_count,
182                tools.len(),
183                error_count
184            );
185        }
186    }
187}
188
189/// Print a tool call inline (single line, updating)
190pub fn print_tool_inline(status: ToolCallStatus, name: &str, description: &str) {
191    let icon = status.icon();
192    let color = status.color();
193
194    print!(
195        "{}{}{} {} {} {}{}",
196        ansi::CLEAR_LINE,
197        color,
198        icon,
199        ansi::RESET,
200        name,
201        description,
202        ansi::RESET
203    );
204    let _ = io::stdout().flush();
205}
206
207/// Print a tool group header
208pub fn print_tool_group_header(count: usize) {
209    println!(
210        "\n{} {} tool{}:",
211        icons::TOOL,
212        count,
213        if count == 1 { "" } else { "s" }
214    );
215}
216
217// ============================================================================
218// Forge-style tool display
219// ============================================================================
220
221/// Forge-style tool display that shows:
222/// ```text
223/// ● tool_name(arg1=value1, arg2=value2)
224///   └ Running...
225/// ```
226pub struct ForgeToolDisplay;
227
228impl ForgeToolDisplay {
229    /// Format tool arguments in a readable way
230    /// - Truncates long strings
231    /// - Shows line counts for multi-line content
232    /// - Uses key=value format
233    pub fn format_args(args: &serde_json::Value) -> String {
234        match args {
235            serde_json::Value::Object(map) => {
236                let formatted: Vec<String> = map
237                    .iter()
238                    .map(|(key, value)| {
239                        let val_str = Self::format_value(value);
240                        format!("{}={}", key, val_str)
241                    })
242                    .collect();
243                formatted.join(", ")
244            }
245            _ => args.to_string(),
246        }
247    }
248
249    /// Format a single value for display
250    fn format_value(value: &serde_json::Value) -> String {
251        match value {
252            serde_json::Value::String(s) => {
253                let line_count = s.lines().count();
254                if line_count > 1 {
255                    format!("<{} lines>", line_count)
256                } else if s.chars().count() > 50 {
257                    let truncated: String = s.chars().take(47).collect();
258                    format!("{}...", truncated)
259                } else {
260                    s.clone()
261                }
262            }
263            serde_json::Value::Bool(b) => b.to_string(),
264            serde_json::Value::Number(n) => n.to_string(),
265            serde_json::Value::Array(arr) => {
266                format!("[{} items]", arr.len())
267            }
268            serde_json::Value::Object(map) => {
269                format!("{{{} keys}}", map.len())
270            }
271            serde_json::Value::Null => "null".to_string(),
272        }
273    }
274
275    /// Print tool start in forge style
276    /// ```text
277    /// ● tool_name(args)
278    ///   └ Running...
279    /// ```
280    pub fn start(name: &str, args: &serde_json::Value) {
281        let formatted_args = Self::format_args(args);
282        println!(
283            "{} {}({})",
284            "●".cyan(),
285            name.cyan().bold(),
286            formatted_args.dimmed()
287        );
288        println!("  {} Running...", "└".dimmed());
289        let _ = io::stdout().flush();
290    }
291
292    /// Update the status line (overwrites "Running...")
293    pub fn update_status(status: &str) {
294        // Move up one line and clear
295        print!("\x1b[1A\x1b[2K");
296        println!("  {} {}", "└".dimmed(), status);
297        let _ = io::stdout().flush();
298    }
299
300    /// Complete the tool with a result summary
301    pub fn complete(result_summary: &str) {
302        // Move up one line and clear
303        print!("\x1b[1A\x1b[2K");
304        println!("  {} {}", "└".green(), result_summary.green());
305        let _ = io::stdout().flush();
306    }
307
308    /// Complete with error
309    pub fn error(error_msg: &str) {
310        // Move up one line and clear
311        print!("\x1b[1A\x1b[2K");
312        println!("  {} {}", "└".red(), error_msg.red());
313        let _ = io::stdout().flush();
314    }
315
316    /// Print tool inline without the tree structure (for simpler display)
317    pub fn print_inline(name: &str, args: &serde_json::Value) {
318        let formatted_args = Self::format_args(args);
319        println!(
320            "{} {}({})",
321            "●".cyan(),
322            name.cyan().bold(),
323            formatted_args.dimmed()
324        );
325        let _ = io::stdout().flush();
326    }
327
328    /// Summarize tool result for display
329    /// Takes the raw result and extracts a short summary
330    pub fn summarize_result(name: &str, result: &str) -> String {
331        // Try to parse as JSON and extract summary
332        if let Ok(json) = serde_json::from_str::<serde_json::Value>(result) {
333            // Handle common patterns
334            if let Some(success) = json.get("success").and_then(|v| v.as_bool())
335                && !success
336            {
337                if let Some(err) = json.get("error").and_then(|v| v.as_str()) {
338                    return format!("Error: {}", truncate_str(err, 50));
339                }
340                return "Failed".to_string();
341            }
342
343            // Check for issues/errors count
344            if let Some(issues) = json.get("issues").and_then(|v| v.as_array()) {
345                return format!("{} issues found", issues.len());
346            }
347
348            // Check for files written
349            if let Some(files) = json.get("files_written").and_then(|v| v.as_u64()) {
350                let lines = json
351                    .get("total_lines")
352                    .and_then(|v| v.as_u64())
353                    .unwrap_or(0);
354                return format!("wrote {} file(s) ({} lines)", files, lines);
355            }
356
357            // Check for lines in file
358            if let Some(lines) = json.get("total_lines").and_then(|v| v.as_u64()) {
359                return format!("read {} lines", lines);
360            }
361
362            // Check for entries (directory listing)
363            if let Some(count) = json.get("total_count").and_then(|v| v.as_u64()) {
364                return format!("{} entries", count);
365            }
366
367            // Default: show action if available
368            if let Some(action) = json.get("action").and_then(|v| v.as_str()) {
369                if let Some(path) = json.get("path").and_then(|v| v.as_str()) {
370                    return format!("{} {}", action.to_lowercase(), path);
371                }
372                return action.to_lowercase();
373            }
374        }
375
376        // Fallback: truncate raw result
377        format!("{} completed", name)
378    }
379}
380
381/// Truncate a string to max length, handling UTF-8 safely
382fn truncate_str(s: &str, max: usize) -> String {
383    let char_count = s.chars().count();
384    if char_count <= max {
385        s.to_string()
386    } else {
387        let truncate_to = max.saturating_sub(3);
388        let truncated: String = s.chars().take(truncate_to).collect();
389        format!("{}...", truncated)
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use serde_json::json;
397
398    #[test]
399    fn test_tool_call_info() {
400        let info = ToolCallInfo::new("read_file", "reading src/main.rs");
401        assert_eq!(info.status, ToolCallStatus::Pending);
402
403        let info = info.executing();
404        assert_eq!(info.status, ToolCallStatus::Executing);
405
406        let info = info.success(Some("file contents".to_string()));
407        assert_eq!(info.status, ToolCallStatus::Success);
408        assert!(info.result.is_some());
409    }
410
411    #[test]
412    fn test_status_icons() {
413        assert_eq!(ToolCallStatus::Pending.icon(), icons::PENDING);
414        assert_eq!(ToolCallStatus::Success.icon(), icons::SUCCESS);
415        assert_eq!(ToolCallStatus::Error.icon(), icons::ERROR);
416    }
417
418    #[test]
419    fn test_forge_format_args() {
420        // Simple args
421        let args = json!({"path": "src/main.rs", "check": true});
422        let formatted = ForgeToolDisplay::format_args(&args);
423        assert!(formatted.contains("path=src/main.rs"));
424        assert!(formatted.contains("check=true"));
425
426        // Multi-line content should show line count
427        let args = json!({"content": "line1\nline2\nline3"});
428        let formatted = ForgeToolDisplay::format_args(&args);
429        assert!(formatted.contains("<3 lines>"));
430
431        // Long string should be truncated
432        let long_str = "a".repeat(100);
433        let args = json!({"data": long_str});
434        let formatted = ForgeToolDisplay::format_args(&args);
435        assert!(formatted.contains("..."));
436    }
437
438    #[test]
439    fn test_forge_summarize_result() {
440        // Files written
441        let result = r#"{"success": true, "files_written": 3, "total_lines": 150}"#;
442        let summary = ForgeToolDisplay::summarize_result("write_files", result);
443        assert!(summary.contains("3 file"));
444        assert!(summary.contains("150 lines"));
445
446        // Issues found
447        let result = r#"{"issues": [1, 2, 3]}"#;
448        let summary = ForgeToolDisplay::summarize_result("hadolint", result);
449        assert!(summary.contains("3 issues"));
450
451        // Directory listing
452        let result = r#"{"total_count": 25}"#;
453        let summary = ForgeToolDisplay::summarize_result("list_directory", result);
454        assert!(summary.contains("25 entries"));
455    }
456
457    #[test]
458    fn test_truncate_str() {
459        assert_eq!(truncate_str("short", 10), "short");
460        assert_eq!(truncate_str("this is a longer string", 10), "this is...");
461    }
462}