Skip to main content

wisp/settings/
menu.rs

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