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