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/// Trait for slash commands
19///
20/// Applications implement this trait for their command types.
21pub trait SlashCommand {
22    /// The command name (without the leading /)
23    fn name(&self) -> &str;
24
25    /// A short description of what the command does
26    fn description(&self) -> &str;
27}
28
29/// State for the slash command popup
30pub struct SlashPopupState {
31    /// Whether the popup is currently visible
32    pub active: bool,
33    /// Index of the currently selected command
34    pub selected_index: usize,
35    /// Number of filtered commands (for bounds checking)
36    filtered_count: usize,
37}
38
39impl SlashPopupState {
40    pub fn new() -> Self {
41        Self {
42            active: false,
43            selected_index: 0,
44            filtered_count: 0,
45        }
46    }
47
48    /// Activate popup and reset state
49    pub fn activate(&mut self) {
50        self.active = true;
51        self.selected_index = 0;
52    }
53
54    /// Deactivate popup
55    pub fn deactivate(&mut self) {
56        self.active = false;
57        self.selected_index = 0;
58        self.filtered_count = 0;
59    }
60
61    /// Update the filtered command count and clamp selection
62    pub fn set_filtered_count(&mut self, count: usize) {
63        self.filtered_count = count;
64        if count > 0 {
65            self.selected_index = self.selected_index.min(count - 1);
66        } else {
67            self.selected_index = 0;
68        }
69    }
70
71    /// Move selection up (with wrap)
72    pub fn select_previous(&mut self) {
73        if self.filtered_count == 0 {
74            return;
75        }
76        if self.selected_index == 0 {
77            self.selected_index = self.filtered_count - 1;
78        } else {
79            self.selected_index -= 1;
80        }
81    }
82
83    /// Move selection down (with wrap)
84    pub fn select_next(&mut self) {
85        if self.filtered_count == 0 {
86            return;
87        }
88        self.selected_index = (self.selected_index + 1) % self.filtered_count;
89    }
90
91    /// Get the currently selected index
92    pub fn selected_index(&self) -> usize {
93        self.selected_index
94    }
95
96    /// Calculate the popup height based on filtered commands
97    pub fn popup_height(&self, max_height: u16) -> u16 {
98        let filtered_count = self.filtered_count.max(1);
99        // header(1) + blank(1) + commands * 3 lines each + border(2)
100        let content_height = 2 + (filtered_count * 3) + 2;
101        (content_height as u16).min(max_height.saturating_sub(10))
102    }
103}
104
105impl Default for SlashPopupState {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111// --- Widget trait implementation ---
112
113use std::any::Any;
114use crossterm::event::{KeyCode, KeyEvent};
115use super::{widget_ids, Widget, WidgetAction, WidgetKeyResult};
116
117/// Result of handling a key event in the slash popup
118#[derive(Debug, Clone, PartialEq)]
119pub enum SlashKeyAction {
120    /// No action taken
121    None,
122    /// Navigation (up/down) handled
123    Navigated,
124    /// Command selected at given index
125    Selected(usize),
126    /// Popup was cancelled
127    Cancelled,
128    /// Character typed (for filtering) - not consumed, App should handle
129    CharTyped(char),
130    /// Backspace pressed - not consumed, App should handle
131    Backspace,
132}
133
134impl SlashPopupState {
135    /// Handle a key event
136    ///
137    /// Returns the action. For CharTyped and Backspace, the App needs to
138    /// update the input buffer and filtered commands.
139    pub fn process_key(&mut self, key: KeyEvent) -> SlashKeyAction {
140        if !self.active {
141            return SlashKeyAction::None;
142        }
143
144        match key.code {
145            KeyCode::Up => {
146                self.select_previous();
147                SlashKeyAction::Navigated
148            }
149            KeyCode::Down => {
150                self.select_next();
151                SlashKeyAction::Navigated
152            }
153            KeyCode::Enter => {
154                let idx = self.selected_index;
155                SlashKeyAction::Selected(idx)
156            }
157            KeyCode::Esc => {
158                self.deactivate();
159                SlashKeyAction::Cancelled
160            }
161            KeyCode::Backspace => SlashKeyAction::Backspace,
162            KeyCode::Char(c) => SlashKeyAction::CharTyped(c),
163            _ => {
164                self.deactivate();
165                SlashKeyAction::Cancelled
166            }
167        }
168    }
169}
170
171impl Widget for SlashPopupState {
172    fn id(&self) -> &'static str {
173        widget_ids::SLASH_POPUP
174    }
175
176    fn priority(&self) -> u8 {
177        150 // Medium-high priority
178    }
179
180    fn is_active(&self) -> bool {
181        self.active
182    }
183
184    fn handle_key(&mut self, key: KeyEvent, _theme: &Theme) -> WidgetKeyResult {
185        if !self.active {
186            return WidgetKeyResult::NotHandled;
187        }
188
189        match self.process_key(key) {
190            SlashKeyAction::Selected(idx) => {
191                // Note: App still needs to execute the command
192                // We'll return an action that tells App which index was selected
193                WidgetKeyResult::Action(WidgetAction::ExecuteCommand {
194                    command: format!("__SLASH_INDEX_{}", idx),
195                })
196            }
197            SlashKeyAction::Cancelled => WidgetKeyResult::Action(WidgetAction::Close),
198            SlashKeyAction::Navigated => WidgetKeyResult::Handled,
199            // For these, we return NotHandled so App can update input buffer
200            SlashKeyAction::CharTyped(_) | SlashKeyAction::Backspace | SlashKeyAction::None => {
201                WidgetKeyResult::NotHandled
202            }
203        }
204    }
205
206    fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
207        // Note: This is a simplified render that doesn't have commands.
208        // App should use render_slash_popup directly with filtered commands.
209        // This default render shows an empty state.
210        if self.active {
211            render_slash_popup(self, &[] as &[SimpleCommand], frame, area, theme);
212        }
213    }
214
215    fn required_height(&self, max_height: u16) -> u16 {
216        if self.active {
217            self.popup_height(max_height)
218        } else {
219            0
220        }
221    }
222
223    fn blocks_input(&self) -> bool {
224        false // Input continues to work while popup is shown
225    }
226
227    fn is_overlay(&self) -> bool {
228        false // Renders in dedicated area
229    }
230
231    fn as_any(&self) -> &dyn Any {
232        self
233    }
234
235    fn as_any_mut(&mut self) -> &mut dyn Any {
236        self
237    }
238
239    fn into_any(self: Box<Self>) -> Box<dyn Any> {
240        self
241    }
242}
243
244/// Render the slash command popup
245///
246/// # Arguments
247/// * `state` - The popup state
248/// * `commands` - The filtered list of commands to display
249/// * `frame` - The ratatui frame
250/// * `area` - The area to render in
251/// * `theme` - The theme to use
252pub fn render_slash_popup<C: SlashCommand>(
253    state: &SlashPopupState,
254    commands: &[C],
255    frame: &mut Frame,
256    area: Rect,
257    theme: &Theme,
258) {
259    if !state.active {
260        return;
261    }
262
263    // Calculate available width inside borders (area width - 2 for left/right borders)
264    let inner_width = area.width.saturating_sub(2) as usize;
265
266    // Build lines: header + commands (all with leading space for padding)
267    let mut lines = Vec::new();
268
269    // Header line with leading space
270    lines.push(Line::from(vec![Span::styled(
271        " Slash command mode \u{2014} Use arrow keys to select, Enter to execute, Esc to cancel",
272        theme.popup_header(),
273    )]));
274    lines.push(Line::from("")); // Blank line after header
275
276    // Command list
277    for (idx, cmd) in commands.iter().enumerate() {
278        let is_selected = idx == state.selected_index;
279
280        // Command name line with leading space, padded to full width for selected
281        let name_text = format!(" /{}", cmd.name());
282        let name_style = if is_selected {
283            theme.popup_selected_bg().patch(theme.popup_item_selected())
284        } else {
285            theme.popup_item()
286        };
287        if is_selected {
288            // Pad to full width for full-row highlight
289            let padded = format!("{:<width$}", name_text, width = inner_width);
290            lines.push(Line::from(Span::styled(padded, name_style)));
291        } else {
292            lines.push(Line::from(Span::styled(name_text, name_style)));
293        }
294
295        // Description line with leading space, padded to full width for selected
296        let desc_text = format!(" {}", cmd.description());
297        let desc_style = if is_selected {
298            theme.popup_selected_bg().patch(theme.popup_item_desc_selected())
299        } else {
300            theme.popup_item_desc()
301        };
302        if is_selected {
303            // Pad to full width for full-row highlight
304            let padded = format!("{:<width$}", desc_text, width = inner_width);
305            lines.push(Line::from(Span::styled(padded, desc_style)));
306        } else {
307            lines.push(Line::from(Span::styled(desc_text, desc_style)));
308        }
309
310        // Blank line between commands (except last)
311        if idx < commands.len() - 1 {
312            lines.push(Line::from(""));
313        }
314    }
315
316    // If no matches, show "No matching commands" with leading space
317    if commands.is_empty() {
318        lines.push(Line::from(Span::styled(
319            " No matching commands",
320            theme.popup_empty(),
321        )));
322    }
323
324    let block = Block::default()
325        .borders(Borders::ALL)
326        .border_style(theme.popup_border());
327
328    // Clear the area first (for overlay effect)
329    frame.render_widget(Clear, area);
330
331    let popup = Paragraph::new(lines).block(block);
332    frame.render_widget(popup, area);
333}
334
335/// A simple command implementation for testing
336#[derive(Clone)]
337pub struct SimpleCommand {
338    name: String,
339    description: String,
340}
341
342impl SimpleCommand {
343    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
344        Self {
345            name: name.into(),
346            description: description.into(),
347        }
348    }
349}
350
351impl SlashCommand for SimpleCommand {
352    fn name(&self) -> &str {
353        &self.name
354    }
355
356    fn description(&self) -> &str {
357        &self.description
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn test_popup_state_navigation() {
367        let mut state = SlashPopupState::new();
368        state.activate();
369        state.set_filtered_count(3);
370
371        assert_eq!(state.selected_index, 0);
372
373        state.select_next();
374        assert_eq!(state.selected_index, 1);
375
376        state.select_next();
377        assert_eq!(state.selected_index, 2);
378
379        // Wrap around
380        state.select_next();
381        assert_eq!(state.selected_index, 0);
382
383        // Wrap backward
384        state.select_previous();
385        assert_eq!(state.selected_index, 2);
386    }
387
388    #[test]
389    fn test_popup_state_empty() {
390        let mut state = SlashPopupState::new();
391        state.activate();
392        state.set_filtered_count(0);
393
394        // Should not crash on empty list
395        state.select_next();
396        state.select_previous();
397        assert_eq!(state.selected_index, 0);
398    }
399
400    #[test]
401    fn test_simple_command() {
402        let cmd = SimpleCommand::new("help", "Show help message");
403        assert_eq!(cmd.name(), "help");
404        assert_eq!(cmd.description(), "Show help message");
405    }
406}