Skip to main content

wisp/settings/
picker.rs

1use super::types::{SettingsChange, SettingsMenuEntry, SettingsMenuValue};
2use tui::{Combobox, Component, Event, Frame, Line, MouseEventKind, PickerKey, Searchable, ViewContext, classify_key};
3impl Searchable for SettingsMenuValue {
4    fn search_text(&self) -> String {
5        format!("{} {}", self.name, self.value)
6    }
7}
8
9pub struct SettingsPicker {
10    pub config_id: String,
11    pub title: String,
12    combobox: Combobox<SettingsMenuValue>,
13    current_value: String,
14}
15
16pub enum SettingsPickerMessage {
17    Close,
18    ApplySelection(Option<SettingsChange>),
19}
20
21impl SettingsPicker {
22    pub fn from_entry(entry: &SettingsMenuEntry) -> Option<Self> {
23        let current_value = entry.values.get(entry.current_value_index)?.value.clone();
24        let mut picker = Self {
25            config_id: entry.config_id.clone(),
26            title: entry.title.clone(),
27            current_value,
28            combobox: Combobox::new(entry.values.clone()),
29        };
30        let initial_index = picker.combobox.matches().iter().position(|m| m.value == picker.current_value).unwrap_or(0);
31        picker.combobox.set_selected_index(initial_index);
32        picker.ensure_selectable();
33        Some(picker)
34    }
35
36    pub fn query(&self) -> &str {
37        self.combobox.query()
38    }
39
40    pub fn confirm_selection(&self) -> Option<SettingsChange> {
41        let selected = self.combobox.selected()?;
42        if selected.is_disabled || selected.value == self.current_value {
43            return None;
44        }
45
46        Some(SettingsChange { config_id: self.config_id.clone(), new_value: selected.value.clone() })
47    }
48
49    fn move_selection_up(&mut self) {
50        self.combobox.move_up_where(|m| !m.is_disabled);
51    }
52
53    fn move_selection_down(&mut self) {
54        self.combobox.move_down_where(|m| !m.is_disabled);
55    }
56
57    fn push_query_char(&mut self, c: char) {
58        self.combobox.push_query_char(c);
59        self.ensure_selectable();
60    }
61
62    fn pop_query_char(&mut self) {
63        self.combobox.pop_query_char();
64        self.ensure_selectable();
65    }
66
67    fn ensure_selectable(&mut self) {
68        if self.combobox.is_empty() {
69            return;
70        }
71        let idx = self.combobox.selected_index();
72        if idx >= self.combobox.matches().len() || self.combobox.matches()[idx].is_disabled {
73            self.combobox.select_first_where(|m| !m.is_disabled);
74        }
75    }
76}
77
78impl SettingsPicker {
79    pub(crate) fn update_viewport(&mut self, max_height: usize) {
80        self.combobox.set_max_visible(max_height.saturating_sub(1).max(1));
81    }
82}
83
84impl Component for SettingsPicker {
85    type Message = SettingsPickerMessage;
86
87    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
88        if let Event::Mouse(mouse) = event {
89            return match mouse.kind {
90                MouseEventKind::ScrollUp => {
91                    self.move_selection_up();
92                    Some(vec![])
93                }
94                MouseEventKind::ScrollDown => {
95                    self.move_selection_down();
96                    Some(vec![])
97                }
98                _ => Some(vec![]),
99            };
100        }
101        let Event::Key(key) = event else {
102            return None;
103        };
104        match classify_key(*key, self.combobox.query().is_empty()) {
105            PickerKey::Escape => Some(vec![SettingsPickerMessage::Close]),
106            PickerKey::MoveUp => {
107                self.move_selection_up();
108                Some(vec![])
109            }
110            PickerKey::MoveDown => {
111                self.move_selection_down();
112                Some(vec![])
113            }
114            PickerKey::Confirm => {
115                let change = self.confirm_selection();
116                Some(vec![SettingsPickerMessage::ApplySelection(change)])
117            }
118            PickerKey::Char(c) => {
119                self.push_query_char(c);
120                Some(vec![])
121            }
122            PickerKey::Backspace => {
123                self.pop_query_char();
124                Some(vec![])
125            }
126            PickerKey::MoveLeft
127            | PickerKey::MoveRight
128            | PickerKey::Tab
129            | PickerKey::BackTab
130            | PickerKey::BackspaceOnEmpty
131            | PickerKey::ControlChar
132            | PickerKey::Other => Some(vec![]),
133        }
134    }
135
136    fn render(&mut self, context: &ViewContext) -> Frame {
137        let mut lines = Vec::new();
138        let header = format!("  {} search: {}", self.title, self.combobox.query());
139        lines.push(Line::styled(header, context.theme.muted()));
140
141        if self.combobox.is_empty() {
142            lines.push(Line::new("  (no matches found)".to_string()));
143            return Frame::new(lines);
144        }
145
146        let item_lines = self.combobox.render_items(context, |option, is_selected, ctx| {
147            let label = if option.name == option.value {
148                option.name.clone()
149            } else {
150                format!("{} ({})", option.name, option.value)
151            };
152
153            let label = if option.is_disabled {
154                if let Some(reason) = option.description.as_deref() { format!("{label} - {reason}") } else { label }
155            } else {
156                label
157            };
158
159            let line_text = label;
160            if option.is_disabled {
161                Line::styled(line_text, ctx.theme.muted())
162            } else if is_selected {
163                Line::with_style(line_text, ctx.theme.selected_row_style())
164            } else {
165                Line::styled(line_text, ctx.theme.text_primary())
166            }
167        });
168        lines.extend(item_lines);
169
170        Frame::new(lines)
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::settings::types::SettingsMenuEntryKind;
178    use acp_utils::config_meta::SelectOptionMeta;
179    use tui::test_picker::{rendered_lines_from, type_query};
180    use tui::{KeyCode, KeyEvent, KeyModifiers};
181
182    fn rendered_lines(picker: &mut SettingsPicker) -> Vec<String> {
183        rendered_lines_from(&picker.render(&ViewContext::new((120, 40))))
184    }
185
186    fn entry() -> SettingsMenuEntry {
187        SettingsMenuEntry {
188            config_id: "model".to_string(),
189            title: "Model".to_string(),
190            multi_select: false,
191            display_name: None,
192            values: vec![
193                SettingsMenuValue {
194                    value: "openrouter:openai/gpt-4o".to_string(),
195                    name: "GPT-4o".to_string(),
196                    description: None,
197                    is_disabled: false,
198                    meta: SelectOptionMeta::default(),
199                },
200                SettingsMenuValue {
201                    value: "openrouter:anthropic/claude-3.5-sonnet".to_string(),
202                    name: "Claude Sonnet".to_string(),
203                    description: None,
204                    is_disabled: false,
205                    meta: SelectOptionMeta::default(),
206                },
207                SettingsMenuValue {
208                    value: "openrouter:google/gemini-2.5-pro".to_string(),
209                    name: "Gemini 2.5 Pro".to_string(),
210                    description: None,
211                    is_disabled: false,
212                    meta: SelectOptionMeta::default(),
213                },
214            ],
215            current_value_index: 0,
216            current_raw_value: "openrouter:openai/gpt-4o".to_string(),
217            entry_kind: SettingsMenuEntryKind::Select,
218        }
219    }
220
221    #[test]
222    fn initializes_with_current_value_selected() {
223        let mut picker = SettingsPicker::from_entry(&entry()).expect("picker");
224        let lines = rendered_lines(&mut picker);
225        // The first item line (after the header) should be the current selection
226        assert!(lines.iter().any(|l| l.contains("GPT-4o")), "should show GPT-4o in rendered lines: {lines:?}");
227    }
228
229    #[tokio::test]
230    async fn query_filters_by_name() {
231        let mut picker = SettingsPicker::from_entry(&entry()).expect("picker");
232        type_query(&mut picker, "gemini").await;
233        let lines = rendered_lines(&mut picker);
234        // header + 1 match
235        assert_eq!(lines.len(), 2);
236        assert!(lines[1].contains("Gemini 2.5 Pro"));
237    }
238
239    #[tokio::test]
240    async fn query_filters_by_value() {
241        let mut picker = SettingsPicker::from_entry(&entry()).expect("picker");
242        type_query(&mut picker, "anthropic/claude").await;
243        let lines = rendered_lines(&mut picker);
244        // header + 1 match
245        assert_eq!(lines.len(), 2);
246        assert!(lines[1].contains("Claude Sonnet"));
247    }
248
249    #[test]
250    fn confirm_selection_omits_unchanged_value() {
251        let picker = SettingsPicker::from_entry(&entry()).expect("picker");
252        assert!(picker.confirm_selection().is_none());
253    }
254
255    #[tokio::test]
256    async fn confirm_selection_returns_change_for_new_value() {
257        let mut picker = SettingsPicker::from_entry(&entry()).expect("picker");
258        picker.on_event(&Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))).await;
259        let change = picker.confirm_selection().expect("settings change");
260        assert_eq!(change.config_id, "model");
261        assert_eq!(change.new_value, "openrouter:anthropic/claude-3.5-sonnet".to_string());
262    }
263
264    #[tokio::test]
265    async fn disabled_option_cannot_be_confirmed() {
266        let mut entry = entry();
267        entry.values[1].is_disabled = true;
268        entry.values[1].description = Some("Unavailable: set ANTHROPIC_API_KEY".to_string());
269        entry.values[1].name = "Disabled Claude".to_string();
270
271        let mut picker = SettingsPicker::from_entry(&entry).expect("picker");
272        type_query(&mut picker, "disabled").await;
273        assert!(picker.confirm_selection().is_none());
274    }
275
276    #[tokio::test]
277    async fn handle_key_enter_returns_apply_selection_message() {
278        let mut picker = SettingsPicker::from_entry(&entry()).expect("picker");
279        picker.on_event(&Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))).await;
280
281        let outcome = picker.on_event(&Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))).await;
282
283        assert!(outcome.is_some());
284
285        let messages = outcome.unwrap();
286        match messages.as_slice() {
287            [SettingsPickerMessage::ApplySelection(Some(change))] => {
288                assert_eq!(change.config_id, "model");
289            }
290            _ => panic!("expected apply selection message"),
291        }
292    }
293
294    #[tokio::test]
295    async fn handle_key_escape_returns_close_message() {
296        let mut picker = SettingsPicker::from_entry(&entry()).expect("picker");
297
298        let outcome = picker.on_event(&Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))).await;
299
300        assert!(outcome.is_some());
301
302        let messages = outcome.unwrap();
303        assert!(matches!(messages.as_slice(), [SettingsPickerMessage::Close]));
304    }
305}