syncable_cli/agent/ui/
hooks.rs

1//! Rig PromptHook implementations for Claude Code style UI
2//!
3//! Shows tool calls in a collapsible format:
4//! - `● tool_name(args...)` header with full command visible
5//! - Preview of output (first few lines)
6//! - `... +N lines` for long outputs
7//! - `└ Running...` while executing
8//! - Agent thinking shown between tool calls
9
10use crate::agent::ui::colors::ansi;
11use colored::Colorize;
12use rig::agent::CancelSignal;
13use rig::completion::{CompletionModel, CompletionResponse, Message};
14use rig::message::{AssistantContent, Reasoning};
15use std::io::{self, Write};
16use std::sync::Arc;
17use tokio::sync::Mutex;
18
19/// Maximum lines to show in preview before collapsing
20const PREVIEW_LINES: usize = 4;
21
22/// Tool call state with full output for expansion
23#[derive(Debug, Clone)]
24pub struct ToolCallState {
25    pub name: String,
26    pub args: String,
27    pub output: Option<String>,
28    pub output_lines: Vec<String>,
29    pub is_running: bool,
30    pub is_expanded: bool,
31    pub is_collapsible: bool,
32    pub status_ok: bool,
33}
34
35/// Shared state for the display
36#[derive(Debug, Default)]
37pub struct DisplayState {
38    pub tool_calls: Vec<ToolCallState>,
39    pub agent_messages: Vec<String>,
40    pub current_tool_index: Option<usize>,
41    pub last_expandable_index: Option<usize>,
42}
43
44/// A hook that shows Claude Code style tool execution
45#[derive(Clone)]
46pub struct ToolDisplayHook {
47    state: Arc<Mutex<DisplayState>>,
48}
49
50impl ToolDisplayHook {
51    pub fn new() -> Self {
52        Self {
53            state: Arc::new(Mutex::new(DisplayState::default())),
54        }
55    }
56
57    /// Get the shared state for external access
58    pub fn state(&self) -> Arc<Mutex<DisplayState>> {
59        self.state.clone()
60    }
61}
62
63impl Default for ToolDisplayHook {
64    fn default() -> Self {
65        Self::new()
66    }
67}
68
69impl<M> rig::agent::PromptHook<M> for ToolDisplayHook
70where
71    M: CompletionModel,
72{
73    fn on_tool_call(
74        &self,
75        tool_name: &str,
76        args: &str,
77        _cancel: CancelSignal,
78    ) -> impl std::future::Future<Output = ()> + Send {
79        let state = self.state.clone();
80        let name = tool_name.to_string();
81        let args_str = args.to_string();
82
83        async move {
84            // Print tool header
85            print_tool_header(&name, &args_str);
86
87            // Store in state
88            let mut s = state.lock().await;
89            let idx = s.tool_calls.len();
90            s.tool_calls.push(ToolCallState {
91                name,
92                args: args_str,
93                output: None,
94                output_lines: Vec::new(),
95                is_running: true,
96                is_expanded: false,
97                is_collapsible: false,
98                status_ok: true,
99            });
100            s.current_tool_index = Some(idx);
101        }
102    }
103
104    fn on_tool_result(
105        &self,
106        tool_name: &str,
107        args: &str,
108        result: &str,
109        _cancel: CancelSignal,
110    ) -> impl std::future::Future<Output = ()> + Send {
111        let state = self.state.clone();
112        let name = tool_name.to_string();
113        let args_str = args.to_string();
114        let result_str = result.to_string();
115
116        async move {
117            // Print tool result and get the output info
118            let (status_ok, output_lines, is_collapsible) = print_tool_result(&name, &args_str, &result_str);
119
120            // Update state
121            let mut s = state.lock().await;
122            if let Some(idx) = s.current_tool_index {
123                if let Some(tool) = s.tool_calls.get_mut(idx) {
124                    tool.output = Some(result_str);
125                    tool.output_lines = output_lines;
126                    tool.is_running = false;
127                    tool.is_collapsible = is_collapsible;
128                    tool.status_ok = status_ok;
129                }
130                // Track last expandable output
131                if is_collapsible {
132                    s.last_expandable_index = Some(idx);
133                }
134            }
135            s.current_tool_index = None;
136        }
137    }
138
139    fn on_completion_response(
140        &self,
141        _prompt: &Message,
142        response: &CompletionResponse<M::Response>,
143        _cancel: CancelSignal,
144    ) -> impl std::future::Future<Output = ()> + Send {
145        let state = self.state.clone();
146
147        // Check if response contains tool calls - if so, any text is "thinking"
148        // If no tool calls, this is the final response - don't show as thinking
149        let has_tool_calls = response.choice.iter().any(|content| {
150            matches!(content, AssistantContent::ToolCall(_))
151        });
152
153        // Extract reasoning content (GPT-5.2 thinking summaries)
154        let reasoning_parts: Vec<String> = response.choice.iter()
155            .filter_map(|content| {
156                if let AssistantContent::Reasoning(Reasoning { reasoning, .. }) = content {
157                    // Join all reasoning strings
158                    let text = reasoning.iter().cloned().collect::<Vec<_>>().join("\n");
159                    if !text.trim().is_empty() {
160                        Some(text)
161                    } else {
162                        None
163                    }
164                } else {
165                    None
166                }
167            })
168            .collect();
169
170        // Extract text content from the response (for non-reasoning models)
171        let text_parts: Vec<String> = response.choice.iter()
172            .filter_map(|content| {
173                if let AssistantContent::Text(text) = content {
174                    // Filter out empty or whitespace-only text
175                    let trimmed = text.text.trim();
176                    if !trimmed.is_empty() {
177                        Some(trimmed.to_string())
178                    } else {
179                        None
180                    }
181                } else {
182                    None
183                }
184            })
185            .collect();
186
187        async move {
188            // First, show reasoning content if available (GPT-5.2 thinking)
189            if !reasoning_parts.is_empty() {
190                let thinking_text = reasoning_parts.join("\n");
191
192                // Store in state for history tracking
193                let mut s = state.lock().await;
194                s.agent_messages.push(thinking_text.clone());
195                drop(s);
196
197                // Display reasoning as thinking
198                print_agent_thinking(&thinking_text);
199            }
200
201            // Also show text content if it's intermediate (has tool calls)
202            // but NOT if it's the final response
203            if !text_parts.is_empty() && has_tool_calls {
204                let thinking_text = text_parts.join("\n");
205
206                // Store in state for history tracking
207                let mut s = state.lock().await;
208                s.agent_messages.push(thinking_text.clone());
209                drop(s);
210
211                // Display as thinking
212                print_agent_thinking(&thinking_text);
213            }
214        }
215    }
216}
217
218/// Print agent thinking/reasoning text with nice formatting
219fn print_agent_thinking(text: &str) {
220    use crate::agent::ui::response::brand;
221
222    println!();
223
224    // Print thinking header in peach/coral
225    println!(
226        "{}{}  💭 Thinking...{}",
227        brand::CORAL,
228        brand::ITALIC,
229        brand::RESET
230    );
231
232    // Format the content with markdown support
233    let mut in_code_block = false;
234
235    for line in text.lines() {
236        let trimmed = line.trim();
237
238        // Handle code blocks
239        if trimmed.starts_with("```") {
240            if in_code_block {
241                println!("{}  └────────────────────────────────────────────────────────┘{}", brand::LIGHT_PEACH, brand::RESET);
242                in_code_block = false;
243            } else {
244                let lang = trimmed.strip_prefix("```").unwrap_or("");
245                let lang_display = if lang.is_empty() { "code" } else { lang };
246                println!(
247                    "{}  ┌─ {}{}{} ────────────────────────────────────────────────────┐{}",
248                    brand::LIGHT_PEACH, brand::CYAN, lang_display, brand::LIGHT_PEACH, brand::RESET
249                );
250                in_code_block = true;
251            }
252            continue;
253        }
254
255        if in_code_block {
256            println!("{}  │ {}{}{}  │", brand::LIGHT_PEACH, brand::CYAN, line, brand::RESET);
257            continue;
258        }
259
260        // Handle bullet points
261        if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
262            let content = trimmed.strip_prefix("- ").or_else(|| trimmed.strip_prefix("* ")).unwrap_or(trimmed);
263            println!("{}  {} {}{}", brand::PEACH, "•", format_thinking_inline(content), brand::RESET);
264            continue;
265        }
266
267        // Handle numbered lists
268        if trimmed.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)
269            && trimmed.chars().nth(1) == Some('.')
270        {
271            println!("{}  {}{}", brand::PEACH, format_thinking_inline(trimmed), brand::RESET);
272            continue;
273        }
274
275        // Regular text with inline formatting
276        if trimmed.is_empty() {
277            println!();
278        } else {
279            // Word wrap long lines
280            let wrapped = wrap_text(trimmed, 76);
281            for wrapped_line in wrapped {
282                println!("{}  {}{}", brand::PEACH, format_thinking_inline(&wrapped_line), brand::RESET);
283            }
284        }
285    }
286
287    println!();
288    let _ = io::stdout().flush();
289}
290
291/// Format inline elements in thinking text (code, bold)
292fn format_thinking_inline(text: &str) -> String {
293    use crate::agent::ui::response::brand;
294
295    let mut result = String::new();
296    let chars: Vec<char> = text.chars().collect();
297    let mut i = 0;
298
299    while i < chars.len() {
300        // Handle `code`
301        if chars[i] == '`' && (i + 1 >= chars.len() || chars[i + 1] != '`') {
302            if let Some(end) = chars[i + 1..].iter().position(|&c| c == '`') {
303                let code_text: String = chars[i + 1..i + 1 + end].iter().collect();
304                result.push_str(brand::CYAN);
305                result.push('`');
306                result.push_str(&code_text);
307                result.push('`');
308                result.push_str(brand::RESET);
309                result.push_str(brand::PEACH);
310                i = i + 2 + end;
311                continue;
312            }
313        }
314
315        // Handle **bold**
316        if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
317            if let Some(end_offset) = find_double_star(&chars, i + 2) {
318                let bold_text: String = chars[i + 2..i + 2 + end_offset].iter().collect();
319                result.push_str(brand::RESET);
320                result.push_str(brand::CORAL);
321                result.push_str(brand::BOLD);
322                result.push_str(&bold_text);
323                result.push_str(brand::RESET);
324                result.push_str(brand::PEACH);
325                i = i + 4 + end_offset;
326                continue;
327            }
328        }
329
330        result.push(chars[i]);
331        i += 1;
332    }
333
334    result
335}
336
337/// Find closing ** marker
338fn find_double_star(chars: &[char], start: usize) -> Option<usize> {
339    for i in start..chars.len().saturating_sub(1) {
340        if chars[i] == '*' && chars[i + 1] == '*' {
341            return Some(i - start);
342        }
343    }
344    None
345}
346
347/// Simple word wrap helper
348fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
349    if text.len() <= max_width {
350        return vec![text.to_string()];
351    }
352
353    let mut lines = Vec::new();
354    let mut current_line = String::new();
355
356    for word in text.split_whitespace() {
357        if current_line.is_empty() {
358            current_line = word.to_string();
359        } else if current_line.len() + 1 + word.len() <= max_width {
360            current_line.push(' ');
361            current_line.push_str(word);
362        } else {
363            lines.push(current_line);
364            current_line = word.to_string();
365        }
366    }
367
368    if !current_line.is_empty() {
369        lines.push(current_line);
370    }
371
372    if lines.is_empty() {
373        lines.push(text.to_string());
374    }
375
376    lines
377}
378
379/// Print tool call header in Claude Code style
380fn print_tool_header(name: &str, args: &str) {
381    let parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
382    let args_display = format_args_display(name, &parsed);
383
384    // Print header with yellow dot (running)
385    if args_display.is_empty() {
386        println!("\n{} {}", "●".yellow(), name.cyan().bold());
387    } else {
388        println!("\n{} {}({})", "●".yellow(), name.cyan().bold(), args_display.dimmed());
389    }
390
391    // Print running indicator
392    println!("  {} {}", "└".dimmed(), "Running...".dimmed());
393
394    let _ = io::stdout().flush();
395}
396
397/// Print tool result with preview and collapse
398/// Returns (status_ok, output_lines, is_collapsible)
399fn print_tool_result(name: &str, args: &str, result: &str) -> (bool, Vec<String>, bool) {
400    // Clear the "Running..." line
401    print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
402    let _ = io::stdout().flush();
403
404    // Parse the result - handle potential double-encoding from Rig
405    let parsed: Result<serde_json::Value, _> = serde_json::from_str(result)
406        .map(|v: serde_json::Value| {
407            // If the parsed value is a string, it might be double-encoded JSON
408            // Try to parse the inner string, but fall back to original if it fails
409            if let Some(inner_str) = v.as_str() {
410                serde_json::from_str(inner_str).unwrap_or(v)
411            } else {
412                v
413            }
414        });
415
416    // Format output based on tool type
417    let (status_ok, output_lines) = match name {
418        "shell" => format_shell_result(&parsed),
419        "write_file" | "write_files" => format_write_result(&parsed),
420        "read_file" => format_read_result(&parsed),
421        "list_directory" => format_list_result(&parsed),
422        "analyze_project" => format_analyze_result(&parsed),
423        "security_scan" | "check_vulnerabilities" => format_security_result(&parsed),
424        "hadolint" => format_hadolint_result(&parsed),
425        _ => (true, vec!["done".to_string()]),
426    };
427
428    // Clear the header line to update dot color
429    print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
430
431    // Reprint header with green/red dot and args
432    let dot = if status_ok { "●".green() } else { "●".red() };
433
434    // Format args for display (same logic as print_tool_header)
435    let args_parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
436    let args_display = format_args_display(name, &args_parsed);
437
438    if args_display.is_empty() {
439        println!("{} {}", dot, name.cyan().bold());
440    } else {
441        println!("{} {}({})", dot, name.cyan().bold(), args_display.dimmed());
442    }
443
444    // Print output preview
445    let total_lines = output_lines.len();
446    let is_collapsible = total_lines > PREVIEW_LINES;
447
448    for (i, line) in output_lines.iter().take(PREVIEW_LINES).enumerate() {
449        let prefix = if i == output_lines.len().min(PREVIEW_LINES) - 1 && !is_collapsible {
450            "└"
451        } else {
452            "│"
453        };
454        println!("  {} {}", prefix.dimmed(), line);
455    }
456
457    // Show collapse indicator if needed
458    if is_collapsible {
459        println!(
460            "  {} {}",
461            "└".dimmed(),
462            format!("+{} more lines", total_lines - PREVIEW_LINES).dimmed()
463        );
464    }
465
466    let _ = io::stdout().flush();
467    (status_ok, output_lines, is_collapsible)
468}
469
470/// Format args for display based on tool type
471fn format_args_display(name: &str, parsed: &Result<serde_json::Value, serde_json::Error>) -> String {
472    match name {
473        "shell" => {
474            if let Ok(v) = parsed {
475                v.get("command").and_then(|c| c.as_str()).unwrap_or("").to_string()
476            } else {
477                String::new()
478            }
479        }
480        "write_file" => {
481            if let Ok(v) = parsed {
482                v.get("path").and_then(|p| p.as_str()).unwrap_or("").to_string()
483            } else {
484                String::new()
485            }
486        }
487        "write_files" => {
488            if let Ok(v) = parsed {
489                if let Some(files) = v.get("files").and_then(|f| f.as_array()) {
490                    let paths: Vec<&str> = files
491                        .iter()
492                        .filter_map(|f| f.get("path").and_then(|p| p.as_str()))
493                        .take(3)
494                        .collect();
495                    let more = if files.len() > 3 {
496                        format!(", +{} more", files.len() - 3)
497                    } else {
498                        String::new()
499                    };
500                    format!("{}{}", paths.join(", "), more)
501                } else {
502                    String::new()
503                }
504            } else {
505                String::new()
506            }
507        }
508        "read_file" => {
509            if let Ok(v) = parsed {
510                v.get("path").and_then(|p| p.as_str()).unwrap_or("").to_string()
511            } else {
512                String::new()
513            }
514        }
515        "list_directory" => {
516            if let Ok(v) = parsed {
517                v.get("path").and_then(|p| p.as_str()).unwrap_or(".").to_string()
518            } else {
519                ".".to_string()
520            }
521        }
522        _ => String::new(),
523    }
524}
525
526/// Format shell command result
527fn format_shell_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
528    if let Ok(v) = parsed {
529        let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
530        let stdout = v.get("stdout").and_then(|s| s.as_str()).unwrap_or("");
531        let stderr = v.get("stderr").and_then(|s| s.as_str()).unwrap_or("");
532        let exit_code = v.get("exit_code").and_then(|c| c.as_i64());
533
534        let mut lines = Vec::new();
535
536        // Add stdout lines
537        for line in stdout.lines() {
538            if !line.trim().is_empty() {
539                lines.push(line.to_string());
540            }
541        }
542
543        // Add stderr lines if failed
544        if !success {
545            for line in stderr.lines() {
546                if !line.trim().is_empty() {
547                    lines.push(format!("{}", line.red()));
548                }
549            }
550            if let Some(code) = exit_code {
551                lines.push(format!("exit code: {}", code).red().to_string());
552            }
553        }
554
555        if lines.is_empty() {
556            lines.push(if success { "completed".to_string() } else { "failed".to_string() });
557        }
558
559        (success, lines)
560    } else {
561        (false, vec!["parse error".to_string()])
562    }
563}
564
565/// Format write file result
566fn format_write_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
567    if let Ok(v) = parsed {
568        let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
569        let action = v.get("action").and_then(|a| a.as_str()).unwrap_or("wrote");
570        let lines_written = v.get("lines_written")
571            .or_else(|| v.get("total_lines"))
572            .and_then(|n| n.as_u64())
573            .unwrap_or(0);
574        let files_written = v.get("files_written").and_then(|n| n.as_u64()).unwrap_or(1);
575
576        let msg = if files_written > 1 {
577            format!("{} {} files ({} lines)", action, files_written, lines_written)
578        } else {
579            format!("{} ({} lines)", action, lines_written)
580        };
581
582        (success, vec![msg])
583    } else {
584        (false, vec!["write failed".to_string()])
585    }
586}
587
588/// Format read file result
589fn format_read_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
590    if let Ok(v) = parsed {
591        // Handle error field
592        if v.get("error").is_some() {
593            let error_msg = v.get("error").and_then(|e| e.as_str()).unwrap_or("file not found");
594            return (false, vec![error_msg.to_string()]);
595        }
596
597        // Try to get total_lines from object
598        if let Some(total_lines) = v.get("total_lines").and_then(|n| n.as_u64()) {
599            let msg = if total_lines == 1 {
600                "read 1 line".to_string()
601            } else {
602                format!("read {} lines", total_lines)
603            };
604            return (true, vec![msg]);
605        }
606
607        // Fallback: if we have a string value (failed inner parse) or missing fields,
608        // try to extract line count from content or just say "read"
609        if let Some(content) = v.get("content").and_then(|c| c.as_str()) {
610            let lines = content.lines().count();
611            return (true, vec![format!("read {} lines", lines)]);
612        }
613
614        // Last resort: check if it's a string (double-encoding fallback)
615        if v.is_string() {
616            // The inner JSON couldn't be parsed, but we got something
617            return (true, vec!["read file".to_string()]);
618        }
619
620        (true, vec!["read file".to_string()])
621    } else {
622        (false, vec!["read failed".to_string()])
623    }
624}
625
626/// Format list directory result
627fn format_list_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
628    if let Ok(v) = parsed {
629        let entries = v.get("entries").and_then(|e| e.as_array());
630
631        let mut lines = Vec::new();
632
633        if let Some(entries) = entries {
634            let total = entries.len();
635            for entry in entries.iter().take(PREVIEW_LINES + 2) {
636                let name = entry.get("name").and_then(|n| n.as_str()).unwrap_or("?");
637                let entry_type = entry.get("type").and_then(|t| t.as_str()).unwrap_or("file");
638                let prefix = if entry_type == "directory" { "📁" } else { "📄" };
639                lines.push(format!("{} {}", prefix, name));
640            }
641            // Add count if there are more entries than shown
642            if total > PREVIEW_LINES + 2 {
643                lines.push(format!("... and {} more", total - (PREVIEW_LINES + 2)));
644            }
645        }
646
647        if lines.is_empty() {
648            lines.push("empty directory".to_string());
649        }
650
651        (true, lines)
652    } else {
653        (false, vec!["parse error".to_string()])
654    }
655}
656
657/// Format analyze result
658fn format_analyze_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
659    if let Ok(v) = parsed {
660        let mut lines = Vec::new();
661
662        // Languages
663        if let Some(langs) = v.get("languages").and_then(|l| l.as_array()) {
664            let lang_names: Vec<&str> = langs
665                .iter()
666                .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
667                .take(5)
668                .collect();
669            if !lang_names.is_empty() {
670                lines.push(format!("Languages: {}", lang_names.join(", ")));
671            }
672        }
673
674        // Frameworks
675        if let Some(frameworks) = v.get("frameworks").and_then(|f| f.as_array()) {
676            let fw_names: Vec<&str> = frameworks
677                .iter()
678                .filter_map(|f| f.get("name").and_then(|n| n.as_str()))
679                .take(5)
680                .collect();
681            if !fw_names.is_empty() {
682                lines.push(format!("Frameworks: {}", fw_names.join(", ")));
683            }
684        }
685
686        if lines.is_empty() {
687            lines.push("analysis complete".to_string());
688        }
689
690        (true, lines)
691    } else {
692        (false, vec!["parse error".to_string()])
693    }
694}
695
696/// Format security scan result
697fn format_security_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
698    if let Ok(v) = parsed {
699        let findings = v.get("findings")
700            .or_else(|| v.get("vulnerabilities"))
701            .and_then(|f| f.as_array())
702            .map(|a| a.len())
703            .unwrap_or(0);
704
705        if findings == 0 {
706            (true, vec!["no issues found".to_string()])
707        } else {
708            (false, vec![format!("{} issues found", findings)])
709        }
710    } else {
711        (false, vec!["parse error".to_string()])
712    }
713}
714
715/// Format hadolint result - uses new priority-based format with Docker styling
716fn format_hadolint_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
717    if let Ok(v) = parsed {
718        let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true);
719        let summary = v.get("summary");
720        let action_plan = v.get("action_plan");
721
722        let mut lines = Vec::new();
723
724        // Get total count
725        let total = summary
726            .and_then(|s| s.get("total"))
727            .and_then(|t| t.as_u64())
728            .unwrap_or(0);
729
730        // Show docker-themed header
731        if total == 0 {
732            lines.push(format!(
733                "{}🐳 Dockerfile OK - no issues found{}",
734                ansi::SUCCESS, ansi::RESET
735            ));
736            return (true, lines);
737        }
738
739        // Get priority counts
740        let critical = summary
741            .and_then(|s| s.get("by_priority"))
742            .and_then(|p| p.get("critical"))
743            .and_then(|c| c.as_u64())
744            .unwrap_or(0);
745        let high = summary
746            .and_then(|s| s.get("by_priority"))
747            .and_then(|p| p.get("high"))
748            .and_then(|h| h.as_u64())
749            .unwrap_or(0);
750        let medium = summary
751            .and_then(|s| s.get("by_priority"))
752            .and_then(|p| p.get("medium"))
753            .and_then(|m| m.as_u64())
754            .unwrap_or(0);
755        let low = summary
756            .and_then(|s| s.get("by_priority"))
757            .and_then(|p| p.get("low"))
758            .and_then(|l| l.as_u64())
759            .unwrap_or(0);
760
761        // Summary with priority breakdown
762        let mut priority_parts = Vec::new();
763        if critical > 0 {
764            priority_parts.push(format!("{}🔴 {} critical{}", ansi::CRITICAL, critical, ansi::RESET));
765        }
766        if high > 0 {
767            priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET));
768        }
769        if medium > 0 {
770            priority_parts.push(format!("{}🟡 {} medium{}", ansi::MEDIUM, medium, ansi::RESET));
771        }
772        if low > 0 {
773            priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET));
774        }
775
776        let header_color = if critical > 0 {
777            ansi::CRITICAL
778        } else if high > 0 {
779            ansi::HIGH
780        } else {
781            ansi::DOCKER_BLUE
782        };
783
784        lines.push(format!(
785            "{}🐳 {} issue{} found: {}{}",
786            header_color,
787            total,
788            if total == 1 { "" } else { "s" },
789            priority_parts.join(" "),
790            ansi::RESET
791        ));
792
793        // Show critical and high priority issues (these are most important)
794        let mut shown = 0;
795        const MAX_PREVIEW: usize = 6;
796
797        // Critical issues first
798        if let Some(critical_issues) = action_plan
799            .and_then(|a| a.get("critical"))
800            .and_then(|c| c.as_array())
801        {
802            for issue in critical_issues.iter().take(MAX_PREVIEW - shown) {
803                lines.push(format_hadolint_issue(issue, "🔴", ansi::CRITICAL));
804                shown += 1;
805            }
806        }
807
808        // Then high priority
809        if shown < MAX_PREVIEW {
810            if let Some(high_issues) = action_plan
811                .and_then(|a| a.get("high"))
812                .and_then(|h| h.as_array())
813            {
814                for issue in high_issues.iter().take(MAX_PREVIEW - shown) {
815                    lines.push(format_hadolint_issue(issue, "🟠", ansi::HIGH));
816                    shown += 1;
817                }
818            }
819        }
820
821        // Show quick fix hint for most important issue
822        if let Some(quick_fixes) = v.get("quick_fixes").and_then(|q| q.as_array()) {
823            if let Some(first_fix) = quick_fixes.first().and_then(|f| f.as_str()) {
824                let truncated = if first_fix.len() > 70 {
825                    format!("{}...", &first_fix[..67])
826                } else {
827                    first_fix.to_string()
828                };
829                lines.push(format!(
830                    "{}  → Fix: {}{}",
831                    ansi::INFO_BLUE, truncated, ansi::RESET
832                ));
833            }
834        }
835
836        // Note about remaining issues
837        let remaining = total as usize - shown;
838        if remaining > 0 {
839            lines.push(format!(
840                "{}  +{} more issue{}{}",
841                ansi::GRAY,
842                remaining,
843                if remaining == 1 { "" } else { "s" },
844                ansi::RESET
845            ));
846        }
847
848        (success, lines)
849    } else {
850        (false, vec!["parse error".to_string()])
851    }
852}
853
854/// Format a single hadolint issue for display
855fn format_hadolint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> String {
856    let code = issue.get("code").and_then(|c| c.as_str()).unwrap_or("?");
857    let message = issue.get("message").and_then(|m| m.as_str()).unwrap_or("?");
858    let line_num = issue.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
859    let category = issue.get("category").and_then(|c| c.as_str()).unwrap_or("");
860
861    // Category badge
862    let badge = match category {
863        "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
864        "best-practice" => format!("{}[BP]{}", ansi::INFO_BLUE, ansi::RESET),
865        "deprecated" => format!("{}[DEP]{}", ansi::MEDIUM, ansi::RESET),
866        "performance" => format!("{}[PERF]{}", ansi::CYAN, ansi::RESET),
867        _ => String::new(),
868    };
869
870    // Truncate message
871    let msg_display = if message.len() > 50 {
872        format!("{}...", &message[..47])
873    } else {
874        message.to_string()
875    };
876
877    format!(
878        "{}{} L{}:{} {}{}[{}]{} {} {}",
879        color, icon, line_num, ansi::RESET,
880        ansi::DOCKER_BLUE, ansi::BOLD, code, ansi::RESET,
881        badge,
882        msg_display
883    )
884}
885
886// Legacy exports for compatibility
887pub use crate::agent::ui::Spinner;
888use tokio::sync::mpsc;
889
890/// Events for backward compatibility
891#[derive(Debug, Clone)]
892pub enum ToolEvent {
893    ToolStart { name: String, args: String },
894    ToolComplete { name: String, result: String },
895}
896
897/// Legacy spawn function - now a no-op since display is handled in hooks
898pub fn spawn_tool_display_handler(
899    _receiver: mpsc::Receiver<ToolEvent>,
900    _spinner: Arc<crate::agent::ui::Spinner>,
901) -> tokio::task::JoinHandle<()> {
902    tokio::spawn(async {})
903}