Skip to main content

agent_engine/engine/
commands.rs

1//! Engine-level command results — TUI-agnostic outcomes of slash commands.
2//!
3//! The engine processes a command and returns a `CommandResult`.
4//! Renderers (TUI, headless) decide how to display the result.
5
6
7/// Result of processing a slash command in the engine.
8#[derive(Debug, Clone)]
9pub enum CommandResult {
10    /// No output, continue.
11    None,
12
13    /// Text output to display to the user.
14    Output(String),
15
16    /// Error message.
17    Error(String),
18
19    /// Model was changed.
20    ModelChanged {
21        model: String,
22    },
23
24    /// Thinking budget was changed.
25    ThinkingChanged {
26        level: String,
27        budget: u32,
28    },
29
30    /// System prompt was updated.
31    SystemPromptSet {
32        source: String, // "inline", "file", "saved"
33    },
34
35    /// System prompt displayed.
36    SystemPromptShow {
37        prompt: String,
38    },
39
40    /// Session list.
41    SessionList {
42        sessions: Vec<SessionSummary>,
43    },
44
45    /// Session cleared. New session returned.
46    Cleared,
47
48    /// Quit requested.
49    Quit,
50
51    /// Compaction requested (engine should trigger it).
52    Compact {
53        custom_instructions: Option<String>,
54    },
55
56    /// Session resumed.
57    Resumed {
58        session_id: String,
59        model: String,
60    },
61
62    /// Session named/saved.
63    Named {
64        name: String,
65    },
66
67    /// Chain info.
68    ChainInfo(String),
69
70    /// Request to open a TUI-specific modal (TUI handles, headless ignores).
71    OpenModal(ModalRequest),
72
73    /// Status/usage info.
74    Status {
75        text: String,
76    },
77
78    /// Ping results.
79    PingStarted,
80
81    /// Keybind list.
82    KeybindList(String),
83
84    /// Skill loaded — needs to be injected into the conversation.
85    SkillLoaded {
86        skill: std::sync::Arc<crate::skills::LoadedSkill>,
87        arg: String,
88    },
89
90    /// Plugin command to execute.
91    PluginCommand {
92        command: std::sync::Arc<crate::skills::registry::RegisteredPluginCommand>,
93        arg: String,
94    },
95
96    /// Sidecar toggle/status.
97    SidecarToggle { plugin_id: Option<String> },
98    SidecarStatus { plugin_id: Option<String> },
99}
100
101/// TUI-specific modals the engine can request.
102#[derive(Debug, Clone)]
103pub enum ModalRequest {
104    Models,
105    Settings,
106    Plugins,
107    HelpFind { query: String },
108    Extensions { sub: String },
109}
110
111/// Summary of a session for listing.
112#[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
122/// Parse a slash command into (command, arg).
123pub 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
136/// Process commands that are pure engine logic — no TUI state needed.
137/// Returns None if the command needs TUI-level handling.
138///
139/// NOTE: this runs BEFORE any renderer-level command arms (the TUI calls it
140/// first and returns early on Some — see tui/commands.rs). Renderer arms for
141/// commands handled here are unreachable for the matched cases.
142pub 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    // Apply the runtime side effects of the (purely computed) result.
149    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
157/// Pure command → result mapping (no runtime mutation). Split out of
158/// `handle_engine_command` so dispatch can be unit-tested without a Runtime.
159pub fn evaluate_engine_command(cmd: &str, arg: &str) -> Option<CommandResult> {
160    match cmd {
161        // `models` is the TUI alias — intercept it identically so the
162        // non-empty-arg path has a single owner.
163        "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, // Not an engine-level command — delegate to renderer
175    }
176}
177
178/// Parse a `/thinking` argument into a canonical (level, budget) pair.
179pub fn parse_thinking_arg(arg: &str) -> Result<(String, u32), String> {
180    match arg {
181        "off" | "none" => Ok(("off".to_string(), 0)),
182        // `adaptive` matches the runtime's own label for budget=0
183        // (see core::models::thinking_level_for_budget). Adding
184        // it here removes the need for renderers to pre-intercept
185        // this case before delegating to the engine.
186        "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
201/// Config-file value for a thinking change: the canonical level name when it
202/// is one config.rs can parse back, otherwise the raw budget number (which
203/// `parse_thinking_budget` also accepts; 0 is the adaptive sentinel).
204pub 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
211/// Persist a config key and return an honest, user-visible status suffix —
212/// never claims "(saved to config)" unless the write actually succeeded.
213pub 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        // `models` alias intercepts identically
231        assert!(matches!(
232            evaluate_engine_command("models", "claude-opus-4-6"),
233            Some(CommandResult::ModelChanged { .. })
234        ));
235        // empty arg falls through to the renderer (model picker)
236        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}