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