1use 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
19const PREVIEW_LINES: usize = 4;
21
22fn 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#[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 pub ag_ui_tool_call_id: Option<syncable_ag_ui_core::ToolCallId>,
48}
49
50#[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 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 pub fn has_data(&self) -> bool {
68 self.input_tokens > 0 || self.output_tokens > 0 || self.total_tokens > 0
69 }
70}
71
72pub 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 pub usage: AccumulatedUsage,
80 pub progress_state: Option<std::sync::Arc<crate::agent::ui::progress::ProgressState>>,
82 pub cancel_signal: Option<CancelSignal>,
84 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#[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 pub fn state(&self) -> Arc<Mutex<DisplayState>> {
118 self.state.clone()
119 }
120
121 pub async fn get_usage(&self) -> AccumulatedUsage {
123 let state = self.state.lock().await;
124 state.usage.clone()
125 }
126
127 pub async fn reset_usage(&self) {
129 let mut state = self.state.lock().await;
130 state.usage = AccumulatedUsage::default();
131 }
132
133 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 pub async fn clear_progress_state(&self) {
144 let mut state = self.state.lock().await;
145 state.progress_state = None;
146 }
147
148 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 pub async fn can_cancel(&self) -> bool {
159 let state = self.state.lock().await;
160 state.cancel_signal.is_some()
161 }
162
163 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 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 {
200 let mut s = state.lock().await;
201 s.cancel_signal = Some(cancel);
202 }
203 {
205 let s = state.lock().await;
206 if let Some(ref progress) = s.progress_state {
207 progress.pause();
208 }
209 }
210
211 print!("\r{}", ansi::CLEAR_LINE);
214 let _ = io::stdout().flush();
215
216 println!(); print_tool_header(&name, &args_str);
219
220 {
222 let s = state.lock().await;
223 if let Some(ref progress) = s.progress_state {
224 let action = tool_to_action(&name);
226 progress.set_action(&action);
227
228 let focus = tool_to_focus(&name, &args_str);
230 if let Some(f) = focus {
231 progress.set_focus(&f);
232 }
233 }
234 }
235
236 let ag_ui_tool_call_id = {
238 let s = state.lock().await;
239 if let Some(ref bridge) = s.event_bridge {
240 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 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 let (status_ok, output_lines, is_collapsible) =
283 print_tool_result(&name, &args_str, &result_str);
284
285 let mut s = state.lock().await;
287 if let Some(idx) = s.current_tool_index {
288 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 if is_collapsible {
303 s.last_expandable_index = Some(idx);
304 }
305
306 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 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 let usage = response.usage;
332
333 let cancel_for_store = cancel.clone();
336
337 let has_tool_calls = response
340 .choice
341 .iter()
342 .any(|content| matches!(content, AssistantContent::ToolCall(_)));
343
344 let reasoning_parts: Vec<String> = response
346 .choice
347 .iter()
348 .filter_map(|content| {
349 if let AssistantContent::Reasoning(Reasoning { reasoning, .. }) = content {
350 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 let text_parts: Vec<String> = response
365 .choice
366 .iter()
367 .filter_map(|content| {
368 if let AssistantContent::Text(text) = content {
369 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 {
385 let mut s = state.lock().await;
386 s.cancel_signal = Some(cancel_for_store);
387 }
388
389 {
391 let mut s = state.lock().await;
392 s.usage.add(&usage);
393
394 if let Some(ref progress) = s.progress_state {
396 progress.update_tokens(usage.input_tokens, usage.output_tokens);
397 }
398 }
399
400 if !reasoning_parts.is_empty() {
402 let thinking_text = reasoning_parts.join("\n");
403
404 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 print!("\r{}", ansi::CLEAR_LINE);
414 let _ = io::stdout().flush();
415
416 print_agent_thinking(&thinking_text);
418
419 let s = state.lock().await;
421 if let Some(ref progress) = s.progress_state {
422 progress.resume();
423 }
424 }
425
426 if !text_parts.is_empty() && has_tool_calls {
429 let thinking_text = text_parts.join("\n");
430
431 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 print!("\r{}", ansi::CLEAR_LINE);
441 let _ = io::stdout().flush();
442
443 print_agent_thinking(&thinking_text);
445
446 let s = state.lock().await;
448 if let Some(ref progress) = s.progress_state {
449 progress.resume();
450 }
451 }
452 }
453 }
454}
455
456fn print_agent_thinking(text: &str) {
459 use crate::agent::ui::response::brand;
460
461 println!();
462
463 let mut in_code_block = false;
465
466 for line in text.lines() {
467 let trimmed = line.trim();
468
469 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 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 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 if trimmed.is_empty() {
539 println!();
540 } else {
541 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
558fn 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 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 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
607fn 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
617fn 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
649fn 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 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 println!(" {} {}", "└".dimmed(), "Running...".dimmed());
668
669 let _ = io::stdout().flush();
670}
671
672fn print_tool_result(name: &str, args: &str, result: &str) -> (bool, Vec<String>, bool) {
675 print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
677 let _ = io::stdout().flush();
678
679 let parsed: Result<serde_json::Value, _> =
681 serde_json::from_str(result).map(|v: serde_json::Value| {
682 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 let parsed = if parsed.is_err() && !result.is_empty() {
694 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 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 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 print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
736
737 let dot = if status_ok {
739 "●".green()
740 } else {
741 "●".red()
742 };
743
744 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 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 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
780fn 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 if let Some(path) = v.get("path").and_then(|p| p.as_str()) {
851 return path.to_string();
852 }
853 if v.get("content").and_then(|c| c.as_str()).is_some() {
855 return "<inline>".to_string();
856 }
857 "<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
881fn format_shell_result(
883 parsed: &Result<serde_json::Value, serde_json::Error>,
884) -> (bool, Vec<String>) {
885 if let Ok(v) = parsed {
886 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 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 for line in stdout.lines() {
914 if !line.trim().is_empty() {
915 lines.push(line.to_string());
916 }
917 }
918
919 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
945fn 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
974fn format_read_result(
976 parsed: &Result<serde_json::Value, serde_json::Error>,
977) -> (bool, Vec<String>) {
978 if let Ok(v) = parsed {
979 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 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 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 if v.is_string() {
1007 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
1017fn 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 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
1054fn 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 let is_compressed = v.get("full_data_ref").is_some();
1063
1064 if is_compressed {
1065 let ref_id = v
1067 .get("full_data_ref")
1068 .and_then(|r| r.as_str())
1069 .unwrap_or("?");
1070
1071 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 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 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 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 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 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 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 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
1163fn 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
1185fn 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 let total = summary
1198 .and_then(|s| s.get("total"))
1199 .and_then(|t| t.as_u64())
1200 .unwrap_or(0);
1201
1202 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 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 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 let mut shown = 0;
1278 const MAX_PREVIEW: usize = 6;
1279
1280 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 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 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 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
1334fn 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 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 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
1368fn 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 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 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 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 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 let mut shown = 0;
1501 const MAX_PREVIEW: usize = 6;
1502
1503 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 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 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 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 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 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
1589fn 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 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 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 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 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 let mut shown = 0;
1722 const MAX_PREVIEW: usize = 6;
1723
1724 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 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 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 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
1778fn 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 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 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 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
1822fn 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 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 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 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 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 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 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 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 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 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
2005fn format_result_preview(result: &serde_json::Value) -> String {
2007 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
2031fn 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
2055fn 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 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_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
2109pub use crate::agent::ui::Spinner;
2111use tokio::sync::mpsc;
2112
2113#[derive(Debug, Clone)]
2115pub enum ToolEvent {
2116 ToolStart { name: String, args: String },
2117 ToolComplete { name: String, result: String },
2118}
2119
2120pub 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}