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
13pub struct CommandsExtension {
17 available_models: std::sync::Mutex<Vec<String>>,
20 pub session_info: Arc<Mutex<Option<SessionInfoInternal>>>,
22}
23
24#[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 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 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
66pub 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 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 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
252struct QuitCommand;
255
256impl CommandHandler for QuitCommand {
257 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
258 Ok(CommandResult::Quit)
259 }
260}
261
262struct 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 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
301struct SettingsCommand;
304
305impl CommandHandler for SettingsCommand {
306 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
307 Ok(CommandResult::OpenSettings)
308 }
309}
310
311struct 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
336struct ReloadCommand;
339
340impl CommandHandler for ReloadCommand {
341 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
342 Ok(CommandResult::Reloaded)
343 }
344}
345
346struct NewCommand;
349
350impl CommandHandler for NewCommand {
351 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
352 Ok(CommandResult::NewSession)
353 }
354}
355
356struct 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
389struct CopyCommand;
392
393impl CommandHandler for CopyCommand {
394 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
395 Ok(CommandResult::CopyLastMessage)
396 }
397}
398
399struct 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
425struct 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 return Ok(CommandResult::Login {
435 provider: None,
436 api_key: None,
437 });
438 }
439 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
451struct 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
468struct 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
485struct TreeCommand;
488
489impl CommandHandler for TreeCommand {
490 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
491 Ok(CommandResult::SessionTree)
492 }
493}
494
495struct 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
510struct 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}