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