scud/commands/spawn/tui/components/
model_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 ModelOption {
19 pub id: String,
21 pub name: String,
23 pub description: String,
25 pub available: bool,
27}
28
29impl ModelOption {
30 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 pub fn with_available(mut self, available: bool) -> Self {
46 self.available = available;
47 self
48 }
49}
50
51pub 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#[derive(Debug, Default)]
67pub struct ModelSelectorState {
68 pub selected: usize,
70 pub offset: usize,
72}
73
74impl ModelSelectorState {
75 pub fn new(selected: usize) -> Self {
77 Self {
78 selected,
79 offset: 0,
80 }
81 }
82
83 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 pub fn next(&mut self, total: usize) {
97 if total == 0 {
98 return;
99 }
100 self.selected = (self.selected + 1) % total;
101 }
102
103 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
113pub struct ModelSelector<'a> {
118 models: &'a [ModelOption],
120 focused: bool,
122 title: String,
124}
125
126impl<'a> ModelSelector<'a> {
127 pub fn new(models: &'a [ModelOption]) -> Self {
129 Self {
130 models,
131 focused: false,
132 title: "Select Model".to_string(),
133 }
134 }
135
136 pub fn focused(mut self, focused: bool) -> Self {
138 self.focused = focused;
139 self
140 }
141
142 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 state.adjust_scroll(visible_height);
173
174 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 Widget::render(block, area, buf);
217
218 let list = List::new(items);
219 Widget::render(list, inner, buf);
220 }
221}
222
223pub struct ModelSelectorCompact<'a> {
225 selected: &'a ModelOption,
227 focused: bool,
229}
230
231impl<'a> ModelSelectorCompact<'a> {
232 pub fn new(selected: &'a ModelOption) -> Self {
234 Self {
235 selected,
236 focused: false,
237 }
238 }
239
240 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 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); assert_eq!(state.selected, 0);
303
304 state.previous(3); 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}