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 mut 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 input_tokens += usage.input;
86 output_tokens += usage.output;
87 cache_read_tokens += usage.cache_read;
88 cache_write_tokens += usage.cache_write;
89 total_tokens += usage.input + usage.output + usage.cache_read + usage.cache_write;
90 cost += usage.input as f64 * 2.0 / 1_000_000.0
92 + usage.output as f64 * 8.0 / 1_000_000.0;
93 }
94 }
95 }
96
97 SessionInfoInternal {
98 session_id: session.session_id(),
99 file_path: session.session_file(),
100 name: session.session_name(),
101 message_count,
102 user_messages,
103 assistant_messages,
104 tool_calls,
105 tool_results,
106 total_tokens,
107 input_tokens,
108 output_tokens,
109 cache_read_tokens,
110 cache_write_tokens,
111 cost,
112 }
113}
114
115impl Extension for CommandsExtension {
116 fn name(&self) -> Cow<'static, str> {
117 "commands".into()
118 }
119
120 fn commands(&self) -> Vec<SlashCommand> {
121 vec![
122 SlashCommand {
123 name: "settings".to_string(),
124 description: "Open settings menu".to_string(),
125 handler: Box::new(SettingsCommand),
126 },
127 SlashCommand {
128 name: "model".to_string(),
129 description: "Select model (opens selector UI)".to_string(),
130 handler: Box::new(ModelCommand {
131 available_models: self.available_models.clone(),
132 }),
133 },
134 SlashCommand {
135 name: "scoped-models".to_string(),
136 description: "Enable/disable models for cycling".to_string(),
137 handler: Box::new(ScopedModelsCommand {
138 available_models: self.available_models.clone(),
139 }),
140 },
141 SlashCommand {
142 name: "export".to_string(),
143 description: "Export session (HTML default, or specify path: .html/.jsonl)"
144 .to_string(),
145 handler: Box::new(command_not_implemented_handler("export")),
146 },
147 SlashCommand {
148 name: "import".to_string(),
149 description: "Import and resume a session from a JSONL file".to_string(),
150 handler: Box::new(command_not_implemented_handler("import")),
151 },
152 SlashCommand {
153 name: "copy".to_string(),
154 description: "Copy last agent message to clipboard".to_string(),
155 handler: Box::new(CopyCommand),
156 },
157 SlashCommand {
158 name: "name".to_string(),
159 description: "Set session display name".to_string(),
160 handler: Box::new(NameCommand {
161 session_info: self.session_info.clone(),
162 }),
163 },
164 SlashCommand {
165 name: "session".to_string(),
166 description: "Show session info and stats".to_string(),
167 handler: Box::new(SessionInfoCommand {
168 info: self.session_info.clone(),
169 }),
170 },
171 SlashCommand {
172 name: "hotkeys".to_string(),
173 description: "Show all keyboard shortcuts".to_string(),
174 handler: Box::new(command_not_implemented_handler("hotkeys")),
175 },
176 SlashCommand {
177 name: "fork".to_string(),
178 description: "Create a new fork from a previous user message".to_string(),
179 handler: Box::new(ForkCommand),
180 },
181 SlashCommand {
182 name: "clone".to_string(),
183 description: "Duplicate the current session at the current position".to_string(),
184 handler: Box::new(command_not_implemented_handler("clone")),
185 },
186 SlashCommand {
187 name: "tree".to_string(),
188 description: "Navigate session tree (switch branches)".to_string(),
189 handler: Box::new(command_not_implemented_handler("tree")),
190 },
191 SlashCommand {
192 name: "login".to_string(),
193 description: "Configure provider authentication".to_string(),
194 handler: Box::new(LoginCommand),
195 },
196 SlashCommand {
197 name: "logout".to_string(),
198 description: "Remove provider authentication".to_string(),
199 handler: Box::new(LogoutCommand),
200 },
201 SlashCommand {
202 name: "new".to_string(),
203 description: "Start a new session".to_string(),
204 handler: Box::new(NewCommand),
205 },
206 SlashCommand {
207 name: "compact".to_string(),
208 description: "Manually compact the session context".to_string(),
209 handler: Box::new(CompactCommand),
210 },
211 SlashCommand {
212 name: "resume".to_string(),
213 description: "Resume a different session".to_string(),
214 handler: Box::new(command_not_implemented_handler("resume")),
215 },
216 SlashCommand {
217 name: "reload".to_string(),
218 description: "Reload keybindings, extensions, skills, prompts, and themes"
219 .to_string(),
220 handler: Box::new(ReloadCommand),
221 },
222 SlashCommand {
223 name: "quit".to_string(),
224 description: "Exit rab".to_string(),
225 handler: Box::new(QuitCommand),
226 },
227 ]
228 }
229}
230
231struct QuitCommand;
234
235impl CommandHandler for QuitCommand {
236 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
237 Ok(CommandResult::Quit)
238 }
239}
240
241struct ModelCommand {
244 available_models: Vec<String>,
245}
246
247impl CommandHandler for ModelCommand {
248 fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
249 let model = args.trim();
250 if model.is_empty() {
251 Ok(CommandResult::OpenModelSelector)
252 } else {
253 if self.available_models.iter().any(|m| m == model) {
255 Ok(CommandResult::ModelChanged(model.to_string()))
256 } else {
257 Ok(CommandResult::Info(format!(
258 "Unknown model: {}. Available: {}",
259 model,
260 self.available_models.join(", ")
261 )))
262 }
263 }
264 }
265
266 fn argument_completions(&self, prefix: &str) -> Vec<AutocompleteItem> {
267 let lower = prefix.to_lowercase();
268 self.available_models
269 .iter()
270 .filter(|m| m.to_lowercase().contains(&lower))
271 .map(|m| AutocompleteItem {
272 value: m.clone(),
273 label: m.clone(),
274 description: None,
275 })
276 .collect()
277 }
278}
279
280struct SettingsCommand;
283
284impl CommandHandler for SettingsCommand {
285 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
286 Ok(CommandResult::OpenSettings)
287 }
288}
289
290struct ScopedModelsCommand {
293 available_models: Vec<String>,
294}
295
296impl CommandHandler for ScopedModelsCommand {
297 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
298 Ok(CommandResult::ScopedModels)
299 }
300
301 fn argument_completions(&self, prefix: &str) -> Vec<AutocompleteItem> {
302 let lower = prefix.to_lowercase();
303 self.available_models
304 .iter()
305 .filter(|m| m.to_lowercase().contains(&lower))
306 .map(|m| AutocompleteItem {
307 value: m.clone(),
308 label: m.clone(),
309 description: None,
310 })
311 .collect()
312 }
313}
314
315struct ReloadCommand;
318
319impl CommandHandler for ReloadCommand {
320 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
321 Ok(CommandResult::Reloaded)
322 }
323}
324
325struct NewCommand;
328
329impl CommandHandler for NewCommand {
330 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
331 Ok(CommandResult::NewSession)
332 }
333}
334
335struct SessionInfoCommand {
338 info: Arc<Mutex<Option<SessionInfoInternal>>>,
339}
340
341impl CommandHandler for SessionInfoCommand {
342 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
343 let info = self.info.lock().unwrap();
344 match info.as_ref() {
345 Some(si) => Ok(CommandResult::SessionInfo {
346 session_id: si.session_id.clone(),
347 file_path: si.file_path.clone(),
348 name: si.name.clone(),
349 message_count: si.message_count,
350 user_messages: si.user_messages,
351 assistant_messages: si.assistant_messages,
352 tool_calls: si.tool_calls,
353 tool_results: si.tool_results,
354 total_tokens: si.total_tokens,
355 input_tokens: si.input_tokens,
356 output_tokens: si.output_tokens,
357 cache_read_tokens: si.cache_read_tokens,
358 cache_write_tokens: si.cache_write_tokens,
359 cost: si.cost,
360 }),
361 None => Ok(CommandResult::Info(
362 "No active session (use --no-session?)".to_string(),
363 )),
364 }
365 }
366}
367
368struct CopyCommand;
371
372impl CommandHandler for CopyCommand {
373 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
374 Ok(CommandResult::CopyLastMessage)
375 }
376}
377
378struct NameCommand {
381 session_info: Arc<Mutex<Option<SessionInfoInternal>>>,
382}
383
384impl CommandHandler for NameCommand {
385 fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
386 let name = args.trim();
387 if name.is_empty() {
388 let info = self.session_info.lock().unwrap();
389 let current_name = info
390 .as_ref()
391 .and_then(|si| si.name.as_deref())
392 .filter(|n| !n.is_empty());
393 return match current_name {
394 Some(n) => Ok(CommandResult::Info(format!("Session name: {}", n))),
395 None => Ok(CommandResult::Info("Usage: /name <name>".to_string())),
396 };
397 }
398 Ok(CommandResult::SessionNamed {
399 name: name.to_string(),
400 })
401 }
402}
403
404struct LoginCommand;
407
408impl CommandHandler for LoginCommand {
409 fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
410 let args = args.trim();
411 if args.is_empty() {
412 return Ok(CommandResult::Login {
414 provider: None,
415 api_key: None,
416 });
417 }
418 let (provider, api_key) = match args.split_once(' ') {
420 Some((p, key)) => (p.trim().to_string(), Some(key.trim().to_string())),
421 None => (args.to_string(), None),
422 };
423 Ok(CommandResult::Login {
424 provider: Some(provider),
425 api_key,
426 })
427 }
428}
429
430struct LogoutCommand;
433
434impl CommandHandler for LogoutCommand {
435 fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
436 let provider = args.trim();
437 Ok(CommandResult::Logout {
438 provider: if provider.is_empty() {
439 None
440 } else {
441 Some(provider.to_string())
442 },
443 })
444 }
445}
446
447struct ForkCommand;
450
451impl CommandHandler for ForkCommand {
452 fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
453 let msg_id = args.trim();
454 if msg_id.is_empty() {
455 Ok(CommandResult::ForkSession { message_id: None })
456 } else {
457 Ok(CommandResult::ForkSession {
458 message_id: Some(msg_id.to_string()),
459 })
460 }
461 }
462}
463
464struct CompactCommand;
467
468impl CommandHandler for CompactCommand {
469 fn execute(&self, args: &str) -> anyhow::Result<CommandResult> {
470 let custom_instructions = if args.trim().is_empty() {
471 None
472 } else {
473 Some(args.trim().to_string())
474 };
475 Ok(CommandResult::CompactSession(custom_instructions))
476 }
477}
478
479struct NotImplementedHandler {
482 name: String,
483}
484
485impl CommandHandler for NotImplementedHandler {
486 fn execute(&self, _args: &str) -> anyhow::Result<CommandResult> {
487 Ok(CommandResult::Info(format!(
488 "/{} - not implemented yet.",
489 self.name
490 )))
491 }
492}
493
494fn command_not_implemented_handler(name: &str) -> NotImplementedHandler {
495 NotImplementedHandler {
496 name: name.to_string(),
497 }
498}