1#[derive(Debug, Clone)]
9pub enum CommandResult {
10 None,
12
13 Output(String),
15
16 Error(String),
18
19 ModelChanged {
21 model: String,
22 },
23
24 ThinkingChanged {
26 level: String,
27 budget: u32,
28 },
29
30 SystemPromptSet {
32 source: String, },
34
35 SystemPromptShow {
37 prompt: String,
38 },
39
40 SessionList {
42 sessions: Vec<SessionSummary>,
43 },
44
45 Cleared,
47
48 Quit,
50
51 Compact {
53 custom_instructions: Option<String>,
54 },
55
56 Resumed {
58 session_id: String,
59 model: String,
60 },
61
62 Named {
64 name: String,
65 },
66
67 ChainInfo(String),
69
70 OpenModal(ModalRequest),
72
73 Status {
75 text: String,
76 },
77
78 PingStarted,
80
81 KeybindList(String),
83
84 SkillLoaded {
86 skill: std::sync::Arc<crate::skills::LoadedSkill>,
87 arg: String,
88 },
89
90 PluginCommand {
92 command: std::sync::Arc<crate::skills::registry::RegisteredPluginCommand>,
93 arg: String,
94 },
95
96 SidecarToggle { plugin_id: Option<String> },
98 SidecarStatus { plugin_id: Option<String> },
99}
100
101#[derive(Debug, Clone)]
103pub enum ModalRequest {
104 Models,
105 Settings,
106 Plugins,
107 HelpFind { query: String },
108 Extensions { sub: String },
109}
110
111#[derive(Debug, Clone)]
113pub struct SessionSummary {
114 pub id: String,
115 pub model: String,
116 pub title: Option<String>,
117 pub cost: f64,
118 pub message_count: usize,
119 pub is_current: bool,
120}
121
122pub fn parse_command(input: &str) -> Option<(&str, &str)> {
124 let trimmed = input.trim();
125 if !trimmed.starts_with('/') {
126 return None;
127 }
128 let without_slash = &trimmed[1..];
129 let (cmd, arg) = match without_slash.find(char::is_whitespace) {
130 Some(pos) => (&without_slash[..pos], without_slash[pos..].trim()),
131 None => (without_slash, ""),
132 };
133 Some((cmd, arg))
134}
135
136pub fn handle_engine_command(
143 cmd: &str,
144 arg: &str,
145 runtime: &mut crate::Runtime,
146) -> Option<CommandResult> {
147 let result = evaluate_engine_command(cmd, arg)?;
148 match &result {
150 CommandResult::ModelChanged { model } => runtime.set_model(model.clone()),
151 CommandResult::ThinkingChanged { budget, .. } => runtime.set_thinking_budget(*budget),
152 _ => {}
153 }
154 Some(result)
155}
156
157pub fn evaluate_engine_command(cmd: &str, arg: &str) -> Option<CommandResult> {
160 match cmd {
161 "model" | "models" if !arg.is_empty() => Some(CommandResult::ModelChanged {
164 model: arg.to_string(),
165 }),
166 "thinking" if !arg.is_empty() => match parse_thinking_arg(arg) {
167 Ok((level, budget)) => Some(CommandResult::ThinkingChanged { level, budget }),
168 Err(e) => Some(CommandResult::Error(e)),
169 },
170 "quit" | "exit" => Some(CommandResult::Quit),
171 "compact" => Some(CommandResult::Compact {
172 custom_instructions: if arg.is_empty() { None } else { Some(arg.to_string()) },
173 }),
174 _ => None, }
176}
177
178pub fn parse_thinking_arg(arg: &str) -> Result<(String, u32), String> {
180 match arg {
181 "off" | "none" => Ok(("off".to_string(), 0)),
182 "adaptive" => Ok(("adaptive".to_string(), 0)),
187 "low" => Ok(("low".to_string(), 2048)),
188 "medium" | "med" => Ok(("medium".to_string(), 4096)),
189 "high" => Ok(("high".to_string(), 16384)),
190 "xhigh" | "max" => Ok(("xhigh".to_string(), 32768)),
191 other => {
192 if let Ok(n) = other.parse::<u32>() {
193 Ok((format!("custom({})", n), n))
194 } else {
195 Err(format!("unknown thinking level: {} (use off/adaptive/low/medium/high/xhigh or a number)", other))
196 }
197 }
198 }
199}
200
201pub fn thinking_config_value(level: &str, budget: u32) -> String {
205 match level {
206 "low" | "medium" | "high" | "xhigh" | "adaptive" => level.to_string(),
207 _ => budget.to_string(),
208 }
209}
210
211pub fn persist_to_config(key: &str, value: &str) -> String {
214 match crate::config::write_config_value(key, value) {
215 Ok(()) => "(saved to config)".to_string(),
216 Err(e) => format!("(session only — failed to persist: {})", e),
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn model_command_carries_model_name() {
226 match evaluate_engine_command("model", "claude-sonnet-4-6") {
227 Some(CommandResult::ModelChanged { model }) => assert_eq!(model, "claude-sonnet-4-6"),
228 other => panic!("expected ModelChanged, got {:?}", other),
229 }
230 assert!(matches!(
232 evaluate_engine_command("models", "claude-opus-4-6"),
233 Some(CommandResult::ModelChanged { .. })
234 ));
235 assert!(evaluate_engine_command("model", "").is_none());
237 }
238
239 #[test]
240 fn thinking_command_normalizes_levels() {
241 match evaluate_engine_command("thinking", "high") {
242 Some(CommandResult::ThinkingChanged { level, budget }) => {
243 assert_eq!(level, "high");
244 assert_eq!(budget, 16384);
245 }
246 other => panic!("expected ThinkingChanged, got {:?}", other),
247 }
248 assert_eq!(parse_thinking_arg("med").unwrap(), ("medium".to_string(), 4096));
249 assert_eq!(parse_thinking_arg("8192").unwrap(), ("custom(8192)".to_string(), 8192));
250 assert!(parse_thinking_arg("bogus").is_err());
251 assert!(evaluate_engine_command("thinking", "").is_none());
252 }
253
254 #[test]
255 fn compact_carries_custom_instructions() {
256 match evaluate_engine_command("compact", "focus on auth") {
257 Some(CommandResult::Compact { custom_instructions }) => {
258 assert_eq!(custom_instructions.as_deref(), Some("focus on auth"));
259 }
260 other => panic!("expected Compact, got {:?}", other),
261 }
262 assert!(matches!(
263 evaluate_engine_command("compact", ""),
264 Some(CommandResult::Compact { custom_instructions: None })
265 ));
266 }
267
268 #[test]
269 fn thinking_config_value_is_parseable() {
270 assert_eq!(thinking_config_value("medium", 4096), "medium");
271 assert_eq!(thinking_config_value("adaptive", 0), "adaptive");
272 assert_eq!(thinking_config_value("off", 0), "0");
273 assert_eq!(thinking_config_value("custom(8192)", 8192), "8192");
274 }
275
276 #[test]
277 #[serial_test::serial]
278 fn persist_to_config_reports_write_result() {
279 let home = std::path::PathBuf::from("/tmp/synaps-engine-persist-test");
280 let _ = std::fs::remove_dir_all(&home);
281 std::fs::create_dir_all(home.join(".synaps-cli")).unwrap();
282 let original = std::env::var("HOME").ok();
283 std::env::set_var("HOME", &home);
284
285 let status = persist_to_config("model", "claude-sonnet-4-6");
286
287 if let Some(h) = original {
288 std::env::set_var("HOME", h);
289 } else {
290 std::env::remove_var("HOME");
291 }
292
293 assert_eq!(status, "(saved to config)");
294 let contents = std::fs::read_to_string(home.join(".synaps-cli/config")).unwrap();
295 assert!(contents.contains("model = claude-sonnet-4-6"));
296 let _ = std::fs::remove_dir_all(&home);
297 }
298}