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