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