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

1//! Model selector component for TUI
2//!
3//! Provides a UI widget for selecting AI models/harnesses when spawning agents.
4//! Displays available models with their descriptions and allows selection via keyboard.
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/// Available AI model/harness options
17#[derive(Debug, Clone, PartialEq)]
18pub struct ModelOption {
19    /// Unique identifier for the model
20    pub id: String,
21    /// Display name for the model
22    pub name: String,
23    /// Short description of the model
24    pub description: String,
25    /// Whether this model is available/enabled
26    pub available: bool,
27}
28
29impl ModelOption {
30    /// Create a new model option
31    pub fn new(id: impl Into<String>, name: impl Into<String>, description: impl Into<String>) -> Self {
32        Self {
33            id: id.into(),
34            name: name.into(),
35            description: description.into(),
36            available: true,
37        }
38    }
39
40    /// Set availability status
41    pub fn with_available(mut self, available: bool) -> Self {
42        self.available = available;
43        self
44    }
45}
46
47/// Default model options for SCUD swarm execution
48pub fn default_models() -> Vec<ModelOption> {
49    vec![
50        ModelOption::new(
51            "claude-code",
52            "Claude Code",
53            "Anthropic's Claude with code tools",
54        ),
55        ModelOption::new(
56            "opencode",
57            "OpenCode",
58            "OpenAI-based code assistant",
59        ),
60        ModelOption::new(
61            "codex",
62            "Codex",
63            "OpenAI Codex for code completion",
64        )
65        .with_available(false),
66    ]
67}
68
69/// State for the model selector widget
70#[derive(Debug, Default)]
71pub struct ModelSelectorState {
72    /// Currently selected index
73    pub selected: usize,
74    /// Scroll offset for long lists
75    pub offset: usize,
76}
77
78impl ModelSelectorState {
79    /// Create new state with given selection
80    pub fn new(selected: usize) -> Self {
81        Self { selected, offset: 0 }
82    }
83
84    /// Move selection up
85    pub fn previous(&mut self, total: usize) {
86        if total == 0 {
87            return;
88        }
89        self.selected = if self.selected > 0 {
90            self.selected - 1
91        } else {
92            total - 1
93        };
94    }
95
96    /// Move selection down
97    pub fn next(&mut self, total: usize) {
98        if total == 0 {
99            return;
100        }
101        self.selected = (self.selected + 1) % total;
102    }
103
104    /// Adjust scroll offset to keep selection visible
105    pub fn adjust_scroll(&mut self, visible_height: usize) {
106        if self.selected < self.offset {
107            self.offset = self.selected;
108        } else if self.selected >= self.offset + visible_height {
109            self.offset = self.selected.saturating_sub(visible_height - 1);
110        }
111    }
112}
113
114/// Model selector widget for TUI
115///
116/// Displays a list of AI models with selection support.
117/// Renders as a bordered list with icons indicating availability.
118pub struct ModelSelector<'a> {
119    /// Available model options
120    models: &'a [ModelOption],
121    /// Whether this widget is focused
122    focused: bool,
123    /// Title for the selector
124    title: String,
125}
126
127impl<'a> ModelSelector<'a> {
128    /// Create a new model selector with the given options
129    pub fn new(models: &'a [ModelOption]) -> Self {
130        Self {
131            models,
132            focused: false,
133            title: "Select Model".to_string(),
134        }
135    }
136
137    /// Set focus state
138    pub fn focused(mut self, focused: bool) -> Self {
139        self.focused = focused;
140        self
141    }
142
143    /// Set custom title
144    pub fn title(mut self, title: impl Into<String>) -> Self {
145        self.title = title.into();
146        self
147    }
148}
149
150impl StatefulWidget for ModelSelector<'_> {
151    type State = ModelSelectorState;
152
153    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
154        let border_color = if self.focused {
155            BORDER_ACTIVE
156        } else {
157            BORDER_DEFAULT
158        };
159        let title_color = if self.focused { ACCENT } else { TEXT_MUTED };
160
161        let block = Block::default()
162            .borders(Borders::ALL)
163            .border_type(BorderType::Rounded)
164            .border_style(Style::default().fg(border_color))
165            .title(Line::from(format!(" {} ", self.title)).fg(title_color))
166            .style(Style::default().bg(BG_SECONDARY))
167            .padding(Padding::new(1, 1, 0, 0));
168
169        let inner = block.inner(area);
170        let visible_height = inner.height as usize;
171
172        // Adjust scroll to keep selection visible
173        state.adjust_scroll(visible_height);
174
175        // Render items
176        let items: Vec<ListItem> = self
177            .models
178            .iter()
179            .enumerate()
180            .skip(state.offset)
181            .take(visible_height)
182            .map(|(i, model)| {
183                let is_selected = i == state.selected && self.focused;
184
185                let icon = if model.available {
186                    ("●", SUCCESS)
187                } else {
188                    ("○", TEXT_MUTED)
189                };
190
191                let prefix = if is_selected { "▸ " } else { "  " };
192
193                let line = Line::from(vec![
194                    Span::styled(prefix, Style::default().fg(ACCENT)),
195                    Span::styled(format!("{} ", icon.0), Style::default().fg(icon.1)),
196                    Span::styled(
197                        &model.name,
198                        Style::default()
199                            .fg(if is_selected { ACCENT } else { TEXT_PRIMARY })
200                            .add_modifier(if is_selected {
201                                Modifier::BOLD
202                            } else {
203                                Modifier::empty()
204                            }),
205                    ),
206                    Span::styled(
207                        format!(" - {}", model.description),
208                        Style::default().fg(TEXT_MUTED),
209                    ),
210                ]);
211
212                ListItem::new(line)
213            })
214            .collect();
215
216        // Render block first, then list inside
217        Widget::render(block, area, buf);
218
219        let list = List::new(items);
220        Widget::render(list, inner, buf);
221    }
222}
223
224/// Compact model selector that renders inline (single line)
225pub struct ModelSelectorCompact<'a> {
226    /// Currently selected model
227    selected: &'a ModelOption,
228    /// Whether this is focused
229    focused: bool,
230}
231
232impl<'a> ModelSelectorCompact<'a> {
233    /// Create a new compact selector showing the current selection
234    pub fn new(selected: &'a ModelOption) -> Self {
235        Self {
236            selected,
237            focused: false,
238        }
239    }
240
241    /// Set focus state
242    pub fn focused(mut self, focused: bool) -> Self {
243        self.focused = focused;
244        self
245    }
246}
247
248impl Widget for ModelSelectorCompact<'_> {
249    fn render(self, area: Rect, buf: &mut Buffer) {
250        if area.width < 10 || area.height < 1 {
251            return;
252        }
253
254        let style = if self.focused {
255            Style::default().fg(ACCENT)
256        } else {
257            Style::default().fg(TEXT_PRIMARY)
258        };
259
260        let icon = if self.selected.available {
261            "●"
262        } else {
263            "○"
264        };
265
266        let text = format!(" {} {} ◂", icon, self.selected.name);
267
268        // Render the text
269        let span = Span::styled(text, style);
270        buf.set_span(area.x, area.y, &span, area.width);
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn test_model_option_creation() {
280        let model = ModelOption::new("test", "Test Model", "A test model");
281        assert_eq!(model.id, "test");
282        assert_eq!(model.name, "Test Model");
283        assert!(model.available);
284    }
285
286    #[test]
287    fn test_model_option_availability() {
288        let model = ModelOption::new("test", "Test", "Test").with_available(false);
289        assert!(!model.available);
290    }
291
292    #[test]
293    fn test_selector_state_navigation() {
294        let mut state = ModelSelectorState::new(0);
295
296        state.next(3);
297        assert_eq!(state.selected, 1);
298
299        state.next(3);
300        assert_eq!(state.selected, 2);
301
302        state.next(3); // Wraps around
303        assert_eq!(state.selected, 0);
304
305        state.previous(3); // Wraps around
306        assert_eq!(state.selected, 2);
307    }
308
309    #[test]
310    fn test_default_models() {
311        let models = default_models();
312        assert!(!models.is_empty());
313        assert!(models.iter().any(|m| m.id == "claude-code"));
314    }
315}