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
22#[derive(Debug, Clone)]
24pub struct ToolCallState {
25 pub name: String,
26 pub args: String,
27 pub output: Option<String>,
28 pub output_lines: Vec<String>,
29 pub is_running: bool,
30 pub is_expanded: bool,
31 pub is_collapsible: bool,
32 pub status_ok: bool,
33}
34
35#[derive(Debug, Default, Clone)]
37pub struct AccumulatedUsage {
38 pub input_tokens: u64,
39 pub output_tokens: u64,
40 pub total_tokens: u64,
41}
42
43impl AccumulatedUsage {
44 pub fn add(&mut self, usage: &Usage) {
46 self.input_tokens += usage.input_tokens;
47 self.output_tokens += usage.output_tokens;
48 self.total_tokens += usage.total_tokens;
49 }
50
51 pub fn has_data(&self) -> bool {
53 self.input_tokens > 0 || self.output_tokens > 0 || self.total_tokens > 0
54 }
55}
56
57#[derive(Debug, Default)]
59pub struct DisplayState {
60 pub tool_calls: Vec<ToolCallState>,
61 pub agent_messages: Vec<String>,
62 pub current_tool_index: Option<usize>,
63 pub last_expandable_index: Option<usize>,
64 pub usage: AccumulatedUsage,
66}
67
68#[derive(Clone)]
70pub struct ToolDisplayHook {
71 state: Arc<Mutex<DisplayState>>,
72}
73
74impl ToolDisplayHook {
75 pub fn new() -> Self {
76 Self {
77 state: Arc::new(Mutex::new(DisplayState::default())),
78 }
79 }
80
81 pub fn state(&self) -> Arc<Mutex<DisplayState>> {
83 self.state.clone()
84 }
85
86 pub async fn get_usage(&self) -> AccumulatedUsage {
88 let state = self.state.lock().await;
89 state.usage.clone()
90 }
91
92 pub async fn reset_usage(&self) {
94 let mut state = self.state.lock().await;
95 state.usage = AccumulatedUsage::default();
96 }
97}
98
99impl Default for ToolDisplayHook {
100 fn default() -> Self {
101 Self::new()
102 }
103}
104
105impl<M> rig::agent::PromptHook<M> for ToolDisplayHook
106where
107 M: CompletionModel,
108{
109 fn on_tool_call(
110 &self,
111 tool_name: &str,
112 _tool_call_id: Option<String>,
113 args: &str,
114 _cancel: CancelSignal,
115 ) -> impl std::future::Future<Output = ()> + Send {
116 let state = self.state.clone();
117 let name = tool_name.to_string();
118 let args_str = args.to_string();
119
120 async move {
121 print_tool_header(&name, &args_str);
123
124 let mut s = state.lock().await;
126 let idx = s.tool_calls.len();
127 s.tool_calls.push(ToolCallState {
128 name,
129 args: args_str,
130 output: None,
131 output_lines: Vec::new(),
132 is_running: true,
133 is_expanded: false,
134 is_collapsible: false,
135 status_ok: true,
136 });
137 s.current_tool_index = Some(idx);
138 }
139 }
140
141 fn on_tool_result(
142 &self,
143 tool_name: &str,
144 _tool_call_id: Option<String>,
145 args: &str,
146 result: &str,
147 _cancel: CancelSignal,
148 ) -> impl std::future::Future<Output = ()> + Send {
149 let state = self.state.clone();
150 let name = tool_name.to_string();
151 let args_str = args.to_string();
152 let result_str = result.to_string();
153
154 async move {
155 let (status_ok, output_lines, is_collapsible) =
157 print_tool_result(&name, &args_str, &result_str);
158
159 let mut s = state.lock().await;
161 if let Some(idx) = s.current_tool_index {
162 if let Some(tool) = s.tool_calls.get_mut(idx) {
163 tool.output = Some(result_str);
164 tool.output_lines = output_lines;
165 tool.is_running = false;
166 tool.is_collapsible = is_collapsible;
167 tool.status_ok = status_ok;
168 }
169 if is_collapsible {
171 s.last_expandable_index = Some(idx);
172 }
173 }
174 s.current_tool_index = None;
175 }
176 }
177
178 fn on_completion_response(
179 &self,
180 _prompt: &Message,
181 response: &CompletionResponse<M::Response>,
182 _cancel: CancelSignal,
183 ) -> impl std::future::Future<Output = ()> + Send {
184 let state = self.state.clone();
185
186 let usage = response.usage.clone();
188
189 let has_tool_calls = response
192 .choice
193 .iter()
194 .any(|content| matches!(content, AssistantContent::ToolCall(_)));
195
196 let reasoning_parts: Vec<String> = response
198 .choice
199 .iter()
200 .filter_map(|content| {
201 if let AssistantContent::Reasoning(Reasoning { reasoning, .. }) = content {
202 let text = reasoning.iter().cloned().collect::<Vec<_>>().join("\n");
204 if !text.trim().is_empty() {
205 Some(text)
206 } else {
207 None
208 }
209 } else {
210 None
211 }
212 })
213 .collect();
214
215 let text_parts: Vec<String> = response
217 .choice
218 .iter()
219 .filter_map(|content| {
220 if let AssistantContent::Text(text) = content {
221 let trimmed = text.text.trim();
223 if !trimmed.is_empty() {
224 Some(trimmed.to_string())
225 } else {
226 None
227 }
228 } else {
229 None
230 }
231 })
232 .collect();
233
234 async move {
235 {
237 let mut s = state.lock().await;
238 s.usage.add(&usage);
239 }
240
241 if !reasoning_parts.is_empty() {
243 let thinking_text = reasoning_parts.join("\n");
244
245 let mut s = state.lock().await;
247 s.agent_messages.push(thinking_text.clone());
248 drop(s);
249
250 print_agent_thinking(&thinking_text);
252 }
253
254 if !text_parts.is_empty() && has_tool_calls {
257 let thinking_text = text_parts.join("\n");
258
259 let mut s = state.lock().await;
261 s.agent_messages.push(thinking_text.clone());
262 drop(s);
263
264 print_agent_thinking(&thinking_text);
266 }
267 }
268 }
269}
270
271fn print_agent_thinking(text: &str) {
273 use crate::agent::ui::response::brand;
274
275 println!();
276
277 println!(
279 "{}{} 💭 Thinking...{}",
280 brand::CORAL,
281 brand::ITALIC,
282 brand::RESET
283 );
284
285 let mut in_code_block = false;
287
288 for line in text.lines() {
289 let trimmed = line.trim();
290
291 if trimmed.starts_with("```") {
293 if in_code_block {
294 println!(
295 "{} └────────────────────────────────────────────────────────┘{}",
296 brand::LIGHT_PEACH,
297 brand::RESET
298 );
299 in_code_block = false;
300 } else {
301 let lang = trimmed.strip_prefix("```").unwrap_or("");
302 let lang_display = if lang.is_empty() { "code" } else { lang };
303 println!(
304 "{} ┌─ {}{}{} ────────────────────────────────────────────────────┐{}",
305 brand::LIGHT_PEACH,
306 brand::CYAN,
307 lang_display,
308 brand::LIGHT_PEACH,
309 brand::RESET
310 );
311 in_code_block = true;
312 }
313 continue;
314 }
315
316 if in_code_block {
317 println!(
318 "{} │ {}{}{} │",
319 brand::LIGHT_PEACH,
320 brand::CYAN,
321 line,
322 brand::RESET
323 );
324 continue;
325 }
326
327 if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
329 let content = trimmed
330 .strip_prefix("- ")
331 .or_else(|| trimmed.strip_prefix("* "))
332 .unwrap_or(trimmed);
333 println!(
334 "{} {} {}{}",
335 brand::PEACH,
336 "•",
337 format_thinking_inline(content),
338 brand::RESET
339 );
340 continue;
341 }
342
343 if trimmed
345 .chars()
346 .next()
347 .map(|c| c.is_ascii_digit())
348 .unwrap_or(false)
349 && trimmed.chars().nth(1) == Some('.')
350 {
351 println!(
352 "{} {}{}",
353 brand::PEACH,
354 format_thinking_inline(trimmed),
355 brand::RESET
356 );
357 continue;
358 }
359
360 if trimmed.is_empty() {
362 println!();
363 } else {
364 let wrapped = wrap_text(trimmed, 76);
366 for wrapped_line in wrapped {
367 println!(
368 "{} {}{}",
369 brand::PEACH,
370 format_thinking_inline(&wrapped_line),
371 brand::RESET
372 );
373 }
374 }
375 }
376
377 println!();
378 let _ = io::stdout().flush();
379}
380
381fn format_thinking_inline(text: &str) -> String {
383 use crate::agent::ui::response::brand;
384
385 let mut result = String::new();
386 let chars: Vec<char> = text.chars().collect();
387 let mut i = 0;
388
389 while i < chars.len() {
390 if chars[i] == '`' && (i + 1 >= chars.len() || chars[i + 1] != '`') {
392 if let Some(end) = chars[i + 1..].iter().position(|&c| c == '`') {
393 let code_text: String = chars[i + 1..i + 1 + end].iter().collect();
394 result.push_str(brand::CYAN);
395 result.push('`');
396 result.push_str(&code_text);
397 result.push('`');
398 result.push_str(brand::RESET);
399 result.push_str(brand::PEACH);
400 i = i + 2 + end;
401 continue;
402 }
403 }
404
405 if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
407 if let Some(end_offset) = find_double_star(&chars, i + 2) {
408 let bold_text: String = chars[i + 2..i + 2 + end_offset].iter().collect();
409 result.push_str(brand::RESET);
410 result.push_str(brand::CORAL);
411 result.push_str(brand::BOLD);
412 result.push_str(&bold_text);
413 result.push_str(brand::RESET);
414 result.push_str(brand::PEACH);
415 i = i + 4 + end_offset;
416 continue;
417 }
418 }
419
420 result.push(chars[i]);
421 i += 1;
422 }
423
424 result
425}
426
427fn find_double_star(chars: &[char], start: usize) -> Option<usize> {
429 for i in start..chars.len().saturating_sub(1) {
430 if chars[i] == '*' && chars[i + 1] == '*' {
431 return Some(i - start);
432 }
433 }
434 None
435}
436
437fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
439 if text.len() <= max_width {
440 return vec![text.to_string()];
441 }
442
443 let mut lines = Vec::new();
444 let mut current_line = String::new();
445
446 for word in text.split_whitespace() {
447 if current_line.is_empty() {
448 current_line = word.to_string();
449 } else if current_line.len() + 1 + word.len() <= max_width {
450 current_line.push(' ');
451 current_line.push_str(word);
452 } else {
453 lines.push(current_line);
454 current_line = word.to_string();
455 }
456 }
457
458 if !current_line.is_empty() {
459 lines.push(current_line);
460 }
461
462 if lines.is_empty() {
463 lines.push(text.to_string());
464 }
465
466 lines
467}
468
469fn print_tool_header(name: &str, args: &str) {
471 let parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
472 let args_display = format_args_display(name, &parsed);
473
474 if args_display.is_empty() {
476 println!("\n{} {}", "●".yellow(), name.cyan().bold());
477 } else {
478 println!(
479 "\n{} {}({})",
480 "●".yellow(),
481 name.cyan().bold(),
482 args_display.dimmed()
483 );
484 }
485
486 println!(" {} {}", "└".dimmed(), "Running...".dimmed());
488
489 let _ = io::stdout().flush();
490}
491
492fn print_tool_result(name: &str, args: &str, result: &str) -> (bool, Vec<String>, bool) {
495 print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
497 let _ = io::stdout().flush();
498
499 let parsed: Result<serde_json::Value, _> =
501 serde_json::from_str(result).map(|v: serde_json::Value| {
502 if let Some(inner_str) = v.as_str() {
505 serde_json::from_str(inner_str).unwrap_or(v)
506 } else {
507 v
508 }
509 });
510
511 let (status_ok, output_lines) = match name {
513 "shell" => format_shell_result(&parsed),
514 "write_file" | "write_files" => format_write_result(&parsed),
515 "read_file" => format_read_result(&parsed),
516 "list_directory" => format_list_result(&parsed),
517 "analyze_project" => format_analyze_result(&parsed),
518 "security_scan" | "check_vulnerabilities" => format_security_result(&parsed),
519 "hadolint" => format_hadolint_result(&parsed),
520 _ => (true, vec!["done".to_string()]),
521 };
522
523 print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
525
526 let dot = if status_ok {
528 "●".green()
529 } else {
530 "●".red()
531 };
532
533 let args_parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
535 let args_display = format_args_display(name, &args_parsed);
536
537 if args_display.is_empty() {
538 println!("{} {}", dot, name.cyan().bold());
539 } else {
540 println!("{} {}({})", dot, name.cyan().bold(), args_display.dimmed());
541 }
542
543 let total_lines = output_lines.len();
545 let is_collapsible = total_lines > PREVIEW_LINES;
546
547 for (i, line) in output_lines.iter().take(PREVIEW_LINES).enumerate() {
548 let prefix = if i == output_lines.len().min(PREVIEW_LINES) - 1 && !is_collapsible {
549 "└"
550 } else {
551 "│"
552 };
553 println!(" {} {}", prefix.dimmed(), line);
554 }
555
556 if is_collapsible {
558 println!(
559 " {} {}",
560 "└".dimmed(),
561 format!("+{} more lines", total_lines - PREVIEW_LINES).dimmed()
562 );
563 }
564
565 let _ = io::stdout().flush();
566 (status_ok, output_lines, is_collapsible)
567}
568
569fn format_args_display(
571 name: &str,
572 parsed: &Result<serde_json::Value, serde_json::Error>,
573) -> String {
574 match name {
575 "shell" => {
576 if let Ok(v) = parsed {
577 v.get("command")
578 .and_then(|c| c.as_str())
579 .unwrap_or("")
580 .to_string()
581 } else {
582 String::new()
583 }
584 }
585 "write_file" => {
586 if let Ok(v) = parsed {
587 v.get("path")
588 .and_then(|p| p.as_str())
589 .unwrap_or("")
590 .to_string()
591 } else {
592 String::new()
593 }
594 }
595 "write_files" => {
596 if let Ok(v) = parsed {
597 if let Some(files) = v.get("files").and_then(|f| f.as_array()) {
598 let paths: Vec<&str> = files
599 .iter()
600 .filter_map(|f| f.get("path").and_then(|p| p.as_str()))
601 .take(3)
602 .collect();
603 let more = if files.len() > 3 {
604 format!(", +{} more", files.len() - 3)
605 } else {
606 String::new()
607 };
608 format!("{}{}", paths.join(", "), more)
609 } else {
610 String::new()
611 }
612 } else {
613 String::new()
614 }
615 }
616 "read_file" => {
617 if let Ok(v) = parsed {
618 v.get("path")
619 .and_then(|p| p.as_str())
620 .unwrap_or("")
621 .to_string()
622 } else {
623 String::new()
624 }
625 }
626 "list_directory" => {
627 if let Ok(v) = parsed {
628 v.get("path")
629 .and_then(|p| p.as_str())
630 .unwrap_or(".")
631 .to_string()
632 } else {
633 ".".to_string()
634 }
635 }
636 _ => String::new(),
637 }
638}
639
640fn format_shell_result(
642 parsed: &Result<serde_json::Value, serde_json::Error>,
643) -> (bool, Vec<String>) {
644 if let Ok(v) = parsed {
645 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
646 let stdout = v.get("stdout").and_then(|s| s.as_str()).unwrap_or("");
647 let stderr = v.get("stderr").and_then(|s| s.as_str()).unwrap_or("");
648 let exit_code = v.get("exit_code").and_then(|c| c.as_i64());
649
650 let mut lines = Vec::new();
651
652 for line in stdout.lines() {
654 if !line.trim().is_empty() {
655 lines.push(line.to_string());
656 }
657 }
658
659 if !success {
661 for line in stderr.lines() {
662 if !line.trim().is_empty() {
663 lines.push(format!("{}", line.red()));
664 }
665 }
666 if let Some(code) = exit_code {
667 lines.push(format!("exit code: {}", code).red().to_string());
668 }
669 }
670
671 if lines.is_empty() {
672 lines.push(if success {
673 "completed".to_string()
674 } else {
675 "failed".to_string()
676 });
677 }
678
679 (success, lines)
680 } else {
681 (false, vec!["parse error".to_string()])
682 }
683}
684
685fn format_write_result(
687 parsed: &Result<serde_json::Value, serde_json::Error>,
688) -> (bool, Vec<String>) {
689 if let Ok(v) = parsed {
690 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
691 let action = v.get("action").and_then(|a| a.as_str()).unwrap_or("wrote");
692 let lines_written = v
693 .get("lines_written")
694 .or_else(|| v.get("total_lines"))
695 .and_then(|n| n.as_u64())
696 .unwrap_or(0);
697 let files_written = v.get("files_written").and_then(|n| n.as_u64()).unwrap_or(1);
698
699 let msg = if files_written > 1 {
700 format!(
701 "{} {} files ({} lines)",
702 action, files_written, lines_written
703 )
704 } else {
705 format!("{} ({} lines)", action, lines_written)
706 };
707
708 (success, vec![msg])
709 } else {
710 (false, vec!["write failed".to_string()])
711 }
712}
713
714fn format_read_result(
716 parsed: &Result<serde_json::Value, serde_json::Error>,
717) -> (bool, Vec<String>) {
718 if let Ok(v) = parsed {
719 if v.get("error").is_some() {
721 let error_msg = v
722 .get("error")
723 .and_then(|e| e.as_str())
724 .unwrap_or("file not found");
725 return (false, vec![error_msg.to_string()]);
726 }
727
728 if let Some(total_lines) = v.get("total_lines").and_then(|n| n.as_u64()) {
730 let msg = if total_lines == 1 {
731 "read 1 line".to_string()
732 } else {
733 format!("read {} lines", total_lines)
734 };
735 return (true, vec![msg]);
736 }
737
738 if let Some(content) = v.get("content").and_then(|c| c.as_str()) {
741 let lines = content.lines().count();
742 return (true, vec![format!("read {} lines", lines)]);
743 }
744
745 if v.is_string() {
747 return (true, vec!["read file".to_string()]);
749 }
750
751 (true, vec!["read file".to_string()])
752 } else {
753 (false, vec!["read failed".to_string()])
754 }
755}
756
757fn format_list_result(
759 parsed: &Result<serde_json::Value, serde_json::Error>,
760) -> (bool, Vec<String>) {
761 if let Ok(v) = parsed {
762 let entries = v.get("entries").and_then(|e| e.as_array());
763
764 let mut lines = Vec::new();
765
766 if let Some(entries) = entries {
767 let total = entries.len();
768 for entry in entries.iter().take(PREVIEW_LINES + 2) {
769 let name = entry.get("name").and_then(|n| n.as_str()).unwrap_or("?");
770 let entry_type = entry.get("type").and_then(|t| t.as_str()).unwrap_or("file");
771 let prefix = if entry_type == "directory" {
772 "📁"
773 } else {
774 "📄"
775 };
776 lines.push(format!("{} {}", prefix, name));
777 }
778 if total > PREVIEW_LINES + 2 {
780 lines.push(format!("... and {} more", total - (PREVIEW_LINES + 2)));
781 }
782 }
783
784 if lines.is_empty() {
785 lines.push("empty directory".to_string());
786 }
787
788 (true, lines)
789 } else {
790 (false, vec!["parse error".to_string()])
791 }
792}
793
794fn format_analyze_result(
796 parsed: &Result<serde_json::Value, serde_json::Error>,
797) -> (bool, Vec<String>) {
798 if let Ok(v) = parsed {
799 let mut lines = Vec::new();
800
801 if let Some(langs) = v.get("languages").and_then(|l| l.as_array()) {
803 let lang_names: Vec<&str> = langs
804 .iter()
805 .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
806 .take(5)
807 .collect();
808 if !lang_names.is_empty() {
809 lines.push(format!("Languages: {}", lang_names.join(", ")));
810 }
811 }
812
813 if let Some(frameworks) = v.get("frameworks").and_then(|f| f.as_array()) {
815 let fw_names: Vec<&str> = frameworks
816 .iter()
817 .filter_map(|f| f.get("name").and_then(|n| n.as_str()))
818 .take(5)
819 .collect();
820 if !fw_names.is_empty() {
821 lines.push(format!("Frameworks: {}", fw_names.join(", ")));
822 }
823 }
824
825 if lines.is_empty() {
826 lines.push("analysis complete".to_string());
827 }
828
829 (true, lines)
830 } else {
831 (false, vec!["parse error".to_string()])
832 }
833}
834
835fn format_security_result(
837 parsed: &Result<serde_json::Value, serde_json::Error>,
838) -> (bool, Vec<String>) {
839 if let Ok(v) = parsed {
840 let findings = v
841 .get("findings")
842 .or_else(|| v.get("vulnerabilities"))
843 .and_then(|f| f.as_array())
844 .map(|a| a.len())
845 .unwrap_or(0);
846
847 if findings == 0 {
848 (true, vec!["no issues found".to_string()])
849 } else {
850 (false, vec![format!("{} issues found", findings)])
851 }
852 } else {
853 (false, vec!["parse error".to_string()])
854 }
855}
856
857fn format_hadolint_result(
859 parsed: &Result<serde_json::Value, serde_json::Error>,
860) -> (bool, Vec<String>) {
861 if let Ok(v) = parsed {
862 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true);
863 let summary = v.get("summary");
864 let action_plan = v.get("action_plan");
865
866 let mut lines = Vec::new();
867
868 let total = summary
870 .and_then(|s| s.get("total"))
871 .and_then(|t| t.as_u64())
872 .unwrap_or(0);
873
874 if total == 0 {
876 lines.push(format!(
877 "{}🐳 Dockerfile OK - no issues found{}",
878 ansi::SUCCESS,
879 ansi::RESET
880 ));
881 return (true, lines);
882 }
883
884 let critical = summary
886 .and_then(|s| s.get("by_priority"))
887 .and_then(|p| p.get("critical"))
888 .and_then(|c| c.as_u64())
889 .unwrap_or(0);
890 let high = summary
891 .and_then(|s| s.get("by_priority"))
892 .and_then(|p| p.get("high"))
893 .and_then(|h| h.as_u64())
894 .unwrap_or(0);
895 let medium = summary
896 .and_then(|s| s.get("by_priority"))
897 .and_then(|p| p.get("medium"))
898 .and_then(|m| m.as_u64())
899 .unwrap_or(0);
900 let low = summary
901 .and_then(|s| s.get("by_priority"))
902 .and_then(|p| p.get("low"))
903 .and_then(|l| l.as_u64())
904 .unwrap_or(0);
905
906 let mut priority_parts = Vec::new();
908 if critical > 0 {
909 priority_parts.push(format!(
910 "{}🔴 {} critical{}",
911 ansi::CRITICAL,
912 critical,
913 ansi::RESET
914 ));
915 }
916 if high > 0 {
917 priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET));
918 }
919 if medium > 0 {
920 priority_parts.push(format!(
921 "{}🟡 {} medium{}",
922 ansi::MEDIUM,
923 medium,
924 ansi::RESET
925 ));
926 }
927 if low > 0 {
928 priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET));
929 }
930
931 let header_color = if critical > 0 {
932 ansi::CRITICAL
933 } else if high > 0 {
934 ansi::HIGH
935 } else {
936 ansi::DOCKER_BLUE
937 };
938
939 lines.push(format!(
940 "{}🐳 {} issue{} found: {}{}",
941 header_color,
942 total,
943 if total == 1 { "" } else { "s" },
944 priority_parts.join(" "),
945 ansi::RESET
946 ));
947
948 let mut shown = 0;
950 const MAX_PREVIEW: usize = 6;
951
952 if let Some(critical_issues) = action_plan
954 .and_then(|a| a.get("critical"))
955 .and_then(|c| c.as_array())
956 {
957 for issue in critical_issues.iter().take(MAX_PREVIEW - shown) {
958 lines.push(format_hadolint_issue(issue, "🔴", ansi::CRITICAL));
959 shown += 1;
960 }
961 }
962
963 if shown < MAX_PREVIEW {
965 if let Some(high_issues) = action_plan
966 .and_then(|a| a.get("high"))
967 .and_then(|h| h.as_array())
968 {
969 for issue in high_issues.iter().take(MAX_PREVIEW - shown) {
970 lines.push(format_hadolint_issue(issue, "🟠", ansi::HIGH));
971 shown += 1;
972 }
973 }
974 }
975
976 if let Some(quick_fixes) = v.get("quick_fixes").and_then(|q| q.as_array()) {
978 if let Some(first_fix) = quick_fixes.first().and_then(|f| f.as_str()) {
979 let truncated = if first_fix.len() > 70 {
980 format!("{}...", &first_fix[..67])
981 } else {
982 first_fix.to_string()
983 };
984 lines.push(format!(
985 "{} → Fix: {}{}",
986 ansi::INFO_BLUE,
987 truncated,
988 ansi::RESET
989 ));
990 }
991 }
992
993 let remaining = total as usize - shown;
995 if remaining > 0 {
996 lines.push(format!(
997 "{} +{} more issue{}{}",
998 ansi::GRAY,
999 remaining,
1000 if remaining == 1 { "" } else { "s" },
1001 ansi::RESET
1002 ));
1003 }
1004
1005 (success, lines)
1006 } else {
1007 (false, vec!["parse error".to_string()])
1008 }
1009}
1010
1011fn format_hadolint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> String {
1013 let code = issue.get("code").and_then(|c| c.as_str()).unwrap_or("?");
1014 let message = issue.get("message").and_then(|m| m.as_str()).unwrap_or("?");
1015 let line_num = issue.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
1016 let category = issue.get("category").and_then(|c| c.as_str()).unwrap_or("");
1017
1018 let badge = match category {
1020 "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
1021 "best-practice" => format!("{}[BP]{}", ansi::INFO_BLUE, ansi::RESET),
1022 "deprecated" => format!("{}[DEP]{}", ansi::MEDIUM, ansi::RESET),
1023 "performance" => format!("{}[PERF]{}", ansi::CYAN, ansi::RESET),
1024 _ => String::new(),
1025 };
1026
1027 let msg_display = if message.len() > 50 {
1029 format!("{}...", &message[..47])
1030 } else {
1031 message.to_string()
1032 };
1033
1034 format!(
1035 "{}{} L{}:{} {}{}[{}]{} {} {}",
1036 color,
1037 icon,
1038 line_num,
1039 ansi::RESET,
1040 ansi::DOCKER_BLUE,
1041 ansi::BOLD,
1042 code,
1043 ansi::RESET,
1044 badge,
1045 msg_display
1046 )
1047}
1048
1049pub use crate::agent::ui::Spinner;
1051use tokio::sync::mpsc;
1052
1053#[derive(Debug, Clone)]
1055pub enum ToolEvent {
1056 ToolStart { name: String, args: String },
1057 ToolComplete { name: String, result: String },
1058}
1059
1060pub fn spawn_tool_display_handler(
1062 _receiver: mpsc::Receiver<ToolEvent>,
1063 _spinner: Arc<crate::agent::ui::Spinner>,
1064) -> tokio::task::JoinHandle<()> {
1065 tokio::spawn(async {})
1066}