Skip to main content

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