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