Skip to main content

vtcode_tui/core_tui/session/
slash.rs

1use ratatui::crossterm::event::{KeyCode, KeyEvent};
2use ratatui::{
3    prelude::*,
4    widgets::{Block, Clear, List, ListItem, Paragraph, Wrap},
5};
6
7use super::terminal_capabilities;
8use crate::config::constants::ui;
9use crate::ui::search::fuzzy_score;
10
11use super::super::types::InlineTextStyle;
12use super::{
13    Session,
14    modal::{ModalListLayout, compute_modal_area},
15    ratatui_color_from_ansi, ratatui_style_from_inline,
16    slash_palette::{self, SlashPaletteUpdate, command_prefix, command_range},
17};
18
19pub fn render_slash_palette(session: &mut Session, frame: &mut Frame<'_>, viewport: Rect) {
20    if viewport.height == 0 || viewport.width == 0 || session.modal.is_some() {
21        session.slash_palette.clear_visible_rows();
22        return;
23    }
24    let suggestions = session.slash_palette.suggestions();
25    if suggestions.is_empty() {
26        session.slash_palette.clear_visible_rows();
27        return;
28    }
29
30    let instructions = slash_palette_instructions(session);
31    let area = compute_modal_area(viewport, instructions.len(), 0, 0, true);
32
33    frame.render_widget(Clear, area);
34    let block = Block::bordered()
35        .title(session.suggestion_block_title())
36        .border_type(terminal_capabilities::get_border_type())
37        .style(session.styles.default_style())
38        .border_style(session.styles.border_style());
39    let inner = block.inner(area);
40    frame.render_widget(block, area);
41    if inner.height == 0 || inner.width == 0 {
42        session.slash_palette.clear_visible_rows();
43        return;
44    }
45
46    let layout = ModalListLayout::new(inner, instructions.len());
47    if let Some(text_area) = layout.text_area {
48        let paragraph = Paragraph::new(instructions).wrap(Wrap { trim: true });
49        frame.render_widget(paragraph, text_area);
50    }
51
52    session
53        .slash_palette
54        .set_visible_rows(layout.list_area.height as usize);
55
56    // Get all list items (scrollable via ListState)
57    let list_items = slash_list_items(session);
58
59    let list = List::new(list_items)
60        .style(session.styles.default_style())
61        .highlight_style(slash_highlight_style(session))
62        .highlight_symbol(ui::MODAL_LIST_HIGHLIGHT_FULL)
63        .repeat_highlight_symbol(true);
64
65    frame.render_stateful_widget(
66        list,
67        layout.list_area,
68        session.slash_palette.list_state_mut(),
69    );
70}
71
72fn slash_palette_instructions(session: &Session) -> Vec<Line<'static>> {
73    vec![
74        Line::from(Span::styled(
75            ui::SLASH_PALETTE_HINT_PRIMARY.to_owned(),
76            session.styles.default_style(),
77        )),
78        Line::from(Span::styled(
79            ui::SLASH_PALETTE_HINT_SECONDARY.to_owned(),
80            session.styles.default_style().add_modifier(Modifier::DIM),
81        )),
82    ]
83}
84
85pub(super) fn handle_slash_palette_change(session: &mut Session) {
86    // session.recalculate_transcript_rows(); // This method was removed from session.rs, need to check where it went.
87    // It was moved to render.rs as `recalculate_transcript_rows`.
88    // But render.rs functions are not methods on Session anymore.
89    // So I need to call `render::recalculate_transcript_rows(session)`.
90    // But I can't import `render` here easily if it's a sibling module.
91    // `use super::render;`
92    // I'll add the import later. For now I'll use the fully qualified path or assume I'll fix imports.
93    // Actually, `recalculate_transcript_rows` is likely `pub(super)` in `render.rs`.
94    // I'll check `render.rs` exports.
95
96    // For now, I'll comment it out and fix it in a separate step or assume `render` is available.
97    // Wait, I can't leave broken code.
98    // I'll assume `crate::ui::tui::session::render::recalculate_transcript_rows(session)` works.
99    crate::ui::tui::session::render::recalculate_transcript_rows(session);
100    session.enforce_scroll_bounds();
101    session.mark_dirty();
102}
103
104pub(super) fn clear_slash_suggestions(session: &mut Session) {
105    if session.slash_palette.clear() {
106        handle_slash_palette_change(session);
107    }
108}
109
110pub(super) fn update_slash_suggestions(session: &mut Session) {
111    if !session.input_enabled {
112        clear_slash_suggestions(session);
113        return;
114    }
115
116    let Some(prefix) = command_prefix(
117        session.input_manager.content(),
118        session.input_manager.cursor(),
119    ) else {
120        clear_slash_suggestions(session);
121        return;
122    };
123
124    match session
125        .slash_palette
126        .update(Some(&prefix), ui::SLASH_SUGGESTION_LIMIT)
127    {
128        SlashPaletteUpdate::NoChange => {}
129        SlashPaletteUpdate::Cleared | SlashPaletteUpdate::Changed { .. } => {
130            handle_slash_palette_change(session);
131        }
132    }
133}
134
135pub(crate) fn slash_navigation_available(session: &Session) -> bool {
136    let has_prefix = command_prefix(
137        session.input_manager.content(),
138        session.input_manager.cursor(),
139    )
140    .is_some();
141    session.input_enabled
142        && !session.slash_palette.is_empty()
143        && has_prefix
144        && session.modal.is_none()
145        && !session.file_palette_active
146}
147
148pub(super) fn move_slash_selection_up(session: &mut Session) -> bool {
149    let changed = session.slash_palette.move_up();
150    handle_slash_selection_change(session, changed)
151}
152
153pub(super) fn move_slash_selection_down(session: &mut Session) -> bool {
154    let changed = session.slash_palette.move_down();
155    handle_slash_selection_change(session, changed)
156}
157
158pub(super) fn select_first_slash_suggestion(session: &mut Session) -> bool {
159    let changed = session.slash_palette.select_first();
160    handle_slash_selection_change(session, changed)
161}
162
163pub(super) fn select_last_slash_suggestion(session: &mut Session) -> bool {
164    let changed = session.slash_palette.select_last();
165    handle_slash_selection_change(session, changed)
166}
167
168pub(super) fn page_up_slash_suggestion(session: &mut Session) -> bool {
169    let changed = session.slash_palette.page_up();
170    handle_slash_selection_change(session, changed)
171}
172
173pub(super) fn page_down_slash_suggestion(session: &mut Session) -> bool {
174    let changed = session.slash_palette.page_down();
175    handle_slash_selection_change(session, changed)
176}
177
178pub(super) fn handle_slash_selection_change(session: &mut Session, changed: bool) -> bool {
179    if changed {
180        preview_selected_slash_suggestion(session);
181        crate::ui::tui::session::render::recalculate_transcript_rows(session);
182        session.enforce_scroll_bounds();
183        session.mark_dirty();
184        true
185    } else {
186        false
187    }
188}
189
190fn preview_selected_slash_suggestion(session: &mut Session) {
191    let Some(command) = session.slash_palette.selected_command() else {
192        return;
193    };
194    let Some(range) = command_range(
195        session.input_manager.content(),
196        session.input_manager.cursor(),
197    ) else {
198        return;
199    };
200
201    let current_input = session.input_manager.content().to_owned();
202    let prefix = &current_input[..range.start];
203    let suffix = &current_input[range.end..];
204
205    let mut new_input = String::new();
206    new_input.push_str(prefix);
207    new_input.push('/');
208    new_input.push_str(command.name.as_str());
209    let cursor_position = new_input.len();
210
211    if !suffix.is_empty() {
212        if !suffix.chars().next().is_some_and(char::is_whitespace) {
213            new_input.push(' ');
214        }
215        new_input.push_str(suffix);
216    }
217
218    session.input_manager.set_content(new_input.clone());
219    session
220        .input_manager
221        .set_cursor(cursor_position.min(new_input.len()));
222    session.mark_dirty();
223}
224
225pub(super) fn apply_selected_slash_suggestion(session: &mut Session) -> bool {
226    let Some(command) = session.slash_palette.selected_command() else {
227        return false;
228    };
229
230    let command_name = command.name.to_owned();
231
232    let input_content = session.input_manager.content();
233    let cursor_pos = session.input_manager.cursor();
234    let Some(range) = command_range(input_content, cursor_pos) else {
235        return false;
236    };
237
238    let suffix = input_content[range.end..].to_owned();
239    let mut new_input = format!("/{}", command_name);
240
241    let cursor_position = if suffix.is_empty() {
242        new_input.push(' ');
243        new_input.len()
244    } else {
245        if !suffix.chars().next().is_some_and(char::is_whitespace) {
246            new_input.push(' ');
247        }
248        let position = new_input.len();
249        new_input.push_str(&suffix);
250        position
251    };
252
253    session.input_manager.set_content(new_input);
254    session.input_manager.set_cursor(cursor_position);
255
256    clear_slash_suggestions(session);
257    session.mark_dirty();
258
259    true
260}
261
262pub(super) fn autocomplete_slash_suggestion(session: &mut Session) -> bool {
263    let input_content = session.input_manager.content();
264    let cursor_pos = session.input_manager.cursor();
265
266    let Some(range) = command_range(input_content, cursor_pos) else {
267        return false;
268    };
269
270    let prefix_text = command_prefix(input_content, cursor_pos).unwrap_or_default();
271
272    if prefix_text.is_empty() {
273        return false;
274    }
275
276    let suggestions = session.slash_palette.suggestions();
277    if suggestions.is_empty() {
278        return false;
279    }
280
281    // Find the best fuzzy match from all suggestions
282    let mut best_match: Option<(usize, u32, String)> = None;
283
284    for (idx, suggestion) in suggestions.iter().enumerate() {
285        let command_name = match suggestion {
286            slash_palette::SlashPaletteSuggestion::Static(cmd) => cmd.name.to_string(),
287        };
288
289        if let Some(score) = fuzzy_score(&prefix_text, &command_name) {
290            if let Some((_, best_score, _)) = &best_match {
291                if score > *best_score {
292                    best_match = Some((idx, score, command_name));
293                }
294            } else {
295                best_match = Some((idx, score, command_name));
296            }
297        }
298    }
299
300    let Some((_, _, best_command)) = best_match else {
301        return false;
302    };
303
304    // Handle static command
305    let suffix = &input_content[range.end..];
306    let mut new_input = format!("/{}", best_command);
307
308    let cursor_position = if suffix.is_empty() {
309        new_input.push(' ');
310        new_input.len()
311    } else {
312        if !suffix.chars().next().is_some_and(char::is_whitespace) {
313            new_input.push(' ');
314        }
315        let position = new_input.len();
316        new_input.push_str(suffix);
317        position
318    };
319
320    session.input_manager.set_content(new_input);
321    session.input_manager.set_cursor(cursor_position);
322
323    clear_slash_suggestions(session);
324    session.mark_dirty();
325    true
326}
327
328pub(super) fn try_handle_slash_navigation(
329    session: &mut Session,
330    key: &KeyEvent,
331    has_control: bool,
332    has_alt: bool,
333    has_command: bool,
334) -> bool {
335    if !slash_navigation_available(session) {
336        return false;
337    }
338
339    // Block Control modifier
340    if has_control {
341        return false;
342    }
343
344    // Block Alt unless combined with Command for Up/Down navigation
345    if has_alt && !matches!(key.code, KeyCode::Up | KeyCode::Down) {
346        return false;
347    }
348
349    let handled = match key.code {
350        KeyCode::Up => {
351            if has_alt && !has_command {
352                return false;
353            }
354            if has_command {
355                select_first_slash_suggestion(session)
356            } else {
357                move_slash_selection_up(session)
358            }
359        }
360        KeyCode::Down => {
361            if has_alt && !has_command {
362                return false;
363            }
364            if has_command {
365                select_last_slash_suggestion(session)
366            } else {
367                move_slash_selection_down(session)
368            }
369        }
370        KeyCode::PageUp => page_up_slash_suggestion(session),
371        KeyCode::PageDown => page_down_slash_suggestion(session),
372        KeyCode::Tab => autocomplete_slash_suggestion(session),
373        KeyCode::BackTab => move_slash_selection_up(session),
374        KeyCode::Enter => {
375            let applied = apply_selected_slash_suggestion(session);
376            if !applied {
377                return false;
378            }
379
380            let should_submit_now = should_submit_immediately_from_palette(session);
381
382            if should_submit_now {
383                return false;
384            }
385
386            true
387        }
388        _ => return false,
389    };
390
391    if handled {
392        session.mark_dirty();
393    }
394
395    handled
396}
397
398fn should_submit_immediately_from_palette(session: &Session) -> bool {
399    let Some(command) = session.input_manager.content().split_whitespace().next() else {
400        return false;
401    };
402
403    matches!(
404        command,
405        "/files"
406            | "/status"
407            | "/doctor"
408            | "/model"
409            | "/new"
410            | "/git"
411            | "/docs"
412            | "/copy"
413            | "/config"
414            | "/settings"
415            | "/help"
416            | "/clear"
417            | "/exit"
418    )
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use crate::ui::tui::InlineTheme;
425
426    #[test]
427    fn immediate_submit_matcher_accepts_immediate_commands() {
428        let mut session = Session::new(InlineTheme::default(), None, 20);
429        session.set_input("/files".to_string());
430        assert!(should_submit_immediately_from_palette(&session));
431
432        session.set_input("   /status   ".to_string());
433        assert!(should_submit_immediately_from_palette(&session));
434    }
435
436    #[test]
437    fn immediate_submit_matcher_rejects_argument_driven_commands() {
438        let mut session = Session::new(InlineTheme::default(), None, 20);
439        session.set_input("/command echo hello".to_string());
440        assert!(!should_submit_immediately_from_palette(&session));
441
442        session.set_input("/add-dir ~/tmp".to_string());
443        assert!(!should_submit_immediately_from_palette(&session));
444    }
445}
446
447fn slash_list_items(session: &Session) -> Vec<ListItem<'static>> {
448    session
449        .slash_palette
450        .suggestions()
451        .iter()
452        .map(|suggestion| match suggestion {
453            slash_palette::SlashPaletteSuggestion::Static(command) => {
454                ListItem::new(Line::from(vec![
455                    Span::styled(format!("/{}", command.name), slash_name_style(session)),
456                    Span::raw(" "),
457                    Span::styled(
458                        command.description.to_owned(),
459                        slash_description_style(session),
460                    ),
461                ]))
462            }
463        })
464        .collect()
465}
466
467fn slash_highlight_style(session: &Session) -> Style {
468    let mut style = Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD);
469    if let Some(primary) = session.theme.primary.or(session.theme.secondary) {
470        style = style.fg(ratatui_color_from_ansi(primary));
471    }
472    style
473}
474
475fn slash_name_style(session: &Session) -> Style {
476    let style = InlineTextStyle::default()
477        .bold()
478        .with_color(session.theme.primary.or(session.theme.foreground));
479    ratatui_style_from_inline(&style, session.theme.foreground)
480}
481
482fn slash_description_style(session: &Session) -> Style {
483    session.styles.default_style().add_modifier(Modifier::DIM)
484}