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