1use std::io;
2
3use crate::tui::utils::tty::TtyExt;
4use anyhow::{Context, Result, anyhow};
5use ratatui::Terminal;
6use ratatui::backend::CrosstermBackend;
7use ratatui::crossterm::event;
8use ratatui::widgets::ListState;
9
10mod input;
11mod render;
12mod terminal;
13
14pub(crate) const SEARCH_MIN_ENTRIES: usize = 3;
16
17#[derive(Debug, Clone)]
18pub struct SelectionEntry {
19 pub title: String,
20 pub description: Option<String>,
21 pub keywords: Vec<String>,
23}
24
25impl SelectionEntry {
26 pub fn new(title: impl Into<String>, description: Option<String>) -> Self {
27 Self {
28 title: title.into(),
29 description,
30 keywords: Vec::new(),
31 }
32 }
33
34 #[must_use]
35 pub fn with_keywords<I, S>(mut self, keywords: I) -> Self
36 where
37 I: IntoIterator<Item = S>,
38 S: Into<String>,
39 {
40 self.keywords = keywords.into_iter().map(Into::into).collect();
41 self
42 }
43}
44
45pub(crate) struct SelectionListState {
47 query: String,
48 visible: Vec<usize>,
49 selected_pos: usize,
51 search_enabled: bool,
52}
53
54impl SelectionListState {
55 fn new(entries: &[SelectionEntry], default_index: usize) -> Self {
56 let search_enabled = entries.len() >= SEARCH_MIN_ENTRIES;
57 let mut state = Self {
58 query: String::new(),
59 visible: (0..entries.len()).collect(),
60 selected_pos: 0,
61 search_enabled,
62 };
63 let default_pos = state.visible.iter().position(|&index| index == default_index).unwrap_or(0);
64 state.selected_pos = default_pos.min(state.visible.len().saturating_sub(1));
65 state
66 }
67
68 pub(crate) fn search_enabled(&self) -> bool {
69 self.search_enabled
70 }
71
72 pub(crate) fn is_filtering(&self) -> bool {
73 self.search_enabled && !self.query.is_empty()
74 }
75
76 pub(crate) fn query(&self) -> &str {
77 &self.query
78 }
79
80 pub(crate) fn visible_indices(&self) -> &[usize] {
81 &self.visible
82 }
83
84 pub(crate) fn selected_visible_index(&self) -> Option<usize> {
85 if self.visible.is_empty() {
86 None
87 } else {
88 Some(self.selected_pos.min(self.visible.len() - 1))
89 }
90 }
91
92 pub(crate) fn selected_entry<'a>(&self, entries: &'a [SelectionEntry]) -> Option<&'a SelectionEntry> {
93 let original = self.selected_original_index()?;
94 entries.get(original)
95 }
96
97 fn selected_original_index(&self) -> Option<usize> {
98 if self.visible.is_empty() {
99 return None;
100 }
101 let pos = self.selected_pos.min(self.visible.len() - 1);
102 self.visible.get(pos).copied()
103 }
104
105 fn move_selection(&mut self, delta: isize) {
106 if self.visible.is_empty() {
107 self.selected_pos = 0;
108 return;
109 }
110 let len = self.visible.len() as isize;
111 let next = self.selected_pos as isize + delta;
112 self.selected_pos = next.rem_euclid(len) as usize;
113 }
114
115 fn page_selection(&mut self, delta: isize) {
117 if self.visible.is_empty() {
118 self.selected_pos = 0;
119 return;
120 }
121 let len = self.visible.len() as isize;
122 let next = (self.selected_pos as isize + delta).clamp(0, len - 1);
123 self.selected_pos = next as usize;
124 }
125
126 fn set_selection_pos(&mut self, pos: usize) {
127 if self.visible.is_empty() {
128 self.selected_pos = 0;
129 return;
130 }
131 self.selected_pos = pos.min(self.visible.len() - 1);
132 }
133
134 fn jump_to_original(&mut self, original: usize) {
135 if let Some(pos) = self.visible.iter().position(|&index| index == original) {
136 self.selected_pos = pos;
137 }
138 }
139
140 fn recompute_visible(&mut self, entries: &[SelectionEntry], previous_original: Option<usize>) {
141 self.visible = render::filter_entries(entries, &self.query, true);
142 if self.visible.is_empty() {
143 self.selected_pos = 0;
144 return;
145 }
146 if let Some(previous) = previous_original {
147 if let Some(pos) = self.visible.iter().position(|&index| index == previous) {
148 self.selected_pos = pos;
149 return;
150 }
151 }
152 self.selected_pos = self.selected_pos.min(self.visible.len() - 1);
153 }
154
155 fn push_char(&mut self, ch: char, entries: &[SelectionEntry]) {
156 let previous = self.selected_original_index();
157 self.query.push(ch);
158 self.recompute_visible(entries, previous);
159 }
160
161 fn backspace(&mut self, entries: &[SelectionEntry]) -> bool {
162 if self.query.pop().is_none() {
163 return false;
164 }
165 let previous = self.selected_original_index();
166 self.recompute_visible(entries, previous);
167 true
168 }
169
170 fn clear_query(&mut self, entries: &[SelectionEntry]) -> bool {
171 if self.query.is_empty() {
172 return false;
173 }
174 let previous = self.selected_original_index();
175 self.query.clear();
176 self.visible = (0..entries.len()).collect();
177 if let Some(previous) = previous {
178 self.jump_to_original(previous);
179 } else if self.visible.is_empty() {
180 self.selected_pos = 0;
181 } else {
182 self.selected_pos = self.selected_pos.min(self.visible.len() - 1);
183 }
184 true
185 }
186}
187
188#[derive(Debug, thiserror::Error)]
189#[error("selection interrupted by Ctrl+C")]
190pub struct SelectionInterrupted;
191
192pub fn run_interactive_selection(
193 title: &str,
194 instructions: &str,
195 entries: &[SelectionEntry],
196 default_index: usize,
197) -> Result<Option<usize>> {
198 if entries.is_empty() {
199 return Err(anyhow!("No options available for selection"));
200 }
201
202 if !io::stderr().is_tty_ext() {
203 return Err(anyhow!("Terminal UI is unavailable"));
204 }
205
206 let mut stderr = io::stderr();
207 let mut terminal_guard = TerminalModeGuard::new(title);
208 terminal_guard.save_cursor_position(&mut stderr);
209 terminal_guard.enable_raw_mode()?;
210 terminal_guard.enter_alternate_screen(&mut stderr)?;
211
212 let backend = CrosstermBackend::new(stderr);
213 let mut terminal = Terminal::new(backend)
214 .with_context(|| format!("Failed to initialize Ratatui terminal for {title} selector"))?;
215 terminal_guard.hide_cursor(&mut terminal)?;
216
217 let selection_result = (|| -> Result<Option<usize>> {
218 let mut state = SelectionListState::new(entries, default_index);
219 let mut number_buffer = String::new();
220 let mut list_state = ListState::default();
221
222 loop {
223 render::draw_selection_ui(&mut terminal, title, instructions, entries, &state, &mut list_state)?;
224
225 let event = event::read().with_context(|| format!("Failed to read terminal input for {title} selector"))?;
226 match input::handle_event(event, entries, &mut state, &mut number_buffer)? {
227 input::SelectionAction::Continue => {}
228 input::SelectionAction::Select => {
229 return Ok(state.selected_original_index());
230 }
231 input::SelectionAction::Cancel => return Ok(None),
232 }
233 }
234 })();
235
236 let cleanup_result = terminal_guard.restore_with_terminal(&mut terminal);
237 cleanup_result?;
238 selection_result
239}
240
241use terminal::TerminalModeGuard;
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 fn sample_entries() -> Vec<SelectionEntry> {
248 vec![
249 SelectionEntry::new("Claude 4 Sonnet", Some("reasoning tools".to_string()))
250 .with_keywords(["anthropic", "claude-sonnet-4"]),
251 SelectionEntry::new("GPT-5", Some("reasoning".to_string())).with_keywords(["openai", "gpt-5.4"]),
252 SelectionEntry::new("GLM Flash", Some("fast chat".to_string())).with_keywords(["zhipu"]),
253 SelectionEntry::new("Local Llama", Some("offline".to_string())).with_keywords(["ollama"]),
254 ]
255 }
256
257 #[test]
258 fn search_enabled_for_catalog_at_or_above_threshold() {
259 let small = SelectionListState::new(&sample_entries()[..2], 0);
260 assert!(!small.search_enabled(), "2 entries stay search-less");
261 let large = SelectionListState::new(&sample_entries(), 0);
262 assert!(large.search_enabled(), "4 entries enable search");
263 }
264
265 #[test]
266 fn typing_filters_by_label_description_and_keywords() {
267 let entries = sample_entries();
268 let mut state = SelectionListState::new(&entries, 0);
269 for ch in "zhipu".chars() {
270 state.push_char(ch, &entries);
271 }
272 assert_eq!(state.visible_indices(), &[2], "keyword zhipu matches GLM");
273 state.clear_query(&entries);
274 for ch in "offline".chars() {
275 state.push_char(ch, &entries);
276 }
277 assert_eq!(state.visible_indices(), &[3], "description offline matches");
278 state.clear_query(&entries);
279 for ch in "sonnet".chars() {
280 state.push_char(ch, &entries);
281 }
282 assert!(state.visible_indices().contains(&0), "label Claude 4 Sonnet matches: {:?}", state.visible_indices());
283 }
284
285 #[test]
286 fn enter_returns_original_index_after_filter() {
287 let entries = sample_entries();
288 let mut state = SelectionListState::new(&entries, 0);
289 state.push_char('g', &entries);
290 state.push_char('l', &entries);
291 state.push_char('m', &entries);
292 assert_eq!(state.visible_indices(), &[2]);
293 assert_eq!(state.selected_original_index(), Some(2));
294 }
295
296 #[test]
297 fn navigation_stays_on_visible_subset() {
298 let entries = sample_entries();
299 let mut state = SelectionListState::new(&entries, 0);
300 state.push_char('e', &entries); state.move_selection(1);
302 let selected = state.selected_original_index();
303 assert!(
304 state.visible_indices().contains(&selected.unwrap_or(usize::MAX)),
305 "selection must stay inside the filtered set"
306 );
307 }
308
309 #[test]
310 fn empty_filter_result_blocks_selection() {
311 let entries = sample_entries();
312 let mut state = SelectionListState::new(&entries, 0);
313 for ch in "zzzzz".chars() {
314 state.push_char(ch, &entries);
315 }
316 assert!(state.visible_indices().is_empty());
317 assert!(state.selected_entry(&entries).is_none());
318 assert_eq!(state.selected_original_index(), None);
319 }
320
321 #[test]
322 fn digit_jump_uses_original_catalog_index() {
323 let entries = sample_entries();
324 let mut state = SelectionListState::new(&entries, 0);
325 state.jump_to_original(2);
326 assert_eq!(state.selected_original_index(), Some(2));
327 }
328
329 use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
330
331 fn press(code: KeyCode) -> Event {
332 Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
333 }
334
335 #[test]
336 fn jk_navigate_when_query_empty_and_type_when_filtering() {
337 let entries = sample_entries();
338 let mut state = SelectionListState::new(&entries, 0);
339 let mut buf = String::new();
340 input::handle_event(press(KeyCode::Char('j')), &entries, &mut state, &mut buf).unwrap();
341 assert_eq!(state.selected_original_index(), Some(1), "j moves down when not filtering");
342 input::handle_event(press(KeyCode::Char('k')), &entries, &mut state, &mut buf).unwrap();
343 assert_eq!(state.selected_original_index(), Some(0), "k moves up when not filtering");
344
345 input::handle_event(press(KeyCode::Char('z')), &entries, &mut state, &mut buf).unwrap();
346 assert!(state.is_filtering(), "other printables start a filter");
347 input::handle_event(press(KeyCode::Char('j')), &entries, &mut state, &mut buf).unwrap();
348 assert_eq!(state.query(), "zj", "j types while filtering");
349 }
350
351 #[test]
352 fn digits_jump_when_empty_and_type_when_filtering() {
353 let entries = sample_entries();
354 let mut state = SelectionListState::new(&entries, 0);
355 let mut buf = String::new();
356 input::handle_event(press(KeyCode::Char('3')), &entries, &mut state, &mut buf).unwrap();
357 assert_eq!(state.selected_original_index(), Some(2), "digit jump uses original index");
358 input::handle_event(press(KeyCode::Char('z')), &entries, &mut state, &mut buf).unwrap();
359 input::handle_event(press(KeyCode::Char('1')), &entries, &mut state, &mut buf).unwrap();
360 assert_eq!(state.query(), "z1", "digits type once filtering");
361 }
362
363 #[test]
364 fn esc_clears_query_then_cancels() {
365 let entries = sample_entries();
366 let mut state = SelectionListState::new(&entries, 0);
367 let mut buf = String::new();
368 input::handle_event(press(KeyCode::Char('z')), &entries, &mut state, &mut buf).unwrap();
369 let action = input::handle_event(press(KeyCode::Esc), &entries, &mut state, &mut buf).unwrap();
370 assert!(matches!(action, input::SelectionAction::Continue));
371 assert!(!state.is_filtering());
372 let action = input::handle_event(press(KeyCode::Esc), &entries, &mut state, &mut buf).unwrap();
373 assert!(matches!(action, input::SelectionAction::Cancel));
374 }
375
376 #[test]
377 fn enter_blocked_when_filter_empty_and_returns_original_index() {
378 let entries = sample_entries();
379 let mut state = SelectionListState::new(&entries, 0);
380 let mut buf = String::new();
381 input::handle_event(press(KeyCode::Char('z')), &entries, &mut state, &mut buf).unwrap();
382 input::handle_event(press(KeyCode::Char('z')), &entries, &mut state, &mut buf).unwrap();
383 input::handle_event(press(KeyCode::Char('z')), &entries, &mut state, &mut buf).unwrap();
384 let action = input::handle_event(press(KeyCode::Enter), &entries, &mut state, &mut buf).unwrap();
385 assert!(matches!(action, input::SelectionAction::Continue), "Enter is a no-op with zero matches");
386 state.clear_query(&entries);
387 for ch in "zhipu".chars() {
388 input::handle_event(press(KeyCode::Char(ch)), &entries, &mut state, &mut buf).unwrap();
389 }
390 let action = input::handle_event(press(KeyCode::Enter), &entries, &mut state, &mut buf).unwrap();
391 assert!(matches!(action, input::SelectionAction::Select));
392 assert_eq!(state.selected_original_index(), Some(2));
393 }
394
395 #[test]
396 fn page_down_clamps_instead_of_wrapping() {
397 let entries = sample_entries();
398 let mut state = SelectionListState::new(&entries, 0);
399 let mut buf = String::new();
400 input::handle_event(press(KeyCode::PageDown), &entries, &mut state, &mut buf).unwrap();
401 assert_eq!(state.selected_original_index(), Some(3), "page down clamps at end");
402 input::handle_event(press(KeyCode::PageDown), &entries, &mut state, &mut buf).unwrap();
403 assert_eq!(state.selected_original_index(), Some(3), "still clamped");
404 input::handle_event(press(KeyCode::PageUp), &entries, &mut state, &mut buf).unwrap();
405 assert_eq!(state.selected_original_index(), Some(0), "page up clamps at start");
406 }
407}