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            truncate_str(&state.task, 47),
402            Style::default().fg(Color::White),
403        ),
404        Span::raw("  "),
405        Span::styled(format!("⏳{}", pending), Style::default().fg(Color::Yellow)),
406        Span::raw(" "),
407        Span::styled(format!("⚡{}", running), Style::default().fg(Color::Cyan)),
408        Span::raw(" "),
409        Span::styled(format!("✓{}", completed), Style::default().fg(Color::Green)),
410        Span::raw(" "),
411        Span::styled(format!("✗{}", failed), Style::default().fg(Color::Red)),
412        Span::raw(" "),
413        Span::styled(format!("/{}", total), Style::default().fg(Color::DarkGray)),
414    ]);
415
416    let paragraph = Paragraph::new(status_line).block(
417        Block::default()
418            .borders(Borders::ALL)
419            .title(title)
420            .border_style(Style::default().fg(if state.complete {
421                if state.error.is_some() {
422                    Color::Red
423                } else {
424                    Color::Green
425                }
426            } else {
427                Color::Cyan
428            })),
429    );
430
431    f.render_widget(paragraph, area);
432}
433
434fn render_stage_progress(f: &mut Frame, state: &SwarmViewState, area: Rect) {
435    let progress = state.progress();
436    let label = format!(
437        "Stage {}/{} - {:.0}%",
438        state.current_stage.min(state.total_stages),
439        state.total_stages,
440        progress
441    );
442
443    let gauge = Gauge::default()
444        .block(Block::default().borders(Borders::ALL).title(" Progress "))
445        .gauge_style(Style::default().fg(Color::Cyan).bg(Color::DarkGray))
446        .percent(progress as u16)
447        .label(label);
448
449    f.render_widget(gauge, area);
450}
451
452fn render_subtask_list(f: &mut Frame, state: &mut SwarmViewState, area: Rect) {
453    // Sync ListState selection from selected_index
454    state.list_state.select(Some(state.selected_index));
455
456    let items: Vec<ListItem> = state
457        .subtasks
458        .iter()
459        .map(|task| {
460            let (icon, color) = match task.status {
461                SubTaskStatus::Pending => ("○", Color::DarkGray),
462                SubTaskStatus::Blocked => ("⊘", Color::Yellow),
463                SubTaskStatus::Running => ("●", Color::Cyan),
464                SubTaskStatus::Completed => ("✓", Color::Green),
465                SubTaskStatus::Failed => ("✗", Color::Red),
466                SubTaskStatus::Cancelled => ("⊗", Color::DarkGray),
467                SubTaskStatus::TimedOut => ("⏱", Color::Red),
468            };
469
470            let mut spans = vec![
471                Span::styled(format!("{} ", icon), Style::default().fg(color)),
472                Span::styled(
473                    format!("[S{}] ", task.stage),
474                    Style::default().fg(Color::DarkGray),
475                ),
476                Span::styled(&task.name, Style::default().fg(Color::White)),
477            ];
478
479            // Show agent/tool info for running tasks
480            if task.status == SubTaskStatus::Running {
481                if let Some(ref agent) = task.agent_name {
482                    spans.push(Span::styled(
483                        format!(" → {}", agent),
484                        Style::default().fg(Color::Cyan),
485                    ));
486                }
487                if let Some(ref tool) = task.current_tool {
488                    spans.push(Span::styled(
489                        format!(" [{}]", tool),
490                        Style::default()
491                            .fg(Color::Yellow)
492                            .add_modifier(Modifier::DIM),
493                    ));
494                }
495            }
496
497            // Show step count
498            if task.steps > 0 {
499                spans.push(Span::styled(
500                    format!(" ({}/{})", task.steps, task.max_steps),
501                    Style::default().fg(Color::DarkGray),
502                ));
503            }
504
505            ListItem::new(Line::from(spans))
506        })
507        .collect();
508
509    let title = if state.subtasks.is_empty() {
510        " SubTasks (none yet) "
511    } else {
512        " SubTasks (↑↓:select  Enter:detail) "
513    };
514
515    let list = List::new(items)
516        .block(Block::default().borders(Borders::ALL).title(title))
517        .highlight_style(
518            Style::default()
519                .add_modifier(Modifier::BOLD)
520                .bg(Color::DarkGray),
521        )
522        .highlight_symbol("▶ ");
523
524    f.render_stateful_widget(list, area, &mut state.list_state);
525}
526
527/// Render a full-screen detail view for the selected sub-agent
528fn render_agent_detail(f: &mut Frame, state: &SwarmViewState, area: Rect) {
529    let task = match state.selected_subtask() {
530        Some(t) => t,
531        None => {
532            let p = Paragraph::new("No subtask selected").block(
533                Block::default()
534                    .borders(Borders::ALL)
535                    .title(" Agent Detail "),
536            );
537            f.render_widget(p, area);
538            return;
539        }
540    };
541
542    let chunks = Layout::default()
543        .direction(Direction::Vertical)
544        .constraints([
545            Constraint::Length(5), // Agent info header
546            Constraint::Min(1),    // Content (tool calls + messages + output)
547            Constraint::Length(1), // Key hints
548        ])
549        .split(area);
550
551    // --- Header: agent info ---
552    let (status_icon, status_color) = match task.status {
553        SubTaskStatus::Pending => ("○ Pending", Color::DarkGray),
554        SubTaskStatus::Blocked => ("⊘ Blocked", Color::Yellow),
555        SubTaskStatus::Running => ("● Running", Color::Cyan),
556        SubTaskStatus::Completed => ("✓ Completed", Color::Green),
557        SubTaskStatus::Failed => ("✗ Failed", Color::Red),
558        SubTaskStatus::Cancelled => ("⊗ Cancelled", Color::DarkGray),
559        SubTaskStatus::TimedOut => ("⏱ Timed Out", Color::Red),
560    };
561
562    let agent_label = task.agent_name.as_deref().unwrap_or("(unassigned)");
563    let header_lines = vec![
564        Line::from(vec![
565            Span::styled("Task: ", Style::default().fg(Color::DarkGray)),
566            Span::styled(
567                &task.name,
568                Style::default()
569                    .fg(Color::White)
570                    .add_modifier(Modifier::BOLD),
571            ),
572        ]),
573        Line::from(vec![
574            Span::styled("Agent: ", Style::default().fg(Color::DarkGray)),
575            Span::styled(agent_label, Style::default().fg(Color::Cyan)),
576            Span::raw("  "),
577            Span::styled("Status: ", Style::default().fg(Color::DarkGray)),
578            Span::styled(status_icon, Style::default().fg(status_color)),
579            Span::raw("  "),
580            Span::styled("Stage: ", Style::default().fg(Color::DarkGray)),
581            Span::styled(format!("{}", task.stage), Style::default().fg(Color::White)),
582            Span::raw("  "),
583            Span::styled("Steps: ", Style::default().fg(Color::DarkGray)),
584            Span::styled(
585                format!("{}/{}", task.steps, task.max_steps),
586                Style::default().fg(Color::White),
587            ),
588        ]),
589        Line::from(vec![
590            Span::styled("Deps: ", Style::default().fg(Color::DarkGray)),
591            Span::styled(
592                if task.dependencies.is_empty() {
593                    "none".to_string()
594                } else {
595                    task.dependencies.join(", ")
596                },
597                Style::default().fg(Color::DarkGray),
598            ),
599        ]),
600    ];
601
602    let title = format!(" Agent Detail: {} ", task.id);
603    let header = Paragraph::new(header_lines).block(
604        Block::default()
605            .borders(Borders::ALL)
606            .title(title)
607            .border_style(Style::default().fg(status_color)),
608    );
609    f.render_widget(header, chunks[0]);
610
611    // --- Content area: tool history, messages, output ---
612    let mut content_lines: Vec<Line> = Vec::new();
613
614    // Tool call history section
615    if !task.tool_call_history.is_empty() {
616        content_lines.push(Line::from(Span::styled(
617            "─── Tool Call History ───",
618            Style::default()
619                .fg(Color::Cyan)
620                .add_modifier(Modifier::BOLD),
621        )));
622        content_lines.push(Line::from(""));
623
624        for (i, tc) in task.tool_call_history.iter().enumerate() {
625            let icon = if tc.success { "✓" } else { "✗" };
626            let icon_color = if tc.success { Color::Green } else { Color::Red };
627            content_lines.push(Line::from(vec![
628                Span::styled(format!(" {icon} "), Style::default().fg(icon_color)),
629                Span::styled(format!("#{} ", i + 1), Style::default().fg(Color::DarkGray)),
630                Span::styled(
631                    &tc.tool_name,
632                    Style::default()
633                        .fg(Color::Yellow)
634                        .add_modifier(Modifier::BOLD),
635                ),
636            ]));
637            if !tc.input_preview.is_empty() {
638                content_lines.push(Line::from(vec![
639                    Span::raw("     "),
640                    Span::styled("in: ", Style::default().fg(Color::DarkGray)),
641                    Span::styled(
642                        truncate_str(&tc.input_preview, 80),
643                        Style::default().fg(Color::White),
644                    ),
645                ]));
646            }
647            if !tc.output_preview.is_empty() {
648                content_lines.push(Line::from(vec![
649                    Span::raw("     "),
650                    Span::styled("out: ", Style::default().fg(Color::DarkGray)),
651                    Span::styled(
652                        truncate_str(&tc.output_preview, 80),
653                        Style::default().fg(Color::White),
654                    ),
655                ]));
656            }
657        }
658        content_lines.push(Line::from(""));
659    } else if task.steps > 0 {
660        // At least show that tool calls happened even without detail
661        content_lines.push(Line::from(Span::styled(
662            format!("─── {} tool calls (no detail captured) ───", task.steps),
663            Style::default().fg(Color::DarkGray),
664        )));
665        content_lines.push(Line::from(""));
666    }
667
668    // Messages section
669    if !task.messages.is_empty() {
670        content_lines.push(Line::from(Span::styled(
671            "─── Conversation ───",
672            Style::default()
673                .fg(Color::Cyan)
674                .add_modifier(Modifier::BOLD),
675        )));
676        content_lines.push(Line::from(""));
677
678        for msg in &task.messages {
679            let (role_color, role_label) = match msg.role.as_str() {
680                "user" => (Color::White, "USER"),
681                "assistant" => (Color::Cyan, "ASST"),
682                "tool" => (Color::Yellow, "TOOL"),
683                "system" => (Color::DarkGray, "SYS "),
684                _ => (Color::White, "    "),
685            };
686            content_lines.push(Line::from(vec![Span::styled(
687                format!(" [{role_label}] "),
688                Style::default().fg(role_color).add_modifier(Modifier::BOLD),
689            )]));
690            // Show message content (truncated lines)
691            for line in msg.content.lines().take(10) {
692                content_lines.push(Line::from(vec![
693                    Span::raw("   "),
694                    Span::styled(line, Style::default().fg(Color::White)),
695                ]));
696            }
697            if msg.content.lines().count() > 10 {
698                content_lines.push(Line::from(vec![
699                    Span::raw("   "),
700                    Span::styled("... (truncated)", Style::default().fg(Color::DarkGray)),
701                ]));
702            }
703            content_lines.push(Line::from(""));
704        }
705    }
706
707    // Output section
708    if let Some(ref output) = task.output {
709        content_lines.push(Line::from(Span::styled(
710            "─── Output ───",
711            Style::default()
712                .fg(Color::Green)
713                .add_modifier(Modifier::BOLD),
714        )));
715        content_lines.push(Line::from(""));
716        for line in output.lines().take(20) {
717            content_lines.push(Line::from(Span::styled(
718                line,
719                Style::default().fg(Color::White),
720            )));
721        }
722        if output.lines().count() > 20 {
723            content_lines.push(Line::from(Span::styled(
724                "... (truncated)",
725                Style::default().fg(Color::DarkGray),
726            )));
727        }
728        content_lines.push(Line::from(""));
729    }
730
731    // Error section
732    if let Some(ref err) = task.error {
733        content_lines.push(Line::from(Span::styled(
734            "─── Error ───",
735            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
736        )));
737        content_lines.push(Line::from(""));
738        for line in err.lines() {
739            content_lines.push(Line::from(Span::styled(
740                line,
741                Style::default().fg(Color::Red),
742            )));
743        }
744        content_lines.push(Line::from(""));
745    }
746
747    // If nothing to show
748    if content_lines.is_empty() {
749        content_lines.push(Line::from(Span::styled(
750            "  Waiting for agent activity...",
751            Style::default().fg(Color::DarkGray),
752        )));
753    }
754
755    let content = Paragraph::new(content_lines)
756        .block(Block::default().borders(Borders::ALL))
757        .wrap(Wrap { trim: false })
758        .scroll((state.detail_scroll as u16, 0));
759    f.render_widget(content, chunks[1]);
760
761    // --- Key hints bar ---
762    let hints = Paragraph::new(Line::from(vec![
763        Span::styled(" Esc", Style::default().fg(Color::Yellow)),
764        Span::raw(": Back  "),
765        Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
766        Span::raw(": Scroll  "),
767        Span::styled("↑/↓", Style::default().fg(Color::Yellow)),
768        Span::raw(": Prev/Next agent"),
769    ]));
770    f.render_widget(hints, chunks[2]);
771}
772
773/// Truncate a string for display, respecting char boundaries
774fn truncate_str(s: &str, max: usize) -> String {
775    if s.len() <= max {
776        s.replace('\n', " ")
777    } else {
778        let mut end = max;
779        while end > 0 && !s.is_char_boundary(end) {
780            end -= 1;
781        }
782        format!("{}...", s[..end].replace('\n', " "))
783    }
784}
785
786fn render_stats(f: &mut Frame, state: &SwarmViewState, area: Rect) {
787    let content = if let Some(ref stats) = state.stats {
788        Line::from(vec![
789            Span::styled("Time: ", Style::default().fg(Color::DarkGray)),
790            Span::styled(
791                format!("{:.1}s", stats.execution_time_ms as f64 / 1000.0),
792                Style::default().fg(Color::White),
793            ),
794            Span::raw("  "),
795            Span::styled("Speedup: ", Style::default().fg(Color::DarkGray)),
796            Span::styled(
797                format!("{:.1}x", stats.speedup_factor),
798                Style::default().fg(Color::Green),
799            ),
800            Span::raw("  "),
801            Span::styled("Tool calls: ", Style::default().fg(Color::DarkGray)),
802            Span::styled(
803                format!("{}", stats.total_tool_calls),
804                Style::default().fg(Color::White),
805            ),
806            Span::raw("  "),
807            Span::styled("Critical path: ", Style::default().fg(Color::DarkGray)),
808            Span::styled(
809                format!("{}", stats.critical_path_length),
810                Style::default().fg(Color::White),
811            ),
812        ])
813    } else if let Some(ref err) = state.error {
814        Line::from(vec![Span::styled(
815            format!("Error: {}", err),
816            Style::default().fg(Color::Red),
817        )])
818    } else {
819        Line::from(vec![Span::styled(
820            "Executing...",
821            Style::default().fg(Color::DarkGray),
822        )])
823    };
824
825    let paragraph = Paragraph::new(content)
826        .block(Block::default().borders(Borders::ALL).title(" Stats "))
827        .wrap(Wrap { trim: true });
828
829    f.render_widget(paragraph, area);
830}