#[derive(Debug, Clone, PartialEq)]
pub enum ChatCommand {
Clear,
Model(String),
System(Option<String>),
MaxTokens(u32),
Temperature(f32),
ClearTemperature,
TopP(f32),
ClearTopP,
TopK(u32),
ClearTopK,
AddStopSequence(String),
ClearStopSequences,
ListStopSequences,
Thinking(Option<u32>),
ThinkingAdaptive,
Effort(crate::types::Effort),
ClearEffort,
Spend(f64),
ClearSpend,
Caching(bool),
TranscriptPath(String),
ClearTranscriptPath,
SaveTranscript(String),
LoadTranscript(String),
Help,
Quit,
Stats,
ShowConfig,
Invalid(String),
}
pub fn parse_command(input: &str) -> Option<ChatCommand> {
let input = input.trim();
if !input.starts_with('/') {
return None;
}
let mut parts = input[1..].splitn(2, ' ');
let command = parts.next()?.to_lowercase();
let argument = parts.next().map(|s| s.trim()).filter(|s| !s.is_empty());
let result = match command.as_str() {
"clear" => ChatCommand::Clear,
"model" => match argument {
Some(model) => ChatCommand::Model(model.to_string()),
None => ChatCommand::Invalid("/model requires a model name".to_string()),
},
"system" => ChatCommand::System(argument.map(|s| s.to_string())),
"help" | "?" => ChatCommand::Help,
"quit" | "exit" | "q" => ChatCommand::Quit,
"stats" | "status" => ChatCommand::Stats,
"config" => ChatCommand::ShowConfig,
"max_tokens" => parse_u32_command(argument, ChatCommand::MaxTokens, "/max_tokens"),
"temperature" => match argument {
Some(arg) if arg.eq_ignore_ascii_case("clear") => ChatCommand::ClearTemperature,
Some(arg) => match parse_f32_in_range(arg, 0.0, 1.0) {
Ok(value) => ChatCommand::Temperature(value),
Err(err) => ChatCommand::Invalid(format!("/temperature {err}")),
},
None => ChatCommand::Invalid("/temperature requires a value".to_string()),
},
"top_p" => match argument {
Some(arg) if arg.eq_ignore_ascii_case("clear") => ChatCommand::ClearTopP,
Some(arg) => match parse_f32_in_range(arg, 0.0, 1.0) {
Ok(value) => ChatCommand::TopP(value),
Err(err) => ChatCommand::Invalid(format!("/top_p {err}")),
},
None => ChatCommand::Invalid("/top_p requires a value".to_string()),
},
"top_k" => match argument {
Some(arg) if arg.eq_ignore_ascii_case("clear") => ChatCommand::ClearTopK,
Some(arg) => match arg.parse::<u32>() {
Ok(value) => ChatCommand::TopK(value),
Err(_) => ChatCommand::Invalid("/top_k expects a positive integer".to_string()),
},
None => ChatCommand::Invalid("/top_k requires a value".to_string()),
},
"stop" => parse_stop_command(argument),
"thinking" => parse_thinking_command(argument),
"effort" => parse_effort_command(argument),
"spend" => match argument {
Some(arg) if arg.eq_ignore_ascii_case("clear") => ChatCommand::ClearSpend,
Some(arg) => match arg.parse::<f64>() {
Ok(value) if value.is_finite() && value > 0.0 => ChatCommand::Spend(value),
Ok(_) => {
ChatCommand::Invalid("/spend expects a positive dollar amount".to_string())
}
Err(_) => {
ChatCommand::Invalid("/spend expects a positive dollar amount".to_string())
}
},
None => ChatCommand::Invalid("/spend requires a dollar amount".to_string()),
},
"cache" => parse_cache_command(argument),
"transcript" => match argument {
Some(arg) if arg.eq_ignore_ascii_case("clear") => ChatCommand::ClearTranscriptPath,
Some(arg) => ChatCommand::TranscriptPath(arg.to_string()),
None => ChatCommand::Invalid("/transcript requires a file path".to_string()),
},
"save" => match argument {
Some(arg) => ChatCommand::SaveTranscript(arg.to_string()),
None => ChatCommand::Invalid("/save requires a file path".to_string()),
},
"load" => match argument {
Some(arg) => ChatCommand::LoadTranscript(arg.to_string()),
None => ChatCommand::Invalid("/load requires a file path".to_string()),
},
_ => ChatCommand::Invalid(format!("Unknown command: /{}", command)),
};
Some(result)
}
fn parse_stop_command(argument: Option<&str>) -> ChatCommand {
let Some(arg) = argument else {
return ChatCommand::Invalid(
"/stop requires 'add <sequence>', 'clear', or 'list'".to_string(),
);
};
let mut parts = arg.splitn(2, ' ');
let action = parts.next().unwrap();
match action.to_lowercase().as_str() {
"add" => {
let Some(sequence) = parts.next().map(|s| s.trim()).filter(|s| !s.is_empty()) else {
return ChatCommand::Invalid("/stop add requires a sequence".to_string());
};
ChatCommand::AddStopSequence(sequence.to_string())
}
"clear" => ChatCommand::ClearStopSequences,
"list" => ChatCommand::ListStopSequences,
_ => {
ChatCommand::Invalid("Unrecognized /stop action (use add, clear, or list)".to_string())
}
}
}
fn parse_u32_command<F>(argument: Option<&str>, constructor: F, name: &str) -> ChatCommand
where
F: Fn(u32) -> ChatCommand,
{
match argument {
Some(arg) => match arg.parse::<u32>() {
Ok(value) => constructor(value),
Err(_) => ChatCommand::Invalid(format!("{} expects a positive integer", name)),
},
None => ChatCommand::Invalid(format!("{} requires a value", name)),
}
}
fn parse_f32_in_range(value: &str, min: f32, max: f32) -> Result<f32, String> {
let parsed: f32 = value
.parse()
.map_err(|_| format!("expects a value between {min} and {max}"))?;
if parsed.is_finite() && parsed >= min && parsed <= max {
Ok(parsed)
} else {
Err(format!("expects a value between {min} and {max}"))
}
}
const DEFAULT_THINKING_BUDGET: u32 = 1024;
fn parse_thinking_command(argument: Option<&str>) -> ChatCommand {
let Some(arg) = argument else {
return ChatCommand::Invalid(
"/thinking expects 'on', 'off', 'adaptive', or a token budget (e.g., 2048)".to_string(),
);
};
let lower = arg.to_lowercase();
match lower.as_str() {
"off" | "false" | "no" => ChatCommand::Thinking(None),
"on" | "true" | "yes" => ChatCommand::Thinking(Some(DEFAULT_THINKING_BUDGET)),
"adaptive" => ChatCommand::ThinkingAdaptive,
_ => match arg.parse::<u32>() {
Ok(budget) => ChatCommand::Thinking(Some(budget)),
Err(_) => ChatCommand::Invalid(
"/thinking expects 'on', 'off', 'adaptive', or a token budget (e.g., 2048)"
.to_string(),
),
},
}
}
fn parse_effort_command(argument: Option<&str>) -> ChatCommand {
let Some(arg) = argument else {
return ChatCommand::Invalid(
"/effort expects 'low', 'medium', 'high', or 'clear'".to_string(),
);
};
let lower = arg.to_lowercase();
match lower.as_str() {
"low" => ChatCommand::Effort(crate::types::Effort::Low),
"medium" | "med" => ChatCommand::Effort(crate::types::Effort::Medium),
"high" => ChatCommand::Effort(crate::types::Effort::High),
"clear" | "off" | "none" => ChatCommand::ClearEffort,
_ => {
ChatCommand::Invalid("/effort expects 'low', 'medium', 'high', or 'clear'".to_string())
}
}
}
fn parse_cache_command(argument: Option<&str>) -> ChatCommand {
let Some(arg) = argument else {
return ChatCommand::Invalid("/cache expects 'on' or 'off'".to_string());
};
let lower = arg.to_lowercase();
match lower.as_str() {
"on" | "true" | "yes" | "enable" | "enabled" => ChatCommand::Caching(true),
"off" | "false" | "no" | "disable" | "disabled" => ChatCommand::Caching(false),
_ => ChatCommand::Invalid("/cache expects 'on' or 'off'".to_string()),
}
}
pub fn help_text() -> &'static str {
r#"Available commands:
/clear Clear conversation history
/model <name> Change the model (e.g., /model claude-sonnet-4-0)
/system [prompt] Set system prompt (no argument clears it)
/max_tokens <n> Set maximum response tokens
/temperature <v> Set temperature 0.0-1.0 (use 'clear' to reset)
/top_p <v> Set top-p 0.0-1.0 (use 'clear' to reset)
/top_k <n> Set top-k (use 'clear' to reset)
/stop add <seq> Add a stop sequence
/stop clear Clear all stop sequences
/stop list List current stop sequences
/thinking on|off|adaptive|<n> Enable/disable extended thinking (or set budget)
/effort low|medium|high|clear Set effort level for adaptive thinking
/cache on|off Enable/disable prompt caching
/spend <dollars> Set session spend limit in dollars (or 'clear')
/transcript <file> Enable auto-saving transcripts (or 'clear')
/save <file> Save the current transcript immediately
/load <file> Load a transcript from disk
/stats Show session statistics
/config Show current configuration
/help Show this help message
/quit Exit the chat"#
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_quit_commands() {
assert_eq!(parse_command("/quit"), Some(ChatCommand::Quit));
assert_eq!(parse_command("/exit"), Some(ChatCommand::Quit));
assert_eq!(parse_command("/q"), Some(ChatCommand::Quit));
assert_eq!(parse_command(" /quit "), Some(ChatCommand::Quit));
}
#[test]
fn parse_clear() {
assert_eq!(parse_command("/clear"), Some(ChatCommand::Clear));
assert_eq!(parse_command("/CLEAR"), Some(ChatCommand::Clear));
}
#[test]
fn parse_model() {
assert_eq!(
parse_command("/model claude-sonnet-4-0"),
Some(ChatCommand::Model("claude-sonnet-4-0".to_string()))
);
assert_eq!(
parse_command("/model claude-haiku-4-5 "),
Some(ChatCommand::Model("claude-haiku-4-5".to_string()))
);
assert_eq!(
parse_command("/model"),
Some(ChatCommand::Invalid(
"/model requires a model name".to_string()
))
);
}
#[test]
fn parse_system() {
assert_eq!(
parse_command("/system You are a helpful assistant"),
Some(ChatCommand::System(Some(
"You are a helpful assistant".to_string()
)))
);
assert_eq!(parse_command("/system"), Some(ChatCommand::System(None)));
}
#[test]
fn parse_temperature() {
assert_eq!(
parse_command("/temperature 0.5"),
Some(ChatCommand::Temperature(0.5))
);
assert_eq!(
parse_command("/temperature clear"),
Some(ChatCommand::ClearTemperature)
);
assert!(matches!(
parse_command("/temperature"),
Some(ChatCommand::Invalid(msg)) if msg.contains("requires")
));
}
#[test]
fn parse_stop_commands() {
assert_eq!(
parse_command("/stop add END"),
Some(ChatCommand::AddStopSequence("END".to_string()))
);
assert_eq!(
parse_command("/stop clear"),
Some(ChatCommand::ClearStopSequences)
);
assert_eq!(
parse_command("/stop list"),
Some(ChatCommand::ListStopSequences)
);
}
#[test]
fn parse_thinking_toggle() {
assert_eq!(
parse_command("/thinking on"),
Some(ChatCommand::Thinking(Some(DEFAULT_THINKING_BUDGET)))
);
assert_eq!(
parse_command("/thinking off"),
Some(ChatCommand::Thinking(None))
);
assert_eq!(
parse_command("/thinking 2048"),
Some(ChatCommand::Thinking(Some(2048)))
);
assert_eq!(
parse_command("/thinking adaptive"),
Some(ChatCommand::ThinkingAdaptive)
);
assert!(matches!(
parse_command("/thinking maybe"),
Some(ChatCommand::Invalid(msg)) if msg.contains("expects")
));
}
#[test]
fn parse_effort_levels() {
assert_eq!(
parse_command("/effort low"),
Some(ChatCommand::Effort(crate::types::Effort::Low))
);
assert_eq!(
parse_command("/effort medium"),
Some(ChatCommand::Effort(crate::types::Effort::Medium))
);
assert_eq!(
parse_command("/effort med"),
Some(ChatCommand::Effort(crate::types::Effort::Medium))
);
assert_eq!(
parse_command("/effort high"),
Some(ChatCommand::Effort(crate::types::Effort::High))
);
assert_eq!(
parse_command("/effort clear"),
Some(ChatCommand::ClearEffort)
);
assert_eq!(parse_command("/effort off"), Some(ChatCommand::ClearEffort));
assert!(matches!(
parse_command("/effort"),
Some(ChatCommand::Invalid(msg)) if msg.contains("expects")
));
assert!(matches!(
parse_command("/effort whatever"),
Some(ChatCommand::Invalid(msg)) if msg.contains("expects")
));
}
#[test]
fn parse_spend() {
assert_eq!(parse_command("/spend 5.0"), Some(ChatCommand::Spend(5.0)));
assert_eq!(parse_command("/spend 0.50"), Some(ChatCommand::Spend(0.50)));
assert_eq!(parse_command("/spend clear"), Some(ChatCommand::ClearSpend));
assert!(matches!(
parse_command("/spend -1.0"),
Some(ChatCommand::Invalid(_))
));
assert!(matches!(
parse_command("/spend 0.0"),
Some(ChatCommand::Invalid(_))
));
assert!(matches!(
parse_command("/spend abc"),
Some(ChatCommand::Invalid(_))
));
}
#[test]
fn parse_transcript_commands() {
assert_eq!(
parse_command("/transcript chat.json"),
Some(ChatCommand::TranscriptPath("chat.json".to_string()))
);
assert_eq!(
parse_command("/transcript clear"),
Some(ChatCommand::ClearTranscriptPath)
);
assert_eq!(
parse_command("/save session.json"),
Some(ChatCommand::SaveTranscript("session.json".to_string()))
);
assert_eq!(
parse_command("/load session.json"),
Some(ChatCommand::LoadTranscript("session.json".to_string()))
);
}
#[test]
fn parse_stats_and_config() {
assert_eq!(parse_command("/stats"), Some(ChatCommand::Stats));
assert_eq!(parse_command("/config"), Some(ChatCommand::ShowConfig));
}
#[test]
fn parse_cache() {
assert_eq!(parse_command("/cache on"), Some(ChatCommand::Caching(true)));
assert_eq!(
parse_command("/cache off"),
Some(ChatCommand::Caching(false))
);
assert_eq!(
parse_command("/cache enable"),
Some(ChatCommand::Caching(true))
);
assert_eq!(
parse_command("/cache disable"),
Some(ChatCommand::Caching(false))
);
assert!(matches!(
parse_command("/cache"),
Some(ChatCommand::Invalid(msg)) if msg.contains("expects")
));
assert!(matches!(
parse_command("/cache maybe"),
Some(ChatCommand::Invalid(msg)) if msg.contains("expects")
));
}
#[test]
fn non_commands() {
assert_eq!(parse_command("Hello, Claude!"), None);
assert_eq!(parse_command(""), None);
assert_eq!(parse_command(" "), None);
}
#[test]
fn help_text_not_empty() {
let help = help_text();
assert!(!help.is_empty());
assert!(help.contains("/quit"));
assert!(help.contains("/clear"));
assert!(help.contains("/model"));
assert!(help.contains("/temperature"));
}
}