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.agents
217            .iter()
218            .filter(|a| a.status == AgentDisplayStatus::Running)
219            .count();
220
221        if let Some(ref custom) = self.title {
222            custom.clone()
223        } else if let Some(filter) = state.status_filter {
224            let count = self.filtered_agents(state).len();
225            format!(" Agents ({} {}) ", count, filter.label().to_lowercase())
226        } else {
227            format!(" Agents ({} running / {} total) ", running, total)
228        }
229    }
230}
231
232impl StatefulWidget for AgentSelector<'_> {
233    type State = AgentSelectorState;
234
235    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
236        let border_color = if self.focused {
237            BORDER_ACTIVE
238        } else {
239            BORDER_DEFAULT
240        };
241        let title_color = if self.focused { ACCENT } else { TEXT_MUTED };
242
243        let title = self.generate_title(state);
244
245        let block = Block::default()
246            .borders(Borders::ALL)
247            .border_type(BorderType::Rounded)
248            .border_style(Style::default().fg(border_color))
249            .title(Line::from(title).fg(title_color))
250            .style(Style::default().bg(BG_SECONDARY))
251            .padding(Padding::new(1, 1, 0, 0));
252
253        let inner = block.inner(area);
254        let visible_height = inner.height as usize;
255
256        // Get filtered agents
257        let filtered = self.filtered_agents(state);
258
259        // Clamp selection to valid range
260        if !filtered.is_empty() && state.selected >= filtered.len() {
261            state.selected = filtered.len() - 1;
262        }
263
264        // Adjust scroll to keep selection visible
265        state.adjust_scroll(visible_height);
266
267        // Render block
268        Widget::render(block, area, buf);
269
270        if filtered.is_empty() {
271            let msg = if state.status_filter.is_some() {
272                "No agents match filter"
273            } else {
274                "No agents spawned"
275            };
276            let line = Line::from(Span::styled(msg, Style::default().fg(TEXT_MUTED)));
277            buf.set_line(inner.x, inner.y, &line, inner.width);
278            return;
279        }
280
281        // Render items
282        let items: Vec<ListItem> = filtered
283            .iter()
284            .enumerate()
285            .skip(state.offset)
286            .take(visible_height)
287            .map(|(display_idx, (_, agent))| {
288                let is_selected = display_idx == state.selected && self.focused;
289                let (icon, icon_color) = agent.status.icon_and_color();
290                let prefix = if is_selected { "▸ " } else { "  " };
291
292                let line = if self.compact {
293                    // Compact: just icon + name
294                    Line::from(vec![
295                        Span::styled(prefix, Style::default().fg(ACCENT)),
296                        Span::styled(format!("{} ", icon), Style::default().fg(icon_color)),
297                        Span::styled(
298                            &agent.name,
299                            Style::default()
300                                .fg(if is_selected { ACCENT } else { TEXT_PRIMARY })
301                                .add_modifier(if is_selected {
302                                    Modifier::BOLD
303                                } else {
304                                    Modifier::empty()
305                                }),
306                        ),
307                    ])
308                } else {
309                    // Full: icon + name + title
310                    let title = agent
311                        .task_title
312                        .as_ref()
313                        .map(|t| {
314                            // Truncate long titles
315                            if t.len() > 30 {
316                                format!("{}...", &t[..27])
317                            } else {
318                                t.clone()
319                            }
320                        })
321                        .unwrap_or_default();
322
323                    Line::from(vec![
324                        Span::styled(prefix, Style::default().fg(ACCENT)),
325                        Span::styled(format!("{} ", icon), Style::default().fg(icon_color)),
326                        Span::styled(
327                            format!("{}: ", agent.name),
328                            Style::default().fg(TEXT_MUTED),
329                        ),
330                        Span::styled(
331                            title,
332                            Style::default()
333                                .fg(if is_selected { ACCENT } else { TEXT_PRIMARY })
334                                .add_modifier(if is_selected {
335                                    Modifier::BOLD
336                                } else {
337                                    Modifier::empty()
338                                }),
339                        ),
340                    ])
341                };
342
343                ListItem::new(line)
344            })
345            .collect();
346
347        let list = List::new(items);
348        Widget::render(list, inner, buf);
349    }
350}
351
352/// Compact single-agent display widget
353pub struct AgentBadge<'a> {
354    /// Agent to display
355    agent: &'a AgentInfo,
356}
357
358impl<'a> AgentBadge<'a> {
359    /// Create a new agent badge
360    pub fn new(agent: &'a AgentInfo) -> Self {
361        Self { agent }
362    }
363}
364
365impl Widget for AgentBadge<'_> {
366    fn render(self, area: Rect, buf: &mut Buffer) {
367        if area.width < 5 || area.height < 1 {
368            return;
369        }
370
371        let (icon, icon_color) = self.agent.status.icon_and_color();
372        let text = format!("{} {}", icon, self.agent.name);
373
374        let line = Line::from(vec![
375            Span::styled(format!("{} ", icon), Style::default().fg(icon_color)),
376            Span::styled(&self.agent.name, Style::default().fg(TEXT_PRIMARY)),
377        ]);
378
379        buf.set_line(area.x, area.y, &line, text.len() as u16);
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn test_agent_info_creation() {
389        let agent = AgentInfo::new("1", "task-1", AgentDisplayStatus::Running)
390            .with_task_title("Implement feature")
391            .with_tag("sprint-1");
392
393        assert_eq!(agent.id, "1");
394        assert_eq!(agent.name, "task-1");
395        assert_eq!(agent.status, AgentDisplayStatus::Running);
396        assert_eq!(agent.task_title, Some("Implement feature".to_string()));
397        assert_eq!(agent.tag, Some("sprint-1".to_string()));
398    }
399
400    #[test]
401    fn test_status_icon_and_color() {
402        assert_eq!(AgentDisplayStatus::Running.icon_and_color().0, "●");
403        assert_eq!(AgentDisplayStatus::Completed.icon_and_color().0, "✓");
404        assert_eq!(AgentDisplayStatus::Failed.icon_and_color().0, "✗");
405    }
406
407    #[test]
408    fn test_selector_state_navigation() {
409        let mut state = AgentSelectorState::new(0);
410
411        state.next(5);
412        assert_eq!(state.selected, 1);
413
414        state.previous(5);
415        assert_eq!(state.selected, 0);
416
417        state.previous(5); // Wraps around
418        assert_eq!(state.selected, 4);
419    }
420
421    #[test]
422    fn test_status_filter() {
423        let mut state = AgentSelectorState::new(2);
424
425        state.filter_by_status(Some(AgentDisplayStatus::Running));
426        assert_eq!(state.selected, 0); // Reset on filter change
427        assert_eq!(state.status_filter, Some(AgentDisplayStatus::Running));
428
429        state.filter_by_status(None);
430        assert!(state.status_filter.is_none());
431    }
432}