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#[derive(Debug, Clone)]
11pub struct SlashCommand {
12 pub name: String,
13 pub description: String,
14}
15
16pub 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#[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 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
176pub 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 let max_name_len = filtered.iter().map(|c| c.name.len()).max().unwrap_or(0);
223
224 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 let indicator = if is_selected { " ▸ " } else { " " };
242
243 let name_text = format!("/{:<width$}", cmd.name, width = max_name_len);
245
246 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 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 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 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}