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