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        "retrieve_output" => format_retrieve_result(&parsed),
635        _ => (true, vec!["done".to_string()]),
636    };
637
638    // Clear the header line to update dot color
639    print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
640
641    // Reprint header with green/red dot and args
642    let dot = if status_ok {
643        "●".green()
644    } else {
645        "●".red()
646    };
647
648    // Format args for display (same logic as print_tool_header)
649    let args_parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
650    let args_display = format_args_display(name, &args_parsed);
651
652    if args_display.is_empty() {
653        println!("{} {}", dot, name.cyan().bold());
654    } else {
655        println!("{} {}({})", dot, name.cyan().bold(), args_display.dimmed());
656    }
657
658    // Print output preview
659    let total_lines = output_lines.len();
660    let is_collapsible = total_lines > PREVIEW_LINES;
661
662    for (i, line) in output_lines.iter().take(PREVIEW_LINES).enumerate() {
663        let prefix = if i == output_lines.len().min(PREVIEW_LINES) - 1 && !is_collapsible {
664            "└"
665        } else {
666            "│"
667        };
668        println!("  {} {}", prefix.dimmed(), line);
669    }
670
671    // Show collapse indicator if needed
672    if is_collapsible {
673        println!(
674            "  {} {}",
675            "└".dimmed(),
676            format!("+{} more lines", total_lines - PREVIEW_LINES).dimmed()
677        );
678    }
679
680    let _ = io::stdout().flush();
681    (status_ok, output_lines, is_collapsible)
682}
683
684/// Format args for display based on tool type
685fn format_args_display(
686    name: &str,
687    parsed: &Result<serde_json::Value, serde_json::Error>,
688) -> String {
689    match name {
690        "shell" => {
691            if let Ok(v) = parsed {
692                v.get("command")
693                    .and_then(|c| c.as_str())
694                    .unwrap_or("")
695                    .to_string()
696            } else {
697                String::new()
698            }
699        }
700        "write_file" => {
701            if let Ok(v) = parsed {
702                v.get("path")
703                    .and_then(|p| p.as_str())
704                    .unwrap_or("")
705                    .to_string()
706            } else {
707                String::new()
708            }
709        }
710        "write_files" => {
711            if let Ok(v) = parsed {
712                if let Some(files) = v.get("files").and_then(|f| f.as_array()) {
713                    let paths: Vec<&str> = files
714                        .iter()
715                        .filter_map(|f| f.get("path").and_then(|p| p.as_str()))
716                        .take(3)
717                        .collect();
718                    let more = if files.len() > 3 {
719                        format!(", +{} more", files.len() - 3)
720                    } else {
721                        String::new()
722                    };
723                    format!("{}{}", paths.join(", "), more)
724                } else {
725                    String::new()
726                }
727            } else {
728                String::new()
729            }
730        }
731        "read_file" => {
732            if let Ok(v) = parsed {
733                v.get("path")
734                    .and_then(|p| p.as_str())
735                    .unwrap_or("")
736                    .to_string()
737            } else {
738                String::new()
739            }
740        }
741        "list_directory" => {
742            if let Ok(v) = parsed {
743                v.get("path")
744                    .and_then(|p| p.as_str())
745                    .unwrap_or(".")
746                    .to_string()
747            } else {
748                ".".to_string()
749            }
750        }
751        "kubelint" | "helmlint" | "hadolint" | "dclint" => {
752            if let Ok(v) = parsed {
753                // Show path if provided
754                if let Some(path) = v.get("path").and_then(|p| p.as_str()) {
755                    return path.to_string();
756                }
757                // Show content indicator if provided
758                if v.get("content").and_then(|c| c.as_str()).is_some() {
759                    return "<inline>".to_string();
760                }
761                // No path - will use auto-discovery
762                "<auto>".to_string()
763            } else {
764                String::new()
765            }
766        }
767        "retrieve_output" => {
768            if let Ok(v) = parsed {
769                let ref_id = v
770                    .get("ref_id")
771                    .and_then(|r| r.as_str())
772                    .unwrap_or("?");
773                let query = v.get("query").and_then(|q| q.as_str());
774
775                if let Some(q) = query {
776                    format!("{}, \"{}\"", ref_id, q)
777                } else {
778                    ref_id.to_string()
779                }
780            } else {
781                String::new()
782            }
783        }
784        _ => String::new(),
785    }
786}
787
788/// Format shell command result
789fn format_shell_result(
790    parsed: &Result<serde_json::Value, serde_json::Error>,
791) -> (bool, Vec<String>) {
792    if let Ok(v) = parsed {
793        let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
794        let stdout = v.get("stdout").and_then(|s| s.as_str()).unwrap_or("");
795        let stderr = v.get("stderr").and_then(|s| s.as_str()).unwrap_or("");
796        let exit_code = v.get("exit_code").and_then(|c| c.as_i64());
797
798        let mut lines = Vec::new();
799
800        // Add stdout lines
801        for line in stdout.lines() {
802            if !line.trim().is_empty() {
803                lines.push(line.to_string());
804            }
805        }
806
807        // Add stderr lines if failed
808        if !success {
809            for line in stderr.lines() {
810                if !line.trim().is_empty() {
811                    lines.push(format!("{}", line.red()));
812                }
813            }
814            if let Some(code) = exit_code {
815                lines.push(format!("exit code: {}", code).red().to_string());
816            }
817        }
818
819        if lines.is_empty() {
820            lines.push(if success {
821                "completed".to_string()
822            } else {
823                "failed".to_string()
824            });
825        }
826
827        (success, lines)
828    } else {
829        (false, vec!["parse error".to_string()])
830    }
831}
832
833/// Format write file result
834fn format_write_result(
835    parsed: &Result<serde_json::Value, serde_json::Error>,
836) -> (bool, Vec<String>) {
837    if let Ok(v) = parsed {
838        let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
839        let action = v.get("action").and_then(|a| a.as_str()).unwrap_or("wrote");
840        let lines_written = v
841            .get("lines_written")
842            .or_else(|| v.get("total_lines"))
843            .and_then(|n| n.as_u64())
844            .unwrap_or(0);
845        let files_written = v.get("files_written").and_then(|n| n.as_u64()).unwrap_or(1);
846
847        let msg = if files_written > 1 {
848            format!(
849                "{} {} files ({} lines)",
850                action, files_written, lines_written
851            )
852        } else {
853            format!("{} ({} lines)", action, lines_written)
854        };
855
856        (success, vec![msg])
857    } else {
858        (false, vec!["write failed".to_string()])
859    }
860}
861
862/// Format read file result
863fn format_read_result(
864    parsed: &Result<serde_json::Value, serde_json::Error>,
865) -> (bool, Vec<String>) {
866    if let Ok(v) = parsed {
867        // Handle error field
868        if v.get("error").is_some() {
869            let error_msg = v
870                .get("error")
871                .and_then(|e| e.as_str())
872                .unwrap_or("file not found");
873            return (false, vec![error_msg.to_string()]);
874        }
875
876        // Try to get total_lines from object
877        if let Some(total_lines) = v.get("total_lines").and_then(|n| n.as_u64()) {
878            let msg = if total_lines == 1 {
879                "read 1 line".to_string()
880            } else {
881                format!("read {} lines", total_lines)
882            };
883            return (true, vec![msg]);
884        }
885
886        // Fallback: if we have a string value (failed inner parse) or missing fields,
887        // try to extract line count from content or just say "read"
888        if let Some(content) = v.get("content").and_then(|c| c.as_str()) {
889            let lines = content.lines().count();
890            return (true, vec![format!("read {} lines", lines)]);
891        }
892
893        // Last resort: check if it's a string (double-encoding fallback)
894        if v.is_string() {
895            // The inner JSON couldn't be parsed, but we got something
896            return (true, vec!["read file".to_string()]);
897        }
898
899        (true, vec!["read file".to_string()])
900    } else {
901        (false, vec!["read failed".to_string()])
902    }
903}
904
905/// Format list directory result
906fn format_list_result(
907    parsed: &Result<serde_json::Value, serde_json::Error>,
908) -> (bool, Vec<String>) {
909    if let Ok(v) = parsed {
910        let entries = v.get("entries").and_then(|e| e.as_array());
911
912        let mut lines = Vec::new();
913
914        if let Some(entries) = entries {
915            let total = entries.len();
916            for entry in entries.iter().take(PREVIEW_LINES + 2) {
917                let name = entry.get("name").and_then(|n| n.as_str()).unwrap_or("?");
918                let entry_type = entry.get("type").and_then(|t| t.as_str()).unwrap_or("file");
919                let prefix = if entry_type == "directory" {
920                    "📁"
921                } else {
922                    "📄"
923                };
924                lines.push(format!("{} {}", prefix, name));
925            }
926            // Add count if there are more entries than shown
927            if total > PREVIEW_LINES + 2 {
928                lines.push(format!("... and {} more", total - (PREVIEW_LINES + 2)));
929            }
930        }
931
932        if lines.is_empty() {
933            lines.push("empty directory".to_string());
934        }
935
936        (true, lines)
937    } else {
938        (false, vec!["parse error".to_string()])
939    }
940}
941
942/// Format analyze result - handles both raw and compressed outputs
943fn format_analyze_result(
944    parsed: &Result<serde_json::Value, serde_json::Error>,
945) -> (bool, Vec<String>) {
946    if let Ok(v) = parsed {
947        let mut lines = Vec::new();
948
949        // Check if this is compressed output (has full_data_ref)
950        let is_compressed = v.get("full_data_ref").is_some();
951
952        if is_compressed {
953            // Compressed output format
954            let ref_id = v.get("full_data_ref").and_then(|r| r.as_str()).unwrap_or("?");
955
956            // Project count (monorepo)
957            if let Some(count) = v.get("project_count").and_then(|c| c.as_u64()) {
958                lines.push(format!(
959                    "{}📁 {} projects detected{}",
960                    ansi::SUCCESS, count, ansi::RESET
961                ));
962            }
963
964            // Languages (compressed uses languages_detected as array of strings)
965            if let Some(langs) = v.get("languages_detected").and_then(|l| l.as_array()) {
966                let names: Vec<&str> = langs.iter().filter_map(|l| l.as_str()).take(5).collect();
967                if !names.is_empty() {
968                    lines.push(format!("  │ Languages: {}", names.join(", ")));
969                }
970            }
971
972            // Frameworks/Technologies (compressed uses frameworks_detected)
973            if let Some(fws) = v.get("frameworks_detected").and_then(|f| f.as_array()) {
974                let names: Vec<&str> = fws.iter().filter_map(|f| f.as_str()).take(5).collect();
975                if !names.is_empty() {
976                    lines.push(format!("  │ Frameworks: {}", names.join(", ")));
977                }
978            }
979
980            // Technologies (ProjectAnalysis format)
981            if let Some(techs) = v.get("technologies_detected").and_then(|t| t.as_array()) {
982                let names: Vec<&str> = techs.iter().filter_map(|t| t.as_str()).take(5).collect();
983                if !names.is_empty() {
984                    lines.push(format!("  │ Technologies: {}", names.join(", ")));
985                }
986            }
987
988            // Services
989            if let Some(services) = v.get("services_detected").and_then(|s| s.as_array()) {
990                let names: Vec<&str> = services.iter().filter_map(|s| s.as_str()).take(4).collect();
991                if !names.is_empty() {
992                    lines.push(format!("  │ Services: {}", names.join(", ")));
993                }
994            } else if let Some(count) = v.get("services_count").and_then(|c| c.as_u64()) {
995                if count > 0 {
996                    lines.push(format!("  │ Services: {} detected", count));
997                }
998            }
999
1000            // Retrieval hint
1001            lines.push(format!(
1002                "{}  └ Full data: retrieve_output('{}'){}",
1003                ansi::GRAY, ref_id, ansi::RESET
1004            ));
1005
1006            return (true, lines);
1007        }
1008
1009        // Raw (non-compressed) output format
1010        // Languages (raw format has objects with name field)
1011        if let Some(langs) = v.get("languages").and_then(|l| l.as_array()) {
1012            let lang_names: Vec<&str> = langs
1013                .iter()
1014                .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
1015                .take(5)
1016                .collect();
1017            if !lang_names.is_empty() {
1018                lines.push(format!("Languages: {}", lang_names.join(", ")));
1019            }
1020        }
1021
1022        // Frameworks (raw format has objects with name field)
1023        if let Some(frameworks) = v.get("frameworks").and_then(|f| f.as_array()) {
1024            let fw_names: Vec<&str> = frameworks
1025                .iter()
1026                .filter_map(|f| f.get("name").and_then(|n| n.as_str()))
1027                .take(5)
1028                .collect();
1029            if !fw_names.is_empty() {
1030                lines.push(format!("Frameworks: {}", fw_names.join(", ")));
1031            }
1032        }
1033
1034        if lines.is_empty() {
1035            lines.push("analysis complete".to_string());
1036        }
1037
1038        (true, lines)
1039    } else {
1040        (false, vec!["parse error".to_string()])
1041    }
1042}
1043
1044/// Format security scan result
1045fn format_security_result(
1046    parsed: &Result<serde_json::Value, serde_json::Error>,
1047) -> (bool, Vec<String>) {
1048    if let Ok(v) = parsed {
1049        let findings = v
1050            .get("findings")
1051            .or_else(|| v.get("vulnerabilities"))
1052            .and_then(|f| f.as_array())
1053            .map(|a| a.len())
1054            .unwrap_or(0);
1055
1056        if findings == 0 {
1057            (true, vec!["no issues found".to_string()])
1058        } else {
1059            (false, vec![format!("{} issues found", findings)])
1060        }
1061    } else {
1062        (false, vec!["parse error".to_string()])
1063    }
1064}
1065
1066/// Format hadolint result - uses new priority-based format with Docker styling
1067fn format_hadolint_result(
1068    parsed: &Result<serde_json::Value, serde_json::Error>,
1069) -> (bool, Vec<String>) {
1070    if let Ok(v) = parsed {
1071        let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true);
1072        let summary = v.get("summary");
1073        let action_plan = v.get("action_plan");
1074
1075        let mut lines = Vec::new();
1076
1077        // Get total count
1078        let total = summary
1079            .and_then(|s| s.get("total"))
1080            .and_then(|t| t.as_u64())
1081            .unwrap_or(0);
1082
1083        // Show docker-themed header
1084        if total == 0 {
1085            lines.push(format!(
1086                "{}🐳 Dockerfile OK - no issues found{}",
1087                ansi::SUCCESS,
1088                ansi::RESET
1089            ));
1090            return (true, lines);
1091        }
1092
1093        // Get priority counts
1094        let critical = summary
1095            .and_then(|s| s.get("by_priority"))
1096            .and_then(|p| p.get("critical"))
1097            .and_then(|c| c.as_u64())
1098            .unwrap_or(0);
1099        let high = summary
1100            .and_then(|s| s.get("by_priority"))
1101            .and_then(|p| p.get("high"))
1102            .and_then(|h| h.as_u64())
1103            .unwrap_or(0);
1104        let medium = summary
1105            .and_then(|s| s.get("by_priority"))
1106            .and_then(|p| p.get("medium"))
1107            .and_then(|m| m.as_u64())
1108            .unwrap_or(0);
1109        let low = summary
1110            .and_then(|s| s.get("by_priority"))
1111            .and_then(|p| p.get("low"))
1112            .and_then(|l| l.as_u64())
1113            .unwrap_or(0);
1114
1115        // Summary with priority breakdown
1116        let mut priority_parts = Vec::new();
1117        if critical > 0 {
1118            priority_parts.push(format!(
1119                "{}🔴 {} critical{}",
1120                ansi::CRITICAL,
1121                critical,
1122                ansi::RESET
1123            ));
1124        }
1125        if high > 0 {
1126            priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET));
1127        }
1128        if medium > 0 {
1129            priority_parts.push(format!(
1130                "{}🟡 {} medium{}",
1131                ansi::MEDIUM,
1132                medium,
1133                ansi::RESET
1134            ));
1135        }
1136        if low > 0 {
1137            priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET));
1138        }
1139
1140        let header_color = if critical > 0 {
1141            ansi::CRITICAL
1142        } else if high > 0 {
1143            ansi::HIGH
1144        } else {
1145            ansi::DOCKER_BLUE
1146        };
1147
1148        lines.push(format!(
1149            "{}🐳 {} issue{} found: {}{}",
1150            header_color,
1151            total,
1152            if total == 1 { "" } else { "s" },
1153            priority_parts.join(" "),
1154            ansi::RESET
1155        ));
1156
1157        // Show critical and high priority issues (these are most important)
1158        let mut shown = 0;
1159        const MAX_PREVIEW: usize = 6;
1160
1161        // Critical issues first
1162        if let Some(critical_issues) = action_plan
1163            .and_then(|a| a.get("critical"))
1164            .and_then(|c| c.as_array())
1165        {
1166            for issue in critical_issues.iter().take(MAX_PREVIEW - shown) {
1167                lines.push(format_hadolint_issue(issue, "🔴", ansi::CRITICAL));
1168                shown += 1;
1169            }
1170        }
1171
1172        // Then high priority
1173        if shown < MAX_PREVIEW
1174            && let Some(high_issues) = action_plan
1175                .and_then(|a| a.get("high"))
1176                .and_then(|h| h.as_array())
1177        {
1178            for issue in high_issues.iter().take(MAX_PREVIEW - shown) {
1179                lines.push(format_hadolint_issue(issue, "🟠", ansi::HIGH));
1180                shown += 1;
1181            }
1182        }
1183
1184        // Show quick fix hint for most important issue
1185        if let Some(quick_fixes) = v.get("quick_fixes").and_then(|q| q.as_array())
1186            && let Some(first_fix) = quick_fixes.first().and_then(|f| f.as_str())
1187        {
1188            let truncated = if first_fix.len() > 70 {
1189                format!("{}...", &first_fix[..67])
1190            } else {
1191                first_fix.to_string()
1192            };
1193            lines.push(format!(
1194                "{}  → Fix: {}{}",
1195                ansi::INFO_BLUE,
1196                truncated,
1197                ansi::RESET
1198            ));
1199        }
1200
1201        // Note about remaining issues
1202        let remaining = total as usize - shown;
1203        if remaining > 0 {
1204            lines.push(format!(
1205                "{}  +{} more issue{}{}",
1206                ansi::GRAY,
1207                remaining,
1208                if remaining == 1 { "" } else { "s" },
1209                ansi::RESET
1210            ));
1211        }
1212
1213        (success, lines)
1214    } else {
1215        (false, vec!["parse error".to_string()])
1216    }
1217}
1218
1219/// Format a single hadolint issue for display
1220fn format_hadolint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> String {
1221    let code = issue.get("code").and_then(|c| c.as_str()).unwrap_or("?");
1222    let message = issue.get("message").and_then(|m| m.as_str()).unwrap_or("?");
1223    let line_num = issue.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
1224    let category = issue.get("category").and_then(|c| c.as_str()).unwrap_or("");
1225
1226    // Category badge
1227    let badge = match category {
1228        "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
1229        "best-practice" => format!("{}[BP]{}", ansi::INFO_BLUE, ansi::RESET),
1230        "deprecated" => format!("{}[DEP]{}", ansi::MEDIUM, ansi::RESET),
1231        "performance" => format!("{}[PERF]{}", ansi::CYAN, ansi::RESET),
1232        _ => String::new(),
1233    };
1234
1235    // Truncate message
1236    let msg_display = if message.len() > 50 {
1237        format!("{}...", &message[..47])
1238    } else {
1239        message.to_string()
1240    };
1241
1242    format!(
1243        "{}{} L{}:{} {}{}[{}]{} {} {}",
1244        color,
1245        icon,
1246        line_num,
1247        ansi::RESET,
1248        ansi::DOCKER_BLUE,
1249        ansi::BOLD,
1250        code,
1251        ansi::RESET,
1252        badge,
1253        msg_display
1254    )
1255}
1256
1257/// Format kubelint result - inline preview format like hadolint
1258fn format_kubelint_result(
1259    parsed: &Result<serde_json::Value, serde_json::Error>,
1260) -> (bool, Vec<String>) {
1261    if let Ok(v) = parsed {
1262        let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true);
1263        let summary = v.get("summary");
1264        let action_plan = v.get("action_plan");
1265        let parse_errors = v.get("parse_errors").and_then(|p| p.as_array());
1266
1267        let total = summary
1268            .and_then(|s| s.get("total_issues"))
1269            .and_then(|t| t.as_u64())
1270            .unwrap_or(0);
1271
1272        let mut lines = Vec::new();
1273
1274        // Check for parse errors first
1275        if let Some(errors) = parse_errors
1276            && !errors.is_empty()
1277        {
1278            lines.push(format!(
1279                "{}☸ {} parse error{} (files could not be fully analyzed){}",
1280                ansi::HIGH,
1281                errors.len(),
1282                if errors.len() == 1 { "" } else { "s" },
1283                ansi::RESET
1284            ));
1285            for (i, err) in errors.iter().take(3).enumerate() {
1286                if let Some(err_str) = err.as_str() {
1287                    let truncated = if err_str.len() > 70 {
1288                        format!("{}...", &err_str[..67])
1289                    } else {
1290                        err_str.to_string()
1291                    };
1292                    lines.push(format!(
1293                        "{}  {} {}{}",
1294                        ansi::HIGH,
1295                        if i == errors.len().min(3) - 1 {
1296                            "└"
1297                        } else {
1298                            "│"
1299                        },
1300                        truncated,
1301                        ansi::RESET
1302                    ));
1303                }
1304            }
1305            if errors.len() > 3 {
1306                lines.push(format!(
1307                    "{}  +{} more errors{}",
1308                    ansi::GRAY,
1309                    errors.len() - 3,
1310                    ansi::RESET
1311                ));
1312            }
1313            // If we only have parse errors and no lint issues, return early
1314            if total == 0 {
1315                return (false, lines);
1316            }
1317        }
1318
1319        if total == 0 && parse_errors.map(|e| e.is_empty()).unwrap_or(true) {
1320            lines.push(format!(
1321                "{}☸ K8s manifests OK - no issues found{}",
1322                ansi::SUCCESS,
1323                ansi::RESET
1324            ));
1325            return (true, lines);
1326        }
1327
1328        // Get priority counts
1329        let critical = summary
1330            .and_then(|s| s.get("by_priority"))
1331            .and_then(|p| p.get("critical"))
1332            .and_then(|c| c.as_u64())
1333            .unwrap_or(0);
1334        let high = summary
1335            .and_then(|s| s.get("by_priority"))
1336            .and_then(|p| p.get("high"))
1337            .and_then(|h| h.as_u64())
1338            .unwrap_or(0);
1339        let medium = summary
1340            .and_then(|s| s.get("by_priority"))
1341            .and_then(|p| p.get("medium"))
1342            .and_then(|m| m.as_u64())
1343            .unwrap_or(0);
1344        let low = summary
1345            .and_then(|s| s.get("by_priority"))
1346            .and_then(|p| p.get("low"))
1347            .and_then(|l| l.as_u64())
1348            .unwrap_or(0);
1349
1350        // Summary with priority breakdown
1351        let mut priority_parts = Vec::new();
1352        if critical > 0 {
1353            priority_parts.push(format!(
1354                "{}🔴 {} critical{}",
1355                ansi::CRITICAL,
1356                critical,
1357                ansi::RESET
1358            ));
1359        }
1360        if high > 0 {
1361            priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET));
1362        }
1363        if medium > 0 {
1364            priority_parts.push(format!(
1365                "{}🟡 {} medium{}",
1366                ansi::MEDIUM,
1367                medium,
1368                ansi::RESET
1369            ));
1370        }
1371        if low > 0 {
1372            priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET));
1373        }
1374
1375        let header_color = if critical > 0 {
1376            ansi::CRITICAL
1377        } else if high > 0 {
1378            ansi::HIGH
1379        } else {
1380            ansi::CYAN
1381        };
1382
1383        lines.push(format!(
1384            "{}☸ {} issue{} found: {}{}",
1385            header_color,
1386            total,
1387            if total == 1 { "" } else { "s" },
1388            priority_parts.join(" "),
1389            ansi::RESET
1390        ));
1391
1392        // Show critical and high priority issues
1393        let mut shown = 0;
1394        const MAX_PREVIEW: usize = 6;
1395
1396        // Critical issues first
1397        if let Some(critical_issues) = action_plan
1398            .and_then(|a| a.get("critical"))
1399            .and_then(|c| c.as_array())
1400        {
1401            for issue in critical_issues.iter().take(MAX_PREVIEW - shown) {
1402                lines.push(format_kubelint_issue(issue, "🔴", ansi::CRITICAL));
1403                shown += 1;
1404            }
1405        }
1406
1407        // Then high priority
1408        if shown < MAX_PREVIEW
1409            && let Some(high_issues) = action_plan
1410                .and_then(|a| a.get("high"))
1411                .and_then(|h| h.as_array())
1412        {
1413            for issue in high_issues.iter().take(MAX_PREVIEW - shown) {
1414                lines.push(format_kubelint_issue(issue, "🟠", ansi::HIGH));
1415                shown += 1;
1416            }
1417        }
1418
1419        // Show quick fix hint
1420        if let Some(quick_fixes) = v.get("quick_fixes").and_then(|q| q.as_array())
1421            && let Some(first_fix) = quick_fixes.first().and_then(|f| f.as_str())
1422        {
1423            let truncated = if first_fix.len() > 70 {
1424                format!("{}...", &first_fix[..67])
1425            } else {
1426                first_fix.to_string()
1427            };
1428            lines.push(format!(
1429                "{}  → Fix: {}{}",
1430                ansi::INFO_BLUE,
1431                truncated,
1432                ansi::RESET
1433            ));
1434        }
1435
1436        // Note about remaining issues
1437        let remaining = total as usize - shown;
1438        if remaining > 0 {
1439            lines.push(format!(
1440                "{}  +{} more issue{}{}",
1441                ansi::GRAY,
1442                remaining,
1443                if remaining == 1 { "" } else { "s" },
1444                ansi::RESET
1445            ));
1446        }
1447
1448        (success && total == 0, lines)
1449    } else {
1450        (false, vec!["kubelint analysis complete".to_string()])
1451    }
1452}
1453
1454/// Format a single kubelint issue for display
1455fn format_kubelint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> String {
1456    let check = issue.get("check").and_then(|c| c.as_str()).unwrap_or("?");
1457    let message = issue.get("message").and_then(|m| m.as_str()).unwrap_or("?");
1458    let line_num = issue.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
1459    let category = issue.get("category").and_then(|c| c.as_str()).unwrap_or("");
1460
1461    // Category badge
1462    let badge = match category {
1463        "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
1464        "rbac" => format!("{}[RBAC]{}", ansi::CRITICAL, ansi::RESET),
1465        "best-practice" => format!("{}[BP]{}", ansi::INFO_BLUE, ansi::RESET),
1466        "validation" => format!("{}[VAL]{}", ansi::MEDIUM, ansi::RESET),
1467        _ => String::new(),
1468    };
1469
1470    // Truncate message
1471    let msg_display = if message.len() > 50 {
1472        format!("{}...", &message[..47])
1473    } else {
1474        message.to_string()
1475    };
1476
1477    format!(
1478        "{}{} L{}:{} {}{}[{}]{} {} {}",
1479        color,
1480        icon,
1481        line_num,
1482        ansi::RESET,
1483        ansi::CYAN,
1484        ansi::BOLD,
1485        check,
1486        ansi::RESET,
1487        badge,
1488        msg_display
1489    )
1490}
1491
1492/// Format helmlint result - inline preview format like hadolint
1493fn format_helmlint_result(
1494    parsed: &Result<serde_json::Value, serde_json::Error>,
1495) -> (bool, Vec<String>) {
1496    if let Ok(v) = parsed {
1497        let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true);
1498        let summary = v.get("summary");
1499        let action_plan = v.get("action_plan");
1500        let parse_errors = v.get("parse_errors").and_then(|p| p.as_array());
1501
1502        let total = summary
1503            .and_then(|s| s.get("total"))
1504            .and_then(|t| t.as_u64())
1505            .unwrap_or(0);
1506
1507        let mut lines = Vec::new();
1508
1509        // Check for parse errors first
1510        if let Some(errors) = parse_errors
1511            && !errors.is_empty()
1512        {
1513            lines.push(format!(
1514                "{}⎈ {} parse error{} (chart could not be fully analyzed){}",
1515                ansi::HIGH,
1516                errors.len(),
1517                if errors.len() == 1 { "" } else { "s" },
1518                ansi::RESET
1519            ));
1520            for (i, err) in errors.iter().take(3).enumerate() {
1521                if let Some(err_str) = err.as_str() {
1522                    let truncated = if err_str.len() > 70 {
1523                        format!("{}...", &err_str[..67])
1524                    } else {
1525                        err_str.to_string()
1526                    };
1527                    lines.push(format!(
1528                        "{}  {} {}{}",
1529                        ansi::HIGH,
1530                        if i == errors.len().min(3) - 1 {
1531                            "└"
1532                        } else {
1533                            "│"
1534                        },
1535                        truncated,
1536                        ansi::RESET
1537                    ));
1538                }
1539            }
1540            if errors.len() > 3 {
1541                lines.push(format!(
1542                    "{}  +{} more errors{}",
1543                    ansi::GRAY,
1544                    errors.len() - 3,
1545                    ansi::RESET
1546                ));
1547            }
1548            // If we only have parse errors and no lint issues, return early
1549            if total == 0 {
1550                return (false, lines);
1551            }
1552        }
1553
1554        if total == 0 && parse_errors.map(|e| e.is_empty()).unwrap_or(true) {
1555            lines.push(format!(
1556                "{}⎈ Helm chart OK - no issues found{}",
1557                ansi::SUCCESS,
1558                ansi::RESET
1559            ));
1560            return (true, lines);
1561        }
1562
1563        // Get priority counts
1564        let critical = summary
1565            .and_then(|s| s.get("by_priority"))
1566            .and_then(|p| p.get("critical"))
1567            .and_then(|c| c.as_u64())
1568            .unwrap_or(0);
1569        let high = summary
1570            .and_then(|s| s.get("by_priority"))
1571            .and_then(|p| p.get("high"))
1572            .and_then(|h| h.as_u64())
1573            .unwrap_or(0);
1574        let medium = summary
1575            .and_then(|s| s.get("by_priority"))
1576            .and_then(|p| p.get("medium"))
1577            .and_then(|m| m.as_u64())
1578            .unwrap_or(0);
1579        let low = summary
1580            .and_then(|s| s.get("by_priority"))
1581            .and_then(|p| p.get("low"))
1582            .and_then(|l| l.as_u64())
1583            .unwrap_or(0);
1584
1585        // Summary with priority breakdown
1586        let mut priority_parts = Vec::new();
1587        if critical > 0 {
1588            priority_parts.push(format!(
1589                "{}🔴 {} critical{}",
1590                ansi::CRITICAL,
1591                critical,
1592                ansi::RESET
1593            ));
1594        }
1595        if high > 0 {
1596            priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET));
1597        }
1598        if medium > 0 {
1599            priority_parts.push(format!(
1600                "{}🟡 {} medium{}",
1601                ansi::MEDIUM,
1602                medium,
1603                ansi::RESET
1604            ));
1605        }
1606        if low > 0 {
1607            priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET));
1608        }
1609
1610        let header_color = if critical > 0 {
1611            ansi::CRITICAL
1612        } else if high > 0 {
1613            ansi::HIGH
1614        } else {
1615            ansi::CYAN
1616        };
1617
1618        lines.push(format!(
1619            "{}⎈ {} issue{} found: {}{}",
1620            header_color,
1621            total,
1622            if total == 1 { "" } else { "s" },
1623            priority_parts.join(" "),
1624            ansi::RESET
1625        ));
1626
1627        // Show critical and high priority issues
1628        let mut shown = 0;
1629        const MAX_PREVIEW: usize = 6;
1630
1631        // Critical issues first
1632        if let Some(critical_issues) = action_plan
1633            .and_then(|a| a.get("critical"))
1634            .and_then(|c| c.as_array())
1635        {
1636            for issue in critical_issues.iter().take(MAX_PREVIEW - shown) {
1637                lines.push(format_helmlint_issue(issue, "🔴", ansi::CRITICAL));
1638                shown += 1;
1639            }
1640        }
1641
1642        // Then high priority
1643        if shown < MAX_PREVIEW
1644            && let Some(high_issues) = action_plan
1645                .and_then(|a| a.get("high"))
1646                .and_then(|h| h.as_array())
1647        {
1648            for issue in high_issues.iter().take(MAX_PREVIEW - shown) {
1649                lines.push(format_helmlint_issue(issue, "🟠", ansi::HIGH));
1650                shown += 1;
1651            }
1652        }
1653
1654        // Show quick fix hint
1655        if let Some(quick_fixes) = v.get("quick_fixes").and_then(|q| q.as_array())
1656            && let Some(first_fix) = quick_fixes.first().and_then(|f| f.as_str())
1657        {
1658            let truncated = if first_fix.len() > 70 {
1659                format!("{}...", &first_fix[..67])
1660            } else {
1661                first_fix.to_string()
1662            };
1663            lines.push(format!(
1664                "{}  → Fix: {}{}",
1665                ansi::INFO_BLUE,
1666                truncated,
1667                ansi::RESET
1668            ));
1669        }
1670
1671        // Note about remaining issues
1672        let remaining = total as usize - shown;
1673        if remaining > 0 {
1674            lines.push(format!(
1675                "{}  +{} more issue{}{}",
1676                ansi::GRAY,
1677                remaining,
1678                if remaining == 1 { "" } else { "s" },
1679                ansi::RESET
1680            ));
1681        }
1682
1683        (success && total == 0, lines)
1684    } else {
1685        (false, vec!["helmlint analysis complete".to_string()])
1686    }
1687}
1688
1689/// Format a single helmlint issue for display
1690fn format_helmlint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> String {
1691    let code = issue.get("code").and_then(|c| c.as_str()).unwrap_or("?");
1692    let message = issue.get("message").and_then(|m| m.as_str()).unwrap_or("?");
1693    let file = issue.get("file").and_then(|f| f.as_str()).unwrap_or("");
1694    let line_num = issue.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
1695    let category = issue.get("category").and_then(|c| c.as_str()).unwrap_or("");
1696
1697    // Category badge
1698    let badge = match category {
1699        "Security" | "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
1700        "Structure" | "structure" => format!("{}[STRUCT]{}", ansi::GRAY, ansi::RESET),
1701        "Template" | "template" => format!("{}[TPL]{}", ansi::MEDIUM, ansi::RESET),
1702        "Values" | "values" => format!("{}[VAL]{}", ansi::MEDIUM, ansi::RESET),
1703        _ => String::new(),
1704    };
1705
1706    // Short file name
1707    let file_short = if file.len() > 20 {
1708        format!("...{}", &file[file.len().saturating_sub(17)..])
1709    } else {
1710        file.to_string()
1711    };
1712
1713    // Truncate message
1714    let msg_display = if message.len() > 40 {
1715        format!("{}...", &message[..37])
1716    } else {
1717        message.to_string()
1718    };
1719
1720    format!(
1721        "{}{} {}:{}:{} {}{}[{}]{} {} {}",
1722        color,
1723        icon,
1724        file_short,
1725        line_num,
1726        ansi::RESET,
1727        ansi::CYAN,
1728        ansi::BOLD,
1729        code,
1730        ansi::RESET,
1731        badge,
1732        msg_display
1733    )
1734}
1735
1736/// Format retrieve_output result - shows what data was retrieved
1737fn format_retrieve_result(
1738    parsed: &Result<serde_json::Value, serde_json::Error>,
1739) -> (bool, Vec<String>) {
1740    if let Ok(v) = parsed {
1741        let mut lines = Vec::new();
1742
1743        // Check for error field first
1744        if let Some(error) = v.get("error").and_then(|e| e.as_str()) {
1745            lines.push(format!("{}❌ {}{}", ansi::CRITICAL, error, ansi::RESET));
1746            return (false, lines);
1747        }
1748
1749        // Check if this is a query result with total_matches
1750        if let Some(total) = v.get("total_matches").and_then(|t| t.as_u64()) {
1751            let query = v
1752                .get("query")
1753                .and_then(|q| q.as_str())
1754                .unwrap_or("unfiltered");
1755
1756            lines.push(format!(
1757                "{}📦 Retrieved {} match{} for '{}'{}",
1758                ansi::SUCCESS,
1759                total,
1760                if total == 1 { "" } else { "es" },
1761                query,
1762                ansi::RESET
1763            ));
1764
1765            // Show preview of results
1766            if let Some(results) = v.get("results").and_then(|r| r.as_array()) {
1767                for (i, result) in results.iter().take(3).enumerate() {
1768                    let preview = format_result_preview(result);
1769                    let prefix = if i == results.len().min(3) - 1 && results.len() <= 3 {
1770                        "└"
1771                    } else {
1772                        "│"
1773                    };
1774                    lines.push(format!("  {} {}", prefix, preview));
1775                }
1776                if results.len() > 3 {
1777                    lines.push(format!(
1778                        "{}  └ +{} more results{}",
1779                        ansi::GRAY,
1780                        results.len() - 3,
1781                        ansi::RESET
1782                    ));
1783                }
1784            }
1785
1786            return (true, lines);
1787        }
1788
1789        // Check for analyze_project section results
1790        if v.get("project_count").is_some() || v.get("total_projects").is_some() {
1791            let count = v
1792                .get("project_count")
1793                .or_else(|| v.get("total_projects"))
1794                .and_then(|c| c.as_u64())
1795                .unwrap_or(0);
1796
1797            lines.push(format!(
1798                "{}📦 Retrieved project summary ({} projects){}",
1799                ansi::SUCCESS,
1800                count,
1801                ansi::RESET
1802            ));
1803
1804            // Show project names if available
1805            if let Some(names) = v.get("project_names").and_then(|n| n.as_array()) {
1806                let name_list: Vec<&str> = names
1807                    .iter()
1808                    .filter_map(|n| n.as_str())
1809                    .take(5)
1810                    .collect();
1811                if !name_list.is_empty() {
1812                    lines.push(format!("  │ Projects: {}", name_list.join(", ")));
1813                }
1814                if names.len() > 5 {
1815                    lines.push(format!("{}  └ +{} more{}", ansi::GRAY, names.len() - 5, ansi::RESET));
1816                }
1817            }
1818
1819            return (true, lines);
1820        }
1821
1822        // Check for services list
1823        if let Some(total) = v.get("total_services").and_then(|t| t.as_u64()) {
1824            lines.push(format!(
1825                "{}📦 Retrieved {} service{}{}",
1826                ansi::SUCCESS,
1827                total,
1828                if total == 1 { "" } else { "s" },
1829                ansi::RESET
1830            ));
1831
1832            if let Some(services) = v.get("services").and_then(|s| s.as_array()) {
1833                for (i, svc) in services.iter().take(4).enumerate() {
1834                    let name = svc.get("name").and_then(|n| n.as_str()).unwrap_or("?");
1835                    let svc_type = svc.get("service_type").and_then(|t| t.as_str()).unwrap_or("");
1836                    let prefix = if i == services.len().min(4) - 1 && services.len() <= 4 {
1837                        "└"
1838                    } else {
1839                        "│"
1840                    };
1841                    lines.push(format!("  {} 🔧 {} {}", prefix, name, svc_type));
1842                }
1843                if services.len() > 4 {
1844                    lines.push(format!("{}  └ +{} more{}", ansi::GRAY, services.len() - 4, ansi::RESET));
1845                }
1846            }
1847
1848            return (true, lines);
1849        }
1850
1851        // Check for languages/frameworks result
1852        if v.get("languages").is_some() || v.get("technologies").is_some() {
1853            lines.push(format!(
1854                "{}📦 Retrieved analysis data{}",
1855                ansi::SUCCESS,
1856                ansi::RESET
1857            ));
1858
1859            if let Some(langs) = v.get("languages").and_then(|l| l.as_array()) {
1860                let names: Vec<&str> = langs
1861                    .iter()
1862                    .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
1863                    .take(5)
1864                    .collect();
1865                if !names.is_empty() {
1866                    lines.push(format!("  │ Languages: {}", names.join(", ")));
1867                }
1868            }
1869
1870            if let Some(techs) = v.get("technologies").and_then(|t| t.as_array()) {
1871                let names: Vec<&str> = techs
1872                    .iter()
1873                    .filter_map(|t| t.get("name").and_then(|n| n.as_str()))
1874                    .take(5)
1875                    .collect();
1876                if !names.is_empty() {
1877                    lines.push(format!("  └ Technologies: {}", names.join(", ")));
1878                }
1879            }
1880
1881            return (true, lines);
1882        }
1883
1884        // Generic fallback - estimate data size
1885        let json_str = serde_json::to_string(v).unwrap_or_default();
1886        let size_kb = json_str.len() as f64 / 1024.0;
1887
1888        lines.push(format!(
1889            "{}📦 Retrieved {:.1} KB of data{}",
1890            ansi::SUCCESS,
1891            size_kb,
1892            ansi::RESET
1893        ));
1894
1895        // Try to show some structure info
1896        if let Some(obj) = v.as_object() {
1897            let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).take(5).collect();
1898            if !keys.is_empty() {
1899                lines.push(format!("  └ Fields: {}", keys.join(", ")));
1900            }
1901        }
1902
1903        (true, lines)
1904    } else {
1905        (false, vec!["retrieve failed".to_string()])
1906    }
1907}
1908
1909/// Format a single result item for preview
1910fn format_result_preview(result: &serde_json::Value) -> String {
1911    // Try to get meaningful identifiers
1912    let name = result
1913        .get("name")
1914        .or_else(|| result.get("code"))
1915        .or_else(|| result.get("check"))
1916        .and_then(|v| v.as_str())
1917        .unwrap_or("item");
1918
1919    let detail = result
1920        .get("message")
1921        .or_else(|| result.get("description"))
1922        .or_else(|| result.get("path"))
1923        .and_then(|v| v.as_str())
1924        .unwrap_or("");
1925
1926    let detail_short = if detail.len() > 40 {
1927        format!("{}...", &detail[..37])
1928    } else {
1929        detail.to_string()
1930    };
1931
1932    if detail_short.is_empty() {
1933        name.to_string()
1934    } else {
1935        format!("{}: {}", name, detail_short)
1936    }
1937}
1938
1939/// Convert tool name to a friendly action description for progress indicator
1940fn tool_to_action(tool_name: &str) -> String {
1941    match tool_name {
1942        "read_file" => "Reading file".to_string(),
1943        "write_file" | "write_files" => "Writing file".to_string(),
1944        "list_directory" => "Listing directory".to_string(),
1945        "shell" => "Running command".to_string(),
1946        "analyze_project" => "Analyzing project".to_string(),
1947        "security_scan" | "check_vulnerabilities" => "Scanning security".to_string(),
1948        "hadolint" => "Linting Dockerfile".to_string(),
1949        "dclint" => "Linting docker-compose".to_string(),
1950        "kubelint" => "Linting Kubernetes".to_string(),
1951        "helmlint" => "Linting Helm chart".to_string(),
1952        "terraform_fmt" => "Formatting Terraform".to_string(),
1953        "terraform_validate" => "Validating Terraform".to_string(),
1954        "plan_create" => "Creating plan".to_string(),
1955        "plan_list" => "Listing plans".to_string(),
1956        "plan_next" | "plan_update" => "Updating plan".to_string(),
1957        "retrieve_output" => "Retrieving data".to_string(),
1958        "list_stored_outputs" => "Listing outputs".to_string(),
1959        _ => "Processing".to_string(),
1960    }
1961}
1962
1963/// Extract focus/detail from tool arguments for progress indicator
1964fn tool_to_focus(tool_name: &str, args: &str) -> Option<String> {
1965    let parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
1966    let parsed = parsed.ok()?;
1967
1968    match tool_name {
1969        "read_file" | "write_file" => {
1970            parsed.get("path").and_then(|p| p.as_str()).map(|p| {
1971                // Shorten long paths
1972                if p.len() > 50 {
1973                    format!("...{}", &p[p.len().saturating_sub(47)..])
1974                } else {
1975                    p.to_string()
1976                }
1977            })
1978        }
1979        "list_directory" => parsed
1980            .get("path")
1981            .and_then(|p| p.as_str())
1982            .map(|p| p.to_string()),
1983        "shell" => parsed.get("command").and_then(|c| c.as_str()).map(|cmd| {
1984            // Truncate long commands
1985            if cmd.len() > 60 {
1986                format!("{}...", &cmd[..57])
1987            } else {
1988                cmd.to_string()
1989            }
1990        }),
1991        "hadolint" | "dclint" | "kubelint" | "helmlint" => parsed
1992            .get("path")
1993            .and_then(|p| p.as_str())
1994            .map(|p| p.to_string())
1995            .or_else(|| {
1996                if parsed.get("content").is_some() {
1997                    Some("<inline content>".to_string())
1998                } else {
1999                    Some("<auto-detect>".to_string())
2000                }
2001            }),
2002        "plan_create" => parsed
2003            .get("name")
2004            .and_then(|n| n.as_str())
2005            .map(|n| n.to_string()),
2006        "retrieve_output" => {
2007            let ref_id = parsed.get("ref_id").and_then(|r| r.as_str())?;
2008            let query = parsed.get("query").and_then(|q| q.as_str());
2009            Some(if let Some(q) = query {
2010                format!("{} ({})", ref_id, q)
2011            } else {
2012                ref_id.to_string()
2013            })
2014        }
2015        _ => None,
2016    }
2017}
2018
2019// Legacy exports for compatibility
2020pub use crate::agent::ui::Spinner;
2021use tokio::sync::mpsc;
2022
2023/// Events for backward compatibility
2024#[derive(Debug, Clone)]
2025pub enum ToolEvent {
2026    ToolStart { name: String, args: String },
2027    ToolComplete { name: String, result: String },
2028}
2029
2030/// Legacy spawn function - now a no-op since display is handled in hooks
2031pub fn spawn_tool_display_handler(
2032    _receiver: mpsc::Receiver<ToolEvent>,
2033    _spinner: Arc<crate::agent::ui::Spinner>,
2034) -> tokio::task::JoinHandle<()> {
2035    tokio::spawn(async {})
2036}