vtcode_tui/core_tui/session/
slash_palette.rs1use ratatui::widgets::ListState;
2use unicode_segmentation::UnicodeSegmentation;
3
4use crate::ui::search::{fuzzy_match, 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 let trimmed = prefix.trim();
337 if trimmed.is_empty() {
338 return self.commands.clone();
339 }
340
341 let query = trimmed.to_ascii_lowercase();
342
343 let mut prefix_matches: Vec<&SlashCommandItem> = self
344 .commands
345 .iter()
346 .filter(|info| info.name.starts_with(query.as_str()))
347 .collect();
348
349 if !prefix_matches.is_empty() {
350 prefix_matches.sort_by(|a, b| a.name.cmp(&b.name));
351 return prefix_matches.into_iter().cloned().collect();
352 }
353
354 let mut substring_matches: Vec<(&SlashCommandItem, usize)> = self
355 .commands
356 .iter()
357 .filter_map(|info| {
358 info.name
359 .find(query.as_str())
360 .map(|position| (info, position))
361 })
362 .collect();
363
364 if !substring_matches.is_empty() {
365 substring_matches.sort_by(|(a, pos_a), (b, pos_b)| {
366 (*pos_a, a.name.len(), a.name.as_str()).cmp(&(
367 *pos_b,
368 b.name.len(),
369 b.name.as_str(),
370 ))
371 });
372 return substring_matches
373 .into_iter()
374 .map(|(info, _)| info.clone())
375 .collect();
376 }
377
378 let normalized_query = normalize_query(&query);
379 if normalized_query.is_empty() {
380 return self.commands.clone();
381 }
382
383 let mut scored: Vec<(&SlashCommandItem, usize, usize)> = self
384 .commands
385 .iter()
386 .filter_map(|info| {
387 let mut candidate = info.name.to_ascii_lowercase();
388 if !info.description.is_empty() {
389 candidate.push(' ');
390 candidate.push_str(info.description.to_ascii_lowercase().as_str());
391 }
392
393 if !fuzzy_match(&normalized_query, &candidate) {
394 return None;
395 }
396
397 let name_pos = info
398 .name
399 .to_ascii_lowercase()
400 .find(query.as_str())
401 .unwrap_or(usize::MAX);
402 let desc_pos = info
403 .description
404 .to_ascii_lowercase()
405 .find(query.as_str())
406 .unwrap_or(usize::MAX);
407
408 Some((info, name_pos, desc_pos))
409 })
410 .collect();
411
412 if scored.is_empty() {
413 return Vec::new();
414 }
415
416 scored.sort_by(|(a, name_pos_a, desc_pos_a), (b, name_pos_b, desc_pos_b)| {
417 let score_a = (
418 *name_pos_a == usize::MAX,
419 *name_pos_a,
420 *desc_pos_a,
421 a.name.as_str(),
422 );
423 let score_b = (
424 *name_pos_b == usize::MAX,
425 *name_pos_b,
426 *desc_pos_b,
427 b.name.as_str(),
428 );
429 score_a.cmp(&score_b)
430 });
431
432 scored
433 .into_iter()
434 .map(|(info, _, _)| info.clone())
435 .collect()
436 }
437
438 fn ensure_selection(&mut self) -> bool {
439 if self.suggestions.is_empty() {
440 if self.list_state.selected().is_some() {
441 self.list_state.select(None);
442 *self.list_state.offset_mut() = 0;
443 return true;
444 }
445 return false;
446 }
447
448 let visible_len = self.suggestions.len();
449 let current = self.list_state.selected().unwrap_or(0);
450 let bounded = current.min(visible_len - 1);
451
452 if Some(bounded) == self.list_state.selected() {
453 self.ensure_list_visible();
454 false
455 } else {
456 self.apply_selection(Some(bounded))
457 }
458 }
459
460 fn apply_selection(&mut self, index: Option<usize>) -> bool {
461 if self.list_state.selected() == index {
462 return false;
463 }
464
465 self.list_state.select(index);
466 if index.is_none() {
467 *self.list_state.offset_mut() = 0;
468 }
469 self.ensure_list_visible();
470 true
471 }
472
473 fn ensure_list_visible(&mut self) {
474 if self.visible_rows == 0 {
475 return;
476 }
477
478 let Some(selected) = self.list_state.selected() else {
479 *self.list_state.offset_mut() = 0;
480 return;
481 };
482
483 let visible_rows = self.visible_rows;
484 let offset_ref = self.list_state.offset_mut();
485 let offset = *offset_ref;
486
487 if selected < offset {
488 *offset_ref = selected;
489 } else if selected >= offset + visible_rows {
490 *offset_ref = selected + 1 - visible_rows;
491 }
492 }
493
494 #[cfg(test)]
495 fn highlight_name_segments_static(&self, name: &str) -> Vec<SlashPaletteHighlightSegment> {
496 let Some(query) = self.filter_query.as_ref().filter(|query| !query.is_empty()) else {
497 return vec![SlashPaletteHighlightSegment::plain(name.to_owned())];
498 };
499
500 let lowercase = name.to_ascii_lowercase();
502 if !lowercase.starts_with(query) {
503 return vec![SlashPaletteHighlightSegment::plain(name.to_owned())];
504 }
505
506 let query_len = query.chars().count();
507 let mut highlighted = String::new();
508 let mut remainder = String::new();
509
510 for (index, ch) in name.chars().enumerate() {
511 if index < query_len {
512 highlighted.push(ch);
513 } else {
514 remainder.push(ch);
515 }
516 }
517
518 let mut segments = Vec::new();
519 if !highlighted.is_empty() {
520 segments.push(SlashPaletteHighlightSegment::highlighted(highlighted));
521 }
522 if !remainder.is_empty() {
523 segments.push(SlashPaletteHighlightSegment::plain(remainder));
524 }
525 if segments.is_empty() {
526 segments.push(SlashPaletteHighlightSegment::plain(String::new()));
527 }
528 segments
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 fn test_commands() -> Vec<SlashCommandItem> {
537 vec![
538 SlashCommandItem::new("command", "Run a terminal command"),
539 SlashCommandItem::new("config", "Show effective configuration"),
540 SlashCommandItem::new("clear", "Clear screen"),
541 SlashCommandItem::new("new", "Start new session"),
542 SlashCommandItem::new("status", "Show status"),
543 SlashCommandItem::new("help", "Show help"),
544 SlashCommandItem::new("theme", "Switch theme"),
545 SlashCommandItem::new("mode", "Switch mode"),
546 ]
547 }
548
549 fn palette_with_commands() -> SlashPalette {
550 let mut palette = SlashPalette::with_commands(test_commands());
551 let _ = palette.update(Some(""), usize::MAX);
552 palette
553 }
554
555 #[test]
556 fn update_applies_prefix_and_highlights_matches() {
557 let mut palette = SlashPalette::with_commands(test_commands());
558
559 let update = palette.update(Some("co"), 10);
560 assert!(matches!(
561 update,
562 SlashPaletteUpdate::Changed {
563 suggestions_changed: true,
564 selection_changed: true
565 }
566 ));
567
568 let items = palette.items();
569 assert!(!items.is_empty());
570 let command = items
571 .into_iter()
572 .find(|item| {
573 item.command
574 .as_ref()
575 .is_some_and(|cmd| cmd.name == "command")
576 })
577 .expect("command suggestion available");
578
579 assert_eq!(command.name_segments.len(), 2);
580 assert!(command.name_segments[0].highlighted);
581 assert_eq!(command.name_segments[0].content, "co");
582 assert_eq!(command.name_segments[1].content, "mmand");
583 }
584
585 #[test]
586 fn update_without_matches_resets_highlights() {
587 let mut palette = SlashPalette::with_commands(test_commands());
588 let _ = palette.update(Some("co"), 10);
589 assert!(!palette.items().is_empty());
590
591 let update = palette.update(Some("zzz"), 10);
592 assert!(matches!(update, SlashPaletteUpdate::Changed { .. }));
593 assert!(palette.items().is_empty());
594
595 for item in palette.items() {
596 assert!(
597 item.name_segments
598 .iter()
599 .all(|segment| !segment.highlighted)
600 );
601 }
602 }
603
604 #[test]
605 fn navigation_wraps_between_items() {
606 let mut palette = palette_with_commands();
607
608 assert!(palette.move_down());
609 let first = palette.list_state.selected();
610 assert_eq!(first, Some(1));
611
612 let steps = palette.suggestions.len().saturating_sub(1);
613 for _ in 0..steps {
614 assert!(palette.move_down());
615 }
616 assert_eq!(palette.list_state.selected(), Some(0));
617
618 assert!(palette.move_up());
619 assert_eq!(
620 palette.list_state.selected(),
621 Some(palette.suggestions.len() - 1)
622 );
623 }
624
625 #[test]
626 fn boundary_shortcuts_jump_to_expected_items() {
627 let mut palette = palette_with_commands();
628
629 assert!(palette.select_last());
630 assert_eq!(
631 palette.list_state.selected(),
632 Some(palette.suggestions.len() - 1)
633 );
634
635 assert!(palette.select_first());
636 assert_eq!(palette.list_state.selected(), Some(0));
637 }
638
639 #[test]
640 fn page_navigation_advances_by_visible_rows() {
641 let mut palette = palette_with_commands();
642 palette.set_visible_rows(3);
643
644 assert!(palette.page_down());
645 assert_eq!(palette.list_state.selected(), Some(3));
646
647 assert!(palette.page_down());
648 assert_eq!(palette.list_state.selected(), Some(6));
649
650 assert!(palette.page_up());
651 assert_eq!(palette.list_state.selected(), Some(3));
652
653 assert!(palette.page_up());
654 assert_eq!(palette.list_state.selected(), Some(0));
655 }
656
657 #[test]
658 fn clear_resets_state() {
659 let mut palette = SlashPalette::with_commands(test_commands());
660 let _ = palette.update(Some("co"), 10);
661 palette.set_visible_rows(3);
662
663 assert!(palette.clear());
664 assert!(palette.suggestions().is_empty());
665 assert_eq!(palette.list_state.selected(), None);
666 assert_eq!(palette.visible_rows(), 0);
667 }
668
669 #[test]
670 fn command_range_tracks_latest_slash_before_cursor() {
671 let input = "/one two /three";
672 let cursor = input.len();
673 let range = command_range(input, cursor).expect("range available");
674 assert_eq!(range.start, 9);
675 assert_eq!(range.end, input.len());
676 }
677
678 #[test]
679 fn command_range_stops_at_whitespace() {
680 let input = "/cmd arg";
681 let cursor = input.len();
682 assert!(command_range(input, cursor).is_none());
685 }
686
687 #[test]
688 fn command_prefix_includes_partial_match() {
689 let input = "/hel";
690 let prefix = command_prefix(input, input.len()).expect("prefix available");
691 assert_eq!(prefix, "hel");
692 }
693
694 #[test]
695 fn command_prefix_is_empty_when_cursor_immediately_after_slash() {
696 let input = "/";
697 let prefix = command_prefix(input, 1).expect("prefix available");
698 assert!(prefix.is_empty());
699 }
700
701 #[test]
702 fn command_prefix_returns_none_when_not_in_command() {
703 let input = "say hello";
704 assert!(command_prefix(input, input.len()).is_none());
705 }
706}