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