1use crate::swarm::{SubTaskStatus, SwarmStats};
8use ratatui::{
9 Frame,
10 layout::{Constraint, Direction, Layout, Rect},
11 style::{Color, Modifier, Style},
12 text::{Line, Span},
13 widgets::{Block, Borders, Gauge, List, ListItem, ListState, Paragraph, Wrap},
14};
15
16#[derive(Debug, Clone)]
18pub struct AgentToolCallDetail {
19 pub tool_name: String,
20 pub input_preview: String,
21 pub output_preview: String,
22 pub success: bool,
23}
24
25#[derive(Debug, Clone)]
27pub struct AgentMessageEntry {
28 pub role: String,
29 pub content: String,
30 pub is_tool_call: bool,
31}
32
33#[derive(Debug, Clone)]
35pub enum SwarmEvent {
36 Started { task: String, total_subtasks: usize },
38 Decomposed { subtasks: Vec<SubTaskInfo> },
40 SubTaskUpdate {
42 id: String,
43 name: String,
44 status: SubTaskStatus,
45 agent_name: Option<String>,
46 },
47 AgentStarted {
49 subtask_id: String,
50 agent_name: String,
51 specialty: String,
52 },
53 AgentToolCall {
55 subtask_id: String,
56 tool_name: String,
57 },
58 AgentToolCallDetail {
60 subtask_id: String,
61 detail: AgentToolCallDetail,
62 },
63 AgentMessage {
65 subtask_id: String,
66 entry: AgentMessageEntry,
67 },
68 AgentComplete {
70 subtask_id: String,
71 success: bool,
72 steps: usize,
73 },
74 AgentOutput { subtask_id: String, output: String },
76 AgentError { subtask_id: String, error: String },
78 StageComplete {
80 stage: usize,
81 completed: usize,
82 failed: usize,
83 },
84 Complete { success: bool, stats: SwarmStats },
86 Error(String),
88}
89
90#[derive(Debug, Clone)]
92pub struct SubTaskInfo {
93 pub id: String,
94 pub name: String,
95 pub status: SubTaskStatus,
96 pub stage: usize,
97 pub dependencies: Vec<String>,
98 pub agent_name: Option<String>,
99 pub current_tool: Option<String>,
100 pub steps: usize,
101 pub max_steps: usize,
102 pub tool_call_history: Vec<AgentToolCallDetail>,
104 pub messages: Vec<AgentMessageEntry>,
106 pub output: Option<String>,
108 pub error: Option<String>,
110}
111
112#[derive(Debug)]
114pub struct SwarmViewState {
115 pub active: bool,
117 pub task: String,
119 pub subtasks: Vec<SubTaskInfo>,
121 pub current_stage: usize,
123 pub total_stages: usize,
125 pub stats: Option<SwarmStats>,
127 pub error: Option<String>,
129 pub complete: bool,
131 pub selected_index: usize,
133 pub detail_mode: bool,
135 pub detail_scroll: usize,
137 pub list_state: ListState,
139}
140
141impl Default for SwarmViewState {
142 fn default() -> Self {
143 Self {
144 active: false,
145 task: String::new(),
146 subtasks: Vec::new(),
147 current_stage: 0,
148 total_stages: 0,
149 stats: None,
150 error: None,
151 complete: false,
152 selected_index: 0,
153 detail_mode: false,
154 detail_scroll: 0,
155 list_state: ListState::default(),
156 }
157 }
158}
159
160impl SwarmViewState {
161 pub fn new() -> Self {
162 Self::default()
163 }
164
165 pub fn handle_event(&mut self, event: SwarmEvent) {
167 match event {
168 SwarmEvent::Started {
169 task,
170 total_subtasks,
171 } => {
172 self.active = true;
173 self.task = task;
174 self.subtasks.clear();
175 self.current_stage = 0;
176 self.complete = false;
177 self.error = None;
178 self.selected_index = 0;
179 self.detail_mode = false;
180 self.detail_scroll = 0;
181 self.list_state = ListState::default();
182 self.subtasks.reserve(total_subtasks);
184 }
185 SwarmEvent::Decomposed { subtasks } => {
186 self.subtasks = subtasks;
187 self.total_stages = self.subtasks.iter().map(|s| s.stage).max().unwrap_or(0) + 1;
188 if !self.subtasks.is_empty() {
189 self.list_state.select(Some(0));
190 }
191 }
192 SwarmEvent::SubTaskUpdate {
193 id,
194 name,
195 status,
196 agent_name,
197 } => {
198 if let Some(task) = self.subtasks.iter_mut().find(|t| t.id == id) {
199 task.status = status;
200 task.name = name;
201 if agent_name.is_some() {
202 task.agent_name = agent_name;
203 }
204 }
205 }
206 SwarmEvent::AgentStarted {
207 subtask_id,
208 agent_name,
209 ..
210 } => {
211 if let Some(task) = self.subtasks.iter_mut().find(|t| t.id == subtask_id) {
212 task.status = SubTaskStatus::Running;
213 task.agent_name = Some(agent_name);
214 }
215 }
216 SwarmEvent::AgentToolCall {
217 subtask_id,
218 tool_name,
219 } => {
220 if let Some(task) = self.subtasks.iter_mut().find(|t| t.id == subtask_id) {
221 task.current_tool = Some(tool_name);
222 task.steps += 1;
223 }
224 }
225 SwarmEvent::AgentToolCallDetail { subtask_id, detail } => {
226 if let Some(task) = self.subtasks.iter_mut().find(|t| t.id == subtask_id) {
227 task.current_tool = Some(detail.tool_name.clone());
228 task.steps += 1;
229 task.tool_call_history.push(detail);
230 }
231 }
232 SwarmEvent::AgentMessage { subtask_id, entry } => {
233 if let Some(task) = self.subtasks.iter_mut().find(|t| t.id == subtask_id) {
234 task.messages.push(entry);
235 }
236 }
237 SwarmEvent::AgentComplete {
238 subtask_id,
239 success,
240 steps,
241 } => {
242 if let Some(task) = self.subtasks.iter_mut().find(|t| t.id == subtask_id) {
243 task.status = if success {
244 SubTaskStatus::Completed
245 } else {
246 SubTaskStatus::Failed
247 };
248 task.steps = steps;
249 task.current_tool = None;
250 }
251 }
252 SwarmEvent::AgentOutput { subtask_id, output } => {
253 if let Some(task) = self.subtasks.iter_mut().find(|t| t.id == subtask_id) {
254 task.output = Some(output);
255 }
256 }
257 SwarmEvent::AgentError { subtask_id, error } => {
258 if let Some(task) = self.subtasks.iter_mut().find(|t| t.id == subtask_id) {
259 task.error = Some(error);
260 }
261 }
262 SwarmEvent::StageComplete { stage, .. } => {
263 self.current_stage = stage + 1;
264 }
265 SwarmEvent::Complete { success: _, stats } => {
266 self.stats = Some(stats);
267 self.complete = true;
268 }
269 SwarmEvent::Error(err) => {
270 self.error = Some(err);
271 }
272 }
273 }
274
275 pub fn select_prev(&mut self) {
277 if self.subtasks.is_empty() {
278 return;
279 }
280 self.selected_index = self.selected_index.saturating_sub(1);
281 self.list_state.select(Some(self.selected_index));
282 }
283
284 pub fn select_next(&mut self) {
286 if self.subtasks.is_empty() {
287 return;
288 }
289 self.selected_index = (self.selected_index + 1).min(self.subtasks.len() - 1);
290 self.list_state.select(Some(self.selected_index));
291 }
292
293 pub fn enter_detail(&mut self) {
295 if !self.subtasks.is_empty() {
296 self.detail_mode = true;
297 self.detail_scroll = 0;
298 }
299 }
300
301 pub fn exit_detail(&mut self) {
303 self.detail_mode = false;
304 self.detail_scroll = 0;
305 }
306
307 pub fn detail_scroll_down(&mut self, amount: usize) {
309 self.detail_scroll = self.detail_scroll.saturating_add(amount);
310 }
311
312 pub fn detail_scroll_up(&mut self, amount: usize) {
314 self.detail_scroll = self.detail_scroll.saturating_sub(amount);
315 }
316
317 pub fn selected_subtask(&self) -> Option<&SubTaskInfo> {
319 self.subtasks.get(self.selected_index)
320 }
321
322 pub fn status_counts(&self) -> (usize, usize, usize, usize) {
324 let mut pending = 0;
325 let mut running = 0;
326 let mut completed = 0;
327 let mut failed = 0;
328
329 for task in &self.subtasks {
330 match task.status {
331 SubTaskStatus::Pending | SubTaskStatus::Blocked => pending += 1,
332 SubTaskStatus::Running => running += 1,
333 SubTaskStatus::Completed => completed += 1,
334 SubTaskStatus::Failed | SubTaskStatus::Cancelled | SubTaskStatus::TimedOut => {
335 failed += 1
336 }
337 }
338 }
339
340 (pending, running, completed, failed)
341 }
342
343 pub fn progress(&self) -> f64 {
345 if self.subtasks.is_empty() {
346 return 0.0;
347 }
348 let (_, _, completed, failed) = self.status_counts();
349 ((completed + failed) as f64 / self.subtasks.len() as f64) * 100.0
350 }
351}
352
353pub fn render_swarm_view(f: &mut Frame, state: &mut SwarmViewState, area: Rect) {
355 if state.detail_mode {
357 render_agent_detail(f, state, area);
358 return;
359 }
360
361 let chunks = Layout::default()
362 .direction(Direction::Vertical)
363 .constraints([
364 Constraint::Length(3), Constraint::Length(3), Constraint::Min(1), Constraint::Length(3), ])
369 .split(area);
370
371 render_header(f, state, chunks[0]);
373
374 render_stage_progress(f, state, chunks[1]);
376
377 render_subtask_list(f, state, chunks[2]);
379
380 render_stats(f, state, chunks[3]);
382}
383
384fn render_header(f: &mut Frame, state: &SwarmViewState, area: Rect) {
385 let (pending, running, completed, failed) = state.status_counts();
386 let total = state.subtasks.len();
387
388 let title = if state.complete {
389 if state.error.is_some() {
390 " Swarm [ERROR] "
391 } else {
392 " Swarm [COMPLETE] "
393 }
394 } else {
395 " Swarm [ACTIVE] "
396 };
397
398 let status_line = Line::from(vec![
399 Span::styled("Task: ", Style::default().fg(Color::DarkGray)),
400 Span::styled(
401 truncate_str(&state.task, 47),
402 Style::default().fg(Color::White),
403 ),
404 Span::raw(" "),
405 Span::styled(format!("⏳{}", pending), Style::default().fg(Color::Yellow)),
406 Span::raw(" "),
407 Span::styled(format!("⚡{}", running), Style::default().fg(Color::Cyan)),
408 Span::raw(" "),
409 Span::styled(format!("✓{}", completed), Style::default().fg(Color::Green)),
410 Span::raw(" "),
411 Span::styled(format!("✗{}", failed), Style::default().fg(Color::Red)),
412 Span::raw(" "),
413 Span::styled(format!("/{}", total), Style::default().fg(Color::DarkGray)),
414 ]);
415
416 let paragraph = Paragraph::new(status_line).block(
417 Block::default()
418 .borders(Borders::ALL)
419 .title(title)
420 .border_style(Style::default().fg(if state.complete {
421 if state.error.is_some() {
422 Color::Red
423 } else {
424 Color::Green
425 }
426 } else {
427 Color::Cyan
428 })),
429 );
430
431 f.render_widget(paragraph, area);
432}
433
434fn render_stage_progress(f: &mut Frame, state: &SwarmViewState, area: Rect) {
435 let progress = state.progress();
436 let label = format!(
437 "Stage {}/{} - {:.0}%",
438 state.current_stage.min(state.total_stages),
439 state.total_stages,
440 progress
441 );
442
443 let gauge = Gauge::default()
444 .block(Block::default().borders(Borders::ALL).title(" Progress "))
445 .gauge_style(Style::default().fg(Color::Cyan).bg(Color::DarkGray))
446 .percent(progress as u16)
447 .label(label);
448
449 f.render_widget(gauge, area);
450}
451
452fn render_subtask_list(f: &mut Frame, state: &mut SwarmViewState, area: Rect) {
453 state.list_state.select(Some(state.selected_index));
455
456 let items: Vec<ListItem> = state
457 .subtasks
458 .iter()
459 .map(|task| {
460 let (icon, color) = match task.status {
461 SubTaskStatus::Pending => ("○", Color::DarkGray),
462 SubTaskStatus::Blocked => ("⊘", Color::Yellow),
463 SubTaskStatus::Running => ("●", Color::Cyan),
464 SubTaskStatus::Completed => ("✓", Color::Green),
465 SubTaskStatus::Failed => ("✗", Color::Red),
466 SubTaskStatus::Cancelled => ("⊗", Color::DarkGray),
467 SubTaskStatus::TimedOut => ("⏱", Color::Red),
468 };
469
470 let mut spans = vec![
471 Span::styled(format!("{} ", icon), Style::default().fg(color)),
472 Span::styled(
473 format!("[S{}] ", task.stage),
474 Style::default().fg(Color::DarkGray),
475 ),
476 Span::styled(&task.name, Style::default().fg(Color::White)),
477 ];
478
479 if task.status == SubTaskStatus::Running {
481 if let Some(ref agent) = task.agent_name {
482 spans.push(Span::styled(
483 format!(" → {}", agent),
484 Style::default().fg(Color::Cyan),
485 ));
486 }
487 if let Some(ref tool) = task.current_tool {
488 spans.push(Span::styled(
489 format!(" [{}]", tool),
490 Style::default()
491 .fg(Color::Yellow)
492 .add_modifier(Modifier::DIM),
493 ));
494 }
495 }
496
497 if task.steps > 0 {
499 spans.push(Span::styled(
500 format!(" ({}/{})", task.steps, task.max_steps),
501 Style::default().fg(Color::DarkGray),
502 ));
503 }
504
505 ListItem::new(Line::from(spans))
506 })
507 .collect();
508
509 let title = if state.subtasks.is_empty() {
510 " SubTasks (none yet) "
511 } else {
512 " SubTasks (↑↓:select Enter:detail) "
513 };
514
515 let list = List::new(items)
516 .block(Block::default().borders(Borders::ALL).title(title))
517 .highlight_style(
518 Style::default()
519 .add_modifier(Modifier::BOLD)
520 .bg(Color::DarkGray),
521 )
522 .highlight_symbol("▶ ");
523
524 f.render_stateful_widget(list, area, &mut state.list_state);
525}
526
527fn render_agent_detail(f: &mut Frame, state: &SwarmViewState, area: Rect) {
529 let task = match state.selected_subtask() {
530 Some(t) => t,
531 None => {
532 let p = Paragraph::new("No subtask selected").block(
533 Block::default()
534 .borders(Borders::ALL)
535 .title(" Agent Detail "),
536 );
537 f.render_widget(p, area);
538 return;
539 }
540 };
541
542 let chunks = Layout::default()
543 .direction(Direction::Vertical)
544 .constraints([
545 Constraint::Length(5), Constraint::Min(1), Constraint::Length(1), ])
549 .split(area);
550
551 let (status_icon, status_color) = match task.status {
553 SubTaskStatus::Pending => ("○ Pending", Color::DarkGray),
554 SubTaskStatus::Blocked => ("⊘ Blocked", Color::Yellow),
555 SubTaskStatus::Running => ("● Running", Color::Cyan),
556 SubTaskStatus::Completed => ("✓ Completed", Color::Green),
557 SubTaskStatus::Failed => ("✗ Failed", Color::Red),
558 SubTaskStatus::Cancelled => ("⊗ Cancelled", Color::DarkGray),
559 SubTaskStatus::TimedOut => ("⏱ Timed Out", Color::Red),
560 };
561
562 let agent_label = task.agent_name.as_deref().unwrap_or("(unassigned)");
563 let header_lines = vec![
564 Line::from(vec![
565 Span::styled("Task: ", Style::default().fg(Color::DarkGray)),
566 Span::styled(
567 &task.name,
568 Style::default()
569 .fg(Color::White)
570 .add_modifier(Modifier::BOLD),
571 ),
572 ]),
573 Line::from(vec![
574 Span::styled("Agent: ", Style::default().fg(Color::DarkGray)),
575 Span::styled(agent_label, Style::default().fg(Color::Cyan)),
576 Span::raw(" "),
577 Span::styled("Status: ", Style::default().fg(Color::DarkGray)),
578 Span::styled(status_icon, Style::default().fg(status_color)),
579 Span::raw(" "),
580 Span::styled("Stage: ", Style::default().fg(Color::DarkGray)),
581 Span::styled(format!("{}", task.stage), Style::default().fg(Color::White)),
582 Span::raw(" "),
583 Span::styled("Steps: ", Style::default().fg(Color::DarkGray)),
584 Span::styled(
585 format!("{}/{}", task.steps, task.max_steps),
586 Style::default().fg(Color::White),
587 ),
588 ]),
589 Line::from(vec![
590 Span::styled("Deps: ", Style::default().fg(Color::DarkGray)),
591 Span::styled(
592 if task.dependencies.is_empty() {
593 "none".to_string()
594 } else {
595 task.dependencies.join(", ")
596 },
597 Style::default().fg(Color::DarkGray),
598 ),
599 ]),
600 ];
601
602 let title = format!(" Agent Detail: {} ", task.id);
603 let header = Paragraph::new(header_lines).block(
604 Block::default()
605 .borders(Borders::ALL)
606 .title(title)
607 .border_style(Style::default().fg(status_color)),
608 );
609 f.render_widget(header, chunks[0]);
610
611 let mut content_lines: Vec<Line> = Vec::new();
613
614 if !task.tool_call_history.is_empty() {
616 content_lines.push(Line::from(Span::styled(
617 "─── Tool Call History ───",
618 Style::default()
619 .fg(Color::Cyan)
620 .add_modifier(Modifier::BOLD),
621 )));
622 content_lines.push(Line::from(""));
623
624 for (i, tc) in task.tool_call_history.iter().enumerate() {
625 let icon = if tc.success { "✓" } else { "✗" };
626 let icon_color = if tc.success { Color::Green } else { Color::Red };
627 content_lines.push(Line::from(vec![
628 Span::styled(format!(" {icon} "), Style::default().fg(icon_color)),
629 Span::styled(format!("#{} ", i + 1), Style::default().fg(Color::DarkGray)),
630 Span::styled(
631 &tc.tool_name,
632 Style::default()
633 .fg(Color::Yellow)
634 .add_modifier(Modifier::BOLD),
635 ),
636 ]));
637 if !tc.input_preview.is_empty() {
638 content_lines.push(Line::from(vec![
639 Span::raw(" "),
640 Span::styled("in: ", Style::default().fg(Color::DarkGray)),
641 Span::styled(
642 truncate_str(&tc.input_preview, 80),
643 Style::default().fg(Color::White),
644 ),
645 ]));
646 }
647 if !tc.output_preview.is_empty() {
648 content_lines.push(Line::from(vec![
649 Span::raw(" "),
650 Span::styled("out: ", Style::default().fg(Color::DarkGray)),
651 Span::styled(
652 truncate_str(&tc.output_preview, 80),
653 Style::default().fg(Color::White),
654 ),
655 ]));
656 }
657 }
658 content_lines.push(Line::from(""));
659 } else if task.steps > 0 {
660 content_lines.push(Line::from(Span::styled(
662 format!("─── {} tool calls (no detail captured) ───", task.steps),
663 Style::default().fg(Color::DarkGray),
664 )));
665 content_lines.push(Line::from(""));
666 }
667
668 if !task.messages.is_empty() {
670 content_lines.push(Line::from(Span::styled(
671 "─── Conversation ───",
672 Style::default()
673 .fg(Color::Cyan)
674 .add_modifier(Modifier::BOLD),
675 )));
676 content_lines.push(Line::from(""));
677
678 for msg in &task.messages {
679 let (role_color, role_label) = match msg.role.as_str() {
680 "user" => (Color::White, "USER"),
681 "assistant" => (Color::Cyan, "ASST"),
682 "tool" => (Color::Yellow, "TOOL"),
683 "system" => (Color::DarkGray, "SYS "),
684 _ => (Color::White, " "),
685 };
686 content_lines.push(Line::from(vec![Span::styled(
687 format!(" [{role_label}] "),
688 Style::default().fg(role_color).add_modifier(Modifier::BOLD),
689 )]));
690 for line in msg.content.lines().take(10) {
692 content_lines.push(Line::from(vec![
693 Span::raw(" "),
694 Span::styled(line, Style::default().fg(Color::White)),
695 ]));
696 }
697 if msg.content.lines().count() > 10 {
698 content_lines.push(Line::from(vec![
699 Span::raw(" "),
700 Span::styled("... (truncated)", Style::default().fg(Color::DarkGray)),
701 ]));
702 }
703 content_lines.push(Line::from(""));
704 }
705 }
706
707 if let Some(ref output) = task.output {
709 content_lines.push(Line::from(Span::styled(
710 "─── Output ───",
711 Style::default()
712 .fg(Color::Green)
713 .add_modifier(Modifier::BOLD),
714 )));
715 content_lines.push(Line::from(""));
716 for line in output.lines().take(20) {
717 content_lines.push(Line::from(Span::styled(
718 line,
719 Style::default().fg(Color::White),
720 )));
721 }
722 if output.lines().count() > 20 {
723 content_lines.push(Line::from(Span::styled(
724 "... (truncated)",
725 Style::default().fg(Color::DarkGray),
726 )));
727 }
728 content_lines.push(Line::from(""));
729 }
730
731 if let Some(ref err) = task.error {
733 content_lines.push(Line::from(Span::styled(
734 "─── Error ───",
735 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
736 )));
737 content_lines.push(Line::from(""));
738 for line in err.lines() {
739 content_lines.push(Line::from(Span::styled(
740 line,
741 Style::default().fg(Color::Red),
742 )));
743 }
744 content_lines.push(Line::from(""));
745 }
746
747 if content_lines.is_empty() {
749 content_lines.push(Line::from(Span::styled(
750 " Waiting for agent activity...",
751 Style::default().fg(Color::DarkGray),
752 )));
753 }
754
755 let content = Paragraph::new(content_lines)
756 .block(Block::default().borders(Borders::ALL))
757 .wrap(Wrap { trim: false })
758 .scroll((state.detail_scroll as u16, 0));
759 f.render_widget(content, chunks[1]);
760
761 let hints = Paragraph::new(Line::from(vec![
763 Span::styled(" Esc", Style::default().fg(Color::Yellow)),
764 Span::raw(": Back "),
765 Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
766 Span::raw(": Scroll "),
767 Span::styled("↑/↓", Style::default().fg(Color::Yellow)),
768 Span::raw(": Prev/Next agent"),
769 ]));
770 f.render_widget(hints, chunks[2]);
771}
772
773fn truncate_str(s: &str, max: usize) -> String {
775 if s.len() <= max {
776 s.replace('\n', " ")
777 } else {
778 let mut end = max;
779 while end > 0 && !s.is_char_boundary(end) {
780 end -= 1;
781 }
782 format!("{}...", s[..end].replace('\n', " "))
783 }
784}
785
786fn render_stats(f: &mut Frame, state: &SwarmViewState, area: Rect) {
787 let content = if let Some(ref stats) = state.stats {
788 Line::from(vec![
789 Span::styled("Time: ", Style::default().fg(Color::DarkGray)),
790 Span::styled(
791 format!("{:.1}s", stats.execution_time_ms as f64 / 1000.0),
792 Style::default().fg(Color::White),
793 ),
794 Span::raw(" "),
795 Span::styled("Speedup: ", Style::default().fg(Color::DarkGray)),
796 Span::styled(
797 format!("{:.1}x", stats.speedup_factor),
798 Style::default().fg(Color::Green),
799 ),
800 Span::raw(" "),
801 Span::styled("Tool calls: ", Style::default().fg(Color::DarkGray)),
802 Span::styled(
803 format!("{}", stats.total_tool_calls),
804 Style::default().fg(Color::White),
805 ),
806 Span::raw(" "),
807 Span::styled("Critical path: ", Style::default().fg(Color::DarkGray)),
808 Span::styled(
809 format!("{}", stats.critical_path_length),
810 Style::default().fg(Color::White),
811 ),
812 ])
813 } else if let Some(ref err) = state.error {
814 Line::from(vec![Span::styled(
815 format!("Error: {}", err),
816 Style::default().fg(Color::Red),
817 )])
818 } else {
819 Line::from(vec![Span::styled(
820 "Executing...",
821 Style::default().fg(Color::DarkGray),
822 )])
823 };
824
825 let paragraph = Paragraph::new(content)
826 .block(Block::default().borders(Borders::ALL).title(" Stats "))
827 .wrap(Wrap { trim: true });
828
829 f.render_widget(paragraph, area);
830}