Skip to main content

vtcode_tui/core_tui/app/session/
slash_palette.rs

1use unicode_segmentation::UnicodeSegmentation;
2
3use crate::core_tui::app::types::SlashCommandItem;
4use crate::core_tui::session::list_navigator::ListNavigator;
5use crate::ui::search::{fuzzy_score, normalize_query};
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    #[expect(dead_code)]
81    pub command: Option<SlashCommandItem>,
82    pub name_segments: Vec<SlashPaletteHighlightSegment>,
83    #[expect(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    navigator: ListNavigator,
102    filter_query: Option<String>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum SlashPaletteSuggestion {
107    Static(SlashCommandItem),
108}
109
110impl SlashPalette {
111    pub fn new() -> Self {
112        Self::with_commands(Vec::new())
113    }
114
115    pub fn with_commands(commands: Vec<SlashCommandItem>) -> Self {
116        Self {
117            commands,
118            suggestions: Vec::new(),
119            navigator: ListNavigator::new(),
120            filter_query: None,
121        }
122    }
123
124    pub fn suggestions(&self) -> &[SlashPaletteSuggestion] {
125        &self.suggestions
126    }
127
128    pub fn is_empty(&self) -> bool {
129        self.suggestions.is_empty()
130    }
131
132    pub fn selected_command(&self) -> Option<&SlashCommandItem> {
133        self.navigator
134            .selected()
135            .and_then(|index| self.suggestions.get(index))
136            .map(|suggestion| match suggestion {
137                SlashPaletteSuggestion::Static(info) => info,
138            })
139    }
140
141    pub fn selected_index(&self) -> Option<usize> {
142        self.navigator.selected()
143    }
144
145    pub fn scroll_offset(&self) -> usize {
146        self.navigator.scroll_offset()
147    }
148
149    pub(super) fn set_selected(&mut self, selected: Option<usize>) {
150        self.navigator.set_selected(selected);
151    }
152
153    pub(super) fn set_scroll_offset(&mut self, offset: usize) {
154        self.navigator.set_scroll_offset(offset);
155    }
156
157    pub fn clear_visible_rows(&mut self) {
158        self.navigator.set_visible_rows(0);
159    }
160
161    pub fn set_visible_rows(&mut self, rows: usize) {
162        self.navigator.set_visible_rows(rows);
163    }
164
165    #[cfg(test)]
166    pub fn visible_rows(&self) -> usize {
167        self.navigator.visible_rows()
168    }
169
170    pub fn update(&mut self, prefix: Option<&str>) -> SlashPaletteUpdate {
171        let Some(prefix) = prefix else {
172            if self.clear_internal() {
173                return SlashPaletteUpdate::Cleared;
174            }
175            return SlashPaletteUpdate::NoChange;
176        };
177        let mut new_suggestions = Vec::new();
178
179        // Handle regular slash commands
180        let static_suggestions = self.suggestions_for(prefix);
181        new_suggestions.extend(
182            static_suggestions
183                .into_iter()
184                .map(SlashPaletteSuggestion::Static),
185        );
186
187        let filter_query = {
188            let normalized = normalize_query(prefix);
189            if normalized.is_empty() {
190                None
191            } else if new_suggestions
192                .iter()
193                .map(|suggestion| match suggestion {
194                    SlashPaletteSuggestion::Static(info) => info,
195                })
196                .all(|info| info.name.starts_with(normalized.as_str()))
197            {
198                Some(normalized.clone())
199            } else {
200                None
201            }
202        };
203
204        let suggestions_changed = self.replace_suggestions(new_suggestions);
205        self.filter_query = filter_query;
206        let selection_changed = self.ensure_selection();
207
208        if suggestions_changed || selection_changed {
209            SlashPaletteUpdate::Changed {
210                suggestions_changed,
211                selection_changed,
212            }
213        } else {
214            SlashPaletteUpdate::NoChange
215        }
216    }
217
218    pub fn clear(&mut self) -> bool {
219        self.clear_internal()
220    }
221
222    pub fn move_up(&mut self) -> bool {
223        self.navigator.move_up()
224    }
225
226    pub fn move_down(&mut self) -> bool {
227        self.navigator.move_down()
228    }
229
230    pub fn select_first(&mut self) -> bool {
231        self.navigator.select_first()
232    }
233
234    pub fn select_last(&mut self) -> bool {
235        self.navigator.select_last()
236    }
237
238    pub fn page_up(&mut self) -> bool {
239        let step = self.navigator.visible_rows().max(1);
240        self.navigator.page_up(step)
241    }
242
243    pub fn page_down(&mut self) -> bool {
244        let step = self.navigator.visible_rows().max(1);
245        self.navigator.page_down(step)
246    }
247
248    pub fn select_index(&mut self, index: usize) -> bool {
249        self.navigator.select_index(index)
250    }
251
252    #[cfg(test)]
253    pub fn items(&self) -> Vec<SlashPaletteItem> {
254        self.suggestions
255            .iter()
256            .map(|suggestion| match suggestion {
257                SlashPaletteSuggestion::Static(command) => SlashPaletteItem {
258                    command: Some(command.clone()),
259                    name_segments: self.highlight_name_segments_static(command.name.as_str()),
260                    description: command.description.to_owned(),
261                },
262            })
263            .collect()
264    }
265
266    fn clear_internal(&mut self) -> bool {
267        if self.suggestions.is_empty()
268            && self.navigator.selected().is_none()
269            && self.navigator.visible_rows() == 0
270            && self.filter_query.is_none()
271        {
272            return false;
273        }
274
275        self.suggestions.clear();
276        self.navigator.set_item_count(0);
277        self.navigator.set_visible_rows(0);
278        self.filter_query = None;
279        true
280    }
281
282    fn replace_suggestions(&mut self, new_suggestions: Vec<SlashPaletteSuggestion>) -> bool {
283        if self.suggestions == new_suggestions {
284            return false;
285        }
286
287        self.suggestions = new_suggestions;
288        self.navigator.set_item_count(self.suggestions.len());
289        true
290    }
291
292    fn suggestions_for(&self, prefix: &str) -> Vec<SlashCommandItem> {
293        struct ScoredCommand<'a> {
294            command: &'a SlashCommandItem,
295            name_match: bool,
296            name_prefix: bool,
297            name_pos: usize,
298            description_pos: usize,
299            name_score: u32,
300            description_score: u32,
301        }
302
303        let normalized_query = normalize_query(prefix);
304        if normalized_query.is_empty() {
305            return self.commands.clone();
306        }
307
308        let mut prefix_matches: Vec<&SlashCommandItem> = self
309            .commands
310            .iter()
311            .filter(|info| info.name.starts_with(normalized_query.as_str()))
312            .collect();
313        if !prefix_matches.is_empty() {
314            prefix_matches.sort_by(|a, b| a.name.cmp(&b.name));
315            return prefix_matches.into_iter().cloned().collect();
316        }
317
318        let mut scored: Vec<ScoredCommand<'_>> = self
319            .commands
320            .iter()
321            .filter_map(|info| {
322                let name_score = fuzzy_score(&normalized_query, info.name.as_str());
323                let description_score = fuzzy_score(&normalized_query, info.description.as_str());
324                if name_score.is_none() && description_score.is_none() {
325                    return None;
326                }
327
328                let name_lower = info.name.to_ascii_lowercase();
329                let description_lower = info.description.to_ascii_lowercase();
330
331                Some(ScoredCommand {
332                    command: info,
333                    name_match: name_score.is_some(),
334                    name_prefix: name_lower.starts_with(normalized_query.as_str()),
335                    name_pos: name_lower
336                        .find(normalized_query.as_str())
337                        .unwrap_or(usize::MAX),
338                    description_pos: description_lower
339                        .find(normalized_query.as_str())
340                        .unwrap_or(usize::MAX),
341                    name_score: name_score.unwrap_or(0),
342                    description_score: description_score.unwrap_or(0),
343                })
344            })
345            .collect();
346
347        if scored.is_empty() {
348            return Vec::new();
349        }
350
351        scored.sort_by(|left, right| {
352            (
353                !left.name_match,
354                !left.name_prefix,
355                left.name_pos == usize::MAX,
356                std::cmp::Reverse(left.name_score),
357                left.name_pos,
358                left.description_pos == usize::MAX,
359                std::cmp::Reverse(left.description_score),
360                left.description_pos,
361                left.command.name.len(),
362                left.command.name.as_str(),
363            )
364                .cmp(&(
365                    !right.name_match,
366                    !right.name_prefix,
367                    right.name_pos == usize::MAX,
368                    std::cmp::Reverse(right.name_score),
369                    right.name_pos,
370                    right.description_pos == usize::MAX,
371                    std::cmp::Reverse(right.description_score),
372                    right.description_pos,
373                    right.command.name.len(),
374                    right.command.name.as_str(),
375                ))
376        });
377
378        scored
379            .into_iter()
380            .map(|info| info.command.clone())
381            .collect()
382    }
383
384    fn ensure_selection(&mut self) -> bool {
385        if self.suggestions.is_empty() {
386            if self.navigator.selected().is_some() {
387                self.navigator.set_item_count(0);
388                return true;
389            }
390            return false;
391        }
392
393        let previous = self.navigator.selected();
394        self.navigator.set_item_count(self.suggestions.len());
395
396        if self.navigator.selected().is_none() {
397            return self.navigator.select_first();
398        }
399
400        self.navigator.selected() != previous
401    }
402
403    #[cfg(test)]
404    fn highlight_name_segments_static(&self, name: &str) -> Vec<SlashPaletteHighlightSegment> {
405        let Some(query) = self.filter_query.as_ref().filter(|query| !query.is_empty()) else {
406            return vec![SlashPaletteHighlightSegment::plain(name.to_owned())];
407        };
408
409        // For static commands, only use the part after the prompt invocation prefix if applicable
410        let lowercase = name.to_ascii_lowercase();
411        if !lowercase.starts_with(query) {
412            return vec![SlashPaletteHighlightSegment::plain(name.to_owned())];
413        }
414
415        let query_len = query.chars().count();
416        let mut highlighted = String::new();
417        let mut remainder = String::new();
418
419        for (index, ch) in name.chars().enumerate() {
420            if index < query_len {
421                highlighted.push(ch);
422            } else {
423                remainder.push(ch);
424            }
425        }
426
427        let mut segments = Vec::new();
428        if !highlighted.is_empty() {
429            segments.push(SlashPaletteHighlightSegment::highlighted(highlighted));
430        }
431        if !remainder.is_empty() {
432            segments.push(SlashPaletteHighlightSegment::plain(remainder));
433        }
434        if segments.is_empty() {
435            segments.push(SlashPaletteHighlightSegment::plain(String::new()));
436        }
437        segments
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    fn test_commands() -> Vec<SlashCommandItem> {
446        vec![
447            SlashCommandItem::new("command", "Run a terminal command"),
448            SlashCommandItem::new("config", "Show effective configuration"),
449            SlashCommandItem::new("clear", "Clear screen"),
450            SlashCommandItem::new("new", "Start new session"),
451            SlashCommandItem::new("status", "Show status"),
452            SlashCommandItem::new("help", "Show help"),
453            SlashCommandItem::new("theme", "Switch theme"),
454            SlashCommandItem::new("mode", "Switch mode"),
455        ]
456    }
457
458    fn palette_with_commands() -> SlashPalette {
459        let mut palette = SlashPalette::with_commands(test_commands());
460        let _ = palette.update(Some(""));
461        palette
462    }
463
464    #[test]
465    fn update_applies_prefix_and_highlights_matches() {
466        let mut palette = SlashPalette::with_commands(test_commands());
467
468        let update = palette.update(Some("co"));
469        assert!(matches!(
470            update,
471            SlashPaletteUpdate::Changed {
472                suggestions_changed: true,
473                selection_changed: true
474            }
475        ));
476
477        let items = palette.items();
478        assert!(!items.is_empty());
479        let command = items
480            .into_iter()
481            .find(|item| {
482                item.command
483                    .as_ref()
484                    .is_some_and(|cmd| cmd.name == "command")
485            })
486            .expect("command suggestion available");
487
488        assert_eq!(command.name_segments.len(), 2);
489        assert!(command.name_segments[0].highlighted);
490        assert_eq!(command.name_segments[0].content, "co");
491        assert_eq!(command.name_segments[1].content, "mmand");
492    }
493
494    #[test]
495    fn update_matches_fuzzy_command_name() {
496        let mut palette = SlashPalette::with_commands(test_commands());
497
498        let update = palette.update(Some("sts"));
499        assert!(matches!(update, SlashPaletteUpdate::Changed { .. }));
500
501        let names: Vec<String> = palette
502            .items()
503            .into_iter()
504            .filter_map(|item| item.command.map(|command| command.name))
505            .collect();
506
507        assert_eq!(names.first().map(String::as_str), Some("status"));
508    }
509
510    #[test]
511    fn update_matches_command_description() {
512        let mut palette = SlashPalette::with_commands(test_commands());
513
514        let update = palette.update(Some("terminal"));
515        assert!(matches!(update, SlashPaletteUpdate::Changed { .. }));
516
517        let names: Vec<String> = palette
518            .items()
519            .into_iter()
520            .filter_map(|item| item.command.map(|command| command.name))
521            .collect();
522
523        assert_eq!(names.first().map(String::as_str), Some("command"));
524    }
525
526    #[test]
527    fn update_without_matches_resets_highlights() {
528        let mut palette = SlashPalette::with_commands(test_commands());
529        let _ = palette.update(Some("co"));
530        assert!(!palette.items().is_empty());
531
532        let update = palette.update(Some("zzz"));
533        assert!(matches!(update, SlashPaletteUpdate::Changed { .. }));
534        assert!(palette.items().is_empty());
535
536        for item in palette.items() {
537            assert!(
538                item.name_segments
539                    .iter()
540                    .all(|segment| !segment.highlighted)
541            );
542        }
543    }
544
545    #[test]
546    fn navigation_wraps_between_items() {
547        let mut palette = palette_with_commands();
548
549        assert!(palette.move_down());
550        let first = palette.selected_index();
551        assert_eq!(first, Some(1));
552
553        let steps = palette.suggestions.len().saturating_sub(1);
554        for _ in 0..steps {
555            assert!(palette.move_down());
556        }
557        assert_eq!(palette.selected_index(), Some(0));
558
559        assert!(palette.move_up());
560        assert_eq!(
561            palette.selected_index(),
562            Some(palette.suggestions.len() - 1)
563        );
564    }
565
566    #[test]
567    fn boundary_shortcuts_jump_to_expected_items() {
568        let mut palette = palette_with_commands();
569
570        assert!(palette.select_last());
571        assert_eq!(
572            palette.selected_index(),
573            Some(palette.suggestions.len() - 1)
574        );
575
576        assert!(palette.select_first());
577        assert_eq!(palette.selected_index(), Some(0));
578    }
579
580    #[test]
581    fn page_navigation_advances_by_visible_rows() {
582        let mut palette = palette_with_commands();
583        palette.set_visible_rows(3);
584
585        assert!(palette.page_down());
586        assert_eq!(palette.selected_index(), Some(3));
587
588        assert!(palette.page_down());
589        assert_eq!(palette.selected_index(), Some(6));
590
591        assert!(palette.page_up());
592        assert_eq!(palette.selected_index(), Some(3));
593
594        assert!(palette.page_up());
595        assert_eq!(palette.selected_index(), Some(0));
596    }
597
598    #[test]
599    fn clear_resets_state() {
600        let mut palette = SlashPalette::with_commands(test_commands());
601        let _ = palette.update(Some("co"));
602        palette.set_visible_rows(3);
603
604        assert!(palette.clear());
605        assert!(palette.suggestions().is_empty());
606        assert_eq!(palette.selected_index(), None);
607        assert_eq!(palette.visible_rows(), 0);
608    }
609
610    #[test]
611    fn update_keeps_all_filtered_matches_scrollable() {
612        let commands = (0..60)
613            .map(|index| {
614                SlashCommandItem::new(
615                    format!("command-{index:02}"),
616                    "Run a terminal command".to_string(),
617                )
618            })
619            .collect();
620        let mut palette = SlashPalette::with_commands(commands);
621
622        let update = palette.update(Some("command"));
623        assert!(matches!(update, SlashPaletteUpdate::Changed { .. }));
624        assert_eq!(palette.suggestions().len(), 60);
625
626        palette.set_visible_rows(10);
627        for _ in 0..5 {
628            assert!(palette.page_down());
629        }
630
631        assert_eq!(palette.selected_index(), Some(50));
632        assert_eq!(
633            palette
634                .selected_command()
635                .map(|command| command.name.as_str()),
636            Some("command-50")
637        );
638    }
639
640    #[test]
641    fn command_range_tracks_latest_slash_before_cursor() {
642        let input = "/one two /three";
643        let cursor = input.len();
644        let range = command_range(input, cursor).expect("range available");
645        assert_eq!(range.start, 9);
646        assert_eq!(range.end, input.len());
647    }
648
649    #[test]
650    fn command_range_stops_at_whitespace() {
651        let input = "/cmd arg";
652        let cursor = input.len();
653        // Previous behavior: returned Some(0..4) (last range)
654        // New behavior: returns None (active range interrupted by space)
655        assert!(command_range(input, cursor).is_none());
656    }
657
658    #[test]
659    fn command_prefix_includes_partial_match() {
660        let input = "/hel";
661        let prefix = command_prefix(input, input.len()).expect("prefix available");
662        assert_eq!(prefix, "hel");
663    }
664
665    #[test]
666    fn command_prefix_is_empty_when_cursor_immediately_after_slash() {
667        let input = "/";
668        let prefix = command_prefix(input, 1).expect("prefix available");
669        assert!(prefix.is_empty());
670    }
671
672    #[test]
673    fn command_prefix_returns_none_when_not_in_command() {
674        let input = "say hello";
675        assert!(command_prefix(input, input.len()).is_none());
676    }
677}