1use super::types::{SettingsChange, SettingsMenuEntry, SettingsMenuValue};
2use tui::{Combobox, Component, Event, Frame, Line, MouseEventKind, PickerKey, Searchable, ViewContext, classify_key};
3impl Searchable for SettingsMenuValue {
4 fn search_text(&self) -> String {
5 format!("{} {}", self.name, self.value)
6 }
7}
8
9pub struct SettingsPicker {
10 pub config_id: String,
11 pub title: String,
12 combobox: Combobox<SettingsMenuValue>,
13 current_value: String,
14}
15
16pub enum SettingsPickerMessage {
17 Close,
18 ApplySelection(Option<SettingsChange>),
19}
20
21impl SettingsPicker {
22 pub fn from_entry(entry: &SettingsMenuEntry) -> Option<Self> {
23 let current_value = entry.values.get(entry.current_value_index)?.value.clone();
24 let mut picker = Self {
25 config_id: entry.config_id.clone(),
26 title: entry.title.clone(),
27 current_value,
28 combobox: Combobox::new(entry.values.clone()),
29 };
30 let initial_index = picker.combobox.matches().iter().position(|m| m.value == picker.current_value).unwrap_or(0);
31 picker.combobox.set_selected_index(initial_index);
32 picker.ensure_selectable();
33 Some(picker)
34 }
35
36 pub fn query(&self) -> &str {
37 self.combobox.query()
38 }
39
40 pub fn confirm_selection(&self) -> Option<SettingsChange> {
41 let selected = self.combobox.selected()?;
42 if selected.is_disabled || selected.value == self.current_value {
43 return None;
44 }
45
46 Some(SettingsChange { config_id: self.config_id.clone(), new_value: selected.value.clone() })
47 }
48
49 fn move_selection_up(&mut self) {
50 self.combobox.move_up_where(|m| !m.is_disabled);
51 }
52
53 fn move_selection_down(&mut self) {
54 self.combobox.move_down_where(|m| !m.is_disabled);
55 }
56
57 fn push_query_char(&mut self, c: char) {
58 self.combobox.push_query_char(c);
59 self.ensure_selectable();
60 }
61
62 fn pop_query_char(&mut self) {
63 self.combobox.pop_query_char();
64 self.ensure_selectable();
65 }
66
67 fn ensure_selectable(&mut self) {
68 if self.combobox.is_empty() {
69 return;
70 }
71 let idx = self.combobox.selected_index();
72 if idx >= self.combobox.matches().len() || self.combobox.matches()[idx].is_disabled {
73 self.combobox.select_first_where(|m| !m.is_disabled);
74 }
75 }
76}
77
78impl SettingsPicker {
79 pub(crate) fn update_viewport(&mut self, max_height: usize) {
80 self.combobox.set_max_visible(max_height.saturating_sub(1).max(1));
81 }
82}
83
84impl Component for SettingsPicker {
85 type Message = SettingsPickerMessage;
86
87 async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
88 if let Event::Mouse(mouse) = event {
89 return match mouse.kind {
90 MouseEventKind::ScrollUp => {
91 self.move_selection_up();
92 Some(vec![])
93 }
94 MouseEventKind::ScrollDown => {
95 self.move_selection_down();
96 Some(vec![])
97 }
98 _ => Some(vec![]),
99 };
100 }
101 let Event::Key(key) = event else {
102 return None;
103 };
104 match classify_key(*key, self.combobox.query().is_empty()) {
105 PickerKey::Escape => Some(vec![SettingsPickerMessage::Close]),
106 PickerKey::MoveUp => {
107 self.move_selection_up();
108 Some(vec![])
109 }
110 PickerKey::MoveDown => {
111 self.move_selection_down();
112 Some(vec![])
113 }
114 PickerKey::Confirm => {
115 let change = self.confirm_selection();
116 Some(vec![SettingsPickerMessage::ApplySelection(change)])
117 }
118 PickerKey::Char(c) => {
119 self.push_query_char(c);
120 Some(vec![])
121 }
122 PickerKey::Backspace => {
123 self.pop_query_char();
124 Some(vec![])
125 }
126 PickerKey::MoveLeft
127 | PickerKey::MoveRight
128 | PickerKey::Tab
129 | PickerKey::BackTab
130 | PickerKey::BackspaceOnEmpty
131 | PickerKey::ControlChar
132 | PickerKey::Other => Some(vec![]),
133 }
134 }
135
136 fn render(&mut self, context: &ViewContext) -> Frame {
137 let mut lines = Vec::new();
138 let header = format!(" {} search: {}", self.title, self.combobox.query());
139 lines.push(Line::styled(header, context.theme.muted()));
140
141 if self.combobox.is_empty() {
142 lines.push(Line::new(" (no matches found)".to_string()));
143 return Frame::new(lines);
144 }
145
146 let item_lines = self.combobox.render_items(context, |option, is_selected, ctx| {
147 let label = if option.name == option.value {
148 option.name.clone()
149 } else {
150 format!("{} ({})", option.name, option.value)
151 };
152
153 let label = if option.is_disabled {
154 if let Some(reason) = option.description.as_deref() { format!("{label} - {reason}") } else { label }
155 } else {
156 label
157 };
158
159 let line_text = label;
160 if option.is_disabled {
161 Line::styled(line_text, ctx.theme.muted())
162 } else if is_selected {
163 Line::with_style(line_text, ctx.theme.selected_row_style())
164 } else {
165 Line::styled(line_text, ctx.theme.text_primary())
166 }
167 });
168 lines.extend(item_lines);
169
170 Frame::new(lines)
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::settings::types::SettingsMenuEntryKind;
178 use acp_utils::config_meta::SelectOptionMeta;
179 use tui::test_picker::{rendered_lines_from, type_query};
180 use tui::{KeyCode, KeyEvent, KeyModifiers};
181
182 fn rendered_lines(picker: &mut SettingsPicker) -> Vec<String> {
183 rendered_lines_from(&picker.render(&ViewContext::new((120, 40))))
184 }
185
186 fn entry() -> SettingsMenuEntry {
187 SettingsMenuEntry {
188 config_id: "model".to_string(),
189 title: "Model".to_string(),
190 multi_select: false,
191 display_name: None,
192 values: vec![
193 SettingsMenuValue {
194 value: "openrouter:openai/gpt-4o".to_string(),
195 name: "GPT-4o".to_string(),
196 description: None,
197 is_disabled: false,
198 meta: SelectOptionMeta::default(),
199 },
200 SettingsMenuValue {
201 value: "openrouter:anthropic/claude-3.5-sonnet".to_string(),
202 name: "Claude Sonnet".to_string(),
203 description: None,
204 is_disabled: false,
205 meta: SelectOptionMeta::default(),
206 },
207 SettingsMenuValue {
208 value: "openrouter:google/gemini-2.5-pro".to_string(),
209 name: "Gemini 2.5 Pro".to_string(),
210 description: None,
211 is_disabled: false,
212 meta: SelectOptionMeta::default(),
213 },
214 ],
215 current_value_index: 0,
216 current_raw_value: "openrouter:openai/gpt-4o".to_string(),
217 entry_kind: SettingsMenuEntryKind::Select,
218 }
219 }
220
221 #[test]
222 fn initializes_with_current_value_selected() {
223 let mut picker = SettingsPicker::from_entry(&entry()).expect("picker");
224 let lines = rendered_lines(&mut picker);
225 assert!(lines.iter().any(|l| l.contains("GPT-4o")), "should show GPT-4o in rendered lines: {lines:?}");
227 }
228
229 #[tokio::test]
230 async fn query_filters_by_name() {
231 let mut picker = SettingsPicker::from_entry(&entry()).expect("picker");
232 type_query(&mut picker, "gemini").await;
233 let lines = rendered_lines(&mut picker);
234 assert_eq!(lines.len(), 2);
236 assert!(lines[1].contains("Gemini 2.5 Pro"));
237 }
238
239 #[tokio::test]
240 async fn query_filters_by_value() {
241 let mut picker = SettingsPicker::from_entry(&entry()).expect("picker");
242 type_query(&mut picker, "anthropic/claude").await;
243 let lines = rendered_lines(&mut picker);
244 assert_eq!(lines.len(), 2);
246 assert!(lines[1].contains("Claude Sonnet"));
247 }
248
249 #[test]
250 fn confirm_selection_omits_unchanged_value() {
251 let picker = SettingsPicker::from_entry(&entry()).expect("picker");
252 assert!(picker.confirm_selection().is_none());
253 }
254
255 #[tokio::test]
256 async fn confirm_selection_returns_change_for_new_value() {
257 let mut picker = SettingsPicker::from_entry(&entry()).expect("picker");
258 picker.on_event(&Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))).await;
259 let change = picker.confirm_selection().expect("settings change");
260 assert_eq!(change.config_id, "model");
261 assert_eq!(change.new_value, "openrouter:anthropic/claude-3.5-sonnet".to_string());
262 }
263
264 #[tokio::test]
265 async fn disabled_option_cannot_be_confirmed() {
266 let mut entry = entry();
267 entry.values[1].is_disabled = true;
268 entry.values[1].description = Some("Unavailable: set ANTHROPIC_API_KEY".to_string());
269 entry.values[1].name = "Disabled Claude".to_string();
270
271 let mut picker = SettingsPicker::from_entry(&entry).expect("picker");
272 type_query(&mut picker, "disabled").await;
273 assert!(picker.confirm_selection().is_none());
274 }
275
276 #[tokio::test]
277 async fn handle_key_enter_returns_apply_selection_message() {
278 let mut picker = SettingsPicker::from_entry(&entry()).expect("picker");
279 picker.on_event(&Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))).await;
280
281 let outcome = picker.on_event(&Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))).await;
282
283 assert!(outcome.is_some());
284
285 let messages = outcome.unwrap();
286 match messages.as_slice() {
287 [SettingsPickerMessage::ApplySelection(Some(change))] => {
288 assert_eq!(change.config_id, "model");
289 }
290 _ => panic!("expected apply selection message"),
291 }
292 }
293
294 #[tokio::test]
295 async fn handle_key_escape_returns_close_message() {
296 let mut picker = SettingsPicker::from_entry(&entry()).expect("picker");
297
298 let outcome = picker.on_event(&Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))).await;
299
300 assert!(outcome.is_some());
301
302 let messages = outcome.unwrap();
303 assert!(matches!(messages.as_slice(), [SettingsPickerMessage::Close]));
304 }
305}