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