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