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