vtcode_tui/core_tui/session/
slash.rs1use 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 = ¤t_input[..range.start];
269 let suffix = ¤t_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 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 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 if has_control {
390 return false;
391 }
392
393 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}