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