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