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