scud/commands/spawn/tui/components/
agent_selector.rs1use 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#[derive(Debug, Clone, PartialEq)]
18pub struct AgentInfo {
19 pub id: String,
21 pub name: String,
23 pub status: AgentDisplayStatus,
25 pub task_title: Option<String>,
27 pub tag: Option<String>,
29}
30
31impl AgentInfo {
32 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 pub fn with_task_title(mut self, title: impl Into<String>) -> Self {
45 self.task_title = Some(title.into());
46 self
47 }
48
49 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
51 self.tag = Some(tag.into());
52 self
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum AgentDisplayStatus {
59 Starting,
61 #[default]
63 Running,
64 Completed,
66 Failed,
68 Idle,
70}
71
72impl AgentDisplayStatus {
73 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 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#[derive(Debug, Default)]
98pub struct AgentSelectorState {
99 pub selected: usize,
101 pub offset: usize,
103 pub status_filter: Option<AgentDisplayStatus>,
105}
106
107impl AgentSelectorState {
108 pub fn new(selected: usize) -> Self {
110 Self {
111 selected,
112 offset: 0,
113 status_filter: None,
114 }
115 }
116
117 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 pub fn next(&mut self, total: usize) {
131 if total == 0 {
132 return;
133 }
134 self.selected = (self.selected + 1) % total;
135 }
136
137 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 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
157pub struct AgentSelector<'a> {
162 agents: &'a [AgentInfo],
164 focused: bool,
166 title: Option<String>,
168 compact: bool,
170}
171
172impl<'a> AgentSelector<'a> {
173 pub fn new(agents: &'a [AgentInfo]) -> Self {
175 Self {
176 agents,
177 focused: false,
178 title: None,
179 compact: false,
180 }
181 }
182
183 pub fn focused(mut self, focused: bool) -> Self {
185 self.focused = focused;
186 self
187 }
188
189 pub fn title(mut self, title: impl Into<String>) -> Self {
191 self.title = Some(title.into());
192 self
193 }
194
195 pub fn compact(mut self, compact: bool) -> Self {
197 self.compact = compact;
198 self
199 }
200
201 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 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 let filtered = self.filtered_agents(state);
258
259 if !filtered.is_empty() && state.selected >= filtered.len() {
261 state.selected = filtered.len() - 1;
262 }
263
264 state.adjust_scroll(visible_height);
266
267 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 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 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 let title = agent
311 .task_title
312 .as_ref()
313 .map(|t| {
314 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
352pub struct AgentBadge<'a> {
354 agent: &'a AgentInfo,
356}
357
358impl<'a> AgentBadge<'a> {
359 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); 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); assert_eq!(state.status_filter, Some(AgentDisplayStatus::Running));
428
429 state.filter_by_status(None);
430 assert!(state.status_filter.is_none());
431 }
432}