1use crate::agent::ui::colors::ansi;
11use colored::Colorize;
12use rig::agent::CancelSignal;
13use rig::completion::{CompletionModel, CompletionResponse, Message};
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)]
37pub struct DisplayState {
38 pub tool_calls: Vec<ToolCallState>,
39 pub agent_messages: Vec<String>,
40 pub current_tool_index: Option<usize>,
41 pub last_expandable_index: Option<usize>,
42}
43
44#[derive(Clone)]
46pub struct ToolDisplayHook {
47 state: Arc<Mutex<DisplayState>>,
48}
49
50impl ToolDisplayHook {
51 pub fn new() -> Self {
52 Self {
53 state: Arc::new(Mutex::new(DisplayState::default())),
54 }
55 }
56
57 pub fn state(&self) -> Arc<Mutex<DisplayState>> {
59 self.state.clone()
60 }
61}
62
63impl Default for ToolDisplayHook {
64 fn default() -> Self {
65 Self::new()
66 }
67}
68
69impl<M> rig::agent::PromptHook<M> for ToolDisplayHook
70where
71 M: CompletionModel,
72{
73 fn on_tool_call(
74 &self,
75 tool_name: &str,
76 args: &str,
77 _cancel: CancelSignal,
78 ) -> impl std::future::Future<Output = ()> + Send {
79 let state = self.state.clone();
80 let name = tool_name.to_string();
81 let args_str = args.to_string();
82
83 async move {
84 print_tool_header(&name, &args_str);
86
87 let mut s = state.lock().await;
89 let idx = s.tool_calls.len();
90 s.tool_calls.push(ToolCallState {
91 name,
92 args: args_str,
93 output: None,
94 output_lines: Vec::new(),
95 is_running: true,
96 is_expanded: false,
97 is_collapsible: false,
98 status_ok: true,
99 });
100 s.current_tool_index = Some(idx);
101 }
102 }
103
104 fn on_tool_result(
105 &self,
106 tool_name: &str,
107 args: &str,
108 result: &str,
109 _cancel: CancelSignal,
110 ) -> impl std::future::Future<Output = ()> + Send {
111 let state = self.state.clone();
112 let name = tool_name.to_string();
113 let args_str = args.to_string();
114 let result_str = result.to_string();
115
116 async move {
117 let (status_ok, output_lines, is_collapsible) = print_tool_result(&name, &args_str, &result_str);
119
120 let mut s = state.lock().await;
122 if let Some(idx) = s.current_tool_index {
123 if let Some(tool) = s.tool_calls.get_mut(idx) {
124 tool.output = Some(result_str);
125 tool.output_lines = output_lines;
126 tool.is_running = false;
127 tool.is_collapsible = is_collapsible;
128 tool.status_ok = status_ok;
129 }
130 if is_collapsible {
132 s.last_expandable_index = Some(idx);
133 }
134 }
135 s.current_tool_index = None;
136 }
137 }
138
139 fn on_completion_response(
140 &self,
141 _prompt: &Message,
142 response: &CompletionResponse<M::Response>,
143 _cancel: CancelSignal,
144 ) -> impl std::future::Future<Output = ()> + Send {
145 let state = self.state.clone();
146
147 let has_tool_calls = response.choice.iter().any(|content| {
150 matches!(content, AssistantContent::ToolCall(_))
151 });
152
153 let reasoning_parts: Vec<String> = response.choice.iter()
155 .filter_map(|content| {
156 if let AssistantContent::Reasoning(Reasoning { reasoning, .. }) = content {
157 let text = reasoning.iter().cloned().collect::<Vec<_>>().join("\n");
159 if !text.trim().is_empty() {
160 Some(text)
161 } else {
162 None
163 }
164 } else {
165 None
166 }
167 })
168 .collect();
169
170 let text_parts: Vec<String> = response.choice.iter()
172 .filter_map(|content| {
173 if let AssistantContent::Text(text) = content {
174 let trimmed = text.text.trim();
176 if !trimmed.is_empty() {
177 Some(trimmed.to_string())
178 } else {
179 None
180 }
181 } else {
182 None
183 }
184 })
185 .collect();
186
187 async move {
188 if !reasoning_parts.is_empty() {
190 let thinking_text = reasoning_parts.join("\n");
191
192 let mut s = state.lock().await;
194 s.agent_messages.push(thinking_text.clone());
195 drop(s);
196
197 print_agent_thinking(&thinking_text);
199 }
200
201 if !text_parts.is_empty() && has_tool_calls {
204 let thinking_text = text_parts.join("\n");
205
206 let mut s = state.lock().await;
208 s.agent_messages.push(thinking_text.clone());
209 drop(s);
210
211 print_agent_thinking(&thinking_text);
213 }
214 }
215 }
216}
217
218fn print_agent_thinking(text: &str) {
220 use crate::agent::ui::response::brand;
221
222 println!();
223
224 println!(
226 "{}{} 💭 Thinking...{}",
227 brand::CORAL,
228 brand::ITALIC,
229 brand::RESET
230 );
231
232 let mut in_code_block = false;
234
235 for line in text.lines() {
236 let trimmed = line.trim();
237
238 if trimmed.starts_with("```") {
240 if in_code_block {
241 println!("{} └────────────────────────────────────────────────────────┘{}", brand::LIGHT_PEACH, brand::RESET);
242 in_code_block = false;
243 } else {
244 let lang = trimmed.strip_prefix("```").unwrap_or("");
245 let lang_display = if lang.is_empty() { "code" } else { lang };
246 println!(
247 "{} ┌─ {}{}{} ────────────────────────────────────────────────────┐{}",
248 brand::LIGHT_PEACH, brand::CYAN, lang_display, brand::LIGHT_PEACH, brand::RESET
249 );
250 in_code_block = true;
251 }
252 continue;
253 }
254
255 if in_code_block {
256 println!("{} │ {}{}{} │", brand::LIGHT_PEACH, brand::CYAN, line, brand::RESET);
257 continue;
258 }
259
260 if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
262 let content = trimmed.strip_prefix("- ").or_else(|| trimmed.strip_prefix("* ")).unwrap_or(trimmed);
263 println!("{} {} {}{}", brand::PEACH, "•", format_thinking_inline(content), brand::RESET);
264 continue;
265 }
266
267 if trimmed.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)
269 && trimmed.chars().nth(1) == Some('.')
270 {
271 println!("{} {}{}", brand::PEACH, format_thinking_inline(trimmed), brand::RESET);
272 continue;
273 }
274
275 if trimmed.is_empty() {
277 println!();
278 } else {
279 let wrapped = wrap_text(trimmed, 76);
281 for wrapped_line in wrapped {
282 println!("{} {}{}", brand::PEACH, format_thinking_inline(&wrapped_line), brand::RESET);
283 }
284 }
285 }
286
287 println!();
288 let _ = io::stdout().flush();
289}
290
291fn format_thinking_inline(text: &str) -> String {
293 use crate::agent::ui::response::brand;
294
295 let mut result = String::new();
296 let chars: Vec<char> = text.chars().collect();
297 let mut i = 0;
298
299 while i < chars.len() {
300 if chars[i] == '`' && (i + 1 >= chars.len() || chars[i + 1] != '`') {
302 if let Some(end) = chars[i + 1..].iter().position(|&c| c == '`') {
303 let code_text: String = chars[i + 1..i + 1 + end].iter().collect();
304 result.push_str(brand::CYAN);
305 result.push('`');
306 result.push_str(&code_text);
307 result.push('`');
308 result.push_str(brand::RESET);
309 result.push_str(brand::PEACH);
310 i = i + 2 + end;
311 continue;
312 }
313 }
314
315 if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
317 if let Some(end_offset) = find_double_star(&chars, i + 2) {
318 let bold_text: String = chars[i + 2..i + 2 + end_offset].iter().collect();
319 result.push_str(brand::RESET);
320 result.push_str(brand::CORAL);
321 result.push_str(brand::BOLD);
322 result.push_str(&bold_text);
323 result.push_str(brand::RESET);
324 result.push_str(brand::PEACH);
325 i = i + 4 + end_offset;
326 continue;
327 }
328 }
329
330 result.push(chars[i]);
331 i += 1;
332 }
333
334 result
335}
336
337fn find_double_star(chars: &[char], start: usize) -> Option<usize> {
339 for i in start..chars.len().saturating_sub(1) {
340 if chars[i] == '*' && chars[i + 1] == '*' {
341 return Some(i - start);
342 }
343 }
344 None
345}
346
347fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
349 if text.len() <= max_width {
350 return vec![text.to_string()];
351 }
352
353 let mut lines = Vec::new();
354 let mut current_line = String::new();
355
356 for word in text.split_whitespace() {
357 if current_line.is_empty() {
358 current_line = word.to_string();
359 } else if current_line.len() + 1 + word.len() <= max_width {
360 current_line.push(' ');
361 current_line.push_str(word);
362 } else {
363 lines.push(current_line);
364 current_line = word.to_string();
365 }
366 }
367
368 if !current_line.is_empty() {
369 lines.push(current_line);
370 }
371
372 if lines.is_empty() {
373 lines.push(text.to_string());
374 }
375
376 lines
377}
378
379fn print_tool_header(name: &str, args: &str) {
381 let parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
382 let args_display = format_args_display(name, &parsed);
383
384 if args_display.is_empty() {
386 println!("\n{} {}", "●".yellow(), name.cyan().bold());
387 } else {
388 println!("\n{} {}({})", "●".yellow(), name.cyan().bold(), args_display.dimmed());
389 }
390
391 println!(" {} {}", "└".dimmed(), "Running...".dimmed());
393
394 let _ = io::stdout().flush();
395}
396
397fn print_tool_result(name: &str, args: &str, result: &str) -> (bool, Vec<String>, bool) {
400 print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
402 let _ = io::stdout().flush();
403
404 let parsed: Result<serde_json::Value, _> = serde_json::from_str(result)
406 .map(|v: serde_json::Value| {
407 if let Some(inner_str) = v.as_str() {
410 serde_json::from_str(inner_str).unwrap_or(v)
411 } else {
412 v
413 }
414 });
415
416 let (status_ok, output_lines) = match name {
418 "shell" => format_shell_result(&parsed),
419 "write_file" | "write_files" => format_write_result(&parsed),
420 "read_file" => format_read_result(&parsed),
421 "list_directory" => format_list_result(&parsed),
422 "analyze_project" => format_analyze_result(&parsed),
423 "security_scan" | "check_vulnerabilities" => format_security_result(&parsed),
424 "hadolint" => format_hadolint_result(&parsed),
425 _ => (true, vec!["done".to_string()]),
426 };
427
428 print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
430
431 let dot = if status_ok { "●".green() } else { "●".red() };
433
434 let args_parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
436 let args_display = format_args_display(name, &args_parsed);
437
438 if args_display.is_empty() {
439 println!("{} {}", dot, name.cyan().bold());
440 } else {
441 println!("{} {}({})", dot, name.cyan().bold(), args_display.dimmed());
442 }
443
444 let total_lines = output_lines.len();
446 let is_collapsible = total_lines > PREVIEW_LINES;
447
448 for (i, line) in output_lines.iter().take(PREVIEW_LINES).enumerate() {
449 let prefix = if i == output_lines.len().min(PREVIEW_LINES) - 1 && !is_collapsible {
450 "└"
451 } else {
452 "│"
453 };
454 println!(" {} {}", prefix.dimmed(), line);
455 }
456
457 if is_collapsible {
459 println!(
460 " {} {}",
461 "└".dimmed(),
462 format!("+{} more lines", total_lines - PREVIEW_LINES).dimmed()
463 );
464 }
465
466 let _ = io::stdout().flush();
467 (status_ok, output_lines, is_collapsible)
468}
469
470fn format_args_display(name: &str, parsed: &Result<serde_json::Value, serde_json::Error>) -> String {
472 match name {
473 "shell" => {
474 if let Ok(v) = parsed {
475 v.get("command").and_then(|c| c.as_str()).unwrap_or("").to_string()
476 } else {
477 String::new()
478 }
479 }
480 "write_file" => {
481 if let Ok(v) = parsed {
482 v.get("path").and_then(|p| p.as_str()).unwrap_or("").to_string()
483 } else {
484 String::new()
485 }
486 }
487 "write_files" => {
488 if let Ok(v) = parsed {
489 if let Some(files) = v.get("files").and_then(|f| f.as_array()) {
490 let paths: Vec<&str> = files
491 .iter()
492 .filter_map(|f| f.get("path").and_then(|p| p.as_str()))
493 .take(3)
494 .collect();
495 let more = if files.len() > 3 {
496 format!(", +{} more", files.len() - 3)
497 } else {
498 String::new()
499 };
500 format!("{}{}", paths.join(", "), more)
501 } else {
502 String::new()
503 }
504 } else {
505 String::new()
506 }
507 }
508 "read_file" => {
509 if let Ok(v) = parsed {
510 v.get("path").and_then(|p| p.as_str()).unwrap_or("").to_string()
511 } else {
512 String::new()
513 }
514 }
515 "list_directory" => {
516 if let Ok(v) = parsed {
517 v.get("path").and_then(|p| p.as_str()).unwrap_or(".").to_string()
518 } else {
519 ".".to_string()
520 }
521 }
522 _ => String::new(),
523 }
524}
525
526fn format_shell_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
528 if let Ok(v) = parsed {
529 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
530 let stdout = v.get("stdout").and_then(|s| s.as_str()).unwrap_or("");
531 let stderr = v.get("stderr").and_then(|s| s.as_str()).unwrap_or("");
532 let exit_code = v.get("exit_code").and_then(|c| c.as_i64());
533
534 let mut lines = Vec::new();
535
536 for line in stdout.lines() {
538 if !line.trim().is_empty() {
539 lines.push(line.to_string());
540 }
541 }
542
543 if !success {
545 for line in stderr.lines() {
546 if !line.trim().is_empty() {
547 lines.push(format!("{}", line.red()));
548 }
549 }
550 if let Some(code) = exit_code {
551 lines.push(format!("exit code: {}", code).red().to_string());
552 }
553 }
554
555 if lines.is_empty() {
556 lines.push(if success { "completed".to_string() } else { "failed".to_string() });
557 }
558
559 (success, lines)
560 } else {
561 (false, vec!["parse error".to_string()])
562 }
563}
564
565fn format_write_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
567 if let Ok(v) = parsed {
568 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
569 let action = v.get("action").and_then(|a| a.as_str()).unwrap_or("wrote");
570 let lines_written = v.get("lines_written")
571 .or_else(|| v.get("total_lines"))
572 .and_then(|n| n.as_u64())
573 .unwrap_or(0);
574 let files_written = v.get("files_written").and_then(|n| n.as_u64()).unwrap_or(1);
575
576 let msg = if files_written > 1 {
577 format!("{} {} files ({} lines)", action, files_written, lines_written)
578 } else {
579 format!("{} ({} lines)", action, lines_written)
580 };
581
582 (success, vec![msg])
583 } else {
584 (false, vec!["write failed".to_string()])
585 }
586}
587
588fn format_read_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
590 if let Ok(v) = parsed {
591 if v.get("error").is_some() {
593 let error_msg = v.get("error").and_then(|e| e.as_str()).unwrap_or("file not found");
594 return (false, vec![error_msg.to_string()]);
595 }
596
597 if let Some(total_lines) = v.get("total_lines").and_then(|n| n.as_u64()) {
599 let msg = if total_lines == 1 {
600 "read 1 line".to_string()
601 } else {
602 format!("read {} lines", total_lines)
603 };
604 return (true, vec![msg]);
605 }
606
607 if let Some(content) = v.get("content").and_then(|c| c.as_str()) {
610 let lines = content.lines().count();
611 return (true, vec![format!("read {} lines", lines)]);
612 }
613
614 if v.is_string() {
616 return (true, vec!["read file".to_string()]);
618 }
619
620 (true, vec!["read file".to_string()])
621 } else {
622 (false, vec!["read failed".to_string()])
623 }
624}
625
626fn format_list_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
628 if let Ok(v) = parsed {
629 let entries = v.get("entries").and_then(|e| e.as_array());
630
631 let mut lines = Vec::new();
632
633 if let Some(entries) = entries {
634 let total = entries.len();
635 for entry in entries.iter().take(PREVIEW_LINES + 2) {
636 let name = entry.get("name").and_then(|n| n.as_str()).unwrap_or("?");
637 let entry_type = entry.get("type").and_then(|t| t.as_str()).unwrap_or("file");
638 let prefix = if entry_type == "directory" { "📁" } else { "📄" };
639 lines.push(format!("{} {}", prefix, name));
640 }
641 if total > PREVIEW_LINES + 2 {
643 lines.push(format!("... and {} more", total - (PREVIEW_LINES + 2)));
644 }
645 }
646
647 if lines.is_empty() {
648 lines.push("empty directory".to_string());
649 }
650
651 (true, lines)
652 } else {
653 (false, vec!["parse error".to_string()])
654 }
655}
656
657fn format_analyze_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
659 if let Ok(v) = parsed {
660 let mut lines = Vec::new();
661
662 if let Some(langs) = v.get("languages").and_then(|l| l.as_array()) {
664 let lang_names: Vec<&str> = langs
665 .iter()
666 .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
667 .take(5)
668 .collect();
669 if !lang_names.is_empty() {
670 lines.push(format!("Languages: {}", lang_names.join(", ")));
671 }
672 }
673
674 if let Some(frameworks) = v.get("frameworks").and_then(|f| f.as_array()) {
676 let fw_names: Vec<&str> = frameworks
677 .iter()
678 .filter_map(|f| f.get("name").and_then(|n| n.as_str()))
679 .take(5)
680 .collect();
681 if !fw_names.is_empty() {
682 lines.push(format!("Frameworks: {}", fw_names.join(", ")));
683 }
684 }
685
686 if lines.is_empty() {
687 lines.push("analysis complete".to_string());
688 }
689
690 (true, lines)
691 } else {
692 (false, vec!["parse error".to_string()])
693 }
694}
695
696fn format_security_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
698 if let Ok(v) = parsed {
699 let findings = v.get("findings")
700 .or_else(|| v.get("vulnerabilities"))
701 .and_then(|f| f.as_array())
702 .map(|a| a.len())
703 .unwrap_or(0);
704
705 if findings == 0 {
706 (true, vec!["no issues found".to_string()])
707 } else {
708 (false, vec![format!("{} issues found", findings)])
709 }
710 } else {
711 (false, vec!["parse error".to_string()])
712 }
713}
714
715fn format_hadolint_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
717 if let Ok(v) = parsed {
718 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true);
719 let summary = v.get("summary");
720 let action_plan = v.get("action_plan");
721
722 let mut lines = Vec::new();
723
724 let total = summary
726 .and_then(|s| s.get("total"))
727 .and_then(|t| t.as_u64())
728 .unwrap_or(0);
729
730 if total == 0 {
732 lines.push(format!(
733 "{}🐳 Dockerfile OK - no issues found{}",
734 ansi::SUCCESS, ansi::RESET
735 ));
736 return (true, lines);
737 }
738
739 let critical = summary
741 .and_then(|s| s.get("by_priority"))
742 .and_then(|p| p.get("critical"))
743 .and_then(|c| c.as_u64())
744 .unwrap_or(0);
745 let high = summary
746 .and_then(|s| s.get("by_priority"))
747 .and_then(|p| p.get("high"))
748 .and_then(|h| h.as_u64())
749 .unwrap_or(0);
750 let medium = summary
751 .and_then(|s| s.get("by_priority"))
752 .and_then(|p| p.get("medium"))
753 .and_then(|m| m.as_u64())
754 .unwrap_or(0);
755 let low = summary
756 .and_then(|s| s.get("by_priority"))
757 .and_then(|p| p.get("low"))
758 .and_then(|l| l.as_u64())
759 .unwrap_or(0);
760
761 let mut priority_parts = Vec::new();
763 if critical > 0 {
764 priority_parts.push(format!("{}🔴 {} critical{}", ansi::CRITICAL, critical, ansi::RESET));
765 }
766 if high > 0 {
767 priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET));
768 }
769 if medium > 0 {
770 priority_parts.push(format!("{}🟡 {} medium{}", ansi::MEDIUM, medium, ansi::RESET));
771 }
772 if low > 0 {
773 priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET));
774 }
775
776 let header_color = if critical > 0 {
777 ansi::CRITICAL
778 } else if high > 0 {
779 ansi::HIGH
780 } else {
781 ansi::DOCKER_BLUE
782 };
783
784 lines.push(format!(
785 "{}🐳 {} issue{} found: {}{}",
786 header_color,
787 total,
788 if total == 1 { "" } else { "s" },
789 priority_parts.join(" "),
790 ansi::RESET
791 ));
792
793 let mut shown = 0;
795 const MAX_PREVIEW: usize = 6;
796
797 if let Some(critical_issues) = action_plan
799 .and_then(|a| a.get("critical"))
800 .and_then(|c| c.as_array())
801 {
802 for issue in critical_issues.iter().take(MAX_PREVIEW - shown) {
803 lines.push(format_hadolint_issue(issue, "🔴", ansi::CRITICAL));
804 shown += 1;
805 }
806 }
807
808 if shown < MAX_PREVIEW {
810 if let Some(high_issues) = action_plan
811 .and_then(|a| a.get("high"))
812 .and_then(|h| h.as_array())
813 {
814 for issue in high_issues.iter().take(MAX_PREVIEW - shown) {
815 lines.push(format_hadolint_issue(issue, "🟠", ansi::HIGH));
816 shown += 1;
817 }
818 }
819 }
820
821 if let Some(quick_fixes) = v.get("quick_fixes").and_then(|q| q.as_array()) {
823 if let Some(first_fix) = quick_fixes.first().and_then(|f| f.as_str()) {
824 let truncated = if first_fix.len() > 70 {
825 format!("{}...", &first_fix[..67])
826 } else {
827 first_fix.to_string()
828 };
829 lines.push(format!(
830 "{} → Fix: {}{}",
831 ansi::INFO_BLUE, truncated, ansi::RESET
832 ));
833 }
834 }
835
836 let remaining = total as usize - shown;
838 if remaining > 0 {
839 lines.push(format!(
840 "{} +{} more issue{}{}",
841 ansi::GRAY,
842 remaining,
843 if remaining == 1 { "" } else { "s" },
844 ansi::RESET
845 ));
846 }
847
848 (success, lines)
849 } else {
850 (false, vec!["parse error".to_string()])
851 }
852}
853
854fn format_hadolint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> String {
856 let code = issue.get("code").and_then(|c| c.as_str()).unwrap_or("?");
857 let message = issue.get("message").and_then(|m| m.as_str()).unwrap_or("?");
858 let line_num = issue.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
859 let category = issue.get("category").and_then(|c| c.as_str()).unwrap_or("");
860
861 let badge = match category {
863 "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
864 "best-practice" => format!("{}[BP]{}", ansi::INFO_BLUE, ansi::RESET),
865 "deprecated" => format!("{}[DEP]{}", ansi::MEDIUM, ansi::RESET),
866 "performance" => format!("{}[PERF]{}", ansi::CYAN, ansi::RESET),
867 _ => String::new(),
868 };
869
870 let msg_display = if message.len() > 50 {
872 format!("{}...", &message[..47])
873 } else {
874 message.to_string()
875 };
876
877 format!(
878 "{}{} L{}:{} {}{}[{}]{} {} {}",
879 color, icon, line_num, ansi::RESET,
880 ansi::DOCKER_BLUE, ansi::BOLD, code, ansi::RESET,
881 badge,
882 msg_display
883 )
884}
885
886pub use crate::agent::ui::Spinner;
888use tokio::sync::mpsc;
889
890#[derive(Debug, Clone)]
892pub enum ToolEvent {
893 ToolStart { name: String, args: String },
894 ToolComplete { name: String, result: String },
895}
896
897pub fn spawn_tool_display_handler(
899 _receiver: mpsc::Receiver<ToolEvent>,
900 _spinner: Arc<crate::agent::ui::Spinner>,
901) -> tokio::task::JoinHandle<()> {
902 tokio::spawn(async {})
903}