Skip to main content

codetether_agent/tui/
swarm_view.rs

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