Skip to main content

saya_cli/interactive/
session_commands.rs

1use super::session_state::SessionState;
2use crate::agent::runtime::PromptOverrides;
3use crate::slash::SlashCommand;
4use saya_agent::AgentOutput;
5use saya_agent::ApprovalPolicy;
6
7// `Agent(AgentOutput)` carries agent events backed by `serde_json::Value`, so
8// this enum is `PartialEq` but not `Eq`.
9#[derive(Debug, Clone, PartialEq)]
10pub enum SessionAction {
11    Message(String),
12    Agent(AgentOutput),
13    Cancelled,
14    NotImplemented(String),
15    Error(String),
16    History,
17    Resume(String),
18    Schema(bool),
19    Sql(String),
20    Export(String),
21    Chart(String),
22    Explain(String),
23    Exit,
24}
25
26impl SessionState {
27    pub fn apply(&mut self, command: SlashCommand, available: &[String]) -> SessionAction {
28        match command {
29            SlashCommand::Connect(name) => {
30                if !available.iter().any(|profile| profile == &name) {
31                    return SessionAction::Error(format!("Unknown configured profile: {name}"));
32                }
33                self.profile = Some(name.clone());
34                SessionAction::Message(format!("Selected profile: {name}"))
35            }
36            SlashCommand::Connections => SessionAction::Message(if available.is_empty() {
37                "No configured connection profiles.".into()
38            } else {
39                format!("Profiles: {}", available.join(", "))
40            }),
41            SlashCommand::Include(name) => {
42                if !available.iter().any(|profile| profile == &name) {
43                    return SessionAction::Error(format!("Unknown configured profile: {name}"));
44                }
45                if !self.included_profiles.contains(&name) {
46                    self.included_profiles.push(name.clone());
47                }
48                SessionAction::Message(format!("Included profile: {name}"))
49            }
50            SlashCommand::Exclude(name) => {
51                self.included_profiles.retain(|item| item != &name);
52                SessionAction::Message(format!("Excluded profile: {name}"))
53            }
54            SlashCommand::Provider(value) => {
55                if let Some(value) = value {
56                    if saya_config::AiProvider::parse(&value).is_none() {
57                        return SessionAction::Error(format!(
58                            "Unsupported provider: {value}. Use ollama, openai, openai_compatible, anthropic, or gemini."
59                        ));
60                    }
61                    self.provider = value;
62                    SessionAction::Message(format!("Provider: {}", self.provider))
63                } else {
64                    SessionAction::Message(format!(
65                        "Provider: {} (available: {})",
66                        self.provider,
67                        available_providers().join(", ")
68                    ))
69                }
70            }
71            SlashCommand::Model(value) => {
72                if let Some(value) = value {
73                    self.model = value;
74                    SessionAction::Message(format!("Model: {}", self.model))
75                } else {
76                    let models = known_models(&self.provider);
77                    if models.is_empty() {
78                        SessionAction::Message(format!(
79                            "Model: {} (no suggestions for provider {})",
80                            self.model, self.provider
81                        ))
82                    } else {
83                        SessionAction::Message(format!(
84                            "Model: {}\nKnown models for {}: {}",
85                            self.model,
86                            self.provider,
87                            models.join(", ")
88                        ))
89                    }
90                }
91            }
92            SlashCommand::Privacy(value) => {
93                if let Some(value) = value {
94                    self.allow_data_sharing = value;
95                }
96                SessionAction::Message(format!(
97                    "Cloud data sharing: {}",
98                    if self.allow_data_sharing {
99                        "enabled"
100                    } else {
101                        "disabled"
102                    }
103                ))
104            }
105            SlashCommand::Approvals(value) => {
106                if let Some(value) = value {
107                    self.approval_mode = approval_name(value);
108                }
109                SessionAction::Message(format!("Approval mode: {}", self.approval_mode))
110            }
111            SlashCommand::Schema(refresh) => SessionAction::Schema(refresh),
112            SlashCommand::Sql(query) => SessionAction::Sql(query),
113            SlashCommand::Export(path) => SessionAction::Export(path),
114            SlashCommand::Chart(args) => SessionAction::Chart(args),
115            SlashCommand::Explain(sql) => SessionAction::Explain(sql),
116            SlashCommand::Clear => {
117                self.messages.clear();
118                self.turns.clear();
119                SessionAction::Message("Conversation context cleared.".into())
120            }
121            SlashCommand::History => SessionAction::History,
122            SlashCommand::Sessions => SessionAction::History,
123            SlashCommand::Resume(id) => SessionAction::Resume(id),
124            SlashCommand::Help(topic) => {
125                SessionAction::Message(crate::slash::help_for(topic.as_deref()))
126            }
127            SlashCommand::Exit => SessionAction::Exit,
128        }
129    }
130
131    pub(crate) fn prompt_overrides(&self) -> PromptOverrides {
132        PromptOverrides {
133            provider: saya_config::AiProvider::parse(&self.provider),
134            model: Some(self.model.clone()),
135            allow_data_sharing: Some(self.allow_data_sharing),
136            profile: self.profile.clone(),
137            included_profiles: self.included_profiles.clone(),
138        }
139    }
140}
141
142fn approval_name(policy: ApprovalPolicy) -> String {
143    match policy {
144        ApprovalPolicy::Ask => "ask",
145        ApprovalPolicy::ReadOnly => "read-only",
146        ApprovalPolicy::Never => "never",
147    }
148    .into()
149}
150
151fn available_providers() -> &'static [&'static str] {
152    &[
153        "ollama",
154        "openai",
155        "openai_compatible",
156        "anthropic",
157        "gemini",
158    ]
159}
160
161/// Curated suggestions for models per provider (convenience suggestions, not an exhaustive or validated list).
162fn known_models(provider: &str) -> &'static [&'static str] {
163    match provider.to_ascii_lowercase().as_str() {
164        "ollama" => &["qwen2.5-coder:14b", "llama3.1", "mistral"],
165        "openai" => &["gpt-4o", "gpt-4o-mini", "o3-mini"],
166        "anthropic" => &["claude-sonnet-4", "claude-opus-4", "claude-3-5-haiku"],
167        "gemini" => &["gemini-2.0-flash", "gemini-1.5-pro"],
168        "openai_compatible" => &[],
169        _ => &[],
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_provider_none_lists_available() {
179        let mut state = SessionState::new("test", None, "gpt-4o");
180        let action = state.apply(SlashCommand::Provider(None), &[]);
181        if let SessionAction::Message(msg) = action {
182            assert!(msg.contains("available:"));
183            assert!(msg.contains("anthropic"));
184        } else {
185            panic!("Expected SessionAction::Message");
186        }
187    }
188
189    #[test]
190    fn test_model_none_lists_known_models_for_ollama() {
191        let mut state = SessionState::new("test", None, "qwen2.5-coder:14b");
192        state.provider = "ollama".into();
193        let action = state.apply(SlashCommand::Model(None), &[]);
194        if let SessionAction::Message(msg) = action {
195            assert!(msg.contains("qwen2.5-coder:14b"));
196        } else {
197            panic!("Expected SessionAction::Message");
198        }
199    }
200
201    #[test]
202    fn test_provider_some_sets_provider_without_available() {
203        let mut state = SessionState::new("test", None, "gpt-4o");
204        let action = state.apply(SlashCommand::Provider(Some("openai".into())), &[]);
205        assert_eq!(state.provider, "openai");
206        if let SessionAction::Message(msg) = action {
207            assert_eq!(msg, "Provider: openai");
208            assert!(!msg.contains("available:"));
209        } else {
210            panic!("Expected SessionAction::Message");
211        }
212    }
213}