agent_core/tui/widgets/
slash_popup.rs

1//! Slash command popup widget and state
2//!
3//! Displays an interactive popup when the user types `/` in the input box.
4//! Shows filtered commands with keyboard navigation.
5//!
6//! This is a generic implementation - applications provide their own command
7//! definitions via the SlashCommand trait.
8
9use ratatui::{
10    layout::Rect,
11    text::{Line, Span},
12    widgets::{Block, Borders, Clear, Paragraph},
13    Frame,
14};
15
16use crate::tui::themes::Theme;
17
18/// Default configuration values for SlashPopup
19pub mod defaults {
20    /// Header text shown at top of popup
21    pub const HEADER_TEXT: &str = " Slash command mode \u{2014} Use arrow keys to select, Enter to execute, Esc to cancel";
22    /// Message when no commands match
23    pub const NO_MATCHES_MESSAGE: &str = " No matching commands";
24    /// Command prefix (the slash)
25    pub const COMMAND_PREFIX: &str = " /";
26    /// Description indent
27    pub const DESCRIPTION_INDENT: &str = " ";
28}
29
30/// Configuration for SlashPopup widget
31#[derive(Clone)]
32pub struct SlashPopupConfig {
33    /// Header text shown at top of popup
34    pub header_text: String,
35    /// Message when no commands match the filter
36    pub no_matches_message: String,
37    /// Prefix shown before command names
38    pub command_prefix: String,
39    /// Indent for description lines
40    pub description_indent: String,
41}
42
43impl Default for SlashPopupConfig {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49impl SlashPopupConfig {
50    /// Create a new SlashPopupConfig with default values
51    pub fn new() -> Self {
52        Self {
53            header_text: defaults::HEADER_TEXT.to_string(),
54            no_matches_message: defaults::NO_MATCHES_MESSAGE.to_string(),
55            command_prefix: defaults::COMMAND_PREFIX.to_string(),
56            description_indent: defaults::DESCRIPTION_INDENT.to_string(),
57        }
58    }
59
60    /// Set the header text
61    pub fn with_header_text(mut self, text: impl Into<String>) -> Self {
62        self.header_text = text.into();
63        self
64    }
65
66    /// Set the no matches message
67    pub fn with_no_matches_message(mut self, message: impl Into<String>) -> Self {
68        self.no_matches_message = message.into();
69        self
70    }
71
72    /// Set the command prefix
73    pub fn with_command_prefix(mut self, prefix: impl Into<String>) -> Self {
74        self.command_prefix = prefix.into();
75        self
76    }
77}
78
79/// Trait for displaying slash commands in the popup.
80///
81/// This is a simple trait that just requires name and description.
82/// The full `SlashCommand` trait from `commands` module extends this.
83pub trait SlashCommandDisplay {
84    /// The command name (without the leading /)
85    fn name(&self) -> &str;
86
87    /// A short description of what the command does
88    fn description(&self) -> &str;
89}
90
91// Blanket impl: any commands::SlashCommand also implements SlashCommandDisplay
92impl<T: crate::tui::commands::SlashCommand + ?Sized> SlashCommandDisplay for T {
93    fn name(&self) -> &str {
94        crate::tui::commands::SlashCommand::name(self)
95    }
96
97    fn description(&self) -> &str {
98        crate::tui::commands::SlashCommand::description(self)
99    }
100}
101
102// Impl for references to dyn SlashCommand
103impl SlashCommandDisplay for &dyn crate::tui::commands::SlashCommand {
104    fn name(&self) -> &str {
105        crate::tui::commands::SlashCommand::name(*self)
106    }
107
108    fn description(&self) -> &str {
109        crate::tui::commands::SlashCommand::description(*self)
110    }
111}
112
113/// State for the slash command popup
114pub struct SlashPopupState {
115    /// Whether the popup is currently visible
116    pub active: bool,
117    /// Index of the currently selected command
118    pub selected_index: usize,
119    /// Number of filtered commands (for bounds checking)
120    filtered_count: usize,
121    /// Configuration for display customization
122    config: SlashPopupConfig,
123}
124
125impl SlashPopupState {
126    /// Create a new slash popup with default configuration.
127    pub fn new() -> Self {
128        Self::with_config(SlashPopupConfig::new())
129    }
130
131    /// Create a new slash popup with custom configuration
132    pub fn with_config(config: SlashPopupConfig) -> Self {
133        Self {
134            active: false,
135            selected_index: 0,
136            filtered_count: 0,
137            config,
138        }
139    }
140
141    /// Get the current configuration
142    pub fn config(&self) -> &SlashPopupConfig {
143        &self.config
144    }
145
146    /// Set a new configuration
147    pub fn set_config(&mut self, config: SlashPopupConfig) {
148        self.config = config;
149    }
150
151    /// Activate popup and reset state
152    pub fn activate(&mut self) {
153        self.active = true;
154        self.selected_index = 0;
155    }
156
157    /// Deactivate popup
158    pub fn deactivate(&mut self) {
159        self.active = false;
160        self.selected_index = 0;
161        self.filtered_count = 0;
162    }
163
164    /// Update the filtered command count and clamp selection
165    pub fn set_filtered_count(&mut self, count: usize) {
166        self.filtered_count = count;
167        if count > 0 {
168            self.selected_index = self.selected_index.min(count - 1);
169        } else {
170            self.selected_index = 0;
171        }
172    }
173
174    /// Move selection up (with wrap)
175    pub fn select_previous(&mut self) {
176        if self.filtered_count == 0 {
177            return;
178        }
179        if self.selected_index == 0 {
180            self.selected_index = self.filtered_count - 1;
181        } else {
182            self.selected_index -= 1;
183        }
184    }
185
186    /// Move selection down (with wrap)
187    pub fn select_next(&mut self) {
188        if self.filtered_count == 0 {
189            return;
190        }
191        self.selected_index = (self.selected_index + 1) % self.filtered_count;
192    }
193
194    /// Get the currently selected index
195    pub fn selected_index(&self) -> usize {
196        self.selected_index
197    }
198
199    /// Calculate the popup height based on filtered commands
200    pub fn popup_height(&self, max_height: u16) -> u16 {
201        let filtered_count = self.filtered_count.max(1);
202        // header(1) + blank(1) + commands * 3 lines each + border(2)
203        let content_height = 2 + (filtered_count * 3) + 2;
204        (content_height as u16).min(max_height.saturating_sub(10))
205    }
206}
207
208impl Default for SlashPopupState {
209    fn default() -> Self {
210        Self::new()
211    }
212}
213
214// --- Widget trait implementation ---
215
216use std::any::Any;
217use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
218use super::{widget_ids, Widget, WidgetAction, WidgetKeyContext, WidgetKeyResult};
219
220/// Result of handling a key event in the slash popup
221#[derive(Debug, Clone, PartialEq)]
222pub enum SlashKeyAction {
223    /// No action taken
224    None,
225    /// Navigation (up/down) handled
226    Navigated,
227    /// Command selected at given index
228    Selected(usize),
229    /// Popup was cancelled
230    Cancelled,
231    /// Character typed (for filtering) - not consumed, App should handle
232    CharTyped(char),
233    /// Backspace pressed - not consumed, App should handle
234    Backspace,
235}
236
237impl SlashPopupState {
238    /// Handle a key event
239    ///
240    /// Returns the action. For CharTyped and Backspace, the App needs to
241    /// update the input buffer and filtered commands.
242    pub fn process_key(&mut self, key: KeyEvent) -> SlashKeyAction {
243        if !self.active {
244            return SlashKeyAction::None;
245        }
246
247        match key.code {
248            KeyCode::Up => {
249                self.select_previous();
250                SlashKeyAction::Navigated
251            }
252            KeyCode::Down => {
253                self.select_next();
254                SlashKeyAction::Navigated
255            }
256            KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
257                self.select_previous();
258                SlashKeyAction::Navigated
259            }
260            KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
261                self.select_next();
262                SlashKeyAction::Navigated
263            }
264            KeyCode::Enter => {
265                let idx = self.selected_index;
266                SlashKeyAction::Selected(idx)
267            }
268            KeyCode::Esc => {
269                self.deactivate();
270                SlashKeyAction::Cancelled
271            }
272            KeyCode::Backspace => SlashKeyAction::Backspace,
273            KeyCode::Char(c) => SlashKeyAction::CharTyped(c),
274            _ => {
275                self.deactivate();
276                SlashKeyAction::Cancelled
277            }
278        }
279    }
280}
281
282impl Widget for SlashPopupState {
283    fn id(&self) -> &'static str {
284        widget_ids::SLASH_POPUP
285    }
286
287    fn priority(&self) -> u8 {
288        150 // Medium-high priority
289    }
290
291    fn is_active(&self) -> bool {
292        self.active
293    }
294
295    fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
296        if !self.active {
297            return WidgetKeyResult::NotHandled;
298        }
299
300        // Use NavigationHelper for key bindings
301        if ctx.nav.is_move_up(&key) {
302            self.select_previous();
303            return WidgetKeyResult::Handled;
304        }
305        if ctx.nav.is_move_down(&key) {
306            self.select_next();
307            return WidgetKeyResult::Handled;
308        }
309        if ctx.nav.is_select(&key) {
310            let idx = self.selected_index;
311            return WidgetKeyResult::Action(WidgetAction::ExecuteCommand {
312                command: format!("__SLASH_INDEX_{}", idx),
313            });
314        }
315        if ctx.nav.is_cancel(&key) {
316            self.deactivate();
317            return WidgetKeyResult::Action(WidgetAction::Close);
318        }
319
320        // Handle special keys not covered by nav helper
321        match key.code {
322            KeyCode::Backspace => WidgetKeyResult::NotHandled,
323            KeyCode::Char(_) => WidgetKeyResult::NotHandled,
324            _ => {
325                // Unknown key - cancel the popup
326                self.deactivate();
327                WidgetKeyResult::Action(WidgetAction::Close)
328            }
329        }
330    }
331
332    fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
333        // Note: This is a simplified render that doesn't have commands.
334        // App should use render_slash_popup directly with filtered commands.
335        // This default render shows an empty state.
336        if self.active {
337            render_slash_popup(self, &[] as &[SimpleCommand], frame, area, theme);
338        }
339    }
340
341    fn required_height(&self, max_height: u16) -> u16 {
342        if self.active {
343            self.popup_height(max_height)
344        } else {
345            0
346        }
347    }
348
349    fn blocks_input(&self) -> bool {
350        self.active // Block input when popup is active so keys reach the widget
351    }
352
353    fn is_overlay(&self) -> bool {
354        false // Renders in dedicated area
355    }
356
357    fn as_any(&self) -> &dyn Any {
358        self
359    }
360
361    fn as_any_mut(&mut self) -> &mut dyn Any {
362        self
363    }
364
365    fn into_any(self: Box<Self>) -> Box<dyn Any> {
366        self
367    }
368}
369
370/// Render the slash command popup
371///
372/// # Arguments
373/// * `state` - The popup state
374/// * `commands` - The filtered list of commands to display
375/// * `frame` - The ratatui frame
376/// * `area` - The area to render in
377/// * `theme` - The theme to use
378pub fn render_slash_popup<C: SlashCommandDisplay>(
379    state: &SlashPopupState,
380    commands: &[C],
381    frame: &mut Frame,
382    area: Rect,
383    theme: &Theme,
384) {
385    if !state.active {
386        return;
387    }
388
389    // Calculate available width inside borders (area width - 2 for left/right borders)
390    let inner_width = area.width.saturating_sub(2) as usize;
391
392    // Build lines: header + commands (all with leading space for padding)
393    let mut lines = Vec::new();
394
395    // Header line with leading space
396    lines.push(Line::from(vec![Span::styled(
397        state.config.header_text.clone(),
398        theme.popup_header(),
399    )]));
400    lines.push(Line::from("")); // Blank line after header
401
402    // Command list
403    for (idx, cmd) in commands.iter().enumerate() {
404        let is_selected = idx == state.selected_index;
405
406        // Command name line with leading space, padded to full width for selected
407        let name_text = format!("{}{}", state.config.command_prefix, cmd.name());
408        let name_style = if is_selected {
409            theme.popup_selected_bg().patch(theme.popup_item_selected())
410        } else {
411            theme.popup_item()
412        };
413        if is_selected {
414            // Pad to full width for full-row highlight
415            let padded = format!("{:<width$}", name_text, width = inner_width);
416            lines.push(Line::from(Span::styled(padded, name_style)));
417        } else {
418            lines.push(Line::from(Span::styled(name_text, name_style)));
419        }
420
421        // Description line with leading space, padded to full width for selected
422        let desc_text = format!("{}{}", state.config.description_indent, cmd.description());
423        let desc_style = if is_selected {
424            theme.popup_selected_bg().patch(theme.popup_item_desc_selected())
425        } else {
426            theme.popup_item_desc()
427        };
428        if is_selected {
429            // Pad to full width for full-row highlight
430            let padded = format!("{:<width$}", desc_text, width = inner_width);
431            lines.push(Line::from(Span::styled(padded, desc_style)));
432        } else {
433            lines.push(Line::from(Span::styled(desc_text, desc_style)));
434        }
435
436        // Blank line between commands (except last)
437        if idx < commands.len() - 1 {
438            lines.push(Line::from(""));
439        }
440    }
441
442    // If no matches, show empty message
443    if commands.is_empty() {
444        lines.push(Line::from(Span::styled(
445            state.config.no_matches_message.clone(),
446            theme.popup_empty(),
447        )));
448    }
449
450    let block = Block::default()
451        .borders(Borders::ALL)
452        .border_style(theme.popup_border());
453
454    // Clear the area first (for overlay effect)
455    frame.render_widget(Clear, area);
456
457    let popup = Paragraph::new(lines).block(block);
458    frame.render_widget(popup, area);
459}
460
461/// A simple command implementation for testing
462#[derive(Clone)]
463pub struct SimpleCommand {
464    name: String,
465    description: String,
466}
467
468impl SimpleCommand {
469    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
470        Self {
471            name: name.into(),
472            description: description.into(),
473        }
474    }
475}
476
477impl SlashCommandDisplay for SimpleCommand {
478    fn name(&self) -> &str {
479        &self.name
480    }
481
482    fn description(&self) -> &str {
483        &self.description
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn test_popup_state_navigation() {
493        let mut state = SlashPopupState::new();
494        state.activate();
495        state.set_filtered_count(3);
496
497        assert_eq!(state.selected_index, 0);
498
499        state.select_next();
500        assert_eq!(state.selected_index, 1);
501
502        state.select_next();
503        assert_eq!(state.selected_index, 2);
504
505        // Wrap around
506        state.select_next();
507        assert_eq!(state.selected_index, 0);
508
509        // Wrap backward
510        state.select_previous();
511        assert_eq!(state.selected_index, 2);
512    }
513
514    #[test]
515    fn test_popup_state_empty() {
516        let mut state = SlashPopupState::new();
517        state.activate();
518        state.set_filtered_count(0);
519
520        // Should not crash on empty list
521        state.select_next();
522        state.select_previous();
523        assert_eq!(state.selected_index, 0);
524    }
525
526    #[test]
527    fn test_simple_command() {
528        let cmd = SimpleCommand::new("help", "Show help message");
529        assert_eq!(cmd.name(), "help");
530        assert_eq!(cmd.description(), "Show help message");
531    }
532}