Skip to main content

vtcode_ui/tui/ui/interactive_list/
mod.rs

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
14/// Minimum entry count that enables the search field automatically.
15pub(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    /// Extra search terms (ids, aliases, provider labels) matched by the filter.
22    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
45/// Live filter + selection over the entry catalog.
46pub(crate) struct SelectionListState {
47    query: String,
48    visible: Vec<usize>,
49    /// Selection position within `visible` (not the original index).
50    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    /// Page jump clamps at the ends (does not wrap like `move_selection`).
116    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); // Claude(0), GLM?(2)? "e" in reasoning/fast/offline
301        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}