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 clear_visible_rows(&mut self) {
148 self.visible_rows = 0;
149 }
150
151 pub fn set_visible_rows(&mut self, rows: usize) {
152 self.visible_rows = rows;
153 self.ensure_list_visible();
154 }
155
156 #[cfg(test)]
157 pub fn visible_rows(&self) -> usize {
158 self.visible_rows
159 }
160
161 pub fn update(&mut self, prefix: Option<&str>, limit: usize) -> SlashPaletteUpdate {
162 let Some(prefix) = prefix else {
163 if self.clear_internal() {
164 return SlashPaletteUpdate::Cleared;
165 }
166 return SlashPaletteUpdate::NoChange;
167 };
168 let mut new_suggestions = Vec::new();
169
170 let static_suggestions = self.suggestions_for(prefix);
172 new_suggestions.extend(
173 static_suggestions
174 .into_iter()
175 .map(SlashPaletteSuggestion::Static),
176 );
177
178 if !prefix.is_empty() {
180 new_suggestions.truncate(limit);
181 }
182
183 let filter_query = {
184 let normalized = normalize_query(prefix);
185 if normalized.is_empty() {
186 None
187 } else if new_suggestions
188 .iter()
189 .map(|suggestion| match suggestion {
190 SlashPaletteSuggestion::Static(info) => info,
191 })
192 .all(|info| info.name.starts_with(normalized.as_str()))
193 {
194 Some(normalized.clone())
195 } else {
196 None
197 }
198 };
199
200 let suggestions_changed = self.replace_suggestions(new_suggestions);
201 self.filter_query = filter_query;
202 let selection_changed = self.ensure_selection();
203
204 if suggestions_changed || selection_changed {
205 SlashPaletteUpdate::Changed {
206 suggestions_changed,
207 selection_changed,
208 }
209 } else {
210 SlashPaletteUpdate::NoChange
211 }
212 }
213
214 pub fn clear(&mut self) -> bool {
215 self.clear_internal()
216 }
217
218 pub fn move_up(&mut self) -> bool {
219 if self.suggestions.is_empty() {
220 return false;
221 }
222
223 let visible_len = self.suggestions.len();
224 let current = self.list_state.selected().unwrap_or(0);
225 let new_index = if current > 0 {
226 current - 1
227 } else {
228 visible_len - 1
229 };
230
231 self.apply_selection(Some(new_index))
232 }
233
234 pub fn move_down(&mut self) -> bool {
235 if self.suggestions.is_empty() {
236 return false;
237 }
238
239 let visible_len = self.suggestions.len();
240 let current = self.list_state.selected().unwrap_or(visible_len - 1);
241 let new_index = if current + 1 < visible_len {
242 current + 1
243 } else {
244 0
245 };
246
247 self.apply_selection(Some(new_index))
248 }
249
250 pub fn select_first(&mut self) -> bool {
251 if self.suggestions.is_empty() {
252 return false;
253 }
254
255 self.apply_selection(Some(0))
256 }
257
258 pub fn select_last(&mut self) -> bool {
259 if self.suggestions.is_empty() {
260 return false;
261 }
262
263 let last = self.suggestions.len() - 1;
264 self.apply_selection(Some(last))
265 }
266
267 pub fn page_up(&mut self) -> bool {
268 if self.suggestions.is_empty() {
269 return false;
270 }
271
272 let step = self.visible_rows.max(1);
273 let current = self.list_state.selected().unwrap_or(0);
274 let new_index = current.saturating_sub(step);
275
276 self.apply_selection(Some(new_index))
277 }
278
279 pub fn page_down(&mut self) -> bool {
280 if self.suggestions.is_empty() {
281 return false;
282 }
283
284 let step = self.visible_rows.max(1);
285 let visible_len = self.suggestions.len();
286 let current = self.list_state.selected().unwrap_or(0);
287 let mut new_index = current.saturating_add(step);
288 if new_index >= visible_len {
289 new_index = visible_len - 1;
290 }
291
292 self.apply_selection(Some(new_index))
293 }
294
295 #[cfg(test)]
296 pub fn items(&self) -> Vec<SlashPaletteItem> {
297 self.suggestions
298 .iter()
299 .map(|suggestion| match suggestion {
300 SlashPaletteSuggestion::Static(command) => SlashPaletteItem {
301 command: Some(command.clone()),
302 name_segments: self.highlight_name_segments_static(command.name.as_str()),
303 description: command.description.to_owned(),
304 },
305 })
306 .collect()
307 }
308
309 fn clear_internal(&mut self) -> bool {
310 if self.suggestions.is_empty()
311 && self.list_state.selected().is_none()
312 && self.visible_rows == 0
313 && self.filter_query.is_none()
314 {
315 return false;
316 }
317
318 self.suggestions.clear();
319 self.list_state.select(None);
320 *self.list_state.offset_mut() = 0;
321 self.visible_rows = 0;
322 self.filter_query = None;
323 true
324 }
325
326 fn replace_suggestions(&mut self, new_suggestions: Vec<SlashPaletteSuggestion>) -> bool {
327 if self.suggestions == new_suggestions {
328 return false;
329 }
330
331 self.suggestions = new_suggestions;
332 true
333 }
334
335 fn suggestions_for(&self, prefix: &str) -> Vec<SlashCommandItem> {
336 struct ScoredCommand<'a> {
337 command: &'a SlashCommandItem,
338 name_match: bool,
339 name_prefix: bool,
340 name_pos: usize,
341 description_pos: usize,
342 name_score: u32,
343 description_score: u32,
344 }
345
346 let normalized_query = normalize_query(prefix);
347 if normalized_query.is_empty() {
348 return self.commands.clone();
349 }
350
351 let mut prefix_matches: Vec<&SlashCommandItem> = self
352 .commands
353 .iter()
354 .filter(|info| info.name.starts_with(normalized_query.as_str()))
355 .collect();
356 if !prefix_matches.is_empty() {
357 prefix_matches.sort_by(|a, b| a.name.cmp(&b.name));
358 return prefix_matches.into_iter().cloned().collect();
359 }
360
361 let mut scored: Vec<ScoredCommand<'_>> = self
362 .commands
363 .iter()
364 .filter_map(|info| {
365 let name_score = fuzzy_score(&normalized_query, info.name.as_str());
366 let description_score = fuzzy_score(&normalized_query, info.description.as_str());
367 if name_score.is_none() && description_score.is_none() {
368 return None;
369 }
370
371 let name_lower = info.name.to_ascii_lowercase();
372 let description_lower = info.description.to_ascii_lowercase();
373
374 Some(ScoredCommand {
375 command: info,
376 name_match: name_score.is_some(),
377 name_prefix: name_lower.starts_with(normalized_query.as_str()),
378 name_pos: name_lower
379 .find(normalized_query.as_str())
380 .unwrap_or(usize::MAX),
381 description_pos: description_lower
382 .find(normalized_query.as_str())
383 .unwrap_or(usize::MAX),
384 name_score: name_score.unwrap_or(0),
385 description_score: description_score.unwrap_or(0),
386 })
387 })
388 .collect();
389
390 if scored.is_empty() {
391 return Vec::new();
392 }
393
394 scored.sort_by(|left, right| {
395 (
396 !left.name_match,
397 !left.name_prefix,
398 left.name_pos == usize::MAX,
399 std::cmp::Reverse(left.name_score),
400 left.name_pos,
401 left.description_pos == usize::MAX,
402 std::cmp::Reverse(left.description_score),
403 left.description_pos,
404 left.command.name.len(),
405 left.command.name.as_str(),
406 )
407 .cmp(&(
408 !right.name_match,
409 !right.name_prefix,
410 right.name_pos == usize::MAX,
411 std::cmp::Reverse(right.name_score),
412 right.name_pos,
413 right.description_pos == usize::MAX,
414 std::cmp::Reverse(right.description_score),
415 right.description_pos,
416 right.command.name.len(),
417 right.command.name.as_str(),
418 ))
419 });
420
421 scored
422 .into_iter()
423 .map(|info| info.command.clone())
424 .collect()
425 }
426
427 fn ensure_selection(&mut self) -> bool {
428 if self.suggestions.is_empty() {
429 if self.list_state.selected().is_some() {
430 self.list_state.select(None);
431 *self.list_state.offset_mut() = 0;
432 return true;
433 }
434 return false;
435 }
436
437 let visible_len = self.suggestions.len();
438 let current = self.list_state.selected().unwrap_or(0);
439 let bounded = current.min(visible_len - 1);
440
441 if Some(bounded) == self.list_state.selected() {
442 self.ensure_list_visible();
443 false
444 } else {
445 self.apply_selection(Some(bounded))
446 }
447 }
448
449 fn apply_selection(&mut self, index: Option<usize>) -> bool {
450 if self.list_state.selected() == index {
451 return false;
452 }
453
454 self.list_state.select(index);
455 if index.is_none() {
456 *self.list_state.offset_mut() = 0;
457 }
458 self.ensure_list_visible();
459 true
460 }
461
462 fn ensure_list_visible(&mut self) {
463 if self.visible_rows == 0 {
464 return;
465 }
466
467 let Some(selected) = self.list_state.selected() else {
468 *self.list_state.offset_mut() = 0;
469 return;
470 };
471
472 let visible_rows = self.visible_rows;
473 let offset_ref = self.list_state.offset_mut();
474 let offset = *offset_ref;
475
476 if selected < offset {
477 *offset_ref = selected;
478 } else if selected >= offset + visible_rows {
479 *offset_ref = selected + 1 - visible_rows;
480 }
481 }
482
483 #[cfg(test)]
484 fn highlight_name_segments_static(&self, name: &str) -> Vec<SlashPaletteHighlightSegment> {
485 let Some(query) = self.filter_query.as_ref().filter(|query| !query.is_empty()) else {
486 return vec![SlashPaletteHighlightSegment::plain(name.to_owned())];
487 };
488
489 let lowercase = name.to_ascii_lowercase();
491 if !lowercase.starts_with(query) {
492 return vec![SlashPaletteHighlightSegment::plain(name.to_owned())];
493 }
494
495 let query_len = query.chars().count();
496 let mut highlighted = String::new();
497 let mut remainder = String::new();
498
499 for (index, ch) in name.chars().enumerate() {
500 if index < query_len {
501 highlighted.push(ch);
502 } else {
503 remainder.push(ch);
504 }
505 }
506
507 let mut segments = Vec::new();
508 if !highlighted.is_empty() {
509 segments.push(SlashPaletteHighlightSegment::highlighted(highlighted));
510 }
511 if !remainder.is_empty() {
512 segments.push(SlashPaletteHighlightSegment::plain(remainder));
513 }
514 if segments.is_empty() {
515 segments.push(SlashPaletteHighlightSegment::plain(String::new()));
516 }
517 segments
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 fn test_commands() -> Vec<SlashCommandItem> {
526 vec![
527 SlashCommandItem::new("command", "Run a terminal command"),
528 SlashCommandItem::new("config", "Show effective configuration"),
529 SlashCommandItem::new("clear", "Clear screen"),
530 SlashCommandItem::new("new", "Start new session"),
531 SlashCommandItem::new("status", "Show status"),
532 SlashCommandItem::new("help", "Show help"),
533 SlashCommandItem::new("theme", "Switch theme"),
534 SlashCommandItem::new("mode", "Switch mode"),
535 ]
536 }
537
538 fn palette_with_commands() -> SlashPalette {
539 let mut palette = SlashPalette::with_commands(test_commands());
540 let _ = palette.update(Some(""), usize::MAX);
541 palette
542 }
543
544 #[test]
545 fn update_applies_prefix_and_highlights_matches() {
546 let mut palette = SlashPalette::with_commands(test_commands());
547
548 let update = palette.update(Some("co"), 10);
549 assert!(matches!(
550 update,
551 SlashPaletteUpdate::Changed {
552 suggestions_changed: true,
553 selection_changed: true
554 }
555 ));
556
557 let items = palette.items();
558 assert!(!items.is_empty());
559 let command = items
560 .into_iter()
561 .find(|item| {
562 item.command
563 .as_ref()
564 .is_some_and(|cmd| cmd.name == "command")
565 })
566 .expect("command suggestion available");
567
568 assert_eq!(command.name_segments.len(), 2);
569 assert!(command.name_segments[0].highlighted);
570 assert_eq!(command.name_segments[0].content, "co");
571 assert_eq!(command.name_segments[1].content, "mmand");
572 }
573
574 #[test]
575 fn update_matches_fuzzy_command_name() {
576 let mut palette = SlashPalette::with_commands(test_commands());
577
578 let update = palette.update(Some("sts"), 10);
579 assert!(matches!(update, SlashPaletteUpdate::Changed { .. }));
580
581 let names: Vec<String> = palette
582 .items()
583 .into_iter()
584 .filter_map(|item| item.command.map(|command| command.name))
585 .collect();
586
587 assert_eq!(names.first().map(String::as_str), Some("status"));
588 }
589
590 #[test]
591 fn update_matches_command_description() {
592 let mut palette = SlashPalette::with_commands(test_commands());
593
594 let update = palette.update(Some("terminal"), 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("command"));
604 }
605
606 #[test]
607 fn update_without_matches_resets_highlights() {
608 let mut palette = SlashPalette::with_commands(test_commands());
609 let _ = palette.update(Some("co"), 10);
610 assert!(!palette.items().is_empty());
611
612 let update = palette.update(Some("zzz"), 10);
613 assert!(matches!(update, SlashPaletteUpdate::Changed { .. }));
614 assert!(palette.items().is_empty());
615
616 for item in palette.items() {
617 assert!(
618 item.name_segments
619 .iter()
620 .all(|segment| !segment.highlighted)
621 );
622 }
623 }
624
625 #[test]
626 fn navigation_wraps_between_items() {
627 let mut palette = palette_with_commands();
628
629 assert!(palette.move_down());
630 let first = palette.list_state.selected();
631 assert_eq!(first, Some(1));
632
633 let steps = palette.suggestions.len().saturating_sub(1);
634 for _ in 0..steps {
635 assert!(palette.move_down());
636 }
637 assert_eq!(palette.list_state.selected(), Some(0));
638
639 assert!(palette.move_up());
640 assert_eq!(
641 palette.list_state.selected(),
642 Some(palette.suggestions.len() - 1)
643 );
644 }
645
646 #[test]
647 fn boundary_shortcuts_jump_to_expected_items() {
648 let mut palette = palette_with_commands();
649
650 assert!(palette.select_last());
651 assert_eq!(
652 palette.list_state.selected(),
653 Some(palette.suggestions.len() - 1)
654 );
655
656 assert!(palette.select_first());
657 assert_eq!(palette.list_state.selected(), Some(0));
658 }
659
660 #[test]
661 fn page_navigation_advances_by_visible_rows() {
662 let mut palette = palette_with_commands();
663 palette.set_visible_rows(3);
664
665 assert!(palette.page_down());
666 assert_eq!(palette.list_state.selected(), Some(3));
667
668 assert!(palette.page_down());
669 assert_eq!(palette.list_state.selected(), Some(6));
670
671 assert!(palette.page_up());
672 assert_eq!(palette.list_state.selected(), Some(3));
673
674 assert!(palette.page_up());
675 assert_eq!(palette.list_state.selected(), Some(0));
676 }
677
678 #[test]
679 fn clear_resets_state() {
680 let mut palette = SlashPalette::with_commands(test_commands());
681 let _ = palette.update(Some("co"), 10);
682 palette.set_visible_rows(3);
683
684 assert!(palette.clear());
685 assert!(palette.suggestions().is_empty());
686 assert_eq!(palette.list_state.selected(), None);
687 assert_eq!(palette.visible_rows(), 0);
688 }
689
690 #[test]
691 fn command_range_tracks_latest_slash_before_cursor() {
692 let input = "/one two /three";
693 let cursor = input.len();
694 let range = command_range(input, cursor).expect("range available");
695 assert_eq!(range.start, 9);
696 assert_eq!(range.end, input.len());
697 }
698
699 #[test]
700 fn command_range_stops_at_whitespace() {
701 let input = "/cmd arg";
702 let cursor = input.len();
703 assert!(command_range(input, cursor).is_none());
706 }
707
708 #[test]
709 fn command_prefix_includes_partial_match() {
710 let input = "/hel";
711 let prefix = command_prefix(input, input.len()).expect("prefix available");
712 assert_eq!(prefix, "hel");
713 }
714
715 #[test]
716 fn command_prefix_is_empty_when_cursor_immediately_after_slash() {
717 let input = "/";
718 let prefix = command_prefix(input, 1).expect("prefix available");
719 assert!(prefix.is_empty());
720 }
721
722 #[test]
723 fn command_prefix_returns_none_when_not_in_command() {
724 let input = "say hello";
725 assert!(command_prefix(input, input.len()).is_none());
726 }
727}