Skip to main content

imp_tui/views/
command_palette.rs

1use std::collections::BTreeMap;
2
3use ratatui::buffer::Buffer;
4use ratatui::layout::Rect;
5use ratatui::style::{Modifier, Style};
6use ratatui::text::{Line, Span};
7use ratatui::widgets::{Block, Borders, Clear, Widget};
8
9use crate::theme::Theme;
10
11/// A slash command definition.
12#[derive(Debug, Clone)]
13pub struct SlashCommand {
14    pub name: String,
15    pub description: String,
16}
17
18/// Merge built-in and extension-provided slash commands for discovery menus.
19pub fn merge_extension_commands(
20    mut commands: Vec<SlashCommand>,
21    extension_commands: impl IntoIterator<Item = (String, String)>,
22) -> Vec<SlashCommand> {
23    let mut by_name: BTreeMap<String, SlashCommand> = commands
24        .drain(..)
25        .map(|command| (command.name.clone(), command))
26        .collect();
27
28    for (name, description) in extension_commands {
29        by_name.entry(name.clone()).or_insert_with(|| SlashCommand {
30            name,
31            description: if description.trim().is_empty() {
32                "Extension command".into()
33            } else {
34                description
35            },
36        });
37    }
38
39    by_name.into_values().collect()
40}
41
42/// Merge skill commands into the slash menu without overriding real commands.
43pub fn merge_skill_commands(
44    mut commands: Vec<SlashCommand>,
45    skills: impl IntoIterator<Item = (String, String)>,
46) -> Vec<SlashCommand> {
47    let mut by_name: BTreeMap<String, SlashCommand> = commands
48        .drain(..)
49        .map(|command| (command.name.clone(), command))
50        .collect();
51
52    for (name, description) in skills {
53        by_name.entry(name.clone()).or_insert_with(|| SlashCommand {
54            name,
55            description: if description.trim().is_empty() {
56                "Skill".into()
57            } else {
58                format!("Skill: {description}")
59            },
60        });
61    }
62
63    by_name.into_values().collect()
64}
65
66pub fn builtin_commands() -> Vec<SlashCommand> {
67    vec![
68        SlashCommand {
69            name: "improve".into(),
70            description: "Switch workflow mode to Improve in a sandbox branch/worktree".into(),
71        },
72        SlashCommand {
73            name: "improve-safe".into(),
74            description: "Switch workflow mode to research-only Improve".into(),
75        },
76        SlashCommand {
77            name: "improve-merge".into(),
78            description: "Merge active Improve branch after reviewing changelog".into(),
79        },
80        SlashCommand {
81            name: "improve-help".into(),
82            description: "Explain Improve autoresearch guardrails".into(),
83        },
84        SlashCommand {
85            name: "status".into(),
86            description: "Show active imp work status".into(),
87        },
88        SlashCommand {
89            name: "autonomy".into(),
90            description: "Set autonomy mode (/autonomy safe|local-auto|allow-all-local)".into(),
91        },
92        SlashCommand {
93            name: "clean".into(),
94            description: "Clean active sandbox/artifacts safely".into(),
95        },
96        SlashCommand {
97            name: "loop".into(),
98            description: "Loop a prompt (/loop <message>)".into(),
99        },
100        SlashCommand {
101            name: "run".into(),
102            description: "Set active mana run (/run <id>, /run clear)".into(),
103        },
104        SlashCommand {
105            name: "stop".into(),
106            description: "Stop active imp work".into(),
107        },
108        SlashCommand {
109            name: "scope".into(),
110            description: "Set active mana scope (/scope <id>, /scope clear)".into(),
111        },
112        SlashCommand {
113            name: "model".into(),
114            description: "Select model".into(),
115        },
116        SlashCommand {
117            name: "settings".into(),
118            description: "Open settings".into(),
119        },
120        SlashCommand {
121            name: "mana".into(),
122            description: "Open mana work graph navigator".into(),
123        },
124        SlashCommand {
125            name: "tree".into(),
126            description: "Session tree view".into(),
127        },
128        SlashCommand {
129            name: "fork".into(),
130            description: "Fork session at current point".into(),
131        },
132        SlashCommand {
133            name: "compact".into(),
134            description: "Compact context".into(),
135        },
136        SlashCommand {
137            name: "new".into(),
138            description: "New session".into(),
139        },
140        SlashCommand {
141            name: "resume".into(),
142            description: "Resume/search sessions".into(),
143        },
144        SlashCommand {
145            name: "name".into(),
146            description: "Name current session".into(),
147        },
148        SlashCommand {
149            name: "copy".into(),
150            description: "Copy last response".into(),
151        },
152        SlashCommand {
153            name: "export".into(),
154            description: "Export session".into(),
155        },
156        SlashCommand {
157            name: "personality".into(),
158            description: "Customize imp personality".into(),
159        },
160        SlashCommand {
161            name: "memory".into(),
162            description: "View/edit agent memory".into(),
163        },
164        SlashCommand {
165            name: "checkpoints".into(),
166            description: "List recorded file checkpoints".into(),
167        },
168        SlashCommand {
169            name: "restore-checkpoint".into(),
170            description: "Restore files from a checkpoint by id or label".into(),
171        },
172        SlashCommand {
173            name: "reload".into(),
174            description: "Reload extensions".into(),
175        },
176        SlashCommand {
177            name: "hotkeys".into(),
178            description: "Show keyboard shortcuts".into(),
179        },
180        SlashCommand {
181            name: "login".into(),
182            description: "OAuth login for Anthropic or OpenAI/ChatGPT".into(),
183        },
184        SlashCommand {
185            name: "secrets".into(),
186            description: "Configure API keys / multi-field service secrets".into(),
187        },
188        SlashCommand {
189            name: "setup".into(),
190            description: "Run setup wizard".into(),
191        },
192        SlashCommand {
193            name: "quit".into(),
194            description: "Quit".into(),
195        },
196    ]
197}
198
199/// State for the command palette.
200#[derive(Debug, Clone)]
201pub struct CommandPaletteState {
202    pub commands: Vec<SlashCommand>,
203    pub filter: String,
204    pub selected: usize,
205}
206
207impl CommandPaletteState {
208    pub fn new(commands: Vec<SlashCommand>) -> Self {
209        Self {
210            commands,
211            filter: String::new(),
212            selected: 0,
213        }
214    }
215
216    pub fn filtered(&self) -> Vec<&SlashCommand> {
217        if self.filter.is_empty() {
218            self.commands.iter().collect()
219        } else {
220            let lower = self.filter.to_lowercase();
221            let mut results: Vec<(usize, &SlashCommand)> = self
222                .commands
223                .iter()
224                .filter_map(|c| {
225                    let name = c.name.to_lowercase();
226                    let desc = c.description.to_lowercase();
227                    // Exact prefix gets priority 0, contains gets 1, description match gets 2
228                    if name.starts_with(&lower) {
229                        Some((0, c))
230                    } else if name.contains(&lower) {
231                        Some((1, c))
232                    } else if desc.contains(&lower) {
233                        Some((2, c))
234                    } else {
235                        None
236                    }
237                })
238                .collect();
239            results.sort_by_key(|(priority, _)| *priority);
240            results.into_iter().map(|(_, c)| c).collect()
241        }
242    }
243
244    pub fn move_up(&mut self) {
245        if self.selected > 0 {
246            self.selected -= 1;
247        }
248    }
249
250    pub fn move_down(&mut self) {
251        let count = self.filtered().len();
252        if self.selected + 1 < count {
253            self.selected += 1;
254        }
255    }
256
257    pub fn push_filter(&mut self, c: char) {
258        self.filter.push(c);
259        self.selected = 0;
260    }
261
262    pub fn pop_filter(&mut self) {
263        self.filter.pop();
264        self.selected = 0;
265    }
266
267    pub fn selected_command(&self) -> Option<&SlashCommand> {
268        let filtered = self.filtered();
269        filtered.get(self.selected).copied()
270    }
271}
272
273/// Command palette overlay widget (shown above the editor).
274pub struct CommandPaletteView<'a> {
275    state: &'a CommandPaletteState,
276    theme: &'a Theme,
277}
278
279impl<'a> CommandPaletteView<'a> {
280    pub fn new(state: &'a CommandPaletteState, theme: &'a Theme) -> Self {
281        Self { state, theme }
282    }
283}
284
285impl Widget for CommandPaletteView<'_> {
286    fn render(self, area: Rect, buf: &mut Buffer) {
287        if area.height < 3 || area.width < 20 {
288            return;
289        }
290
291        Clear.render(area, buf);
292
293        let title = if self.state.filter.is_empty() {
294            " Commands ".to_string()
295        } else {
296            format!(" /{} ", self.state.filter)
297        };
298
299        let block = Block::default()
300            .title(title)
301            .borders(Borders::ALL)
302            .border_style(self.theme.accent_style());
303        let inner = block.inner(area);
304        block.render(area, buf);
305
306        let filtered = self.state.filtered();
307        let total = filtered.len();
308
309        if total == 0 {
310            let line = Line::from(Span::styled(
311                "  No matching commands",
312                self.theme.muted_style(),
313            ));
314            buf.set_line(inner.x, inner.y, &line, inner.width);
315            return;
316        }
317
318        // Find the longest command name for alignment
319        let max_name_len = filtered.iter().map(|c| c.name.len()).max().unwrap_or(0);
320
321        // Scroll to keep selected visible
322        let visible = inner.height as usize;
323        let scroll_offset = if self.state.selected >= visible {
324            self.state.selected - visible + 1
325        } else {
326            0
327        };
328
329        for (i, cmd) in filtered.iter().skip(scroll_offset).enumerate() {
330            if i >= visible {
331                break;
332            }
333
334            let abs_idx = scroll_offset + i;
335            let is_selected = abs_idx == self.state.selected;
336
337            // Selection indicator
338            let indicator = if is_selected { " ▸ " } else { "   " };
339
340            // Build the command name with / prefix, padded for alignment
341            let name_text = format!("/{:<width$}", cmd.name, width = max_name_len);
342
343            // Build the line with full-row highlight when selected
344            let row_style = if is_selected {
345                self.theme.selected_style()
346            } else {
347                Style::default()
348            };
349
350            let name_style = if is_selected {
351                self.theme.selected_style().add_modifier(Modifier::BOLD)
352            } else {
353                Style::default().add_modifier(Modifier::BOLD)
354            };
355
356            let desc_style = if is_selected {
357                self.theme.selected_style()
358            } else {
359                self.theme.muted_style()
360            };
361
362            let line = Line::from(vec![
363                Span::styled(indicator, row_style),
364                Span::styled(name_text, name_style),
365                Span::styled("  ", row_style),
366                Span::styled(&cmd.description, desc_style),
367            ]);
368
369            // Fill the entire row with the background color first
370            if is_selected {
371                let fill = " ".repeat(inner.width as usize);
372                buf.set_line(
373                    inner.x,
374                    inner.y + i as u16,
375                    &Line::from(Span::styled(fill, row_style)),
376                    inner.width,
377                );
378            }
379
380            buf.set_line(inner.x, inner.y + i as u16, &line, inner.width);
381        }
382
383        // Scroll indicators
384        if scroll_offset > 0 {
385            let hint = Line::from(Span::styled("  ↑ more", self.theme.muted_style()));
386            buf.set_line(inner.x + inner.width.saturating_sub(10), inner.y, &hint, 10);
387        }
388        if scroll_offset + visible < total {
389            let y = inner.y + inner.height.saturating_sub(1);
390            let hint = Line::from(Span::styled("  ↓ more", self.theme.muted_style()));
391            buf.set_line(inner.x + inner.width.saturating_sub(10), y, &hint, 10);
392        }
393
394        // Footer hint
395        if inner.height > 1 && total > 0 {
396            let hint_y = area.y + area.height - 1;
397            let hint_text = " ↑↓/Tab  Enter  Esc ";
398            let hint_x = area.x + area.width.saturating_sub(hint_text.len() as u16 + 1);
399            let hint_line = Line::from(Span::styled(hint_text, self.theme.muted_style()));
400            buf.set_line(hint_x, hint_y, &hint_line, hint_text.len() as u16);
401        }
402    }
403}