agent_core/tui/widgets/
slash_popup.rs1use ratatui::{
10 layout::Rect,
11 text::{Line, Span},
12 widgets::{Block, Borders, Clear, Paragraph},
13 Frame,
14};
15
16use crate::tui::themes::Theme;
17
18pub trait SlashCommand {
22 fn name(&self) -> &str;
24
25 fn description(&self) -> &str;
27}
28
29pub struct SlashPopupState {
31 pub active: bool,
33 pub selected_index: usize,
35 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 pub fn activate(&mut self) {
50 self.active = true;
51 self.selected_index = 0;
52 }
53
54 pub fn deactivate(&mut self) {
56 self.active = false;
57 self.selected_index = 0;
58 self.filtered_count = 0;
59 }
60
61 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 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 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 pub fn selected_index(&self) -> usize {
93 self.selected_index
94 }
95
96 pub fn popup_height(&self, max_height: u16) -> u16 {
98 let filtered_count = self.filtered_count.max(1);
99 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
111use std::any::Any;
114use crossterm::event::{KeyCode, KeyEvent};
115use super::{widget_ids, Widget, WidgetAction, WidgetKeyResult};
116
117#[derive(Debug, Clone, PartialEq)]
119pub enum SlashKeyAction {
120 None,
122 Navigated,
124 Selected(usize),
126 Cancelled,
128 CharTyped(char),
130 Backspace,
132}
133
134impl SlashPopupState {
135 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 }
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 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 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 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 }
226
227 fn is_overlay(&self) -> bool {
228 false }
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
244pub 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 let inner_width = area.width.saturating_sub(2) as usize;
265
266 let mut lines = Vec::new();
268
269 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("")); for (idx, cmd) in commands.iter().enumerate() {
278 let is_selected = idx == state.selected_index;
279
280 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 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 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 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 if idx < commands.len() - 1 {
312 lines.push(Line::from(""));
313 }
314 }
315
316 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 frame.render_widget(Clear, area);
330
331 let popup = Paragraph::new(lines).block(block);
332 frame.render_widget(popup, area);
333}
334
335#[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 state.select_next();
381 assert_eq!(state.selected_index, 0);
382
383 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 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}