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