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