Skip to main content

vtcode_tui/core_tui/session/
slash.rs

1use ratatui::crossterm::event::{KeyCode, KeyEvent};
2use ratatui::{prelude::*, widgets::Clear};
3
4use crate::config::constants::ui;
5use crate::ui::search::fuzzy_score;
6
7use super::super::types::InlineTextStyle;
8use super::inline_list::{InlineListRow, selection_padding};
9use super::list_panel::{
10    SharedListPanelSections, SharedListPanelStyles, StaticRowsListPanelModel, fixed_section_rows,
11    render_shared_list_panel, rows_to_u16, split_bottom_list_panel,
12};
13use super::{
14    Session, ratatui_color_from_ansi, ratatui_style_from_inline,
15    slash_palette::{self, SlashPaletteUpdate, command_prefix, command_range},
16};
17
18pub(crate) fn split_inline_slash_area(session: &mut Session, area: Rect) -> (Rect, Option<Rect>) {
19    if area.height == 0
20        || area.width == 0
21        || session.modal.is_some()
22        || !session.inline_lists_visible()
23        || session.file_palette_active
24        || session.history_picker_state.active
25    {
26        session.slash_palette.clear_visible_rows();
27        return (area, None);
28    }
29
30    let suggestions = session.slash_palette.suggestions();
31    if suggestions.is_empty() {
32        session.slash_palette.clear_visible_rows();
33        return (area, None);
34    }
35
36    let info_rows = slash_palette_instructions(session).len();
37    let has_search_row = command_prefix(
38        session.input_manager.content(),
39        session.input_manager.cursor(),
40    )
41    .is_some();
42    let fixed_rows = fixed_section_rows(1, info_rows, has_search_row);
43    let desired_list_rows = rows_to_u16(suggestions.len().min(ui::INLINE_LIST_MAX_ROWS));
44    let (transcript_area, panel_area) =
45        split_bottom_list_panel(area, fixed_rows, desired_list_rows);
46    if panel_area.is_none() {
47        session.slash_palette.clear_visible_rows();
48        return (transcript_area, None);
49    }
50    (transcript_area, panel_area)
51}
52
53pub fn render_slash_palette(session: &mut Session, frame: &mut Frame<'_>, area: Rect) {
54    if area.height == 0
55        || area.width == 0
56        || session.modal.is_some()
57        || !session.inline_lists_visible()
58    {
59        session.slash_palette.clear_visible_rows();
60        return;
61    }
62    let suggestions = session.slash_palette.suggestions();
63    if suggestions.is_empty() {
64        session.slash_palette.clear_visible_rows();
65        return;
66    }
67
68    frame.render_widget(Clear, area);
69
70    let rows = slash_rows(session);
71    let item_count = rows.len();
72    let default_style = session.styles.default_style();
73    let highlight_style = slash_highlight_style(session);
74    let name_style = slash_name_style(session);
75    let description_style = slash_description_style(session);
76    let prefix = selection_padding();
77
78    let rendered_rows = rows
79        .into_iter()
80        .map(|row| {
81            (
82                InlineListRow::single(
83                    Line::from(vec![
84                        Span::styled(prefix.clone(), default_style),
85                        Span::styled(format!("/{}", row.name), name_style),
86                        Span::raw(" "),
87                        Span::styled(row.description, description_style),
88                    ]),
89                    default_style,
90                ),
91                1_u16,
92            )
93        })
94        .collect::<Vec<_>>();
95
96    let (selected, offset) = {
97        let list_state = session.slash_palette.list_state_mut();
98        (
99            list_state.selected().filter(|index| *index < item_count),
100            list_state.offset(),
101        )
102    };
103    let search_line = command_prefix(
104        session.input_manager.content(),
105        session.input_manager.cursor(),
106    )
107    .map(|prefix| {
108        let filter = prefix.trim_start_matches('/');
109        if filter.is_empty() {
110            "Filter: (type to narrow commands)".to_owned()
111        } else {
112            format!("Filter: {}", filter)
113        }
114    });
115    let sections = SharedListPanelSections {
116        header: vec![Line::from(Span::styled(
117            "Slash Commands".to_owned(),
118            default_style,
119        ))],
120        info: slash_palette_instructions(session),
121        search: search_line.map(|value| Line::from(Span::styled(value, default_style))),
122    };
123    let mut model = StaticRowsListPanelModel {
124        rows: rendered_rows,
125        selected,
126        offset,
127        visible_rows: 0,
128    };
129    render_shared_list_panel(
130        frame,
131        area,
132        sections,
133        SharedListPanelStyles {
134            base_style: default_style,
135            selected_style: Some(highlight_style),
136            text_style: default_style,
137        },
138        &mut model,
139    );
140
141    session
142        .slash_palette
143        .set_visible_rows(model.visible_rows.min(ui::INLINE_LIST_MAX_ROWS));
144
145    let list_state = session.slash_palette.list_state_mut();
146    list_state.select(model.selected);
147    *list_state.offset_mut() = model.offset;
148}
149
150fn slash_palette_instructions(session: &Session) -> Vec<Line<'static>> {
151    vec![Line::from(Span::styled(
152        "Navigation: ↑/↓ select • Enter apply • Esc dismiss".to_owned(),
153        session.styles.default_style(),
154    ))]
155}
156
157pub(super) fn handle_slash_palette_change(session: &mut Session) {
158    crate::ui::tui::session::render::recalculate_transcript_rows(session);
159    session.enforce_scroll_bounds();
160    session.mark_dirty();
161}
162
163pub(super) fn clear_slash_suggestions(session: &mut Session) {
164    if session.slash_palette.clear() {
165        handle_slash_palette_change(session);
166    }
167}
168
169pub(super) fn update_slash_suggestions(session: &mut Session) {
170    if !session.input_enabled {
171        clear_slash_suggestions(session);
172        return;
173    }
174
175    let Some(prefix) = command_prefix(
176        session.input_manager.content(),
177        session.input_manager.cursor(),
178    ) else {
179        clear_slash_suggestions(session);
180        return;
181    };
182    session.ensure_inline_lists_visible_for_trigger();
183
184    match session
185        .slash_palette
186        .update(Some(&prefix), ui::SLASH_SUGGESTION_LIMIT)
187    {
188        SlashPaletteUpdate::NoChange => {}
189        SlashPaletteUpdate::Cleared | SlashPaletteUpdate::Changed { .. } => {
190            handle_slash_palette_change(session);
191        }
192    }
193}
194
195pub(crate) fn slash_navigation_available(session: &Session) -> bool {
196    let has_prefix = command_prefix(
197        session.input_manager.content(),
198        session.input_manager.cursor(),
199    )
200    .is_some();
201    session.input_enabled
202        && session.inline_lists_visible()
203        && !session.slash_palette.is_empty()
204        && has_prefix
205        && session.modal.is_none()
206        && !session.file_palette_active
207        && !session.history_picker_state.active
208}
209
210pub(super) fn move_slash_selection_up(session: &mut Session) -> bool {
211    let changed = session.slash_palette.move_up();
212    handle_slash_selection_change(session, changed)
213}
214
215pub(super) fn move_slash_selection_down(session: &mut Session) -> bool {
216    let changed = session.slash_palette.move_down();
217    handle_slash_selection_change(session, changed)
218}
219
220pub(super) fn select_first_slash_suggestion(session: &mut Session) -> bool {
221    let changed = session.slash_palette.select_first();
222    handle_slash_selection_change(session, changed)
223}
224
225pub(super) fn select_last_slash_suggestion(session: &mut Session) -> bool {
226    let changed = session.slash_palette.select_last();
227    handle_slash_selection_change(session, changed)
228}
229
230pub(super) fn page_up_slash_suggestion(session: &mut Session) -> bool {
231    let changed = session.slash_palette.page_up();
232    handle_slash_selection_change(session, changed)
233}
234
235pub(super) fn page_down_slash_suggestion(session: &mut Session) -> bool {
236    let changed = session.slash_palette.page_down();
237    handle_slash_selection_change(session, changed)
238}
239
240pub(super) fn handle_slash_selection_change(session: &mut Session, changed: bool) -> bool {
241    if changed {
242        preview_selected_slash_suggestion(session);
243        crate::ui::tui::session::render::recalculate_transcript_rows(session);
244        session.enforce_scroll_bounds();
245        session.mark_dirty();
246        true
247    } else {
248        false
249    }
250}
251
252fn preview_selected_slash_suggestion(session: &mut Session) {
253    let Some(command) = session.slash_palette.selected_command() else {
254        return;
255    };
256    let Some(range) = command_range(
257        session.input_manager.content(),
258        session.input_manager.cursor(),
259    ) else {
260        return;
261    };
262
263    let current_input = session.input_manager.content().to_owned();
264    let prefix = &current_input[..range.start];
265    let suffix = &current_input[range.end..];
266
267    let mut new_input = String::new();
268    new_input.push_str(prefix);
269    new_input.push('/');
270    new_input.push_str(command.name.as_str());
271    let cursor_position = new_input.len();
272
273    if !suffix.is_empty() {
274        if !suffix.chars().next().is_some_and(char::is_whitespace) {
275            new_input.push(' ');
276        }
277        new_input.push_str(suffix);
278    }
279
280    session.input_manager.set_content(new_input.clone());
281    session
282        .input_manager
283        .set_cursor(cursor_position.min(new_input.len()));
284    session.mark_dirty();
285}
286
287pub(super) fn apply_selected_slash_suggestion(session: &mut Session) -> bool {
288    let Some(command) = session.slash_palette.selected_command() else {
289        return false;
290    };
291
292    let command_name = command.name.to_owned();
293
294    let input_content = session.input_manager.content();
295    let cursor_pos = session.input_manager.cursor();
296    let Some(range) = command_range(input_content, cursor_pos) else {
297        return false;
298    };
299
300    let suffix = input_content[range.end..].to_owned();
301    let mut new_input = format!("/{}", command_name);
302
303    let cursor_position = if suffix.is_empty() {
304        new_input.push(' ');
305        new_input.len()
306    } else {
307        if !suffix.chars().next().is_some_and(char::is_whitespace) {
308            new_input.push(' ');
309        }
310        let position = new_input.len();
311        new_input.push_str(&suffix);
312        position
313    };
314
315    session.input_manager.set_content(new_input);
316    session.input_manager.set_cursor(cursor_position);
317
318    clear_slash_suggestions(session);
319    session.mark_dirty();
320
321    true
322}
323
324pub(super) fn autocomplete_slash_suggestion(session: &mut Session) -> bool {
325    let input_content = session.input_manager.content();
326    let cursor_pos = session.input_manager.cursor();
327
328    let Some(range) = command_range(input_content, cursor_pos) else {
329        return false;
330    };
331
332    let prefix_text = command_prefix(input_content, cursor_pos).unwrap_or_default();
333
334    if prefix_text.is_empty() {
335        return false;
336    }
337
338    let suggestions = session.slash_palette.suggestions();
339    if suggestions.is_empty() {
340        return false;
341    }
342
343    // Find the best fuzzy match from all suggestions
344    let mut best_match: Option<(usize, u32, String)> = None;
345
346    for (idx, suggestion) in suggestions.iter().enumerate() {
347        let command_name = match suggestion {
348            slash_palette::SlashPaletteSuggestion::Static(cmd) => cmd.name.to_string(),
349        };
350
351        if let Some(score) = fuzzy_score(&prefix_text, &command_name) {
352            if let Some((_, best_score, _)) = &best_match {
353                if score > *best_score {
354                    best_match = Some((idx, score, command_name));
355                }
356            } else {
357                best_match = Some((idx, score, command_name));
358            }
359        }
360    }
361
362    let Some((_, _, best_command)) = best_match else {
363        return false;
364    };
365
366    // Handle static command
367    let suffix = &input_content[range.end..];
368    let mut new_input = format!("/{}", best_command);
369
370    let cursor_position = if suffix.is_empty() {
371        new_input.push(' ');
372        new_input.len()
373    } else {
374        if !suffix.chars().next().is_some_and(char::is_whitespace) {
375            new_input.push(' ');
376        }
377        let position = new_input.len();
378        new_input.push_str(suffix);
379        position
380    };
381
382    session.input_manager.set_content(new_input);
383    session.input_manager.set_cursor(cursor_position);
384
385    clear_slash_suggestions(session);
386    session.mark_dirty();
387    true
388}
389
390pub(super) fn try_handle_slash_navigation(
391    session: &mut Session,
392    key: &KeyEvent,
393    has_control: bool,
394    has_alt: bool,
395    has_command: bool,
396) -> bool {
397    if !slash_navigation_available(session) {
398        return false;
399    }
400
401    // Block Control modifier
402    if has_control {
403        return false;
404    }
405
406    // Block Alt unless combined with Command for Up/Down navigation
407    if has_alt && !matches!(key.code, KeyCode::Up | KeyCode::Down) {
408        return false;
409    }
410
411    let handled = match key.code {
412        KeyCode::Up => {
413            if has_alt && !has_command {
414                return false;
415            }
416            if has_command {
417                select_first_slash_suggestion(session)
418            } else {
419                move_slash_selection_up(session)
420            }
421        }
422        KeyCode::Down => {
423            if has_alt && !has_command {
424                return false;
425            }
426            if has_command {
427                select_last_slash_suggestion(session)
428            } else {
429                move_slash_selection_down(session)
430            }
431        }
432        KeyCode::PageUp => page_up_slash_suggestion(session),
433        KeyCode::PageDown => page_down_slash_suggestion(session),
434        KeyCode::Tab => autocomplete_slash_suggestion(session),
435        KeyCode::BackTab => move_slash_selection_up(session),
436        KeyCode::Enter => {
437            let applied = apply_selected_slash_suggestion(session);
438            if !applied {
439                return false;
440            }
441
442            let should_submit_now = should_submit_immediately_from_palette(session);
443
444            if should_submit_now {
445                return false;
446            }
447
448            true
449        }
450        _ => return false,
451    };
452
453    if handled {
454        session.mark_dirty();
455    }
456
457    handled
458}
459
460fn should_submit_immediately_from_palette(session: &Session) -> bool {
461    let Some(command) = session.input_manager.content().split_whitespace().next() else {
462        return false;
463    };
464
465    matches!(
466        command,
467        "/files"
468            | "/status"
469            | "/doctor"
470            | "/model"
471            | "/mcp"
472            | "/skills"
473            | "/new"
474            | "/git"
475            | "/docs"
476            | "/copy"
477            | "/help"
478            | "/clear"
479            | "/history"
480            | "/exit"
481    )
482}
483
484#[derive(Clone)]
485struct SlashRow {
486    name: String,
487    description: String,
488}
489
490fn slash_rows(session: &Session) -> Vec<SlashRow> {
491    session
492        .slash_palette
493        .suggestions()
494        .iter()
495        .map(|suggestion| match suggestion {
496            slash_palette::SlashPaletteSuggestion::Static(command) => SlashRow {
497                name: command.name.to_owned(),
498                description: command.description.to_owned(),
499            },
500        })
501        .collect()
502}
503
504fn slash_highlight_style(session: &Session) -> Style {
505    let mut style = Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD);
506    if let Some(primary) = session.theme.primary.or(session.theme.secondary) {
507        style = style.fg(ratatui_color_from_ansi(primary));
508    }
509    style
510}
511
512fn slash_name_style(session: &Session) -> Style {
513    let style = InlineTextStyle::default()
514        .bold()
515        .with_color(session.theme.primary.or(session.theme.foreground));
516    ratatui_style_from_inline(&style, session.theme.foreground)
517}
518
519fn slash_description_style(session: &Session) -> Style {
520    session.styles.default_style().add_modifier(Modifier::DIM)
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526    use crate::ui::tui::InlineTheme;
527
528    #[test]
529    fn immediate_submit_matcher_accepts_immediate_commands() {
530        let mut session = Session::new(InlineTheme::default(), None, 20);
531        session.set_input("/files".to_string());
532        assert!(should_submit_immediately_from_palette(&session));
533
534        session.set_input("   /status   ".to_string());
535        assert!(should_submit_immediately_from_palette(&session));
536
537        session.set_input("/history".to_string());
538        assert!(should_submit_immediately_from_palette(&session));
539
540        session.set_input("/mcp".to_string());
541        assert!(should_submit_immediately_from_palette(&session));
542
543        session.set_input("/skills".to_string());
544        assert!(should_submit_immediately_from_palette(&session));
545    }
546
547    #[test]
548    fn immediate_submit_matcher_rejects_argument_driven_commands() {
549        let mut session = Session::new(InlineTheme::default(), None, 20);
550        session.set_input("/command echo hello".to_string());
551        assert!(!should_submit_immediately_from_palette(&session));
552
553        session.set_input("/add-dir ~/tmp".to_string());
554        assert!(!should_submit_immediately_from_palette(&session));
555    }
556
557    #[test]
558    fn slash_palette_instructions_hide_filter_hint_row() {
559        let session = Session::new(InlineTheme::default(), None, 20);
560        let instructions = slash_palette_instructions(&session);
561
562        assert_eq!(instructions.len(), 1);
563        let text: String = instructions[0]
564            .spans
565            .iter()
566            .map(|span| span.content.clone().into_owned())
567            .collect();
568        assert!(text.contains("Navigation:"));
569        assert!(!text.contains("Type to filter slash commands"));
570    }
571}