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 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                let inp = usage.input;
86                let outp = usage.output;
87                let cache = usage.cache_read;
88                input_tokens += inp;
89                output_tokens += outp;
90                total_tokens += inp + outp;
91                cache_write_tokens += cache;
92                // Rough cost estimate: $2/M input, $8/M output (deepseek pricing)
93                cost += inp as f64 * 2.0 / 1_000_000.0 + outp as f64 * 8.0 / 1_000_000.0;
94            }
95        }
96    }
97
98    SessionInfoInternal {
99        session_id: session.session_id(),
100        file_path: session.session_file(),
101        name: session.session_name(),
102        message_count,
103        user_messages,
104        assistant_messages,
105        tool_calls,
106        tool_results,
107        total_tokens,
108        input_tokens,
109        output_tokens,
110        cache_read_tokens,
111        cache_write_tokens,
112        cost,
113    }
114}
115
116impl Extension for CommandsExtension {
117    fn name(&self) -> Cow<'static, str> {
118        "commands".into()
119    }
120
121    fn commands(&self) -> Vec<SlashCommand> {
122        vec![
123            SlashCommand {
124                name: "settings".to_string(),
125                description: "Open settings menu".to_string(),
126                handler: Box::new(SettingsCommand),
127            },
128            SlashCommand {
129                name: "model".to_string(),
130                description: "Select model (opens selector UI)".to_string(),
131                handler: Box::new(ModelCommand {
132                    available_models: self.available_models.clone(),
133                }),
134            },
135            SlashCommand {
136                name: "scoped-models".to_string(),
137                description: "Enable/disable models for cycling".to_string(),
138                handler: Box::new(ScopedModelsCommand {
139                    available_models: self.available_models.clone(),
140                }),
141            },
142            SlashCommand {
143                name: "export".to_string(),
144                description: "Export session (HTML default, or specify path: .html/.jsonl)"
145                    .to_string(),
146                handler: Box::new(ExportCommand),
147            },
148            SlashCommand {
149                name: "import".to_string(),
150                description: "Import and resume a session from a JSONL file".to_string(),
151                handler: Box::new(ImportCommand),
152            },
153            SlashCommand {
154                name: "share".to_string(),
155                description: "Share session as a secret GitHub gist".to_string(),
156                handler: Box::new(NotImplementedCommand),
157            },
158            SlashCommand {
159                name: "copy".to_string(),
160                description: "Copy last agent message to clipboard".to_string(),
161                handler: Box::new(CopyLastCommand),
162            },
163            SlashCommand {
164                name: "name".to_string(),
165                description: "Set session display name".to_string(),
166                handler: Box::new(NameCommand),
167            },
168            SlashCommand {
169                name: "session".to_string(),
170                description: "Show session info and stats".to_string(),
171                handler: Box::new(SessionInfoCommand {
172                    info: self.session_info.clone(),
173                }),
174            },
175            SlashCommand {
176                name: "changelog".to_string(),
177                description: "Show changelog entries".to_string(),
178                handler: Box::new(ChangelogCommand),
179            },
180            SlashCommand {
181                name: "hotkeys".to_string(),
182                description: "Show all keyboard shortcuts".to_string(),
183                handler: Box::new(HotkeysCommand),
184            },
185            SlashCommand {
186                name: "fork".to_string(),
187                description: "Create a new fork from a previous user message".to_string(),
188                handler: Box::new(ForkCommand),
189            },
190            SlashCommand {
191                name: "clone".to_string(),
192                description: "Duplicate the current session at the current position".to_string(),
193                handler: Box::new(CloneCommand),
194            },
195            SlashCommand {
196                name: "tree".to_string(),
197                description: "Navigate session tree (switch branches)".to_string(),
198                handler: Box::new(TreeCommand),
199            },
200            SlashCommand {
201                name: "trust".to_string(),
202                description: "Save project trust decision for future sessions".to_string(),
203                handler: Box::new(TrustCommand),
204            },
205            SlashCommand {
206                name: "login".to_string(),
207                description: "Configure provider authentication".to_string(),
208                handler: Box::new(LoginCommand),
209            },
210            SlashCommand {
211                name: "logout".to_string(),
212                description: "Remove provider authentication".to_string(),
213                handler: Box::new(LogoutCommand),
214            },
215            SlashCommand {
216                name: "new".to_string(),
217                description: "Start a new session".to_string(),
218                handler: Box::new(NewCommand),
219            },
220            SlashCommand {
221                name: "compact".to_string(),
222                description: "Manually compact the session context".to_string(),
223                handler: Box::new(CompactCommand),
224            },
225            SlashCommand {
226                name: "resume".to_string(),
227                description: "Resume a different session".to_string(),
228                handler: Box::new(ResumeCommand),
229            },
230            SlashCommand {
231                name: "reload".to_string(),
232                description: "Reload keybindings, extensions, skills, prompts, and themes"
233                    .to_string(),
234                handler: Box::new(ReloadCommand),
235            },
236            SlashCommand {
237                name: "quit".to_string(),
238                description: "Exit rab".to_string(),
239                handler: Box::new(QuitCommand),
240            },
241        ]
242    }
243}
244
245// ── /quit ─────────────────────────────────────────────────────────
246
247struct QuitCommand;
248
249impl CommandHandler for QuitCommand {
250    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
251        Ok(CommandResult::Quit)
252    }
253}
254
255// ── /model ────────────────────────────────────────────────────────
256
257struct ModelCommand {
258    available_models: Vec<String>,
259}
260
261impl CommandHandler for ModelCommand {
262    fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
263        let model = args.trim();
264        if model.is_empty() {
265            Ok(CommandResult::Info(format!(
266                "Available models: {}\nUsage: /model <name>",
267                self.available_models.join(", ")
268            )))
269        } else {
270            // Validate model exists
271            if self.available_models.iter().any(|m| m == model) {
272                Ok(CommandResult::ModelChanged(model.to_string()))
273            } else {
274                Ok(CommandResult::Info(format!(
275                    "Unknown model: {}. Available: {}",
276                    model,
277                    self.available_models.join(", ")
278                )))
279            }
280        }
281    }
282
283    fn argument_completions(&self, prefix: &str) -> Vec<AutocompleteItem> {
284        let lower = prefix.to_lowercase();
285        self.available_models
286            .iter()
287            .filter(|m| m.to_lowercase().contains(&lower))
288            .map(|m| AutocompleteItem {
289                value: m.clone(),
290                label: m.clone(),
291                description: None,
292            })
293            .collect()
294    }
295}
296
297// ── /settings ─────────────────────────────────────────────────────
298
299struct SettingsCommand;
300
301impl CommandHandler for SettingsCommand {
302    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
303        Ok(CommandResult::OpenSettings)
304    }
305}
306
307// ── /scoped-models ────────────────────────────────────────────────
308
309struct ScopedModelsCommand {
310    available_models: Vec<String>,
311}
312
313impl CommandHandler for ScopedModelsCommand {
314    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
315        Ok(CommandResult::ScopedModels)
316    }
317
318    fn argument_completions(&self, prefix: &str) -> Vec<AutocompleteItem> {
319        let lower = prefix.to_lowercase();
320        self.available_models
321            .iter()
322            .filter(|m| m.to_lowercase().contains(&lower))
323            .map(|m| AutocompleteItem {
324                value: m.clone(),
325                label: m.clone(),
326                description: None,
327            })
328            .collect()
329    }
330}
331
332// ── /hotkeys ──────────────────────────────────────────────────────
333
334struct HotkeysCommand;
335
336impl CommandHandler for HotkeysCommand {
337    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
338        Ok(CommandResult::ShowHelp)
339    }
340}
341
342// ── /reload ───────────────────────────────────────────────────────
343
344struct ReloadCommand;
345
346impl CommandHandler for ReloadCommand {
347    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
348        Ok(CommandResult::Reloaded)
349    }
350}
351
352// ── /new ──────────────────────────────────────────────────────────
353
354struct NewCommand;
355
356impl CommandHandler for NewCommand {
357    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
358        Ok(CommandResult::NewSession)
359    }
360}
361
362// ── /resume ───────────────────────────────────────────────────────
363
364struct ResumeCommand;
365
366impl CommandHandler for ResumeCommand {
367    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
368        Ok(CommandResult::OpenSessionSelector)
369    }
370}
371
372// ── /session ──────────────────────────────────────────────────────
373
374struct SessionInfoCommand {
375    info: Arc<Mutex<Option<SessionInfoInternal>>>,
376}
377
378impl CommandHandler for SessionInfoCommand {
379    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
380        let info = self.info.lock().unwrap();
381        match info.as_ref() {
382            Some(si) => Ok(CommandResult::SessionInfo {
383                session_id: si.session_id.clone(),
384                file_path: si.file_path.clone(),
385                name: si.name.clone(),
386                message_count: si.message_count,
387                user_messages: si.user_messages,
388                assistant_messages: si.assistant_messages,
389                tool_calls: si.tool_calls,
390                tool_results: si.tool_results,
391                total_tokens: si.total_tokens,
392                input_tokens: si.input_tokens,
393                output_tokens: si.output_tokens,
394                cache_read_tokens: si.cache_read_tokens,
395                cache_write_tokens: si.cache_write_tokens,
396                cost: si.cost,
397            }),
398            None => Ok(CommandResult::Info(
399                "No active session (use --no-session?)".to_string(),
400            )),
401        }
402    }
403}
404
405// ── /name ─────────────────────────────────────────────────────────
406
407struct NameCommand;
408
409impl CommandHandler for NameCommand {
410    fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
411        let name = args.trim();
412        if name.is_empty() {
413            return Ok(CommandResult::Info(
414                "Usage: /name <name> - set session display name".to_string(),
415            ));
416        }
417        Ok(CommandResult::SessionNamed {
418            name: name.to_string(),
419        })
420    }
421}
422
423// ── /trust ────────────────────────────────────────────────────────
424
425struct TrustCommand;
426
427impl CommandHandler for TrustCommand {
428    fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
429        let decision = args.trim();
430        if decision.is_empty() {
431            Ok(CommandResult::Info(
432                "Usage: /trust <auto|always|never> - save project trust decision for future sessions"
433                    .to_string(),
434            ))
435        } else {
436            Ok(CommandResult::TrustDecision {
437                decision: decision.to_string(),
438            })
439        }
440    }
441}
442
443// ── /login ────────────────────────────────────────────────────────
444
445struct LoginCommand;
446
447impl CommandHandler for LoginCommand {
448    fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
449        let provider = args.trim();
450        Ok(CommandResult::Login {
451            provider: if provider.is_empty() {
452                None
453            } else {
454                Some(provider.to_string())
455            },
456        })
457    }
458}
459
460// ── /fork ─────────────────────────────────────────────────────────
461
462struct ForkCommand;
463
464impl CommandHandler for ForkCommand {
465    fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
466        let msg_id = args.trim();
467        if msg_id.is_empty() {
468            Ok(CommandResult::ForkSession { message_id: None })
469        } else {
470            Ok(CommandResult::ForkSession {
471                message_id: Some(msg_id.to_string()),
472            })
473        }
474    }
475}
476
477// ── /compact ──────────────────────────────────────────────────────
478
479struct CompactCommand;
480
481impl CommandHandler for CompactCommand {
482    fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
483        let custom_instructions = if args.trim().is_empty() {
484            None
485        } else {
486            Some(args.trim().to_string())
487        };
488        Ok(CommandResult::CompactSession(custom_instructions))
489    }
490}
491
492// ── /export ──────────────────────────────────────────────────────
493
494struct ExportCommand;
495
496impl CommandHandler for ExportCommand {
497    fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
498        let path = args.trim();
499        Ok(CommandResult::ExportSession {
500            path: if path.is_empty() {
501                None
502            } else {
503                Some(path.to_string())
504            },
505        })
506    }
507}
508
509// ── /import ──────────────────────────────────────────────────────
510
511struct ImportCommand;
512
513impl CommandHandler for ImportCommand {
514    fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
515        let path = args.trim();
516        if path.is_empty() {
517            Ok(CommandResult::Info(
518                "Usage: /import <path> - import and resume a session from a JSONL file".to_string(),
519            ))
520        } else {
521            Ok(CommandResult::ImportSession {
522                path: path.to_string(),
523            })
524        }
525    }
526}
527
528// ── /copy ─────────────────────────────────────────────────────────
529
530struct CopyLastCommand;
531
532impl CommandHandler for CopyLastCommand {
533    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
534        Ok(CommandResult::CopyLastMessage)
535    }
536}
537
538// ── /changelog ────────────────────────────────────────────────────
539
540struct ChangelogCommand;
541
542impl CommandHandler for ChangelogCommand {
543    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
544        Ok(CommandResult::ShowChangelog)
545    }
546}
547
548// ── /clone ────────────────────────────────────────────────────────
549
550struct CloneCommand;
551
552impl CommandHandler for CloneCommand {
553    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
554        Ok(CommandResult::CloneSession)
555    }
556}
557
558// ── /tree ─────────────────────────────────────────────────────────
559
560struct TreeCommand;
561
562impl CommandHandler for TreeCommand {
563    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
564        Ok(CommandResult::SessionTree)
565    }
566}
567
568// ── /logout ───────────────────────────────────────────────────────
569
570struct LogoutCommand;
571
572impl CommandHandler for LogoutCommand {
573    fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
574        let provider = args.trim();
575        Ok(CommandResult::Logout {
576            provider: if provider.is_empty() {
577                None
578            } else {
579                Some(provider.to_string())
580            },
581        })
582    }
583}
584
585// ── Not Implemented fallback ─────────────────────────────────────
586
587struct NotImplementedCommand;
588
589impl CommandHandler for NotImplementedCommand {
590    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
591        Ok(CommandResult::Info("Not implemented yet.".to_string()))
592    }
593}