Skip to main content

wisp/settings/
menu.rs

1pub use super::types::{SettingsChange, SettingsMenuEntry, SettingsMenuEntryKind, SettingsMenuValue};
2use acp_utils::config_meta::{ConfigOptionMeta, SelectOptionMeta};
3use acp_utils::config_option_id::{ConfigOptionId, THEME_CONFIG_ID};
4use agent_client_protocol::schema::{SessionConfigKind, SessionConfigOption, SessionConfigSelectOptions};
5use tui::{Component, Event, Frame, Line, SelectItem, SelectList, SelectListMessage, ViewContext};
6
7pub struct SettingsMenu {
8    list: SelectList<SettingsMenuEntry>,
9}
10
11pub enum SettingMenuMessage {
12    CloseAll,
13    OpenSelectedPicker,
14    OpenMcpServers,
15    OpenProviderLogins,
16    OpenModelSelector,
17}
18
19impl SelectItem for SettingsMenuEntry {
20    fn render_item(&self, selected: bool, ctx: &ViewContext) -> Line {
21        let current_name = self
22            .display_name
23            .as_deref()
24            .or_else(|| self.values.get(self.current_value_index).map(|v| v.name.as_str()))
25            .unwrap_or("?");
26        let current_disabled =
27            self.display_name.is_none() && self.values.get(self.current_value_index).is_some_and(|v| v.is_disabled);
28        let text = format!("{}: {}", self.title, current_name);
29        if current_disabled {
30            Line::styled(text, ctx.theme.muted())
31        } else if selected {
32            Line::with_style(text, ctx.theme.selected_row_style())
33        } else {
34            Line::new(text)
35        }
36    }
37}
38
39impl Component for SettingsMenu {
40    type Message = SettingMenuMessage;
41
42    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
43        let outcome = self.list.on_event(event).await;
44        match outcome.as_deref() {
45            Some([SelectListMessage::Close]) => Some(vec![SettingMenuMessage::CloseAll]),
46            Some([SelectListMessage::Select(_)]) => {
47                let msg = match self.list.selected_item() {
48                    Some(e) if e.entry_kind == SettingsMenuEntryKind::McpServers => SettingMenuMessage::OpenMcpServers,
49                    Some(e) if e.entry_kind == SettingsMenuEntryKind::ProviderLogins => {
50                        SettingMenuMessage::OpenProviderLogins
51                    }
52                    Some(e) if e.multi_select => SettingMenuMessage::OpenModelSelector,
53                    _ => SettingMenuMessage::OpenSelectedPicker,
54                };
55                Some(vec![msg])
56            }
57            _ => outcome.map(|_| vec![]),
58        }
59    }
60
61    fn render(&mut self, context: &ViewContext) -> Frame {
62        self.list.render(context)
63    }
64}
65
66impl SettingsMenu {
67    pub fn from_config_options(options: &[SessionConfigOption]) -> Self {
68        let entries: Vec<SettingsMenuEntry> = options
69            .iter()
70            .filter(|opt| opt.id.0.as_ref() != ConfigOptionId::ReasoningEffort.as_str())
71            .filter_map(|opt| {
72                let SessionConfigKind::Select(ref select) = opt.kind else {
73                    return None;
74                };
75
76                let flat_options = match &select.options {
77                    SessionConfigSelectOptions::Ungrouped(opts) => opts.clone(),
78                    SessionConfigSelectOptions::Grouped(groups) => {
79                        groups.iter().flat_map(|g| g.options.clone()).collect()
80                    }
81                    _ => return None,
82                };
83
84                if flat_options.is_empty() {
85                    return None;
86                }
87
88                let current_value_index =
89                    flat_options.iter().position(|o| o.value == select.current_value).unwrap_or(0);
90
91                let values: Vec<SettingsMenuValue> = flat_options
92                    .into_iter()
93                    .map(|o| SettingsMenuValue {
94                        value: o.value.0.to_string(),
95                        name: o.name,
96                        is_disabled: o.description.as_deref().is_some_and(|d| d.starts_with("Unavailable:")),
97                        description: o.description,
98                        meta: SelectOptionMeta::from_meta(o.meta.as_ref()),
99                    })
100                    .collect();
101
102                let multi_select = ConfigOptionMeta::from_meta(opt.meta.as_ref()).multi_select;
103
104                let display_name = if multi_select && select.current_value.0.contains(',') {
105                    let parts: Vec<&str> = select.current_value.0.split(',').map(str::trim).collect();
106
107                    let names: Vec<&str> = parts
108                        .iter()
109                        .filter_map(|val| values.iter().find(|v| v.value == *val).map(|v| v.name.as_str()))
110                        .collect();
111
112                    if names.is_empty() { Some(format!("{} models", parts.len())) } else { Some(names.join(", ")) }
113                } else {
114                    None
115                };
116
117                Some(SettingsMenuEntry {
118                    config_id: opt.id.0.to_string(),
119                    title: opt.name.clone(),
120                    values,
121                    current_value_index,
122                    current_raw_value: select.current_value.0.to_string(),
123                    entry_kind: SettingsMenuEntryKind::Select,
124                    multi_select,
125                    display_name,
126                })
127            })
128            .collect();
129
130        Self { list: SelectList::new(entries, "no settings options") }
131    }
132
133    #[allow(dead_code)] // Used by integration tests
134    pub fn from_entries(entries: Vec<SettingsMenuEntry>) -> Self {
135        Self { list: SelectList::new(entries, "no settings options") }
136    }
137
138    #[cfg(test)]
139    pub fn options(&self) -> &[SettingsMenuEntry] {
140        self.list.items()
141    }
142
143    #[cfg(test)]
144    pub fn selected_index(&self) -> usize {
145        self.list.selected_index()
146    }
147
148    pub fn add_theme_entry(&mut self, current_theme_file: Option<&str>, theme_files: &[String]) {
149        let mut values = Vec::with_capacity(theme_files.len() + 1);
150        values.push(SettingsMenuValue {
151            value: String::new(),
152            name: "Default".to_string(),
153            description: None,
154            is_disabled: false,
155            meta: SelectOptionMeta::default(),
156        });
157
158        values.extend(theme_files.iter().map(|file| SettingsMenuValue {
159            value: file.clone(),
160            name: file.clone(),
161            description: None,
162            is_disabled: false,
163            meta: SelectOptionMeta::default(),
164        }));
165
166        let current_value_index =
167            current_theme_file.and_then(|file| values.iter().position(|v| v.value == file)).unwrap_or(0);
168        let current_raw_value = values.get(current_value_index).map(|v| v.value.clone()).unwrap_or_default();
169
170        self.list.push(SettingsMenuEntry {
171            config_id: THEME_CONFIG_ID.to_string(),
172            title: "Theme".to_string(),
173            values,
174            current_value_index,
175            current_raw_value,
176            entry_kind: SettingsMenuEntryKind::Select,
177            multi_select: false,
178            display_name: None,
179        });
180    }
181
182    pub fn add_mcp_servers_entry(&mut self, summary: &str) {
183        self.list.push(SettingsMenuEntry {
184            config_id: "__mcp_servers".to_string(),
185            title: "MCP Servers".to_string(),
186            values: vec![SettingsMenuValue {
187                value: String::new(),
188                name: summary.to_string(),
189                description: None,
190                is_disabled: false,
191                meta: SelectOptionMeta::default(),
192            }],
193            current_value_index: 0,
194            current_raw_value: String::new(),
195            entry_kind: SettingsMenuEntryKind::McpServers,
196            multi_select: false,
197            display_name: None,
198        });
199    }
200
201    pub fn upsert_mcp_servers_entry(&mut self, summary: &str) {
202        if let Some(entry) =
203            self.list.items_mut().iter_mut().find(|entry| entry.entry_kind == SettingsMenuEntryKind::McpServers)
204        {
205            if let Some(value) = entry.values.first_mut() {
206                value.name = summary.to_string();
207            }
208            return;
209        }
210
211        self.add_mcp_servers_entry(summary);
212    }
213
214    pub fn add_provider_logins_entry(&mut self, summary: &str) {
215        self.list.push(SettingsMenuEntry {
216            config_id: "__provider_logins".to_string(),
217            title: "Provider Logins".to_string(),
218            values: vec![SettingsMenuValue {
219                value: String::new(),
220                name: summary.to_string(),
221                description: None,
222                is_disabled: false,
223                meta: SelectOptionMeta::default(),
224            }],
225            current_value_index: 0,
226            current_raw_value: String::new(),
227            entry_kind: SettingsMenuEntryKind::ProviderLogins,
228            multi_select: false,
229            display_name: None,
230        });
231    }
232
233    pub fn update_options(&mut self, options: &[SessionConfigOption]) {
234        let prev_index = self.list.selected_index();
235        *self = Self::from_config_options(options);
236        let max = self.list.len().saturating_sub(1);
237        self.list.set_selected(prev_index.min(max));
238    }
239
240    pub fn selected_entry(&self) -> Option<&SettingsMenuEntry> {
241        self.list.selected_item()
242    }
243
244    pub fn apply_change(&mut self, change: &SettingsChange) {
245        let Some(entry) = self.list.items_mut().iter_mut().find(|entry| entry.config_id == change.config_id) else {
246            return;
247        };
248
249        entry.current_raw_value.clone_from(&change.new_value);
250        if let Some(index) = entry.values.iter().position(|value| value.value == change.new_value) {
251            entry.current_value_index = index;
252        }
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use agent_client_protocol::schema::{SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption};
260    use tui::{KeyCode, KeyEvent, KeyModifiers};
261
262    fn sel(id: &str, name: &str, current: &str, values: &[(&str, &str)]) -> SessionConfigOption {
263        let options: Vec<SessionConfigSelectOption> =
264            values.iter().map(|(v, n)| SessionConfigSelectOption::new((*v).to_string(), (*n).to_string())).collect();
265        SessionConfigOption::select(id.to_string(), name.to_string(), current.to_string(), options)
266    }
267
268    fn menu(opts: &[SessionConfigOption]) -> SettingsMenu {
269        SettingsMenu::from_config_options(opts)
270    }
271
272    fn key(code: KeyCode) -> Event {
273        Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
274    }
275
276    async fn press(menu: &mut SettingsMenu, code: KeyCode) -> Option<Vec<SettingMenuMessage>> {
277        menu.on_event(&key(code)).await
278    }
279
280    fn theme_files() -> Vec<String> {
281        vec!["sage.tmTheme".into(), "nord.tmTheme".into()]
282    }
283
284    fn theme_menu(current: Option<&str>) -> SettingsMenu {
285        let mut m = menu(&[]);
286        m.add_theme_entry(current, &theme_files());
287        m
288    }
289
290    #[test]
291    fn from_config_options_builds_entries() {
292        let m = menu(&[
293            sel("model", "Model", "gpt-4o", &[("gpt-4o", "GPT-4o"), ("claude", "Claude")]),
294            sel("mode", "Mode", "code", &[("code", "Code"), ("chat", "Chat")]),
295        ]);
296        assert_eq!(m.options().len(), 2);
297        assert_eq!(m.options()[0].config_id, "model");
298        assert_eq!(m.options()[0].current_value_index, 0);
299        assert_eq!(m.options()[0].display_name, None);
300        assert_eq!(m.options()[1].config_id, "mode");
301    }
302
303    #[test]
304    fn from_config_options_finds_current_value() {
305        let m =
306            menu(&[sel("model", "Model", "claude", &[("gpt-4o", "GPT-4o"), ("claude", "Claude"), ("llama", "Llama")])]);
307        assert_eq!(m.options()[0].current_value_index, 1);
308    }
309
310    #[tokio::test]
311    async fn navigation_wraps_around() {
312        let mut m = menu(&[
313            sel("a", "A", "v1", &[("v1", "V1")]),
314            sel("b", "B", "v1", &[("v1", "V1")]),
315            sel("c", "C", "v1", &[("v1", "V1")]),
316        ]);
317        assert_eq!(m.selected_index(), 0);
318
319        press(&mut m, KeyCode::Up).await;
320        assert_eq!(m.selected_index(), 2);
321
322        press(&mut m, KeyCode::Down).await;
323        assert_eq!(m.selected_index(), 0);
324
325        for _ in 0..3 {
326            press(&mut m, KeyCode::Down).await;
327        }
328        assert_eq!(m.selected_index(), 0);
329    }
330
331    #[test]
332    fn update_options_clamps_index() {
333        let mut m = menu(&[
334            sel("a", "A", "v1", &[("v1", "V1")]),
335            sel("b", "B", "v1", &[("v1", "V1")]),
336            sel("c", "C", "v1", &[("v1", "V1")]),
337        ]);
338        m.list.set_selected(2);
339        m.update_options(&[sel("a", "A", "v1", &[("v1", "V1")])]);
340        assert_eq!(m.selected_index(), 0);
341    }
342
343    #[test]
344    fn update_options_preserves_index_when_within_bounds() {
345        let mut m = menu(&[
346            sel("provider", "Provider", "a", &[("a", "A"), ("b", "B")]),
347            sel("model", "Model", "m1", &[("m1", "M1"), ("m2", "M2")]),
348        ]);
349        m.list.set_selected(1);
350        m.update_options(&[
351            sel("provider", "Provider", "b", &[("a", "A"), ("b", "B")]),
352            sel("model", "Model", "m3", &[("m3", "M3")]),
353        ]);
354        assert_eq!(m.selected_index(), 1);
355    }
356
357    #[test]
358    fn from_config_options_skips_empty_values() {
359        let empty = SessionConfigOption::select("x", "X", "v", Vec::<SessionConfigSelectOption>::new());
360        let m = menu(&[empty, sel("model", "Model", "a", &[("a", "A")])]);
361        assert_eq!(m.options().len(), 1);
362        assert_eq!(m.options()[0].config_id, "model");
363    }
364
365    #[test]
366    fn from_config_options_with_category() {
367        let opt = sel("model", "Model", "gpt-4o", &[("gpt-4o", "GPT-4o")]).category(SessionConfigOptionCategory::Model);
368        let m = menu(&[opt]);
369        assert_eq!(m.options().len(), 1);
370        assert_eq!(m.options()[0].title, "Model");
371    }
372
373    #[test]
374    fn upsert_mcp_servers_entry_adds_when_missing() {
375        let mut m = menu(&[]);
376        m.upsert_mcp_servers_entry("none");
377
378        let entries = m
379            .options()
380            .iter()
381            .filter(|entry| entry.entry_kind == SettingsMenuEntryKind::McpServers)
382            .collect::<Vec<_>>();
383        assert_eq!(entries.len(), 1);
384        assert_eq!(entries[0].values[0].name, "none");
385    }
386
387    #[test]
388    fn upsert_mcp_servers_entry_updates_existing_without_duplicate() {
389        let mut m = menu(&[]);
390        m.upsert_mcp_servers_entry("1 connected");
391        m.upsert_mcp_servers_entry("none");
392
393        let entries = m
394            .options()
395            .iter()
396            .filter(|entry| entry.entry_kind == SettingsMenuEntryKind::McpServers)
397            .collect::<Vec<_>>();
398        assert_eq!(entries.len(), 1);
399        assert_eq!(entries[0].values[0].name, "none");
400    }
401
402    #[tokio::test]
403    async fn key_enter_opens_picker_and_escape_closes() {
404        for (code, expected) in [(KeyCode::Enter, "OpenSelectedPicker"), (KeyCode::Esc, "CloseAll")] {
405            let mut m = menu(&[sel("model", "Model", "a", &[("a", "A")])]);
406            let msgs = press(&mut m, code).await.unwrap();
407            let tag = match &msgs[..] {
408                [SettingMenuMessage::OpenSelectedPicker] => "OpenSelectedPicker",
409                [SettingMenuMessage::CloseAll] => "CloseAll",
410                _ => "other",
411            };
412            assert_eq!(tag, expected, "key {code:?} should produce {expected}");
413        }
414    }
415
416    #[test]
417    fn multi_select_detected_from_meta() {
418        for (has_meta, expected) in [(true, true), (false, false)] {
419            let mut opt = sel("model", "Model", "a", &[("a", "A"), ("b", "B")]);
420            if has_meta {
421                opt = opt.meta(ConfigOptionMeta { multi_select: true }.into_meta());
422            }
423            let m = menu(&[opt]);
424            assert_eq!(m.options()[0].multi_select, expected, "meta={has_meta}");
425        }
426    }
427
428    #[tokio::test]
429    async fn multi_select_entry_opens_model_selector() {
430        let opt = sel("model", "Model", "a", &[("a", "A"), ("b", "B")])
431            .meta(ConfigOptionMeta { multi_select: true }.into_meta());
432        let mut m = menu(&[opt]);
433        let msgs = press(&mut m, KeyCode::Enter).await.unwrap();
434        assert!(matches!(msgs.as_slice(), [SettingMenuMessage::OpenModelSelector]));
435    }
436
437    #[test]
438    fn multi_select_with_comma_value_shows_model_names() {
439        let opt = sel("model", "Model", "a,b", &[("a", "Alpha"), ("b", "Beta")])
440            .meta(ConfigOptionMeta { multi_select: true }.into_meta());
441        let display = menu(&[opt]).options()[0].display_name.as_deref().unwrap().to_string();
442        assert!(display.contains("Alpha"), "display: {display}");
443        assert!(display.contains("Beta"), "display: {display}");
444    }
445
446    #[test]
447    fn apply_change_updates_matching_entry_value_and_index() {
448        let mut m = theme_menu(None);
449        m.apply_change(&SettingsChange {
450            config_id: THEME_CONFIG_ID.to_string(),
451            new_value: "nord.tmTheme".to_string(),
452        });
453        assert_eq!(m.options()[0].current_raw_value, "nord.tmTheme");
454        assert_eq!(m.options()[0].current_value_index, 2);
455    }
456
457    #[test]
458    fn add_theme_entry_inserts_theme_row() {
459        let m = theme_menu(None);
460        assert_eq!(m.options().len(), 1);
461        let t = &m.options()[0];
462        assert_eq!(t.config_id, THEME_CONFIG_ID);
463        assert_eq!(t.title, "Theme");
464        assert_eq!(t.entry_kind, SettingsMenuEntryKind::Select);
465        assert!(!t.multi_select);
466        assert_eq!(t.values.len(), 3);
467        assert_eq!(t.values[0].name, "Default");
468        assert_eq!(t.values[0].value, "");
469        assert_eq!(t.values[1].value, "sage.tmTheme");
470        assert_eq!(t.values[2].value, "nord.tmTheme");
471    }
472
473    #[test]
474    fn add_theme_entry_selects_correct_index() {
475        let cases: &[(Option<&str>, usize, &str)] =
476            &[(None, 0, ""), (Some("nord.tmTheme"), 2, "nord.tmTheme"), (Some("missing.tmTheme"), 0, "")];
477        for &(current, expected_idx, expected_raw) in cases {
478            let m = theme_menu(current);
479            let t = &m.options()[0];
480            assert_eq!(t.current_value_index, expected_idx, "current={current:?}");
481            assert_eq!(t.current_raw_value, expected_raw, "current={current:?}");
482        }
483    }
484
485    #[test]
486    fn from_config_options_excludes_reasoning_effort_entry() {
487        let m = menu(&[
488            sel("model", "Model", "gpt-4o", &[("gpt-4o", "GPT-4o"), ("claude", "Claude")]),
489            sel(
490                "reasoning_effort",
491                "Reasoning Effort",
492                "high",
493                &[("none", "None"), ("low", "Low"), ("medium", "Medium"), ("high", "High")],
494            ),
495        ]);
496        assert!(m.options().iter().any(|e| e.config_id == "model"));
497        assert!(!m.options().iter().any(|e| e.config_id == "reasoning_effort"));
498    }
499}