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