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(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    /// Optional progress indicator state for real-time token display
67    pub progress_state: Option<std::sync::Arc<crate::agent::ui::progress::ProgressState>>,
68    /// Cancel signal from rig - stored for external cancellation trigger
69    pub cancel_signal: Option<CancelSignal>,
70}
71
72/// A hook that shows Claude Code style tool execution
73#[derive(Clone)]
74pub struct ToolDisplayHook {
75    state: Arc<Mutex<DisplayState>>,
76}
77
78impl ToolDisplayHook {
79    pub fn new() -> Self {
80        Self {
81            state: Arc::new(Mutex::new(DisplayState::default())),
82        }
83    }
84
85    /// Get the shared state for external access
86    pub fn state(&self) -> Arc<Mutex<DisplayState>> {
87        self.state.clone()
88    }
89
90    /// Get accumulated usage (blocks on lock)
91    pub async fn get_usage(&self) -> AccumulatedUsage {
92        let state = self.state.lock().await;
93        state.usage.clone()
94    }
95
96    /// Reset usage counter (e.g., at start of a new request batch)
97    pub async fn reset_usage(&self) {
98        let mut state = self.state.lock().await;
99        state.usage = AccumulatedUsage::default();
100    }
101
102    /// Set the progress indicator state for real-time token display
103    pub async fn set_progress_state(
104        &self,
105        progress: std::sync::Arc<crate::agent::ui::progress::ProgressState>,
106    ) {
107        let mut state = self.state.lock().await;
108        state.progress_state = Some(progress);
109    }
110
111    /// Clear the progress state
112    pub async fn clear_progress_state(&self) {
113        let mut state = self.state.lock().await;
114        state.progress_state = None;
115    }
116
117    /// Trigger cancellation of the current request.
118    /// This will cause rig to stop after the current tool/response completes.
119    pub async fn cancel(&self) {
120        let state = self.state.lock().await;
121        if let Some(ref cancel_sig) = state.cancel_signal {
122            cancel_sig.cancel();
123        }
124    }
125
126    /// Check if cancellation is possible (a cancel signal is stored)
127    pub async fn can_cancel(&self) -> bool {
128        let state = self.state.lock().await;
129        state.cancel_signal.is_some()
130    }
131}
132
133impl Default for ToolDisplayHook {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl<M> rig::agent::PromptHook<M> for ToolDisplayHook
140where
141    M: CompletionModel,
142{
143    fn on_tool_call(
144        &self,
145        tool_name: &str,
146        _tool_call_id: Option<String>,
147        args: &str,
148        cancel: CancelSignal,
149    ) -> impl std::future::Future<Output = ()> + Send {
150        let state = self.state.clone();
151        let name = tool_name.to_string();
152        let args_str = args.to_string();
153
154        async move {
155            // Store the cancel signal for external cancellation
156            {
157                let mut s = state.lock().await;
158                s.cancel_signal = Some(cancel);
159            }
160            // Pause progress indicator before printing
161            {
162                let s = state.lock().await;
163                if let Some(ref progress) = s.progress_state {
164                    progress.pause();
165                }
166            }
167
168            // Clear any progress line that might still be visible (timing issue)
169            // Progress loop will also clear, but we do it here to avoid race
170            print!("\r{}", ansi::CLEAR_LINE);
171            let _ = io::stdout().flush();
172
173            // Print tool header with spacing
174            println!(); // Add blank line before tool output
175            print_tool_header(&name, &args_str);
176
177            // Update progress indicator with current action (for when it resumes)
178            {
179                let s = state.lock().await;
180                if let Some(ref progress) = s.progress_state {
181                    // Set action based on tool type
182                    let action = tool_to_action(&name);
183                    progress.set_action(&action);
184
185                    // Set focus to tool details
186                    let focus = tool_to_focus(&name, &args_str);
187                    if let Some(f) = focus {
188                        progress.set_focus(&f);
189                    }
190                }
191            }
192
193            // Store in state
194            let mut s = state.lock().await;
195            let idx = s.tool_calls.len();
196            s.tool_calls.push(ToolCallState {
197                name,
198                args: args_str,
199                output: None,
200                output_lines: Vec::new(),
201                is_running: true,
202                is_expanded: false,
203                is_collapsible: false,
204                status_ok: true,
205            });
206            s.current_tool_index = Some(idx);
207        }
208    }
209
210    fn on_tool_result(
211        &self,
212        tool_name: &str,
213        _tool_call_id: Option<String>,
214        args: &str,
215        result: &str,
216        _cancel: CancelSignal,
217    ) -> impl std::future::Future<Output = ()> + Send {
218        let state = self.state.clone();
219        let name = tool_name.to_string();
220        let args_str = args.to_string();
221        let result_str = result.to_string();
222
223        async move {
224            // Print tool result and get the output info
225            let (status_ok, output_lines, is_collapsible) =
226                print_tool_result(&name, &args_str, &result_str);
227
228            // Update state
229            let mut s = state.lock().await;
230            if let Some(idx) = s.current_tool_index {
231                if let Some(tool) = s.tool_calls.get_mut(idx) {
232                    tool.output = Some(result_str);
233                    tool.output_lines = output_lines;
234                    tool.is_running = false;
235                    tool.is_collapsible = is_collapsible;
236                    tool.status_ok = status_ok;
237                }
238                // Track last expandable output
239                if is_collapsible {
240                    s.last_expandable_index = Some(idx);
241                }
242            }
243            s.current_tool_index = None;
244
245            // Resume progress indicator after tool completes
246            if let Some(ref progress) = s.progress_state {
247                progress.set_action("Thinking");
248                progress.clear_focus();
249                progress.resume();
250            }
251        }
252    }
253
254    fn on_completion_response(
255        &self,
256        _prompt: &Message,
257        response: &CompletionResponse<M::Response>,
258        cancel: CancelSignal,
259    ) -> impl std::future::Future<Output = ()> + Send {
260        let state = self.state.clone();
261
262        // Capture usage from response for token tracking
263        let usage = response.usage;
264
265        // Store the cancel signal immediately - this is called before tool calls
266        // so we can support Ctrl+C during initial "Thinking" phase
267        let cancel_for_store = cancel.clone();
268
269        // Check if response contains tool calls - if so, any text is "thinking"
270        // If no tool calls, this is the final response - don't show as thinking
271        let has_tool_calls = response
272            .choice
273            .iter()
274            .any(|content| matches!(content, AssistantContent::ToolCall(_)));
275
276        // Extract reasoning content (GPT-5.2 thinking summaries)
277        let reasoning_parts: Vec<String> = response
278            .choice
279            .iter()
280            .filter_map(|content| {
281                if let AssistantContent::Reasoning(Reasoning { reasoning, .. }) = content {
282                    // Join all reasoning strings
283                    let text = reasoning.to_vec().join("\n");
284                    if !text.trim().is_empty() {
285                        Some(text)
286                    } else {
287                        None
288                    }
289                } else {
290                    None
291                }
292            })
293            .collect();
294
295        // Extract text content from the response (for non-reasoning models)
296        let text_parts: Vec<String> = response
297            .choice
298            .iter()
299            .filter_map(|content| {
300                if let AssistantContent::Text(text) = content {
301                    // Filter out empty or whitespace-only text
302                    let trimmed = text.text.trim();
303                    if !trimmed.is_empty() {
304                        Some(trimmed.to_string())
305                    } else {
306                        None
307                    }
308                } else {
309                    None
310                }
311            })
312            .collect();
313
314        async move {
315            // Store the cancel signal first - enables Ctrl+C during initial "Thinking"
316            {
317                let mut s = state.lock().await;
318                s.cancel_signal = Some(cancel_for_store);
319            }
320
321            // Accumulate usage tokens from this response
322            {
323                let mut s = state.lock().await;
324                s.usage.add(&usage);
325
326                // Update progress indicator if connected
327                if let Some(ref progress) = s.progress_state {
328                    progress.update_tokens(usage.input_tokens, usage.output_tokens);
329                }
330            }
331
332            // First, show reasoning content if available (GPT-5.2 thinking)
333            if !reasoning_parts.is_empty() {
334                let thinking_text = reasoning_parts.join("\n");
335
336                // Store in state for history tracking and pause progress
337                let mut s = state.lock().await;
338                s.agent_messages.push(thinking_text.clone());
339                if let Some(ref progress) = s.progress_state {
340                    progress.pause();
341                }
342                drop(s);
343
344                // Clear any progress line (race condition prevention)
345                print!("\r{}", ansi::CLEAR_LINE);
346                let _ = io::stdout().flush();
347
348                // Display reasoning as thinking (minimal style - no redundant header)
349                print_agent_thinking(&thinking_text);
350
351                // Resume progress after
352                let s = state.lock().await;
353                if let Some(ref progress) = s.progress_state {
354                    progress.resume();
355                }
356            }
357
358            // Also show text content if it's intermediate (has tool calls)
359            // but NOT if it's the final response
360            if !text_parts.is_empty() && has_tool_calls {
361                let thinking_text = text_parts.join("\n");
362
363                // Store in state for history tracking and pause progress
364                let mut s = state.lock().await;
365                s.agent_messages.push(thinking_text.clone());
366                if let Some(ref progress) = s.progress_state {
367                    progress.pause();
368                }
369                drop(s);
370
371                // Clear any progress line (race condition prevention)
372                print!("\r{}", ansi::CLEAR_LINE);
373                let _ = io::stdout().flush();
374
375                // Display as thinking (minimal style)
376                print_agent_thinking(&thinking_text);
377
378                // Resume progress after
379                let s = state.lock().await;
380                if let Some(ref progress) = s.progress_state {
381                    progress.resume();
382                }
383            }
384        }
385    }
386}
387
388/// Print agent thinking/reasoning text with nice formatting
389/// Note: No header needed - progress indicator shows "Thinking" action
390fn print_agent_thinking(text: &str) {
391    use crate::agent::ui::response::brand;
392
393    println!();
394
395    // Format the content with markdown support (subtle style)
396    let mut in_code_block = false;
397
398    for line in text.lines() {
399        let trimmed = line.trim();
400
401        // Handle code blocks
402        if trimmed.starts_with("```") {
403            if in_code_block {
404                println!(
405                    "{}  └────────────────────────────────────────────────────────┘{}",
406                    brand::LIGHT_PEACH,
407                    brand::RESET
408                );
409                in_code_block = false;
410            } else {
411                let lang = trimmed.strip_prefix("```").unwrap_or("");
412                let lang_display = if lang.is_empty() { "code" } else { lang };
413                println!(
414                    "{}  ┌─ {}{}{} ────────────────────────────────────────────────────┐{}",
415                    brand::LIGHT_PEACH,
416                    brand::CYAN,
417                    lang_display,
418                    brand::LIGHT_PEACH,
419                    brand::RESET
420                );
421                in_code_block = true;
422            }
423            continue;
424        }
425
426        if in_code_block {
427            println!(
428                "{}  │ {}{}{}  │",
429                brand::LIGHT_PEACH,
430                brand::CYAN,
431                line,
432                brand::RESET
433            );
434            continue;
435        }
436
437        // Handle bullet points
438        if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
439            let content = trimmed
440                .strip_prefix("- ")
441                .or_else(|| trimmed.strip_prefix("* "))
442                .unwrap_or(trimmed);
443            println!(
444                "{}  • {}{}",
445                brand::PEACH,
446                format_thinking_inline(content),
447                brand::RESET
448            );
449            continue;
450        }
451
452        // Handle numbered lists
453        if trimmed
454            .chars()
455            .next()
456            .map(|c| c.is_ascii_digit())
457            .unwrap_or(false)
458            && trimmed.chars().nth(1) == Some('.')
459        {
460            println!(
461                "{}  {}{}",
462                brand::PEACH,
463                format_thinking_inline(trimmed),
464                brand::RESET
465            );
466            continue;
467        }
468
469        // Regular text with inline formatting
470        if trimmed.is_empty() {
471            println!();
472        } else {
473            // Word wrap long lines
474            let wrapped = wrap_text(trimmed, 76);
475            for wrapped_line in wrapped {
476                println!(
477                    "{}  {}{}",
478                    brand::PEACH,
479                    format_thinking_inline(&wrapped_line),
480                    brand::RESET
481                );
482            }
483        }
484    }
485
486    println!();
487    let _ = io::stdout().flush();
488}
489
490/// Format inline elements in thinking text (code, bold)
491fn format_thinking_inline(text: &str) -> String {
492    use crate::agent::ui::response::brand;
493
494    let mut result = String::new();
495    let chars: Vec<char> = text.chars().collect();
496    let mut i = 0;
497
498    while i < chars.len() {
499        // Handle `code`
500        if chars[i] == '`'
501            && (i + 1 >= chars.len() || chars[i + 1] != '`')
502            && let Some(end) = chars[i + 1..].iter().position(|&c| c == '`')
503        {
504            let code_text: String = chars[i + 1..i + 1 + end].iter().collect();
505            result.push_str(brand::CYAN);
506            result.push('`');
507            result.push_str(&code_text);
508            result.push('`');
509            result.push_str(brand::RESET);
510            result.push_str(brand::PEACH);
511            i = i + 2 + end;
512            continue;
513        }
514
515        // Handle **bold**
516        if i + 1 < chars.len()
517            && chars[i] == '*'
518            && chars[i + 1] == '*'
519            && let Some(end_offset) = find_double_star(&chars, i + 2)
520        {
521            let bold_text: String = chars[i + 2..i + 2 + end_offset].iter().collect();
522            result.push_str(brand::RESET);
523            result.push_str(brand::CORAL);
524            result.push_str(brand::BOLD);
525            result.push_str(&bold_text);
526            result.push_str(brand::RESET);
527            result.push_str(brand::PEACH);
528            i = i + 4 + end_offset;
529            continue;
530        }
531
532        result.push(chars[i]);
533        i += 1;
534    }
535
536    result
537}
538
539/// Find closing ** marker
540fn find_double_star(chars: &[char], start: usize) -> Option<usize> {
541    for i in start..chars.len().saturating_sub(1) {
542        if chars[i] == '*' && chars[i + 1] == '*' {
543            return Some(i - start);
544        }
545    }
546    None
547}
548
549/// Simple word wrap helper
550fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
551    if text.len() <= max_width {
552        return vec![text.to_string()];
553    }
554
555    let mut lines = Vec::new();
556    let mut current_line = String::new();
557
558    for word in text.split_whitespace() {
559        if current_line.is_empty() {
560            current_line = word.to_string();
561        } else if current_line.len() + 1 + word.len() <= max_width {
562            current_line.push(' ');
563            current_line.push_str(word);
564        } else {
565            lines.push(current_line);
566            current_line = word.to_string();
567        }
568    }
569
570    if !current_line.is_empty() {
571        lines.push(current_line);
572    }
573
574    if lines.is_empty() {
575        lines.push(text.to_string());
576    }
577
578    lines
579}
580
581/// Print tool call header in Claude Code style
582fn print_tool_header(name: &str, args: &str) {
583    let parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
584    let args_display = format_args_display(name, &parsed);
585
586    // Print header with yellow dot (running)
587    if args_display.is_empty() {
588        println!("\n{} {}", "●".yellow(), name.cyan().bold());
589    } else {
590        println!(
591            "\n{} {}({})",
592            "●".yellow(),
593            name.cyan().bold(),
594            args_display.dimmed()
595        );
596    }
597
598    // Print running indicator
599    println!("  {} {}", "└".dimmed(), "Running...".dimmed());
600
601    let _ = io::stdout().flush();
602}
603
604/// Print tool result with preview and collapse
605/// Returns (status_ok, output_lines, is_collapsible)
606fn print_tool_result(name: &str, args: &str, result: &str) -> (bool, Vec<String>, bool) {
607    // Clear the "Running..." line
608    print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
609    let _ = io::stdout().flush();
610
611    // Parse the result - handle potential double-encoding from Rig
612    let parsed: Result<serde_json::Value, _> =
613        serde_json::from_str(result).map(|v: serde_json::Value| {
614            // If the parsed value is a string, it might be double-encoded JSON
615            // Try to parse the inner string, but fall back to original if it fails
616            if let Some(inner_str) = v.as_str() {
617                serde_json::from_str(inner_str).unwrap_or(v)
618            } else {
619                v
620            }
621        });
622
623    // Format output based on tool type
624    let (status_ok, output_lines) = match name {
625        "shell" => format_shell_result(&parsed),
626        "write_file" | "write_files" => format_write_result(&parsed),
627        "read_file" => format_read_result(&parsed),
628        "list_directory" => format_list_result(&parsed),
629        "analyze_project" => format_analyze_result(&parsed),
630        "security_scan" | "check_vulnerabilities" => format_security_result(&parsed),
631        "hadolint" => format_hadolint_result(&parsed),
632        "kubelint" => format_kubelint_result(&parsed),
633        "helmlint" => format_helmlint_result(&parsed),
634        _ => (true, vec!["done".to_string()]),
635    };
636
637    // Clear the header line to update dot color
638    print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
639
640    // Reprint header with green/red dot and args
641    let dot = if status_ok {
642        "●".green()
643    } else {
644        "●".red()
645    };
646
647    // Format args for display (same logic as print_tool_header)
648    let args_parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
649    let args_display = format_args_display(name, &args_parsed);
650
651    if args_display.is_empty() {
652        println!("{} {}", dot, name.cyan().bold());
653    } else {
654        println!("{} {}({})", dot, name.cyan().bold(), args_display.dimmed());
655    }
656
657    // Print output preview
658    let total_lines = output_lines.len();
659    let is_collapsible = total_lines > PREVIEW_LINES;
660
661    for (i, line) in output_lines.iter().take(PREVIEW_LINES).enumerate() {
662        let prefix = if i == output_lines.len().min(PREVIEW_LINES) - 1 && !is_collapsible {
663            "└"
664        } else {
665            "│"
666        };
667        println!("  {} {}", prefix.dimmed(), line);
668    }
669
670    // Show collapse indicator if needed
671    if is_collapsible {
672        println!(
673            "  {} {}",
674            "└".dimmed(),
675            format!("+{} more lines", total_lines - PREVIEW_LINES).dimmed()
676        );
677    }
678
679    let _ = io::stdout().flush();
680    (status_ok, output_lines, is_collapsible)
681}
682
683/// Format args for display based on tool type
684fn format_args_display(
685    name: &str,
686    parsed: &Result<serde_json::Value, serde_json::Error>,
687) -> String {
688    match name {
689        "shell" => {
690            if let Ok(v) = parsed {
691                v.get("command")
692                    .and_then(|c| c.as_str())
693                    .unwrap_or("")
694                    .to_string()
695            } else {
696                String::new()
697            }
698        }
699        "write_file" => {
700            if let Ok(v) = parsed {
701                v.get("path")
702                    .and_then(|p| p.as_str())
703                    .unwrap_or("")
704                    .to_string()
705            } else {
706                String::new()
707            }
708        }
709        "write_files" => {
710            if let Ok(v) = parsed {
711                if let Some(files) = v.get("files").and_then(|f| f.as_array()) {
712                    let paths: Vec<&str> = files
713                        .iter()
714                        .filter_map(|f| f.get("path").and_then(|p| p.as_str()))
715                        .take(3)
716                        .collect();
717                    let more = if files.len() > 3 {
718                        format!(", +{} more", files.len() - 3)
719                    } else {
720                        String::new()
721                    };
722                    format!("{}{}", paths.join(", "), more)
723                } else {
724                    String::new()
725                }
726            } else {
727                String::new()
728            }
729        }
730        "read_file" => {
731            if let Ok(v) = parsed {
732                v.get("path")
733                    .and_then(|p| p.as_str())
734                    .unwrap_or("")
735                    .to_string()
736            } else {
737                String::new()
738            }
739        }
740        "list_directory" => {
741            if let Ok(v) = parsed {
742                v.get("path")
743                    .and_then(|p| p.as_str())
744                    .unwrap_or(".")
745                    .to_string()
746            } else {
747                ".".to_string()
748            }
749        }
750        "kubelint" | "helmlint" | "hadolint" | "dclint" => {
751            if let Ok(v) = parsed {
752                // Show path if provided
753                if let Some(path) = v.get("path").and_then(|p| p.as_str()) {
754                    return path.to_string();
755                }
756                // Show content indicator if provided
757                if v.get("content").and_then(|c| c.as_str()).is_some() {
758                    return "<inline>".to_string();
759                }
760                // No path - will use auto-discovery
761                "<auto>".to_string()
762            } else {
763                String::new()
764            }
765        }
766        _ => String::new(),
767    }
768}
769
770/// Format shell command result
771fn format_shell_result(
772    parsed: &Result<serde_json::Value, serde_json::Error>,
773) -> (bool, Vec<String>) {
774    if let Ok(v) = parsed {
775        let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
776        let stdout = v.get("stdout").and_then(|s| s.as_str()).unwrap_or("");
777        let stderr = v.get("stderr").and_then(|s| s.as_str()).unwrap_or("");
778        let exit_code = v.get("exit_code").and_then(|c| c.as_i64());
779
780        let mut lines = Vec::new();
781
782        // Add stdout lines
783        for line in stdout.lines() {
784            if !line.trim().is_empty() {
785                lines.push(line.to_string());
786            }
787        }
788
789        // Add stderr lines if failed
790        if !success {
791            for line in stderr.lines() {
792                if !line.trim().is_empty() {
793                    lines.push(format!("{}", line.red()));
794                }
795            }
796            if let Some(code) = exit_code {
797                lines.push(format!("exit code: {}", code).red().to_string());
798            }
799        }
800
801        if lines.is_empty() {
802            lines.push(if success {
803                "completed".to_string()
804            } else {
805                "failed".to_string()
806            });
807        }
808
809        (success, lines)
810    } else {
811        (false, vec!["parse error".to_string()])
812    }
813}
814
815/// Format write file result
816fn format_write_result(
817    parsed: &Result<serde_json::Value, serde_json::Error>,
818) -> (bool, Vec<String>) {
819    if let Ok(v) = parsed {
820        let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
821        let action = v.get("action").and_then(|a| a.as_str()).unwrap_or("wrote");
822        let lines_written = v
823            .get("lines_written")
824            .or_else(|| v.get("total_lines"))
825            .and_then(|n| n.as_u64())
826            .unwrap_or(0);
827        let files_written = v.get("files_written").and_then(|n| n.as_u64()).unwrap_or(1);
828
829        let msg = if files_written > 1 {
830            format!(
831                "{} {} files ({} lines)",
832                action, files_written, lines_written
833            )
834        } else {
835            format!("{} ({} lines)", action, lines_written)
836        };
837
838        (success, vec![msg])
839    } else {
840        (false, vec!["write failed".to_string()])
841    }
842}
843
844/// Format read file result
845fn format_read_result(
846    parsed: &Result<serde_json::Value, serde_json::Error>,
847) -> (bool, Vec<String>) {
848    if let Ok(v) = parsed {
849        // Handle error field
850        if v.get("error").is_some() {
851            let error_msg = v
852                .get("error")
853                .and_then(|e| e.as_str())
854                .unwrap_or("file not found");
855            return (false, vec![error_msg.to_string()]);
856        }
857
858        // Try to get total_lines from object
859        if let Some(total_lines) = v.get("total_lines").and_then(|n| n.as_u64()) {
860            let msg = if total_lines == 1 {
861                "read 1 line".to_string()
862            } else {
863                format!("read {} lines", total_lines)
864            };
865            return (true, vec![msg]);
866        }
867
868        // Fallback: if we have a string value (failed inner parse) or missing fields,
869        // try to extract line count from content or just say "read"
870        if let Some(content) = v.get("content").and_then(|c| c.as_str()) {
871            let lines = content.lines().count();
872            return (true, vec![format!("read {} lines", lines)]);
873        }
874
875        // Last resort: check if it's a string (double-encoding fallback)
876        if v.is_string() {
877            // The inner JSON couldn't be parsed, but we got something
878            return (true, vec!["read file".to_string()]);
879        }
880
881        (true, vec!["read file".to_string()])
882    } else {
883        (false, vec!["read failed".to_string()])
884    }
885}
886
887/// Format list directory result
888fn format_list_result(
889    parsed: &Result<serde_json::Value, serde_json::Error>,
890) -> (bool, Vec<String>) {
891    if let Ok(v) = parsed {
892        let entries = v.get("entries").and_then(|e| e.as_array());
893
894        let mut lines = Vec::new();
895
896        if let Some(entries) = entries {
897            let total = entries.len();
898            for entry in entries.iter().take(PREVIEW_LINES + 2) {
899                let name = entry.get("name").and_then(|n| n.as_str()).unwrap_or("?");
900                let entry_type = entry.get("type").and_then(|t| t.as_str()).unwrap_or("file");
901                let prefix = if entry_type == "directory" {
902                    "📁"
903                } else {
904                    "📄"
905                };
906                lines.push(format!("{} {}", prefix, name));
907            }
908            // Add count if there are more entries than shown
909            if total > PREVIEW_LINES + 2 {
910                lines.push(format!("... and {} more", total - (PREVIEW_LINES + 2)));
911            }
912        }
913
914        if lines.is_empty() {
915            lines.push("empty directory".to_string());
916        }
917
918        (true, lines)
919    } else {
920        (false, vec!["parse error".to_string()])
921    }
922}
923
924/// Format analyze result
925fn format_analyze_result(
926    parsed: &Result<serde_json::Value, serde_json::Error>,
927) -> (bool, Vec<String>) {
928    if let Ok(v) = parsed {
929        let mut lines = Vec::new();
930
931        // Languages
932        if let Some(langs) = v.get("languages").and_then(|l| l.as_array()) {
933            let lang_names: Vec<&str> = langs
934                .iter()
935                .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
936                .take(5)
937                .collect();
938            if !lang_names.is_empty() {
939                lines.push(format!("Languages: {}", lang_names.join(", ")));
940            }
941        }
942
943        // Frameworks
944        if let Some(frameworks) = v.get("frameworks").and_then(|f| f.as_array()) {
945            let fw_names: Vec<&str> = frameworks
946                .iter()
947                .filter_map(|f| f.get("name").and_then(|n| n.as_str()))
948                .take(5)
949                .collect();
950            if !fw_names.is_empty() {
951                lines.push(format!("Frameworks: {}", fw_names.join(", ")));
952            }
953        }
954
955        if lines.is_empty() {
956            lines.push("analysis complete".to_string());
957        }
958
959        (true, lines)
960    } else {
961        (false, vec!["parse error".to_string()])
962    }
963}
964
965/// Format security scan result
966fn format_security_result(
967    parsed: &Result<serde_json::Value, serde_json::Error>,
968) -> (bool, Vec<String>) {
969    if let Ok(v) = parsed {
970        let findings = v
971            .get("findings")
972            .or_else(|| v.get("vulnerabilities"))
973            .and_then(|f| f.as_array())
974            .map(|a| a.len())
975            .unwrap_or(0);
976
977        if findings == 0 {
978            (true, vec!["no issues found".to_string()])
979        } else {
980            (false, vec![format!("{} issues found", findings)])
981        }
982    } else {
983        (false, vec!["parse error".to_string()])
984    }
985}
986
987/// Format hadolint result - uses new priority-based format with Docker styling
988fn format_hadolint_result(
989    parsed: &Result<serde_json::Value, serde_json::Error>,
990) -> (bool, Vec<String>) {
991    if let Ok(v) = parsed {
992        let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true);
993        let summary = v.get("summary");
994        let action_plan = v.get("action_plan");
995
996        let mut lines = Vec::new();
997
998        // Get total count
999        let total = summary
1000            .and_then(|s| s.get("total"))
1001            .and_then(|t| t.as_u64())
1002            .unwrap_or(0);
1003
1004        // Show docker-themed header
1005        if total == 0 {
1006            lines.push(format!(
1007                "{}🐳 Dockerfile OK - no issues found{}",
1008                ansi::SUCCESS,
1009                ansi::RESET
1010            ));
1011            return (true, lines);
1012        }
1013
1014        // Get priority counts
1015        let critical = summary
1016            .and_then(|s| s.get("by_priority"))
1017            .and_then(|p| p.get("critical"))
1018            .and_then(|c| c.as_u64())
1019            .unwrap_or(0);
1020        let high = summary
1021            .and_then(|s| s.get("by_priority"))
1022            .and_then(|p| p.get("high"))
1023            .and_then(|h| h.as_u64())
1024            .unwrap_or(0);
1025        let medium = summary
1026            .and_then(|s| s.get("by_priority"))
1027            .and_then(|p| p.get("medium"))
1028            .and_then(|m| m.as_u64())
1029            .unwrap_or(0);
1030        let low = summary
1031            .and_then(|s| s.get("by_priority"))
1032            .and_then(|p| p.get("low"))
1033            .and_then(|l| l.as_u64())
1034            .unwrap_or(0);
1035
1036        // Summary with priority breakdown
1037        let mut priority_parts = Vec::new();
1038        if critical > 0 {
1039            priority_parts.push(format!(
1040                "{}🔴 {} critical{}",
1041                ansi::CRITICAL,
1042                critical,
1043                ansi::RESET
1044            ));
1045        }
1046        if high > 0 {
1047            priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET));
1048        }
1049        if medium > 0 {
1050            priority_parts.push(format!(
1051                "{}🟡 {} medium{}",
1052                ansi::MEDIUM,
1053                medium,
1054                ansi::RESET
1055            ));
1056        }
1057        if low > 0 {
1058            priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET));
1059        }
1060
1061        let header_color = if critical > 0 {
1062            ansi::CRITICAL
1063        } else if high > 0 {
1064            ansi::HIGH
1065        } else {
1066            ansi::DOCKER_BLUE
1067        };
1068
1069        lines.push(format!(
1070            "{}🐳 {} issue{} found: {}{}",
1071            header_color,
1072            total,
1073            if total == 1 { "" } else { "s" },
1074            priority_parts.join(" "),
1075            ansi::RESET
1076        ));
1077
1078        // Show critical and high priority issues (these are most important)
1079        let mut shown = 0;
1080        const MAX_PREVIEW: usize = 6;
1081
1082        // Critical issues first
1083        if let Some(critical_issues) = action_plan
1084            .and_then(|a| a.get("critical"))
1085            .and_then(|c| c.as_array())
1086        {
1087            for issue in critical_issues.iter().take(MAX_PREVIEW - shown) {
1088                lines.push(format_hadolint_issue(issue, "🔴", ansi::CRITICAL));
1089                shown += 1;
1090            }
1091        }
1092
1093        // Then high priority
1094        if shown < MAX_PREVIEW
1095            && let Some(high_issues) = action_plan
1096                .and_then(|a| a.get("high"))
1097                .and_then(|h| h.as_array())
1098        {
1099            for issue in high_issues.iter().take(MAX_PREVIEW - shown) {
1100                lines.push(format_hadolint_issue(issue, "🟠", ansi::HIGH));
1101                shown += 1;
1102            }
1103        }
1104
1105        // Show quick fix hint for most important issue
1106        if let Some(quick_fixes) = v.get("quick_fixes").and_then(|q| q.as_array())
1107            && let Some(first_fix) = quick_fixes.first().and_then(|f| f.as_str())
1108        {
1109            let truncated = if first_fix.len() > 70 {
1110                format!("{}...", &first_fix[..67])
1111            } else {
1112                first_fix.to_string()
1113            };
1114            lines.push(format!(
1115                "{}  → Fix: {}{}",
1116                ansi::INFO_BLUE,
1117                truncated,
1118                ansi::RESET
1119            ));
1120        }
1121
1122        // Note about remaining issues
1123        let remaining = total as usize - shown;
1124        if remaining > 0 {
1125            lines.push(format!(
1126                "{}  +{} more issue{}{}",
1127                ansi::GRAY,
1128                remaining,
1129                if remaining == 1 { "" } else { "s" },
1130                ansi::RESET
1131            ));
1132        }
1133
1134        (success, lines)
1135    } else {
1136        (false, vec!["parse error".to_string()])
1137    }
1138}
1139
1140/// Format a single hadolint issue for display
1141fn format_hadolint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> String {
1142    let code = issue.get("code").and_then(|c| c.as_str()).unwrap_or("?");
1143    let message = issue.get("message").and_then(|m| m.as_str()).unwrap_or("?");
1144    let line_num = issue.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
1145    let category = issue.get("category").and_then(|c| c.as_str()).unwrap_or("");
1146
1147    // Category badge
1148    let badge = match category {
1149        "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
1150        "best-practice" => format!("{}[BP]{}", ansi::INFO_BLUE, ansi::RESET),
1151        "deprecated" => format!("{}[DEP]{}", ansi::MEDIUM, ansi::RESET),
1152        "performance" => format!("{}[PERF]{}", ansi::CYAN, ansi::RESET),
1153        _ => String::new(),
1154    };
1155
1156    // Truncate message
1157    let msg_display = if message.len() > 50 {
1158        format!("{}...", &message[..47])
1159    } else {
1160        message.to_string()
1161    };
1162
1163    format!(
1164        "{}{} L{}:{} {}{}[{}]{} {} {}",
1165        color,
1166        icon,
1167        line_num,
1168        ansi::RESET,
1169        ansi::DOCKER_BLUE,
1170        ansi::BOLD,
1171        code,
1172        ansi::RESET,
1173        badge,
1174        msg_display
1175    )
1176}
1177
1178/// Format kubelint result - inline preview format like hadolint
1179fn format_kubelint_result(
1180    parsed: &Result<serde_json::Value, serde_json::Error>,
1181) -> (bool, Vec<String>) {
1182    if let Ok(v) = parsed {
1183        let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true);
1184        let summary = v.get("summary");
1185        let action_plan = v.get("action_plan");
1186        let parse_errors = v.get("parse_errors").and_then(|p| p.as_array());
1187
1188        let total = summary
1189            .and_then(|s| s.get("total_issues"))
1190            .and_then(|t| t.as_u64())
1191            .unwrap_or(0);
1192
1193        let mut lines = Vec::new();
1194
1195        // Check for parse errors first
1196        if let Some(errors) = parse_errors {
1197            if !errors.is_empty() {
1198                lines.push(format!(
1199                    "{}☸ {} parse error{} (files could not be fully analyzed){}",
1200                    ansi::HIGH,
1201                    errors.len(),
1202                    if errors.len() == 1 { "" } else { "s" },
1203                    ansi::RESET
1204                ));
1205                for (i, err) in errors.iter().take(3).enumerate() {
1206                    if let Some(err_str) = err.as_str() {
1207                        let truncated = if err_str.len() > 70 {
1208                            format!("{}...", &err_str[..67])
1209                        } else {
1210                            err_str.to_string()
1211                        };
1212                        lines.push(format!(
1213                            "{}  {} {}{}",
1214                            ansi::HIGH,
1215                            if i == errors.len().min(3) - 1 {
1216                                "└"
1217                            } else {
1218                                "│"
1219                            },
1220                            truncated,
1221                            ansi::RESET
1222                        ));
1223                    }
1224                }
1225                if errors.len() > 3 {
1226                    lines.push(format!(
1227                        "{}  +{} more errors{}",
1228                        ansi::GRAY,
1229                        errors.len() - 3,
1230                        ansi::RESET
1231                    ));
1232                }
1233                // If we only have parse errors and no lint issues, return early
1234                if total == 0 {
1235                    return (false, lines);
1236                }
1237            }
1238        }
1239
1240        if total == 0 && parse_errors.map(|e| e.is_empty()).unwrap_or(true) {
1241            lines.push(format!(
1242                "{}☸ K8s manifests OK - no issues found{}",
1243                ansi::SUCCESS,
1244                ansi::RESET
1245            ));
1246            return (true, lines);
1247        }
1248
1249        // Get priority counts
1250        let critical = summary
1251            .and_then(|s| s.get("by_priority"))
1252            .and_then(|p| p.get("critical"))
1253            .and_then(|c| c.as_u64())
1254            .unwrap_or(0);
1255        let high = summary
1256            .and_then(|s| s.get("by_priority"))
1257            .and_then(|p| p.get("high"))
1258            .and_then(|h| h.as_u64())
1259            .unwrap_or(0);
1260        let medium = summary
1261            .and_then(|s| s.get("by_priority"))
1262            .and_then(|p| p.get("medium"))
1263            .and_then(|m| m.as_u64())
1264            .unwrap_or(0);
1265        let low = summary
1266            .and_then(|s| s.get("by_priority"))
1267            .and_then(|p| p.get("low"))
1268            .and_then(|l| l.as_u64())
1269            .unwrap_or(0);
1270
1271        // Summary with priority breakdown
1272        let mut priority_parts = Vec::new();
1273        if critical > 0 {
1274            priority_parts.push(format!(
1275                "{}🔴 {} critical{}",
1276                ansi::CRITICAL,
1277                critical,
1278                ansi::RESET
1279            ));
1280        }
1281        if high > 0 {
1282            priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET));
1283        }
1284        if medium > 0 {
1285            priority_parts.push(format!(
1286                "{}🟡 {} medium{}",
1287                ansi::MEDIUM,
1288                medium,
1289                ansi::RESET
1290            ));
1291        }
1292        if low > 0 {
1293            priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET));
1294        }
1295
1296        let header_color = if critical > 0 {
1297            ansi::CRITICAL
1298        } else if high > 0 {
1299            ansi::HIGH
1300        } else {
1301            ansi::CYAN
1302        };
1303
1304        lines.push(format!(
1305            "{}☸ {} issue{} found: {}{}",
1306            header_color,
1307            total,
1308            if total == 1 { "" } else { "s" },
1309            priority_parts.join(" "),
1310            ansi::RESET
1311        ));
1312
1313        // Show critical and high priority issues
1314        let mut shown = 0;
1315        const MAX_PREVIEW: usize = 6;
1316
1317        // Critical issues first
1318        if let Some(critical_issues) = action_plan
1319            .and_then(|a| a.get("critical"))
1320            .and_then(|c| c.as_array())
1321        {
1322            for issue in critical_issues.iter().take(MAX_PREVIEW - shown) {
1323                lines.push(format_kubelint_issue(issue, "🔴", ansi::CRITICAL));
1324                shown += 1;
1325            }
1326        }
1327
1328        // Then high priority
1329        if shown < MAX_PREVIEW {
1330            if let Some(high_issues) = action_plan
1331                .and_then(|a| a.get("high"))
1332                .and_then(|h| h.as_array())
1333            {
1334                for issue in high_issues.iter().take(MAX_PREVIEW - shown) {
1335                    lines.push(format_kubelint_issue(issue, "🟠", ansi::HIGH));
1336                    shown += 1;
1337                }
1338            }
1339        }
1340
1341        // Show quick fix hint
1342        if let Some(quick_fixes) = v.get("quick_fixes").and_then(|q| q.as_array()) {
1343            if let Some(first_fix) = quick_fixes.first().and_then(|f| f.as_str()) {
1344                let truncated = if first_fix.len() > 70 {
1345                    format!("{}...", &first_fix[..67])
1346                } else {
1347                    first_fix.to_string()
1348                };
1349                lines.push(format!(
1350                    "{}  → Fix: {}{}",
1351                    ansi::INFO_BLUE,
1352                    truncated,
1353                    ansi::RESET
1354                ));
1355            }
1356        }
1357
1358        // Note about remaining issues
1359        let remaining = total as usize - shown;
1360        if remaining > 0 {
1361            lines.push(format!(
1362                "{}  +{} more issue{}{}",
1363                ansi::GRAY,
1364                remaining,
1365                if remaining == 1 { "" } else { "s" },
1366                ansi::RESET
1367            ));
1368        }
1369
1370        (success && total == 0, lines)
1371    } else {
1372        (false, vec!["kubelint analysis complete".to_string()])
1373    }
1374}
1375
1376/// Format a single kubelint issue for display
1377fn format_kubelint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> String {
1378    let check = issue.get("check").and_then(|c| c.as_str()).unwrap_or("?");
1379    let message = issue.get("message").and_then(|m| m.as_str()).unwrap_or("?");
1380    let line_num = issue.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
1381    let category = issue.get("category").and_then(|c| c.as_str()).unwrap_or("");
1382
1383    // Category badge
1384    let badge = match category {
1385        "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
1386        "rbac" => format!("{}[RBAC]{}", ansi::CRITICAL, ansi::RESET),
1387        "best-practice" => format!("{}[BP]{}", ansi::INFO_BLUE, ansi::RESET),
1388        "validation" => format!("{}[VAL]{}", ansi::MEDIUM, ansi::RESET),
1389        _ => String::new(),
1390    };
1391
1392    // Truncate message
1393    let msg_display = if message.len() > 50 {
1394        format!("{}...", &message[..47])
1395    } else {
1396        message.to_string()
1397    };
1398
1399    format!(
1400        "{}{} L{}:{} {}{}[{}]{} {} {}",
1401        color,
1402        icon,
1403        line_num,
1404        ansi::RESET,
1405        ansi::CYAN,
1406        ansi::BOLD,
1407        check,
1408        ansi::RESET,
1409        badge,
1410        msg_display
1411    )
1412}
1413
1414/// Format helmlint result - inline preview format like hadolint
1415fn format_helmlint_result(
1416    parsed: &Result<serde_json::Value, serde_json::Error>,
1417) -> (bool, Vec<String>) {
1418    if let Ok(v) = parsed {
1419        let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true);
1420        let summary = v.get("summary");
1421        let action_plan = v.get("action_plan");
1422        let parse_errors = v.get("parse_errors").and_then(|p| p.as_array());
1423
1424        let total = summary
1425            .and_then(|s| s.get("total"))
1426            .and_then(|t| t.as_u64())
1427            .unwrap_or(0);
1428
1429        let mut lines = Vec::new();
1430
1431        // Check for parse errors first
1432        if let Some(errors) = parse_errors {
1433            if !errors.is_empty() {
1434                lines.push(format!(
1435                    "{}⎈ {} parse error{} (chart could not be fully analyzed){}",
1436                    ansi::HIGH,
1437                    errors.len(),
1438                    if errors.len() == 1 { "" } else { "s" },
1439                    ansi::RESET
1440                ));
1441                for (i, err) in errors.iter().take(3).enumerate() {
1442                    if let Some(err_str) = err.as_str() {
1443                        let truncated = if err_str.len() > 70 {
1444                            format!("{}...", &err_str[..67])
1445                        } else {
1446                            err_str.to_string()
1447                        };
1448                        lines.push(format!(
1449                            "{}  {} {}{}",
1450                            ansi::HIGH,
1451                            if i == errors.len().min(3) - 1 {
1452                                "└"
1453                            } else {
1454                                "│"
1455                            },
1456                            truncated,
1457                            ansi::RESET
1458                        ));
1459                    }
1460                }
1461                if errors.len() > 3 {
1462                    lines.push(format!(
1463                        "{}  +{} more errors{}",
1464                        ansi::GRAY,
1465                        errors.len() - 3,
1466                        ansi::RESET
1467                    ));
1468                }
1469                // If we only have parse errors and no lint issues, return early
1470                if total == 0 {
1471                    return (false, lines);
1472                }
1473            }
1474        }
1475
1476        if total == 0 && parse_errors.map(|e| e.is_empty()).unwrap_or(true) {
1477            lines.push(format!(
1478                "{}⎈ Helm chart OK - no issues found{}",
1479                ansi::SUCCESS,
1480                ansi::RESET
1481            ));
1482            return (true, lines);
1483        }
1484
1485        // Get priority counts
1486        let critical = summary
1487            .and_then(|s| s.get("by_priority"))
1488            .and_then(|p| p.get("critical"))
1489            .and_then(|c| c.as_u64())
1490            .unwrap_or(0);
1491        let high = summary
1492            .and_then(|s| s.get("by_priority"))
1493            .and_then(|p| p.get("high"))
1494            .and_then(|h| h.as_u64())
1495            .unwrap_or(0);
1496        let medium = summary
1497            .and_then(|s| s.get("by_priority"))
1498            .and_then(|p| p.get("medium"))
1499            .and_then(|m| m.as_u64())
1500            .unwrap_or(0);
1501        let low = summary
1502            .and_then(|s| s.get("by_priority"))
1503            .and_then(|p| p.get("low"))
1504            .and_then(|l| l.as_u64())
1505            .unwrap_or(0);
1506
1507        // Summary with priority breakdown
1508        let mut priority_parts = Vec::new();
1509        if critical > 0 {
1510            priority_parts.push(format!(
1511                "{}🔴 {} critical{}",
1512                ansi::CRITICAL,
1513                critical,
1514                ansi::RESET
1515            ));
1516        }
1517        if high > 0 {
1518            priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET));
1519        }
1520        if medium > 0 {
1521            priority_parts.push(format!(
1522                "{}🟡 {} medium{}",
1523                ansi::MEDIUM,
1524                medium,
1525                ansi::RESET
1526            ));
1527        }
1528        if low > 0 {
1529            priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET));
1530        }
1531
1532        let header_color = if critical > 0 {
1533            ansi::CRITICAL
1534        } else if high > 0 {
1535            ansi::HIGH
1536        } else {
1537            ansi::CYAN
1538        };
1539
1540        lines.push(format!(
1541            "{}⎈ {} issue{} found: {}{}",
1542            header_color,
1543            total,
1544            if total == 1 { "" } else { "s" },
1545            priority_parts.join(" "),
1546            ansi::RESET
1547        ));
1548
1549        // Show critical and high priority issues
1550        let mut shown = 0;
1551        const MAX_PREVIEW: usize = 6;
1552
1553        // Critical issues first
1554        if let Some(critical_issues) = action_plan
1555            .and_then(|a| a.get("critical"))
1556            .and_then(|c| c.as_array())
1557        {
1558            for issue in critical_issues.iter().take(MAX_PREVIEW - shown) {
1559                lines.push(format_helmlint_issue(issue, "🔴", ansi::CRITICAL));
1560                shown += 1;
1561            }
1562        }
1563
1564        // Then high priority
1565        if shown < MAX_PREVIEW {
1566            if let Some(high_issues) = action_plan
1567                .and_then(|a| a.get("high"))
1568                .and_then(|h| h.as_array())
1569            {
1570                for issue in high_issues.iter().take(MAX_PREVIEW - shown) {
1571                    lines.push(format_helmlint_issue(issue, "🟠", ansi::HIGH));
1572                    shown += 1;
1573                }
1574            }
1575        }
1576
1577        // Show quick fix hint
1578        if let Some(quick_fixes) = v.get("quick_fixes").and_then(|q| q.as_array()) {
1579            if let Some(first_fix) = quick_fixes.first().and_then(|f| f.as_str()) {
1580                let truncated = if first_fix.len() > 70 {
1581                    format!("{}...", &first_fix[..67])
1582                } else {
1583                    first_fix.to_string()
1584                };
1585                lines.push(format!(
1586                    "{}  → Fix: {}{}",
1587                    ansi::INFO_BLUE,
1588                    truncated,
1589                    ansi::RESET
1590                ));
1591            }
1592        }
1593
1594        // Note about remaining issues
1595        let remaining = total as usize - shown;
1596        if remaining > 0 {
1597            lines.push(format!(
1598                "{}  +{} more issue{}{}",
1599                ansi::GRAY,
1600                remaining,
1601                if remaining == 1 { "" } else { "s" },
1602                ansi::RESET
1603            ));
1604        }
1605
1606        (success && total == 0, lines)
1607    } else {
1608        (false, vec!["helmlint analysis complete".to_string()])
1609    }
1610}
1611
1612/// Format a single helmlint issue for display
1613fn format_helmlint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> String {
1614    let code = issue.get("code").and_then(|c| c.as_str()).unwrap_or("?");
1615    let message = issue.get("message").and_then(|m| m.as_str()).unwrap_or("?");
1616    let file = issue.get("file").and_then(|f| f.as_str()).unwrap_or("");
1617    let line_num = issue.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
1618    let category = issue.get("category").and_then(|c| c.as_str()).unwrap_or("");
1619
1620    // Category badge
1621    let badge = match category {
1622        "Security" | "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
1623        "Structure" | "structure" => format!("{}[STRUCT]{}", ansi::GRAY, ansi::RESET),
1624        "Template" | "template" => format!("{}[TPL]{}", ansi::MEDIUM, ansi::RESET),
1625        "Values" | "values" => format!("{}[VAL]{}", ansi::MEDIUM, ansi::RESET),
1626        _ => String::new(),
1627    };
1628
1629    // Short file name
1630    let file_short = if file.len() > 20 {
1631        format!("...{}", &file[file.len().saturating_sub(17)..])
1632    } else {
1633        file.to_string()
1634    };
1635
1636    // Truncate message
1637    let msg_display = if message.len() > 40 {
1638        format!("{}...", &message[..37])
1639    } else {
1640        message.to_string()
1641    };
1642
1643    format!(
1644        "{}{} {}:{}:{} {}{}[{}]{} {} {}",
1645        color,
1646        icon,
1647        file_short,
1648        line_num,
1649        ansi::RESET,
1650        ansi::CYAN,
1651        ansi::BOLD,
1652        code,
1653        ansi::RESET,
1654        badge,
1655        msg_display
1656    )
1657}
1658
1659/// Convert tool name to a friendly action description for progress indicator
1660fn tool_to_action(tool_name: &str) -> String {
1661    match tool_name {
1662        "read_file" => "Reading file".to_string(),
1663        "write_file" | "write_files" => "Writing file".to_string(),
1664        "list_directory" => "Listing directory".to_string(),
1665        "shell" => "Running command".to_string(),
1666        "analyze_project" => "Analyzing project".to_string(),
1667        "security_scan" | "check_vulnerabilities" => "Scanning security".to_string(),
1668        "hadolint" => "Linting Dockerfile".to_string(),
1669        "dclint" => "Linting docker-compose".to_string(),
1670        "kubelint" => "Linting Kubernetes".to_string(),
1671        "helmlint" => "Linting Helm chart".to_string(),
1672        "terraform_fmt" => "Formatting Terraform".to_string(),
1673        "terraform_validate" => "Validating Terraform".to_string(),
1674        "plan_create" => "Creating plan".to_string(),
1675        "plan_list" => "Listing plans".to_string(),
1676        "plan_next" | "plan_update" => "Updating plan".to_string(),
1677        _ => "Processing".to_string(),
1678    }
1679}
1680
1681/// Extract focus/detail from tool arguments for progress indicator
1682fn tool_to_focus(tool_name: &str, args: &str) -> Option<String> {
1683    let parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
1684    let parsed = parsed.ok()?;
1685
1686    match tool_name {
1687        "read_file" | "write_file" => {
1688            parsed.get("path").and_then(|p| p.as_str()).map(|p| {
1689                // Shorten long paths
1690                if p.len() > 50 {
1691                    format!("...{}", &p[p.len().saturating_sub(47)..])
1692                } else {
1693                    p.to_string()
1694                }
1695            })
1696        }
1697        "list_directory" => parsed
1698            .get("path")
1699            .and_then(|p| p.as_str())
1700            .map(|p| p.to_string()),
1701        "shell" => parsed.get("command").and_then(|c| c.as_str()).map(|cmd| {
1702            // Truncate long commands
1703            if cmd.len() > 60 {
1704                format!("{}...", &cmd[..57])
1705            } else {
1706                cmd.to_string()
1707            }
1708        }),
1709        "hadolint" | "dclint" | "kubelint" | "helmlint" => parsed
1710            .get("path")
1711            .and_then(|p| p.as_str())
1712            .map(|p| p.to_string())
1713            .or_else(|| {
1714                if parsed.get("content").is_some() {
1715                    Some("<inline content>".to_string())
1716                } else {
1717                    Some("<auto-detect>".to_string())
1718                }
1719            }),
1720        "plan_create" => parsed
1721            .get("name")
1722            .and_then(|n| n.as_str())
1723            .map(|n| n.to_string()),
1724        _ => None,
1725    }
1726}
1727
1728// Legacy exports for compatibility
1729pub use crate::agent::ui::Spinner;
1730use tokio::sync::mpsc;
1731
1732/// Events for backward compatibility
1733#[derive(Debug, Clone)]
1734pub enum ToolEvent {
1735    ToolStart { name: String, args: String },
1736    ToolComplete { name: String, result: String },
1737}
1738
1739/// Legacy spawn function - now a no-op since display is handled in hooks
1740pub fn spawn_tool_display_handler(
1741    _receiver: mpsc::Receiver<ToolEvent>,
1742    _spinner: Arc<crate::agent::ui::Spinner>,
1743) -> tokio::task::JoinHandle<()> {
1744    tokio::spawn(async {})
1745}