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#[derive(Debug, Clone)]
13pub struct SlashCommand {
14 pub name: String,
15 pub description: String,
16}
17
18pub 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
42pub 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#[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 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
273pub 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 let max_name_len = filtered.iter().map(|c| c.name.len()).max().unwrap_or(0);
320
321 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 let indicator = if is_selected { " ▸ " } else { " " };
339
340 let name_text = format!("/{:<width$}", cmd.name, width = max_name_len);
342
343 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 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 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 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}