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(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 pub fn with_available(mut self, available: bool) -> Self {
42 self.available = available;
43 self
44 }
45}
46
47pub 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#[derive(Debug, Default)]
71pub struct ModelSelectorState {
72 pub selected: usize,
74 pub offset: usize,
76}
77
78impl ModelSelectorState {
79 pub fn new(selected: usize) -> Self {
81 Self { selected, offset: 0 }
82 }
83
84 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 pub fn next(&mut self, total: usize) {
98 if total == 0 {
99 return;
100 }
101 self.selected = (self.selected + 1) % total;
102 }
103
104 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
114pub struct ModelSelector<'a> {
119 models: &'a [ModelOption],
121 focused: bool,
123 title: String,
125}
126
127impl<'a> ModelSelector<'a> {
128 pub fn new(models: &'a [ModelOption]) -> Self {
130 Self {
131 models,
132 focused: false,
133 title: "Select Model".to_string(),
134 }
135 }
136
137 pub fn focused(mut self, focused: bool) -> Self {
139 self.focused = focused;
140 self
141 }
142
143 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 state.adjust_scroll(visible_height);
174
175 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 Widget::render(block, area, buf);
218
219 let list = List::new(items);
220 Widget::render(list, inner, buf);
221 }
222}
223
224pub struct ModelSelectorCompact<'a> {
226 selected: &'a ModelOption,
228 focused: bool,
230}
231
232impl<'a> ModelSelectorCompact<'a> {
233 pub fn new(selected: &'a ModelOption) -> Self {
235 Self {
236 selected,
237 focused: false,
238 }
239 }
240
241 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 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); assert_eq!(state.selected, 0);
304
305 state.previous(3); 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}