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