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