Skip to main content

vtcode_tui/core_tui/session/
slash.rs

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