1#[derive(Debug, Clone, PartialEq)]
11pub enum ChatCommand {
12 Clear,
14
15 Model(String),
17
18 System(Option<String>),
21
22 MaxTokens(u32),
24
25 Temperature(f32),
27
28 ClearTemperature,
30
31 TopP(f32),
33
34 ClearTopP,
36
37 TopK(u32),
39
40 ClearTopK,
42
43 AddStopSequence(String),
45
46 ClearStopSequences,
48
49 ListStopSequences,
51
52 Thinking(Option<u32>),
55
56 ThinkingAdaptive,
58
59 Effort(crate::types::Effort),
61
62 ClearEffort,
64
65 Spend(f64),
67
68 ClearSpend,
70
71 Caching(bool),
73
74 TranscriptPath(String),
76
77 ClearTranscriptPath,
79
80 SaveTranscript(String),
82
83 LoadTranscript(String),
85
86 Help,
88
89 Quit,
91
92 Stats,
94
95 ShowConfig,
97
98 Invalid(String),
100}
101
102pub fn parse_command(input: &str) -> Option<ChatCommand> {
116 let input = input.trim();
117
118 if !input.starts_with('/') {
119 return None;
120 }
121
122 let mut parts = input[1..].splitn(2, ' ');
123 let command = parts.next()?.to_lowercase();
124 let argument = parts.next().map(|s| s.trim()).filter(|s| !s.is_empty());
125
126 let result = match command.as_str() {
127 "clear" => ChatCommand::Clear,
128 "model" => match argument {
129 Some(model) => ChatCommand::Model(model.to_string()),
130 None => ChatCommand::Invalid("/model requires a model name".to_string()),
131 },
132 "system" => ChatCommand::System(argument.map(|s| s.to_string())),
133 "help" | "?" => ChatCommand::Help,
134 "quit" | "exit" | "q" => ChatCommand::Quit,
135 "stats" | "status" => ChatCommand::Stats,
136 "config" => ChatCommand::ShowConfig,
137 "max_tokens" => parse_u32_command(argument, ChatCommand::MaxTokens, "/max_tokens"),
138 "temperature" => match argument {
139 Some(arg) if arg.eq_ignore_ascii_case("clear") => ChatCommand::ClearTemperature,
140 Some(arg) => match parse_f32_in_range(arg, 0.0, 1.0) {
141 Ok(value) => ChatCommand::Temperature(value),
142 Err(err) => ChatCommand::Invalid(format!("/temperature {err}")),
143 },
144 None => ChatCommand::Invalid("/temperature requires a value".to_string()),
145 },
146 "top_p" => match argument {
147 Some(arg) if arg.eq_ignore_ascii_case("clear") => ChatCommand::ClearTopP,
148 Some(arg) => match parse_f32_in_range(arg, 0.0, 1.0) {
149 Ok(value) => ChatCommand::TopP(value),
150 Err(err) => ChatCommand::Invalid(format!("/top_p {err}")),
151 },
152 None => ChatCommand::Invalid("/top_p requires a value".to_string()),
153 },
154 "top_k" => match argument {
155 Some(arg) if arg.eq_ignore_ascii_case("clear") => ChatCommand::ClearTopK,
156 Some(arg) => match arg.parse::<u32>() {
157 Ok(value) => ChatCommand::TopK(value),
158 Err(_) => ChatCommand::Invalid("/top_k expects a positive integer".to_string()),
159 },
160 None => ChatCommand::Invalid("/top_k requires a value".to_string()),
161 },
162 "stop" => parse_stop_command(argument),
163 "thinking" => parse_thinking_command(argument),
164 "effort" => parse_effort_command(argument),
165 "spend" => match argument {
166 Some(arg) if arg.eq_ignore_ascii_case("clear") => ChatCommand::ClearSpend,
167 Some(arg) => match arg.parse::<f64>() {
168 Ok(value) if value.is_finite() && value > 0.0 => ChatCommand::Spend(value),
169 Ok(_) => {
170 ChatCommand::Invalid("/spend expects a positive dollar amount".to_string())
171 }
172 Err(_) => {
173 ChatCommand::Invalid("/spend expects a positive dollar amount".to_string())
174 }
175 },
176 None => ChatCommand::Invalid("/spend requires a dollar amount".to_string()),
177 },
178 "cache" => parse_cache_command(argument),
179 "transcript" => match argument {
180 Some(arg) if arg.eq_ignore_ascii_case("clear") => ChatCommand::ClearTranscriptPath,
181 Some(arg) => ChatCommand::TranscriptPath(arg.to_string()),
182 None => ChatCommand::Invalid("/transcript requires a file path".to_string()),
183 },
184 "save" => match argument {
185 Some(arg) => ChatCommand::SaveTranscript(arg.to_string()),
186 None => ChatCommand::Invalid("/save requires a file path".to_string()),
187 },
188 "load" => match argument {
189 Some(arg) => ChatCommand::LoadTranscript(arg.to_string()),
190 None => ChatCommand::Invalid("/load requires a file path".to_string()),
191 },
192 _ => ChatCommand::Invalid(format!("Unknown command: /{}", command)),
193 };
194
195 Some(result)
196}
197
198fn parse_stop_command(argument: Option<&str>) -> ChatCommand {
199 let Some(arg) = argument else {
200 return ChatCommand::Invalid(
201 "/stop requires 'add <sequence>', 'clear', or 'list'".to_string(),
202 );
203 };
204
205 let mut parts = arg.splitn(2, ' ');
206 let action = parts.next().unwrap();
207 match action.to_lowercase().as_str() {
208 "add" => {
209 let Some(sequence) = parts.next().map(|s| s.trim()).filter(|s| !s.is_empty()) else {
210 return ChatCommand::Invalid("/stop add requires a sequence".to_string());
211 };
212 ChatCommand::AddStopSequence(sequence.to_string())
213 }
214 "clear" => ChatCommand::ClearStopSequences,
215 "list" => ChatCommand::ListStopSequences,
216 _ => {
217 ChatCommand::Invalid("Unrecognized /stop action (use add, clear, or list)".to_string())
218 }
219 }
220}
221
222fn parse_u32_command<F>(argument: Option<&str>, constructor: F, name: &str) -> ChatCommand
223where
224 F: Fn(u32) -> ChatCommand,
225{
226 match argument {
227 Some(arg) => match arg.parse::<u32>() {
228 Ok(value) => constructor(value),
229 Err(_) => ChatCommand::Invalid(format!("{} expects a positive integer", name)),
230 },
231 None => ChatCommand::Invalid(format!("{} requires a value", name)),
232 }
233}
234
235fn parse_f32_in_range(value: &str, min: f32, max: f32) -> Result<f32, String> {
236 let parsed: f32 = value
237 .parse()
238 .map_err(|_| format!("expects a value between {min} and {max}"))?;
239 if parsed.is_finite() && parsed >= min && parsed <= max {
240 Ok(parsed)
241 } else {
242 Err(format!("expects a value between {min} and {max}"))
243 }
244}
245
246const DEFAULT_THINKING_BUDGET: u32 = 1024;
248
249fn parse_thinking_command(argument: Option<&str>) -> ChatCommand {
250 let Some(arg) = argument else {
251 return ChatCommand::Invalid(
252 "/thinking expects 'on', 'off', 'adaptive', or a token budget (e.g., 2048)".to_string(),
253 );
254 };
255
256 let lower = arg.to_lowercase();
257 match lower.as_str() {
258 "off" | "false" | "no" => ChatCommand::Thinking(None),
259 "on" | "true" | "yes" => ChatCommand::Thinking(Some(DEFAULT_THINKING_BUDGET)),
260 "adaptive" => ChatCommand::ThinkingAdaptive,
261 _ => match arg.parse::<u32>() {
262 Ok(budget) => ChatCommand::Thinking(Some(budget)),
263 Err(_) => ChatCommand::Invalid(
264 "/thinking expects 'on', 'off', 'adaptive', or a token budget (e.g., 2048)"
265 .to_string(),
266 ),
267 },
268 }
269}
270
271fn parse_effort_command(argument: Option<&str>) -> ChatCommand {
272 let Some(arg) = argument else {
273 return ChatCommand::Invalid(
274 "/effort expects 'low', 'medium', 'high', or 'clear'".to_string(),
275 );
276 };
277
278 let lower = arg.to_lowercase();
279 match lower.as_str() {
280 "low" => ChatCommand::Effort(crate::types::Effort::Low),
281 "medium" | "med" => ChatCommand::Effort(crate::types::Effort::Medium),
282 "high" => ChatCommand::Effort(crate::types::Effort::High),
283 "clear" | "off" | "none" => ChatCommand::ClearEffort,
284 _ => {
285 ChatCommand::Invalid("/effort expects 'low', 'medium', 'high', or 'clear'".to_string())
286 }
287 }
288}
289
290fn parse_cache_command(argument: Option<&str>) -> ChatCommand {
291 let Some(arg) = argument else {
292 return ChatCommand::Invalid("/cache expects 'on' or 'off'".to_string());
293 };
294
295 let lower = arg.to_lowercase();
296 match lower.as_str() {
297 "on" | "true" | "yes" | "enable" | "enabled" => ChatCommand::Caching(true),
298 "off" | "false" | "no" | "disable" | "disabled" => ChatCommand::Caching(false),
299 _ => ChatCommand::Invalid("/cache expects 'on' or 'off'".to_string()),
300 }
301}
302
303pub fn help_text() -> &'static str {
305 r#"Available commands:
306 /clear Clear conversation history
307 /model <name> Change the model (e.g., /model claude-sonnet-4-0)
308 /system [prompt] Set system prompt (no argument clears it)
309 /max_tokens <n> Set maximum response tokens
310 /temperature <v> Set temperature 0.0-1.0 (use 'clear' to reset)
311 /top_p <v> Set top-p 0.0-1.0 (use 'clear' to reset)
312 /top_k <n> Set top-k (use 'clear' to reset)
313 /stop add <seq> Add a stop sequence
314 /stop clear Clear all stop sequences
315 /stop list List current stop sequences
316 /thinking on|off|adaptive|<n> Enable/disable extended thinking (or set budget)
317 /effort low|medium|high|clear Set effort level for adaptive thinking
318 /cache on|off Enable/disable prompt caching
319 /spend <dollars> Set session spend limit in dollars (or 'clear')
320 /transcript <file> Enable auto-saving transcripts (or 'clear')
321 /save <file> Save the current transcript immediately
322 /load <file> Load a transcript from disk
323 /stats Show session statistics
324 /config Show current configuration
325 /help Show this help message
326 /quit Exit the chat"#
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn parse_quit_commands() {
335 assert_eq!(parse_command("/quit"), Some(ChatCommand::Quit));
336 assert_eq!(parse_command("/exit"), Some(ChatCommand::Quit));
337 assert_eq!(parse_command("/q"), Some(ChatCommand::Quit));
338 assert_eq!(parse_command(" /quit "), Some(ChatCommand::Quit));
339 }
340
341 #[test]
342 fn parse_clear() {
343 assert_eq!(parse_command("/clear"), Some(ChatCommand::Clear));
344 assert_eq!(parse_command("/CLEAR"), Some(ChatCommand::Clear));
345 }
346
347 #[test]
348 fn parse_model() {
349 assert_eq!(
350 parse_command("/model claude-sonnet-4-0"),
351 Some(ChatCommand::Model("claude-sonnet-4-0".to_string()))
352 );
353 assert_eq!(
354 parse_command("/model claude-haiku-4-5 "),
355 Some(ChatCommand::Model("claude-haiku-4-5".to_string()))
356 );
357 assert_eq!(
358 parse_command("/model"),
359 Some(ChatCommand::Invalid(
360 "/model requires a model name".to_string()
361 ))
362 );
363 }
364
365 #[test]
366 fn parse_system() {
367 assert_eq!(
368 parse_command("/system You are a helpful assistant"),
369 Some(ChatCommand::System(Some(
370 "You are a helpful assistant".to_string()
371 )))
372 );
373 assert_eq!(parse_command("/system"), Some(ChatCommand::System(None)));
374 }
375
376 #[test]
377 fn parse_temperature() {
378 assert_eq!(
379 parse_command("/temperature 0.5"),
380 Some(ChatCommand::Temperature(0.5))
381 );
382 assert_eq!(
383 parse_command("/temperature clear"),
384 Some(ChatCommand::ClearTemperature)
385 );
386 assert!(matches!(
387 parse_command("/temperature"),
388 Some(ChatCommand::Invalid(msg)) if msg.contains("requires")
389 ));
390 }
391
392 #[test]
393 fn parse_stop_commands() {
394 assert_eq!(
395 parse_command("/stop add END"),
396 Some(ChatCommand::AddStopSequence("END".to_string()))
397 );
398 assert_eq!(
399 parse_command("/stop clear"),
400 Some(ChatCommand::ClearStopSequences)
401 );
402 assert_eq!(
403 parse_command("/stop list"),
404 Some(ChatCommand::ListStopSequences)
405 );
406 }
407
408 #[test]
409 fn parse_thinking_toggle() {
410 assert_eq!(
411 parse_command("/thinking on"),
412 Some(ChatCommand::Thinking(Some(DEFAULT_THINKING_BUDGET)))
413 );
414 assert_eq!(
415 parse_command("/thinking off"),
416 Some(ChatCommand::Thinking(None))
417 );
418 assert_eq!(
419 parse_command("/thinking 2048"),
420 Some(ChatCommand::Thinking(Some(2048)))
421 );
422 assert_eq!(
423 parse_command("/thinking adaptive"),
424 Some(ChatCommand::ThinkingAdaptive)
425 );
426 assert!(matches!(
427 parse_command("/thinking maybe"),
428 Some(ChatCommand::Invalid(msg)) if msg.contains("expects")
429 ));
430 }
431
432 #[test]
433 fn parse_effort_levels() {
434 assert_eq!(
435 parse_command("/effort low"),
436 Some(ChatCommand::Effort(crate::types::Effort::Low))
437 );
438 assert_eq!(
439 parse_command("/effort medium"),
440 Some(ChatCommand::Effort(crate::types::Effort::Medium))
441 );
442 assert_eq!(
443 parse_command("/effort med"),
444 Some(ChatCommand::Effort(crate::types::Effort::Medium))
445 );
446 assert_eq!(
447 parse_command("/effort high"),
448 Some(ChatCommand::Effort(crate::types::Effort::High))
449 );
450 assert_eq!(
451 parse_command("/effort clear"),
452 Some(ChatCommand::ClearEffort)
453 );
454 assert_eq!(parse_command("/effort off"), Some(ChatCommand::ClearEffort));
455 assert!(matches!(
456 parse_command("/effort"),
457 Some(ChatCommand::Invalid(msg)) if msg.contains("expects")
458 ));
459 assert!(matches!(
460 parse_command("/effort whatever"),
461 Some(ChatCommand::Invalid(msg)) if msg.contains("expects")
462 ));
463 }
464
465 #[test]
466 fn parse_spend() {
467 assert_eq!(
468 parse_command("/spend 5.0"),
469 Some(ChatCommand::Spend(5.0))
470 );
471 assert_eq!(
472 parse_command("/spend 0.50"),
473 Some(ChatCommand::Spend(0.50))
474 );
475 assert_eq!(
476 parse_command("/spend clear"),
477 Some(ChatCommand::ClearSpend)
478 );
479 assert!(matches!(
480 parse_command("/spend -1.0"),
481 Some(ChatCommand::Invalid(_))
482 ));
483 assert!(matches!(
484 parse_command("/spend 0.0"),
485 Some(ChatCommand::Invalid(_))
486 ));
487 assert!(matches!(
488 parse_command("/spend abc"),
489 Some(ChatCommand::Invalid(_))
490 ));
491 }
492
493 #[test]
494 fn parse_transcript_commands() {
495 assert_eq!(
496 parse_command("/transcript chat.json"),
497 Some(ChatCommand::TranscriptPath("chat.json".to_string()))
498 );
499 assert_eq!(
500 parse_command("/transcript clear"),
501 Some(ChatCommand::ClearTranscriptPath)
502 );
503 assert_eq!(
504 parse_command("/save session.json"),
505 Some(ChatCommand::SaveTranscript("session.json".to_string()))
506 );
507 assert_eq!(
508 parse_command("/load session.json"),
509 Some(ChatCommand::LoadTranscript("session.json".to_string()))
510 );
511 }
512
513 #[test]
514 fn parse_stats_and_config() {
515 assert_eq!(parse_command("/stats"), Some(ChatCommand::Stats));
516 assert_eq!(parse_command("/config"), Some(ChatCommand::ShowConfig));
517 }
518
519 #[test]
520 fn parse_cache() {
521 assert_eq!(parse_command("/cache on"), Some(ChatCommand::Caching(true)));
522 assert_eq!(
523 parse_command("/cache off"),
524 Some(ChatCommand::Caching(false))
525 );
526 assert_eq!(
527 parse_command("/cache enable"),
528 Some(ChatCommand::Caching(true))
529 );
530 assert_eq!(
531 parse_command("/cache disable"),
532 Some(ChatCommand::Caching(false))
533 );
534 assert!(matches!(
535 parse_command("/cache"),
536 Some(ChatCommand::Invalid(msg)) if msg.contains("expects")
537 ));
538 assert!(matches!(
539 parse_command("/cache maybe"),
540 Some(ChatCommand::Invalid(msg)) if msg.contains("expects")
541 ));
542 }
543
544 #[test]
545 fn non_commands() {
546 assert_eq!(parse_command("Hello, Claude!"), None);
547 assert_eq!(parse_command(""), None);
548 assert_eq!(parse_command(" "), None);
549 }
550
551 #[test]
552 fn help_text_not_empty() {
553 let help = help_text();
554 assert!(!help.is_empty());
555 assert!(help.contains("/quit"));
556 assert!(help.contains("/clear"));
557 assert!(help.contains("/model"));
558 assert!(help.contains("/temperature"));
559 }
560}