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 _ => (true, vec!["done".to_string()]),
425 };
426
427 print!("{}{}", ansi::CURSOR_UP, ansi::CLEAR_LINE);
429
430 let dot = if status_ok { "●".green() } else { "●".red() };
432
433 let args_parsed: Result<serde_json::Value, _> = serde_json::from_str(args);
435 let args_display = format_args_display(name, &args_parsed);
436
437 if args_display.is_empty() {
438 println!("{} {}", dot, name.cyan().bold());
439 } else {
440 println!("{} {}({})", dot, name.cyan().bold(), args_display.dimmed());
441 }
442
443 let total_lines = output_lines.len();
445 let is_collapsible = total_lines > PREVIEW_LINES;
446
447 for (i, line) in output_lines.iter().take(PREVIEW_LINES).enumerate() {
448 let prefix = if i == output_lines.len().min(PREVIEW_LINES) - 1 && !is_collapsible {
449 "└"
450 } else {
451 "│"
452 };
453 println!(" {} {}", prefix.dimmed(), line);
454 }
455
456 if is_collapsible {
458 println!(
459 " {} {}",
460 "└".dimmed(),
461 format!("+{} more lines", total_lines - PREVIEW_LINES).dimmed()
462 );
463 }
464
465 let _ = io::stdout().flush();
466 (status_ok, output_lines, is_collapsible)
467}
468
469fn format_args_display(name: &str, parsed: &Result<serde_json::Value, serde_json::Error>) -> String {
471 match name {
472 "shell" => {
473 if let Ok(v) = parsed {
474 v.get("command").and_then(|c| c.as_str()).unwrap_or("").to_string()
475 } else {
476 String::new()
477 }
478 }
479 "write_file" => {
480 if let Ok(v) = parsed {
481 v.get("path").and_then(|p| p.as_str()).unwrap_or("").to_string()
482 } else {
483 String::new()
484 }
485 }
486 "write_files" => {
487 if let Ok(v) = parsed {
488 if let Some(files) = v.get("files").and_then(|f| f.as_array()) {
489 let paths: Vec<&str> = files
490 .iter()
491 .filter_map(|f| f.get("path").and_then(|p| p.as_str()))
492 .take(3)
493 .collect();
494 let more = if files.len() > 3 {
495 format!(", +{} more", files.len() - 3)
496 } else {
497 String::new()
498 };
499 format!("{}{}", paths.join(", "), more)
500 } else {
501 String::new()
502 }
503 } else {
504 String::new()
505 }
506 }
507 "read_file" => {
508 if let Ok(v) = parsed {
509 v.get("path").and_then(|p| p.as_str()).unwrap_or("").to_string()
510 } else {
511 String::new()
512 }
513 }
514 "list_directory" => {
515 if let Ok(v) = parsed {
516 v.get("path").and_then(|p| p.as_str()).unwrap_or(".").to_string()
517 } else {
518 ".".to_string()
519 }
520 }
521 _ => String::new(),
522 }
523}
524
525fn format_shell_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
527 if let Ok(v) = parsed {
528 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
529 let stdout = v.get("stdout").and_then(|s| s.as_str()).unwrap_or("");
530 let stderr = v.get("stderr").and_then(|s| s.as_str()).unwrap_or("");
531 let exit_code = v.get("exit_code").and_then(|c| c.as_i64());
532
533 let mut lines = Vec::new();
534
535 for line in stdout.lines() {
537 if !line.trim().is_empty() {
538 lines.push(line.to_string());
539 }
540 }
541
542 if !success {
544 for line in stderr.lines() {
545 if !line.trim().is_empty() {
546 lines.push(format!("{}", line.red()));
547 }
548 }
549 if let Some(code) = exit_code {
550 lines.push(format!("exit code: {}", code).red().to_string());
551 }
552 }
553
554 if lines.is_empty() {
555 lines.push(if success { "completed".to_string() } else { "failed".to_string() });
556 }
557
558 (success, lines)
559 } else {
560 (false, vec!["parse error".to_string()])
561 }
562}
563
564fn format_write_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
566 if let Ok(v) = parsed {
567 let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
568 let action = v.get("action").and_then(|a| a.as_str()).unwrap_or("wrote");
569 let lines_written = v.get("lines_written")
570 .or_else(|| v.get("total_lines"))
571 .and_then(|n| n.as_u64())
572 .unwrap_or(0);
573 let files_written = v.get("files_written").and_then(|n| n.as_u64()).unwrap_or(1);
574
575 let msg = if files_written > 1 {
576 format!("{} {} files ({} lines)", action, files_written, lines_written)
577 } else {
578 format!("{} ({} lines)", action, lines_written)
579 };
580
581 (success, vec![msg])
582 } else {
583 (false, vec!["write failed".to_string()])
584 }
585}
586
587fn format_read_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
589 if let Ok(v) = parsed {
590 if v.get("error").is_some() {
592 let error_msg = v.get("error").and_then(|e| e.as_str()).unwrap_or("file not found");
593 return (false, vec![error_msg.to_string()]);
594 }
595
596 if let Some(total_lines) = v.get("total_lines").and_then(|n| n.as_u64()) {
598 let msg = if total_lines == 1 {
599 "read 1 line".to_string()
600 } else {
601 format!("read {} lines", total_lines)
602 };
603 return (true, vec![msg]);
604 }
605
606 if let Some(content) = v.get("content").and_then(|c| c.as_str()) {
609 let lines = content.lines().count();
610 return (true, vec![format!("read {} lines", lines)]);
611 }
612
613 if v.is_string() {
615 return (true, vec!["read file".to_string()]);
617 }
618
619 (true, vec!["read file".to_string()])
620 } else {
621 (false, vec!["read failed".to_string()])
622 }
623}
624
625fn format_list_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
627 if let Ok(v) = parsed {
628 let entries = v.get("entries").and_then(|e| e.as_array());
629
630 let mut lines = Vec::new();
631
632 if let Some(entries) = entries {
633 let total = entries.len();
634 for entry in entries.iter().take(PREVIEW_LINES + 2) {
635 let name = entry.get("name").and_then(|n| n.as_str()).unwrap_or("?");
636 let entry_type = entry.get("type").and_then(|t| t.as_str()).unwrap_or("file");
637 let prefix = if entry_type == "directory" { "📁" } else { "📄" };
638 lines.push(format!("{} {}", prefix, name));
639 }
640 if total > PREVIEW_LINES + 2 {
642 lines.push(format!("... and {} more", total - (PREVIEW_LINES + 2)));
643 }
644 }
645
646 if lines.is_empty() {
647 lines.push("empty directory".to_string());
648 }
649
650 (true, lines)
651 } else {
652 (false, vec!["parse error".to_string()])
653 }
654}
655
656fn format_analyze_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
658 if let Ok(v) = parsed {
659 let mut lines = Vec::new();
660
661 if let Some(langs) = v.get("languages").and_then(|l| l.as_array()) {
663 let lang_names: Vec<&str> = langs
664 .iter()
665 .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
666 .take(5)
667 .collect();
668 if !lang_names.is_empty() {
669 lines.push(format!("Languages: {}", lang_names.join(", ")));
670 }
671 }
672
673 if let Some(frameworks) = v.get("frameworks").and_then(|f| f.as_array()) {
675 let fw_names: Vec<&str> = frameworks
676 .iter()
677 .filter_map(|f| f.get("name").and_then(|n| n.as_str()))
678 .take(5)
679 .collect();
680 if !fw_names.is_empty() {
681 lines.push(format!("Frameworks: {}", fw_names.join(", ")));
682 }
683 }
684
685 if lines.is_empty() {
686 lines.push("analysis complete".to_string());
687 }
688
689 (true, lines)
690 } else {
691 (false, vec!["parse error".to_string()])
692 }
693}
694
695fn format_security_result(parsed: &Result<serde_json::Value, serde_json::Error>) -> (bool, Vec<String>) {
697 if let Ok(v) = parsed {
698 let findings = v.get("findings")
699 .or_else(|| v.get("vulnerabilities"))
700 .and_then(|f| f.as_array())
701 .map(|a| a.len())
702 .unwrap_or(0);
703
704 if findings == 0 {
705 (true, vec!["no issues found".to_string()])
706 } else {
707 (false, vec![format!("{} issues found", findings)])
708 }
709 } else {
710 (false, vec!["parse error".to_string()])
711 }
712}
713
714pub use crate::agent::ui::Spinner;
716use tokio::sync::mpsc;
717
718#[derive(Debug, Clone)]
720pub enum ToolEvent {
721 ToolStart { name: String, args: String },
722 ToolComplete { name: String, result: String },
723}
724
725pub fn spawn_tool_display_handler(
727 _receiver: mpsc::Receiver<ToolEvent>,
728 _spinner: Arc<crate::agent::ui::Spinner>,
729) -> tokio::task::JoinHandle<()> {
730 tokio::spawn(async {})
731}