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