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        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        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        // For static commands, only use the part after the prompt invocation prefix if applicable
501        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        // Previous behavior: returned Some(0..4) (last range)
683        // New behavior: returns None (active range interrupted by space)
684        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}