Skip to main content

wisp/settings/
overlay.rs

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