Skip to main content

wisp/settings/
overlay.rs

1use super::menu::{SettingMenuMessage, SettingsMenu};
2use super::picker::{SettingsPicker, SettingsPickerMessage};
3use crate::components::model_selector::{ModelEntry, ModelSelector, ModelSelectorMessage};
4use crate::components::provider_login::{ProviderLoginMessage, ProviderLoginOverlay};
5use crate::components::server_status::{ServerStatusMessage, ServerStatusOverlay};
6use acp_utils::config_option_id::ConfigOptionId;
7use acp_utils::notifications::McpServerStatusEntry;
8use agent_client_protocol::{self as acp, SessionConfigKind, SessionConfigOption};
9use tui::Panel;
10use tui::{Component, Cursor, Event, Frame, Layout, Line, ViewContext};
11use unicode_width::UnicodeWidthStr;
12
13const MIN_HEIGHT: usize = 3;
14const MIN_WIDTH: usize = 6;
15/// Panel chrome above child content: top border (1) + blank line (1).
16const TOP_CHROME: usize = 2;
17/// Panel left border width: "│ " = 2 chars.
18const BORDER_LEFT_WIDTH: usize = 2;
19/// Gap between children inside the container.
20const GAP: usize = 1;
21
22enum SettingsPane {
23    Menu,
24    Picker(SettingsPicker),
25    ModelSelector(ModelSelector),
26    ServerStatus(ServerStatusOverlay),
27    ProviderLogin(ProviderLoginOverlay),
28}
29
30pub struct SettingsOverlay {
31    menu: SettingsMenu,
32    active_pane: SettingsPane,
33    server_statuses: Vec<McpServerStatusEntry>,
34    auth_methods: Vec<acp::AuthMethod>,
35    current_reasoning_effort: Option<String>,
36}
37
38#[derive(Debug)]
39pub enum SettingsMessage {
40    Close,
41    SetConfigOption { config_id: String, value: String },
42    SetTheme(tui::Theme),
43    AuthenticateServer(String),
44    AuthenticateProvider(String),
45}
46
47impl SettingsOverlay {
48    pub fn new(
49        menu: SettingsMenu,
50        server_statuses: Vec<McpServerStatusEntry>,
51        auth_methods: Vec<acp::AuthMethod>,
52    ) -> Self {
53        Self {
54            menu,
55            active_pane: SettingsPane::Menu,
56            server_statuses,
57            auth_methods,
58            current_reasoning_effort: None,
59        }
60    }
61
62    pub fn with_reasoning_effort_from_options(mut self, options: &[SessionConfigOption]) -> Self {
63        self.current_reasoning_effort = Self::extract_reasoning_effort(options);
64        self
65    }
66
67    fn extract_reasoning_effort(options: &[SessionConfigOption]) -> Option<String> {
68        options
69            .iter()
70            .find(|opt| opt.id.0.as_ref() == ConfigOptionId::ReasoningEffort.as_str())
71            .and_then(|opt| match &opt.kind {
72                SessionConfigKind::Select(select) => {
73                    let value = select.current_value.0.trim();
74                    (!value.is_empty() && value != "none").then(|| value.to_string())
75                }
76                _ => None,
77            })
78    }
79
80    pub fn build_frame(&mut self, ctx: &ViewContext) -> Frame {
81        let cursor = if self.has_picker() {
82            Cursor::visible(self.cursor_row_offset(), self.cursor_col())
83        } else {
84            Cursor::hidden()
85        };
86        let mut layout = Layout::new();
87        layout.section(self.render(ctx).into_lines());
88        layout.into_frame().with_cursor(cursor)
89    }
90
91    pub fn update_child_viewport(&mut self, max_height: usize) {
92        match &mut self.active_pane {
93            SettingsPane::ModelSelector(ms) => ms.update_viewport(max_height),
94            SettingsPane::Picker(p) => p.update_viewport(max_height),
95            _ => {}
96        }
97    }
98
99    pub fn update_config_options(&mut self, options: &[SessionConfigOption]) {
100        self.current_reasoning_effort = Self::extract_reasoning_effort(options);
101        self.menu.update_options(options);
102        super::decorate_menu(&mut self.menu, &self.server_statuses, &self.auth_methods);
103    }
104
105    pub fn update_server_statuses(&mut self, statuses: Vec<McpServerStatusEntry>) {
106        self.server_statuses = statuses;
107        if let SettingsPane::ServerStatus(ref mut overlay) = self.active_pane {
108            overlay.update_entries(self.server_statuses.clone());
109        }
110    }
111
112    pub fn update_auth_methods(&mut self, auth_methods: Vec<acp::AuthMethod>) {
113        self.auth_methods = auth_methods;
114        super::decorate_menu(&mut self.menu, &self.server_statuses, &self.auth_methods);
115        let login_entries = super::build_login_entries(&self.auth_methods);
116        if let SettingsPane::ProviderLogin(ref mut overlay) = self.active_pane {
117            overlay.replace_entries(login_entries);
118        }
119    }
120
121    pub fn on_authenticate_started(&mut self, method_id: &str) {
122        if let SettingsPane::ProviderLogin(ref mut overlay) = self.active_pane {
123            overlay.set_authenticating(method_id);
124        }
125    }
126
127    pub fn on_authenticate_complete(&mut self, method_id: &str) {
128        if let SettingsPane::ProviderLogin(ref mut overlay) = self.active_pane {
129            overlay.set_logged_in(method_id);
130        }
131    }
132
133    pub fn on_authenticate_failed(&mut self, method_id: &str) {
134        if let SettingsPane::ProviderLogin(ref mut overlay) = self.active_pane {
135            overlay.reset_to_needs_login(method_id);
136        }
137    }
138
139    pub fn cursor_col(&self) -> usize {
140        match &self.active_pane {
141            SettingsPane::Picker(picker) => {
142                let prefix = format!("  {} search: ", picker.title);
143                BORDER_LEFT_WIDTH
144                    + UnicodeWidthStr::width(prefix.as_str())
145                    + UnicodeWidthStr::width(picker.query())
146            }
147            SettingsPane::ModelSelector(selector) => {
148                let prefix = "  Model search: ";
149                BORDER_LEFT_WIDTH
150                    + UnicodeWidthStr::width(prefix)
151                    + UnicodeWidthStr::width(selector.query())
152            }
153            _ => 0,
154        }
155    }
156
157    /// Returns the row offset of the cursor within the overlay (0-indexed from top of overlay).
158    /// Only meaningful when a search-based submenu is open (picker or model selector).
159    pub fn cursor_row_offset(&self) -> usize {
160        match &self.active_pane {
161            SettingsPane::Picker(_) | SettingsPane::ModelSelector(_) => TOP_CHROME,
162            _ => 0,
163        }
164    }
165
166    pub fn has_picker(&self) -> bool {
167        matches!(self.active_pane, SettingsPane::Picker(_))
168    }
169
170    fn footer_text(&self) -> &'static str {
171        match &self.active_pane {
172            SettingsPane::ModelSelector(_) => "[Space/Enter] Toggle  [Tab] Reasoning  [Esc] Done",
173            SettingsPane::Picker(_) => "[Enter] Confirm  [Esc] Back",
174            SettingsPane::ServerStatus(_) | SettingsPane::ProviderLogin(_) => {
175                "[Enter] Authenticate  [Esc] Back"
176            }
177            SettingsPane::Menu => "[Enter] Select  [Esc] Close",
178        }
179    }
180}
181
182impl Component for SettingsOverlay {
183    type Message = SettingsMessage;
184
185    #[allow(clippy::too_many_lines)]
186    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
187        if !matches!(event, Event::Key(_) | Event::Mouse(_)) {
188            return None;
189        }
190
191        match &mut self.active_pane {
192            SettingsPane::ServerStatus(overlay) => {
193                let outcome = overlay.on_event(event).await;
194                match outcome.unwrap_or_default().into_iter().next() {
195                    Some(ServerStatusMessage::Close) => {
196                        self.active_pane = SettingsPane::Menu;
197                        Some(vec![])
198                    }
199                    Some(ServerStatusMessage::Authenticate(name)) => {
200                        Some(vec![SettingsMessage::AuthenticateServer(name)])
201                    }
202                    None => Some(vec![]),
203                }
204            }
205            SettingsPane::ProviderLogin(overlay) => {
206                let outcome = overlay.on_event(event).await;
207                match outcome.unwrap_or_default().into_iter().next() {
208                    Some(ProviderLoginMessage::Close) => {
209                        self.active_pane = SettingsPane::Menu;
210                        Some(vec![])
211                    }
212                    Some(ProviderLoginMessage::Authenticate(method_id)) => {
213                        Some(vec![SettingsMessage::AuthenticateProvider(method_id)])
214                    }
215                    None => Some(vec![]),
216                }
217            }
218            SettingsPane::ModelSelector(selector) => {
219                let outcome = selector.on_event(event).await;
220                match outcome.unwrap_or_default().into_iter().next() {
221                    Some(ModelSelectorMessage::Done(changes)) => {
222                        self.active_pane = SettingsPane::Menu;
223                        if changes.is_empty() {
224                            Some(vec![])
225                        } else {
226                            Some(super::process_config_changes(changes))
227                        }
228                    }
229                    None => Some(vec![]),
230                }
231            }
232            SettingsPane::Picker(picker) => {
233                let outcome = picker.on_event(event).await;
234                match outcome.unwrap_or_default().into_iter().next() {
235                    Some(SettingsPickerMessage::Close) => {
236                        self.active_pane = SettingsPane::Menu;
237                        Some(vec![])
238                    }
239                    Some(SettingsPickerMessage::ApplySelection(change)) => {
240                        if let Some(change) = change {
241                            self.menu.apply_change(&change);
242                            self.active_pane = SettingsPane::Menu;
243                            Some(super::process_config_changes(vec![change]))
244                        } else {
245                            self.active_pane = SettingsPane::Menu;
246                            Some(vec![])
247                        }
248                    }
249                    None => Some(vec![]),
250                }
251            }
252            SettingsPane::Menu => {
253                let outcome = self.menu.on_event(event).await;
254                let messages = outcome.unwrap_or_default();
255                match messages.as_slice() {
256                    [SettingMenuMessage::CloseAll] => Some(vec![SettingsMessage::Close]),
257                    [SettingMenuMessage::OpenSelectedPicker] => {
258                        if let Some(picker) = self
259                            .menu
260                            .selected_entry()
261                            .and_then(SettingsPicker::from_entry)
262                        {
263                            self.active_pane = SettingsPane::Picker(picker);
264                        }
265                        Some(vec![])
266                    }
267                    [SettingMenuMessage::OpenModelSelector] => {
268                        if let Some(entry) = self.menu.selected_entry() {
269                            let current =
270                                Some(entry.current_raw_value.as_str()).filter(|v| !v.is_empty());
271                            let items: Vec<ModelEntry> = entry
272                                .values
273                                .iter()
274                                .filter(|v| !v.is_disabled)
275                                .map(|v| ModelEntry {
276                                    value: v.value.clone(),
277                                    name: v.name.clone(),
278                                    reasoning_levels: v.meta.reasoning_levels.clone(),
279                                    supports_image: v.meta.supports_image,
280                                    supports_audio: v.meta.supports_audio,
281                                })
282                                .collect();
283                            self.active_pane = SettingsPane::ModelSelector(ModelSelector::new(
284                                items,
285                                entry.config_id.clone(),
286                                current,
287                                self.current_reasoning_effort.as_deref(),
288                            ));
289                        }
290                        Some(vec![])
291                    }
292                    [SettingMenuMessage::OpenMcpServers] => {
293                        self.active_pane = SettingsPane::ServerStatus(ServerStatusOverlay::new(
294                            self.server_statuses.clone(),
295                        ));
296                        Some(vec![])
297                    }
298                    [SettingMenuMessage::OpenProviderLogins] => {
299                        let entries = super::build_login_entries(&self.auth_methods);
300                        self.active_pane =
301                            SettingsPane::ProviderLogin(ProviderLoginOverlay::new(entries));
302                        Some(vec![])
303                    }
304                    _ => Some(vec![]),
305                }
306            }
307        }
308    }
309
310    fn render(&mut self, context: &ViewContext) -> Frame {
311        let height = (context.size.height.saturating_sub(1)) as usize;
312        let width = context.size.width as usize;
313        if height < MIN_HEIGHT || width < MIN_WIDTH {
314            return Frame::new(vec![Line::new("(terminal too small)")]);
315        }
316
317        let footer = self.footer_text();
318        #[allow(clippy::cast_possible_truncation)]
319        let child_max_height = height.saturating_sub(4) as u16;
320        let inner_w = Panel::inner_width(context.size.width);
321        let child_context = context.with_size((inner_w, child_max_height));
322
323        let child_lines = match &mut self.active_pane {
324            SettingsPane::ServerStatus(overlay) => overlay.render(&child_context).into_lines(),
325            SettingsPane::ProviderLogin(overlay) => overlay.render(&child_context).into_lines(),
326            SettingsPane::ModelSelector(selector) => selector.render(&child_context).into_lines(),
327            SettingsPane::Picker(picker) => picker.render(&child_context).into_lines(),
328            SettingsPane::Menu => self.menu.render(&child_context).into_lines(),
329        };
330
331        let mut container = Panel::new(context.theme.muted())
332            .title(" Configuration ")
333            .footer(footer)
334            .fill_height(height)
335            .gap(GAP);
336        container.push(child_lines);
337        Frame::new(container.render(context))
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::components::provider_login::ProviderLoginStatus;
345    use acp_utils::config_option_id::THEME_CONFIG_ID;
346    use acp_utils::notifications::McpServerStatus;
347    use agent_client_protocol::SessionConfigSelectOption;
348    use tui::{KeyCode, KeyEvent, KeyModifiers};
349
350    fn select_opt(id: &'static str, name: &'static str) -> SessionConfigSelectOption {
351        SessionConfigSelectOption::new(id, name)
352    }
353
354    fn config_select(
355        id: &'static str,
356        label: &'static str,
357        current: &'static str,
358        opts: Vec<SessionConfigSelectOption>,
359    ) -> agent_client_protocol::SessionConfigOption {
360        agent_client_protocol::SessionConfigOption::select(id, label, current, opts)
361    }
362
363    fn provider_model_options(
364        multi_select_model: bool,
365    ) -> Vec<agent_client_protocol::SessionConfigOption> {
366        let provider = config_select(
367            "provider",
368            "Provider",
369            "openrouter",
370            vec![
371                select_opt("openrouter", "OpenRouter"),
372                select_opt("ollama", "Ollama"),
373            ],
374        );
375        let mut model = config_select(
376            "model",
377            "Model",
378            "gpt-4o",
379            vec![
380                select_opt("gpt-4o", "GPT-4o"),
381                select_opt("claude", "Claude"),
382            ],
383        );
384        if multi_select_model {
385            let mut meta = serde_json::Map::new();
386            meta.insert("multi_select".to_string(), serde_json::Value::Bool(true));
387            model = model.meta(meta);
388        }
389        vec![provider, model]
390    }
391
392    fn make_menu() -> SettingsMenu {
393        SettingsMenu::from_config_options(&provider_model_options(false))
394    }
395
396    fn make_multi_select_menu() -> SettingsMenu {
397        SettingsMenu::from_config_options(&provider_model_options(true))
398    }
399
400    fn make_server_statuses() -> Vec<McpServerStatusEntry> {
401        vec![
402            McpServerStatusEntry {
403                name: "github".to_string(),
404                status: McpServerStatus::Connected { tool_count: 5 },
405            },
406            McpServerStatusEntry {
407                name: "linear".to_string(),
408                status: McpServerStatus::NeedsOAuth,
409            },
410        ]
411    }
412
413    fn make_auth_methods() -> Vec<acp::AuthMethod> {
414        vec![
415            acp::AuthMethod::Agent(acp::AuthMethodAgent::new("anthropic", "Anthropic")),
416            acp::AuthMethod::Agent(acp::AuthMethodAgent::new("openrouter", "OpenRouter")),
417        ]
418    }
419
420    fn key(code: KeyCode) -> KeyEvent {
421        KeyEvent::new(code, KeyModifiers::NONE)
422    }
423
424    async fn send_keys(overlay: &mut SettingsOverlay, codes: &[KeyCode]) {
425        for code in codes {
426            overlay.on_event(&Event::Key(key(*code))).await;
427        }
428    }
429
430    fn render_footer(overlay: &mut SettingsOverlay) -> String {
431        let context = ViewContext::new((80, 24));
432        let height = (context.size.height.saturating_sub(1)) as usize;
433        overlay.update_child_viewport(height.saturating_sub(4));
434        let frame = overlay.render(&context);
435        let lines = frame.lines();
436        lines[lines.len() - 2].plain_text()
437    }
438
439    fn new_overlay() -> SettingsOverlay {
440        SettingsOverlay::new(make_menu(), vec![], vec![])
441    }
442
443    fn new_multi_select_overlay() -> SettingsOverlay {
444        SettingsOverlay::new(make_multi_select_menu(), vec![], vec![])
445    }
446
447    /// Open the model selector on a multi-select overlay (Down to model row, Enter to open).
448    async fn open_model_selector() -> SettingsOverlay {
449        let mut overlay = new_multi_select_overlay();
450        send_keys(&mut overlay, &[KeyCode::Down, KeyCode::Enter]).await;
451        assert!(render_footer(&mut overlay).contains("Toggle"));
452        overlay
453    }
454
455    fn assert_footer_contains(overlay: &mut SettingsOverlay, needle: &str) {
456        let footer = render_footer(overlay);
457        assert!(
458            footer.contains(needle),
459            "expected footer to contain '{needle}'; got: {footer}"
460        );
461    }
462
463    fn has_entry_kind(
464        overlay: &SettingsOverlay,
465        kind: crate::settings::types::SettingsMenuEntryKind,
466    ) -> bool {
467        overlay.menu.options().iter().any(|e| e.entry_kind == kind)
468    }
469
470    #[tokio::test]
471    async fn esc_closes_overlay() {
472        let mut overlay = new_overlay();
473        let messages = overlay
474            .on_event(&Event::Key(key(KeyCode::Esc)))
475            .await
476            .unwrap();
477        assert!(matches!(messages.as_slice(), [SettingsMessage::Close]));
478    }
479
480    #[tokio::test]
481    async fn enter_opens_picker() {
482        let mut overlay = new_overlay();
483        let outcome = overlay.on_event(&Event::Key(key(KeyCode::Enter))).await;
484        assert!(outcome.is_some());
485        assert!(overlay.has_picker());
486    }
487
488    #[tokio::test]
489    async fn picker_esc_closes_picker_not_overlay() {
490        let mut overlay = new_overlay();
491        send_keys(&mut overlay, &[KeyCode::Enter]).await;
492        assert!(overlay.has_picker());
493
494        let messages = overlay
495            .on_event(&Event::Key(key(KeyCode::Esc)))
496            .await
497            .unwrap();
498        assert!(!overlay.has_picker());
499        assert!(messages.is_empty(), "overlay should remain open");
500    }
501
502    #[tokio::test]
503    async fn picker_confirm_returns_settings_change_action() {
504        let mut overlay = new_overlay();
505        send_keys(&mut overlay, &[KeyCode::Enter, KeyCode::Down]).await;
506        let messages = overlay
507            .on_event(&Event::Key(key(KeyCode::Enter)))
508            .await
509            .unwrap();
510
511        match messages.as_slice() {
512            [SettingsMessage::SetConfigOption { config_id, value }] => {
513                assert_eq!(config_id, "provider");
514                assert_eq!(value, "ollama");
515            }
516            other => panic!("expected SetConfigOption, got: {other:?}"),
517        }
518    }
519
520    /// Shared setup for theme-picker tests: creates temp dir, theme menu, sends Enter/Down/Enter.
521    fn run_theme_picker_test(check: impl FnOnce(&SettingsOverlay)) {
522        use crate::test_helpers::with_wisp_home;
523
524        let temp_dir = tempfile::TempDir::new().unwrap();
525        with_wisp_home(temp_dir.path(), || {
526            let rt = tokio::runtime::Runtime::new().unwrap();
527            rt.block_on(async {
528                let mut menu = SettingsMenu::from_config_options(&[]);
529                menu.add_theme_entry(None, &["nord.tmTheme".to_string()]);
530                let mut overlay = SettingsOverlay::new(menu, vec![], vec![]);
531                send_keys(
532                    &mut overlay,
533                    &[KeyCode::Enter, KeyCode::Down, KeyCode::Enter],
534                )
535                .await;
536                check(&overlay);
537            });
538        });
539    }
540
541    #[test]
542    fn settings_overlay_picker_confirm_updates_menu_row_immediately() {
543        run_theme_picker_test(|overlay| {
544            let entry = &overlay.menu.options()[0];
545            assert_eq!(entry.config_id, THEME_CONFIG_ID);
546            assert_eq!(entry.current_raw_value, "nord.tmTheme");
547            assert_eq!(entry.current_value_index, 1);
548        });
549    }
550
551    #[test]
552    fn settings_overlay_picker_confirm_persists_theme_to_settings() {
553        run_theme_picker_test(|_overlay| {
554            let settings = crate::settings::load_or_create_settings();
555            assert_eq!(settings.theme.file.as_deref(), Some("nord.tmTheme"));
556        });
557    }
558
559    #[tokio::test]
560    async fn cursor_col_and_row_offset() {
561        // Without picker: cursor col is 0
562        let overlay = new_overlay();
563        assert_eq!(overlay.cursor_col(), 0);
564
565        // With picker open: cursor col includes border + prefix, row offset = TOP_CHROME
566        let mut overlay = new_overlay();
567        send_keys(&mut overlay, &[KeyCode::Enter]).await;
568        assert!(overlay.cursor_col() > 0);
569        assert_eq!(overlay.cursor_row_offset(), TOP_CHROME);
570    }
571
572    #[tokio::test]
573    async fn model_selector_esc_without_toggle_returns_no_change() {
574        let mut overlay = open_model_selector().await;
575        let messages = overlay
576            .on_event(&Event::Key(key(KeyCode::Esc)))
577            .await
578            .unwrap();
579        assert_footer_contains(&mut overlay, "[Enter] Select");
580        assert!(
581            messages.is_empty(),
582            "escape without toggling should produce no change"
583        );
584    }
585
586    #[tokio::test]
587    async fn model_selector_esc_after_deselecting_all_returns_no_change() {
588        let mut overlay = open_model_selector().await;
589        send_keys(&mut overlay, &[KeyCode::Char(' ')]).await; // deselect pre-selected
590
591        let messages = overlay
592            .on_event(&Event::Key(key(KeyCode::Esc)))
593            .await
594            .unwrap();
595        assert_footer_contains(&mut overlay, "[Enter] Select");
596        assert!(messages.is_empty());
597    }
598
599    #[tokio::test]
600    async fn model_selector_enter_toggles_not_confirms() {
601        let mut overlay = open_model_selector().await;
602        send_keys(&mut overlay, &[KeyCode::Enter]).await;
603        assert_footer_contains(&mut overlay, "Toggle");
604    }
605
606    #[tokio::test]
607    async fn model_selector_uses_overlay_reasoning_prefill_after_menu_removal() {
608        use crate::settings::types::{SettingsMenuEntry, SettingsMenuEntryKind, SettingsMenuValue};
609        use acp_utils::config_meta::SelectOptionMeta;
610
611        let menu = SettingsMenu::from_entries(vec![SettingsMenuEntry {
612            config_id: "model".to_string(),
613            title: "Model".to_string(),
614            values: vec![
615                SettingsMenuValue {
616                    value: "claude-opus".to_string(),
617                    name: "Claude Opus".to_string(),
618                    description: None,
619                    is_disabled: false,
620                    meta: SelectOptionMeta {
621                        reasoning_levels: vec![
622                            utils::ReasoningEffort::Low,
623                            utils::ReasoningEffort::Medium,
624                            utils::ReasoningEffort::High,
625                        ],
626                        supports_image: false,
627                        supports_audio: false,
628                    },
629                },
630                SettingsMenuValue {
631                    value: "gpt-4o".to_string(),
632                    name: "GPT-4o".to_string(),
633                    description: None,
634                    is_disabled: false,
635                    meta: SelectOptionMeta::default(),
636                },
637            ],
638            current_value_index: 0,
639            current_raw_value: "claude-opus".to_string(),
640            entry_kind: SettingsMenuEntryKind::Select,
641            multi_select: true,
642            display_name: None,
643        }]);
644
645        let reasoning_options = vec![
646            config_select(
647                "model",
648                "Model",
649                "claude-opus",
650                vec![
651                    select_opt("claude-opus", "Claude Opus"),
652                    select_opt("gpt-4o", "GPT-4o"),
653                ],
654            ),
655            config_select(
656                "reasoning_effort",
657                "Reasoning Effort",
658                "medium",
659                vec![
660                    select_opt("none", "None"),
661                    select_opt("low", "Low"),
662                    select_opt("medium", "Medium"),
663                    select_opt("high", "High"),
664                ],
665            ),
666        ];
667        let mut overlay = SettingsOverlay::new(menu, vec![], vec![])
668            .with_reasoning_effort_from_options(&reasoning_options);
669
670        send_keys(&mut overlay, &[KeyCode::Enter]).await;
671        assert_footer_contains(&mut overlay, "Toggle");
672
673        send_keys(&mut overlay, &[KeyCode::Tab]).await;
674        let messages = overlay
675            .on_event(&Event::Key(key(KeyCode::Esc)))
676            .await
677            .unwrap();
678
679        let reasoning_msg = messages.iter().find(|m| {
680            matches!(m, SettingsMessage::SetConfigOption { config_id, .. } if config_id == "reasoning_effort")
681        });
682        assert!(
683            reasoning_msg.is_some(),
684            "expected reasoning_effort change; got: {messages:?}"
685        );
686        match reasoning_msg.unwrap() {
687            SettingsMessage::SetConfigOption { value, .. } => {
688                assert_eq!(
689                    value, "high",
690                    "reasoning should be high after one right from medium"
691                );
692            }
693            other => panic!("expected SetConfigOption, got: {other:?}"),
694        }
695    }
696
697    #[test]
698    fn update_settings_options_preserves_mcp_servers_entry() {
699        use crate::settings::types::SettingsMenuEntryKind;
700        use crate::test_helpers::with_wisp_home;
701
702        let temp_dir = tempfile::TempDir::new().unwrap();
703        let themes_dir = temp_dir.path().join("themes");
704        std::fs::create_dir_all(&themes_dir).unwrap();
705        std::fs::write(themes_dir.join("custom.tmTheme"), "x").unwrap();
706
707        with_wisp_home(temp_dir.path(), || {
708            let mut menu = make_menu();
709            menu.add_mcp_servers_entry("1 connected, 1 needs auth");
710            let mut overlay = SettingsOverlay::new(menu, make_server_statuses(), vec![]);
711
712            assert!(
713                has_entry_kind(&overlay, SettingsMenuEntryKind::McpServers),
714                "MCP servers entry should exist before update"
715            );
716
717            let new_options = vec![
718                config_select(
719                    "provider",
720                    "Provider",
721                    "ollama",
722                    vec![
723                        select_opt("openrouter", "OpenRouter"),
724                        select_opt("ollama", "Ollama"),
725                    ],
726                ),
727                config_select(
728                    "model",
729                    "Model",
730                    "llama",
731                    vec![select_opt("llama", "Llama")],
732                ),
733            ];
734            overlay.update_config_options(&new_options);
735
736            assert!(
737                overlay
738                    .menu
739                    .options()
740                    .iter()
741                    .any(|e| e.config_id == THEME_CONFIG_ID),
742                "Theme entry should survive update_config_options"
743            );
744            assert!(
745                has_entry_kind(&overlay, SettingsMenuEntryKind::McpServers),
746                "MCP servers entry should survive update_config_options"
747            );
748        });
749    }
750
751    #[tokio::test]
752    async fn authenticate_complete_sets_logged_in_status() {
753        let mut menu = make_menu();
754        menu.add_provider_logins_entry("2 needs login");
755        let mut overlay = SettingsOverlay::new(menu, vec![], make_auth_methods());
756        send_keys(
757            &mut overlay,
758            &[KeyCode::Down, KeyCode::Down, KeyCode::Enter],
759        )
760        .await;
761        assert!(matches!(
762            overlay.active_pane,
763            SettingsPane::ProviderLogin(_)
764        ));
765
766        overlay.on_authenticate_complete("anthropic");
767
768        assert!(matches!(
769            overlay.active_pane,
770            SettingsPane::ProviderLogin(_)
771        ));
772        if let SettingsPane::ProviderLogin(ref inner) = overlay.active_pane {
773            let entry = inner
774                .entries()
775                .iter()
776                .find(|e| e.method_id == "anthropic")
777                .expect("anthropic entry should still exist");
778            assert_eq!(entry.status, ProviderLoginStatus::LoggedIn);
779        }
780    }
781}