vtcode_tui/core_tui/session/
slash_palette.rs1use ratatui::widgets::ListState;
2use unicode_segmentation::UnicodeSegmentation;
3
4use crate::ui::search::{fuzzy_score, normalize_query};
5use crate::ui::tui::types::SlashCommandItem;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct SlashCommandRange {
9 pub start: usize,
10 pub end: usize,
11}
12
13pub fn command_range(input: &str, cursor: usize) -> Option<SlashCommandRange> {
14 if !input.starts_with('/') {
15 return None;
16 }
17
18 let mut active_range = None;
19
20 for (index, grapheme) in input.grapheme_indices(true) {
21 if index > cursor {
22 break;
23 }
24
25 if grapheme == "/" {
26 active_range = Some(SlashCommandRange {
27 start: index,
28 end: input.len(),
29 });
30 } else if grapheme.chars().all(char::is_whitespace) {
31 active_range = None;
33 } else if let Some(range) = &mut active_range {
34 range.end = index + grapheme.len();
35 }
36 }
37
38 active_range.filter(|range| range.end > range.start)
39}
40
41pub fn command_prefix(input: &str, cursor: usize) -> Option<String> {
42 let range = command_range(input, cursor)?;
43 let end = cursor.min(range.end);
44 let start = range.start + 1;
45 if end < start {
46 return Some(String::new());
47 }
48 Some(input[start..end].to_owned())
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52#[cfg(test)]
53pub struct SlashPaletteHighlightSegment {
54 pub content: String,
55 pub highlighted: bool,
56}
57
58#[cfg(test)]
59impl SlashPaletteHighlightSegment {
60 #[cfg(test)]
61 pub fn highlighted(content: impl Into<String>) -> Self {
62 Self {
63 content: content.into(),
64 highlighted: true,
65 }
66 }
67
68 #[cfg(test)]
69 pub fn plain(content: impl Into<String>) -> Self {
70 Self {
71 content: content.into(),
72 highlighted: false,
73 }
74 }
75}
76
77#[derive(Debug, Clone)]
78#[cfg(test)]
79pub struct SlashPaletteItem {
80 #[allow(dead_code)]
81 pub command: Option<SlashCommandItem>,
82 pub name_segments: Vec<SlashPaletteHighlightSegment>,
83 #[allow(dead_code)]
84 pub description: String,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum SlashPaletteUpdate {
89 NoChange,
90 Cleared,
91 Changed {
92 suggestions_changed: bool,
93 selection_changed: bool,
94 },
95}
96
97#[derive(Debug, Default)]
98pub struct SlashPalette {
99 commands: Vec<SlashCommandItem>,
100 suggestions: Vec<SlashPaletteSuggestion>,
101 list_state: ListState,
102 visible_rows: usize,
103 filter_query: Option<String>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum SlashPaletteSuggestion {
108 Static(SlashCommandItem),
109}
110
111impl SlashPalette {
112 pub fn new() -> Self {
113 Self::with_commands(Vec::new())
114 }
115
116 pub fn with_commands(commands: Vec<SlashCommandItem>) -> Self {
117 Self {
118 commands,
119 suggestions: Vec::new(),
120 list_state: ListState::default(),
121 visible_rows: 0,
122 filter_query: None,
123 }
124 }
125
126 pub fn suggestions(&self) -> &[SlashPaletteSuggestion] {
127 &self.suggestions
128 }
129
130 pub fn is_empty(&self) -> bool {
131 self.suggestions.is_empty()
132 }
133
134 pub fn selected_command(&self) -> Option<&SlashCommandItem> {
135 self.list_state
136 .selected()
137 .and_then(|index| self.suggestions.get(index))
138 .map(|suggestion| match suggestion {
139 SlashPaletteSuggestion::Static(info) => info,
140 })
141 }
142
143 pub fn list_state_mut(&mut self) -> &mut ListState {
144 &mut self.list_state
145 }
146
147 pub fn selected_index(&self) -> Option<usize> {
148 self.list_state.selected()
149 }
150
151 pub fn scroll_offset(&self) -> usize {
152 self.list_state.offset()
153 }
154
155 pub fn clear_visible_rows(&mut self) {
156 self.visible_rows = 0;
157 }
158
159 pub fn set_visible_rows(&mut self, rows: usize) {
160 self.visible_rows = rows;
161 self.ensure_list_visible();
162 }
163
164 #[cfg(test)]
165 pub fn visible_rows(&self) -> usize {
166 self.visible_rows
167 }
168
169 pub fn update(&mut self, prefix: Option<&str>, limit: usize) -> SlashPaletteUpdate {
170 let Some(prefix) = prefix else {
171 if self.clear_internal() {
172 return SlashPaletteUpdate::Cleared;
173 }
174 return SlashPaletteUpdate::NoChange;
175 };
176 let mut new_suggestions = Vec::new();
177
178 let static_suggestions = self.suggestions_for(prefix);
180 new_suggestions.extend(
181 static_suggestions
182 .into_iter()
183 .map(SlashPaletteSuggestion::Static),
184 );
185
186 if !prefix.is_empty() {
188 new_suggestions.truncate(limit);
189 }
190
191 let filter_query = {
192 let normalized = normalize_query(prefix);
193 if normalized.is_empty() {
194 None
195 } else if new_suggestions
196 .iter()
197 .map(|suggestion| match suggestion {
198 SlashPaletteSuggestion::Static(info) => info,
199 })
200 .all(|info| info.name.starts_with(normalized.as_str()))
201 {
202 Some(normalized.clone())
203 } else {
204 None
205 }
206 };
207
208 let suggestions_changed = self.replace_suggestions(new_suggestions);
209 self.filter_query = filter_query;
210 let selection_changed = self.ensure_selection();
211
212 if suggestions_changed || selection_changed {
213 SlashPaletteUpdate::Changed {
214 suggestions_changed,
215 selection_changed,
216 }
217 } else {
218 SlashPaletteUpdate::NoChange
219 }
220 }
221
222 pub fn clear(&mut self) -> bool {
223 self.clear_internal()
224 }
225
226 pub fn move_up(&mut self) -> bool {
227 if self.suggestions.is_empty() {
228 return false;
229 }
230
231 let visible_len = self.suggestions.len();
232 let current = self.list_state.selected().unwrap_or(0);
233 let new_index = if current > 0 {
234 current - 1
235 } else {
236 visible_len - 1
237 };
238
239 self.apply_selection(Some(new_index))
240 }
241
242 pub fn move_down(&mut self) -> bool {
243 if self.suggestions.is_empty() {
244 return false;
245 }
246
247 let visible_len = self.suggestions.len();
248 let current = self.list_state.selected().unwrap_or(visible_len - 1);
249 let new_index = if current + 1 < visible_len {
250 current + 1
251 } else {
252 0
253 };
254
255 self.apply_selection(Some(new_index))
256 }
257
258 pub fn select_first(&mut self) -> bool {
259 if self.suggestions.is_empty() {
260 return false;
261 }
262
263 self.apply_selection(Some(0))
264 }
265
266 pub fn select_last(&mut self) -> bool {
267 if self.suggestions.is_empty() {
268 return false;
269 }
270
271 let last = self.suggestions.len() - 1;
272 self.apply_selection(Some(last))
273 }
274
275 pub fn page_up(&mut self) -> bool {
276 if self.suggestions.is_empty() {
277 return false;
278 }
279
280 let step = self.visible_rows.max(1);
281 let current = self.list_state.selected().unwrap_or(0);
282 let new_index = current.saturating_sub(step);
283
284 self.apply_selection(Some(new_index))
285 }
286
287 pub fn page_down(&mut self) -> bool {
288 if self.suggestions.is_empty() {
289 return false;
290 }
291
292 let step = self.visible_rows.max(1);
293 let visible_len = self.suggestions.len();
294 let current = self.list_state.selected().unwrap_or(0);
295 let mut new_index = current.saturating_add(step);
296 if new_index >= visible_len {
297 new_index = visible_len - 1;
298 }
299
300 self.apply_selection(Some(new_index))
301 }
302
303 pub fn select_index(&mut self, index: usize) -> bool {
304 if index >= self.suggestions.len() {
305 return false;
306 }
307
308 self.apply_selection(Some(index))
309 }
310
311 #[cfg(test)]
312 pub fn items(&self) -> Vec<SlashPaletteItem> {
313 self.suggestions
314 .iter()
315 .map(|suggestion| match suggestion {
316 SlashPaletteSuggestion::Static(command) => SlashPaletteItem {
317 command: Some(command.clone()),
318 name_segments: self.highlight_name_segments_static(command.name.as_str()),
319 description: command.description.to_owned(),
320 },
321 })
322 .collect()
323 }
324
325 fn clear_internal(&mut self) -> bool {
326 if self.suggestions.is_empty()
327 && self.list_state.selected().is_none()
328 && self.visible_rows == 0
329 && self.filter_query.is_none()
330 {
331 return false;
332 }
333
334 self.suggestions.clear();
335 self.list_state.select(None);
336 *self.list_state.offset_mut() = 0;
337 self.visible_rows = 0;
338 self.filter_query = None;
339 true
340 }
341
342 fn replace_suggestions(&mut self, new_suggestions: Vec<SlashPaletteSuggestion>) -> bool {
343 if self.suggestions == new_suggestions {
344 return false;
345 }
346
347 self.suggestions = new_suggestions;
348 true
349 }
350
351 fn suggestions_for(&self, prefix: &str) -> Vec<SlashCommandItem> {
352 struct ScoredCommand<'a> {
353 command: &'a SlashCommandItem,
354 name_match: bool,
355 name_prefix: bool,
356 name_pos: usize,
357 description_pos: usize,
358 name_score: u32,
359 description_score: u32,
360 }
361
362 let normalized_query = normalize_query(prefix);
363 if normalized_query.is_empty() {
364 return self.commands.clone();
365 }
366
367 let mut prefix_matches: Vec<&SlashCommandItem> = self
368 .commands
369 .iter()
370 .filter(|info| info.name.starts_with(normalized_query.as_str()))
371 .collect();
372 if !prefix_matches.is_empty() {
373 prefix_matches.sort_by(|a, b| a.name.cmp(&b.name));
374 return prefix_matches.into_iter().cloned().collect();
375 }
376
377 let mut scored: Vec<ScoredCommand<'_>> = self
378 .commands
379 .iter()
380 .filter_map(|info| {
381 let name_score = fuzzy_score(&normalized_query, info.name.as_str());
382 let description_score = fuzzy_score(&normalized_query, info.description.as_str());
383 if name_score.is_none() && description_score.is_none() {
384 return None;
385 }
386
387 let name_lower = info.name.to_ascii_lowercase();
388 let description_lower = info.description.to_ascii_lowercase();
389
390 Some(ScoredCommand {
391 command: info,
392 name_match: name_score.is_some(),
393 name_prefix: name_lower.starts_with(normalized_query.as_str()),
394 name_pos: name_lower
395 .find(normalized_query.as_str())
396 .unwrap_or(usize::MAX),
397 description_pos: description_lower
398 .find(normalized_query.as_str())
399 .unwrap_or(usize::MAX),
400 name_score: name_score.unwrap_or(0),
401 description_score: description_score.unwrap_or(0),
402 })
403 })
404 .collect();
405
406 if scored.is_empty() {
407 return Vec::new();
408 }
409
410 scored.sort_by(|left, right| {
411 (
412 !left.name_match,
413 !left.name_prefix,
414 left.name_pos == usize::MAX,
415 std::cmp::Reverse(left.name_score),
416 left.name_pos,
417 left.description_pos == usize::MAX,
418 std::cmp::Reverse(left.description_score),
419 left.description_pos,
420 left.command.name.len(),
421 left.command.name.as_str(),
422 )
423 .cmp(&(
424 !right.name_match,
425 !right.name_prefix,
426 right.name_pos == usize::MAX,
427 std::cmp::Reverse(right.name_score),
428 right.name_pos,
429 right.description_pos == usize::MAX,
430 std::cmp::Reverse(right.description_score),
431 right.description_pos,
432 right.command.name.len(),
433 right.command.name.as_str(),
434 ))
435 });
436
437 scored
438 .into_iter()
439 .map(|info| info.command.clone())
440 .collect()
441 }
442
443 fn ensure_selection(&mut self) -> bool {
444 if self.suggestions.is_empty() {
445 if self.list_state.selected().is_some() {
446 self.list_state.select(None);
447 *self.list_state.offset_mut() = 0;
448 return true;
449 }
450 return false;
451 }
452
453 let visible_len = self.suggestions.len();
454 let current = self.list_state.selected().unwrap_or(0);
455 let bounded = current.min(visible_len - 1);
456
457 if Some(bounded) == self.list_state.selected() {
458 self.ensure_list_visible();
459 false
460 } else {
461 self.apply_selection(Some(bounded))
462 }
463 }
464
465 fn apply_selection(&mut self, index: Option<usize>) -> bool {
466 if self.list_state.selected() == index {
467 return false;
468 }
469
470 self.list_state.select(index);
471 if index.is_none() {
472 *self.list_state.offset_mut() = 0;
473 }
474 self.ensure_list_visible();
475 true
476 }
477
478 fn ensure_list_visible(&mut self) {
479 if self.visible_rows == 0 {
480 return;
481 }
482
483 let Some(selected) = self.list_state.selected() else {
484 *self.list_state.offset_mut() = 0;
485 return;
486 };
487
488 let visible_rows = self.visible_rows;
489 let offset_ref = self.list_state.offset_mut();
490 let offset = *offset_ref;
491
492 if selected < offset {
493 *offset_ref = selected;
494 } else if selected >= offset + visible_rows {
495 *offset_ref = selected + 1 - visible_rows;
496 }
497 }
498
499 #[cfg(test)]
500 fn highlight_name_segments_static(&self, name: &str) -> Vec<SlashPaletteHighlightSegment> {
501 let Some(query) = self.filter_query.as_ref().filter(|query| !query.is_empty()) else {
502 return vec![SlashPaletteHighlightSegment::plain(name.to_owned())];
503 };
504
505 let lowercase = name.to_ascii_lowercase();
507 if !lowercase.starts_with(query) {
508 return vec![SlashPaletteHighlightSegment::plain(name.to_owned())];
509 }
510
511 let query_len = query.chars().count();
512 let mut highlighted = String::new();
513 let mut remainder = String::new();
514
515 for (index, ch) in name.chars().enumerate() {
516 if index < query_len {
517 highlighted.push(ch);
518 } else {
519 remainder.push(ch);
520 }
521 }
522
523 let mut segments = Vec::new();
524 if !highlighted.is_empty() {
525 segments.push(SlashPaletteHighlightSegment::highlighted(highlighted));
526 }
527 if !remainder.is_empty() {
528 segments.push(SlashPaletteHighlightSegment::plain(remainder));
529 }
530 if segments.is_empty() {
531 segments.push(SlashPaletteHighlightSegment::plain(String::new()));
532 }
533 segments
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 fn test_commands() -> Vec<SlashCommandItem> {
542 vec![
543 SlashCommandItem::new("command", "Run a terminal command"),
544 SlashCommandItem::new("config", "Show effective configuration"),
545 SlashCommandItem::new("clear", "Clear screen"),
546 SlashCommandItem::new("new", "Start new session"),
547 SlashCommandItem::new("status", "Show status"),
548 SlashCommandItem::new("help", "Show help"),
549 SlashCommandItem::new("theme", "Switch theme"),
550 SlashCommandItem::new("mode", "Switch mode"),
551 ]
552 }
553
554 fn palette_with_commands() -> SlashPalette {
555 let mut palette = SlashPalette::with_commands(test_commands());
556 let _ = palette.update(Some(""), usize::MAX);
557 palette
558 }
559
560 #[test]
561 fn update_applies_prefix_and_highlights_matches() {
562 let mut palette = SlashPalette::with_commands(test_commands());
563
564 let update = palette.update(Some("co"), 10);
565 assert!(matches!(
566 update,
567 SlashPaletteUpdate::Changed {
568 suggestions_changed: true,
569 selection_changed: true
570 }
571 ));
572
573 let items = palette.items();
574 assert!(!items.is_empty());
575 let command = items
576 .into_iter()
577 .find(|item| {
578 item.command
579 .as_ref()
580 .is_some_and(|cmd| cmd.name == "command")
581 })
582 .expect("command suggestion available");
583
584 assert_eq!(command.name_segments.len(), 2);
585 assert!(command.name_segments[0].highlighted);
586 assert_eq!(command.name_segments[0].content, "co");
587 assert_eq!(command.name_segments[1].content, "mmand");
588 }
589
590 #[test]
591 fn update_matches_fuzzy_command_name() {
592 let mut palette = SlashPalette::with_commands(test_commands());
593
594 let update = palette.update(Some("sts"), 10);
595 assert!(matches!(update, SlashPaletteUpdate::Changed { .. }));
596
597 let names: Vec<String> = palette
598 .items()
599 .into_iter()
600 .filter_map(|item| item.command.map(|command| command.name))
601 .collect();
602
603 assert_eq!(names.first().map(String::as_str), Some("status"));
604 }
605
606 #[test]
607 fn update_matches_command_description() {
608 let mut palette = SlashPalette::with_commands(test_commands());
609
610 let update = palette.update(Some("terminal"), 10);
611 assert!(matches!(update, SlashPaletteUpdate::Changed { .. }));
612
613 let names: Vec<String> = palette
614 .items()
615 .into_iter()
616 .filter_map(|item| item.command.map(|command| command.name))
617 .collect();
618
619 assert_eq!(names.first().map(String::as_str), Some("command"));
620 }
621
622 #[test]
623 fn update_without_matches_resets_highlights() {
624 let mut palette = SlashPalette::with_commands(test_commands());
625 let _ = palette.update(Some("co"), 10);
626 assert!(!palette.items().is_empty());
627
628 let update = palette.update(Some("zzz"), 10);
629 assert!(matches!(update, SlashPaletteUpdate::Changed { .. }));
630 assert!(palette.items().is_empty());
631
632 for item in palette.items() {
633 assert!(
634 item.name_segments
635 .iter()
636 .all(|segment| !segment.highlighted)
637 );
638 }
639 }
640
641 #[test]
642 fn navigation_wraps_between_items() {
643 let mut palette = palette_with_commands();
644
645 assert!(palette.move_down());
646 let first = palette.list_state.selected();
647 assert_eq!(first, Some(1));
648
649 let steps = palette.suggestions.len().saturating_sub(1);
650 for _ in 0..steps {
651 assert!(palette.move_down());
652 }
653 assert_eq!(palette.list_state.selected(), Some(0));
654
655 assert!(palette.move_up());
656 assert_eq!(
657 palette.list_state.selected(),
658 Some(palette.suggestions.len() - 1)
659 );
660 }
661
662 #[test]
663 fn boundary_shortcuts_jump_to_expected_items() {
664 let mut palette = palette_with_commands();
665
666 assert!(palette.select_last());
667 assert_eq!(
668 palette.list_state.selected(),
669 Some(palette.suggestions.len() - 1)
670 );
671
672 assert!(palette.select_first());
673 assert_eq!(palette.list_state.selected(), Some(0));
674 }
675
676 #[test]
677 fn page_navigation_advances_by_visible_rows() {
678 let mut palette = palette_with_commands();
679 palette.set_visible_rows(3);
680
681 assert!(palette.page_down());
682 assert_eq!(palette.list_state.selected(), Some(3));
683
684 assert!(palette.page_down());
685 assert_eq!(palette.list_state.selected(), Some(6));
686
687 assert!(palette.page_up());
688 assert_eq!(palette.list_state.selected(), Some(3));
689
690 assert!(palette.page_up());
691 assert_eq!(palette.list_state.selected(), Some(0));
692 }
693
694 #[test]
695 fn clear_resets_state() {
696 let mut palette = SlashPalette::with_commands(test_commands());
697 let _ = palette.update(Some("co"), 10);
698 palette.set_visible_rows(3);
699
700 assert!(palette.clear());
701 assert!(palette.suggestions().is_empty());
702 assert_eq!(palette.list_state.selected(), None);
703 assert_eq!(palette.visible_rows(), 0);
704 }
705
706 #[test]
707 fn command_range_tracks_latest_slash_before_cursor() {
708 let input = "/one two /three";
709 let cursor = input.len();
710 let range = command_range(input, cursor).expect("range available");
711 assert_eq!(range.start, 9);
712 assert_eq!(range.end, input.len());
713 }
714
715 #[test]
716 fn command_range_stops_at_whitespace() {
717 let input = "/cmd arg";
718 let cursor = input.len();
719 assert!(command_range(input, cursor).is_none());
722 }
723
724 #[test]
725 fn command_prefix_includes_partial_match() {
726 let input = "/hel";
727 let prefix = command_prefix(input, input.len()).expect("prefix available");
728 assert_eq!(prefix, "hel");
729 }
730
731 #[test]
732 fn command_prefix_is_empty_when_cursor_immediately_after_slash() {
733 let input = "/";
734 let prefix = command_prefix(input, 1).expect("prefix available");
735 assert!(prefix.is_empty());
736 }
737
738 #[test]
739 fn command_prefix_returns_none_when_not_in_command() {
740 let input = "say hello";
741 assert!(command_prefix(input, input.len()).is_none());
742 }
743}