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, StaticRowsListPanelModel, fixed_section_rows,
10 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.modal.is_some()
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.modal.is_some()
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 if filter.is_empty() {
109 "Filter: (type to narrow commands)".to_owned()
110 } else {
111 format!("Filter: {}", filter)
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.map(|value| Line::from(Span::styled(value, default_style))),
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.modal.is_none()
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
251fn preview_selected_slash_suggestion(session: &mut Session) {
252 let Some(command) = session.slash_palette.selected_command() else {
253 return;
254 };
255 let Some(range) = command_range(
256 session.input_manager.content(),
257 session.input_manager.cursor(),
258 ) else {
259 return;
260 };
261
262 let current_input = session.input_manager.content().to_owned();
263 let prefix = ¤t_input[..range.start];
264 let suffix = ¤t_input[range.end..];
265
266 let mut new_input = String::new();
267 new_input.push_str(prefix);
268 new_input.push('/');
269 new_input.push_str(command.name.as_str());
270 let cursor_position = new_input.len();
271
272 if !suffix.is_empty() {
273 if !suffix.chars().next().is_some_and(char::is_whitespace) {
274 new_input.push(' ');
275 }
276 new_input.push_str(suffix);
277 }
278
279 session.input_manager.set_content(new_input.clone());
280 session
281 .input_manager
282 .set_cursor(cursor_position.min(new_input.len()));
283 session.mark_dirty();
284}
285
286pub(super) fn apply_selected_slash_suggestion(session: &mut Session) -> bool {
287 let Some(command) = session.slash_palette.selected_command() else {
288 return false;
289 };
290
291 let command_name = command.name.to_owned();
292
293 let input_content = session.input_manager.content();
294 let cursor_pos = session.input_manager.cursor();
295 let Some(range) = command_range(input_content, cursor_pos) else {
296 return false;
297 };
298
299 let suffix = input_content[range.end..].to_owned();
300 let mut new_input = format!("/{}", command_name);
301
302 let cursor_position = if suffix.is_empty() {
303 new_input.push(' ');
304 new_input.len()
305 } else {
306 if !suffix.chars().next().is_some_and(char::is_whitespace) {
307 new_input.push(' ');
308 }
309 let position = new_input.len();
310 new_input.push_str(&suffix);
311 position
312 };
313
314 session.input_manager.set_content(new_input);
315 session.input_manager.set_cursor(cursor_position);
316
317 clear_slash_suggestions(session);
318 session.mark_dirty();
319
320 true
321}
322
323pub(super) fn autocomplete_slash_suggestion(session: &mut Session) -> bool {
324 let input_content = session.input_manager.content();
325 let cursor_pos = session.input_manager.cursor();
326
327 let Some(range) = command_range(input_content, cursor_pos) else {
328 return false;
329 };
330
331 let prefix_text = command_prefix(input_content, cursor_pos).unwrap_or_default();
332 if prefix_text.is_empty() {
333 return false;
334 }
335
336 let suggestions = session.slash_palette.suggestions();
337 if suggestions.is_empty() {
338 return false;
339 }
340
341 let Some(best_command) = suggestions.first().map(|suggestion| match suggestion {
343 slash_palette::SlashPaletteSuggestion::Static(command) => command.name.as_str(),
344 }) else {
345 return false;
346 };
347
348 let suffix = &input_content[range.end..];
350 let mut new_input = format!("/{}", best_command);
351
352 let cursor_position = if suffix.is_empty() {
353 new_input.push(' ');
354 new_input.len()
355 } else {
356 if !suffix.chars().next().is_some_and(char::is_whitespace) {
357 new_input.push(' ');
358 }
359 let position = new_input.len();
360 new_input.push_str(suffix);
361 position
362 };
363
364 session.input_manager.set_content(new_input);
365 session.input_manager.set_cursor(cursor_position);
366
367 clear_slash_suggestions(session);
368 session.mark_dirty();
369 true
370}
371
372pub(super) fn try_handle_slash_navigation(
373 session: &mut Session,
374 key: &KeyEvent,
375 has_control: bool,
376 has_alt: bool,
377 has_command: bool,
378) -> bool {
379 if !slash_navigation_available(session) {
380 return false;
381 }
382
383 if has_control {
385 return false;
386 }
387
388 if has_alt && !matches!(key.code, KeyCode::Up | KeyCode::Down) {
390 return false;
391 }
392
393 let handled = match key.code {
394 KeyCode::Up => {
395 if has_alt && !has_command {
396 return false;
397 }
398 if has_command {
399 select_first_slash_suggestion(session)
400 } else {
401 move_slash_selection_up(session)
402 }
403 }
404 KeyCode::Down => {
405 if has_alt && !has_command {
406 return false;
407 }
408 if has_command {
409 select_last_slash_suggestion(session)
410 } else {
411 move_slash_selection_down(session)
412 }
413 }
414 KeyCode::PageUp => page_up_slash_suggestion(session),
415 KeyCode::PageDown => page_down_slash_suggestion(session),
416 KeyCode::Tab => autocomplete_slash_suggestion(session),
417 KeyCode::BackTab => move_slash_selection_up(session),
418 KeyCode::Enter => {
419 let applied = apply_selected_slash_suggestion(session);
420 if !applied {
421 return false;
422 }
423
424 let should_submit_now = should_submit_immediately_from_palette(session);
425
426 if should_submit_now {
427 return false;
428 }
429
430 true
431 }
432 _ => return false,
433 };
434
435 if handled {
436 session.mark_dirty();
437 }
438
439 handled
440}
441
442fn should_submit_immediately_from_palette(session: &Session) -> bool {
443 let Some(command) = session.input_manager.content().split_whitespace().next() else {
444 return false;
445 };
446
447 matches!(
448 command,
449 "/files"
450 | "/status"
451 | "/doctor"
452 | "/model"
453 | "/mcp"
454 | "/skills"
455 | "/new"
456 | "/git"
457 | "/docs"
458 | "/copy"
459 | "/help"
460 | "/clear"
461 | "/history"
462 | "/exit"
463 )
464}
465
466#[derive(Clone)]
467struct SlashRow {
468 name: String,
469 description: String,
470}
471
472fn slash_rows(session: &Session) -> Vec<SlashRow> {
473 session
474 .slash_palette
475 .suggestions()
476 .iter()
477 .map(|suggestion| match suggestion {
478 slash_palette::SlashPaletteSuggestion::Static(command) => SlashRow {
479 name: command.name.to_owned(),
480 description: command.description.to_owned(),
481 },
482 })
483 .collect()
484}
485
486fn slash_highlight_style(session: &Session) -> Style {
487 let mut style = Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD);
488 if let Some(primary) = session.theme.primary.or(session.theme.secondary) {
489 style = style.fg(ratatui_color_from_ansi(primary));
490 }
491 style
492}
493
494fn slash_name_style(session: &Session) -> Style {
495 let style = InlineTextStyle::default()
496 .bold()
497 .with_color(session.theme.primary.or(session.theme.foreground));
498 ratatui_style_from_inline(&style, session.theme.foreground)
499}
500
501fn slash_description_style(session: &Session) -> Style {
502 session.styles.default_style().add_modifier(Modifier::DIM)
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508 use crate::ui::tui::InlineTheme;
509
510 #[test]
511 fn immediate_submit_matcher_accepts_immediate_commands() {
512 let mut session = Session::new(InlineTheme::default(), None, 20);
513 session.set_input("/files".to_string());
514 assert!(should_submit_immediately_from_palette(&session));
515
516 session.set_input(" /status ".to_string());
517 assert!(should_submit_immediately_from_palette(&session));
518
519 session.set_input("/history".to_string());
520 assert!(should_submit_immediately_from_palette(&session));
521
522 session.set_input("/mcp".to_string());
523 assert!(should_submit_immediately_from_palette(&session));
524
525 session.set_input("/skills".to_string());
526 assert!(should_submit_immediately_from_palette(&session));
527 }
528
529 #[test]
530 fn immediate_submit_matcher_rejects_argument_driven_commands() {
531 let mut session = Session::new(InlineTheme::default(), None, 20);
532 session.set_input("/command echo hello".to_string());
533 assert!(!should_submit_immediately_from_palette(&session));
534
535 session.set_input("/add-dir ~/tmp".to_string());
536 assert!(!should_submit_immediately_from_palette(&session));
537 }
538
539 #[test]
540 fn slash_palette_instructions_hide_filter_hint_row() {
541 let session = Session::new(InlineTheme::default(), None, 20);
542 let instructions = slash_palette_instructions(&session);
543
544 assert_eq!(instructions.len(), 1);
545 let text: String = instructions[0]
546 .spans
547 .iter()
548 .map(|span| span.content.clone().into_owned())
549 .collect();
550 assert!(text.contains("Navigation:"));
551 assert!(!text.contains("Type to filter slash commands"));
552 }
553}