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) = print_tool_result(&name, &args_str, &result_str);
157
158 let mut s = state.lock().await;
160 if let Some(idx) = s.current_tool_index {
161 if let Some(tool) = s.tool_calls.get_mut(idx) {
162 tool.output = Some(result_str);
163 tool.output_lines = output_lines;
164 tool.is_running = false;
165 tool.is_collapsible = is_collapsible;
166 tool.status_ok = status_ok;
167 }
168 if is_collapsible {
170 s.last_expandable_index = Some(idx);
171 }
172 }
173 s.current_tool_index = None;
174 }
175 }
176
177 fn on_completion_response(
178 &self,
179 _prompt: &Message,
180 response: &CompletionResponse<M::Response>,
181 _cancel: CancelSignal,
182 ) -> impl std::future::Future<Output = ()> + Send {
183 let state = self.state.clone();
184
185 let usage = response.usage.clone();
187
188 let has_tool_calls = response.choice.iter().any(|content| {
191 matches!(content, AssistantContent::ToolCall(_))
192 });
193
194 let reasoning_parts: Vec<String> = response.choice.iter()
196 .filter_map(|content| {
197 if let AssistantContent::Reasoning(Reasoning { reasoning, .. }) = content {
198 let text = reasoning.iter().cloned().collect::<Vec<_>>().join("\n");
200 if !text.trim().is_empty() {
201 Some(text)
202 } else {
203 None
204 }
205 } else {
206 None
207 }
208 })
209 .collect();
210
211 let text_parts: Vec<String> = response.choice.iter()
213 .filter_map(|content| {
214 if let AssistantContent::Text(text) = content {
215 let trimmed = text.text.trim();
217 if !trimmed.is_empty() {
218 Some(trimmed.to_string())
219 } else {
220 None
221 }
222 } else {
223 None
224 }
225 })
226 .collect();
227
228 async move {
229 {
231 let mut s = state.lock().await;
232 s.usage.add(&usage);
233 }
234
235 if !reasoning_parts.is_empty() {
237 let thinking_text = reasoning_parts.join("\n");
238
239 let mut s = state.lock().await;
241 s.agent_messages.push(thinking_text.clone());
242 drop(s);
243
244 print_agent_thinking(&thinking_text);
246 }
247
248 if !text_parts.is_empty() && has_tool_calls {
251 let thinking_text = text_parts.join("\n");
252
253 let mut s = state.lock().await;
255 s.agent_messages.push(thinking_text.clone());
256 drop(s);
257
258 print_agent_thinking(&thinking_text);
260 }
261 }
262 }
263}
264
265fn print_agent_thinking(text: &str) {
267 use crate::agent::ui::response::brand;
268
269 println!();
270
271 println!(
273 "{}{} 💭 Thinking...{}",
274 brand::CORAL,
275 brand::ITALIC,
276 brand::RESET
277 );
278
279 let mut in_code_block = false;
281
282 for line in text.lines() {
283 let trimmed = line.trim();
284
285 if trimmed.starts_with("```") {
287 if in_code_block {
288 println!("{} └────────────────────────────────────────────────────────┘{}", brand::LIGHT_PEACH, brand::RESET);
289 in_code_block = false;
290 } else {
291 let lang = trimmed.strip_prefix("```").unwrap_or("");
292 let lang_display = if lang.is_empty() { "code" } else { lang };
293 println!(
294 "{} ┌─ {}{}{} ────────────────────────────────────────────────────┐{}",
295 brand::LIGHT_PEACH, brand::CYAN, lang_display, brand::LIGHT_PEACH, brand::RESET
296 );
297 in_code_block = true;
298 }
299 continue;
300 }
301
302 if in_code_block {
303 println!("{} │ {}{}{} │", brand::LIGHT_PEACH, brand::CYAN, line, brand::RESET);
304 continue;
305 }
306
307 if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
309 let content = trimmed.strip_prefix("- ").or_else(|| trimmed.strip_prefix("* ")).unwrap_or(trimmed);
310 println!("{} {} {}{}", brand::PEACH, "•", format_thinking_inline(content), brand::RESET);
311 continue;
312 }
313
314 if trimmed.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)
316 && trimmed.chars().nth(1) == Some('.')
317 {
318 println!("{} {}{}", brand::PEACH, format_thinking_inline(trimmed), brand::RESET);
319 continue;
320 }
321
322 if trimmed.is_empty() {
324 println!();
325 } else {
326 let wrapped = wrap_text(trimmed, 76);
328 for wrapped_line in wrapped {
329 println!("{} {}{}", brand::PEACH, format_thinking_inline(&wrapped_line), brand::RESET);
330 }
331 }
332 }
333
334 println!();
335 let _ = io::stdout().flush();
336}
337
338fn format_thinking_inline(text: &str) -> String {
340 use crate::agent::ui::response::brand;
341
342 let mut result = String::new();
343 let chars: Vec<char> = text.chars().collect();
344 let mut i = 0;
345
346 while i < chars.len() {
347 if chars[i] == '`' && (i + 1 >= chars.len() || chars[i + 1] != '`') {
349 if let Some(end) = chars[i + 1..].iter().position(|&c| c == '`') {
350 let code_text: String = chars[i + 1..i + 1 + end].iter().collect();
351 result.push_str(brand::CYAN);
352 result.push('`');
353 result.push_str(&code_text);
354 result.push('`');
355 result.push_str(brand::RESET);
356 result.push_str(brand::PEACH);
357 i = i + 2 + end;
358 continue;
359 }
360 }
361
362 if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
364 if let Some(end_offset) = find_double_star(&chars, i + 2) {
365 let bold_text: String = chars[i + 2..i + 2 + end_offset].iter().collect();
366 result.push_str(brand::RESET);
367 result.push_str(brand::CORAL);
368 result.push_str(brand::BOLD);
369 result.push_str(&bold_text);
370 result.push_str(brand::RESET);
371 result.push_str(brand::PEACH);
372 i = i + 4 + end_offset;
373 continue;
374 }
375 }
376
377 result.push(chars[i]);
378 i += 1;
379 }
380
381 result
382}
383
384fn find_double_star(chars: &[char], start: usize) -> Option<usize> {
386 for i in start..chars.len().saturating_sub(1) {
387 if chars[i] == '*' && chars[i + 1] == '*' {
388 return Some(i - start);
389 }
390 }
391 None
392}
393
394fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
396 if text.len() <= max_width {
397 return vec![text.to_string()];
398 }
399
400 let mut lines = Vec::new();
401 let mut current_line = String::new();
402
403 for word in text.split_whitespace() {
404 if current_line.is_empty() {
405 current_line = word.to_string();
406 } else if current_line.len() + 1 + word.len() <= max_width {
407 current_line.push(' ');
408 current_line.push_str(word);
409 } else {
410 lines.push(current_line);
411 current_line = word.to_string();
412 }
413 }
414
415 if !current_line.is_empty() {
416 lines.push(current_line);
417 }
418
419 if lines.is_empty() {
420 lines.push(text.to_string());
421 }
422
423 lines
424}
425
426fn print_tool_header(name: &str, args: &str) {
428 let parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
429 let args_display = format_args_display(name, &parsed);
430
431 if args_display.is_empty() {
433 println!("\n{} {}", "●".yellow(), name.cyan().bold());
434 } else {
435 println!("\n{} {}({})", "●".yellow(), name.cyan().bold(), args_display.dimmed());
436 }
437
438 println!(" {} {}", "└".dimmed(), "Running...".dimmed());
440
441 let _ = io::stdout().flush();
442}
443
444fn print_tool_result(name: &str, args: &str, result: &str) -> (bool, Vec<String>, bool) {
447 print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
449 let _ = io::stdout().flush();
450
451 let parsed: Result<serde_json::Value, _> = serde_json::from_str(result)
453 .map(|v: serde_json::Value| {
454 if let Some(inner_str) = v.as_str() {
457 serde_json::from_str(inner_str).unwrap_or(v)
458 } else {
459 v
460 }
461 });
462
463 let (status_ok, output_lines) = match name {
465 "shell" => format_shell_result(&parsed),
466 "write_file" | "write_files" => format_write_result(&parsed),
467 "read_file" => format_read_result(&parsed),
468 "list_directory" => format_list_result(&parsed),
469 "analyze_project" => format_analyze_result(&parsed),
470 "security_scan" | "check_vulnerabilities" => format_security_result(&parsed),
471 "hadolint" => format_hadolint_result(&parsed),
472 _ => (true, vec!["done".to_string()]),
473 };
474
475 print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
477
478 let dot = if status_ok { "●".green() } else { "●".red() };
480
481 let args_parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
483 let args_display = format_args_display(name, &args_parsed);
484
485 if args_display.is_empty() {
486 println!("{} {}", dot, name.cyan().bold());
487 } else {
488 println!("{} {}({})", dot, name.cyan().bold(), args_display.dimmed());
489 }
490
491 let total_lines = output_lines.len();
493 let is_collapsible = total_lines > PREVIEW_LINES;
494
495 for (i, line) in output_lines.iter().take(PREVIEW_LINES).enumerate() {
496 let prefix = if i == output_lines.len().min(PREVIEW_LINES) - 1 && !is_collapsible {
497 "└"
498 } else {
499 "│"
500 };
501 println!(" {} {}", prefix.dimmed(), line);
502 }
503
504 if is_collapsible {
506 println!(
507 " {} {}",
508 "└".dimmed(),
509 format!("+{} more lines", total_lines - PREVIEW_LINES).dimmed()
510 );
511 }
512
513 let _ = io::stdout().flush();
514 (status_ok, output_lines, is_collapsible)
515}
516
517fn format_args_display(name: &str, parsed: &Result<serde_json::Value, serde_json::Error>) -> String {
519 match name {
520 "shell" => {
521 if let Ok(v) = parsed {
522 v.get("command").and_then(|c| c.as_str()).unwrap_or("").to_string()
523 } else {
524 String::new()
525 }
526 }
527 "write_file" => {
528 if let Ok(v) = parsed {
529 v.get("path").and_then(|p| p.as_str()).unwrap_or("").to_string()
530 } else {
531 String::new()
532 }
533 }
534 "write_files" => {
535 if let Ok(v) = parsed {
536 if let Some(files) = v.get("files").and_then(|f| f.as_array()) {
537 let paths: Vec<&str> = files
538 .iter()
539 .filter_map(|f| f.get("path").and_then(|p| p.as_str()))
540 .take(3)
541 .collect();
542 let more = if files.len() > 3 {
543 format!(", +{} more", files.len() - 3)
544 } else {
545 String::new()
546 };
547 format!("{}{}", paths.join(", "), more)
548 } else {
549 String::new()
550 }
551 } else {
552 String::new()
553 }
554 }
555 "read_file" => {
556 if let Ok(v) = parsed {
557 v.get("path").and_then(|p| p.as_str()).unwrap_or("").to_string()
558 } else {
559 String::new()
560 }
561 }
562 "list_directory" => {
563 if let Ok(v) = parsed {
564 v.get("path").and_then(|p| p.as_str()).unwrap_or(".").to_string()
565 } else {
566 ".".to_string()
567 }
568 }
569 _ => String::new(),
570 }
571}
572
573fn format_shell_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
575 if let Ok(v) = parsed {
576 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
577 let stdout = v.get("stdout").and_then(|s| s.as_str()).unwrap_or("");
578 let stderr = v.get("stderr").and_then(|s| s.as_str()).unwrap_or("");
579 let exit_code = v.get("exit_code").and_then(|c| c.as_i64());
580
581 let mut lines = Vec::new();
582
583 for line in stdout.lines() {
585 if !line.trim().is_empty() {
586 lines.push(line.to_string());
587 }
588 }
589
590 if !success {
592 for line in stderr.lines() {
593 if !line.trim().is_empty() {
594 lines.push(format!("{}", line.red()));
595 }
596 }
597 if let Some(code) = exit_code {
598 lines.push(format!("exit code: {}", code).red().to_string());
599 }
600 }
601
602 if lines.is_empty() {
603 lines.push(if success { "completed".to_string() } else { "failed".to_string() });
604 }
605
606 (success, lines)
607 } else {
608 (false, vec!["parse error".to_string()])
609 }
610}
611
612fn format_write_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
614 if let Ok(v) = parsed {
615 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
616 let action = v.get("action").and_then(|a| a.as_str()).unwrap_or("wrote");
617 let lines_written = v.get("lines_written")
618 .or_else(|| v.get("total_lines"))
619 .and_then(|n| n.as_u64())
620 .unwrap_or(0);
621 let files_written = v.get("files_written").and_then(|n| n.as_u64()).unwrap_or(1);
622
623 let msg = if files_written > 1 {
624 format!("{} {} files ({} lines)", action, files_written, lines_written)
625 } else {
626 format!("{} ({} lines)", action, lines_written)
627 };
628
629 (success, vec![msg])
630 } else {
631 (false, vec!["write failed".to_string()])
632 }
633}
634
635fn format_read_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
637 if let Ok(v) = parsed {
638 if v.get("error").is_some() {
640 let error_msg = v.get("error").and_then(|e| e.as_str()).unwrap_or("file not found");
641 return (false, vec![error_msg.to_string()]);
642 }
643
644 if let Some(total_lines) = v.get("total_lines").and_then(|n| n.as_u64()) {
646 let msg = if total_lines == 1 {
647 "read 1 line".to_string()
648 } else {
649 format!("read {} lines", total_lines)
650 };
651 return (true, vec![msg]);
652 }
653
654 if let Some(content) = v.get("content").and_then(|c| c.as_str()) {
657 let lines = content.lines().count();
658 return (true, vec![format!("read {} lines", lines)]);
659 }
660
661 if v.is_string() {
663 return (true, vec!["read file".to_string()]);
665 }
666
667 (true, vec!["read file".to_string()])
668 } else {
669 (false, vec!["read failed".to_string()])
670 }
671}
672
673fn format_list_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
675 if let Ok(v) = parsed {
676 let entries = v.get("entries").and_then(|e| e.as_array());
677
678 let mut lines = Vec::new();
679
680 if let Some(entries) = entries {
681 let total = entries.len();
682 for entry in entries.iter().take(PREVIEW_LINES + 2) {
683 let name = entry.get("name").and_then(|n| n.as_str()).unwrap_or("?");
684 let entry_type = entry.get("type").and_then(|t| t.as_str()).unwrap_or("file");
685 let prefix = if entry_type == "directory" { "📁" } else { "📄" };
686 lines.push(format!("{} {}", prefix, name));
687 }
688 if total > PREVIEW_LINES + 2 {
690 lines.push(format!("... and {} more", total - (PREVIEW_LINES + 2)));
691 }
692 }
693
694 if lines.is_empty() {
695 lines.push("empty directory".to_string());
696 }
697
698 (true, lines)
699 } else {
700 (false, vec!["parse error".to_string()])
701 }
702}
703
704fn format_analyze_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
706 if let Ok(v) = parsed {
707 let mut lines = Vec::new();
708
709 if let Some(langs) = v.get("languages").and_then(|l| l.as_array()) {
711 let lang_names: Vec<&str> = langs
712 .iter()
713 .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
714 .take(5)
715 .collect();
716 if !lang_names.is_empty() {
717 lines.push(format!("Languages: {}", lang_names.join(", ")));
718 }
719 }
720
721 if let Some(frameworks) = v.get("frameworks").and_then(|f| f.as_array()) {
723 let fw_names: Vec<&str> = frameworks
724 .iter()
725 .filter_map(|f| f.get("name").and_then(|n| n.as_str()))
726 .take(5)
727 .collect();
728 if !fw_names.is_empty() {
729 lines.push(format!("Frameworks: {}", fw_names.join(", ")));
730 }
731 }
732
733 if lines.is_empty() {
734 lines.push("analysis complete".to_string());
735 }
736
737 (true, lines)
738 } else {
739 (false, vec!["parse error".to_string()])
740 }
741}
742
743fn format_security_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
745 if let Ok(v) = parsed {
746 let findings = v.get("findings")
747 .or_else(|| v.get("vulnerabilities"))
748 .and_then(|f| f.as_array())
749 .map(|a| a.len())
750 .unwrap_or(0);
751
752 if findings == 0 {
753 (true, vec!["no issues found".to_string()])
754 } else {
755 (false, vec![format!("{} issues found", findings)])
756 }
757 } else {
758 (false, vec!["parse error".to_string()])
759 }
760}
761
762fn format_hadolint_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
764 if let Ok(v) = parsed {
765 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true);
766 let summary = v.get("summary");
767 let action_plan = v.get("action_plan");
768
769 let mut lines = Vec::new();
770
771 let total = summary
773 .and_then(|s| s.get("total"))
774 .and_then(|t| t.as_u64())
775 .unwrap_or(0);
776
777 if total == 0 {
779 lines.push(format!(
780 "{}🐳 Dockerfile OK - no issues found{}",
781 ansi::SUCCESS, ansi::RESET
782 ));
783 return (true, lines);
784 }
785
786 let critical = summary
788 .and_then(|s| s.get("by_priority"))
789 .and_then(|p| p.get("critical"))
790 .and_then(|c| c.as_u64())
791 .unwrap_or(0);
792 let high = summary
793 .and_then(|s| s.get("by_priority"))
794 .and_then(|p| p.get("high"))
795 .and_then(|h| h.as_u64())
796 .unwrap_or(0);
797 let medium = summary
798 .and_then(|s| s.get("by_priority"))
799 .and_then(|p| p.get("medium"))
800 .and_then(|m| m.as_u64())
801 .unwrap_or(0);
802 let low = summary
803 .and_then(|s| s.get("by_priority"))
804 .and_then(|p| p.get("low"))
805 .and_then(|l| l.as_u64())
806 .unwrap_or(0);
807
808 let mut priority_parts = Vec::new();
810 if critical > 0 {
811 priority_parts.push(format!("{}🔴 {} critical{}", ansi::CRITICAL, critical, ansi::RESET));
812 }
813 if high > 0 {
814 priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET));
815 }
816 if medium > 0 {
817 priority_parts.push(format!("{}🟡 {} medium{}", ansi::MEDIUM, medium, ansi::RESET));
818 }
819 if low > 0 {
820 priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET));
821 }
822
823 let header_color = if critical > 0 {
824 ansi::CRITICAL
825 } else if high > 0 {
826 ansi::HIGH
827 } else {
828 ansi::DOCKER_BLUE
829 };
830
831 lines.push(format!(
832 "{}🐳 {} issue{} found: {}{}",
833 header_color,
834 total,
835 if total == 1 { "" } else { "s" },
836 priority_parts.join(" "),
837 ansi::RESET
838 ));
839
840 let mut shown = 0;
842 const MAX_PREVIEW: usize = 6;
843
844 if let Some(critical_issues) = action_plan
846 .and_then(|a| a.get("critical"))
847 .and_then(|c| c.as_array())
848 {
849 for issue in critical_issues.iter().take(MAX_PREVIEW - shown) {
850 lines.push(format_hadolint_issue(issue, "🔴", ansi::CRITICAL));
851 shown += 1;
852 }
853 }
854
855 if shown < MAX_PREVIEW {
857 if let Some(high_issues) = action_plan
858 .and_then(|a| a.get("high"))
859 .and_then(|h| h.as_array())
860 {
861 for issue in high_issues.iter().take(MAX_PREVIEW - shown) {
862 lines.push(format_hadolint_issue(issue, "🟠", ansi::HIGH));
863 shown += 1;
864 }
865 }
866 }
867
868 if let Some(quick_fixes) = v.get("quick_fixes").and_then(|q| q.as_array()) {
870 if let Some(first_fix) = quick_fixes.first().and_then(|f| f.as_str()) {
871 let truncated = if first_fix.len() > 70 {
872 format!("{}...", &first_fix[..67])
873 } else {
874 first_fix.to_string()
875 };
876 lines.push(format!(
877 "{} → Fix: {}{}",
878 ansi::INFO_BLUE, truncated, ansi::RESET
879 ));
880 }
881 }
882
883 let remaining = total as usize - shown;
885 if remaining > 0 {
886 lines.push(format!(
887 "{} +{} more issue{}{}",
888 ansi::GRAY,
889 remaining,
890 if remaining == 1 { "" } else { "s" },
891 ansi::RESET
892 ));
893 }
894
895 (success, lines)
896 } else {
897 (false, vec!["parse error".to_string()])
898 }
899}
900
901fn format_hadolint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> String {
903 let code = issue.get("code").and_then(|c| c.as_str()).unwrap_or("?");
904 let message = issue.get("message").and_then(|m| m.as_str()).unwrap_or("?");
905 let line_num = issue.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
906 let category = issue.get("category").and_then(|c| c.as_str()).unwrap_or("");
907
908 let badge = match category {
910 "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
911 "best-practice" => format!("{}[BP]{}", ansi::INFO_BLUE, ansi::RESET),
912 "deprecated" => format!("{}[DEP]{}", ansi::MEDIUM, ansi::RESET),
913 "performance" => format!("{}[PERF]{}", ansi::CYAN, ansi::RESET),
914 _ => String::new(),
915 };
916
917 let msg_display = if message.len() > 50 {
919 format!("{}...", &message[..47])
920 } else {
921 message.to_string()
922 };
923
924 format!(
925 "{}{} L{}:{} {}{}[{}]{} {} {}",
926 color, icon, line_num, ansi::RESET,
927 ansi::DOCKER_BLUE, ansi::BOLD, code, ansi::RESET,
928 badge,
929 msg_display
930 )
931}
932
933pub use crate::agent::ui::Spinner;
935use tokio::sync::mpsc;
936
937#[derive(Debug, Clone)]
939pub enum ToolEvent {
940 ToolStart { name: String, args: String },
941 ToolComplete { name: String, result: String },
942}
943
944pub fn spawn_tool_display_handler(
946 _receiver: mpsc::Receiver<ToolEvent>,
947 _spinner: Arc<crate::agent::ui::Spinner>,
948) -> tokio::task::JoinHandle<()> {
949 tokio::spawn(async {})
950}