Skip to main content

claudius/chat/
commands.rs

1//! Slash command parsing for the chat application.
2//!
3//! This module handles parsing of special commands that start with `/`,
4//! allowing users to control the chat session without sending messages
5//! to the API.
6
7/// A parsed chat command.
8///
9/// These commands control the chat session and are not sent to the API.
10#[derive(Debug, Clone, PartialEq)]
11pub enum ChatCommand {
12    /// Clear the conversation history.
13    Clear,
14
15    /// Change the model.
16    Model(String),
17
18    /// Set or clear the system prompt.
19    /// `None` clears the current system prompt.
20    System(Option<String>),
21
22    /// Set the maximum tokens per response.
23    MaxTokens(u32),
24
25    /// Set the sampling temperature.
26    Temperature(f32),
27
28    /// Clear the sampling temperature (use model default).
29    ClearTemperature,
30
31    /// Set the top-p value.
32    TopP(f32),
33
34    /// Clear the top-p value.
35    ClearTopP,
36
37    /// Set the top-k value.
38    TopK(u32),
39
40    /// Clear the top-k value.
41    ClearTopK,
42
43    /// Add a stop sequence.
44    AddStopSequence(String),
45
46    /// Clear all stop sequences.
47    ClearStopSequences,
48
49    /// List stop sequences.
50    ListStopSequences,
51
52    /// Configure extended thinking.
53    /// `None` disables thinking, `Some(budget)` enables with the given token budget.
54    Thinking(Option<u32>),
55
56    /// Enable adaptive thinking (with default or current effort level).
57    ThinkingAdaptive,
58
59    /// Set the effort level for adaptive thinking.
60    Effort(crate::types::Effort),
61
62    /// Clear the effort level.
63    ClearEffort,
64
65    /// Set a per-session dollar spend limit.
66    Spend(f64),
67
68    /// Clear the spend limit.
69    ClearSpend,
70
71    /// Enable or disable prompt caching.
72    Caching(bool),
73
74    /// Set the auto-save transcript path.
75    TranscriptPath(String),
76
77    /// Clear the auto-save transcript path.
78    ClearTranscriptPath,
79
80    /// Save the transcript to a specific file immediately.
81    SaveTranscript(String),
82
83    /// Load conversation history from a file.
84    LoadTranscript(String),
85
86    /// Display help information.
87    Help,
88
89    /// Exit the chat application.
90    Quit,
91
92    /// Display session statistics (message count, current model, etc.).
93    Stats,
94
95    /// Show the current configuration.
96    ShowConfig,
97
98    /// Report a parsing error back to the caller.
99    Invalid(String),
100}
101
102/// Parses user input for slash commands.
103///
104/// Returns `Some(ChatCommand)` if the input is a valid command,
105/// or `None` if it should be treated as a regular message.
106///
107/// # Examples
108///
109/// ```
110/// # use claudius::chat::parse_command;
111/// assert!(parse_command("/quit").is_some());
112/// assert!(parse_command("/model claude-sonnet-4-0").is_some());
113/// assert!(parse_command("Hello, Claude!").is_none());
114/// ```
115pub 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
246/// Default thinking budget when enabled without a specific value.
247const 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
303/// Returns help text describing available commands.
304pub 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}