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