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