1pub use super::types::{
2 SettingsChange, SettingsMenuEntry, SettingsMenuEntryKind, SettingsMenuValue,
3};
4use acp_utils::config_meta::{ConfigOptionMeta, SelectOptionMeta};
5use acp_utils::config_option_id::{ConfigOptionId, THEME_CONFIG_ID};
6use agent_client_protocol::{SessionConfigKind, SessionConfigOption, SessionConfigSelectOptions};
7use tui::{Component, Event, Frame, Line, SelectItem, SelectList, SelectListMessage, ViewContext};
8
9pub struct SettingsMenu {
10 list: SelectList<SettingsMenuEntry>,
11}
12
13pub enum SettingMenuMessage {
14 CloseAll,
15 OpenSelectedPicker,
16 OpenMcpServers,
17 OpenProviderLogins,
18 OpenModelSelector,
19}
20
21impl SelectItem for SettingsMenuEntry {
22 fn render_item(&self, selected: bool, ctx: &ViewContext) -> Line {
23 let current_name = self
24 .display_name
25 .as_deref()
26 .or_else(|| {
27 self.values
28 .get(self.current_value_index)
29 .map(|v| v.name.as_str())
30 })
31 .unwrap_or("?");
32 let current_disabled = self.display_name.is_none()
33 && self
34 .values
35 .get(self.current_value_index)
36 .is_some_and(|v| v.is_disabled);
37 let text = format!("{}: {}", self.title, current_name);
38 if current_disabled {
39 Line::styled(text, ctx.theme.muted())
40 } else if selected {
41 Line::with_style(text, ctx.theme.selected_row_style())
42 } else {
43 Line::new(text)
44 }
45 }
46}
47
48impl Component for SettingsMenu {
49 type Message = SettingMenuMessage;
50
51 async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
52 let outcome = self.list.on_event(event).await;
53 match outcome.as_deref() {
54 Some([SelectListMessage::Close]) => Some(vec![SettingMenuMessage::CloseAll]),
55 Some([SelectListMessage::Select(_)]) => {
56 let msg = match self.list.selected_item() {
57 Some(e) if e.entry_kind == SettingsMenuEntryKind::McpServers => {
58 SettingMenuMessage::OpenMcpServers
59 }
60 Some(e) if e.entry_kind == SettingsMenuEntryKind::ProviderLogins => {
61 SettingMenuMessage::OpenProviderLogins
62 }
63 Some(e) if e.multi_select => SettingMenuMessage::OpenModelSelector,
64 _ => SettingMenuMessage::OpenSelectedPicker,
65 };
66 Some(vec![msg])
67 }
68 _ => outcome.map(|_| vec![]),
69 }
70 }
71
72 fn render(&mut self, context: &ViewContext) -> Frame {
73 self.list.render(context)
74 }
75}
76
77impl SettingsMenu {
78 pub fn from_config_options(options: &[SessionConfigOption]) -> Self {
79 let entries: Vec<SettingsMenuEntry> = options
80 .iter()
81 .filter(|opt| opt.id.0.as_ref() != ConfigOptionId::ReasoningEffort.as_str())
82 .filter_map(|opt| {
83 let SessionConfigKind::Select(ref select) = opt.kind else {
84 return None;
85 };
86
87 let flat_options = match &select.options {
88 SessionConfigSelectOptions::Ungrouped(opts) => opts.clone(),
89 SessionConfigSelectOptions::Grouped(groups) => {
90 groups.iter().flat_map(|g| g.options.clone()).collect()
91 }
92 _ => return None,
93 };
94
95 if flat_options.is_empty() {
96 return None;
97 }
98
99 let current_value_index = flat_options
100 .iter()
101 .position(|o| o.value == select.current_value)
102 .unwrap_or(0);
103
104 let values: Vec<SettingsMenuValue> = flat_options
105 .into_iter()
106 .map(|o| SettingsMenuValue {
107 value: o.value.0.to_string(),
108 name: o.name,
109 is_disabled: o
110 .description
111 .as_deref()
112 .is_some_and(|d| d.starts_with("Unavailable:")),
113 description: o.description,
114 meta: SelectOptionMeta::from_meta(o.meta.as_ref()),
115 })
116 .collect();
117
118 let multi_select = ConfigOptionMeta::from_meta(opt.meta.as_ref()).multi_select;
119
120 let display_name = if multi_select && select.current_value.0.contains(',') {
121 let parts: Vec<&str> =
122 select.current_value.0.split(',').map(str::trim).collect();
123
124 let names: Vec<&str> = parts
125 .iter()
126 .filter_map(|val| {
127 values
128 .iter()
129 .find(|v| v.value == *val)
130 .map(|v| v.name.as_str())
131 })
132 .collect();
133
134 if names.is_empty() {
135 Some(format!("{} models", parts.len()))
136 } else {
137 Some(names.join(", "))
138 }
139 } else {
140 None
141 };
142
143 Some(SettingsMenuEntry {
144 config_id: opt.id.0.to_string(),
145 title: opt.name.clone(),
146 values,
147 current_value_index,
148 current_raw_value: select.current_value.0.to_string(),
149 entry_kind: SettingsMenuEntryKind::Select,
150 multi_select,
151 display_name,
152 })
153 })
154 .collect();
155
156 Self {
157 list: SelectList::new(entries, "no settings options"),
158 }
159 }
160
161 #[allow(dead_code)] pub fn from_entries(entries: Vec<SettingsMenuEntry>) -> Self {
163 Self {
164 list: SelectList::new(entries, "no settings options"),
165 }
166 }
167
168 #[cfg(test)]
169 pub fn options(&self) -> &[SettingsMenuEntry] {
170 self.list.items()
171 }
172
173 #[cfg(test)]
174 pub fn selected_index(&self) -> usize {
175 self.list.selected_index()
176 }
177
178 pub fn add_theme_entry(&mut self, current_theme_file: Option<&str>, theme_files: &[String]) {
179 let mut values = Vec::with_capacity(theme_files.len() + 1);
180 values.push(SettingsMenuValue {
181 value: String::new(),
182 name: "Default".to_string(),
183 description: None,
184 is_disabled: false,
185 meta: SelectOptionMeta::default(),
186 });
187
188 values.extend(theme_files.iter().map(|file| SettingsMenuValue {
189 value: file.clone(),
190 name: file.clone(),
191 description: None,
192 is_disabled: false,
193 meta: SelectOptionMeta::default(),
194 }));
195
196 let current_value_index = current_theme_file
197 .and_then(|file| values.iter().position(|v| v.value == file))
198 .unwrap_or(0);
199 let current_raw_value = values
200 .get(current_value_index)
201 .map(|v| v.value.clone())
202 .unwrap_or_default();
203
204 self.list.push(SettingsMenuEntry {
205 config_id: THEME_CONFIG_ID.to_string(),
206 title: "Theme".to_string(),
207 values,
208 current_value_index,
209 current_raw_value,
210 entry_kind: SettingsMenuEntryKind::Select,
211 multi_select: false,
212 display_name: None,
213 });
214 }
215
216 pub fn add_mcp_servers_entry(&mut self, summary: &str) {
217 self.list.push(SettingsMenuEntry {
218 config_id: "__mcp_servers".to_string(),
219 title: "MCP Servers".to_string(),
220 values: vec![SettingsMenuValue {
221 value: String::new(),
222 name: summary.to_string(),
223 description: None,
224 is_disabled: false,
225 meta: SelectOptionMeta::default(),
226 }],
227 current_value_index: 0,
228 current_raw_value: String::new(),
229 entry_kind: SettingsMenuEntryKind::McpServers,
230 multi_select: false,
231 display_name: None,
232 });
233 }
234
235 pub fn add_provider_logins_entry(&mut self, summary: &str) {
236 self.list.push(SettingsMenuEntry {
237 config_id: "__provider_logins".to_string(),
238 title: "Provider Logins".to_string(),
239 values: vec![SettingsMenuValue {
240 value: String::new(),
241 name: summary.to_string(),
242 description: None,
243 is_disabled: false,
244 meta: SelectOptionMeta::default(),
245 }],
246 current_value_index: 0,
247 current_raw_value: String::new(),
248 entry_kind: SettingsMenuEntryKind::ProviderLogins,
249 multi_select: false,
250 display_name: None,
251 });
252 }
253
254 pub fn update_options(&mut self, options: &[SessionConfigOption]) {
255 let prev_index = self.list.selected_index();
256 *self = Self::from_config_options(options);
257 let max = self.list.len().saturating_sub(1);
258 self.list.set_selected(prev_index.min(max));
259 }
260
261 pub fn selected_entry(&self) -> Option<&SettingsMenuEntry> {
262 self.list.selected_item()
263 }
264
265 pub fn apply_change(&mut self, change: &SettingsChange) {
266 let Some(entry) = self
267 .list
268 .items_mut()
269 .iter_mut()
270 .find(|entry| entry.config_id == change.config_id)
271 else {
272 return;
273 };
274
275 entry.current_raw_value.clone_from(&change.new_value);
276 if let Some(index) = entry
277 .values
278 .iter()
279 .position(|value| value.value == change.new_value)
280 {
281 entry.current_value_index = index;
282 }
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use agent_client_protocol::{
290 SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption,
291 };
292 use tui::{KeyCode, KeyEvent, KeyModifiers};
293
294 fn sel(id: &str, name: &str, current: &str, values: &[(&str, &str)]) -> SessionConfigOption {
295 let options: Vec<SessionConfigSelectOption> = values
296 .iter()
297 .map(|(v, n)| SessionConfigSelectOption::new(v.to_string(), n.to_string()))
298 .collect();
299 SessionConfigOption::select(
300 id.to_string(),
301 name.to_string(),
302 current.to_string(),
303 options,
304 )
305 }
306
307 fn menu(opts: &[SessionConfigOption]) -> SettingsMenu {
308 SettingsMenu::from_config_options(opts)
309 }
310
311 fn key(code: KeyCode) -> Event {
312 Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
313 }
314
315 async fn press(menu: &mut SettingsMenu, code: KeyCode) -> Option<Vec<SettingMenuMessage>> {
316 menu.on_event(&key(code)).await
317 }
318
319 fn theme_files() -> Vec<String> {
320 vec!["catppuccin.tmTheme".into(), "nord.tmTheme".into()]
321 }
322
323 fn theme_menu(current: Option<&str>) -> SettingsMenu {
324 let mut m = menu(&[]);
325 m.add_theme_entry(current, &theme_files());
326 m
327 }
328
329 #[test]
330 fn from_config_options_builds_entries() {
331 let m = menu(&[
332 sel(
333 "model",
334 "Model",
335 "gpt-4o",
336 &[("gpt-4o", "GPT-4o"), ("claude", "Claude")],
337 ),
338 sel(
339 "mode",
340 "Mode",
341 "code",
342 &[("code", "Code"), ("chat", "Chat")],
343 ),
344 ]);
345 assert_eq!(m.options().len(), 2);
346 assert_eq!(m.options()[0].config_id, "model");
347 assert_eq!(m.options()[0].current_value_index, 0);
348 assert_eq!(m.options()[0].display_name, None);
349 assert_eq!(m.options()[1].config_id, "mode");
350 }
351
352 #[test]
353 fn from_config_options_finds_current_value() {
354 let m = menu(&[sel(
355 "model",
356 "Model",
357 "claude",
358 &[
359 ("gpt-4o", "GPT-4o"),
360 ("claude", "Claude"),
361 ("llama", "Llama"),
362 ],
363 )]);
364 assert_eq!(m.options()[0].current_value_index, 1);
365 }
366
367 #[tokio::test]
368 async fn navigation_wraps_around() {
369 let mut m = menu(&[
370 sel("a", "A", "v1", &[("v1", "V1")]),
371 sel("b", "B", "v1", &[("v1", "V1")]),
372 sel("c", "C", "v1", &[("v1", "V1")]),
373 ]);
374 assert_eq!(m.selected_index(), 0);
375
376 press(&mut m, KeyCode::Up).await;
377 assert_eq!(m.selected_index(), 2);
378
379 press(&mut m, KeyCode::Down).await;
380 assert_eq!(m.selected_index(), 0);
381
382 for _ in 0..3 {
383 press(&mut m, KeyCode::Down).await;
384 }
385 assert_eq!(m.selected_index(), 0);
386 }
387
388 #[test]
389 fn update_options_clamps_index() {
390 let mut m = menu(&[
391 sel("a", "A", "v1", &[("v1", "V1")]),
392 sel("b", "B", "v1", &[("v1", "V1")]),
393 sel("c", "C", "v1", &[("v1", "V1")]),
394 ]);
395 m.list.set_selected(2);
396 m.update_options(&[sel("a", "A", "v1", &[("v1", "V1")])]);
397 assert_eq!(m.selected_index(), 0);
398 }
399
400 #[test]
401 fn update_options_preserves_index_when_within_bounds() {
402 let mut m = menu(&[
403 sel("provider", "Provider", "a", &[("a", "A"), ("b", "B")]),
404 sel("model", "Model", "m1", &[("m1", "M1"), ("m2", "M2")]),
405 ]);
406 m.list.set_selected(1);
407 m.update_options(&[
408 sel("provider", "Provider", "b", &[("a", "A"), ("b", "B")]),
409 sel("model", "Model", "m3", &[("m3", "M3")]),
410 ]);
411 assert_eq!(m.selected_index(), 1);
412 }
413
414 #[test]
415 fn from_config_options_skips_empty_values() {
416 let empty =
417 SessionConfigOption::select("x", "X", "v", Vec::<SessionConfigSelectOption>::new());
418 let m = menu(&[empty, sel("model", "Model", "a", &[("a", "A")])]);
419 assert_eq!(m.options().len(), 1);
420 assert_eq!(m.options()[0].config_id, "model");
421 }
422
423 #[test]
424 fn from_config_options_with_category() {
425 let opt = sel("model", "Model", "gpt-4o", &[("gpt-4o", "GPT-4o")])
426 .category(SessionConfigOptionCategory::Model);
427 let m = menu(&[opt]);
428 assert_eq!(m.options().len(), 1);
429 assert_eq!(m.options()[0].title, "Model");
430 }
431
432 #[tokio::test]
433 async fn key_enter_opens_picker_and_escape_closes() {
434 for (code, expected) in [
435 (KeyCode::Enter, "OpenSelectedPicker"),
436 (KeyCode::Esc, "CloseAll"),
437 ] {
438 let mut m = menu(&[sel("model", "Model", "a", &[("a", "A")])]);
439 let msgs = press(&mut m, code).await.unwrap();
440 let tag = match &msgs[..] {
441 [SettingMenuMessage::OpenSelectedPicker] => "OpenSelectedPicker",
442 [SettingMenuMessage::CloseAll] => "CloseAll",
443 _ => "other",
444 };
445 assert_eq!(tag, expected, "key {code:?} should produce {expected}");
446 }
447 }
448
449 #[test]
450 fn multi_select_detected_from_meta() {
451 for (has_meta, expected) in [(true, true), (false, false)] {
452 let mut opt = sel("model", "Model", "a", &[("a", "A"), ("b", "B")]);
453 if has_meta {
454 opt = opt.meta(ConfigOptionMeta { multi_select: true }.into_meta());
455 }
456 let m = menu(&[opt]);
457 assert_eq!(m.options()[0].multi_select, expected, "meta={has_meta}");
458 }
459 }
460
461 #[tokio::test]
462 async fn multi_select_entry_opens_model_selector() {
463 let opt = sel("model", "Model", "a", &[("a", "A"), ("b", "B")])
464 .meta(ConfigOptionMeta { multi_select: true }.into_meta());
465 let mut m = menu(&[opt]);
466 let msgs = press(&mut m, KeyCode::Enter).await.unwrap();
467 assert!(matches!(
468 msgs.as_slice(),
469 [SettingMenuMessage::OpenModelSelector]
470 ));
471 }
472
473 #[test]
474 fn multi_select_with_comma_value_shows_model_names() {
475 let opt = sel("model", "Model", "a,b", &[("a", "Alpha"), ("b", "Beta")])
476 .meta(ConfigOptionMeta { multi_select: true }.into_meta());
477 let display = menu(&[opt]).options()[0]
478 .display_name
479 .as_deref()
480 .unwrap()
481 .to_string();
482 assert!(display.contains("Alpha"), "display: {display}");
483 assert!(display.contains("Beta"), "display: {display}");
484 }
485
486 #[test]
487 fn apply_change_updates_matching_entry_value_and_index() {
488 let mut m = theme_menu(None);
489 m.apply_change(&SettingsChange {
490 config_id: THEME_CONFIG_ID.to_string(),
491 new_value: "nord.tmTheme".to_string(),
492 });
493 assert_eq!(m.options()[0].current_raw_value, "nord.tmTheme");
494 assert_eq!(m.options()[0].current_value_index, 2);
495 }
496
497 #[test]
498 fn add_theme_entry_inserts_theme_row() {
499 let m = theme_menu(None);
500 assert_eq!(m.options().len(), 1);
501 let t = &m.options()[0];
502 assert_eq!(t.config_id, THEME_CONFIG_ID);
503 assert_eq!(t.title, "Theme");
504 assert_eq!(t.entry_kind, SettingsMenuEntryKind::Select);
505 assert!(!t.multi_select);
506 assert_eq!(t.values.len(), 3);
507 assert_eq!(t.values[0].name, "Default");
508 assert_eq!(t.values[0].value, "");
509 assert_eq!(t.values[1].value, "catppuccin.tmTheme");
510 assert_eq!(t.values[2].value, "nord.tmTheme");
511 }
512
513 #[test]
514 fn add_theme_entry_selects_correct_index() {
515 let cases: &[(Option<&str>, usize, &str)] = &[
516 (None, 0, ""),
517 (Some("nord.tmTheme"), 2, "nord.tmTheme"),
518 (Some("missing.tmTheme"), 0, ""),
519 ];
520 for &(current, expected_idx, expected_raw) in cases {
521 let m = theme_menu(current);
522 let t = &m.options()[0];
523 assert_eq!(t.current_value_index, expected_idx, "current={current:?}");
524 assert_eq!(t.current_raw_value, expected_raw, "current={current:?}");
525 }
526 }
527
528 #[test]
529 fn from_config_options_excludes_reasoning_effort_entry() {
530 let m = menu(&[
531 sel(
532 "model",
533 "Model",
534 "gpt-4o",
535 &[("gpt-4o", "GPT-4o"), ("claude", "Claude")],
536 ),
537 sel(
538 "reasoning_effort",
539 "Reasoning Effort",
540 "high",
541 &[
542 ("none", "None"),
543 ("low", "Low"),
544 ("medium", "Medium"),
545 ("high", "High"),
546 ],
547 ),
548 ]);
549 assert!(m.options().iter().any(|e| e.config_id == "model"));
550 assert!(
551 !m.options()
552 .iter()
553 .any(|e| e.config_id == "reasoning_effort")
554 );
555 }
556}