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