Skip to main content

vtcode_tui/core_tui/app/session/
slash.rs

1use ratatui::crossterm::event::{KeyCode, KeyEvent};
2use ratatui::{prelude::*, widgets::Clear};
3
4use crate::config::constants::ui;
5use crate::core_tui::app::session::transient::TransientSurface;
6use crate::core_tui::session::inline_list::{InlineListRow, selection_padding};
7use crate::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::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    matches!(
488        command,
489        "/files"
490            | "/ide"
491            | "/mode"
492            | "/status"
493            | "/stop"
494            | "/pause"
495            | "/doctor"
496            | "/model"
497            | "/mcp"
498            | "/skills"
499            | "/new"
500            | "/review"
501            | "/git"
502            | "/docs"
503            | "/copy"
504            | "/help"
505            | "/clear"
506            | "/compact"
507            | "/login"
508            | "/logout"
509            | "/auth"
510            | "/refresh-oauth"
511            | "/resume"
512            | "/fork"
513            | "/history"
514            | "/exit"
515    )
516}
517
518#[derive(Clone)]
519struct SlashRow {
520    name: String,
521    description: String,
522}
523
524fn slash_rows(session: &Session) -> Vec<SlashRow> {
525    session
526        .slash_palette
527        .suggestions()
528        .iter()
529        .map(|suggestion| match suggestion {
530            slash_palette::SlashPaletteSuggestion::Static(command) => SlashRow {
531                name: command.name.to_owned(),
532                description: command.description.to_owned(),
533            },
534        })
535        .collect()
536}
537
538fn slash_highlight_style(session: &Session) -> Style {
539    let mut style = Style::default().add_modifier(Modifier::BOLD);
540    if let Some(primary) = session.core.theme.primary.or(session.core.theme.secondary) {
541        style = style.fg(ratatui_color_from_ansi(primary));
542    }
543    style
544}
545
546fn slash_name_style(session: &Session) -> Style {
547    let style = InlineTextStyle::default()
548        .bold()
549        .with_color(session.core.theme.primary.or(session.core.theme.foreground));
550    ratatui_style_from_inline(&style, session.core.theme.foreground)
551}
552
553fn slash_description_style(session: &Session) -> Style {
554    session
555        .core
556        .styles
557        .default_style()
558        .add_modifier(Modifier::DIM)
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use crate::ui::tui::InlineTheme;
565
566    #[test]
567    fn immediate_submit_matcher_accepts_immediate_commands() {
568        let mut session = Session::new(InlineTheme::default(), None, 20);
569        session.set_input("/files".to_string());
570        assert!(should_submit_immediately_from_palette(&session));
571
572        session.set_input("/ide".to_string());
573        assert!(should_submit_immediately_from_palette(&session));
574
575        session.set_input("   /status   ".to_string());
576        assert!(should_submit_immediately_from_palette(&session));
577
578        session.set_input("/mode".to_string());
579        assert!(should_submit_immediately_from_palette(&session));
580
581        session.set_input("/history".to_string());
582        assert!(should_submit_immediately_from_palette(&session));
583
584        session.set_input("/mcp".to_string());
585        assert!(should_submit_immediately_from_palette(&session));
586
587        session.set_input("/skills".to_string());
588        assert!(should_submit_immediately_from_palette(&session));
589
590        session.set_input("/review".to_string());
591        assert!(should_submit_immediately_from_palette(&session));
592
593        session.set_input("/resume".to_string());
594        assert!(should_submit_immediately_from_palette(&session));
595
596        session.set_input("/fork".to_string());
597        assert!(should_submit_immediately_from_palette(&session));
598
599        session.set_input("/stop".to_string());
600        assert!(should_submit_immediately_from_palette(&session));
601
602        session.set_input("/pause".to_string());
603        assert!(should_submit_immediately_from_palette(&session));
604
605        session.set_input("/login".to_string());
606        assert!(should_submit_immediately_from_palette(&session));
607
608        session.set_input("/logout".to_string());
609        assert!(should_submit_immediately_from_palette(&session));
610
611        session.set_input("/auth".to_string());
612        assert!(should_submit_immediately_from_palette(&session));
613
614        session.set_input("/refresh-oauth".to_string());
615        assert!(should_submit_immediately_from_palette(&session));
616
617        session.set_input("/compact".to_string());
618        assert!(should_submit_immediately_from_palette(&session));
619    }
620
621    #[test]
622    fn immediate_submit_matcher_rejects_argument_driven_commands() {
623        let mut session = Session::new(InlineTheme::default(), None, 20);
624        session.set_input("/command echo hello".to_string());
625        assert!(!should_submit_immediately_from_palette(&session));
626
627        session.set_input("/review-template src/lib.rs".to_string());
628        assert!(!should_submit_immediately_from_palette(&session));
629    }
630
631    #[test]
632    fn slash_palette_instructions_hide_filter_hint_row() {
633        let session = Session::new(InlineTheme::default(), None, 20);
634        let instructions = slash_palette_instructions(&session);
635
636        assert_eq!(instructions.len(), 1);
637        let text: String = instructions[0]
638            .spans
639            .iter()
640            .map(|span| span.content.clone().into_owned())
641            .collect();
642        assert!(text.contains("Navigation:"));
643        assert!(!text.contains("Type to filter slash commands"));
644    }
645}