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