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