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
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 let filtered = self.filtered_agents(state);
259
260 if !filtered.is_empty() && state.selected >= filtered.len() {
262 state.selected = filtered.len() - 1;
263 }
264
265 state.adjust_scroll(visible_height);
267
268 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 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 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 let title = agent
312 .task_title
313 .as_ref()
314 .map(|t| {
315 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
350pub struct AgentBadge<'a> {
352 agent: &'a AgentInfo,
354}
355
356impl<'a> AgentBadge<'a> {
357 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); 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); assert_eq!(state.status_filter, Some(AgentDisplayStatus::Running));
426
427 state.filter_by_status(None);
428 assert!(state.status_filter.is_none());
429 }
430}