Skip to main content

rab/builtin/
commands.rs

1use crate::agent::extension::{
2    AutocompleteItem, CommandHandler, CommandResult, Extension, SlashCommand,
3};
4use std::borrow::Cow;
5use std::sync::{Arc, Mutex};
6
7/// Built-in commands extension — provides /quit, /model, and other core commands.
8/// Uses the same Extension trait as all other extensions, making built-in
9/// commands indistinguishable from user-provided commands.
10pub struct CommandsExtension {
11    /// Available model identifiers (provider/id), e.g. ["deepseek-v4-flash", "deepseek-v4-pro"]
12    available_models: Vec<String>,
13    /// Current session info for /session command.
14    pub session_info: Arc<Mutex<Option<SessionInfoInternal>>>,
15}
16
17/// Session info passed to commands for display.
18#[derive(Debug, Clone)]
19pub struct SessionInfoInternal {
20    pub session_id: String,
21    pub file_path: Option<std::path::PathBuf>,
22    pub name: Option<String>,
23    pub message_count: usize,
24}
25
26impl CommandsExtension {
27    pub fn new(available_models: Vec<String>) -> Self {
28        Self {
29            available_models,
30            session_info: Arc::new(Mutex::new(None)),
31        }
32    }
33
34    /// Update the session info that /session will display.
35    pub fn set_session_info(&self, info: SessionInfoInternal) {
36        if let Ok(mut guard) = self.session_info.lock() {
37            *guard = Some(info);
38        }
39    }
40}
41
42impl Extension for CommandsExtension {
43    fn name(&self) -> Cow<'static, str> {
44        "commands".into()
45    }
46
47    fn commands(&self) -> Vec<SlashCommand> {
48        vec![
49            SlashCommand {
50                name: "quit".to_string(),
51                description: "Exit rab".to_string(),
52                handler: Box::new(QuitCommand),
53            },
54            SlashCommand {
55                name: "model".to_string(),
56                description: "Switch model".to_string(),
57                handler: Box::new(ModelCommand {
58                    available_models: self.available_models.clone(),
59                }),
60            },
61            SlashCommand {
62                name: "hotkeys".to_string(),
63                description: "Show keyboard shortcuts".to_string(),
64                handler: Box::new(HotkeysCommand),
65            },
66            SlashCommand {
67                name: "reload".to_string(),
68                description: "Reload settings and auth from disk".to_string(),
69                handler: Box::new(ReloadCommand),
70            },
71            SlashCommand {
72                name: "new".to_string(),
73                description: "Start a new session (clear conversation)".to_string(),
74                handler: Box::new(NewCommand),
75            },
76            SlashCommand {
77                name: "resume".to_string(),
78                description: "Resume a different session".to_string(),
79                handler: Box::new(ResumeCommand),
80            },
81            SlashCommand {
82                name: "session".to_string(),
83                description: "Show session info".to_string(),
84                handler: Box::new(SessionInfoCommand {
85                    info: self.session_info.clone(),
86                }),
87            },
88            SlashCommand {
89                name: "name".to_string(),
90                description: "Set session display name".to_string(),
91                handler: Box::new(NameCommand),
92            },
93        ]
94    }
95}
96
97// ── /quit ─────────────────────────────────────────────────────────
98
99struct QuitCommand;
100
101impl CommandHandler for QuitCommand {
102    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
103        Ok(CommandResult::Quit)
104    }
105}
106
107// ── /model ────────────────────────────────────────────────────────
108
109struct ModelCommand {
110    available_models: Vec<String>,
111}
112
113impl CommandHandler for ModelCommand {
114    fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
115        let model = args.trim();
116        if model.is_empty() {
117            let list = self.available_models.join(", ");
118            Ok(CommandResult::Info(format!(
119                "Available models: {}\nUsage: /model <name>",
120                list
121            )))
122        } else {
123            // Validate model exists
124            if self.available_models.iter().any(|m| m == model) {
125                Ok(CommandResult::ModelChanged(model.to_string()))
126            } else {
127                Ok(CommandResult::Info(format!(
128                    "Unknown model: {}. Available: {}",
129                    model,
130                    self.available_models.join(", ")
131                )))
132            }
133        }
134    }
135
136    fn argument_completions(&self, prefix: &str) -> Vec<AutocompleteItem> {
137        let lower = prefix.to_lowercase();
138        self.available_models
139            .iter()
140            .filter(|m| m.to_lowercase().contains(&lower))
141            .map(|m| AutocompleteItem {
142                value: m.clone(),
143                label: m.clone(),
144                description: None,
145            })
146            .collect()
147    }
148}
149
150// ── /hotkeys ──────────────────────────────────────────────────────
151
152struct HotkeysCommand;
153
154impl CommandHandler for HotkeysCommand {
155    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
156        Ok(CommandResult::ShowHelp)
157    }
158}
159
160// ── /reload ───────────────────────────────────────────────────────
161
162struct ReloadCommand;
163
164impl CommandHandler for ReloadCommand {
165    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
166        Ok(CommandResult::Reloaded)
167    }
168}
169
170// ── /new ──────────────────────────────────────────────────────────
171
172struct NewCommand;
173
174impl CommandHandler for NewCommand {
175    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
176        Ok(CommandResult::NewSession)
177    }
178}
179
180// ── /resume ───────────────────────────────────────────────────────
181
182struct ResumeCommand;
183
184impl CommandHandler for ResumeCommand {
185    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
186        Ok(CommandResult::OpenSessionSelector)
187    }
188}
189
190// ── /session ──────────────────────────────────────────────────────
191
192struct SessionInfoCommand {
193    info: Arc<Mutex<Option<SessionInfoInternal>>>,
194}
195
196impl CommandHandler for SessionInfoCommand {
197    fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
198        let info = self.info.lock().unwrap();
199        match info.as_ref() {
200            Some(si) => Ok(CommandResult::SessionInfo {
201                session_id: si.session_id.clone(),
202                file_path: si.file_path.clone(),
203                name: si.name.clone(),
204                message_count: si.message_count,
205            }),
206            None => Ok(CommandResult::Info(
207                "No active session (use --no-session?)".to_string(),
208            )),
209        }
210    }
211}
212
213// ── /name ─────────────────────────────────────────────────────────
214
215struct NameCommand;
216
217impl CommandHandler for NameCommand {
218    fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
219        let name = args.trim();
220        if name.is_empty() {
221            return Ok(CommandResult::Info(
222                "Usage: /name <name> — set session display name".to_string(),
223            ));
224        }
225        Ok(CommandResult::SessionNamed {
226            name: name.to_string(),
227        })
228    }
229}