Skip to main content

vtcode_tui/core_tui/session/
history_picker.rs

1/// History Picker - Fuzzy search for command history (Ctrl+R)
2///
3/// Provides a visual palette for searching and selecting from command history
4/// using nucleo fuzzy matching, similar to the slash command palette.
5use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
6use ratatui::widgets::ListState;
7
8use super::super::types::ContentPart;
9use crate::ui::search::fuzzy_score;
10
11use super::input_manager::InputManager;
12
13/// A single history entry with fuzzy match score
14#[derive(Debug, Clone)]
15pub struct HistoryMatch {
16    /// Index in the original history
17    pub history_index: usize,
18    /// The command text
19    pub content: String,
20    /// Fuzzy match score (higher is better)
21    pub score: u32,
22    /// Associated attachments
23    pub attachments: Vec<ContentPart>,
24}
25
26/// State for the history picker overlay
27#[derive(Debug)]
28pub struct HistoryPickerState {
29    /// Whether the picker is currently active
30    pub active: bool,
31    /// Current search/filter query
32    pub search_query: String,
33    /// Filtered and sorted matches
34    pub matches: Vec<HistoryMatch>,
35    /// List state for selection tracking
36    pub list_state: ListState,
37    /// Number of visible rows in the picker
38    pub visible_rows: usize,
39    /// Original content before picker was opened (for cancel restoration)
40    original_content: String,
41    /// Original cursor position before picker was opened
42    original_cursor: usize,
43    /// Original attachments before picker was opened
44    original_attachments: Vec<ContentPart>,
45}
46
47impl Default for HistoryPickerState {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53impl HistoryPickerState {
54    /// Create a new history picker state
55    pub fn new() -> Self {
56        Self {
57            active: false,
58            search_query: String::new(),
59            matches: Vec::new(),
60            list_state: ListState::default(),
61            visible_rows: 10,
62            original_content: String::new(),
63            original_cursor: 0,
64            original_attachments: Vec::new(),
65        }
66    }
67
68    /// Open the history picker
69    pub fn open(&mut self, input_manager: &InputManager) {
70        self.active = true;
71        self.search_query.clear();
72        self.original_content = input_manager.content().to_string();
73        self.original_cursor = input_manager.cursor();
74        self.original_attachments = input_manager.attachments().to_vec();
75        self.list_state.select(Some(0));
76    }
77
78    /// Close the picker and restore original input
79    pub fn cancel(&mut self, input_manager: &mut InputManager) {
80        self.active = false;
81        self.search_query.clear();
82        self.matches.clear();
83        input_manager.set_content(self.original_content.clone());
84        input_manager.set_cursor(self.original_cursor);
85        input_manager.set_attachments(self.original_attachments.clone());
86    }
87
88    /// Accept the current selection and close the picker
89    pub fn accept(&mut self, input_manager: &mut InputManager) {
90        if let Some(selected) = self.selected_match() {
91            input_manager.set_content(selected.content.clone());
92            input_manager.set_attachments(selected.attachments.clone());
93        }
94        self.active = false;
95        self.search_query.clear();
96        self.matches.clear();
97    }
98
99    /// Get the currently selected match
100    pub fn selected_match(&self) -> Option<&HistoryMatch> {
101        self.list_state
102            .selected()
103            .and_then(|idx| self.matches.get(idx))
104    }
105
106    /// Update the search query and filter matches
107    pub fn update_search(&mut self, history: &[(String, Vec<ContentPart>)]) {
108        self.matches.clear();
109
110        // Score and collect all matching entries
111        let query = self.search_query.to_lowercase();
112        for (idx, (content, attachments)) in history.iter().enumerate().rev() {
113            // Skip empty entries
114            if content.trim().is_empty() {
115                continue;
116            }
117
118            // Calculate fuzzy score or use substring match as fallback
119            let score = if query.is_empty() {
120                // No query - include all with recency score
121                Some((history.len() - idx) as u32)
122            } else {
123                fuzzy_score(&query, content)
124            };
125
126            if let Some(score) = score {
127                self.matches.push(HistoryMatch {
128                    history_index: idx,
129                    content: content.clone(),
130                    score,
131                    attachments: attachments.clone(),
132                });
133            }
134        }
135
136        // Sort by score (descending) - higher scores first
137        self.matches.sort_by(|a, b| b.score.cmp(&a.score));
138
139        // Deduplicate by content (keep highest scored entry for each unique command)
140        let mut seen = hashbrown::HashSet::new();
141        self.matches.retain(|m| seen.insert(m.content.clone()));
142
143        // Limit to reasonable number
144        self.matches.truncate(100);
145
146        // Reset selection to first item if available
147        if self.matches.is_empty() {
148            self.list_state.select(None);
149        } else {
150            self.list_state.select(Some(0));
151        }
152    }
153
154    /// Add a character to the search query
155    pub fn add_char(&mut self, ch: char, history: &[(String, Vec<ContentPart>)]) {
156        self.search_query.push(ch);
157        self.update_search(history);
158    }
159
160    /// Remove the last character from the search query
161    pub fn backspace(&mut self, history: &[(String, Vec<ContentPart>)]) {
162        self.search_query.pop();
163        self.update_search(history);
164    }
165
166    /// Move selection up
167    pub fn move_up(&mut self) {
168        if self.matches.is_empty() {
169            return;
170        }
171
172        let current = self.list_state.selected().unwrap_or(0);
173        let new_index = if current == 0 {
174            self.matches.len() - 1
175        } else {
176            current - 1
177        };
178        self.list_state.select(Some(new_index));
179    }
180
181    /// Move selection down
182    pub fn move_down(&mut self) {
183        if self.matches.is_empty() {
184            return;
185        }
186
187        let current = self.list_state.selected().unwrap_or(0);
188        let new_index = (current + 1) % self.matches.len();
189        self.list_state.select(Some(new_index));
190    }
191
192    /// Check if the picker is empty (no matches)
193    pub fn is_empty(&self) -> bool {
194        self.matches.is_empty()
195    }
196
197    /// Get number of matches
198    pub fn match_count(&self) -> usize {
199        self.matches.len()
200    }
201}
202
203/// Handle keyboard input for the history picker
204/// Returns true if the key was handled
205pub fn handle_history_picker_key(
206    key: &KeyEvent,
207    picker: &mut HistoryPickerState,
208    input_manager: &mut InputManager,
209    history: &[(String, Vec<ContentPart>)],
210) -> bool {
211    if !picker.active {
212        return false;
213    }
214
215    match key.code {
216        KeyCode::Esc => {
217            picker.cancel(input_manager);
218            true
219        }
220        KeyCode::Enter => {
221            picker.accept(input_manager);
222            true
223        }
224        // Plain Up/Down arrows for navigation
225        KeyCode::Up => {
226            picker.move_up();
227            true
228        }
229        KeyCode::Down => {
230            picker.move_down();
231            true
232        }
233        // Ctrl+K/J for vim-style navigation
234        KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
235            picker.move_up();
236            true
237        }
238        KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
239            picker.move_down();
240            true
241        }
242        KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
243            picker.move_up();
244            true
245        }
246        KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
247            picker.move_down();
248            true
249        }
250        KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
251            // Ctrl+R while in picker - cycle through matches
252            picker.move_down();
253            true
254        }
255        KeyCode::Tab => {
256            // Tab cycles forward through matches
257            picker.move_down();
258            true
259        }
260        KeyCode::BackTab => {
261            // Shift+Tab cycles backward through matches
262            picker.move_up();
263            true
264        }
265        KeyCode::Char(ch) => {
266            picker.add_char(ch, history);
267            true
268        }
269        KeyCode::Backspace => {
270            picker.backspace(history);
271            true
272        }
273        _ => false,
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    fn make_history() -> Vec<(String, Vec<ContentPart>)> {
282        vec![
283            ("cargo build".to_string(), vec![]),
284            ("cargo test".to_string(), vec![]),
285            ("git status".to_string(), vec![]),
286            ("cargo clippy".to_string(), vec![]),
287            ("git diff".to_string(), vec![]),
288        ]
289    }
290
291    #[test]
292    fn test_open_picker() {
293        let mut picker = HistoryPickerState::new();
294        let manager = InputManager::new();
295
296        assert!(!picker.active);
297        picker.open(&manager);
298        assert!(picker.active);
299    }
300
301    #[test]
302    fn test_filter_matches() {
303        let mut picker = HistoryPickerState::new();
304        let manager = InputManager::new();
305        let history = make_history();
306
307        picker.open(&manager);
308        picker.update_search(&history);
309
310        // All entries should match with empty query
311        assert_eq!(picker.match_count(), 5);
312
313        // Filter to "cargo"
314        picker.search_query = "cargo".to_string();
315        picker.update_search(&history);
316        assert_eq!(picker.match_count(), 3);
317
318        // Filter to "git"
319        picker.search_query = "git".to_string();
320        picker.update_search(&history);
321        assert_eq!(picker.match_count(), 2);
322    }
323
324    #[test]
325    fn test_navigation() {
326        let mut picker = HistoryPickerState::new();
327        let manager = InputManager::new();
328        let history = make_history();
329
330        picker.open(&manager);
331        picker.update_search(&history);
332
333        assert_eq!(picker.list_state.selected(), Some(0));
334
335        picker.move_down();
336        assert_eq!(picker.list_state.selected(), Some(1));
337
338        picker.move_up();
339        assert_eq!(picker.list_state.selected(), Some(0));
340
341        // Wrap around
342        picker.move_up();
343        assert_eq!(picker.list_state.selected(), Some(4));
344    }
345
346    #[test]
347    fn test_accept_selection() {
348        let mut picker = HistoryPickerState::new();
349        let mut manager = InputManager::new();
350        let history = make_history();
351
352        picker.open(&manager);
353        picker.update_search(&history);
354        picker.move_down(); // Select second item
355
356        let selected_content = picker.selected_match().map(|m| m.content.clone());
357        picker.accept(&mut manager);
358
359        assert!(!picker.active);
360        assert_eq!(Some(manager.content().to_string()), selected_content);
361    }
362
363    #[test]
364    fn test_cancel_restores_original() {
365        let mut picker = HistoryPickerState::new();
366        let mut manager = InputManager::new();
367        manager.set_content("original content".to_string());
368        let history = make_history();
369
370        picker.open(&manager);
371        picker.update_search(&history);
372        picker.cancel(&mut manager);
373
374        assert!(!picker.active);
375        assert_eq!(manager.content(), "original content");
376    }
377
378    #[test]
379    fn test_deduplication() {
380        let mut picker = HistoryPickerState::new();
381        let manager = InputManager::new();
382        let history = vec![
383            ("cargo build".to_string(), vec![]),
384            ("cargo test".to_string(), vec![]),
385            ("cargo build".to_string(), vec![]), // Duplicate
386            ("cargo build".to_string(), vec![]), // Another duplicate
387        ];
388
389        picker.open(&manager);
390        picker.update_search(&history);
391
392        // Should deduplicate to 2 unique entries
393        assert_eq!(picker.match_count(), 2);
394    }
395}