Skip to main content

wisp/settings/
picker.rs

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