Skip to main content

vtcode_ui/tui/core_tui/app/session/
slash.rs

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