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, SharedSearchField, StaticRowsListPanelModel,
10    fixed_section_rows, 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.has_active_overlay()
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.has_active_overlay()
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        SharedSearchField {
109            label: "Search commands".to_owned(),
110            placeholder: Some("command name or description".to_owned()),
111            query: filter.to_owned(),
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,
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.has_active_overlay()
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
251pub(super) fn select_slash_suggestion_index(session: &mut Session, index: usize) -> bool {
252    let changed = session.slash_palette.select_index(index);
253    handle_slash_selection_change(session, changed)
254}
255
256fn preview_selected_slash_suggestion(session: &mut Session) {
257    let Some(command) = session.slash_palette.selected_command() else {
258        return;
259    };
260    let Some(range) = command_range(
261        session.input_manager.content(),
262        session.input_manager.cursor(),
263    ) else {
264        return;
265    };
266
267    let current_input = session.input_manager.content().to_owned();
268    let prefix = &current_input[..range.start];
269    let suffix = &current_input[range.end..];
270
271    let mut new_input = String::new();
272    new_input.push_str(prefix);
273    new_input.push('/');
274    new_input.push_str(command.name.as_str());
275    let cursor_position = new_input.len();
276
277    if !suffix.is_empty() {
278        if !suffix.chars().next().is_some_and(char::is_whitespace) {
279            new_input.push(' ');
280        }
281        new_input.push_str(suffix);
282    }
283
284    session.input_manager.set_content(new_input.clone());
285    session
286        .input_manager
287        .set_cursor(cursor_position.min(new_input.len()));
288    session.mark_dirty();
289}
290
291pub(super) fn apply_selected_slash_suggestion(session: &mut Session) -> bool {
292    let Some(command) = session.slash_palette.selected_command() else {
293        return false;
294    };
295
296    let command_name = command.name.to_owned();
297
298    let input_content = session.input_manager.content();
299    let cursor_pos = session.input_manager.cursor();
300    let Some(range) = command_range(input_content, cursor_pos) else {
301        return false;
302    };
303
304    let suffix = input_content[range.end..].to_owned();
305    let mut new_input = format!("/{}", command_name);
306
307    let cursor_position = if suffix.is_empty() {
308        new_input.push(' ');
309        new_input.len()
310    } else {
311        if !suffix.chars().next().is_some_and(char::is_whitespace) {
312            new_input.push(' ');
313        }
314        let position = new_input.len();
315        new_input.push_str(&suffix);
316        position
317    };
318
319    session.input_manager.set_content(new_input);
320    session.input_manager.set_cursor(cursor_position);
321
322    clear_slash_suggestions(session);
323    session.mark_dirty();
324
325    true
326}
327
328pub(super) fn autocomplete_slash_suggestion(session: &mut Session) -> bool {
329    let input_content = session.input_manager.content();
330    let cursor_pos = session.input_manager.cursor();
331
332    let Some(range) = command_range(input_content, cursor_pos) else {
333        return false;
334    };
335
336    let prefix_text = command_prefix(input_content, cursor_pos).unwrap_or_default();
337    if prefix_text.is_empty() {
338        return false;
339    }
340
341    let suggestions = session.slash_palette.suggestions();
342    if suggestions.is_empty() {
343        return false;
344    }
345
346    // suggestions() is already ranked by slash_palette fuzzy scoring.
347    let Some(best_command) = suggestions.first().map(|suggestion| match suggestion {
348        slash_palette::SlashPaletteSuggestion::Static(command) => command.name.as_str(),
349    }) else {
350        return false;
351    };
352
353    // Handle static command
354    let suffix = &input_content[range.end..];
355    let mut new_input = format!("/{}", best_command);
356
357    let cursor_position = if suffix.is_empty() {
358        new_input.push(' ');
359        new_input.len()
360    } else {
361        if !suffix.chars().next().is_some_and(char::is_whitespace) {
362            new_input.push(' ');
363        }
364        let position = new_input.len();
365        new_input.push_str(suffix);
366        position
367    };
368
369    session.input_manager.set_content(new_input);
370    session.input_manager.set_cursor(cursor_position);
371
372    clear_slash_suggestions(session);
373    session.mark_dirty();
374    true
375}
376
377pub(super) fn try_handle_slash_navigation(
378    session: &mut Session,
379    key: &KeyEvent,
380    has_control: bool,
381    has_alt: bool,
382    has_command: bool,
383) -> bool {
384    if !slash_navigation_available(session) {
385        return false;
386    }
387
388    // Block Control modifier
389    if has_control {
390        return false;
391    }
392
393    // Block Alt unless combined with Command for Up/Down navigation
394    if has_alt && !matches!(key.code, KeyCode::Up | KeyCode::Down) {
395        return false;
396    }
397
398    let handled = match key.code {
399        KeyCode::Up => {
400            if has_alt && !has_command {
401                return false;
402            }
403            if has_command {
404                select_first_slash_suggestion(session)
405            } else {
406                move_slash_selection_up(session)
407            }
408        }
409        KeyCode::Down => {
410            if has_alt && !has_command {
411                return false;
412            }
413            if has_command {
414                select_last_slash_suggestion(session)
415            } else {
416                move_slash_selection_down(session)
417            }
418        }
419        KeyCode::PageUp => page_up_slash_suggestion(session),
420        KeyCode::PageDown => page_down_slash_suggestion(session),
421        KeyCode::Tab => autocomplete_slash_suggestion(session),
422        KeyCode::BackTab => move_slash_selection_up(session),
423        KeyCode::Enter => {
424            let applied = apply_selected_slash_suggestion(session);
425            if !applied {
426                return false;
427            }
428
429            let should_submit_now = should_submit_immediately_from_palette(session);
430
431            if should_submit_now {
432                return false;
433            }
434
435            true
436        }
437        _ => return false,
438    };
439
440    if handled {
441        session.mark_dirty();
442    }
443
444    handled
445}
446
447pub(crate) fn should_submit_immediately_from_palette(session: &Session) -> bool {
448    let Some(command) = session.input_manager.content().split_whitespace().next() else {
449        return false;
450    };
451
452    matches!(
453        command,
454        "/files"
455            | "/ide"
456            | "/status"
457            | "/stop"
458            | "/pause"
459            | "/doctor"
460            | "/model"
461            | "/mcp"
462            | "/skills"
463            | "/new"
464            | "/review"
465            | "/git"
466            | "/docs"
467            | "/copy"
468            | "/help"
469            | "/clear"
470            | "/login"
471            | "/logout"
472            | "/auth"
473            | "/refresh-oauth"
474            | "/resume"
475            | "/fork"
476            | "/history"
477            | "/exit"
478    )
479}
480
481#[derive(Clone)]
482struct SlashRow {
483    name: String,
484    description: String,
485}
486
487fn slash_rows(session: &Session) -> Vec<SlashRow> {
488    session
489        .slash_palette
490        .suggestions()
491        .iter()
492        .map(|suggestion| match suggestion {
493            slash_palette::SlashPaletteSuggestion::Static(command) => SlashRow {
494                name: command.name.to_owned(),
495                description: command.description.to_owned(),
496            },
497        })
498        .collect()
499}
500
501fn slash_highlight_style(session: &Session) -> Style {
502    let mut style = Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD);
503    if let Some(primary) = session.theme.primary.or(session.theme.secondary) {
504        style = style.fg(ratatui_color_from_ansi(primary));
505    }
506    style
507}
508
509fn slash_name_style(session: &Session) -> Style {
510    let style = InlineTextStyle::default()
511        .bold()
512        .with_color(session.theme.primary.or(session.theme.foreground));
513    ratatui_style_from_inline(&style, session.theme.foreground)
514}
515
516fn slash_description_style(session: &Session) -> Style {
517    session.styles.default_style().add_modifier(Modifier::DIM)
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use crate::ui::tui::InlineTheme;
524
525    #[test]
526    fn immediate_submit_matcher_accepts_immediate_commands() {
527        let mut session = Session::new(InlineTheme::default(), None, 20);
528        session.set_input("/files".to_string());
529        assert!(should_submit_immediately_from_palette(&session));
530
531        session.set_input("/ide".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        session.set_input("/review".to_string());
547        assert!(should_submit_immediately_from_palette(&session));
548
549        session.set_input("/resume".to_string());
550        assert!(should_submit_immediately_from_palette(&session));
551
552        session.set_input("/fork".to_string());
553        assert!(should_submit_immediately_from_palette(&session));
554
555        session.set_input("/stop".to_string());
556        assert!(should_submit_immediately_from_palette(&session));
557
558        session.set_input("/pause".to_string());
559        assert!(should_submit_immediately_from_palette(&session));
560
561        session.set_input("/login".to_string());
562        assert!(should_submit_immediately_from_palette(&session));
563
564        session.set_input("/logout".to_string());
565        assert!(should_submit_immediately_from_palette(&session));
566
567        session.set_input("/auth".to_string());
568        assert!(should_submit_immediately_from_palette(&session));
569
570        session.set_input("/refresh-oauth".to_string());
571        assert!(should_submit_immediately_from_palette(&session));
572    }
573
574    #[test]
575    fn immediate_submit_matcher_rejects_argument_driven_commands() {
576        let mut session = Session::new(InlineTheme::default(), None, 20);
577        session.set_input("/command echo hello".to_string());
578        assert!(!should_submit_immediately_from_palette(&session));
579
580        session.set_input("/add-dir ~/tmp".to_string());
581        assert!(!should_submit_immediately_from_palette(&session));
582    }
583
584    #[test]
585    fn slash_palette_instructions_hide_filter_hint_row() {
586        let session = Session::new(InlineTheme::default(), None, 20);
587        let instructions = slash_palette_instructions(&session);
588
589        assert_eq!(instructions.len(), 1);
590        let text: String = instructions[0]
591            .spans
592            .iter()
593            .map(|span| span.content.clone().into_owned())
594            .collect();
595        assert!(text.contains("Navigation:"));
596        assert!(!text.contains("Type to filter slash commands"));
597    }
598}