syncable_cli/agent/session/
mod.rs

1//! Interactive chat session with /model and /provider commands
2//!
3//! Provides a rich REPL experience similar to Claude Code with:
4//! - `/model` - Select from available models based on configured API keys
5//! - `/provider` - Switch provider (prompts for API key if not set)
6//! - `/cost` - Show token usage and estimated cost
7//! - `/help` - Show available commands
8//! - `/clear` - Clear conversation history
9//! - `/exit` or `/quit` - Exit the session
10
11// Submodules
12mod commands;
13mod plan_mode;
14mod providers;
15mod ui;
16
17// Re-exports for backward compatibility
18pub use plan_mode::{IncompletePlan, PlanMode, find_incomplete_plans};
19pub use providers::{get_available_models, get_configured_providers, prompt_api_key};
20
21use crate::agent::commands::TokenUsage;
22use crate::agent::{AgentResult, ProviderType};
23use crate::platform::PlatformSession;
24use colored::Colorize;
25use std::io;
26use std::path::Path;
27
28/// Chat session state
29pub struct ChatSession {
30    pub provider: ProviderType,
31    pub model: String,
32    pub project_path: std::path::PathBuf,
33    pub history: Vec<(String, String)>, // (role, content)
34    pub token_usage: TokenUsage,
35    /// Current planning mode state
36    pub plan_mode: PlanMode,
37    /// Session loaded via /resume command, to be processed by main loop
38    pub pending_resume: Option<crate::agent::persistence::ConversationRecord>,
39    /// Platform session state (selected project/org context)
40    pub platform_session: PlatformSession,
41}
42
43impl ChatSession {
44    pub fn new(project_path: &Path, provider: ProviderType, model: Option<String>) -> Self {
45        let default_model = match provider {
46            ProviderType::OpenAI => "gpt-5.2".to_string(),
47            ProviderType::Anthropic => "claude-sonnet-4-5-20250929".to_string(),
48            ProviderType::Bedrock => "global.anthropic.claude-sonnet-4-20250514-v1:0".to_string(),
49        };
50
51        // Load platform session from disk (returns default if not exists)
52        let platform_session = PlatformSession::load().unwrap_or_default();
53
54        Self {
55            provider,
56            model: model.unwrap_or(default_model),
57            project_path: project_path.to_path_buf(),
58            history: Vec::new(),
59            token_usage: TokenUsage::new(),
60            plan_mode: PlanMode::default(),
61            pending_resume: None,
62            platform_session,
63        }
64    }
65
66    /// Update the platform session and save to disk
67    pub fn update_platform_session(&mut self, session: PlatformSession) {
68        self.platform_session = session;
69        if let Err(e) = self.platform_session.save() {
70            eprintln!(
71                "{}",
72                format!("Warning: Failed to save platform session: {}", e).yellow()
73            );
74        }
75    }
76
77    /// Toggle planning mode and return the new mode
78    pub fn toggle_plan_mode(&mut self) -> PlanMode {
79        self.plan_mode = self.plan_mode.toggle();
80        self.plan_mode
81    }
82
83    /// Check if currently in planning mode
84    pub fn is_planning(&self) -> bool {
85        self.plan_mode.is_planning()
86    }
87
88    /// Check if API key is configured for a provider (env var OR config file)
89    pub fn has_api_key(provider: ProviderType) -> bool {
90        providers::has_api_key(provider)
91    }
92
93    /// Load API key from config if not in env, and set it in env for use
94    pub fn load_api_key_to_env(provider: ProviderType) {
95        providers::load_api_key_to_env(provider)
96    }
97
98    /// Prompt user to enter API key for a provider
99    pub fn prompt_api_key(provider: ProviderType) -> AgentResult<String> {
100        providers::prompt_api_key(provider)
101    }
102
103    /// Handle /model command - interactive model selection
104    pub fn handle_model_command(&mut self) -> AgentResult<()> {
105        commands::handle_model_command(self)
106    }
107
108    /// Handle /provider command - switch provider with API key prompt if needed
109    pub fn handle_provider_command(&mut self) -> AgentResult<()> {
110        commands::handle_provider_command(self)
111    }
112
113    /// Handle /reset command - reset provider credentials
114    pub fn handle_reset_command(&mut self) -> AgentResult<()> {
115        commands::handle_reset_command(self)
116    }
117
118    /// Handle /profile command - manage global profiles
119    pub fn handle_profile_command(&mut self) -> AgentResult<()> {
120        commands::handle_profile_command(self)
121    }
122
123    /// Handle /plans command - show incomplete plans and offer to continue
124    pub fn handle_plans_command(&self) -> AgentResult<()> {
125        commands::handle_plans_command(self)
126    }
127
128    /// Handle /resume command - browse and select a session to resume
129    /// Returns true if a session was loaded and should be displayed
130    pub fn handle_resume_command(&mut self) -> AgentResult<bool> {
131        commands::handle_resume_command(self)
132    }
133
134    /// Handle /sessions command - list available sessions
135    pub fn handle_list_sessions_command(&self) {
136        commands::handle_list_sessions_command(self)
137    }
138
139    /// Handle /help command - delegates to ui module
140    pub fn print_help() {
141        ui::print_help()
142    }
143
144    /// Print session banner with colorful SYNCABLE ASCII art - delegates to ui module
145    pub fn print_logo() {
146        ui::print_logo()
147    }
148
149    /// Print the welcome banner - delegates to ui module
150    pub fn print_banner(&self) {
151        ui::print_banner(self)
152    }
153
154    /// Process a command (returns true if should continue, false if should exit)
155    pub fn process_command(&mut self, input: &str) -> AgentResult<bool> {
156        let cmd = input.trim().to_lowercase();
157
158        // Handle bare "/" - now handled interactively in read_input
159        // Just show help if they somehow got here
160        if cmd == "/" {
161            Self::print_help();
162            return Ok(true);
163        }
164
165        match cmd.as_str() {
166            "/exit" | "/quit" | "/q" => {
167                println!("\n{}", "👋 Goodbye!".green());
168                return Ok(false);
169            }
170            "/help" | "/h" | "/?" => {
171                Self::print_help();
172            }
173            "/model" | "/m" => {
174                self.handle_model_command()?;
175            }
176            "/provider" | "/p" => {
177                self.handle_provider_command()?;
178            }
179            "/cost" => {
180                self.token_usage.print_report(&self.model);
181            }
182            "/clear" | "/c" => {
183                self.history.clear();
184                println!("{}", "✓ Conversation history cleared".green());
185            }
186            "/reset" | "/r" => {
187                self.handle_reset_command()?;
188            }
189            "/profile" => {
190                self.handle_profile_command()?;
191            }
192            "/plans" => {
193                self.handle_plans_command()?;
194            }
195            "/resume" | "/s" => {
196                // Resume loads session into self.pending_resume
197                // Main loop in mod.rs will detect and process it
198                let _ = self.handle_resume_command()?;
199            }
200            "/sessions" | "/ls" => {
201                self.handle_list_sessions_command();
202            }
203            _ => {
204                if cmd.starts_with('/') {
205                    // Unknown command - interactive picker already handled in read_input
206                    println!(
207                        "{}",
208                        format!(
209                            "Unknown command: {}. Type /help for available commands.",
210                            cmd
211                        )
212                        .yellow()
213                    );
214                }
215            }
216        }
217
218        Ok(true)
219    }
220
221    /// Check if input is a command
222    pub fn is_command(input: &str) -> bool {
223        input.trim().starts_with('/')
224    }
225
226    /// Strip @ prefix from file/folder references for AI consumption
227    /// Keeps the path but removes the leading @ that was used for autocomplete
228    /// e.g., "check @src/main.rs for issues" -> "check src/main.rs for issues"
229    fn strip_file_references(input: &str) -> String {
230        let mut result = String::with_capacity(input.len());
231        let chars: Vec<char> = input.chars().collect();
232        let mut i = 0;
233
234        while i < chars.len() {
235            if chars[i] == '@' {
236                // Check if this @ is at start or after whitespace (valid file reference trigger)
237                let is_valid_trigger = i == 0 || chars[i - 1].is_whitespace();
238
239                if is_valid_trigger {
240                    // Check if there's a path after @ (not just @ followed by space/end)
241                    let has_path = i + 1 < chars.len() && !chars[i + 1].is_whitespace();
242
243                    if has_path {
244                        // Skip the @ but keep the path
245                        i += 1;
246                        continue;
247                    }
248                }
249            }
250            result.push(chars[i]);
251            i += 1;
252        }
253
254        result
255    }
256
257    /// Read user input with prompt - with interactive file picker support
258    /// Uses custom terminal handling for @ file references and / commands
259    /// Returns InputResult which the main loop should handle
260    pub fn read_input(&self) -> io::Result<crate::agent::ui::input::InputResult> {
261        use crate::agent::ui::input::read_input_with_file_picker;
262
263        // Build prompt with platform context if project is selected
264        let prompt = if self.platform_session.is_project_selected() {
265            format!(
266                "{} >",
267                self.platform_session.display_context()
268            )
269        } else {
270            ">".to_string()
271        };
272
273        Ok(read_input_with_file_picker(
274            &prompt,
275            &self.project_path,
276            self.plan_mode.is_planning(),
277        ))
278    }
279
280    /// Process a submitted input text - strips @ references and handles suggestion format
281    pub fn process_submitted_text(text: &str) -> String {
282        let trimmed = text.trim();
283        // Handle case where full suggestion was submitted (e.g., "/model        Description")
284        // Extract just the command if it looks like a suggestion format
285        if trimmed.starts_with('/') && trimmed.contains("  ") {
286            // This looks like a suggestion format, extract just the command
287            if let Some(cmd) = trimmed.split_whitespace().next() {
288                return cmd.to_string();
289            }
290        }
291        // Strip @ prefix from file references before sending to AI
292        // The @ is for UI autocomplete, but the AI should see just the path
293        Self::strip_file_references(trimmed)
294    }
295}