Skip to main content

recursive/tui/ui/
command_menu.rs

1//! Command-completion popup (Goal 146).
2//!
3//! When the prompt input is in [`crate::tui::app::InputMode::Command`],
4//! the chat renderer overlays a small popup just above the input box
5//! that lists the candidate commands matching the current buffer.
6//!
7//! The popup is purely visual — keyboard navigation lives in
8//! [`crate::tui::app::App::handle_command_menu_key`], which is called
9//! from [`crate::tui::app::App::handle_key`] before falling through to
10//! the generic chat key path.
11
12use ratatui::layout::Rect;
13use ratatui::prelude::*;
14use ratatui::style::{Color, Modifier, Style};
15use ratatui::text::{Line, Span};
16use ratatui::widgets::{Block, Borders, Clear, Paragraph};
17
18use crate::tui::app::App;
19use crate::tui::commands::CommandSpec;
20
21/// Maximum number of candidate rows to display in the popup.
22pub const MAX_VISIBLE: usize = 8;
23
24/// Compute the longest common prefix of `s1` and `s2`. Pure, used by
25/// [`tab_completion_target`] and exposed for tests.
26pub fn longest_common_prefix<'a>(s1: &'a str, s2: &'a str) -> &'a str {
27    let limit = s1.len().min(s2.len());
28    let mut idx = 0;
29    while idx < limit && s1.as_bytes()[idx] == s2.as_bytes()[idx] {
30        idx += 1;
31    }
32    &s1[..idx]
33}
34
35/// Return the canonical name to autocomplete to, given the current
36/// buffer and the set of matches.
37///
38/// Algorithm: zero matches yields `None`; exactly one match yields
39/// `Some(canonical_name)`; multiple matches yield the longest common
40/// prefix of all canonical names, but only if it strictly extends
41/// the buffer (otherwise `None`, leaving the buffer alone).
42pub fn tab_completion_target(buffer: &str, matches: &[&CommandSpec]) -> Option<String> {
43    let buf = buffer.trim_start_matches('/');
44    match matches.len() {
45        0 => None,
46        1 => Some(matches[0].name.to_string()),
47        _ => {
48            let mut common: &str = matches[0].name;
49            for m in &matches[1..] {
50                common = longest_common_prefix(common, m.name);
51                if common.is_empty() {
52                    break;
53                }
54            }
55            if common.len() > buf.len() {
56                Some(common.to_string())
57            } else {
58                None
59            }
60        }
61    }
62}
63
64/// Compute the rectangle to render the popup in, given the input
65/// box's `area` and the number of candidate rows. Returns `None`
66/// when the popup doesn't fit (terminal too short or candidates
67/// empty).
68pub fn popup_rect(input_area: Rect, candidate_count: usize, frame_area: Rect) -> Option<Rect> {
69    if candidate_count == 0 {
70        return None;
71    }
72    // 2 borders + N rows
73    let popup_h = (candidate_count.min(MAX_VISIBLE) as u16) + 2;
74    if input_area.y < popup_h || frame_area.y > input_area.y - popup_h {
75        return None;
76    }
77    let popup_w = input_area.width.clamp(20, 60);
78    Some(Rect {
79        x: input_area.x,
80        y: input_area.y - popup_h,
81        width: popup_w,
82        height: popup_h,
83    })
84}
85
86/// A single entry in the combined command-menu popup — either a built-in
87/// command or a Goal-169 skill-backed command.
88enum MenuEntry<'a> {
89    Builtin(&'a crate::tui::commands::CommandSpec),
90    Skill(&'a crate::tui::skill_commands::SkillCommand),
91}
92
93impl<'a> MenuEntry<'a> {
94    fn name(&self) -> &str {
95        match self {
96            MenuEntry::Builtin(s) => s.name,
97            MenuEntry::Skill(s) => &s.name,
98        }
99    }
100    fn summary(&self) -> &str {
101        match self {
102            MenuEntry::Builtin(s) => s.summary,
103            MenuEntry::Skill(s) => &s.description,
104        }
105    }
106    fn is_skill(&self) -> bool {
107        matches!(self, MenuEntry::Skill(_))
108    }
109}
110
111/// Render the popup. No-op when the input mode is not Command, the
112/// buffer is empty, or no matches are available.
113///
114/// Goal-169: the popup now shows both built-in and skill-backed commands
115/// (skills are suffixed with `[skill]` in a dim colour so the user can
116/// distinguish them at a glance).
117pub fn render(frame: &mut Frame, input_area: Rect, app: &App) {
118    if app.prompt.mode != crate::tui::app::InputMode::Command {
119        return;
120    }
121    let builtin_matches = app.commands.search(&app.prompt.buffer);
122    let skill_matches = app.commands.search_skills(&app.prompt.buffer);
123
124    // Combine: built-ins first, then skills.
125    let mut combined: Vec<MenuEntry<'_>> = builtin_matches
126        .iter()
127        .map(|s| MenuEntry::Builtin(s))
128        .chain(skill_matches.iter().map(|s| MenuEntry::Skill(s)))
129        .collect();
130    combined.truncate(MAX_VISIBLE);
131
132    if combined.is_empty() {
133        return;
134    }
135    let Some(area) = popup_rect(input_area, combined.len(), frame.area()) else {
136        return;
137    };
138    frame.render_widget(Clear, area);
139
140    let selected_style = Style::default()
141        .fg(Color::Black)
142        .bg(Color::Yellow)
143        .add_modifier(Modifier::BOLD);
144    let normal_style = Style::default().fg(Color::White);
145    let summary_style = Style::default().fg(Color::DarkGray);
146    let skill_badge_style = Style::default().fg(Color::Green);
147
148    let lines: Vec<Line<'static>> = combined
149        .iter()
150        .enumerate()
151        .map(|(i, entry)| {
152            let style = if app.command_menu_selected == Some(i) {
153                selected_style
154            } else {
155                normal_style
156            };
157            let mut spans = vec![
158                Span::styled(format!(" /{:<10} ", entry.name().to_string()), style),
159                Span::styled(entry.summary().to_string(), summary_style),
160            ];
161            if entry.is_skill() {
162                spans.push(Span::styled(" [skill]".to_string(), skill_badge_style));
163            }
164            Line::from(spans)
165        })
166        .collect();
167
168    let block = Block::default()
169        .borders(Borders::ALL)
170        .border_style(Style::default().fg(Color::DarkGray))
171        .title(" Commands ")
172        .style(Style::default().bg(Color::Black));
173    let para = Paragraph::new(lines).block(block);
174    frame.render_widget(para, area);
175}
176
177/// Render the @file completion popup (Goal 158). No-op when not in
178/// AtFile mode or when the suggestion list is empty.
179pub fn render_atfile(frame: &mut Frame, input_area: Rect, app: &App) {
180    if app.prompt.mode != crate::tui::app::InputMode::AtFile {
181        return;
182    }
183    let suggestions = &app.atfile_suggestions;
184    if suggestions.is_empty() {
185        return;
186    }
187    let visible = &suggestions[..suggestions.len().min(MAX_VISIBLE)];
188    let Some(area) = popup_rect(input_area, visible.len(), frame.area()) else {
189        return;
190    };
191    frame.render_widget(Clear, area);
192
193    let selected_style = Style::default()
194        .fg(Color::Black)
195        .bg(Color::Cyan)
196        .add_modifier(Modifier::BOLD);
197    let normal_style = Style::default().fg(Color::White);
198
199    let lines: Vec<Line<'static>> = visible
200        .iter()
201        .enumerate()
202        .map(|(i, path)| {
203            let style = if app.atfile_selected == Some(i) {
204                selected_style
205            } else {
206                normal_style
207            };
208            Line::from(Span::styled(format!(" {} ", path), style))
209        })
210        .collect();
211
212    let title = format!(" @files  query: {:?} ", app.atfile_query);
213    let block = Block::default()
214        .borders(Borders::ALL)
215        .border_style(Style::default().fg(Color::Cyan))
216        .title(title)
217        .style(Style::default().bg(Color::Black));
218    let para = Paragraph::new(lines).block(block);
219    frame.render_widget(para, area);
220}
221
222/// Render the Ctrl+R history-search popup (Goal 160). No-op when not in
223/// HistorySearch mode or when there are no matches to show.
224pub fn render_history_search(frame: &mut Frame, input_area: Rect, app: &App) {
225    if app.prompt.mode != crate::tui::app::InputMode::HistorySearch {
226        return;
227    }
228    // Always show the popup (even when matches is empty) so the user
229    // can see the search box with the current query.
230    let match_count = app.hsearch_matches.len();
231    let visible_count = match_count.clamp(1, MAX_VISIBLE);
232    let Some(area) = popup_rect(input_area, visible_count, frame.area()) else {
233        return;
234    };
235    frame.render_widget(Clear, area);
236
237    let selected_style = Style::default()
238        .fg(Color::Black)
239        .bg(Color::LightGreen)
240        .add_modifier(Modifier::BOLD);
241    let normal_style = Style::default().fg(Color::White);
242    let empty_style = Style::default()
243        .fg(Color::DarkGray)
244        .add_modifier(Modifier::ITALIC);
245
246    let lines: Vec<Line<'static>> = if app.hsearch_matches.is_empty() {
247        vec![Line::from(Span::styled(" (no matches) ", empty_style))]
248    } else {
249        let history = &app.prompt.history;
250        app.hsearch_matches
251            .iter()
252            .take(MAX_VISIBLE)
253            .enumerate()
254            .map(|(i, &hist_idx)| {
255                let entry = history.get(hist_idx).map(String::as_str).unwrap_or("");
256                // Truncate long entries to 60 chars.
257                let display = if entry.len() > 60 {
258                    format!(" {}… ", &entry[..57])
259                } else {
260                    format!(" {} ", entry)
261                };
262                let style = if i == app.hsearch_selected {
263                    selected_style
264                } else {
265                    normal_style
266                };
267                Line::from(Span::styled(display, style))
268            })
269            .collect()
270    };
271
272    let title = format!(" 🔍 {} ", app.hsearch_query);
273    let block = Block::default()
274        .borders(Borders::ALL)
275        .border_style(Style::default().fg(Color::LightGreen))
276        .title(title)
277        .style(Style::default().bg(Color::Black));
278    let para = Paragraph::new(lines).block(block);
279    frame.render_widget(para, area);
280}
281
282// ── Goal-161: Permission Request Modal ───────────────────────────────────────
283
284/// Render the permission-request modal when a tool is waiting for user
285/// approval. Displayed as a centred overlay with the tool name, an
286/// abbreviated argument preview, and `[Y]es / [N]o` instructions.
287pub fn render_permission_modal(frame: &mut Frame, app: &App) {
288    let Some(ref perm) = app.pending_permission else {
289        return;
290    };
291
292    // Centre a fixed-size box on screen.
293    let area = frame.area();
294    let modal_w = area.width.saturating_sub(8).min(72);
295    let modal_h = 8u16;
296    let x = (area.width.saturating_sub(modal_w)) / 2;
297    let y = (area.height.saturating_sub(modal_h)) / 2;
298    let modal_area = Rect::new(x, y, modal_w, modal_h);
299
300    frame.render_widget(Clear, modal_area);
301
302    let header_style = Style::default()
303        .fg(Color::Black)
304        .bg(Color::Yellow)
305        .add_modifier(Modifier::BOLD);
306    let body_style = Style::default().fg(Color::White);
307    let hint_style = Style::default()
308        .fg(Color::LightGreen)
309        .add_modifier(Modifier::BOLD);
310    let muted_style = Style::default().fg(Color::DarkGray);
311
312    let tool_line = Line::from(vec![
313        Span::styled(" Tool: ", header_style),
314        Span::styled(perm.tool_name.clone(), body_style),
315    ]);
316
317    let args_preview = if perm.args_preview.is_empty() {
318        "(no arguments)".to_string()
319    } else {
320        perm.args_preview.clone()
321    };
322    let args_short: String = args_preview.chars().take(60).collect();
323    let args_truncated = if args_preview.chars().count() > 60 {
324        format!("{args_short}…")
325    } else {
326        args_short
327    };
328    let args_line = Line::from(vec![
329        Span::styled(" Args: ", muted_style),
330        Span::styled(args_truncated, body_style),
331    ]);
332
333    let sep_line = Line::from(Span::styled("─".repeat(modal_w as usize - 2), muted_style));
334
335    let hint_line = Line::from(vec![
336        Span::styled("  [Y]", hint_style),
337        Span::styled("/", muted_style),
338        Span::styled("[Enter]", hint_style),
339        Span::styled(" Allow  ", body_style),
340        Span::styled(
341            "[N]",
342            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
343        ),
344        Span::styled("/", muted_style),
345        Span::styled("[Esc]", muted_style),
346        Span::styled(" Deny  ", body_style),
347        Span::styled(
348            "[A]",
349            Style::default()
350                .fg(Color::LightCyan)
351                .add_modifier(Modifier::BOLD),
352        ),
353        Span::styled(" Allow All", body_style),
354    ]);
355
356    let lines = vec![
357        Line::from(""),
358        tool_line,
359        args_line,
360        Line::from(""),
361        sep_line,
362        hint_line,
363    ];
364
365    let block = Block::default()
366        .borders(Borders::ALL)
367        .border_style(Style::default().fg(Color::Yellow))
368        .title(Span::styled(
369            " ⚠ Permission Request ",
370            Style::default()
371                .fg(Color::Yellow)
372                .add_modifier(Modifier::BOLD),
373        ))
374        .style(Style::default().bg(Color::Black));
375
376    let para = Paragraph::new(lines).block(block);
377    frame.render_widget(para, modal_area);
378}
379
380// ──────────────────────────────────────────────────────────────────────
381// Tests
382// ──────────────────────────────────────────────────────────────────────
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use crate::tui::app::{App, AppScreen, InputMode};
388    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
389
390    fn key(code: KeyCode) -> KeyEvent {
391        KeyEvent::new(code, KeyModifiers::NONE)
392    }
393
394    fn fresh_command_app() -> App {
395        let mut app = App::new();
396        app.screen = AppScreen::Chat;
397        app.prompt.mode = InputMode::Command;
398        app
399    }
400
401    #[test]
402    fn longest_common_prefix_works() {
403        assert_eq!(longest_common_prefix("clear", "compact"), "c");
404        assert_eq!(longest_common_prefix("help", "help"), "help");
405        assert_eq!(longest_common_prefix("a", "b"), "");
406    }
407
408    #[test]
409    fn tab_completes_unique_prefix() {
410        let r = crate::tui::commands::CommandRegistry::default_set();
411        // "he" → only /help; should complete to "help".
412        let matches = r.search("he");
413        let target = tab_completion_target("he", &matches);
414        assert_eq!(target.as_deref(), Some("help"));
415    }
416
417    #[test]
418    fn tab_extends_to_common_prefix_with_multiple_matches() {
419        let r = crate::tui::commands::CommandRegistry::default_set();
420        // "co" matches /compact and /cost → common prefix "co"; not a
421        // strict extension of "co", so target is None.
422        let matches = r.search("co");
423        assert_eq!(tab_completion_target("co", &matches), None);
424        // "c" matches /clear /compact /cost → common is "c"; no
425        // extension.
426        let matches = r.search("c");
427        assert_eq!(tab_completion_target("c", &matches), None);
428    }
429
430    #[test]
431    fn tab_with_no_matches_returns_none() {
432        let r = crate::tui::commands::CommandRegistry::default_set();
433        let matches = r.search("zz");
434        assert!(tab_completion_target("zz", &matches).is_none());
435    }
436
437    #[test]
438    fn up_down_moves_selection() {
439        let mut app = fresh_command_app();
440        // "c" matches 3 entries: clear, compact, cost.
441        app.prompt.buffer = "c".into();
442        app.prompt.cursor = 1;
443
444        // Down enters the menu and selects the first item.
445        let _ = app.handle_key(key(KeyCode::Down));
446        assert_eq!(app.command_menu_selected, Some(0));
447        let _ = app.handle_key(key(KeyCode::Down));
448        assert_eq!(app.command_menu_selected, Some(1));
449        let _ = app.handle_key(key(KeyCode::Up));
450        assert_eq!(app.command_menu_selected, Some(0));
451        // Up at the top wraps off (None).
452        let _ = app.handle_key(key(KeyCode::Up));
453        assert_eq!(app.command_menu_selected, None);
454    }
455
456    #[test]
457    fn enter_runs_selected_command() {
458        let mut app = fresh_command_app();
459        app.prompt.buffer = "c".into();
460        app.prompt.cursor = 1;
461        // Select index 0 → /clear.
462        let _ = app.handle_key(key(KeyCode::Down));
463        assert_eq!(app.command_menu_selected, Some(0));
464        let _ = app.handle_key(key(KeyCode::Enter));
465        // /clear resets the transcript to a single "cleared" block.
466        assert_eq!(app.blocks.len(), 1);
467        match &app.blocks[0] {
468            crate::tui::app::TranscriptBlock::System { text } => {
469                assert!(text.contains("cleared"), "got {text:?}")
470            }
471            other => panic!("expected System, got {other:?}"),
472        }
473    }
474
475    #[test]
476    fn tab_completes_buffer() {
477        let mut app = fresh_command_app();
478        app.prompt.buffer = "he".into();
479        app.prompt.cursor = 2;
480        let _ = app.handle_key(key(KeyCode::Tab));
481        // Buffer extends to "help".
482        assert_eq!(app.prompt.buffer, "help");
483    }
484
485    #[test]
486    fn esc_clears_buffer_and_exits_command_mode() {
487        let mut app = fresh_command_app();
488        app.prompt.buffer = "co".into();
489        app.prompt.cursor = 2;
490        // App's existing Esc behaviour clears buffer + reverts to
491        // Prompt; we just verify it's not blocked by the menu logic.
492        let _ = app.handle_key(key(KeyCode::Esc));
493        assert!(app.prompt.buffer.is_empty());
494        assert_eq!(app.prompt.mode, InputMode::Prompt);
495    }
496}