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