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