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(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 pub progress_state: Option<std::sync::Arc<crate::agent::ui::progress::ProgressState>>,
68 pub cancel_signal: Option<CancelSignal>,
70}
71
72#[derive(Clone)]
74pub struct ToolDisplayHook {
75 state: Arc<Mutex<DisplayState>>,
76}
77
78impl ToolDisplayHook {
79 pub fn new() -> Self {
80 Self {
81 state: Arc::new(Mutex::new(DisplayState::default())),
82 }
83 }
84
85 pub fn state(&self) -> Arc<Mutex<DisplayState>> {
87 self.state.clone()
88 }
89
90 pub async fn get_usage(&self) -> AccumulatedUsage {
92 let state = self.state.lock().await;
93 state.usage.clone()
94 }
95
96 pub async fn reset_usage(&self) {
98 let mut state = self.state.lock().await;
99 state.usage = AccumulatedUsage::default();
100 }
101
102 pub async fn set_progress_state(
104 &self,
105 progress: std::sync::Arc<crate::agent::ui::progress::ProgressState>,
106 ) {
107 let mut state = self.state.lock().await;
108 state.progress_state = Some(progress);
109 }
110
111 pub async fn clear_progress_state(&self) {
113 let mut state = self.state.lock().await;
114 state.progress_state = None;
115 }
116
117 pub async fn cancel(&self) {
120 let state = self.state.lock().await;
121 if let Some(ref cancel_sig) = state.cancel_signal {
122 cancel_sig.cancel();
123 }
124 }
125
126 pub async fn can_cancel(&self) -> bool {
128 let state = self.state.lock().await;
129 state.cancel_signal.is_some()
130 }
131}
132
133impl Default for ToolDisplayHook {
134 fn default() -> Self {
135 Self::new()
136 }
137}
138
139impl<M> rig::agent::PromptHook<M> for ToolDisplayHook
140where
141 M: CompletionModel,
142{
143 fn on_tool_call(
144 &self,
145 tool_name: &str,
146 _tool_call_id: Option<String>,
147 args: &str,
148 cancel: CancelSignal,
149 ) -> impl std::future::Future<Output = ()> + Send {
150 let state = self.state.clone();
151 let name = tool_name.to_string();
152 let args_str = args.to_string();
153
154 async move {
155 {
157 let mut s = state.lock().await;
158 s.cancel_signal = Some(cancel);
159 }
160 {
162 let s = state.lock().await;
163 if let Some(ref progress) = s.progress_state {
164 progress.pause();
165 }
166 }
167
168 print!("\r{}", ansi::CLEAR_LINE);
171 let _ = io::stdout().flush();
172
173 println!(); print_tool_header(&name, &args_str);
176
177 {
179 let s = state.lock().await;
180 if let Some(ref progress) = s.progress_state {
181 let action = tool_to_action(&name);
183 progress.set_action(&action);
184
185 let focus = tool_to_focus(&name, &args_str);
187 if let Some(f) = focus {
188 progress.set_focus(&f);
189 }
190 }
191 }
192
193 let mut s = state.lock().await;
195 let idx = s.tool_calls.len();
196 s.tool_calls.push(ToolCallState {
197 name,
198 args: args_str,
199 output: None,
200 output_lines: Vec::new(),
201 is_running: true,
202 is_expanded: false,
203 is_collapsible: false,
204 status_ok: true,
205 });
206 s.current_tool_index = Some(idx);
207 }
208 }
209
210 fn on_tool_result(
211 &self,
212 tool_name: &str,
213 _tool_call_id: Option<String>,
214 args: &str,
215 result: &str,
216 _cancel: CancelSignal,
217 ) -> impl std::future::Future<Output = ()> + Send {
218 let state = self.state.clone();
219 let name = tool_name.to_string();
220 let args_str = args.to_string();
221 let result_str = result.to_string();
222
223 async move {
224 let (status_ok, output_lines, is_collapsible) =
226 print_tool_result(&name, &args_str, &result_str);
227
228 let mut s = state.lock().await;
230 if let Some(idx) = s.current_tool_index {
231 if let Some(tool) = s.tool_calls.get_mut(idx) {
232 tool.output = Some(result_str);
233 tool.output_lines = output_lines;
234 tool.is_running = false;
235 tool.is_collapsible = is_collapsible;
236 tool.status_ok = status_ok;
237 }
238 if is_collapsible {
240 s.last_expandable_index = Some(idx);
241 }
242 }
243 s.current_tool_index = None;
244
245 if let Some(ref progress) = s.progress_state {
247 progress.set_action("Thinking");
248 progress.clear_focus();
249 progress.resume();
250 }
251 }
252 }
253
254 fn on_completion_response(
255 &self,
256 _prompt: &Message,
257 response: &CompletionResponse<M::Response>,
258 cancel: CancelSignal,
259 ) -> impl std::future::Future<Output = ()> + Send {
260 let state = self.state.clone();
261
262 let usage = response.usage;
264
265 let cancel_for_store = cancel.clone();
268
269 let has_tool_calls = response
272 .choice
273 .iter()
274 .any(|content| matches!(content, AssistantContent::ToolCall(_)));
275
276 let reasoning_parts: Vec<String> = response
278 .choice
279 .iter()
280 .filter_map(|content| {
281 if let AssistantContent::Reasoning(Reasoning { reasoning, .. }) = content {
282 let text = reasoning.to_vec().join("\n");
284 if !text.trim().is_empty() {
285 Some(text)
286 } else {
287 None
288 }
289 } else {
290 None
291 }
292 })
293 .collect();
294
295 let text_parts: Vec<String> = response
297 .choice
298 .iter()
299 .filter_map(|content| {
300 if let AssistantContent::Text(text) = content {
301 let trimmed = text.text.trim();
303 if !trimmed.is_empty() {
304 Some(trimmed.to_string())
305 } else {
306 None
307 }
308 } else {
309 None
310 }
311 })
312 .collect();
313
314 async move {
315 {
317 let mut s = state.lock().await;
318 s.cancel_signal = Some(cancel_for_store);
319 }
320
321 {
323 let mut s = state.lock().await;
324 s.usage.add(&usage);
325
326 if let Some(ref progress) = s.progress_state {
328 progress.update_tokens(usage.input_tokens, usage.output_tokens);
329 }
330 }
331
332 if !reasoning_parts.is_empty() {
334 let thinking_text = reasoning_parts.join("\n");
335
336 let mut s = state.lock().await;
338 s.agent_messages.push(thinking_text.clone());
339 if let Some(ref progress) = s.progress_state {
340 progress.pause();
341 }
342 drop(s);
343
344 print!("\r{}", ansi::CLEAR_LINE);
346 let _ = io::stdout().flush();
347
348 print_agent_thinking(&thinking_text);
350
351 let s = state.lock().await;
353 if let Some(ref progress) = s.progress_state {
354 progress.resume();
355 }
356 }
357
358 if !text_parts.is_empty() && has_tool_calls {
361 let thinking_text = text_parts.join("\n");
362
363 let mut s = state.lock().await;
365 s.agent_messages.push(thinking_text.clone());
366 if let Some(ref progress) = s.progress_state {
367 progress.pause();
368 }
369 drop(s);
370
371 print!("\r{}", ansi::CLEAR_LINE);
373 let _ = io::stdout().flush();
374
375 print_agent_thinking(&thinking_text);
377
378 let s = state.lock().await;
380 if let Some(ref progress) = s.progress_state {
381 progress.resume();
382 }
383 }
384 }
385 }
386}
387
388fn print_agent_thinking(text: &str) {
391 use crate::agent::ui::response::brand;
392
393 println!();
394
395 let mut in_code_block = false;
397
398 for line in text.lines() {
399 let trimmed = line.trim();
400
401 if trimmed.starts_with("```") {
403 if in_code_block {
404 println!(
405 "{} └────────────────────────────────────────────────────────┘{}",
406 brand::LIGHT_PEACH,
407 brand::RESET
408 );
409 in_code_block = false;
410 } else {
411 let lang = trimmed.strip_prefix("```").unwrap_or("");
412 let lang_display = if lang.is_empty() { "code" } else { lang };
413 println!(
414 "{} ┌─ {}{}{} ────────────────────────────────────────────────────┐{}",
415 brand::LIGHT_PEACH,
416 brand::CYAN,
417 lang_display,
418 brand::LIGHT_PEACH,
419 brand::RESET
420 );
421 in_code_block = true;
422 }
423 continue;
424 }
425
426 if in_code_block {
427 println!(
428 "{} │ {}{}{} │",
429 brand::LIGHT_PEACH,
430 brand::CYAN,
431 line,
432 brand::RESET
433 );
434 continue;
435 }
436
437 if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
439 let content = trimmed
440 .strip_prefix("- ")
441 .or_else(|| trimmed.strip_prefix("* "))
442 .unwrap_or(trimmed);
443 println!(
444 "{} • {}{}",
445 brand::PEACH,
446 format_thinking_inline(content),
447 brand::RESET
448 );
449 continue;
450 }
451
452 if trimmed
454 .chars()
455 .next()
456 .map(|c| c.is_ascii_digit())
457 .unwrap_or(false)
458 && trimmed.chars().nth(1) == Some('.')
459 {
460 println!(
461 "{} {}{}",
462 brand::PEACH,
463 format_thinking_inline(trimmed),
464 brand::RESET
465 );
466 continue;
467 }
468
469 if trimmed.is_empty() {
471 println!();
472 } else {
473 let wrapped = wrap_text(trimmed, 76);
475 for wrapped_line in wrapped {
476 println!(
477 "{} {}{}",
478 brand::PEACH,
479 format_thinking_inline(&wrapped_line),
480 brand::RESET
481 );
482 }
483 }
484 }
485
486 println!();
487 let _ = io::stdout().flush();
488}
489
490fn format_thinking_inline(text: &str) -> String {
492 use crate::agent::ui::response::brand;
493
494 let mut result = String::new();
495 let chars: Vec<char> = text.chars().collect();
496 let mut i = 0;
497
498 while i < chars.len() {
499 if chars[i] == '`'
501 && (i + 1 >= chars.len() || chars[i + 1] != '`')
502 && let Some(end) = chars[i + 1..].iter().position(|&c| c == '`')
503 {
504 let code_text: String = chars[i + 1..i + 1 + end].iter().collect();
505 result.push_str(brand::CYAN);
506 result.push('`');
507 result.push_str(&code_text);
508 result.push('`');
509 result.push_str(brand::RESET);
510 result.push_str(brand::PEACH);
511 i = i + 2 + end;
512 continue;
513 }
514
515 if i + 1 < chars.len()
517 && chars[i] == '*'
518 && chars[i + 1] == '*'
519 && let Some(end_offset) = find_double_star(&chars, i + 2)
520 {
521 let bold_text: String = chars[i + 2..i + 2 + end_offset].iter().collect();
522 result.push_str(brand::RESET);
523 result.push_str(brand::CORAL);
524 result.push_str(brand::BOLD);
525 result.push_str(&bold_text);
526 result.push_str(brand::RESET);
527 result.push_str(brand::PEACH);
528 i = i + 4 + end_offset;
529 continue;
530 }
531
532 result.push(chars[i]);
533 i += 1;
534 }
535
536 result
537}
538
539fn find_double_star(chars: &[char], start: usize) -> Option<usize> {
541 for i in start..chars.len().saturating_sub(1) {
542 if chars[i] == '*' && chars[i + 1] == '*' {
543 return Some(i - start);
544 }
545 }
546 None
547}
548
549fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
551 if text.len() <= max_width {
552 return vec![text.to_string()];
553 }
554
555 let mut lines = Vec::new();
556 let mut current_line = String::new();
557
558 for word in text.split_whitespace() {
559 if current_line.is_empty() {
560 current_line = word.to_string();
561 } else if current_line.len() + 1 + word.len() <= max_width {
562 current_line.push(' ');
563 current_line.push_str(word);
564 } else {
565 lines.push(current_line);
566 current_line = word.to_string();
567 }
568 }
569
570 if !current_line.is_empty() {
571 lines.push(current_line);
572 }
573
574 if lines.is_empty() {
575 lines.push(text.to_string());
576 }
577
578 lines
579}
580
581fn print_tool_header(name: &str, args: &str) {
583 let parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
584 let args_display = format_args_display(name, &parsed);
585
586 if args_display.is_empty() {
588 println!("\n{} {}", "●".yellow(), name.cyan().bold());
589 } else {
590 println!(
591 "\n{} {}({})",
592 "●".yellow(),
593 name.cyan().bold(),
594 args_display.dimmed()
595 );
596 }
597
598 println!(" {} {}", "└".dimmed(), "Running...".dimmed());
600
601 let _ = io::stdout().flush();
602}
603
604fn print_tool_result(name: &str, args: &str, result: &str) -> (bool, Vec<String>, bool) {
607 print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
609 let _ = io::stdout().flush();
610
611 let parsed: Result<serde_json::Value, _> =
613 serde_json::from_str(result).map(|v: serde_json::Value| {
614 if let Some(inner_str) = v.as_str() {
617 serde_json::from_str(inner_str).unwrap_or(v)
618 } else {
619 v
620 }
621 });
622
623 let (status_ok, output_lines) = match name {
625 "shell" => format_shell_result(&parsed),
626 "write_file" | "write_files" => format_write_result(&parsed),
627 "read_file" => format_read_result(&parsed),
628 "list_directory" => format_list_result(&parsed),
629 "analyze_project" => format_analyze_result(&parsed),
630 "security_scan" | "check_vulnerabilities" => format_security_result(&parsed),
631 "hadolint" => format_hadolint_result(&parsed),
632 "kubelint" => format_kubelint_result(&parsed),
633 "helmlint" => format_helmlint_result(&parsed),
634 _ => (true, vec!["done".to_string()]),
635 };
636
637 print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
639
640 let dot = if status_ok {
642 "●".green()
643 } else {
644 "●".red()
645 };
646
647 let args_parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
649 let args_display = format_args_display(name, &args_parsed);
650
651 if args_display.is_empty() {
652 println!("{} {}", dot, name.cyan().bold());
653 } else {
654 println!("{} {}({})", dot, name.cyan().bold(), args_display.dimmed());
655 }
656
657 let total_lines = output_lines.len();
659 let is_collapsible = total_lines > PREVIEW_LINES;
660
661 for (i, line) in output_lines.iter().take(PREVIEW_LINES).enumerate() {
662 let prefix = if i == output_lines.len().min(PREVIEW_LINES) - 1 && !is_collapsible {
663 "└"
664 } else {
665 "│"
666 };
667 println!(" {} {}", prefix.dimmed(), line);
668 }
669
670 if is_collapsible {
672 println!(
673 " {} {}",
674 "└".dimmed(),
675 format!("+{} more lines", total_lines - PREVIEW_LINES).dimmed()
676 );
677 }
678
679 let _ = io::stdout().flush();
680 (status_ok, output_lines, is_collapsible)
681}
682
683fn format_args_display(
685 name: &str,
686 parsed: &Result<serde_json::Value, serde_json::Error>,
687) -> String {
688 match name {
689 "shell" => {
690 if let Ok(v) = parsed {
691 v.get("command")
692 .and_then(|c| c.as_str())
693 .unwrap_or("")
694 .to_string()
695 } else {
696 String::new()
697 }
698 }
699 "write_file" => {
700 if let Ok(v) = parsed {
701 v.get("path")
702 .and_then(|p| p.as_str())
703 .unwrap_or("")
704 .to_string()
705 } else {
706 String::new()
707 }
708 }
709 "write_files" => {
710 if let Ok(v) = parsed {
711 if let Some(files) = v.get("files").and_then(|f| f.as_array()) {
712 let paths: Vec<&str> = files
713 .iter()
714 .filter_map(|f| f.get("path").and_then(|p| p.as_str()))
715 .take(3)
716 .collect();
717 let more = if files.len() > 3 {
718 format!(", +{} more", files.len() - 3)
719 } else {
720 String::new()
721 };
722 format!("{}{}", paths.join(", "), more)
723 } else {
724 String::new()
725 }
726 } else {
727 String::new()
728 }
729 }
730 "read_file" => {
731 if let Ok(v) = parsed {
732 v.get("path")
733 .and_then(|p| p.as_str())
734 .unwrap_or("")
735 .to_string()
736 } else {
737 String::new()
738 }
739 }
740 "list_directory" => {
741 if let Ok(v) = parsed {
742 v.get("path")
743 .and_then(|p| p.as_str())
744 .unwrap_or(".")
745 .to_string()
746 } else {
747 ".".to_string()
748 }
749 }
750 "kubelint" | "helmlint" | "hadolint" | "dclint" => {
751 if let Ok(v) = parsed {
752 if let Some(path) = v.get("path").and_then(|p| p.as_str()) {
754 return path.to_string();
755 }
756 if v.get("content").and_then(|c| c.as_str()).is_some() {
758 return "<inline>".to_string();
759 }
760 "<auto>".to_string()
762 } else {
763 String::new()
764 }
765 }
766 _ => String::new(),
767 }
768}
769
770fn format_shell_result(
772 parsed: &Result<serde_json::Value, serde_json::Error>,
773) -> (bool, Vec<String>) {
774 if let Ok(v) = parsed {
775 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
776 let stdout = v.get("stdout").and_then(|s| s.as_str()).unwrap_or("");
777 let stderr = v.get("stderr").and_then(|s| s.as_str()).unwrap_or("");
778 let exit_code = v.get("exit_code").and_then(|c| c.as_i64());
779
780 let mut lines = Vec::new();
781
782 for line in stdout.lines() {
784 if !line.trim().is_empty() {
785 lines.push(line.to_string());
786 }
787 }
788
789 if !success {
791 for line in stderr.lines() {
792 if !line.trim().is_empty() {
793 lines.push(format!("{}", line.red()));
794 }
795 }
796 if let Some(code) = exit_code {
797 lines.push(format!("exit code: {}", code).red().to_string());
798 }
799 }
800
801 if lines.is_empty() {
802 lines.push(if success {
803 "completed".to_string()
804 } else {
805 "failed".to_string()
806 });
807 }
808
809 (success, lines)
810 } else {
811 (false, vec!["parse error".to_string()])
812 }
813}
814
815fn format_write_result(
817 parsed: &Result<serde_json::Value, serde_json::Error>,
818) -> (bool, Vec<String>) {
819 if let Ok(v) = parsed {
820 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
821 let action = v.get("action").and_then(|a| a.as_str()).unwrap_or("wrote");
822 let lines_written = v
823 .get("lines_written")
824 .or_else(|| v.get("total_lines"))
825 .and_then(|n| n.as_u64())
826 .unwrap_or(0);
827 let files_written = v.get("files_written").and_then(|n| n.as_u64()).unwrap_or(1);
828
829 let msg = if files_written > 1 {
830 format!(
831 "{} {} files ({} lines)",
832 action, files_written, lines_written
833 )
834 } else {
835 format!("{} ({} lines)", action, lines_written)
836 };
837
838 (success, vec![msg])
839 } else {
840 (false, vec!["write failed".to_string()])
841 }
842}
843
844fn format_read_result(
846 parsed: &Result<serde_json::Value, serde_json::Error>,
847) -> (bool, Vec<String>) {
848 if let Ok(v) = parsed {
849 if v.get("error").is_some() {
851 let error_msg = v
852 .get("error")
853 .and_then(|e| e.as_str())
854 .unwrap_or("file not found");
855 return (false, vec![error_msg.to_string()]);
856 }
857
858 if let Some(total_lines) = v.get("total_lines").and_then(|n| n.as_u64()) {
860 let msg = if total_lines == 1 {
861 "read 1 line".to_string()
862 } else {
863 format!("read {} lines", total_lines)
864 };
865 return (true, vec![msg]);
866 }
867
868 if let Some(content) = v.get("content").and_then(|c| c.as_str()) {
871 let lines = content.lines().count();
872 return (true, vec![format!("read {} lines", lines)]);
873 }
874
875 if v.is_string() {
877 return (true, vec!["read file".to_string()]);
879 }
880
881 (true, vec!["read file".to_string()])
882 } else {
883 (false, vec!["read failed".to_string()])
884 }
885}
886
887fn format_list_result(
889 parsed: &Result<serde_json::Value, serde_json::Error>,
890) -> (bool, Vec<String>) {
891 if let Ok(v) = parsed {
892 let entries = v.get("entries").and_then(|e| e.as_array());
893
894 let mut lines = Vec::new();
895
896 if let Some(entries) = entries {
897 let total = entries.len();
898 for entry in entries.iter().take(PREVIEW_LINES + 2) {
899 let name = entry.get("name").and_then(|n| n.as_str()).unwrap_or("?");
900 let entry_type = entry.get("type").and_then(|t| t.as_str()).unwrap_or("file");
901 let prefix = if entry_type == "directory" {
902 "📁"
903 } else {
904 "📄"
905 };
906 lines.push(format!("{} {}", prefix, name));
907 }
908 if total > PREVIEW_LINES + 2 {
910 lines.push(format!("... and {} more", total - (PREVIEW_LINES + 2)));
911 }
912 }
913
914 if lines.is_empty() {
915 lines.push("empty directory".to_string());
916 }
917
918 (true, lines)
919 } else {
920 (false, vec!["parse error".to_string()])
921 }
922}
923
924fn format_analyze_result(
926 parsed: &Result<serde_json::Value, serde_json::Error>,
927) -> (bool, Vec<String>) {
928 if let Ok(v) = parsed {
929 let mut lines = Vec::new();
930
931 if let Some(langs) = v.get("languages").and_then(|l| l.as_array()) {
933 let lang_names: Vec<&str> = langs
934 .iter()
935 .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
936 .take(5)
937 .collect();
938 if !lang_names.is_empty() {
939 lines.push(format!("Languages: {}", lang_names.join(", ")));
940 }
941 }
942
943 if let Some(frameworks) = v.get("frameworks").and_then(|f| f.as_array()) {
945 let fw_names: Vec<&str> = frameworks
946 .iter()
947 .filter_map(|f| f.get("name").and_then(|n| n.as_str()))
948 .take(5)
949 .collect();
950 if !fw_names.is_empty() {
951 lines.push(format!("Frameworks: {}", fw_names.join(", ")));
952 }
953 }
954
955 if lines.is_empty() {
956 lines.push("analysis complete".to_string());
957 }
958
959 (true, lines)
960 } else {
961 (false, vec!["parse error".to_string()])
962 }
963}
964
965fn format_security_result(
967 parsed: &Result<serde_json::Value, serde_json::Error>,
968) -> (bool, Vec<String>) {
969 if let Ok(v) = parsed {
970 let findings = v
971 .get("findings")
972 .or_else(|| v.get("vulnerabilities"))
973 .and_then(|f| f.as_array())
974 .map(|a| a.len())
975 .unwrap_or(0);
976
977 if findings == 0 {
978 (true, vec!["no issues found".to_string()])
979 } else {
980 (false, vec![format!("{} issues found", findings)])
981 }
982 } else {
983 (false, vec!["parse error".to_string()])
984 }
985}
986
987fn format_hadolint_result(
989 parsed: &Result<serde_json::Value, serde_json::Error>,
990) -> (bool, Vec<String>) {
991 if let Ok(v) = parsed {
992 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true);
993 let summary = v.get("summary");
994 let action_plan = v.get("action_plan");
995
996 let mut lines = Vec::new();
997
998 let total = summary
1000 .and_then(|s| s.get("total"))
1001 .and_then(|t| t.as_u64())
1002 .unwrap_or(0);
1003
1004 if total == 0 {
1006 lines.push(format!(
1007 "{}🐳 Dockerfile OK - no issues found{}",
1008 ansi::SUCCESS,
1009 ansi::RESET
1010 ));
1011 return (true, lines);
1012 }
1013
1014 let critical = summary
1016 .and_then(|s| s.get("by_priority"))
1017 .and_then(|p| p.get("critical"))
1018 .and_then(|c| c.as_u64())
1019 .unwrap_or(0);
1020 let high = summary
1021 .and_then(|s| s.get("by_priority"))
1022 .and_then(|p| p.get("high"))
1023 .and_then(|h| h.as_u64())
1024 .unwrap_or(0);
1025 let medium = summary
1026 .and_then(|s| s.get("by_priority"))
1027 .and_then(|p| p.get("medium"))
1028 .and_then(|m| m.as_u64())
1029 .unwrap_or(0);
1030 let low = summary
1031 .and_then(|s| s.get("by_priority"))
1032 .and_then(|p| p.get("low"))
1033 .and_then(|l| l.as_u64())
1034 .unwrap_or(0);
1035
1036 let mut priority_parts = Vec::new();
1038 if critical > 0 {
1039 priority_parts.push(format!(
1040 "{}🔴 {} critical{}",
1041 ansi::CRITICAL,
1042 critical,
1043 ansi::RESET
1044 ));
1045 }
1046 if high > 0 {
1047 priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET));
1048 }
1049 if medium > 0 {
1050 priority_parts.push(format!(
1051 "{}🟡 {} medium{}",
1052 ansi::MEDIUM,
1053 medium,
1054 ansi::RESET
1055 ));
1056 }
1057 if low > 0 {
1058 priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET));
1059 }
1060
1061 let header_color = if critical > 0 {
1062 ansi::CRITICAL
1063 } else if high > 0 {
1064 ansi::HIGH
1065 } else {
1066 ansi::DOCKER_BLUE
1067 };
1068
1069 lines.push(format!(
1070 "{}🐳 {} issue{} found: {}{}",
1071 header_color,
1072 total,
1073 if total == 1 { "" } else { "s" },
1074 priority_parts.join(" "),
1075 ansi::RESET
1076 ));
1077
1078 let mut shown = 0;
1080 const MAX_PREVIEW: usize = 6;
1081
1082 if let Some(critical_issues) = action_plan
1084 .and_then(|a| a.get("critical"))
1085 .and_then(|c| c.as_array())
1086 {
1087 for issue in critical_issues.iter().take(MAX_PREVIEW - shown) {
1088 lines.push(format_hadolint_issue(issue, "🔴", ansi::CRITICAL));
1089 shown += 1;
1090 }
1091 }
1092
1093 if shown < MAX_PREVIEW
1095 && let Some(high_issues) = action_plan
1096 .and_then(|a| a.get("high"))
1097 .and_then(|h| h.as_array())
1098 {
1099 for issue in high_issues.iter().take(MAX_PREVIEW - shown) {
1100 lines.push(format_hadolint_issue(issue, "🟠", ansi::HIGH));
1101 shown += 1;
1102 }
1103 }
1104
1105 if let Some(quick_fixes) = v.get("quick_fixes").and_then(|q| q.as_array())
1107 && let Some(first_fix) = quick_fixes.first().and_then(|f| f.as_str())
1108 {
1109 let truncated = if first_fix.len() > 70 {
1110 format!("{}...", &first_fix[..67])
1111 } else {
1112 first_fix.to_string()
1113 };
1114 lines.push(format!(
1115 "{} → Fix: {}{}",
1116 ansi::INFO_BLUE,
1117 truncated,
1118 ansi::RESET
1119 ));
1120 }
1121
1122 let remaining = total as usize - shown;
1124 if remaining > 0 {
1125 lines.push(format!(
1126 "{} +{} more issue{}{}",
1127 ansi::GRAY,
1128 remaining,
1129 if remaining == 1 { "" } else { "s" },
1130 ansi::RESET
1131 ));
1132 }
1133
1134 (success, lines)
1135 } else {
1136 (false, vec!["parse error".to_string()])
1137 }
1138}
1139
1140fn format_hadolint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> String {
1142 let code = issue.get("code").and_then(|c| c.as_str()).unwrap_or("?");
1143 let message = issue.get("message").and_then(|m| m.as_str()).unwrap_or("?");
1144 let line_num = issue.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
1145 let category = issue.get("category").and_then(|c| c.as_str()).unwrap_or("");
1146
1147 let badge = match category {
1149 "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
1150 "best-practice" => format!("{}[BP]{}", ansi::INFO_BLUE, ansi::RESET),
1151 "deprecated" => format!("{}[DEP]{}", ansi::MEDIUM, ansi::RESET),
1152 "performance" => format!("{}[PERF]{}", ansi::CYAN, ansi::RESET),
1153 _ => String::new(),
1154 };
1155
1156 let msg_display = if message.len() > 50 {
1158 format!("{}...", &message[..47])
1159 } else {
1160 message.to_string()
1161 };
1162
1163 format!(
1164 "{}{} L{}:{} {}{}[{}]{} {} {}",
1165 color,
1166 icon,
1167 line_num,
1168 ansi::RESET,
1169 ansi::DOCKER_BLUE,
1170 ansi::BOLD,
1171 code,
1172 ansi::RESET,
1173 badge,
1174 msg_display
1175 )
1176}
1177
1178fn format_kubelint_result(
1180 parsed: &Result<serde_json::Value, serde_json::Error>,
1181) -> (bool, Vec<String>) {
1182 if let Ok(v) = parsed {
1183 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true);
1184 let summary = v.get("summary");
1185 let action_plan = v.get("action_plan");
1186 let parse_errors = v.get("parse_errors").and_then(|p| p.as_array());
1187
1188 let total = summary
1189 .and_then(|s| s.get("total_issues"))
1190 .and_then(|t| t.as_u64())
1191 .unwrap_or(0);
1192
1193 let mut lines = Vec::new();
1194
1195 if let Some(errors) = parse_errors
1197 && !errors.is_empty()
1198 {
1199 lines.push(format!(
1200 "{}☸ {} parse error{} (files could not be fully analyzed){}",
1201 ansi::HIGH,
1202 errors.len(),
1203 if errors.len() == 1 { "" } else { "s" },
1204 ansi::RESET
1205 ));
1206 for (i, err) in errors.iter().take(3).enumerate() {
1207 if let Some(err_str) = err.as_str() {
1208 let truncated = if err_str.len() > 70 {
1209 format!("{}...", &err_str[..67])
1210 } else {
1211 err_str.to_string()
1212 };
1213 lines.push(format!(
1214 "{} {} {}{}",
1215 ansi::HIGH,
1216 if i == errors.len().min(3) - 1 {
1217 "└"
1218 } else {
1219 "│"
1220 },
1221 truncated,
1222 ansi::RESET
1223 ));
1224 }
1225 }
1226 if errors.len() > 3 {
1227 lines.push(format!(
1228 "{} +{} more errors{}",
1229 ansi::GRAY,
1230 errors.len() - 3,
1231 ansi::RESET
1232 ));
1233 }
1234 if total == 0 {
1236 return (false, lines);
1237 }
1238 }
1239
1240 if total == 0 && parse_errors.map(|e| e.is_empty()).unwrap_or(true) {
1241 lines.push(format!(
1242 "{}☸ K8s manifests OK - no issues found{}",
1243 ansi::SUCCESS,
1244 ansi::RESET
1245 ));
1246 return (true, lines);
1247 }
1248
1249 let critical = summary
1251 .and_then(|s| s.get("by_priority"))
1252 .and_then(|p| p.get("critical"))
1253 .and_then(|c| c.as_u64())
1254 .unwrap_or(0);
1255 let high = summary
1256 .and_then(|s| s.get("by_priority"))
1257 .and_then(|p| p.get("high"))
1258 .and_then(|h| h.as_u64())
1259 .unwrap_or(0);
1260 let medium = summary
1261 .and_then(|s| s.get("by_priority"))
1262 .and_then(|p| p.get("medium"))
1263 .and_then(|m| m.as_u64())
1264 .unwrap_or(0);
1265 let low = summary
1266 .and_then(|s| s.get("by_priority"))
1267 .and_then(|p| p.get("low"))
1268 .and_then(|l| l.as_u64())
1269 .unwrap_or(0);
1270
1271 let mut priority_parts = Vec::new();
1273 if critical > 0 {
1274 priority_parts.push(format!(
1275 "{}🔴 {} critical{}",
1276 ansi::CRITICAL,
1277 critical,
1278 ansi::RESET
1279 ));
1280 }
1281 if high > 0 {
1282 priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET));
1283 }
1284 if medium > 0 {
1285 priority_parts.push(format!(
1286 "{}🟡 {} medium{}",
1287 ansi::MEDIUM,
1288 medium,
1289 ansi::RESET
1290 ));
1291 }
1292 if low > 0 {
1293 priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET));
1294 }
1295
1296 let header_color = if critical > 0 {
1297 ansi::CRITICAL
1298 } else if high > 0 {
1299 ansi::HIGH
1300 } else {
1301 ansi::CYAN
1302 };
1303
1304 lines.push(format!(
1305 "{}☸ {} issue{} found: {}{}",
1306 header_color,
1307 total,
1308 if total == 1 { "" } else { "s" },
1309 priority_parts.join(" "),
1310 ansi::RESET
1311 ));
1312
1313 let mut shown = 0;
1315 const MAX_PREVIEW: usize = 6;
1316
1317 if let Some(critical_issues) = action_plan
1319 .and_then(|a| a.get("critical"))
1320 .and_then(|c| c.as_array())
1321 {
1322 for issue in critical_issues.iter().take(MAX_PREVIEW - shown) {
1323 lines.push(format_kubelint_issue(issue, "🔴", ansi::CRITICAL));
1324 shown += 1;
1325 }
1326 }
1327
1328 if shown < MAX_PREVIEW
1330 && let Some(high_issues) = action_plan
1331 .and_then(|a| a.get("high"))
1332 .and_then(|h| h.as_array())
1333 {
1334 for issue in high_issues.iter().take(MAX_PREVIEW - shown) {
1335 lines.push(format_kubelint_issue(issue, "🟠", ansi::HIGH));
1336 shown += 1;
1337 }
1338 }
1339
1340 if let Some(quick_fixes) = v.get("quick_fixes").and_then(|q| q.as_array())
1342 && let Some(first_fix) = quick_fixes.first().and_then(|f| f.as_str())
1343 {
1344 let truncated = if first_fix.len() > 70 {
1345 format!("{}...", &first_fix[..67])
1346 } else {
1347 first_fix.to_string()
1348 };
1349 lines.push(format!(
1350 "{} → Fix: {}{}",
1351 ansi::INFO_BLUE,
1352 truncated,
1353 ansi::RESET
1354 ));
1355 }
1356
1357 let remaining = total as usize - shown;
1359 if remaining > 0 {
1360 lines.push(format!(
1361 "{} +{} more issue{}{}",
1362 ansi::GRAY,
1363 remaining,
1364 if remaining == 1 { "" } else { "s" },
1365 ansi::RESET
1366 ));
1367 }
1368
1369 (success && total == 0, lines)
1370 } else {
1371 (false, vec!["kubelint analysis complete".to_string()])
1372 }
1373}
1374
1375fn format_kubelint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> String {
1377 let check = issue.get("check").and_then(|c| c.as_str()).unwrap_or("?");
1378 let message = issue.get("message").and_then(|m| m.as_str()).unwrap_or("?");
1379 let line_num = issue.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
1380 let category = issue.get("category").and_then(|c| c.as_str()).unwrap_or("");
1381
1382 let badge = match category {
1384 "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
1385 "rbac" => format!("{}[RBAC]{}", ansi::CRITICAL, ansi::RESET),
1386 "best-practice" => format!("{}[BP]{}", ansi::INFO_BLUE, ansi::RESET),
1387 "validation" => format!("{}[VAL]{}", ansi::MEDIUM, ansi::RESET),
1388 _ => String::new(),
1389 };
1390
1391 let msg_display = if message.len() > 50 {
1393 format!("{}...", &message[..47])
1394 } else {
1395 message.to_string()
1396 };
1397
1398 format!(
1399 "{}{} L{}:{} {}{}[{}]{} {} {}",
1400 color,
1401 icon,
1402 line_num,
1403 ansi::RESET,
1404 ansi::CYAN,
1405 ansi::BOLD,
1406 check,
1407 ansi::RESET,
1408 badge,
1409 msg_display
1410 )
1411}
1412
1413fn format_helmlint_result(
1415 parsed: &Result<serde_json::Value, serde_json::Error>,
1416) -> (bool, Vec<String>) {
1417 if let Ok(v) = parsed {
1418 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true);
1419 let summary = v.get("summary");
1420 let action_plan = v.get("action_plan");
1421 let parse_errors = v.get("parse_errors").and_then(|p| p.as_array());
1422
1423 let total = summary
1424 .and_then(|s| s.get("total"))
1425 .and_then(|t| t.as_u64())
1426 .unwrap_or(0);
1427
1428 let mut lines = Vec::new();
1429
1430 if let Some(errors) = parse_errors
1432 && !errors.is_empty()
1433 {
1434 lines.push(format!(
1435 "{}⎈ {} parse error{} (chart could not be fully analyzed){}",
1436 ansi::HIGH,
1437 errors.len(),
1438 if errors.len() == 1 { "" } else { "s" },
1439 ansi::RESET
1440 ));
1441 for (i, err) in errors.iter().take(3).enumerate() {
1442 if let Some(err_str) = err.as_str() {
1443 let truncated = if err_str.len() > 70 {
1444 format!("{}...", &err_str[..67])
1445 } else {
1446 err_str.to_string()
1447 };
1448 lines.push(format!(
1449 "{} {} {}{}",
1450 ansi::HIGH,
1451 if i == errors.len().min(3) - 1 {
1452 "└"
1453 } else {
1454 "│"
1455 },
1456 truncated,
1457 ansi::RESET
1458 ));
1459 }
1460 }
1461 if errors.len() > 3 {
1462 lines.push(format!(
1463 "{} +{} more errors{}",
1464 ansi::GRAY,
1465 errors.len() - 3,
1466 ansi::RESET
1467 ));
1468 }
1469 if total == 0 {
1471 return (false, lines);
1472 }
1473 }
1474
1475 if total == 0 && parse_errors.map(|e| e.is_empty()).unwrap_or(true) {
1476 lines.push(format!(
1477 "{}⎈ Helm chart OK - no issues found{}",
1478 ansi::SUCCESS,
1479 ansi::RESET
1480 ));
1481 return (true, lines);
1482 }
1483
1484 let critical = summary
1486 .and_then(|s| s.get("by_priority"))
1487 .and_then(|p| p.get("critical"))
1488 .and_then(|c| c.as_u64())
1489 .unwrap_or(0);
1490 let high = summary
1491 .and_then(|s| s.get("by_priority"))
1492 .and_then(|p| p.get("high"))
1493 .and_then(|h| h.as_u64())
1494 .unwrap_or(0);
1495 let medium = summary
1496 .and_then(|s| s.get("by_priority"))
1497 .and_then(|p| p.get("medium"))
1498 .and_then(|m| m.as_u64())
1499 .unwrap_or(0);
1500 let low = summary
1501 .and_then(|s| s.get("by_priority"))
1502 .and_then(|p| p.get("low"))
1503 .and_then(|l| l.as_u64())
1504 .unwrap_or(0);
1505
1506 let mut priority_parts = Vec::new();
1508 if critical > 0 {
1509 priority_parts.push(format!(
1510 "{}🔴 {} critical{}",
1511 ansi::CRITICAL,
1512 critical,
1513 ansi::RESET
1514 ));
1515 }
1516 if high > 0 {
1517 priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET));
1518 }
1519 if medium > 0 {
1520 priority_parts.push(format!(
1521 "{}🟡 {} medium{}",
1522 ansi::MEDIUM,
1523 medium,
1524 ansi::RESET
1525 ));
1526 }
1527 if low > 0 {
1528 priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET));
1529 }
1530
1531 let header_color = if critical > 0 {
1532 ansi::CRITICAL
1533 } else if high > 0 {
1534 ansi::HIGH
1535 } else {
1536 ansi::CYAN
1537 };
1538
1539 lines.push(format!(
1540 "{}⎈ {} issue{} found: {}{}",
1541 header_color,
1542 total,
1543 if total == 1 { "" } else { "s" },
1544 priority_parts.join(" "),
1545 ansi::RESET
1546 ));
1547
1548 let mut shown = 0;
1550 const MAX_PREVIEW: usize = 6;
1551
1552 if let Some(critical_issues) = action_plan
1554 .and_then(|a| a.get("critical"))
1555 .and_then(|c| c.as_array())
1556 {
1557 for issue in critical_issues.iter().take(MAX_PREVIEW - shown) {
1558 lines.push(format_helmlint_issue(issue, "🔴", ansi::CRITICAL));
1559 shown += 1;
1560 }
1561 }
1562
1563 if shown < MAX_PREVIEW
1565 && let Some(high_issues) = action_plan
1566 .and_then(|a| a.get("high"))
1567 .and_then(|h| h.as_array())
1568 {
1569 for issue in high_issues.iter().take(MAX_PREVIEW - shown) {
1570 lines.push(format_helmlint_issue(issue, "🟠", ansi::HIGH));
1571 shown += 1;
1572 }
1573 }
1574
1575 if let Some(quick_fixes) = v.get("quick_fixes").and_then(|q| q.as_array())
1577 && let Some(first_fix) = quick_fixes.first().and_then(|f| f.as_str())
1578 {
1579 let truncated = if first_fix.len() > 70 {
1580 format!("{}...", &first_fix[..67])
1581 } else {
1582 first_fix.to_string()
1583 };
1584 lines.push(format!(
1585 "{} → Fix: {}{}",
1586 ansi::INFO_BLUE,
1587 truncated,
1588 ansi::RESET
1589 ));
1590 }
1591
1592 let remaining = total as usize - shown;
1594 if remaining > 0 {
1595 lines.push(format!(
1596 "{} +{} more issue{}{}",
1597 ansi::GRAY,
1598 remaining,
1599 if remaining == 1 { "" } else { "s" },
1600 ansi::RESET
1601 ));
1602 }
1603
1604 (success && total == 0, lines)
1605 } else {
1606 (false, vec!["helmlint analysis complete".to_string()])
1607 }
1608}
1609
1610fn format_helmlint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> String {
1612 let code = issue.get("code").and_then(|c| c.as_str()).unwrap_or("?");
1613 let message = issue.get("message").and_then(|m| m.as_str()).unwrap_or("?");
1614 let file = issue.get("file").and_then(|f| f.as_str()).unwrap_or("");
1615 let line_num = issue.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
1616 let category = issue.get("category").and_then(|c| c.as_str()).unwrap_or("");
1617
1618 let badge = match category {
1620 "Security" | "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
1621 "Structure" | "structure" => format!("{}[STRUCT]{}", ansi::GRAY, ansi::RESET),
1622 "Template" | "template" => format!("{}[TPL]{}", ansi::MEDIUM, ansi::RESET),
1623 "Values" | "values" => format!("{}[VAL]{}", ansi::MEDIUM, ansi::RESET),
1624 _ => String::new(),
1625 };
1626
1627 let file_short = if file.len() > 20 {
1629 format!("...{}", &file[file.len().saturating_sub(17)..])
1630 } else {
1631 file.to_string()
1632 };
1633
1634 let msg_display = if message.len() > 40 {
1636 format!("{}...", &message[..37])
1637 } else {
1638 message.to_string()
1639 };
1640
1641 format!(
1642 "{}{} {}:{}:{} {}{}[{}]{} {} {}",
1643 color,
1644 icon,
1645 file_short,
1646 line_num,
1647 ansi::RESET,
1648 ansi::CYAN,
1649 ansi::BOLD,
1650 code,
1651 ansi::RESET,
1652 badge,
1653 msg_display
1654 )
1655}
1656
1657fn tool_to_action(tool_name: &str) -> String {
1659 match tool_name {
1660 "read_file" => "Reading file".to_string(),
1661 "write_file" | "write_files" => "Writing file".to_string(),
1662 "list_directory" => "Listing directory".to_string(),
1663 "shell" => "Running command".to_string(),
1664 "analyze_project" => "Analyzing project".to_string(),
1665 "security_scan" | "check_vulnerabilities" => "Scanning security".to_string(),
1666 "hadolint" => "Linting Dockerfile".to_string(),
1667 "dclint" => "Linting docker-compose".to_string(),
1668 "kubelint" => "Linting Kubernetes".to_string(),
1669 "helmlint" => "Linting Helm chart".to_string(),
1670 "terraform_fmt" => "Formatting Terraform".to_string(),
1671 "terraform_validate" => "Validating Terraform".to_string(),
1672 "plan_create" => "Creating plan".to_string(),
1673 "plan_list" => "Listing plans".to_string(),
1674 "plan_next" | "plan_update" => "Updating plan".to_string(),
1675 _ => "Processing".to_string(),
1676 }
1677}
1678
1679fn tool_to_focus(tool_name: &str, args: &str) -> Option<String> {
1681 let parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
1682 let parsed = parsed.ok()?;
1683
1684 match tool_name {
1685 "read_file" | "write_file" => {
1686 parsed.get("path").and_then(|p| p.as_str()).map(|p| {
1687 if p.len() > 50 {
1689 format!("...{}", &p[p.len().saturating_sub(47)..])
1690 } else {
1691 p.to_string()
1692 }
1693 })
1694 }
1695 "list_directory" => parsed
1696 .get("path")
1697 .and_then(|p| p.as_str())
1698 .map(|p| p.to_string()),
1699 "shell" => parsed.get("command").and_then(|c| c.as_str()).map(|cmd| {
1700 if cmd.len() > 60 {
1702 format!("{}...", &cmd[..57])
1703 } else {
1704 cmd.to_string()
1705 }
1706 }),
1707 "hadolint" | "dclint" | "kubelint" | "helmlint" => parsed
1708 .get("path")
1709 .and_then(|p| p.as_str())
1710 .map(|p| p.to_string())
1711 .or_else(|| {
1712 if parsed.get("content").is_some() {
1713 Some("<inline content>".to_string())
1714 } else {
1715 Some("<auto-detect>".to_string())
1716 }
1717 }),
1718 "plan_create" => parsed
1719 .get("name")
1720 .and_then(|n| n.as_str())
1721 .map(|n| n.to_string()),
1722 _ => None,
1723 }
1724}
1725
1726pub use crate::agent::ui::Spinner;
1728use tokio::sync::mpsc;
1729
1730#[derive(Debug, Clone)]
1732pub enum ToolEvent {
1733 ToolStart { name: String, args: String },
1734 ToolComplete { name: String, result: String },
1735}
1736
1737pub fn spawn_tool_display_handler(
1739 _receiver: mpsc::Receiver<ToolEvent>,
1740 _spinner: Arc<crate::agent::ui::Spinner>,
1741) -> tokio::task::JoinHandle<()> {
1742 tokio::spawn(async {})
1743}