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.len() > 200 {
139            format!("{}... (truncated)", &result[..200])
140        } else {
141            result.to_string()
142        };
143
144        println!(
145            "  {} {} {}",
146            icons::ARROW.dimmed(),
147            name.cyan(),
148            display_result.dimmed()
149        );
150        let _ = io::stdout().flush();
151    }
152
153    /// Print a summary of tool calls
154    pub fn print_summary(tools: &[ToolCallInfo]) {
155        if tools.is_empty() {
156            return;
157        }
158
159        let success_count = tools.iter().filter(|t| t.status == ToolCallStatus::Success).count();
160        let error_count = tools.iter().filter(|t| t.status == ToolCallStatus::Error).count();
161
162        println!();
163        if error_count == 0 {
164            println!(
165                "{} {} tool{} executed successfully",
166                icons::SUCCESS.green(),
167                success_count,
168                if success_count == 1 { "" } else { "s" }
169            );
170        } else {
171            println!(
172                "{} {}/{} tools succeeded, {} failed",
173                icons::ERROR.red(),
174                success_count,
175                tools.len(),
176                error_count
177            );
178        }
179    }
180}
181
182/// Print a tool call inline (single line, updating)
183pub fn print_tool_inline(status: ToolCallStatus, name: &str, description: &str) {
184    let icon = status.icon();
185    let color = status.color();
186
187    print!(
188        "{}{}{} {} {} {}{}",
189        ansi::CLEAR_LINE,
190        color,
191        icon,
192        ansi::RESET,
193        name,
194        description,
195        ansi::RESET
196    );
197    let _ = io::stdout().flush();
198}
199
200/// Print a tool group header
201pub fn print_tool_group_header(count: usize) {
202    println!("\n{} {} tool{}:", icons::TOOL, count, if count == 1 { "" } else { "s" });
203}
204
205// ============================================================================
206// Forge-style tool display
207// ============================================================================
208
209/// Forge-style tool display that shows:
210/// ```
211/// ● tool_name(arg1=value1, arg2=value2)
212///   └ Running...
213/// ```
214pub struct ForgeToolDisplay;
215
216impl ForgeToolDisplay {
217    /// Format tool arguments in a readable way
218    /// - Truncates long strings
219    /// - Shows line counts for multi-line content
220    /// - Uses key=value format
221    pub fn format_args(args: &serde_json::Value) -> String {
222        match args {
223            serde_json::Value::Object(map) => {
224                let formatted: Vec<String> = map
225                    .iter()
226                    .map(|(key, value)| {
227                        let val_str = Self::format_value(value);
228                        format!("{}={}", key, val_str)
229                    })
230                    .collect();
231                formatted.join(", ")
232            }
233            _ => args.to_string(),
234        }
235    }
236
237    /// Format a single value for display
238    fn format_value(value: &serde_json::Value) -> String {
239        match value {
240            serde_json::Value::String(s) => {
241                let line_count = s.lines().count();
242                if line_count > 1 {
243                    format!("<{} lines>", line_count)
244                } else if s.len() > 50 {
245                    format!("{}...", &s[..47])
246                } else {
247                    s.clone()
248                }
249            }
250            serde_json::Value::Bool(b) => b.to_string(),
251            serde_json::Value::Number(n) => n.to_string(),
252            serde_json::Value::Array(arr) => {
253                format!("[{} items]", arr.len())
254            }
255            serde_json::Value::Object(map) => {
256                format!("{{{} keys}}", map.len())
257            }
258            serde_json::Value::Null => "null".to_string(),
259        }
260    }
261
262    /// Print tool start in forge style
263    /// ```
264    /// ● tool_name(args)
265    ///   └ Running...
266    /// ```
267    pub fn start(name: &str, args: &serde_json::Value) {
268        let formatted_args = Self::format_args(args);
269        println!(
270            "{} {}({})",
271            "●".cyan(),
272            name.cyan().bold(),
273            formatted_args.dimmed()
274        );
275        println!("  {} Running...", "└".dimmed());
276        let _ = io::stdout().flush();
277    }
278
279    /// Update the status line (overwrites "Running...")
280    pub fn update_status(status: &str) {
281        // Move up one line and clear
282        print!("\x1b[1A\x1b[2K");
283        println!("  {} {}", "└".dimmed(), status);
284        let _ = io::stdout().flush();
285    }
286
287    /// Complete the tool with a result summary
288    pub fn complete(result_summary: &str) {
289        // Move up one line and clear
290        print!("\x1b[1A\x1b[2K");
291        println!("  {} {}", "└".green(), result_summary.green());
292        let _ = io::stdout().flush();
293    }
294
295    /// Complete with error
296    pub fn error(error_msg: &str) {
297        // Move up one line and clear
298        print!("\x1b[1A\x1b[2K");
299        println!("  {} {}", "└".red(), error_msg.red());
300        let _ = io::stdout().flush();
301    }
302
303    /// Print tool inline without the tree structure (for simpler display)
304    pub fn print_inline(name: &str, args: &serde_json::Value) {
305        let formatted_args = Self::format_args(args);
306        println!(
307            "{} {}({})",
308            "●".cyan(),
309            name.cyan().bold(),
310            formatted_args.dimmed()
311        );
312        let _ = io::stdout().flush();
313    }
314
315    /// Summarize tool result for display
316    /// Takes the raw result and extracts a short summary
317    pub fn summarize_result(name: &str, result: &str) -> String {
318        // Try to parse as JSON and extract summary
319        if let Ok(json) = serde_json::from_str::<serde_json::Value>(result) {
320            // Handle common patterns
321            if let Some(success) = json.get("success").and_then(|v| v.as_bool()) {
322                if !success {
323                    if let Some(err) = json.get("error").and_then(|v| v.as_str()) {
324                        return format!("Error: {}", truncate_str(err, 50));
325                    }
326                    return "Failed".to_string();
327                }
328            }
329
330            // Check for issues/errors count
331            if let Some(issues) = json.get("issues").and_then(|v| v.as_array()) {
332                return format!("{} issues found", issues.len());
333            }
334
335            // Check for files written
336            if let Some(files) = json.get("files_written").and_then(|v| v.as_u64()) {
337                let lines = json.get("total_lines").and_then(|v| v.as_u64()).unwrap_or(0);
338                return format!("wrote {} file(s) ({} lines)", files, lines);
339            }
340
341            // Check for lines in file
342            if let Some(lines) = json.get("total_lines").and_then(|v| v.as_u64()) {
343                return format!("read {} lines", lines);
344            }
345
346            // Check for entries (directory listing)
347            if let Some(count) = json.get("total_count").and_then(|v| v.as_u64()) {
348                return format!("{} entries", count);
349            }
350
351            // Default: show action if available
352            if let Some(action) = json.get("action").and_then(|v| v.as_str()) {
353                if let Some(path) = json.get("path").and_then(|v| v.as_str()) {
354                    return format!("{} {}", action.to_lowercase(), path);
355                }
356                return action.to_lowercase();
357            }
358        }
359
360        // Fallback: truncate raw result
361        format!("{} completed", name)
362    }
363}
364
365/// Truncate a string to max length
366fn truncate_str(s: &str, max: usize) -> String {
367    if s.len() <= max {
368        s.to_string()
369    } else {
370        format!("{}...", &s[..max.saturating_sub(3)])
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use serde_json::json;
378
379    #[test]
380    fn test_tool_call_info() {
381        let info = ToolCallInfo::new("read_file", "reading src/main.rs");
382        assert_eq!(info.status, ToolCallStatus::Pending);
383
384        let info = info.executing();
385        assert_eq!(info.status, ToolCallStatus::Executing);
386
387        let info = info.success(Some("file contents".to_string()));
388        assert_eq!(info.status, ToolCallStatus::Success);
389        assert!(info.result.is_some());
390    }
391
392    #[test]
393    fn test_status_icons() {
394        assert_eq!(ToolCallStatus::Pending.icon(), icons::PENDING);
395        assert_eq!(ToolCallStatus::Success.icon(), icons::SUCCESS);
396        assert_eq!(ToolCallStatus::Error.icon(), icons::ERROR);
397    }
398
399    #[test]
400    fn test_forge_format_args() {
401        // Simple args
402        let args = json!({"path": "src/main.rs", "check": true});
403        let formatted = ForgeToolDisplay::format_args(&args);
404        assert!(formatted.contains("path=src/main.rs"));
405        assert!(formatted.contains("check=true"));
406
407        // Multi-line content should show line count
408        let args = json!({"content": "line1\nline2\nline3"});
409        let formatted = ForgeToolDisplay::format_args(&args);
410        assert!(formatted.contains("<3 lines>"));
411
412        // Long string should be truncated
413        let long_str = "a".repeat(100);
414        let args = json!({"data": long_str});
415        let formatted = ForgeToolDisplay::format_args(&args);
416        assert!(formatted.contains("..."));
417    }
418
419    #[test]
420    fn test_forge_summarize_result() {
421        // Files written
422        let result = r#"{"success": true, "files_written": 3, "total_lines": 150}"#;
423        let summary = ForgeToolDisplay::summarize_result("write_files", result);
424        assert!(summary.contains("3 file"));
425        assert!(summary.contains("150 lines"));
426
427        // Issues found
428        let result = r#"{"issues": [1, 2, 3]}"#;
429        let summary = ForgeToolDisplay::summarize_result("hadolint", result);
430        assert!(summary.contains("3 issues"));
431
432        // Directory listing
433        let result = r#"{"total_count": 25}"#;
434        let summary = ForgeToolDisplay::summarize_result("list_directory", result);
435        assert!(summary.contains("25 entries"));
436    }
437
438    #[test]
439    fn test_truncate_str() {
440        assert_eq!(truncate_str("short", 10), "short");
441        assert_eq!(truncate_str("this is a longer string", 10), "this is...");
442    }
443}