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