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;
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.to_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 format_thinking_inline(content),
337 brand::RESET
338 );
339 continue;
340 }
341
342 if trimmed
344 .chars()
345 .next()
346 .map(|c| c.is_ascii_digit())
347 .unwrap_or(false)
348 && trimmed.chars().nth(1) == Some('.')
349 {
350 println!(
351 "{} {}{}",
352 brand::PEACH,
353 format_thinking_inline(trimmed),
354 brand::RESET
355 );
356 continue;
357 }
358
359 if trimmed.is_empty() {
361 println!();
362 } else {
363 let wrapped = wrap_text(trimmed, 76);
365 for wrapped_line in wrapped {
366 println!(
367 "{} {}{}",
368 brand::PEACH,
369 format_thinking_inline(&wrapped_line),
370 brand::RESET
371 );
372 }
373 }
374 }
375
376 println!();
377 let _ = io::stdout().flush();
378}
379
380fn format_thinking_inline(text: &str) -> String {
382 use crate::agent::ui::response::brand;
383
384 let mut result = String::new();
385 let chars: Vec<char> = text.chars().collect();
386 let mut i = 0;
387
388 while i < chars.len() {
389 if chars[i] == '`'
391 && (i + 1 >= chars.len() || chars[i + 1] != '`')
392 && let Some(end) = chars[i + 1..].iter().position(|&c| c == '`')
393 {
394 let code_text: String = chars[i + 1..i + 1 + end].iter().collect();
395 result.push_str(brand::CYAN);
396 result.push('`');
397 result.push_str(&code_text);
398 result.push('`');
399 result.push_str(brand::RESET);
400 result.push_str(brand::PEACH);
401 i = i + 2 + end;
402 continue;
403 }
404
405 if i + 1 < chars.len()
407 && chars[i] == '*'
408 && chars[i + 1] == '*'
409 && let Some(end_offset) = find_double_star(&chars, i + 2)
410 {
411 let bold_text: String = chars[i + 2..i + 2 + end_offset].iter().collect();
412 result.push_str(brand::RESET);
413 result.push_str(brand::CORAL);
414 result.push_str(brand::BOLD);
415 result.push_str(&bold_text);
416 result.push_str(brand::RESET);
417 result.push_str(brand::PEACH);
418 i = i + 4 + end_offset;
419 continue;
420 }
421
422 result.push(chars[i]);
423 i += 1;
424 }
425
426 result
427}
428
429fn find_double_star(chars: &[char], start: usize) -> Option<usize> {
431 for i in start..chars.len().saturating_sub(1) {
432 if chars[i] == '*' && chars[i + 1] == '*' {
433 return Some(i - start);
434 }
435 }
436 None
437}
438
439fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
441 if text.len() <= max_width {
442 return vec![text.to_string()];
443 }
444
445 let mut lines = Vec::new();
446 let mut current_line = String::new();
447
448 for word in text.split_whitespace() {
449 if current_line.is_empty() {
450 current_line = word.to_string();
451 } else if current_line.len() + 1 + word.len() <= max_width {
452 current_line.push(' ');
453 current_line.push_str(word);
454 } else {
455 lines.push(current_line);
456 current_line = word.to_string();
457 }
458 }
459
460 if !current_line.is_empty() {
461 lines.push(current_line);
462 }
463
464 if lines.is_empty() {
465 lines.push(text.to_string());
466 }
467
468 lines
469}
470
471fn print_tool_header(name: &str, args: &str) {
473 let parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
474 let args_display = format_args_display(name, &parsed);
475
476 if args_display.is_empty() {
478 println!("\n{} {}", "●".yellow(), name.cyan().bold());
479 } else {
480 println!(
481 "\n{} {}({})",
482 "●".yellow(),
483 name.cyan().bold(),
484 args_display.dimmed()
485 );
486 }
487
488 println!(" {} {}", "└".dimmed(), "Running...".dimmed());
490
491 let _ = io::stdout().flush();
492}
493
494fn print_tool_result(name: &str, args: &str, result: &str) -> (bool, Vec<String>, bool) {
497 print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
499 let _ = io::stdout().flush();
500
501 let parsed: Result<serde_json::Value, _> =
503 serde_json::from_str(result).map(|v: serde_json::Value| {
504 if let Some(inner_str) = v.as_str() {
507 serde_json::from_str(inner_str).unwrap_or(v)
508 } else {
509 v
510 }
511 });
512
513 let (status_ok, output_lines) = match name {
515 "shell" => format_shell_result(&parsed),
516 "write_file" | "write_files" => format_write_result(&parsed),
517 "read_file" => format_read_result(&parsed),
518 "list_directory" => format_list_result(&parsed),
519 "analyze_project" => format_analyze_result(&parsed),
520 "security_scan" | "check_vulnerabilities" => format_security_result(&parsed),
521 "hadolint" => format_hadolint_result(&parsed),
522 _ => (true, vec!["done".to_string()]),
523 };
524
525 print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
527
528 let dot = if status_ok {
530 "●".green()
531 } else {
532 "●".red()
533 };
534
535 let args_parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
537 let args_display = format_args_display(name, &args_parsed);
538
539 if args_display.is_empty() {
540 println!("{} {}", dot, name.cyan().bold());
541 } else {
542 println!("{} {}({})", dot, name.cyan().bold(), args_display.dimmed());
543 }
544
545 let total_lines = output_lines.len();
547 let is_collapsible = total_lines > PREVIEW_LINES;
548
549 for (i, line) in output_lines.iter().take(PREVIEW_LINES).enumerate() {
550 let prefix = if i == output_lines.len().min(PREVIEW_LINES) - 1 && !is_collapsible {
551 "└"
552 } else {
553 "│"
554 };
555 println!(" {} {}", prefix.dimmed(), line);
556 }
557
558 if is_collapsible {
560 println!(
561 " {} {}",
562 "└".dimmed(),
563 format!("+{} more lines", total_lines - PREVIEW_LINES).dimmed()
564 );
565 }
566
567 let _ = io::stdout().flush();
568 (status_ok, output_lines, is_collapsible)
569}
570
571fn format_args_display(
573 name: &str,
574 parsed: &Result<serde_json::Value, serde_json::Error>,
575) -> String {
576 match name {
577 "shell" => {
578 if let Ok(v) = parsed {
579 v.get("command")
580 .and_then(|c| c.as_str())
581 .unwrap_or("")
582 .to_string()
583 } else {
584 String::new()
585 }
586 }
587 "write_file" => {
588 if let Ok(v) = parsed {
589 v.get("path")
590 .and_then(|p| p.as_str())
591 .unwrap_or("")
592 .to_string()
593 } else {
594 String::new()
595 }
596 }
597 "write_files" => {
598 if let Ok(v) = parsed {
599 if let Some(files) = v.get("files").and_then(|f| f.as_array()) {
600 let paths: Vec<&str> = files
601 .iter()
602 .filter_map(|f| f.get("path").and_then(|p| p.as_str()))
603 .take(3)
604 .collect();
605 let more = if files.len() > 3 {
606 format!(", +{} more", files.len() - 3)
607 } else {
608 String::new()
609 };
610 format!("{}{}", paths.join(", "), more)
611 } else {
612 String::new()
613 }
614 } else {
615 String::new()
616 }
617 }
618 "read_file" => {
619 if let Ok(v) = parsed {
620 v.get("path")
621 .and_then(|p| p.as_str())
622 .unwrap_or("")
623 .to_string()
624 } else {
625 String::new()
626 }
627 }
628 "list_directory" => {
629 if let Ok(v) = parsed {
630 v.get("path")
631 .and_then(|p| p.as_str())
632 .unwrap_or(".")
633 .to_string()
634 } else {
635 ".".to_string()
636 }
637 }
638 _ => String::new(),
639 }
640}
641
642fn format_shell_result(
644 parsed: &Result<serde_json::Value, serde_json::Error>,
645) -> (bool, Vec<String>) {
646 if let Ok(v) = parsed {
647 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
648 let stdout = v.get("stdout").and_then(|s| s.as_str()).unwrap_or("");
649 let stderr = v.get("stderr").and_then(|s| s.as_str()).unwrap_or("");
650 let exit_code = v.get("exit_code").and_then(|c| c.as_i64());
651
652 let mut lines = Vec::new();
653
654 for line in stdout.lines() {
656 if !line.trim().is_empty() {
657 lines.push(line.to_string());
658 }
659 }
660
661 if !success {
663 for line in stderr.lines() {
664 if !line.trim().is_empty() {
665 lines.push(format!("{}", line.red()));
666 }
667 }
668 if let Some(code) = exit_code {
669 lines.push(format!("exit code: {}", code).red().to_string());
670 }
671 }
672
673 if lines.is_empty() {
674 lines.push(if success {
675 "completed".to_string()
676 } else {
677 "failed".to_string()
678 });
679 }
680
681 (success, lines)
682 } else {
683 (false, vec!["parse error".to_string()])
684 }
685}
686
687fn format_write_result(
689 parsed: &Result<serde_json::Value, serde_json::Error>,
690) -> (bool, Vec<String>) {
691 if let Ok(v) = parsed {
692 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
693 let action = v.get("action").and_then(|a| a.as_str()).unwrap_or("wrote");
694 let lines_written = v
695 .get("lines_written")
696 .or_else(|| v.get("total_lines"))
697 .and_then(|n| n.as_u64())
698 .unwrap_or(0);
699 let files_written = v.get("files_written").and_then(|n| n.as_u64()).unwrap_or(1);
700
701 let msg = if files_written > 1 {
702 format!(
703 "{} {} files ({} lines)",
704 action, files_written, lines_written
705 )
706 } else {
707 format!("{} ({} lines)", action, lines_written)
708 };
709
710 (success, vec![msg])
711 } else {
712 (false, vec!["write failed".to_string()])
713 }
714}
715
716fn format_read_result(
718 parsed: &Result<serde_json::Value, serde_json::Error>,
719) -> (bool, Vec<String>) {
720 if let Ok(v) = parsed {
721 if v.get("error").is_some() {
723 let error_msg = v
724 .get("error")
725 .and_then(|e| e.as_str())
726 .unwrap_or("file not found");
727 return (false, vec![error_msg.to_string()]);
728 }
729
730 if let Some(total_lines) = v.get("total_lines").and_then(|n| n.as_u64()) {
732 let msg = if total_lines == 1 {
733 "read 1 line".to_string()
734 } else {
735 format!("read {} lines", total_lines)
736 };
737 return (true, vec![msg]);
738 }
739
740 if let Some(content) = v.get("content").and_then(|c| c.as_str()) {
743 let lines = content.lines().count();
744 return (true, vec![format!("read {} lines", lines)]);
745 }
746
747 if v.is_string() {
749 return (true, vec!["read file".to_string()]);
751 }
752
753 (true, vec!["read file".to_string()])
754 } else {
755 (false, vec!["read failed".to_string()])
756 }
757}
758
759fn format_list_result(
761 parsed: &Result<serde_json::Value, serde_json::Error>,
762) -> (bool, Vec<String>) {
763 if let Ok(v) = parsed {
764 let entries = v.get("entries").and_then(|e| e.as_array());
765
766 let mut lines = Vec::new();
767
768 if let Some(entries) = entries {
769 let total = entries.len();
770 for entry in entries.iter().take(PREVIEW_LINES + 2) {
771 let name = entry.get("name").and_then(|n| n.as_str()).unwrap_or("?");
772 let entry_type = entry.get("type").and_then(|t| t.as_str()).unwrap_or("file");
773 let prefix = if entry_type == "directory" {
774 "📁"
775 } else {
776 "📄"
777 };
778 lines.push(format!("{} {}", prefix, name));
779 }
780 if total > PREVIEW_LINES + 2 {
782 lines.push(format!("... and {} more", total - (PREVIEW_LINES + 2)));
783 }
784 }
785
786 if lines.is_empty() {
787 lines.push("empty directory".to_string());
788 }
789
790 (true, lines)
791 } else {
792 (false, vec!["parse error".to_string()])
793 }
794}
795
796fn format_analyze_result(
798 parsed: &Result<serde_json::Value, serde_json::Error>,
799) -> (bool, Vec<String>) {
800 if let Ok(v) = parsed {
801 let mut lines = Vec::new();
802
803 if let Some(langs) = v.get("languages").and_then(|l| l.as_array()) {
805 let lang_names: Vec<&str> = langs
806 .iter()
807 .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
808 .take(5)
809 .collect();
810 if !lang_names.is_empty() {
811 lines.push(format!("Languages: {}", lang_names.join(", ")));
812 }
813 }
814
815 if let Some(frameworks) = v.get("frameworks").and_then(|f| f.as_array()) {
817 let fw_names: Vec<&str> = frameworks
818 .iter()
819 .filter_map(|f| f.get("name").and_then(|n| n.as_str()))
820 .take(5)
821 .collect();
822 if !fw_names.is_empty() {
823 lines.push(format!("Frameworks: {}", fw_names.join(", ")));
824 }
825 }
826
827 if lines.is_empty() {
828 lines.push("analysis complete".to_string());
829 }
830
831 (true, lines)
832 } else {
833 (false, vec!["parse error".to_string()])
834 }
835}
836
837fn format_security_result(
839 parsed: &Result<serde_json::Value, serde_json::Error>,
840) -> (bool, Vec<String>) {
841 if let Ok(v) = parsed {
842 let findings = v
843 .get("findings")
844 .or_else(|| v.get("vulnerabilities"))
845 .and_then(|f| f.as_array())
846 .map(|a| a.len())
847 .unwrap_or(0);
848
849 if findings == 0 {
850 (true, vec!["no issues found".to_string()])
851 } else {
852 (false, vec![format!("{} issues found", findings)])
853 }
854 } else {
855 (false, vec!["parse error".to_string()])
856 }
857}
858
859fn format_hadolint_result(
861 parsed: &Result<serde_json::Value, serde_json::Error>,
862) -> (bool, Vec<String>) {
863 if let Ok(v) = parsed {
864 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true);
865 let summary = v.get("summary");
866 let action_plan = v.get("action_plan");
867
868 let mut lines = Vec::new();
869
870 let total = summary
872 .and_then(|s| s.get("total"))
873 .and_then(|t| t.as_u64())
874 .unwrap_or(0);
875
876 if total == 0 {
878 lines.push(format!(
879 "{}🐳 Dockerfile OK - no issues found{}",
880 ansi::SUCCESS,
881 ansi::RESET
882 ));
883 return (true, lines);
884 }
885
886 let critical = summary
888 .and_then(|s| s.get("by_priority"))
889 .and_then(|p| p.get("critical"))
890 .and_then(|c| c.as_u64())
891 .unwrap_or(0);
892 let high = summary
893 .and_then(|s| s.get("by_priority"))
894 .and_then(|p| p.get("high"))
895 .and_then(|h| h.as_u64())
896 .unwrap_or(0);
897 let medium = summary
898 .and_then(|s| s.get("by_priority"))
899 .and_then(|p| p.get("medium"))
900 .and_then(|m| m.as_u64())
901 .unwrap_or(0);
902 let low = summary
903 .and_then(|s| s.get("by_priority"))
904 .and_then(|p| p.get("low"))
905 .and_then(|l| l.as_u64())
906 .unwrap_or(0);
907
908 let mut priority_parts = Vec::new();
910 if critical > 0 {
911 priority_parts.push(format!(
912 "{}🔴 {} critical{}",
913 ansi::CRITICAL,
914 critical,
915 ansi::RESET
916 ));
917 }
918 if high > 0 {
919 priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET));
920 }
921 if medium > 0 {
922 priority_parts.push(format!(
923 "{}🟡 {} medium{}",
924 ansi::MEDIUM,
925 medium,
926 ansi::RESET
927 ));
928 }
929 if low > 0 {
930 priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET));
931 }
932
933 let header_color = if critical > 0 {
934 ansi::CRITICAL
935 } else if high > 0 {
936 ansi::HIGH
937 } else {
938 ansi::DOCKER_BLUE
939 };
940
941 lines.push(format!(
942 "{}🐳 {} issue{} found: {}{}",
943 header_color,
944 total,
945 if total == 1 { "" } else { "s" },
946 priority_parts.join(" "),
947 ansi::RESET
948 ));
949
950 let mut shown = 0;
952 const MAX_PREVIEW: usize = 6;
953
954 if let Some(critical_issues) = action_plan
956 .and_then(|a| a.get("critical"))
957 .and_then(|c| c.as_array())
958 {
959 for issue in critical_issues.iter().take(MAX_PREVIEW - shown) {
960 lines.push(format_hadolint_issue(issue, "🔴", ansi::CRITICAL));
961 shown += 1;
962 }
963 }
964
965 if shown < MAX_PREVIEW
967 && let Some(high_issues) = action_plan
968 .and_then(|a| a.get("high"))
969 .and_then(|h| h.as_array())
970 {
971 for issue in high_issues.iter().take(MAX_PREVIEW - shown) {
972 lines.push(format_hadolint_issue(issue, "🟠", ansi::HIGH));
973 shown += 1;
974 }
975 }
976
977 if let Some(quick_fixes) = v.get("quick_fixes").and_then(|q| q.as_array())
979 && let Some(first_fix) = quick_fixes.first().and_then(|f| f.as_str())
980 {
981 let truncated = if first_fix.len() > 70 {
982 format!("{}...", &first_fix[..67])
983 } else {
984 first_fix.to_string()
985 };
986 lines.push(format!(
987 "{} → Fix: {}{}",
988 ansi::INFO_BLUE,
989 truncated,
990 ansi::RESET
991 ));
992 }
993
994 let remaining = total as usize - shown;
996 if remaining > 0 {
997 lines.push(format!(
998 "{} +{} more issue{}{}",
999 ansi::GRAY,
1000 remaining,
1001 if remaining == 1 { "" } else { "s" },
1002 ansi::RESET
1003 ));
1004 }
1005
1006 (success, lines)
1007 } else {
1008 (false, vec!["parse error".to_string()])
1009 }
1010}
1011
1012fn format_hadolint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> String {
1014 let code = issue.get("code").and_then(|c| c.as_str()).unwrap_or("?");
1015 let message = issue.get("message").and_then(|m| m.as_str()).unwrap_or("?");
1016 let line_num = issue.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
1017 let category = issue.get("category").and_then(|c| c.as_str()).unwrap_or("");
1018
1019 let badge = match category {
1021 "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
1022 "best-practice" => format!("{}[BP]{}", ansi::INFO_BLUE, ansi::RESET),
1023 "deprecated" => format!("{}[DEP]{}", ansi::MEDIUM, ansi::RESET),
1024 "performance" => format!("{}[PERF]{}", ansi::CYAN, ansi::RESET),
1025 _ => String::new(),
1026 };
1027
1028 let msg_display = if message.len() > 50 {
1030 format!("{}...", &message[..47])
1031 } else {
1032 message.to_string()
1033 };
1034
1035 format!(
1036 "{}{} L{}:{} {}{}[{}]{} {} {}",
1037 color,
1038 icon,
1039 line_num,
1040 ansi::RESET,
1041 ansi::DOCKER_BLUE,
1042 ansi::BOLD,
1043 code,
1044 ansi::RESET,
1045 badge,
1046 msg_display
1047 )
1048}
1049
1050pub use crate::agent::ui::Spinner;
1052use tokio::sync::mpsc;
1053
1054#[derive(Debug, Clone)]
1056pub enum ToolEvent {
1057 ToolStart { name: String, args: String },
1058 ToolComplete { name: String, result: String },
1059}
1060
1061pub fn spawn_tool_display_handler(
1063 _receiver: mpsc::Receiver<ToolEvent>,
1064 _spinner: Arc<crate::agent::ui::Spinner>,
1065) -> tokio::task::JoinHandle<()> {
1066 tokio::spawn(async {})
1067}