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