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 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        // Handle regular slash commands
171        let static_suggestions = self.suggestions_for(prefix);
172        new_suggestions.extend(
173            static_suggestions
174                .into_iter()
175                .map(SlashPaletteSuggestion::Static),
176        );
177
178        // Apply limit if prefix is not empty
179        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        // For static commands, only use the part after the prompt invocation prefix if applicable
490        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        // Previous behavior: returned Some(0..4) (last range)
704        // New behavior: returns None (active range interrupted by space)
705        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}