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