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