Skip to main content

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