#[derive(Debug, Clone)]
pub enum CommandResult {
None,
Output(String),
Error(String),
ModelChanged {
model: String,
},
ThinkingChanged {
level: String,
budget: u32,
},
SystemPromptSet {
source: String, },
SystemPromptShow {
prompt: String,
},
SessionList {
sessions: Vec<SessionSummary>,
},
Cleared,
Quit,
Compact {
custom_instructions: Option<String>,
},
Resumed {
session_id: String,
model: String,
},
Named {
name: String,
},
ChainInfo(String),
OpenModal(ModalRequest),
Status {
text: String,
},
PingStarted,
KeybindList(String),
SkillLoaded {
skill: std::sync::Arc<crate::skills::LoadedSkill>,
arg: String,
},
PluginCommand {
command: std::sync::Arc<crate::skills::registry::RegisteredPluginCommand>,
arg: String,
},
SidecarToggle { plugin_id: Option<String> },
SidecarStatus { plugin_id: Option<String> },
}
#[derive(Debug, Clone)]
pub enum ModalRequest {
Models,
Settings,
Plugins,
HelpFind { query: String },
Extensions { sub: String },
}
#[derive(Debug, Clone)]
pub struct SessionSummary {
pub id: String,
pub model: String,
pub title: Option<String>,
pub cost: f64,
pub message_count: usize,
pub is_current: bool,
}
pub fn parse_command(input: &str) -> Option<(&str, &str)> {
let trimmed = input.trim();
if !trimmed.starts_with('/') {
return None;
}
let without_slash = &trimmed[1..];
let (cmd, arg) = match without_slash.find(char::is_whitespace) {
Some(pos) => (&without_slash[..pos], without_slash[pos..].trim()),
None => (without_slash, ""),
};
Some((cmd, arg))
}
pub fn handle_engine_command(
cmd: &str,
arg: &str,
runtime: &mut crate::Runtime,
) -> Option<CommandResult> {
let result = evaluate_engine_command(cmd, arg)?;
match &result {
CommandResult::ModelChanged { model } => runtime.set_model(model.clone()),
CommandResult::ThinkingChanged { budget, .. } => runtime.set_thinking_budget(*budget),
_ => {}
}
Some(result)
}
pub fn evaluate_engine_command(cmd: &str, arg: &str) -> Option<CommandResult> {
match cmd {
"model" | "models" if !arg.is_empty() => Some(CommandResult::ModelChanged {
model: arg.to_string(),
}),
"thinking" if !arg.is_empty() => match parse_thinking_arg(arg) {
Ok((level, budget)) => Some(CommandResult::ThinkingChanged { level, budget }),
Err(e) => Some(CommandResult::Error(e)),
},
"quit" | "exit" => Some(CommandResult::Quit),
"compact" => Some(CommandResult::Compact {
custom_instructions: if arg.is_empty() { None } else { Some(arg.to_string()) },
}),
_ => None, }
}
pub fn parse_thinking_arg(arg: &str) -> Result<(String, u32), String> {
match arg {
"off" | "none" => Ok(("off".to_string(), 0)),
"adaptive" => Ok(("adaptive".to_string(), 0)),
"low" => Ok(("low".to_string(), 2048)),
"medium" | "med" => Ok(("medium".to_string(), 4096)),
"high" => Ok(("high".to_string(), 16384)),
"xhigh" | "max" => Ok(("xhigh".to_string(), 32768)),
other => {
if let Ok(n) = other.parse::<u32>() {
Ok((format!("custom({})", n), n))
} else {
Err(format!("unknown thinking level: {} (use off/adaptive/low/medium/high/xhigh or a number)", other))
}
}
}
}
pub fn thinking_config_value(level: &str, budget: u32) -> String {
match level {
"low" | "medium" | "high" | "xhigh" | "adaptive" => level.to_string(),
_ => budget.to_string(),
}
}
pub fn persist_to_config(key: &str, value: &str) -> String {
match crate::config::write_config_value(key, value) {
Ok(()) => "(saved to config)".to_string(),
Err(e) => format!("(session only — failed to persist: {})", e),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn model_command_carries_model_name() {
match evaluate_engine_command("model", "claude-sonnet-4-6") {
Some(CommandResult::ModelChanged { model }) => assert_eq!(model, "claude-sonnet-4-6"),
other => panic!("expected ModelChanged, got {:?}", other),
}
assert!(matches!(
evaluate_engine_command("models", "claude-opus-4-6"),
Some(CommandResult::ModelChanged { .. })
));
assert!(evaluate_engine_command("model", "").is_none());
}
#[test]
fn thinking_command_normalizes_levels() {
match evaluate_engine_command("thinking", "high") {
Some(CommandResult::ThinkingChanged { level, budget }) => {
assert_eq!(level, "high");
assert_eq!(budget, 16384);
}
other => panic!("expected ThinkingChanged, got {:?}", other),
}
assert_eq!(parse_thinking_arg("med").unwrap(), ("medium".to_string(), 4096));
assert_eq!(parse_thinking_arg("8192").unwrap(), ("custom(8192)".to_string(), 8192));
assert!(parse_thinking_arg("bogus").is_err());
assert!(evaluate_engine_command("thinking", "").is_none());
}
#[test]
fn compact_carries_custom_instructions() {
match evaluate_engine_command("compact", "focus on auth") {
Some(CommandResult::Compact { custom_instructions }) => {
assert_eq!(custom_instructions.as_deref(), Some("focus on auth"));
}
other => panic!("expected Compact, got {:?}", other),
}
assert!(matches!(
evaluate_engine_command("compact", ""),
Some(CommandResult::Compact { custom_instructions: None })
));
}
#[test]
fn thinking_config_value_is_parseable() {
assert_eq!(thinking_config_value("medium", 4096), "medium");
assert_eq!(thinking_config_value("adaptive", 0), "adaptive");
assert_eq!(thinking_config_value("off", 0), "0");
assert_eq!(thinking_config_value("custom(8192)", 8192), "8192");
}
#[test]
#[serial_test::serial]
fn persist_to_config_reports_write_result() {
let home = std::path::PathBuf::from("/tmp/synaps-engine-persist-test");
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(home.join(".synaps-cli")).unwrap();
let original = std::env::var("HOME").ok();
std::env::set_var("HOME", &home);
let status = persist_to_config("model", "claude-sonnet-4-6");
if let Some(h) = original {
std::env::set_var("HOME", h);
} else {
std::env::remove_var("HOME");
}
assert_eq!(status, "(saved to config)");
let contents = std::fs::read_to_string(home.join(".synaps-cli/config")).unwrap();
assert!(contents.contains("model = claude-sonnet-4-6"));
let _ = std::fs::remove_dir_all(&home);
}
}