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