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};
15
16/// A recorded tool call for a sub-agent
17#[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/// A conversation message entry for a sub-agent
26#[derive(Debug, Clone)]
27pub struct AgentMessageEntry {
28    pub role: String,
29    pub content: String,
30    pub is_tool_call: bool,
31}
32
33/// Events emitted by swarm execution for TUI updates
34#[derive(Debug, Clone)]
35pub enum SwarmEvent {
36    /// Swarm execution started
37    Started { task: String, total_subtasks: usize },
38    /// Task decomposition complete
39    Decomposed { subtasks: Vec<SubTaskInfo> },
40    /// SubTask status changed
41    SubTaskUpdate {
42        id: String,
43        name: String,
44        status: SubTaskStatus,
45        agent_name: Option<String>,
46    },
47    /// SubAgent started working
48    AgentStarted {
49        subtask_id: String,
50        agent_name: String,
51        specialty: String,
52    },
53    /// SubAgent made a tool call (basic)
54    AgentToolCall {
55        subtask_id: String,
56        tool_name: String,
57    },
58    /// SubAgent made a tool call with detail
59    AgentToolCallDetail {
60        subtask_id: String,
61        detail: AgentToolCallDetail,
62    },
63    /// SubAgent sent/received a message
64    AgentMessage {
65        subtask_id: String,
66        entry: AgentMessageEntry,
67    },
68    /// SubAgent completed
69    AgentComplete {
70        subtask_id: String,
71        success: bool,
72        steps: usize,
73    },
74    /// SubAgent produced output text
75    AgentOutput { subtask_id: String, output: String },
76    /// SubAgent encountered an error
77    AgentError { subtask_id: String, error: String },
78    /// Stage completed
79    StageComplete {
80        stage: usize,
81        completed: usize,
82        failed: usize,
83    },
84    /// Swarm execution complete
85    Complete { success: bool, stats: SwarmStats },
86    /// Error occurred
87    Error(String),
88}
89
90/// Information about a subtask for display
91#[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    /// Tool call history for this sub-agent
103    pub tool_call_history: Vec<AgentToolCallDetail>,
104    /// Conversation messages for this sub-agent
105    pub messages: Vec<AgentMessageEntry>,
106    /// Final output text
107    pub output: Option<String>,
108    /// Error message if failed
109    pub error: Option<String>,
110}
111
112/// State for the swarm view
113#[derive(Debug)]
114pub struct SwarmViewState {
115    /// Whether swarm mode is active
116    pub active: bool,
117    /// The main task being executed
118    pub task: String,
119    /// All subtasks
120    pub subtasks: Vec<SubTaskInfo>,
121    /// Current stage (0-indexed)
122    pub current_stage: usize,
123    /// Total stages
124    pub total_stages: usize,
125    /// Stats from execution
126    pub stats: Option<SwarmStats>,
127    /// Any error message
128    pub error: Option<String>,
129    /// Whether execution is complete
130    pub complete: bool,
131    /// Currently selected subtask index
132    pub selected_index: usize,
133    /// Whether we're in detail mode (viewing a single agent)
134    pub detail_mode: bool,
135    /// Scroll offset within the detail view
136    pub detail_scroll: usize,
137    /// ListState for StatefulWidget rendering
138    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    /// Handle a swarm event
166    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                // Pre-allocate
183                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    /// Move selection up
276    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    /// Move selection down
285    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    /// Enter detail mode for the selected subtask
294    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    /// Exit detail mode, return to list
302    pub fn exit_detail(&mut self) {
303        self.detail_mode = false;
304        self.detail_scroll = 0;
305    }
306
307    /// Scroll detail view down
308    pub fn detail_scroll_down(&mut self, amount: usize) {
309        self.detail_scroll = self.detail_scroll.saturating_add(amount);
310    }
311
312    /// Scroll detail view up
313    pub fn detail_scroll_up(&mut self, amount: usize) {
314        self.detail_scroll = self.detail_scroll.saturating_sub(amount);
315    }
316
317    /// Get the currently selected subtask
318    pub fn selected_subtask(&self) -> Option<&SubTaskInfo> {
319        self.subtasks.get(self.selected_index)
320    }
321
322    /// Get count of tasks by status
323    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    /// Overall progress as percentage
344    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
353/// Render the swarm view (takes &mut for StatefulWidget rendering)
354pub fn render_swarm_view(f: &mut Frame, state: &mut SwarmViewState, area: Rect) {
355    // If in detail mode, render full-screen detail for selected agent
356    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), // Header with task + progress
365            Constraint::Length(3), // Stage progress
366            Constraint::Min(1),    // Subtask list
367            Constraint::Length(3), // Stats
368        ])
369        .split(area);
370
371    // Header
372    render_header(f, state, chunks[0]);
373
374    // Stage progress
375    render_stage_progress(f, state, chunks[1]);
376
377    // Subtask list (stateful for selection)
378    render_subtask_list(f, state, chunks[2]);
379
380    // Stats footer
381    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            if state.task.len() > 50 {
402                format!("{}...", &state.task[..47])
403            } else {
404                state.task.clone()
405            },
406            Style::default().fg(Color::White),
407        ),
408        Span::raw("  "),
409        Span::styled(format!("⏳{}", pending), Style::default().fg(Color::Yellow)),
410        Span::raw(" "),
411        Span::styled(format!("⚡{}", running), Style::default().fg(Color::Cyan)),
412        Span::raw(" "),
413        Span::styled(format!("✓{}", completed), Style::default().fg(Color::Green)),
414        Span::raw(" "),
415        Span::styled(format!("✗{}", failed), Style::default().fg(Color::Red)),
416        Span::raw(" "),
417        Span::styled(format!("/{}", total), Style::default().fg(Color::DarkGray)),
418    ]);
419
420    let paragraph = Paragraph::new(status_line).block(
421        Block::default()
422            .borders(Borders::ALL)
423            .title(title)
424            .border_style(Style::default().fg(if state.complete {
425                if state.error.is_some() {
426                    Color::Red
427                } else {
428                    Color::Green
429                }
430            } else {
431                Color::Cyan
432            })),
433    );
434
435    f.render_widget(paragraph, area);
436}
437
438fn render_stage_progress(f: &mut Frame, state: &SwarmViewState, area: Rect) {
439    let progress = state.progress();
440    let label = format!(
441        "Stage {}/{} - {:.0}%",
442        state.current_stage.min(state.total_stages),
443        state.total_stages,
444        progress
445    );
446
447    let gauge = Gauge::default()
448        .block(Block::default().borders(Borders::ALL).title(" Progress "))
449        .gauge_style(Style::default().fg(Color::Cyan).bg(Color::DarkGray))
450        .percent(progress as u16)
451        .label(label);
452
453    f.render_widget(gauge, area);
454}
455
456fn render_subtask_list(f: &mut Frame, state: &mut SwarmViewState, area: Rect) {
457    // Sync ListState selection from selected_index
458    state.list_state.select(Some(state.selected_index));
459
460    let items: Vec<ListItem> = state
461        .subtasks
462        .iter()
463        .map(|task| {
464            let (icon, color) = match task.status {
465                SubTaskStatus::Pending => ("○", Color::DarkGray),
466                SubTaskStatus::Blocked => ("⊘", Color::Yellow),
467                SubTaskStatus::Running => ("●", Color::Cyan),
468                SubTaskStatus::Completed => ("✓", Color::Green),
469                SubTaskStatus::Failed => ("✗", Color::Red),
470                SubTaskStatus::Cancelled => ("⊗", Color::DarkGray),
471                SubTaskStatus::TimedOut => ("⏱", Color::Red),
472            };
473
474            let mut spans = vec![
475                Span::styled(format!("{} ", icon), Style::default().fg(color)),
476                Span::styled(
477                    format!("[S{}] ", task.stage),
478                    Style::default().fg(Color::DarkGray),
479                ),
480                Span::styled(&task.name, Style::default().fg(Color::White)),
481            ];
482
483            // Show agent/tool info for running tasks
484            if task.status == SubTaskStatus::Running {
485                if let Some(ref agent) = task.agent_name {
486                    spans.push(Span::styled(
487                        format!(" → {}", agent),
488                        Style::default().fg(Color::Cyan),
489                    ));
490                }
491                if let Some(ref tool) = task.current_tool {
492                    spans.push(Span::styled(
493                        format!(" [{}]", tool),
494                        Style::default()
495                            .fg(Color::Yellow)
496                            .add_modifier(Modifier::DIM),
497                    ));
498                }
499            }
500
501            // Show step count
502            if task.steps > 0 {
503                spans.push(Span::styled(
504                    format!(" ({}/{})", task.steps, task.max_steps),
505                    Style::default().fg(Color::DarkGray),
506                ));
507            }
508
509            ListItem::new(Line::from(spans))
510        })
511        .collect();
512
513    let title = if state.subtasks.is_empty() {
514        " SubTasks (none yet) "
515    } else {
516        " SubTasks (↑↓:select  Enter:detail) "
517    };
518
519    let list = List::new(items)
520        .block(Block::default().borders(Borders::ALL).title(title))
521        .highlight_style(
522            Style::default()
523                .add_modifier(Modifier::BOLD)
524                .bg(Color::DarkGray),
525        )
526        .highlight_symbol("▶ ");
527
528    f.render_stateful_widget(list, area, &mut state.list_state);
529}
530
531/// Render a full-screen detail view for the selected sub-agent
532fn render_agent_detail(f: &mut Frame, state: &SwarmViewState, area: Rect) {
533    let task = match state.selected_subtask() {
534        Some(t) => t,
535        None => {
536            let p = Paragraph::new("No subtask selected").block(
537                Block::default()
538                    .borders(Borders::ALL)
539                    .title(" Agent Detail "),
540            );
541            f.render_widget(p, area);
542            return;
543        }
544    };
545
546    let chunks = Layout::default()
547        .direction(Direction::Vertical)
548        .constraints([
549            Constraint::Length(5), // Agent info header
550            Constraint::Min(1),    // Content (tool calls + messages + output)
551            Constraint::Length(1), // Key hints
552        ])
553        .split(area);
554
555    // --- Header: agent info ---
556    let (status_icon, status_color) = match task.status {
557        SubTaskStatus::Pending => ("○ Pending", Color::DarkGray),
558        SubTaskStatus::Blocked => ("⊘ Blocked", Color::Yellow),
559        SubTaskStatus::Running => ("● Running", Color::Cyan),
560        SubTaskStatus::Completed => ("✓ Completed", Color::Green),
561        SubTaskStatus::Failed => ("✗ Failed", Color::Red),
562        SubTaskStatus::Cancelled => ("⊗ Cancelled", Color::DarkGray),
563        SubTaskStatus::TimedOut => ("⏱ Timed Out", Color::Red),
564    };
565
566    let agent_label = task.agent_name.as_deref().unwrap_or("(unassigned)");
567    let header_lines = vec![
568        Line::from(vec![
569            Span::styled("Task: ", Style::default().fg(Color::DarkGray)),
570            Span::styled(
571                &task.name,
572                Style::default()
573                    .fg(Color::White)
574                    .add_modifier(Modifier::BOLD),
575            ),
576        ]),
577        Line::from(vec![
578            Span::styled("Agent: ", Style::default().fg(Color::DarkGray)),
579            Span::styled(agent_label, Style::default().fg(Color::Cyan)),
580            Span::raw("  "),
581            Span::styled("Status: ", Style::default().fg(Color::DarkGray)),
582            Span::styled(status_icon, Style::default().fg(status_color)),
583            Span::raw("  "),
584            Span::styled("Stage: ", Style::default().fg(Color::DarkGray)),
585            Span::styled(format!("{}", task.stage), Style::default().fg(Color::White)),
586            Span::raw("  "),
587            Span::styled("Steps: ", Style::default().fg(Color::DarkGray)),
588            Span::styled(
589                format!("{}/{}", task.steps, task.max_steps),
590                Style::default().fg(Color::White),
591            ),
592        ]),
593        Line::from(vec![
594            Span::styled("Deps: ", Style::default().fg(Color::DarkGray)),
595            Span::styled(
596                if task.dependencies.is_empty() {
597                    "none".to_string()
598                } else {
599                    task.dependencies.join(", ")
600                },
601                Style::default().fg(Color::DarkGray),
602            ),
603        ]),
604    ];
605
606    let title = format!(" Agent Detail: {} ", task.id);
607    let header = Paragraph::new(header_lines).block(
608        Block::default()
609            .borders(Borders::ALL)
610            .title(title)
611            .border_style(Style::default().fg(status_color)),
612    );
613    f.render_widget(header, chunks[0]);
614
615    // --- Content area: tool history, messages, output ---
616    let mut content_lines: Vec<Line> = Vec::new();
617
618    // Tool call history section
619    if !task.tool_call_history.is_empty() {
620        content_lines.push(Line::from(Span::styled(
621            "─── Tool Call History ───",
622            Style::default()
623                .fg(Color::Cyan)
624                .add_modifier(Modifier::BOLD),
625        )));
626        content_lines.push(Line::from(""));
627
628        for (i, tc) in task.tool_call_history.iter().enumerate() {
629            let icon = if tc.success { "✓" } else { "✗" };
630            let icon_color = if tc.success { Color::Green } else { Color::Red };
631            content_lines.push(Line::from(vec![
632                Span::styled(format!(" {icon} "), Style::default().fg(icon_color)),
633                Span::styled(format!("#{} ", i + 1), Style::default().fg(Color::DarkGray)),
634                Span::styled(
635                    &tc.tool_name,
636                    Style::default()
637                        .fg(Color::Yellow)
638                        .add_modifier(Modifier::BOLD),
639                ),
640            ]));
641            if !tc.input_preview.is_empty() {
642                content_lines.push(Line::from(vec![
643                    Span::raw("     "),
644                    Span::styled("in: ", Style::default().fg(Color::DarkGray)),
645                    Span::styled(
646                        truncate_str(&tc.input_preview, 80),
647                        Style::default().fg(Color::White),
648                    ),
649                ]));
650            }
651            if !tc.output_preview.is_empty() {
652                content_lines.push(Line::from(vec![
653                    Span::raw("     "),
654                    Span::styled("out: ", Style::default().fg(Color::DarkGray)),
655                    Span::styled(
656                        truncate_str(&tc.output_preview, 80),
657                        Style::default().fg(Color::White),
658                    ),
659                ]));
660            }
661        }
662        content_lines.push(Line::from(""));
663    } else if task.steps > 0 {
664        // At least show that tool calls happened even without detail
665        content_lines.push(Line::from(Span::styled(
666            format!("─── {} tool calls (no detail captured) ───", task.steps),
667            Style::default().fg(Color::DarkGray),
668        )));
669        content_lines.push(Line::from(""));
670    }
671
672    // Messages section
673    if !task.messages.is_empty() {
674        content_lines.push(Line::from(Span::styled(
675            "─── Conversation ───",
676            Style::default()
677                .fg(Color::Cyan)
678                .add_modifier(Modifier::BOLD),
679        )));
680        content_lines.push(Line::from(""));
681
682        for msg in &task.messages {
683            let (role_color, role_label) = match msg.role.as_str() {
684                "user" => (Color::White, "USER"),
685                "assistant" => (Color::Cyan, "ASST"),
686                "tool" => (Color::Yellow, "TOOL"),
687                "system" => (Color::DarkGray, "SYS "),
688                _ => (Color::White, "    "),
689            };
690            content_lines.push(Line::from(vec![Span::styled(
691                format!(" [{role_label}] "),
692                Style::default().fg(role_color).add_modifier(Modifier::BOLD),
693            )]));
694            // Show message content (truncated lines)
695            for line in msg.content.lines().take(10) {
696                content_lines.push(Line::from(vec![
697                    Span::raw("   "),
698                    Span::styled(line, Style::default().fg(Color::White)),
699                ]));
700            }
701            if msg.content.lines().count() > 10 {
702                content_lines.push(Line::from(vec![
703                    Span::raw("   "),
704                    Span::styled("... (truncated)", Style::default().fg(Color::DarkGray)),
705                ]));
706            }
707            content_lines.push(Line::from(""));
708        }
709    }
710
711    // Output section
712    if let Some(ref output) = task.output {
713        content_lines.push(Line::from(Span::styled(
714            "─── Output ───",
715            Style::default()
716                .fg(Color::Green)
717                .add_modifier(Modifier::BOLD),
718        )));
719        content_lines.push(Line::from(""));
720        for line in output.lines().take(20) {
721            content_lines.push(Line::from(Span::styled(
722                line,
723                Style::default().fg(Color::White),
724            )));
725        }
726        if output.lines().count() > 20 {
727            content_lines.push(Line::from(Span::styled(
728                "... (truncated)",
729                Style::default().fg(Color::DarkGray),
730            )));
731        }
732        content_lines.push(Line::from(""));
733    }
734
735    // Error section
736    if let Some(ref err) = task.error {
737        content_lines.push(Line::from(Span::styled(
738            "─── Error ───",
739            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
740        )));
741        content_lines.push(Line::from(""));
742        for line in err.lines() {
743            content_lines.push(Line::from(Span::styled(
744                line,
745                Style::default().fg(Color::Red),
746            )));
747        }
748        content_lines.push(Line::from(""));
749    }
750
751    // If nothing to show
752    if content_lines.is_empty() {
753        content_lines.push(Line::from(Span::styled(
754            "  Waiting for agent activity...",
755            Style::default().fg(Color::DarkGray),
756        )));
757    }
758
759    let content = Paragraph::new(content_lines)
760        .block(Block::default().borders(Borders::ALL))
761        .wrap(Wrap { trim: false })
762        .scroll((state.detail_scroll as u16, 0));
763    f.render_widget(content, chunks[1]);
764
765    // --- Key hints bar ---
766    let hints = Paragraph::new(Line::from(vec![
767        Span::styled(" Esc", Style::default().fg(Color::Yellow)),
768        Span::raw(": Back  "),
769        Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
770        Span::raw(": Scroll  "),
771        Span::styled("↑/↓", Style::default().fg(Color::Yellow)),
772        Span::raw(": Prev/Next agent"),
773    ]));
774    f.render_widget(hints, chunks[2]);
775}
776
777/// Truncate a string for display, respecting char boundaries
778fn truncate_str(s: &str, max: usize) -> String {
779    if s.len() <= max {
780        s.replace('\n', " ")
781    } else {
782        let mut end = max;
783        while end > 0 && !s.is_char_boundary(end) {
784            end -= 1;
785        }
786        format!("{}...", s[..end].replace('\n', " "))
787    }
788}
789
790fn render_stats(f: &mut Frame, state: &SwarmViewState, area: Rect) {
791    let content = if let Some(ref stats) = state.stats {
792        Line::from(vec![
793            Span::styled("Time: ", Style::default().fg(Color::DarkGray)),
794            Span::styled(
795                format!("{:.1}s", stats.execution_time_ms as f64 / 1000.0),
796                Style::default().fg(Color::White),
797            ),
798            Span::raw("  "),
799            Span::styled("Speedup: ", Style::default().fg(Color::DarkGray)),
800            Span::styled(
801                format!("{:.1}x", stats.speedup_factor),
802                Style::default().fg(Color::Green),
803            ),
804            Span::raw("  "),
805            Span::styled("Tool calls: ", Style::default().fg(Color::DarkGray)),
806            Span::styled(
807                format!("{}", stats.total_tool_calls),
808                Style::default().fg(Color::White),
809            ),
810            Span::raw("  "),
811            Span::styled("Critical path: ", Style::default().fg(Color::DarkGray)),
812            Span::styled(
813                format!("{}", stats.critical_path_length),
814                Style::default().fg(Color::White),
815            ),
816        ])
817    } else if let Some(ref err) = state.error {
818        Line::from(vec![Span::styled(
819            format!("Error: {}", err),
820            Style::default().fg(Color::Red),
821        )])
822    } else {
823        Line::from(vec![Span::styled(
824            "Executing...",
825            Style::default().fg(Color::DarkGray),
826        )])
827    };
828
829    let paragraph = Paragraph::new(content)
830        .block(Block::default().borders(Borders::ALL).title(" Stats "))
831        .wrap(Wrap { trim: true });
832
833    f.render_widget(paragraph, area);
834}