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