vtcode_tui/core_tui/session/
history_picker.rs1use 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#[derive(Debug, Clone)]
15pub struct HistoryMatch {
16 pub history_index: usize,
18 pub content: String,
20 pub score: u32,
22 pub attachments: Vec<ContentPart>,
24}
25
26#[derive(Debug)]
28pub struct HistoryPickerState {
29 pub active: bool,
31 pub search_query: String,
33 pub matches: Vec<HistoryMatch>,
35 pub list_state: ListState,
37 pub visible_rows: usize,
39 original_content: String,
41 original_cursor: usize,
43 original_attachments: Vec<ContentPart>,
45}
46
47impl Default for HistoryPickerState {
48 fn default() -> Self {
49 Self::new()
50 }
51}
52
53impl HistoryPickerState {
54 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 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 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 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 pub fn selected_match(&self) -> Option<&HistoryMatch> {
101 self.list_state
102 .selected()
103 .and_then(|idx| self.matches.get(idx))
104 }
105
106 pub fn update_search(&mut self, history: &[(String, Vec<ContentPart>)]) {
108 self.matches.clear();
109
110 let query = self.search_query.to_lowercase();
112 for (idx, (content, attachments)) in history.iter().enumerate().rev() {
113 if content.trim().is_empty() {
115 continue;
116 }
117
118 let score = if query.is_empty() {
120 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 self.matches.sort_by(|a, b| b.score.cmp(&a.score));
138
139 let mut seen = hashbrown::HashSet::new();
141 self.matches.retain(|m| seen.insert(m.content.clone()));
142
143 self.matches.truncate(100);
145
146 if self.matches.is_empty() {
148 self.list_state.select(None);
149 } else {
150 self.list_state.select(Some(0));
151 }
152 }
153
154 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 pub fn backspace(&mut self, history: &[(String, Vec<ContentPart>)]) {
162 self.search_query.pop();
163 self.update_search(history);
164 }
165
166 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 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 pub fn is_empty(&self) -> bool {
194 self.matches.is_empty()
195 }
196
197 pub fn match_count(&self) -> usize {
199 self.matches.len()
200 }
201}
202
203pub 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 KeyCode::Up => {
226 picker.move_up();
227 true
228 }
229 KeyCode::Down => {
230 picker.move_down();
231 true
232 }
233 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 picker.move_down();
253 true
254 }
255 KeyCode::Tab => {
256 picker.move_down();
258 true
259 }
260 KeyCode::BackTab => {
261 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 assert_eq!(picker.match_count(), 5);
312
313 picker.search_query = "cargo".to_string();
315 picker.update_search(&history);
316 assert_eq!(picker.match_count(), 3);
317
318 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 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(); 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![]), ("cargo build".to_string(), vec![]), ];
388
389 picker.open(&manager);
390 picker.update_search(&history);
391
392 assert_eq!(picker.match_count(), 2);
394 }
395}