Skip to main content

scud/commands/spawn/tui/components/
agent_selector.rs

1//! Agent selector component for TUI
2//!
3//! Provides a UI widget for selecting and switching between agents.
4//! Supports keyboard navigation, status display, and quick selection.
5
6use ratatui::{
7    buffer::Buffer,
8    layout::Rect,
9    style::{Modifier, Style, Stylize},
10    text::{Line, Span},
11    widgets::{Block, BorderType, Borders, List, ListItem, Padding, StatefulWidget, Widget},
12};
13
14use super::super::theme::*;
15
16/// Agent display information for the selector
17#[derive(Debug, Clone, PartialEq)]
18pub struct AgentInfo {
19    /// Unique identifier for the agent
20    pub id: String,
21    /// Display name or task ID
22    pub name: String,
23    /// Current status of the agent
24    pub status: AgentDisplayStatus,
25    /// Task title if available
26    pub task_title: Option<String>,
27    /// Tag/phase the agent belongs to
28    pub tag: Option<String>,
29}
30
31impl AgentInfo {
32    /// Create a new agent info entry
33    pub fn new(id: impl Into<String>, name: impl Into<String>, status: AgentDisplayStatus) -> Self {
34        Self {
35            id: id.into(),
36            name: name.into(),
37            status,
38            task_title: None,
39            tag: None,
40        }
41    }
42
43    /// Set task title
44    pub fn with_task_title(mut self, title: impl Into<String>) -> Self {
45        self.task_title = Some(title.into());
46        self
47    }
48
49    /// Set tag
50    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
51        self.tag = Some(tag.into());
52        self
53    }
54}
55
56/// Display status for agents (simplified view)
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum AgentDisplayStatus {
59    /// Agent is starting up
60    Starting,
61    /// Agent is actively running
62    #[default]
63    Running,
64    /// Agent has completed its task
65    Completed,
66    /// Agent has failed or errored
67    Failed,
68    /// Agent is paused/idle
69    Idle,
70}
71
72impl AgentDisplayStatus {
73    /// Get display icon and color for the status
74    pub fn icon_and_color(&self) -> (&'static str, ratatui::style::Color) {
75        match self {
76            Self::Starting => ("◐", STATUS_STARTING),
77            Self::Running => ("●", STATUS_RUNNING),
78            Self::Completed => ("✓", STATUS_COMPLETED),
79            Self::Failed => ("✗", STATUS_FAILED),
80            Self::Idle => ("○", TEXT_MUTED),
81        }
82    }
83
84    /// Get a short status label
85    pub fn label(&self) -> &'static str {
86        match self {
87            Self::Starting => "Starting",
88            Self::Running => "Running",
89            Self::Completed => "Done",
90            Self::Failed => "Failed",
91            Self::Idle => "Idle",
92        }
93    }
94}
95
96/// State for the agent selector widget
97#[derive(Debug, Default)]
98pub struct AgentSelectorState {
99    /// Currently selected index
100    pub selected: usize,
101    /// Scroll offset for long lists
102    pub offset: usize,
103    /// Filter to show only certain statuses (None = show all)
104    pub status_filter: Option<AgentDisplayStatus>,
105}
106
107impl AgentSelectorState {
108    /// Create new state with given selection
109    pub fn new(selected: usize) -> Self {
110        Self {
111            selected,
112            offset: 0,
113            status_filter: None,
114        }
115    }
116
117    /// Move selection up
118    pub fn previous(&mut self, total: usize) {
119        if total == 0 {
120            return;
121        }
122        self.selected = if self.selected > 0 {
123            self.selected - 1
124        } else {
125            total - 1
126        };
127    }
128
129    /// Move selection down
130    pub fn next(&mut self, total: usize) {
131        if total == 0 {
132            return;
133        }
134        self.selected = (self.selected + 1) % total;
135    }
136
137    /// Set status filter
138    pub fn filter_by_status(&mut self, status: Option<AgentDisplayStatus>) {
139        self.status_filter = status;
140        self.selected = 0;
141        self.offset = 0;
142    }
143
144    /// Adjust scroll offset to keep selection visible
145    pub fn adjust_scroll(&mut self, visible_height: usize) {
146        if visible_height == 0 {
147            return;
148        }
149        if self.selected < self.offset {
150            self.offset = self.selected;
151        } else if self.selected >= self.offset + visible_height {
152            self.offset = self.selected.saturating_sub(visible_height - 1);
153        }
154    }
155}
156
157/// Agent selector widget for TUI
158///
159/// Displays a list of agents with their status and allows selection.
160/// Supports filtering by status and keyboard navigation.
161pub struct AgentSelector<'a> {
162    /// Available agents
163    agents: &'a [AgentInfo],
164    /// Whether this widget is focused
165    focused: bool,
166    /// Title for the selector
167    title: Option<String>,
168    /// Show compact view (less details)
169    compact: bool,
170}
171
172impl<'a> AgentSelector<'a> {
173    /// Create a new agent selector with the given agents
174    pub fn new(agents: &'a [AgentInfo]) -> Self {
175        Self {
176            agents,
177            focused: false,
178            title: None,
179            compact: false,
180        }
181    }
182
183    /// Set focus state
184    pub fn focused(mut self, focused: bool) -> Self {
185        self.focused = focused;
186        self
187    }
188
189    /// Set custom title (auto-generates if None)
190    pub fn title(mut self, title: impl Into<String>) -> Self {
191        self.title = Some(title.into());
192        self
193    }
194
195    /// Use compact rendering mode
196    pub fn compact(mut self, compact: bool) -> Self {
197        self.compact = compact;
198        self
199    }
200
201    /// Filter agents based on state filter
202    fn filtered_agents(&self, state: &AgentSelectorState) -> Vec<(usize, &'a AgentInfo)> {
203        self.agents
204            .iter()
205            .enumerate()
206            .filter(|(_, a)| match state.status_filter {
207                Some(status) => a.status == status,
208                None => true,
209            })
210            .collect()
211    }
212
213    /// Generate title with agent counts
214    fn generate_title(&self, state: &AgentSelectorState) -> String {
215        let total = self.agents.len();
216        let running = self
217            .agents
218            .iter()
219            .filter(|a| a.status == AgentDisplayStatus::Running)
220            .count();
221
222        if let Some(ref custom) = self.title {
223            custom.clone()
224        } else if let Some(filter) = state.status_filter {
225            let count = self.filtered_agents(state).len();
226            format!(" Agents ({} {}) ", count, filter.label().to_lowercase())
227        } else {
228            format!(" Agents ({} running / {} total) ", running, total)
229        }
230    }
231}
232
233impl StatefulWidget for AgentSelector<'_> {
234    type State = AgentSelectorState;
235
236    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
237        let border_color = if self.focused {
238            BORDER_ACTIVE
239        } else {
240            BORDER_DEFAULT
241        };
242        let title_color = if self.focused { ACCENT } else { TEXT_MUTED };
243
244        let title = self.generate_title(state);
245
246        let block = Block::default()
247            .borders(Borders::ALL)
248            .border_type(BorderType::Rounded)
249            .border_style(Style::default().fg(border_color))
250            .title(Line::from(title).fg(title_color))
251            .style(Style::default().bg(BG_SECONDARY))
252            .padding(Padding::new(1, 1, 0, 0));
253
254        let inner = block.inner(area);
255        let visible_height = inner.height as usize;
256
257        // Get filtered agents
258        let filtered = self.filtered_agents(state);
259
260        // Clamp selection to valid range
261        if !filtered.is_empty() && state.selected >= filtered.len() {
262            state.selected = filtered.len() - 1;
263        }
264
265        // Adjust scroll to keep selection visible
266        state.adjust_scroll(visible_height);
267
268        // Render block
269        Widget::render(block, area, buf);
270
271        if filtered.is_empty() {
272            let msg = if state.status_filter.is_some() {
273                "No agents match filter"
274            } else {
275                "No agents spawned"
276            };
277            let line = Line::from(Span::styled(msg, Style::default().fg(TEXT_MUTED)));
278            buf.set_line(inner.x, inner.y, &line, inner.width);
279            return;
280        }
281
282        // Render items
283        let items: Vec<ListItem> = filtered
284            .iter()
285            .enumerate()
286            .skip(state.offset)
287            .take(visible_height)
288            .map(|(display_idx, (_, agent))| {
289                let is_selected = display_idx == state.selected && self.focused;
290                let (icon, icon_color) = agent.status.icon_and_color();
291                let prefix = if is_selected { "▸ " } else { "  " };
292
293                let line = if self.compact {
294                    // Compact: just icon + name
295                    Line::from(vec![
296                        Span::styled(prefix, Style::default().fg(ACCENT)),
297                        Span::styled(format!("{} ", icon), Style::default().fg(icon_color)),
298                        Span::styled(
299                            &agent.name,
300                            Style::default()
301                                .fg(if is_selected { ACCENT } else { TEXT_PRIMARY })
302                                .add_modifier(if is_selected {
303                                    Modifier::BOLD
304                                } else {
305                                    Modifier::empty()
306                                }),
307                        ),
308                    ])
309                } else {
310                    // Full: icon + name + title
311                    let title = agent
312                        .task_title
313                        .as_ref()
314                        .map(|t| {
315                            // Truncate long titles
316                            if t.len() > 30 {
317                                format!("{}...", &t[..27])
318                            } else {
319                                t.clone()
320                            }
321                        })
322                        .unwrap_or_default();
323
324                    Line::from(vec![
325                        Span::styled(prefix, Style::default().fg(ACCENT)),
326                        Span::styled(format!("{} ", icon), Style::default().fg(icon_color)),
327                        Span::styled(format!("{}: ", agent.name), Style::default().fg(TEXT_MUTED)),
328                        Span::styled(
329                            title,
330                            Style::default()
331                                .fg(if is_selected { ACCENT } else { TEXT_PRIMARY })
332                                .add_modifier(if is_selected {
333                                    Modifier::BOLD
334                                } else {
335                                    Modifier::empty()
336                                }),
337                        ),
338                    ])
339                };
340
341                ListItem::new(line)
342            })
343            .collect();
344
345        let list = List::new(items);
346        Widget::render(list, inner, buf);
347    }
348}
349
350/// Compact single-agent display widget
351pub struct AgentBadge<'a> {
352    /// Agent to display
353    agent: &'a AgentInfo,
354}
355
356impl<'a> AgentBadge<'a> {
357    /// Create a new agent badge
358    pub fn new(agent: &'a AgentInfo) -> Self {
359        Self { agent }
360    }
361}
362
363impl Widget for AgentBadge<'_> {
364    fn render(self, area: Rect, buf: &mut Buffer) {
365        if area.width < 5 || area.height < 1 {
366            return;
367        }
368
369        let (icon, icon_color) = self.agent.status.icon_and_color();
370        let text = format!("{} {}", icon, self.agent.name);
371
372        let line = Line::from(vec![
373            Span::styled(format!("{} ", icon), Style::default().fg(icon_color)),
374            Span::styled(&self.agent.name, Style::default().fg(TEXT_PRIMARY)),
375        ]);
376
377        buf.set_line(area.x, area.y, &line, text.len() as u16);
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn test_agent_info_creation() {
387        let agent = AgentInfo::new("1", "task-1", AgentDisplayStatus::Running)
388            .with_task_title("Implement feature")
389            .with_tag("sprint-1");
390
391        assert_eq!(agent.id, "1");
392        assert_eq!(agent.name, "task-1");
393        assert_eq!(agent.status, AgentDisplayStatus::Running);
394        assert_eq!(agent.task_title, Some("Implement feature".to_string()));
395        assert_eq!(agent.tag, Some("sprint-1".to_string()));
396    }
397
398    #[test]
399    fn test_status_icon_and_color() {
400        assert_eq!(AgentDisplayStatus::Running.icon_and_color().0, "●");
401        assert_eq!(AgentDisplayStatus::Completed.icon_and_color().0, "✓");
402        assert_eq!(AgentDisplayStatus::Failed.icon_and_color().0, "✗");
403    }
404
405    #[test]
406    fn test_selector_state_navigation() {
407        let mut state = AgentSelectorState::new(0);
408
409        state.next(5);
410        assert_eq!(state.selected, 1);
411
412        state.previous(5);
413        assert_eq!(state.selected, 0);
414
415        state.previous(5); // Wraps around
416        assert_eq!(state.selected, 4);
417    }
418
419    #[test]
420    fn test_status_filter() {
421        let mut state = AgentSelectorState::new(2);
422
423        state.filter_by_status(Some(AgentDisplayStatus::Running));
424        assert_eq!(state.selected, 0); // Reset on filter change
425        assert_eq!(state.status_filter, Some(AgentDisplayStatus::Running));
426
427        state.filter_by_status(None);
428        assert!(state.status_filter.is_none());
429    }
430}