Skip to main content

synaps_cli/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
54    /// Session resumed.
55    Resumed {
56        session_id: String,
57        model: String,
58    },
59
60    /// Session named/saved.
61    Named {
62        name: String,
63    },
64
65    /// Chain info.
66    ChainInfo(String),
67
68    /// Request to open a TUI-specific modal (TUI handles, headless ignores).
69    OpenModal(ModalRequest),
70
71    /// Status/usage info.
72    Status {
73        text: String,
74    },
75
76    /// Ping results.
77    PingStarted,
78
79    /// Keybind list.
80    KeybindList(String),
81
82    /// Skill loaded — needs to be injected into the conversation.
83    SkillLoaded {
84        skill: std::sync::Arc<crate::skills::LoadedSkill>,
85        arg: String,
86    },
87
88    /// Plugin command to execute.
89    PluginCommand {
90        command: std::sync::Arc<crate::skills::registry::RegisteredPluginCommand>,
91        arg: String,
92    },
93
94    /// Sidecar toggle/status.
95    SidecarToggle { plugin_id: Option<String> },
96    SidecarStatus { plugin_id: Option<String> },
97}
98
99/// TUI-specific modals the engine can request.
100#[derive(Debug, Clone)]
101pub enum ModalRequest {
102    Models,
103    Settings,
104    Plugins,
105    HelpFind { query: String },
106    Extensions { sub: String },
107}
108
109/// Summary of a session for listing.
110#[derive(Debug, Clone)]
111pub struct SessionSummary {
112    pub id: String,
113    pub model: String,
114    pub title: Option<String>,
115    pub cost: f64,
116    pub message_count: usize,
117    pub is_current: bool,
118}
119
120/// Parse a slash command into (command, arg).
121pub fn parse_command(input: &str) -> Option<(&str, &str)> {
122    let trimmed = input.trim();
123    if !trimmed.starts_with('/') {
124        return None;
125    }
126    let without_slash = &trimmed[1..];
127    let (cmd, arg) = match without_slash.find(char::is_whitespace) {
128        Some(pos) => (&without_slash[..pos], without_slash[pos..].trim()),
129        None => (without_slash, ""),
130    };
131    Some((cmd, arg))
132}
133
134/// Process commands that are pure engine logic — no TUI state needed.
135/// Returns None if the command needs TUI-level handling.
136pub fn handle_engine_command(
137    cmd: &str,
138    arg: &str,
139    runtime: &mut crate::Runtime,
140) -> Option<CommandResult> {
141    match cmd {
142        "model" if !arg.is_empty() => {
143            runtime.set_model(arg.to_string());
144            Some(CommandResult::ModelChanged {
145                model: arg.to_string(),
146            })
147        }
148        "thinking" if !arg.is_empty() => {
149            let (level, budget) = match arg {
150                "off" | "none" => ("off".to_string(), 0),
151                // `adaptive` matches the runtime's own label for budget=0
152                // (see core::models::thinking_level_for_budget). Adding
153                // it here removes the need for renderers to pre-intercept
154                // this case before delegating to the engine.
155                "adaptive" => ("adaptive".to_string(), 0),
156                "low" => ("low".to_string(), 2048),
157                "medium" | "med" => ("medium".to_string(), 4096),
158                "high" => ("high".to_string(), 16384),
159                "xhigh" | "max" => ("xhigh".to_string(), 32768),
160                other => {
161                    if let Ok(n) = other.parse::<u32>() {
162                        (format!("custom({})", n), n)
163                    } else {
164                        return Some(CommandResult::Error(
165                            format!("unknown thinking level: {} (use off/adaptive/low/medium/high/xhigh or a number)", other)
166                        ));
167                    }
168                }
169            };
170            runtime.set_thinking_budget(budget);
171            Some(CommandResult::ThinkingChanged { level, budget })
172        }
173        "quit" | "exit" => Some(CommandResult::Quit),
174        "compact" => Some(CommandResult::Compact),
175        _ => None, // Not an engine-level command — delegate to renderer
176    }
177}