Skip to main content

rab/builtin/
commands.rs

1use crate::agent::extension::{
2    AutocompleteItem, CommandHandler, CommandResult, Extension, SlashCommand,
3};
4use crate::agent::session::Session;
5use crate::agent::types::{
6    message_is_assistant, message_is_tool_result, message_is_user, message_tool_call_count,
7    message_usage,
8};
9use std::borrow::Cow;
10use std::sync::{Arc, Mutex};
11
12/// Built-in commands extension - provides all 22 pi slash commands.
13/// Uses the same Extension trait as all other extensions, making built-in
14/// commands indistinguishable from user-provided commands.
15pub struct CommandsExtension {
16    /// Available model identifiers (provider/id), e.g. ["deepseek-v4-flash", "deepseek-v4-pro"]
17    available_models: Vec<String>,
18    /// Current session info for /session command.
19    pub session_info: Arc<Mutex<Option<SessionInfoInternal>>>,
20}
21
22/// Session info passed to commands for display.
23#[derive(Debug, Clone)]
24pub struct SessionInfoInternal {
25    pub session_id: String,
26    pub file_path: Option<std::path::PathBuf>,
27    pub name: Option<String>,
28    pub message_count: usize,
29    pub user_messages: usize,
30    pub assistant_messages: usize,
31    pub tool_calls: usize,
32    pub tool_results: usize,
33    pub total_tokens: u64,
34    pub input_tokens: u64,
35    pub output_tokens: u64,
36    pub cache_read_tokens: u64,
37    pub cache_write_tokens: u64,
38    pub cost: f64,
39}
40
41impl CommandsExtension {
42    pub fn new(available_models: Vec<String>) -> Self {
43        Self {
44            available_models,
45            session_info: Arc::new(Mutex::new(None)),
46        }
47    }
48
49    /// Update the session info that /session will display.
50    pub fn set_session_info(&self, info: SessionInfoInternal) {
51        if let Ok(mut guard) = self.session_info.lock() {
52            *guard = Some(info);
53        }
54    }
55}
56
57/// Compute session info from a Session.
58pub fn compute_session_info(session: &Session) -> SessionInfoInternal {
59    let entries = session.get_entries();
60    let mut message_count: usize = 0;
61    let mut user_messages: usize = 0;
62    let mut assistant_messages: usize = 0;
63    let mut tool_calls: usize = 0;
64    let mut tool_results: usize = 0;
65    let mut total_tokens: u64 = 0;
66    let mut input_tokens: u64 = 0;
67    let mut output_tokens: u64 = 0;
68    let mut cache_read_tokens: u64 = 0;
69    let mut cache_write_tokens: u64 = 0;
70    let mut cost: f64 = 0.0;
71
72    for entry in entries {
73        if let super::super::agent::session::SessionEntry::Message(m) = entry {
74            message_count += 1;
75            if message_is_user(&m.message) {
76                user_messages += 1;
77            } else if message_is_assistant(&m.message) {
78                assistant_messages += 1;
79                let tc_count = message_tool_call_count(&m.message);
80                tool_calls += tc_count;
81            } else if message_is_tool_result(&m.message) {
82                tool_results += 1;
83            }
84            if let Some(usage) = message_usage(&m.message) {
85                input_tokens += usage.input;
86                output_tokens += usage.output;
87                cache_read_tokens += usage.cache_read;
88                cache_write_tokens += usage.cache_write;
89                total_tokens += usage.input + usage.output + usage.cache_read + usage.cache_write;
90                // Rough cost estimate: $2/M input, $8/M output (deepseek pricing)
91                cost += usage.input as f64 * 2.0 / 1_000_000.0
92                    + usage.output as f64 * 8.0 / 1_000_000.0;
93            }
94        }
95    }
96
97    SessionInfoInternal {
98        session_id: session.session_id(),
99        file_path: session.session_file(),
100        name: session.session_name(),
101        message_count,
102        user_messages,
103        assistant_messages,
104        tool_calls,
105        tool_results,
106        total_tokens,
107        input_tokens,
108        output_tokens,
109        cache_read_tokens,
110        cache_write_tokens,
111        cost,
112    }
113}
114
115impl Extension for CommandsExtension {
116    fn name(&self) -> Cow<'static, str> {
117        "commands".into()
118    }
119
120    fn commands(&self) -> Vec<SlashCommand> {
121        vec![
122            SlashCommand {
123                name: "settings".to_string(),
124                description: "Open settings menu".to_string(),
125                handler: Box::new(SettingsCommand),
126            },
127            SlashCommand {
128                name: "model".to_string(),
129                description: "Select model (opens selector UI)".to_string(),
130                handler: Box::new(ModelCommand {
131                    available_models: self.available_models.clone(),
132                }),
133            },
134            SlashCommand {
135                name: "scoped-models".to_string(),
136                description: "Enable/disable models for cycling".to_string(),
137                handler: Box::new(ScopedModelsCommand {
138                    available_models: self.available_models.clone(),
139                }),
140            },
141            SlashCommand {
142                name: "export".to_string(),
143                description: "Export session (HTML default, or specify path: .html/.jsonl)"
144                    .to_string(),
145                handler: Box::new(command_not_implemented_handler("export")),
146            },
147            SlashCommand {
148                name: "import".to_string(),
149                description: "Import and resume a session from a JSONL file".to_string(),
150                handler: Box::new(command_not_implemented_handler("import")),
151            },
152            SlashCommand {
153                name: "copy".to_string(),
154                description: "Copy last agent message to clipboard".to_string(),
155                handler: Box::new(CopyCommand),
156            },
157            SlashCommand {
158                name: "name".to_string(),
159                description: "Set session display name".to_string(),
160                handler: Box::new(NameCommand {
161                    session_info: self.session_info.clone(),
162                }),
163            },
164            SlashCommand {
165                name: "session".to_string(),
166                description: "Show session info and stats".to_string(),
167                handler: Box::new(SessionInfoCommand {
168                    info: self.session_info.clone(),
169                }),
170            },
171            SlashCommand {
172                name: "hotkeys".to_string(),
173                description: "Show all keyboard shortcuts".to_string(),
174                handler: Box::new(command_not_implemented_handler("hotkeys")),
175            },
176            SlashCommand {
177                name: "fork".to_string(),
178                description: "Create a new fork from a previous user message".to_string(),
179                handler: Box::new(ForkCommand),
180            },
181            SlashCommand {
182                name: "clone".to_string(),
183                description: "Duplicate the current session at the current position".to_string(),
184                handler: Box::new(command_not_implemented_handler("clone")),
185            },
186            SlashCommand {
187                name: "tree".to_string(),
188                description: "Navigate session tree (switch branches)".to_string(),
189                handler: Box::new(command_not_implemented_handler("tree")),
190            },
191            SlashCommand {
192                name: "login".to_string(),
193                description: "Configure provider authentication".to_string(),
194                handler: Box::new(LoginCommand),
195            },
196            SlashCommand {
197                name: "logout".to_string(),
198                description: "Remove provider authentication".to_string(),
199                handler: Box::new(LogoutCommand),
200            },
201            SlashCommand {
202                name: "new".to_string(),
203                description: "Start a new session".to_string(),
204                handler: Box::new(NewCommand),
205            },
206            SlashCommand {
207                name: "compact".to_string(),
208                description: "Manually compact the session context".to_string(),
209                handler: Box::new(CompactCommand),
210            },
211            SlashCommand {
212                name: "resume".to_string(),
213                description: "Resume a different session".to_string(),
214                handler: Box::new(command_not_implemented_handler("resume")),
215            },
216            SlashCommand {
217                name: "reload".to_string(),
218                description: "Reload keybindings, extensions, skills, prompts, and themes"
219                    .to_string(),
220                handler: Box::new(ReloadCommand),
221            },
222            SlashCommand {
223                name: "quit".to_string(),
224                description: "Exit rab".to_string(),
225                handler: Box::new(QuitCommand),
226            },
227        ]
228    }
229}
230
231// ── /quit ─────────────────────────────────────────────────────────
232
233struct QuitCommand;
234
235impl CommandHandler for QuitCommand {
236    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
237        Ok(CommandResult::Quit)
238    }
239}
240
241// ── /model ────────────────────────────────────────────────────────
242
243struct ModelCommand {
244    available_models: Vec<String>,
245}
246
247impl CommandHandler for ModelCommand {
248    fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
249        let model = args.trim();
250        if model.is_empty() {
251            Ok(CommandResult::OpenModelSelector)
252        } else {
253            // Validate model exists
254            if self.available_models.iter().any(|m| m == model) {
255                Ok(CommandResult::ModelChanged(model.to_string()))
256            } else {
257                Ok(CommandResult::Info(format!(
258                    "Unknown model: {}. Available: {}",
259                    model,
260                    self.available_models.join(", ")
261                )))
262            }
263        }
264    }
265
266    fn argument_completions(&self, prefix: &str) -> Vec<AutocompleteItem> {
267        let lower = prefix.to_lowercase();
268        self.available_models
269            .iter()
270            .filter(|m| m.to_lowercase().contains(&lower))
271            .map(|m| AutocompleteItem {
272                value: m.clone(),
273                label: m.clone(),
274                description: None,
275            })
276            .collect()
277    }
278}
279
280// ── /settings ─────────────────────────────────────────────────────
281
282struct SettingsCommand;
283
284impl CommandHandler for SettingsCommand {
285    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
286        Ok(CommandResult::OpenSettings)
287    }
288}
289
290// ── /scoped-models ────────────────────────────────────────────────
291
292struct ScopedModelsCommand {
293    available_models: Vec<String>,
294}
295
296impl CommandHandler for ScopedModelsCommand {
297    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
298        Ok(CommandResult::ScopedModels)
299    }
300
301    fn argument_completions(&self, prefix: &str) -> Vec<AutocompleteItem> {
302        let lower = prefix.to_lowercase();
303        self.available_models
304            .iter()
305            .filter(|m| m.to_lowercase().contains(&lower))
306            .map(|m| AutocompleteItem {
307                value: m.clone(),
308                label: m.clone(),
309                description: None,
310            })
311            .collect()
312    }
313}
314
315// ── /reload ───────────────────────────────────────────────────────
316
317struct ReloadCommand;
318
319impl CommandHandler for ReloadCommand {
320    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
321        Ok(CommandResult::Reloaded)
322    }
323}
324
325// ── /new ──────────────────────────────────────────────────────────
326
327struct NewCommand;
328
329impl CommandHandler for NewCommand {
330    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
331        Ok(CommandResult::NewSession)
332    }
333}
334
335// ── /session ──────────────────────────────────────────────────────
336
337struct SessionInfoCommand {
338    info: Arc<Mutex<Option<SessionInfoInternal>>>,
339}
340
341impl CommandHandler for SessionInfoCommand {
342    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
343        let info = self.info.lock().unwrap();
344        match info.as_ref() {
345            Some(si) => Ok(CommandResult::SessionInfo {
346                session_id: si.session_id.clone(),
347                file_path: si.file_path.clone(),
348                name: si.name.clone(),
349                message_count: si.message_count,
350                user_messages: si.user_messages,
351                assistant_messages: si.assistant_messages,
352                tool_calls: si.tool_calls,
353                tool_results: si.tool_results,
354                total_tokens: si.total_tokens,
355                input_tokens: si.input_tokens,
356                output_tokens: si.output_tokens,
357                cache_read_tokens: si.cache_read_tokens,
358                cache_write_tokens: si.cache_write_tokens,
359                cost: si.cost,
360            }),
361            None => Ok(CommandResult::Info(
362                "No active session (use --no-session?)".to_string(),
363            )),
364        }
365    }
366}
367
368// ── /copy ────────────────────────────────────────────────────────
369
370struct CopyCommand;
371
372impl CommandHandler for CopyCommand {
373    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
374        Ok(CommandResult::CopyLastMessage)
375    }
376}
377
378// ── /name ─────────────────────────────────────────────────────────
379
380struct NameCommand {
381    session_info: Arc<Mutex<Option<SessionInfoInternal>>>,
382}
383
384impl CommandHandler for NameCommand {
385    fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
386        let name = args.trim();
387        if name.is_empty() {
388            let info = self.session_info.lock().unwrap();
389            let current_name = info
390                .as_ref()
391                .and_then(|si| si.name.as_deref())
392                .filter(|n| !n.is_empty());
393            return match current_name {
394                Some(n) => Ok(CommandResult::Info(format!("Session name: {}", n))),
395                None => Ok(CommandResult::Info("Usage: /name <name>".to_string())),
396            };
397        }
398        Ok(CommandResult::SessionNamed {
399            name: name.to_string(),
400        })
401    }
402}
403
404// ── /login ────────────────────────────────────────────────────────
405
406struct LoginCommand;
407
408impl CommandHandler for LoginCommand {
409    fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
410        let args = args.trim();
411        if args.is_empty() {
412            // No args — show provider selector
413            return Ok(CommandResult::Login {
414                provider: None,
415                api_key: None,
416            });
417        }
418        // Split on first space: provider [api-key]
419        let (provider, api_key) = match args.split_once(' ') {
420            Some((p, key)) => (p.trim().to_string(), Some(key.trim().to_string())),
421            None => (args.to_string(), None),
422        };
423        Ok(CommandResult::Login {
424            provider: Some(provider),
425            api_key,
426        })
427    }
428}
429
430// ── /logout ───────────────────────────────────────────────────────
431
432struct LogoutCommand;
433
434impl CommandHandler for LogoutCommand {
435    fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
436        let provider = args.trim();
437        Ok(CommandResult::Logout {
438            provider: if provider.is_empty() {
439                None
440            } else {
441                Some(provider.to_string())
442            },
443        })
444    }
445}
446
447// ── /fork ─────────────────────────────────────────────────────────
448
449struct ForkCommand;
450
451impl CommandHandler for ForkCommand {
452    fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
453        let msg_id = args.trim();
454        if msg_id.is_empty() {
455            Ok(CommandResult::ForkSession { message_id: None })
456        } else {
457            Ok(CommandResult::ForkSession {
458                message_id: Some(msg_id.to_string()),
459            })
460        }
461    }
462}
463
464// ── /compact ──────────────────────────────────────────────────────
465
466struct CompactCommand;
467
468impl CommandHandler for CompactCommand {
469    fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
470        let custom_instructions = if args.trim().is_empty() {
471            None
472        } else {
473            Some(args.trim().to_string())
474        };
475        Ok(CommandResult::CompactSession(custom_instructions))
476    }
477}
478
479// ── Not Implemented handler factory ──────────────────────────────
480
481struct NotImplementedHandler {
482    name: String,
483}
484
485impl CommandHandler for NotImplementedHandler {
486    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
487        Ok(CommandResult::Info(format!(
488            "/{} - not implemented yet.",
489            self.name
490        )))
491    }
492}
493
494fn command_not_implemented_handler(name: &str) -> NotImplementedHandler {
495    NotImplementedHandler {
496        name: name.to_string(),
497    }
498}