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