Skip to main content

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