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(
278                        &mut task.output,
279                        &output,
280                        "swarm output",
281                    );
282                }
283            }
284            SwarmEvent::AgentError { subtask_id, error } => {
285                if let Some(task) = self.subtasks.iter_mut().find(|t| t.id == subtask_id) {
286                    task.error = Some(error);
287                }
288            }
289            SwarmEvent::StageComplete { stage, .. } => {
290                self.current_stage = stage + 1;
291            }
292            SwarmEvent::Complete { success: _, stats } => {
293                self.stats = Some(stats);
294                self.complete = true;
295            }
296            SwarmEvent::Error(err) => {
297                self.error = Some(err);
298            }
299        }
300    }
301
302    /// Move selection up
303    pub fn select_prev(&mut self) {
304        if self.subtasks.is_empty() {
305            return;
306        }
307        self.selected_index = self.selected_index.saturating_sub(1);
308        self.list_state.select(Some(self.selected_index));
309    }
310
311    /// Move selection down
312    pub fn select_next(&mut self) {
313        if self.subtasks.is_empty() {
314            return;
315        }
316        self.selected_index = (self.selected_index + 1).min(self.subtasks.len() - 1);
317        self.list_state.select(Some(self.selected_index));
318    }
319
320    /// Enter detail mode for the selected subtask
321    pub fn enter_detail(&mut self) {
322        if !self.subtasks.is_empty() {
323            self.detail_mode = true;
324            self.detail_scroll = 0;
325        }
326    }
327
328    /// Exit detail mode, return to list
329    pub fn exit_detail(&mut self) {
330        self.detail_mode = false;
331        self.detail_scroll = 0;
332    }
333
334    /// Scroll detail view down
335    pub fn detail_scroll_down(&mut self, amount: usize) {
336        self.detail_scroll = self.detail_scroll.saturating_add(amount);
337    }
338
339    /// Scroll detail view up
340    pub fn detail_scroll_up(&mut self, amount: usize) {
341        self.detail_scroll = self.detail_scroll.saturating_sub(amount);
342    }
343
344    /// Get the currently selected subtask
345    pub fn selected_subtask(&self) -> Option<&SubTaskInfo> {
346        self.subtasks.get(self.selected_index)
347    }
348
349    /// Get count of tasks by status
350    pub fn status_counts(&self) -> (usize, usize, usize, usize) {
351        let mut pending = 0;
352        let mut running = 0;
353        let mut completed = 0;
354        let mut failed = 0;
355
356        for task in &self.subtasks {
357            match task.status {
358                SubTaskStatus::Pending | SubTaskStatus::Blocked => pending += 1,
359                SubTaskStatus::Running => running += 1,
360                SubTaskStatus::Completed => completed += 1,
361                SubTaskStatus::Failed | SubTaskStatus::Cancelled | SubTaskStatus::TimedOut => {
362                    failed += 1
363                }
364            }
365        }
366
367        (pending, running, completed, failed)
368    }
369
370    /// Overall progress as percentage
371    pub fn progress(&self) -> f64 {
372        if self.subtasks.is_empty() {
373            return 0.0;
374        }
375        let (_, _, completed, failed) = self.status_counts();
376        ((completed + failed) as f64 / self.subtasks.len() as f64) * 100.0
377    }
378}
379
380/// Render the swarm view (takes &mut for StatefulWidget rendering)
381pub fn render_swarm_view(f: &mut Frame, state: &mut SwarmViewState, area: Rect) {
382    // If in detail mode, render full-screen detail for selected agent
383    if state.detail_mode {
384        render_agent_detail(f, state, area);
385        return;
386    }
387
388    let chunks = Layout::default()
389        .direction(Direction::Vertical)
390        .constraints([
391            Constraint::Length(3), // Header with task + progress
392            Constraint::Length(3), // Stage progress
393            Constraint::Min(1),    // Subtask list
394            Constraint::Length(3), // Stats
395        ])
396        .split(area);
397
398    // Header
399    render_header(f, state, chunks[0]);
400
401    // Stage progress
402    render_stage_progress(f, state, chunks[1]);
403
404    // Subtask list (stateful for selection)
405    render_subtask_list(f, state, chunks[2]);
406
407    // Stats footer
408    render_stats(f, state, chunks[3]);
409}
410
411fn render_header(f: &mut Frame, state: &SwarmViewState, area: Rect) {
412    let (pending, running, completed, failed) = state.status_counts();
413    let total = state.subtasks.len();
414
415    let title = if state.complete {
416        if state.error.is_some() {
417            " Swarm [ERROR] "
418        } else {
419            " Swarm [COMPLETE] "
420        }
421    } else if state.active {
422        " Swarm [ACTIVE] "
423    } else {
424        " Swarm [IDLE] "
425    };
426
427    let status_line = Line::from(vec![
428        Span::styled("Task: ", Style::default().fg(Color::DarkGray)),
429        Span::styled(
430            truncate_str(&state.task, 47),
431            Style::default().fg(Color::White),
432        ),
433        Span::raw("  "),
434        Span::styled(format!("⏳{}", pending), Style::default().fg(Color::Yellow)),
435        Span::raw(" "),
436        Span::styled(format!("⚡{}", running), Style::default().fg(Color::Cyan)),
437        Span::raw(" "),
438        Span::styled(format!("✓{}", completed), Style::default().fg(Color::Green)),
439        Span::raw(" "),
440        Span::styled(format!("✗{}", failed), Style::default().fg(Color::Red)),
441        Span::raw(" "),
442        Span::styled(format!("/{}", total), Style::default().fg(Color::DarkGray)),
443    ]);
444
445    let paragraph = Paragraph::new(status_line).block(
446        Block::default()
447            .borders(Borders::ALL)
448            .title(title)
449            .border_style(Style::default().fg(if state.complete {
450                if state.error.is_some() {
451                    Color::Red
452                } else {
453                    Color::Green
454                }
455            } else {
456                Color::Cyan
457            })),
458    );
459
460    f.render_widget(paragraph, area);
461}
462
463fn render_stage_progress(f: &mut Frame, state: &SwarmViewState, area: Rect) {
464    let progress = state.progress();
465    let label = format!(
466        "Stage {}/{} - {:.0}%",
467        state.current_stage.min(state.total_stages),
468        state.total_stages,
469        progress
470    );
471
472    let gauge = Gauge::default()
473        .block(Block::default().borders(Borders::ALL).title(" Progress "))
474        .gauge_style(Style::default().fg(Color::Cyan).bg(Color::DarkGray))
475        .percent(progress as u16)
476        .label(label);
477
478    f.render_widget(gauge, area);
479}
480
481fn render_subtask_list(f: &mut Frame, state: &mut SwarmViewState, area: Rect) {
482    // Sync ListState selection from selected_index
483    state.list_state.select(Some(state.selected_index));
484
485    let items: Vec<ListItem> = state
486        .subtasks
487        .iter()
488        .map(|task| {
489            let (icon, color) = match task.status {
490                SubTaskStatus::Pending => ("○", Color::DarkGray),
491                SubTaskStatus::Blocked => ("⊘", Color::Yellow),
492                SubTaskStatus::Running => ("●", Color::Cyan),
493                SubTaskStatus::Completed => ("✓", Color::Green),
494                SubTaskStatus::Failed => ("✗", Color::Red),
495                SubTaskStatus::Cancelled => ("⊗", Color::DarkGray),
496                SubTaskStatus::TimedOut => ("⏱", Color::Red),
497            };
498
499            let mut spans = vec![
500                Span::styled(format!("{} ", icon), Style::default().fg(color)),
501                Span::styled(
502                    format!("[S{}] ", task.stage),
503                    Style::default().fg(Color::DarkGray),
504                ),
505                Span::styled(&task.name, Style::default().fg(Color::White)),
506            ];
507
508            // Show agent/tool info for running tasks
509            if task.status == SubTaskStatus::Running {
510                if let Some(ref agent) = task.agent_name {
511                    spans.push(Span::styled(
512                        format!(" → {}", agent),
513                        Style::default().fg(Color::Cyan),
514                    ));
515                }
516                if let Some(ref tool) = task.current_tool {
517                    spans.push(Span::styled(
518                        format!(" [{}]", tool),
519                        Style::default()
520                            .fg(Color::Yellow)
521                            .add_modifier(Modifier::DIM),
522                    ));
523                }
524            }
525
526            // Show step count
527            if task.steps > 0 {
528                spans.push(Span::styled(
529                    format!(" ({}/{})", task.steps, task.max_steps),
530                    Style::default().fg(Color::DarkGray),
531                ));
532            }
533
534            ListItem::new(Line::from(spans))
535        })
536        .collect();
537
538    let title = if state.subtasks.is_empty() {
539        " SubTasks (none yet) "
540    } else {
541        " SubTasks (↑↓:select  Enter:detail) "
542    };
543
544    let list = List::new(items)
545        .block(Block::default().borders(Borders::ALL).title(title))
546        .highlight_style(
547            Style::default()
548                .add_modifier(Modifier::BOLD)
549                .bg(Color::DarkGray),
550        )
551        .highlight_symbol("▶ ");
552
553    f.render_stateful_widget(list, area, &mut state.list_state);
554}
555
556/// Render a full-screen detail view for the selected sub-agent
557fn render_agent_detail(f: &mut Frame, state: &SwarmViewState, area: Rect) {
558    let task = match state.selected_subtask() {
559        Some(t) => t,
560        None => {
561            let p = Paragraph::new("No subtask selected").block(
562                Block::default()
563                    .borders(Borders::ALL)
564                    .title(" Agent Detail "),
565            );
566            f.render_widget(p, area);
567            return;
568        }
569    };
570
571    let chunks = Layout::default()
572        .direction(Direction::Vertical)
573        .constraints([
574            Constraint::Length(5), // Agent info header
575            Constraint::Min(1),    // Content (tool calls + messages + output)
576            Constraint::Length(1), // Key hints
577        ])
578        .split(area);
579
580    // --- Header: agent info ---
581    let (status_icon, status_color) = match task.status {
582        SubTaskStatus::Pending => ("○ Pending", Color::DarkGray),
583        SubTaskStatus::Blocked => ("⊘ Blocked", Color::Yellow),
584        SubTaskStatus::Running => ("● Running", Color::Cyan),
585        SubTaskStatus::Completed => ("✓ Completed", Color::Green),
586        SubTaskStatus::Failed => ("✗ Failed", Color::Red),
587        SubTaskStatus::Cancelled => ("⊗ Cancelled", Color::DarkGray),
588        SubTaskStatus::TimedOut => ("⏱ Timed Out", Color::Red),
589    };
590
591    let agent_label = task.agent_name.as_deref().unwrap_or("(unassigned)");
592    let header_lines = vec![
593        Line::from(vec![
594            Span::styled("Task: ", Style::default().fg(Color::DarkGray)),
595            Span::styled(
596                &task.name,
597                Style::default()
598                    .fg(Color::White)
599                    .add_modifier(Modifier::BOLD),
600            ),
601        ]),
602        Line::from(vec![
603            Span::styled("Agent: ", Style::default().fg(Color::DarkGray)),
604            Span::styled(agent_label, Style::default().fg(Color::Cyan)),
605            Span::raw("  "),
606            Span::styled("Status: ", Style::default().fg(Color::DarkGray)),
607            Span::styled(status_icon, Style::default().fg(status_color)),
608            Span::raw("  "),
609            Span::styled("Stage: ", Style::default().fg(Color::DarkGray)),
610            Span::styled(format!("{}", task.stage), Style::default().fg(Color::White)),
611            Span::raw("  "),
612            Span::styled("Steps: ", Style::default().fg(Color::DarkGray)),
613            Span::styled(
614                format!("{}/{}", task.steps, task.max_steps),
615                Style::default().fg(Color::White),
616            ),
617        ]),
618        Line::from(vec![
619            Span::styled("Deps: ", Style::default().fg(Color::DarkGray)),
620            Span::styled(
621                if task.dependencies.is_empty() {
622                    "none".to_string()
623                } else {
624                    task.dependencies.join(", ")
625                },
626                Style::default().fg(Color::DarkGray),
627            ),
628        ]),
629    ];
630
631    let title = format!(" Agent Detail: {} ", task.id);
632    let header = Paragraph::new(header_lines).block(
633        Block::default()
634            .borders(Borders::ALL)
635            .title(title)
636            .border_style(Style::default().fg(status_color)),
637    );
638    f.render_widget(header, chunks[0]);
639
640    // --- Content area: tool history, messages, output ---
641    let mut content_lines: Vec<Line> = Vec::new();
642
643    // Tool call history section
644    if !task.tool_call_history.is_empty() {
645        content_lines.push(Line::from(Span::styled(
646            "─── Tool Call History ───",
647            Style::default()
648                .fg(Color::Cyan)
649                .add_modifier(Modifier::BOLD),
650        )));
651        content_lines.push(Line::from(""));
652
653        for (i, tc) in task.tool_call_history.iter().enumerate() {
654            let icon = if tc.success { "✓" } else { "✗" };
655            let icon_color = if tc.success { Color::Green } else { Color::Red };
656            content_lines.push(Line::from(vec![
657                Span::styled(format!(" {icon} "), Style::default().fg(icon_color)),
658                Span::styled(format!("#{} ", i + 1), Style::default().fg(Color::DarkGray)),
659                Span::styled(
660                    &tc.tool_name,
661                    Style::default()
662                        .fg(Color::Yellow)
663                        .add_modifier(Modifier::BOLD),
664                ),
665            ]));
666            if !tc.input_preview.is_empty() {
667                content_lines.push(Line::from(vec![
668                    Span::raw("     "),
669                    Span::styled("in: ", Style::default().fg(Color::DarkGray)),
670                    Span::styled(
671                        truncate_str(&tc.input_preview, 80),
672                        Style::default().fg(Color::White),
673                    ),
674                ]));
675            }
676            if !tc.output_preview.is_empty() {
677                content_lines.push(Line::from(vec![
678                    Span::raw("     "),
679                    Span::styled("out: ", Style::default().fg(Color::DarkGray)),
680                    Span::styled(
681                        truncate_str(&tc.output_preview, 80),
682                        Style::default().fg(Color::White),
683                    ),
684                ]));
685            }
686        }
687        content_lines.push(Line::from(""));
688    } else if task.steps > 0 {
689        // At least show that tool calls happened even without detail
690        content_lines.push(Line::from(Span::styled(
691            format!("─── {} tool calls (no detail captured) ───", task.steps),
692            Style::default().fg(Color::DarkGray),
693        )));
694        content_lines.push(Line::from(""));
695    }
696
697    // Messages section
698    if !task.messages.is_empty() {
699        content_lines.push(Line::from(Span::styled(
700            "─── Conversation ───",
701            Style::default()
702                .fg(Color::Cyan)
703                .add_modifier(Modifier::BOLD),
704        )));
705        content_lines.push(Line::from(""));
706
707        for msg in &task.messages {
708            let (role_color, role_label) = match msg.role.as_str() {
709                "user" => (Color::White, "USER"),
710                "assistant" => (Color::Cyan, "ASST"),
711                "tool" => (Color::Yellow, "TOOL"),
712                "system" => (Color::DarkGray, "SYS "),
713                _ => (Color::White, "    "),
714            };
715            content_lines.push(Line::from(vec![Span::styled(
716                format!(" [{role_label}] "),
717                Style::default().fg(role_color).add_modifier(Modifier::BOLD),
718            )]));
719            // Show message content (truncated lines)
720            for line in msg.content.lines().take(10) {
721                content_lines.push(Line::from(vec![
722                    Span::raw("   "),
723                    Span::styled(line, Style::default().fg(Color::White)),
724                ]));
725            }
726            if msg.content.lines().count() > 10 {
727                content_lines.push(Line::from(vec![
728                    Span::raw("   "),
729                    Span::styled("... (truncated)", Style::default().fg(Color::DarkGray)),
730                ]));
731            }
732            content_lines.push(Line::from(""));
733        }
734    }
735
736    // Output section
737    if let Some(ref output) = task.output {
738        content_lines.push(Line::from(Span::styled(
739            "─── Output ───",
740            Style::default()
741                .fg(Color::Green)
742                .add_modifier(Modifier::BOLD),
743        )));
744        content_lines.push(Line::from(""));
745        for line in output.lines().take(20) {
746            content_lines.push(Line::from(Span::styled(
747                line,
748                Style::default().fg(Color::White),
749            )));
750        }
751        if output.lines().count() > 20 {
752            content_lines.push(Line::from(Span::styled(
753                "... (truncated)",
754                Style::default().fg(Color::DarkGray),
755            )));
756        }
757        content_lines.push(Line::from(""));
758    }
759
760    // Error section
761    if let Some(ref err) = task.error {
762        content_lines.push(Line::from(Span::styled(
763            "─── Error ───",
764            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
765        )));
766        content_lines.push(Line::from(""));
767        for line in err.lines() {
768            content_lines.push(Line::from(Span::styled(
769                line,
770                Style::default().fg(Color::Red),
771            )));
772        }
773        content_lines.push(Line::from(""));
774    }
775
776    // If nothing to show
777    if content_lines.is_empty() {
778        content_lines.push(Line::from(Span::styled(
779            "  Waiting for agent activity...",
780            Style::default().fg(Color::DarkGray),
781        )));
782    }
783
784    let content = Paragraph::new(content_lines)
785        .block(Block::default().borders(Borders::ALL))
786        .wrap(Wrap { trim: false })
787        .scroll((state.detail_scroll as u16, 0));
788    f.render_widget(content, chunks[1]);
789
790    // --- Key hints bar ---
791    let hints = Paragraph::new(Line::from(vec![
792        Span::styled(" Esc", Style::default().fg(Color::Yellow)),
793        Span::raw(": Back  "),
794        Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
795        Span::raw(": Scroll  "),
796        Span::styled("↑/↓", Style::default().fg(Color::Yellow)),
797        Span::raw(": Prev/Next agent"),
798    ]));
799    f.render_widget(hints, chunks[2]);
800}
801
802/// Truncate a string for display, respecting char boundaries
803fn truncate_str(s: &str, max: usize) -> String {
804    if s.len() <= max {
805        s.replace('\n', " ")
806    } else {
807        let mut end = max;
808        while end > 0 && !s.is_char_boundary(end) {
809            end -= 1;
810        }
811        format!("{}...", s[..end].replace('\n', " "))
812    }
813}
814
815fn render_stats(f: &mut Frame, state: &SwarmViewState, area: Rect) {
816    let content = if let Some(ref stats) = state.stats {
817        Line::from(vec![
818            Span::styled("Time: ", Style::default().fg(Color::DarkGray)),
819            Span::styled(
820                format!("{:.1}s", stats.execution_time_ms as f64 / 1000.0),
821                Style::default().fg(Color::White),
822            ),
823            Span::raw("  "),
824            Span::styled("Speedup: ", Style::default().fg(Color::DarkGray)),
825            Span::styled(
826                format!("{:.1}x", stats.speedup_factor),
827                Style::default().fg(Color::Green),
828            ),
829            Span::raw("  "),
830            Span::styled("Tool calls: ", Style::default().fg(Color::DarkGray)),
831            Span::styled(
832                format!("{}", stats.total_tool_calls),
833                Style::default().fg(Color::White),
834            ),
835            Span::raw("  "),
836            Span::styled("Critical path: ", Style::default().fg(Color::DarkGray)),
837            Span::styled(
838                format!("{}", stats.critical_path_length),
839                Style::default().fg(Color::White),
840            ),
841        ])
842    } else if let Some(ref err) = state.error {
843        Line::from(vec![Span::styled(
844            format!("Error: {}", err),
845            Style::default().fg(Color::Red),
846        )])
847    } else {
848        Line::from(vec![Span::styled(
849            "Executing...",
850            Style::default().fg(Color::DarkGray),
851        )])
852    };
853
854    let paragraph = Paragraph::new(content)
855        .block(Block::default().borders(Borders::ALL).title(" Stats "))
856        .wrap(Wrap { trim: true });
857
858    f.render_widget(paragraph, area);
859}