Skip to main content

vtcode_tui/core_tui/session/
slash_palette.rs

1use 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            // Space terminates the current command token
32            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        // Handle regular slash commands
179        let static_suggestions = self.suggestions_for(prefix);
180        new_suggestions.extend(
181            static_suggestions
182                .into_iter()
183                .map(SlashPaletteSuggestion::Static),
184        );
185
186        // Apply limit if prefix is not empty
187        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        // For static commands, only use the part after the prompt invocation prefix if applicable
506        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        // Previous behavior: returned Some(0..4) (last range)
720        // New behavior: returns None (active range interrupted by space)
721        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}