Skip to main content

imp_tui/views/
command_palette.rs

1use ratatui::buffer::Buffer;
2use ratatui::layout::Rect;
3use ratatui::style::{Modifier, Style};
4use ratatui::text::{Line, Span};
5use ratatui::widgets::{Block, Borders, Clear, Widget};
6
7use crate::theme::Theme;
8
9/// A slash command definition.
10#[derive(Debug, Clone)]
11pub struct SlashCommand {
12    pub name: String,
13    pub description: String,
14}
15
16/// Built-in slash commands.
17pub fn builtin_commands() -> Vec<SlashCommand> {
18    vec![
19        SlashCommand {
20            name: "model".into(),
21            description: "Select model".into(),
22        },
23        SlashCommand {
24            name: "settings".into(),
25            description: "Open settings".into(),
26        },
27        SlashCommand {
28            name: "tree".into(),
29            description: "Session tree view".into(),
30        },
31        SlashCommand {
32            name: "fork".into(),
33            description: "Fork session at current point".into(),
34        },
35        SlashCommand {
36            name: "compact".into(),
37            description: "Compact context".into(),
38        },
39        SlashCommand {
40            name: "new".into(),
41            description: "New session".into(),
42        },
43        SlashCommand {
44            name: "resume".into(),
45            description: "Resume/search sessions".into(),
46        },
47        SlashCommand {
48            name: "name".into(),
49            description: "Name current session".into(),
50        },
51        SlashCommand {
52            name: "copy".into(),
53            description: "Copy last response".into(),
54        },
55        SlashCommand {
56            name: "export".into(),
57            description: "Export session".into(),
58        },
59        SlashCommand {
60            name: "personality".into(),
61            description: "Customize imp personality".into(),
62        },
63        SlashCommand {
64            name: "memory".into(),
65            description: "View/edit agent memory".into(),
66        },
67        SlashCommand {
68            name: "checkpoints".into(),
69            description: "List recorded file checkpoints".into(),
70        },
71        SlashCommand {
72            name: "restore-checkpoint".into(),
73            description: "Restore files from a checkpoint by id or label".into(),
74        },
75        SlashCommand {
76            name: "reload".into(),
77            description: "Reload extensions".into(),
78        },
79        SlashCommand {
80            name: "hotkeys".into(),
81            description: "Show keyboard shortcuts".into(),
82        },
83        SlashCommand {
84            name: "login".into(),
85            description: "OAuth login for Anthropic or OpenAI/ChatGPT".into(),
86        },
87        SlashCommand {
88            name: "secrets".into(),
89            description: "Configure API keys / multi-field service secrets".into(),
90        },
91        SlashCommand {
92            name: "setup".into(),
93            description: "Run setup wizard".into(),
94        },
95        SlashCommand {
96            name: "quit".into(),
97            description: "Quit".into(),
98        },
99    ]
100}
101
102/// State for the command palette.
103#[derive(Debug, Clone)]
104pub struct CommandPaletteState {
105    pub commands: Vec<SlashCommand>,
106    pub filter: String,
107    pub selected: usize,
108}
109
110impl CommandPaletteState {
111    pub fn new(commands: Vec<SlashCommand>) -> Self {
112        Self {
113            commands,
114            filter: String::new(),
115            selected: 0,
116        }
117    }
118
119    pub fn filtered(&self) -> Vec<&SlashCommand> {
120        if self.filter.is_empty() {
121            self.commands.iter().collect()
122        } else {
123            let lower = self.filter.to_lowercase();
124            let mut results: Vec<(usize, &SlashCommand)> = self
125                .commands
126                .iter()
127                .filter_map(|c| {
128                    let name = c.name.to_lowercase();
129                    let desc = c.description.to_lowercase();
130                    // Exact prefix gets priority 0, contains gets 1, description match gets 2
131                    if name.starts_with(&lower) {
132                        Some((0, c))
133                    } else if name.contains(&lower) {
134                        Some((1, c))
135                    } else if desc.contains(&lower) {
136                        Some((2, c))
137                    } else {
138                        None
139                    }
140                })
141                .collect();
142            results.sort_by_key(|(priority, _)| *priority);
143            results.into_iter().map(|(_, c)| c).collect()
144        }
145    }
146
147    pub fn move_up(&mut self) {
148        if self.selected > 0 {
149            self.selected -= 1;
150        }
151    }
152
153    pub fn move_down(&mut self) {
154        let count = self.filtered().len();
155        if self.selected + 1 < count {
156            self.selected += 1;
157        }
158    }
159
160    pub fn push_filter(&mut self, c: char) {
161        self.filter.push(c);
162        self.selected = 0;
163    }
164
165    pub fn pop_filter(&mut self) {
166        self.filter.pop();
167        self.selected = 0;
168    }
169
170    pub fn selected_command(&self) -> Option<&SlashCommand> {
171        let filtered = self.filtered();
172        filtered.get(self.selected).copied()
173    }
174}
175
176/// Command palette overlay widget (shown above the editor).
177pub struct CommandPaletteView<'a> {
178    state: &'a CommandPaletteState,
179    theme: &'a Theme,
180}
181
182impl<'a> CommandPaletteView<'a> {
183    pub fn new(state: &'a CommandPaletteState, theme: &'a Theme) -> Self {
184        Self { state, theme }
185    }
186}
187
188impl Widget for CommandPaletteView<'_> {
189    fn render(self, area: Rect, buf: &mut Buffer) {
190        if area.height < 3 || area.width < 20 {
191            return;
192        }
193
194        Clear.render(area, buf);
195
196        let title = if self.state.filter.is_empty() {
197            " Commands ".to_string()
198        } else {
199            format!(" /{} ", self.state.filter)
200        };
201
202        let block = Block::default()
203            .title(title)
204            .borders(Borders::ALL)
205            .border_style(self.theme.accent_style());
206        let inner = block.inner(area);
207        block.render(area, buf);
208
209        let filtered = self.state.filtered();
210        let total = filtered.len();
211
212        if total == 0 {
213            let line = Line::from(Span::styled(
214                "  No matching commands",
215                self.theme.muted_style(),
216            ));
217            buf.set_line(inner.x, inner.y, &line, inner.width);
218            return;
219        }
220
221        // Find the longest command name for alignment
222        let max_name_len = filtered.iter().map(|c| c.name.len()).max().unwrap_or(0);
223
224        // Scroll to keep selected visible
225        let visible = inner.height as usize;
226        let scroll_offset = if self.state.selected >= visible {
227            self.state.selected - visible + 1
228        } else {
229            0
230        };
231
232        for (i, cmd) in filtered.iter().skip(scroll_offset).enumerate() {
233            if i >= visible {
234                break;
235            }
236
237            let abs_idx = scroll_offset + i;
238            let is_selected = abs_idx == self.state.selected;
239
240            // Selection indicator
241            let indicator = if is_selected { " ▸ " } else { "   " };
242
243            // Build the command name with / prefix, padded for alignment
244            let name_text = format!("/{:<width$}", cmd.name, width = max_name_len);
245
246            // Build the line with full-row highlight when selected
247            let row_style = if is_selected {
248                self.theme.selected_style()
249            } else {
250                Style::default()
251            };
252
253            let name_style = if is_selected {
254                self.theme.selected_style().add_modifier(Modifier::BOLD)
255            } else {
256                Style::default().add_modifier(Modifier::BOLD)
257            };
258
259            let desc_style = if is_selected {
260                self.theme.selected_style()
261            } else {
262                self.theme.muted_style()
263            };
264
265            let line = Line::from(vec![
266                Span::styled(indicator, row_style),
267                Span::styled(name_text, name_style),
268                Span::styled("  ", row_style),
269                Span::styled(&cmd.description, desc_style),
270            ]);
271
272            // Fill the entire row with the background color first
273            if is_selected {
274                let fill = " ".repeat(inner.width as usize);
275                buf.set_line(
276                    inner.x,
277                    inner.y + i as u16,
278                    &Line::from(Span::styled(fill, row_style)),
279                    inner.width,
280                );
281            }
282
283            buf.set_line(inner.x, inner.y + i as u16, &line, inner.width);
284        }
285
286        // Scroll indicators
287        if scroll_offset > 0 {
288            let hint = Line::from(Span::styled("  ↑ more", self.theme.muted_style()));
289            buf.set_line(inner.x + inner.width.saturating_sub(10), inner.y, &hint, 10);
290        }
291        if scroll_offset + visible < total {
292            let y = inner.y + inner.height.saturating_sub(1);
293            let hint = Line::from(Span::styled("  ↓ more", self.theme.muted_style()));
294            buf.set_line(inner.x + inner.width.saturating_sub(10), y, &hint, 10);
295        }
296
297        // Footer hint
298        if inner.height > 1 && total > 0 {
299            let hint_y = area.y + area.height - 1;
300            let hint_text = " ↑↓/Tab  Enter  Esc ";
301            let hint_x = area.x + area.width.saturating_sub(hint_text.len() as u16 + 1);
302            let hint_line = Line::from(Span::styled(hint_text, self.theme.muted_style()));
303            buf.set_line(hint_x, hint_y, &hint_line, hint_text.len() as u16);
304        }
305    }
306}