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