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
12pub struct CommandsExtension {
16 available_models: Vec<String>,
18 pub session_info: Arc<Mutex<Option<SessionInfoInternal>>>,
20}
21
22#[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 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
57pub 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 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
245struct QuitCommand;
248
249impl CommandHandler for QuitCommand {
250 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
251 Ok(CommandResult::Quit)
252 }
253}
254
255struct 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 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
297struct SettingsCommand;
300
301impl CommandHandler for SettingsCommand {
302 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
303 Ok(CommandResult::OpenSettings)
304 }
305}
306
307struct 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
332struct HotkeysCommand;
335
336impl CommandHandler for HotkeysCommand {
337 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
338 Ok(CommandResult::ShowHelp)
339 }
340}
341
342struct ReloadCommand;
345
346impl CommandHandler for ReloadCommand {
347 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
348 Ok(CommandResult::Reloaded)
349 }
350}
351
352struct NewCommand;
355
356impl CommandHandler for NewCommand {
357 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
358 Ok(CommandResult::NewSession)
359 }
360}
361
362struct ResumeCommand;
365
366impl CommandHandler for ResumeCommand {
367 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
368 Ok(CommandResult::OpenSessionSelector)
369 }
370}
371
372struct 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
405struct 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
423struct 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
443struct 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
460struct 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
477struct 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
492struct 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
509struct 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
528struct CopyLastCommand;
531
532impl CommandHandler for CopyLastCommand {
533 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
534 Ok(CommandResult::CopyLastMessage)
535 }
536}
537
538struct ChangelogCommand;
541
542impl CommandHandler for ChangelogCommand {
543 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
544 Ok(CommandResult::ShowChangelog)
545 }
546}
547
548struct CloneCommand;
551
552impl CommandHandler for CloneCommand {
553 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
554 Ok(CommandResult::CloneSession)
555 }
556}
557
558struct TreeCommand;
561
562impl CommandHandler for TreeCommand {
563 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
564 Ok(CommandResult::SessionTree)
565 }
566}
567
568struct 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
585struct 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}