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