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