├── .gitignore ├── Cargo.toml ├── image.png ├── readme.md ├── repl.png └── src ├── api.rs ├── app.rs ├── config.rs ├── files.rs ├── lib.rs ├── main.rs ├── tui.rs └── ui.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .DS_Store 3 | Cargo.lock 4 | deps.md 5 | index.js 6 | main.js 7 | sample.rs 8 | index.html 9 | *.MDC 10 | todo.md 11 | code_ai.log 12 | /tg 13 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "code-ai" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | anyhow = "1.0" 8 | dirs = "5.0" 9 | reqwest = { version = "0.11", features = ["json"] } 10 | serde = { version = "1.0", features = ["derive"] } 11 | serde_json = "1.0" 12 | tokio = { version = "1.28", features = ["full"] } 13 | # TUI dependencies 14 | ratatui = "0.26.0" 15 | crossterm = "0.27" 16 | # Config management 17 | config = "0.13" 18 | # For static initialization 19 | once_cell = "1.18" 20 | # For logging 21 | log = "0.4" 22 | # For date/time handling 23 | chrono = "0.4" 24 | log4rs = "1.3.0" 25 | -------------------------------------------------------------------------------- /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xKoda/coders/d0bf33f595c7a064dfa1e8beff37d5adc940984a/image.png -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Coders AI Assistant 2 | 3 | Coders is an AI-powered terminal user interface (TUI) tool that helps you edit code with any LLM on [OpenRouter](https://openrouter.ai/models). 4 | 5 | ## Features 6 | 7 | - **Interactive TUI Interface**: Fully navigable terminal interface with keyboard shortcuts 8 | - **Model Selection**: Choose between Claude, Gemini, or any OpenRouter model 9 | - **Cost Tracking**: Monitor your OpenRouter **credit usage** directly in the app 10 | - **File Browser**: Easily navigate and select files to modify 11 | - **Multi-File Context**: Select multiple files to provide better context for AI prompts 12 | - **Side-by-Side Diff View**: Compare original and modified code before accepting changes 13 | - **Explanation Panel**: View AI explanations for each code change 14 | - **Context Window Tracking**: Track the total prompt size and progress of the context window 15 | 16 | ![Coders AI Assistant](repl.png) 17 | 18 | ## Getting Started 19 | 20 | 1. Clone the repository: `git clone https://github.com/0xKoda/coders.git && cd coders` 21 | 2. Launch the application with `cargo run` 22 | 3. On first run, you'll be prompted to enter your OpenRouter API key 23 | 4. Navigate the file browser to select a file to modify 24 | 5. Press the appropriate key to perform actions (as shown in the command bar) 25 | 26 | ## Navigation and Controls 27 | 28 | The application has several modes, each with its own set of controls: 29 | 30 | ### Global Controls 31 | - `h`: Access help screen from most modes 32 | 33 | ### Welcome Screen 34 | - `Enter`: Continue to configuration or file browser 35 | - `q`: Quit the application 36 | 37 | ### Configuration Screen 38 | - `↑/↓`: Navigate between available models 39 | - `Enter`: Select model and proceed 40 | - `Ctrl+A`: Set API key 41 | 42 | ### File Browser 43 | - `↑/↓`: Navigate between files 44 | - `Enter`: Open selected file 45 | - `m`: Switch to multi-file selection mode 46 | - `c`: View credits information 47 | - `q`: Quit the application 48 | - `s`: Select File 49 | 50 | ### Multi-File Selection 51 | - `↑/↓`: Navigate between files 52 | - `Space`: Toggle selection of current file 53 | - `Enter`: Confirm selection and proceed to prompt 54 | - `Esc`: Return to file browser 55 | 56 | ### Editor View 57 | - `↑/↓`: Scroll through file content 58 | - `p`: Enter prompt mode to modify the file 59 | - `q`: Return to file browser 60 | 61 | ### Prompt Input 62 | - Type your request for code modifications 63 | - `Enter`: Submit prompt 64 | - `Esc`: Cancel and return to previous screen 65 | 66 | ### Results View 67 | - `Tab`: Switch between original and modified code panels 68 | - `e`: Toggle explanation panel 69 | - `y`: Accept changes and apply them 70 | - `n`: Reject changes and return to editor 71 | - `q`: Return to file browser without applying changes 72 | - `←/→`: Navigate between files when viewing multi-file diffs 73 | - `↑/↓`: Scroll Files 74 | 75 | ### Credits Screen 76 | - `q`: Return to previous screen 77 | 78 | ## Commands and Workflow 79 | 80 | 1. **Select a File**: Navigate the file browser and press `Enter` to open a file 81 | 2. **Provide Context (Optional)**: Press `m` to enter multi-file selection, choose additional files with `Space`, then confirm with `Enter` 82 | 3. **Modify Code**: With a file open, press `p` to enter a prompt describing the changes you want 83 | 4. **Review Changes**: In the diff view, examine the proposed changes and explanation 84 | 5. **Apply Changes**: Press `y` to accept changes or `n` to reject them 85 | 6. **Continue Iterating**: Make additional changes or select different files 86 | 87 | ## Cost Monitoring 88 | 89 | Press `c` in the file browser to view your OpenRouter credits information: 90 | - Total credits available 91 | - Total usage so far 92 | - Last updated time 93 | 94 | ## Model Selection 95 | 96 | By default, the application uses Claude 3.7 Sonnet. To select a different model: 97 | 1. Start the application 98 | 2. In the configuration screen, use `↑/↓` to select a model 99 | 3. Select "custom" to enter any OpenRouter model ID 100 | 101 | 102 | ## API Key Management 103 | 104 | Your OpenRouter API key is securely stored in your system's configuration directory: 105 | - To change your API key, press `Ctrl+A` in the configuration screen 106 | - Enter your new API key and press `Enter` 107 | 108 | ## Note 109 | 110 | Make sure you have a valid OpenRouter API key. The application will prompt you to enter it if it's not already saved. -------------------------------------------------------------------------------- /repl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xKoda/coders/d0bf33f595c7a064dfa1e8beff37d5adc940984a/repl.png -------------------------------------------------------------------------------- /src/api.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use reqwest::Client; 3 | use serde_json::json; 4 | use std::path::{Path, PathBuf}; 5 | use log::{debug, info, error}; 6 | use serde::Deserialize; 7 | 8 | // Default models for OpenRouter 9 | pub const DEFAULT_CLAUDE: &str = "anthropic/claude-3.7-sonnet:beta"; 10 | pub const DEFAULT_GEMINI: &str = "google/gemini-2.0-flash-001"; 11 | pub const AST_GENERATION_MODEL: &str = "google/gemini-2.0-flash-001"; // Use Gemini for AST generation 12 | 13 | /// Generate an AST for the provided code 14 | pub async fn generate_ast(api_key: &str, code: &str, language: &str) -> Result { 15 | let client = Client::new(); 16 | let url = "https://openrouter.ai/api/v1/chat/completions"; 17 | 18 | let request_body = json!({ 19 | "model": AST_GENERATION_MODEL, // Always use Gemini for AST generation 20 | "messages": [ 21 | { 22 | "role": "system", 23 | "content": "You are an expert programmer specializing in code analysis. Generate a concise Abstract Syntax Tree (AST) for the provided code. Focus on the key structural elements without being overly verbose. The AST should be clear, accurate, and helpful for understanding the code structure." 24 | }, 25 | { 26 | "role": "user", 27 | "content": "Generate an AST for this JavaScript code: function add(a, b) { return a + b; }" 28 | }, 29 | { 30 | "role": "assistant", 31 | "content": "Program\n └─ FunctionDeclaration (name: add)\n ├─ Parameters\n │ ├─ Identifier (name: a)\n │ └─ Identifier (name: b)\n └─ BlockStatement\n └─ ReturnStatement\n └─ BinaryExpression (operator: +)\n ├─ Identifier (name: a)\n └─ Identifier (name: b)" 32 | }, 33 | { 34 | "role": "user", 35 | "content": "Generate an AST for this Python code: def factorial(n): if n <= 1: return 1 else: return n * factorial(n-1)" 36 | }, 37 | { 38 | "role": "assistant", 39 | "content": "Module\n └─ FunctionDef (name: factorial)\n ├─ Parameters\n │ └─ Parameter (name: n)\n └─ Body\n └─ If\n ├─ Test: Compare\n │ ├─ Left: Name (id: n)\n │ └─ Comparator: LessThanOrEqual (<=)\n │ └─ Constant (value: 1)\n ├─ Body\n │ └─ Return\n │ └─ Constant (value: 1)\n └─ Orelse\n └─ Return\n └─ BinOp (op: Mult)\n ├─ Left: Name (id: n)\n └─ Right: Call\n ├─ Func: Name (id: factorial)\n └─ Args: BinOp (op: Sub)\n ├─ Left: Name (id: n)\n └─ Right: Constant (value: 1)" 40 | }, 41 | { 42 | "role": "user", 43 | "content": "Generate an AST for this Rust code: fn main() { println!(\"Hello, world!\"); }" 44 | }, 45 | { 46 | "role": "assistant", 47 | "content": "Crate\n └─ Function (name: main)\n ├─ Parameters: []\n └─ Body: Block\n └─ MacroCall (name: println)\n └─ Arguments\n └─ Literal (type: string, value: \"Hello, world!\")" 48 | }, 49 | { 50 | "role": "user", 51 | "content": format!("Generate an Abstract Syntax Tree (AST) for the following {} code:\n\n{}", language, code) 52 | } 53 | ], 54 | "max_tokens": 1500, 55 | "temperature": 0.7, 56 | }); 57 | 58 | let response = client.post(url) 59 | .header("Content-Type", "application/json") 60 | .header("Authorization", format!("Bearer {}", api_key)) 61 | .json(&request_body) 62 | .send() 63 | .await?; 64 | 65 | if response.status().is_success() { 66 | let body = response.text().await?; 67 | let json_response: serde_json::Value = serde_json::from_str(&body)?; 68 | 69 | if let Some(content) = json_response["choices"][0]["message"]["content"].as_str() { 70 | Ok(content.to_string()) 71 | } else { 72 | Err(anyhow::anyhow!("Failed to extract AST from response")) 73 | } 74 | } else { 75 | Err(anyhow::anyhow!("API request failed: {}", response.status())) 76 | } 77 | } 78 | 79 | /// Send a code modification request to OpenRouter 80 | pub async fn send_code_modification_request( 81 | api_key: &str, 82 | prompt: &str, 83 | code: &str, 84 | ast: Option<&str>, 85 | model: &str, 86 | language: &str, 87 | ) -> Result { 88 | info!("Sending code modification request to model: {}", model); 89 | debug!("Language: {}, Code length: {} bytes", language, code.len()); 90 | debug!("Prompt: {}", prompt); 91 | 92 | // Construct a prompt message that includes instructions for modifying code 93 | let system_message = format!( 94 | "You are a helpful AI coding assistant. You will be given a user request and their code in {}. 95 | Your task is to modify the code according to the user's request. 96 | Respond with both an explanation of the changes and the modified code. 97 | 98 | Guidelines: 99 | 1. Always respond with the full, modified version of the original code 100 | 2. Wrap the code in triple backticks (```) with the language name 101 | 3. Explain what changes you made and why 102 | 4. If the user wants a completely new implementation, provide a full solution 103 | 5. Use best practices and write efficient, clean code", 104 | language 105 | ); 106 | 107 | // Create full user message with prompt and code 108 | let user_message = format!( 109 | "Here is my code in {}:\n\n```\n{}\n```\n\nRequest: {}\n\nPlease provide a full, modified version of the code that addresses my request, along with an explanation of your changes.", 110 | language, code, prompt 111 | ); 112 | 113 | // Add AST information if available 114 | let user_message = if let Some(ast_data) = ast { 115 | format!("{}\n\nHere is the AST for the code:\n```\n{}\n```", user_message, ast_data) 116 | } else { 117 | user_message 118 | }; 119 | 120 | // Create request payload 121 | let payload = serde_json::json!({ 122 | "model": model, 123 | "messages": [ 124 | { 125 | "role": "system", 126 | "content": system_message 127 | }, 128 | { 129 | "role": "user", 130 | "content": user_message 131 | } 132 | ], 133 | "temperature": get_model_temperature(model), 134 | "max_tokens": 8000 135 | }); 136 | 137 | debug!("Sending request to OpenRouter with payload length: {} bytes", 138 | serde_json::to_string(&payload)?.len()); 139 | 140 | // Set up headers 141 | let mut headers = reqwest::header::HeaderMap::new(); 142 | headers.insert( 143 | reqwest::header::AUTHORIZATION, 144 | reqwest::header::HeaderValue::from_str(&format!("Bearer {}", api_key))? 145 | ); 146 | headers.insert(reqwest::header::CONTENT_TYPE, reqwest::header::HeaderValue::from_static("application/json")); 147 | headers.insert("HTTP-Referer", reqwest::header::HeaderValue::from_static("https://github.com/yourname/code-ai")); 148 | headers.insert("X-Title", reqwest::header::HeaderValue::from_static("Code-AI Assistant")); 149 | 150 | // Send request 151 | let client = reqwest::Client::new(); 152 | let response = client 153 | .post("https://openrouter.ai/api/v1/chat/completions") 154 | .headers(headers) 155 | .json(&payload) 156 | .send() 157 | .await?; 158 | 159 | // Check response status 160 | if !response.status().is_success() { 161 | let error_text = response.text().await?; 162 | error!("OpenRouter API error: {}", error_text); 163 | return Err(anyhow::anyhow!("API error: {}", error_text)); 164 | } 165 | 166 | // Parse response 167 | let response_text = response.text().await?; 168 | debug!("Received response from OpenRouter: {} bytes", response_text.len()); 169 | 170 | // Create a sanitized version for logging (truncated to avoid giant log files) 171 | let max_log_length = 1000; 172 | let truncated_response = if response_text.len() > max_log_length { 173 | format!("{}... [truncated, total length: {}]", 174 | &response_text[0..max_log_length], 175 | response_text.len()) 176 | } else { 177 | response_text.clone() 178 | }; 179 | 180 | debug!("Response content: {}", truncated_response); 181 | 182 | #[derive(Deserialize)] 183 | struct OpenRouterResponse { 184 | choices: Vec, 185 | } 186 | 187 | #[derive(Deserialize)] 188 | struct OpenRouterChoice { 189 | message: OpenRouterMessage, 190 | } 191 | 192 | #[derive(Deserialize)] 193 | struct OpenRouterMessage { 194 | content: String, 195 | } 196 | 197 | let response_data: OpenRouterResponse = match serde_json::from_str(&response_text) { 198 | Ok(data) => data, 199 | Err(e) => { 200 | error!("Failed to parse OpenRouter response: {}", e); 201 | return Err(anyhow::anyhow!("Failed to parse API response: {}", e)); 202 | } 203 | }; 204 | 205 | // Get the content from the first choice 206 | if let Some(choice) = response_data.choices.first() { 207 | info!("Successfully received code modification response"); 208 | Ok(choice.message.content.clone()) 209 | } else { 210 | error!("No choices in the API response"); 211 | Err(anyhow::anyhow!("No content in the API response")) 212 | } 213 | } 214 | 215 | /// Process a response that may contain multiple file edits 216 | fn process_multi_file_response_internal(response: &str) -> Vec<(String, String)> { 217 | // Log the response we're trying to process for multiple files 218 | log::info!("Processing multi-file response: {}", response); 219 | 220 | let mut file_edits = Vec::new(); 221 | 222 | // Check if the response contains multiple file sections 223 | if response.contains("File:") || response.contains("file:") { 224 | // Log that we detected a potential multi-file response 225 | log::info!("Detected potential multi-file response"); 226 | 227 | // Extract multiple files from the response 228 | file_edits = extract_multiple_files_from_response(response); 229 | 230 | // Log what we found 231 | log::info!("Extracted {} files from response", file_edits.len()); 232 | for (file, content) in &file_edits { 233 | log::info!("Found file in response: {} with content length: {}", file, content.len()); 234 | } 235 | } else { 236 | log::info!("Response does not appear to contain multiple files"); 237 | } 238 | 239 | file_edits 240 | } 241 | 242 | /// Calculate diff between original and modified code 243 | fn calculate_diff(original: &str, modified: &str) -> Vec { 244 | let mut changes = Vec::new(); 245 | 246 | // Split the original and modified code into lines 247 | let original_lines: Vec<&str> = original.lines().collect(); 248 | let modified_lines: Vec<&str> = modified.lines().collect(); 249 | 250 | // Use a simple line-by-line comparison for now 251 | // This is a basic implementation and could be improved with a proper diff algorithm 252 | let max_lines = std::cmp::max(original_lines.len(), modified_lines.len()); 253 | 254 | for i in 0..max_lines { 255 | let original_line = original_lines.get(i).map(|s| *s).unwrap_or(""); 256 | let modified_line = modified_lines.get(i).map(|s| *s).unwrap_or(""); 257 | 258 | if i >= original_lines.len() { 259 | // Line was added 260 | changes.push(crate::app::Change { 261 | change_type: crate::app::ChangeType::Insert, 262 | line_number: i, 263 | content: modified_line.to_string(), 264 | }); 265 | } else if i >= modified_lines.len() { 266 | // Line was deleted 267 | changes.push(crate::app::Change { 268 | change_type: crate::app::ChangeType::Delete, 269 | line_number: i, 270 | content: original_line.to_string(), 271 | }); 272 | } else if original_line != modified_line { 273 | // Line was modified 274 | changes.push(crate::app::Change { 275 | change_type: crate::app::ChangeType::Modify, 276 | line_number: i, 277 | content: modified_line.to_string(), 278 | }); 279 | } 280 | } 281 | 282 | changes 283 | } 284 | 285 | /// Extract code from the LLM response 286 | pub fn extract_code_from_response(response: &str) -> String { 287 | // Log the response we're trying to extract code from 288 | log::info!("Extracting code from response: {}", response); 289 | 290 | // Check if the response contains multiple file edits 291 | if (response.contains("File:") || response.contains("file:")) && response.contains("```") { 292 | log::info!("Detected multi-file response format, processing accordingly"); 293 | 294 | // Process the multi-file response and get the files 295 | let files = process_multi_file_response_internal(response); 296 | 297 | // If we found files, return the content of the first one 298 | if !files.is_empty() { 299 | log::info!("Returning content of first file: {}", files[0].0); 300 | return files[0].1.clone(); 301 | } 302 | } 303 | 304 | // Regular single file extraction 305 | log::info!("Falling back to single file extraction"); 306 | let mut code = String::new(); 307 | let mut in_code_block = false; 308 | let mut language_line = false; 309 | 310 | for line in response.lines() { 311 | if line.trim().starts_with("```") { 312 | if in_code_block { 313 | in_code_block = false; 314 | } else { 315 | in_code_block = true; 316 | language_line = true; 317 | continue; 318 | } 319 | } else if in_code_block { 320 | if language_line { 321 | language_line = false; 322 | // Skip language identifier line 323 | continue; 324 | } else { 325 | code.push_str(line); 326 | code.push('\n'); 327 | } 328 | } 329 | } 330 | 331 | log::info!("Extracted code length: {}", code.len()); 332 | code 333 | } 334 | 335 | /// Extract explanation text from a response 336 | fn extract_explanation_text(response: &str) -> Option { 337 | // Log the response we're extracting from 338 | log::debug!("Extracting explanation from response of length: {}", response.len()); 339 | 340 | // Split the response into lines 341 | let lines: Vec<&str> = response.lines().collect(); 342 | 343 | // Skip initial empty lines 344 | let mut i = 0; 345 | while i < lines.len() && lines[i].trim().is_empty() { 346 | i += 1; 347 | } 348 | 349 | // Skip lines that might be part of markdown formatting at the beginning 350 | // like "```" if it's the first line 351 | if i < lines.len() && lines[i].trim() == "```" { 352 | i += 1; 353 | } 354 | 355 | let mut explanation = Vec::new(); 356 | 357 | // Collect lines until we hit a code block or file marker 358 | while i < lines.len() { 359 | let line = lines[i].trim(); 360 | 361 | // Break if we hit a code block or file marker 362 | if line.starts_with("```") || 363 | line.starts_with("File:") || 364 | line.starts_with("file:") { 365 | break; 366 | } 367 | 368 | explanation.push(lines[i]); 369 | i += 1; 370 | } 371 | 372 | // If we collected any explanation lines, join them and return 373 | if !explanation.is_empty() { 374 | let result = explanation.join("\n"); 375 | log::info!("Extracted explanation of length: {}", result.len()); 376 | Some(result) 377 | } else { 378 | // If we didn't find any explanation at the beginning, look for text between code blocks 379 | log::info!("No explanation found at beginning, looking between code blocks"); 380 | 381 | let mut i = 0; 382 | let mut in_code_block = false; 383 | let mut between_blocks_text = Vec::new(); 384 | 385 | while i < lines.len() { 386 | let line = lines[i].trim(); 387 | 388 | if line.starts_with("```") { 389 | in_code_block = !in_code_block; 390 | 391 | // If we just ended a code block, start collecting text 392 | if !in_code_block { 393 | let mut j = i + 1; 394 | let mut block_text = Vec::new(); 395 | 396 | // Collect lines until the next code block or file marker 397 | while j < lines.len() { 398 | let next_line = lines[j].trim(); 399 | if next_line.starts_with("```") || 400 | next_line.starts_with("File:") || 401 | next_line.starts_with("file:") { 402 | break; 403 | } 404 | 405 | if !next_line.is_empty() { 406 | block_text.push(lines[j]); 407 | } 408 | j += 1; 409 | } 410 | 411 | // If we found text, add it to our collection 412 | if !block_text.is_empty() { 413 | between_blocks_text.extend(block_text); 414 | } 415 | } 416 | } 417 | 418 | i += 1; 419 | } 420 | 421 | // If we found text between code blocks, return it 422 | if !between_blocks_text.is_empty() { 423 | let result = between_blocks_text.join("\n"); 424 | log::info!("Extracted explanation between code blocks, length: {}", result.len()); 425 | Some(result) 426 | } else { 427 | log::info!("No explanation found in response"); 428 | None 429 | } 430 | } 431 | } 432 | 433 | /// Process a response from the API 434 | pub async fn process_response( 435 | response: String, 436 | original_content: &str, 437 | _file_path: &Path, 438 | ) -> Result { 439 | // Extract code from the response 440 | let code = extract_code_from_response(&response); 441 | 442 | // Extract explanation text 443 | let explanation_text = extract_explanation_text(&response); 444 | 445 | // Calculate the diff between the original and modified code 446 | let changes = calculate_diff(original_content, &code); 447 | 448 | // Create a FileDiff struct 449 | let diff = crate::app::FileDiff { 450 | original: original_content.to_string(), 451 | modified: code, 452 | changes, 453 | explanation_text, 454 | }; 455 | 456 | Ok(diff) 457 | } 458 | 459 | /// Process a multi-file response 460 | pub async fn process_multi_file_response( 461 | response: String, 462 | files_content: &[(PathBuf, String)], 463 | ) -> Result> { 464 | // Extract files from the response 465 | let files = extract_multiple_files_from_response(&response); 466 | 467 | // Log the extracted files for debugging 468 | log::info!("Extracted {} files from response", files.len()); 469 | for (file_name, content) in &files { 470 | log::info!(" - {} (content length: {})", file_name, content.len()); 471 | } 472 | 473 | // Log the available files for matching 474 | log::info!("Available files for matching: {}", files_content.len()); 475 | for (path, _) in files_content { 476 | log::info!(" - {:?}", path); 477 | } 478 | 479 | // Extract explanation text (common for all files) 480 | let explanation_text = extract_explanation_text(&response); 481 | if let Some(ref exp) = explanation_text { 482 | log::info!("Extracted explanation text (length: {})", exp.len()); 483 | } else { 484 | log::info!("No explanation text extracted"); 485 | } 486 | 487 | let mut result = Vec::new(); 488 | 489 | // Process each file 490 | for (file_name, file_content) in files { 491 | // Find the original content for this file using various matching strategies 492 | let normalized_name = if file_name.starts_with("./") { 493 | file_name[2..].to_string() 494 | } else { 495 | file_name.clone() 496 | }; 497 | 498 | let basename = Path::new(&normalized_name) 499 | .file_name() 500 | .map(|name| name.to_string_lossy().to_string()) 501 | .unwrap_or_else(|| normalized_name.clone()); 502 | 503 | // Try multiple matching strategies 504 | let original_content = files_content 505 | .iter() 506 | .find(|(path, _)| { 507 | let path_str = path.to_string_lossy().to_string(); 508 | 509 | // Exact path match 510 | if path_str == normalized_name { 511 | log::info!("Found exact path match for {}: {:?}", file_name, path); 512 | return true; 513 | } 514 | 515 | // Path ends with the filename 516 | if path_str.ends_with(&normalized_name) || 517 | path_str.ends_with(&format!("/{}", normalized_name)) { 518 | log::info!("Found path ending with {} for {}: {:?}", normalized_name, file_name, path); 519 | return true; 520 | } 521 | 522 | // Basename match 523 | if let Some(name) = path.file_name() { 524 | if name.to_string_lossy() == basename { 525 | log::info!("Found basename match ({}) for {}: {:?}", basename, file_name, path); 526 | return true; 527 | } 528 | } 529 | 530 | false 531 | }) 532 | .map(|(_, content)| content.clone()) 533 | .unwrap_or_else(|| { 534 | // Log that we couldn't find the original content 535 | log::warn!("Original content not found for file: {}, using empty string", file_name); 536 | String::new() 537 | }); 538 | 539 | log::info!("File: {}, Original content length: {}, Modified content length: {}", 540 | file_name, original_content.len(), file_content.len()); 541 | 542 | // Calculate the diff 543 | let changes = calculate_diff(&original_content, &file_content); 544 | 545 | // Create a FileDiff struct 546 | let diff = crate::app::FileDiff { 547 | original: original_content, 548 | modified: file_content, 549 | changes, 550 | explanation_text: explanation_text.clone(), 551 | }; 552 | 553 | result.push((file_name, diff)); 554 | } 555 | 556 | Ok(result) 557 | } 558 | 559 | /// Get the programming language from a file extension 560 | pub fn get_file_language(file_path: &Path) -> &'static str { 561 | let extension = file_path 562 | .extension() 563 | .and_then(std::ffi::OsStr::to_str) 564 | .unwrap_or(""); 565 | 566 | match extension { 567 | "js" => "javascript", 568 | "ts" => "typescript", 569 | "py" => "python", 570 | "rs" => "rust", 571 | "go" => "go", 572 | "java" => "java", 573 | "cpp" | "cc" | "cxx" => "c++", 574 | "c" => "c", 575 | "cs" => "c#", 576 | "php" => "php", 577 | "rb" => "ruby", 578 | "swift" => "swift", 579 | "kt" | "kts" => "kotlin", 580 | "scala" => "scala", 581 | "hs" => "haskell", 582 | "lua" => "lua", 583 | "pl" => "perl", 584 | "r" => "r", 585 | "sh" => "shell", 586 | "sql" => "sql", 587 | "html" => "html", 588 | "css" => "css", 589 | "md" | "markdown" => "markdown", 590 | "json" => "json", 591 | "xml" => "xml", 592 | "yaml" | "yml" => "yaml", 593 | _ => "plaintext", 594 | } 595 | } 596 | 597 | /// Validate API key with OpenRouter 598 | pub async fn validate_api_key(api_key: &str) -> Result { 599 | let client = Client::new(); 600 | let url = "https://openrouter.ai/api/v1/models"; 601 | 602 | let response = client.get(url) 603 | .header("Authorization", format!("Bearer {}", api_key)) 604 | .send() 605 | .await?; 606 | 607 | let is_valid = response.status().is_success(); 608 | Ok(is_valid) 609 | } 610 | 611 | /// Save API key to configuration 612 | pub fn save_api_key(api_key: &str) -> Result<()> { 613 | let config_dir = dirs::config_dir() 614 | .context("Failed to get config directory")?; 615 | let config_file = config_dir.join("code_ai_openrouter_api_key.txt"); 616 | 617 | std::fs::create_dir_all(&config_dir)?; 618 | std::fs::write(config_file, api_key)?; 619 | 620 | Ok(()) 621 | } 622 | 623 | /// Save preferred model to configuration 624 | pub fn save_preferred_model(model: &str) -> Result<()> { 625 | let config_dir = dirs::config_dir() 626 | .context("Failed to get config directory")?; 627 | let config_file = config_dir.join("code_ai_preferred_model.txt"); 628 | 629 | std::fs::create_dir_all(&config_dir)?; 630 | std::fs::write(config_file, model)?; 631 | 632 | Ok(()) 633 | } 634 | 635 | /// Send a code modification request with context from multiple files 636 | pub async fn send_code_modification_request_with_context( 637 | api_key: &str, 638 | prompt: &str, 639 | code: &str, 640 | context: &str, 641 | ast: Option<&str>, 642 | model: &str, 643 | language: &str, 644 | ) -> Result { 645 | // Create a client 646 | let client = reqwest::Client::new(); 647 | 648 | // Construct the system prompt 649 | let system_prompt = format!( 650 | r#"You are an expert software developer. Your task is to modify the provided code according to the user's request. 651 | 652 | If the user's request involves modifying multiple files, you MUST format your response exactly as follows: 653 | 654 | 1. Start with a brief summary of the changes you're making 655 | 2. For each file that needs changes, include: 656 | - A line that says "File: filename.ext" (use the exact filename) 657 | - Immediately followed by a code block with triple backticks 658 | - The complete updated content of the file inside the code block 659 | - Close the code block with triple backticks 660 | 661 | IMPORTANT: Each file section MUST follow this exact pattern: 662 | File: filename.ext 663 | ``` 664 | [complete file content here] 665 | ``` 666 | 667 | Example multi-file response format: 668 | ``` 669 | I've made the following changes: 670 | 1. Updated the function in main.js 671 | 2. Modified the helper function in utils.js 672 | 673 | File: main.js 674 | ```javascript 675 | // Complete content of main.js with changes 676 | ``` 677 | 678 | File: utils.js 679 | ```javascript 680 | // Complete content of utils.js with changes 681 | ``` 682 | ``` 683 | 684 | If only one file needs changes, you can simply return the modified code in a code block. 685 | 686 | The primary file you need to modify is written in {language}. Focus on making the requested changes while maintaining the overall structure and style of the code. 687 | 688 | DO NOT include any explanations or comments outside of the code blocks unless absolutely necessary for clarity. The code should be ready to use without any modifications."# 689 | ); 690 | 691 | // Log the system prompt 692 | log::debug!("System prompt: {}", system_prompt); 693 | 694 | // Create the user prompt with code and context 695 | let user_prompt = if !context.is_empty() { 696 | format!( 697 | "I need to modify the following code based on this request: {}\n\nPRIMARY FILE TO MODIFY:\n```\n{}\n```\n\nCONTEXT FROM RELATED FILES:{}\n\nPlease provide the complete modified code for any files that need changes.", 698 | prompt, code, context 699 | ) 700 | } else { 701 | format!( 702 | "I need to modify the following code based on this request: {}\n\n```\n{}\n```\n\nPlease provide the complete modified code.", 703 | prompt, code 704 | ) 705 | }; 706 | 707 | // Add AST if provided 708 | let user_prompt = if let Some(ast) = ast { 709 | format!("{}\n\nHere is the AST of the code:\n```\n{}\n```", user_prompt, ast) 710 | } else { 711 | user_prompt 712 | }; 713 | 714 | // Create the request body 715 | let request_body = serde_json::json!({ 716 | "model": model, 717 | "messages": [ 718 | { 719 | "role": "system", 720 | "content": system_prompt 721 | }, 722 | { 723 | "role": "user", 724 | "content": user_prompt 725 | } 726 | ], 727 | "temperature": 0.7, 728 | "max_tokens": 4000 729 | }); 730 | 731 | // Log the request for debugging 732 | log::debug!("Sending request to OpenRouter API: {:?}", request_body); 733 | 734 | // Make the API request 735 | let response = client.post("https://openrouter.ai/api/v1/chat/completions") 736 | .header("Authorization", format!("Bearer {}", api_key)) 737 | .header("Content-Type", "application/json") 738 | .json(&request_body) 739 | .send() 740 | .await?; 741 | 742 | // Check if the request was successful 743 | if !response.status().is_success() { 744 | let error_text = response.text().await?; 745 | return Err(anyhow::anyhow!("API request failed: {}", error_text)); 746 | } 747 | 748 | // Parse the response 749 | let response_json: serde_json::Value = response.json().await?; 750 | 751 | // Extract the response text 752 | let response_text = response_json["choices"][0]["message"]["content"] 753 | .as_str() 754 | .ok_or_else(|| anyhow::anyhow!("Failed to extract response text"))? 755 | .to_string(); 756 | 757 | // Log the raw response for debugging 758 | log::info!("Raw API response: {}", response_text); 759 | 760 | Ok(response_text) 761 | } 762 | 763 | /// Fetch credits information from OpenRouter 764 | pub async fn fetch_openrouter_credits(api_key: &str) -> Result { 765 | // Create a client with a timeout 766 | let client = reqwest::Client::builder() 767 | .timeout(std::time::Duration::from_secs(30)) 768 | .build()?; 769 | 770 | // Make the request to the OpenRouter API 771 | let response = client.get("https://openrouter.ai/api/v1/credits") 772 | .header("Authorization", format!("Bearer {}", api_key)) 773 | .send() 774 | .await?; 775 | 776 | // Check if the request was successful 777 | if !response.status().is_success() { 778 | let error_text = response.text().await?; 779 | error!("Failed to fetch credits: {}", error_text); 780 | return Err(anyhow::anyhow!("Failed to fetch credits: {}", error_text)); 781 | } 782 | 783 | // Parse the response JSON 784 | let response_text = response.text().await?; 785 | log::debug!("Credits response: {}", response_text); 786 | 787 | // Parse the JSON response according to OpenRouter's format 788 | let response_json: serde_json::Value = serde_json::from_str(&response_text)?; 789 | 790 | // Extract the credits information from the response 791 | // The format is {"data": {"total_credits": 1.1, "total_usage": 1.1}} 792 | let data = response_json.get("data").ok_or_else(|| anyhow::anyhow!("Missing 'data' field in response"))?; 793 | 794 | let total_credits = data.get("total_credits") 795 | .and_then(|v| v.as_f64()) 796 | .ok_or_else(|| anyhow::anyhow!("Missing or invalid 'total_credits' field"))?; 797 | 798 | let total_usage = data.get("total_usage") 799 | .and_then(|v| v.as_f64()) 800 | .ok_or_else(|| anyhow::anyhow!("Missing or invalid 'total_usage' field"))?; 801 | 802 | // Create and return the CreditsInfo struct 803 | Ok(crate::app::CreditsInfo { 804 | total_credits, 805 | total_usage, 806 | last_updated: std::time::SystemTime::now(), 807 | }) 808 | } 809 | 810 | /// Extract multiple files from a response 811 | fn extract_multiple_files_from_response(response: &str) -> Vec<(String, String)> { 812 | let mut file_edits = Vec::new(); 813 | 814 | // Split the response by lines for processing 815 | let lines: Vec<&str> = response.lines().collect(); 816 | let mut i = 0; 817 | 818 | while i < lines.len() { 819 | let line = lines[i].trim(); 820 | 821 | // Log each line for detailed debugging 822 | log::debug!("Processing line {}: {}", i, line); 823 | 824 | // Look for file markers 825 | if line.contains("File:") || line.contains("file:") { 826 | // Extract the file name 827 | let file_name = extract_file_name(line); 828 | log::info!("Found file marker at line {}: {}", i, file_name); 829 | 830 | // Look for the start of a code block 831 | let mut code_block_start = i + 1; 832 | while code_block_start < lines.len() && !lines[code_block_start].trim().starts_with("```") { 833 | code_block_start += 1; 834 | } 835 | 836 | if code_block_start < lines.len() { 837 | // Found the start of a code block 838 | let mut code_block_end = code_block_start + 1; 839 | while code_block_end < lines.len() && !lines[code_block_end].trim().starts_with("```") { 840 | code_block_end += 1; 841 | } 842 | 843 | if code_block_end < lines.len() { 844 | // Found the end of a code block 845 | let mut code_content = String::new(); 846 | 847 | // Skip the language identifier line if present 848 | let content_start = if code_block_start + 1 < code_block_end && 849 | (lines[code_block_start + 1].contains("javascript") || 850 | lines[code_block_start + 1].contains("python") || 851 | lines[code_block_start + 1].contains("rust") || 852 | lines[code_block_start + 1].contains("java") || 853 | lines[code_block_start + 1].contains("typescript")) { 854 | code_block_start + 2 855 | } else { 856 | code_block_start + 1 857 | }; 858 | 859 | // Extract the code content 860 | for j in content_start..code_block_end { 861 | code_content.push_str(lines[j]); 862 | code_content.push('\n'); 863 | } 864 | 865 | log::info!("Extracted code for file {} from lines {}-{}, content length: {}", 866 | file_name, content_start, code_block_end, code_content.len()); 867 | 868 | file_edits.push((file_name, code_content)); 869 | 870 | // Move to the end of this code block 871 | i = code_block_end; 872 | } 873 | } 874 | } 875 | 876 | i += 1; 877 | } 878 | 879 | file_edits 880 | } 881 | 882 | /// Helper function to extract file name from a line 883 | fn extract_file_name(line: &str) -> String { 884 | // Try different formats of file markers 885 | if let Some(pos) = line.find("File:") { 886 | let file_part = &line[pos + 5..].trim(); 887 | // Extract until the end of line or until a special character 888 | if let Some(end) = file_part.find(|c: char| c == '`' || c == ':' || c == '(' || c == ')') { 889 | file_part[..end].trim().to_string() 890 | } else { 891 | file_part.to_string() 892 | } 893 | } else if let Some(pos) = line.find("file:") { 894 | let file_part = &line[pos + 5..].trim(); 895 | if let Some(end) = file_part.find(|c: char| c == '`' || c == ':' || c == '(' || c == ')') { 896 | file_part[..end].trim().to_string() 897 | } else { 898 | file_part.to_string() 899 | } 900 | } else if let Some(pos) = line.find("File ") { 901 | let file_part = &line[pos + 5..].trim(); 902 | if let Some(end) = file_part.find(|c: char| c == '`' || c == ':' || c == '(' || c == ')') { 903 | file_part[..end].trim().to_string() 904 | } else { 905 | file_part.to_string() 906 | } 907 | } else if let Some(pos) = line.find("file ") { 908 | let file_part = &line[pos + 5..].trim(); 909 | if let Some(end) = file_part.find(|c: char| c == '`' || c == ':' || c == '(' || c == ')') { 910 | file_part[..end].trim().to_string() 911 | } else { 912 | file_part.to_string() 913 | } 914 | } else { 915 | // Default case if we can't extract a proper file name 916 | "unknown_file".to_string() 917 | } 918 | } 919 | 920 | /// Model specific temperature settings 921 | fn get_model_temperature(model: &str) -> f32 { 922 | match model { 923 | m if m.contains("gemini") => 0.7, // Gemini models work better with slightly lower temp 924 | m if m.contains("claude") => 0.7, // Claude models work well with moderate temperature 925 | _ => 0.7, // Default for other models 926 | } 927 | } 928 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use log::{debug, info, error}; 3 | use std::path::{Path, PathBuf}; 4 | use std::time::{SystemTime, Duration, Instant}; 5 | 6 | /// Represents different views/modes in the application 7 | #[derive(Debug, Clone, PartialEq, Eq)] 8 | pub enum AppMode { 9 | Welcome, // Initial welcome screen 10 | Configuration, // Setting up API key/model 11 | ApiKeyInput, // Input screen for API key 12 | CustomModelInput, // Input screen for custom model 13 | FileBrowser, // Browsing files 14 | FileSelection, // Selecting multiple files for context 15 | Editor, // Viewing/editing code 16 | PromptInput, // Writing a prompt 17 | Results, // Viewing results with diffs 18 | Help, // Help screen 19 | Credits, // Viewing OpenRouter credits 20 | } 21 | 22 | /// Processing state for API requests 23 | #[derive(Debug, Clone, PartialEq, Eq)] 24 | pub enum ProcessingState { 25 | Idle, 26 | Processing, 27 | Done, 28 | Error(String), 29 | } 30 | 31 | /// Side panel selection 32 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 33 | pub enum ActivePanel { 34 | Left, 35 | Right, 36 | } 37 | 38 | /// Represents the current state of a file diff 39 | #[derive(Debug, Clone)] 40 | pub struct FileDiff { 41 | pub original: String, 42 | pub modified: String, 43 | pub changes: Vec, 44 | pub explanation_text: Option, 45 | } 46 | 47 | /// Represents a single change in a file 48 | #[derive(Debug, Clone)] 49 | pub struct Change { 50 | pub change_type: ChangeType, 51 | pub line_number: usize, 52 | pub content: String, 53 | } 54 | 55 | /// Type of change 56 | #[derive(Debug, Clone)] 57 | pub enum ChangeType { 58 | Insert, 59 | Delete, 60 | Modify, 61 | } 62 | 63 | /// OpenRouter credits information 64 | #[derive(Debug, Clone)] 65 | pub struct CreditsInfo { 66 | pub total_credits: f64, 67 | pub total_usage: f64, 68 | pub last_updated: SystemTime, 69 | } 70 | 71 | impl Default for CreditsInfo { 72 | fn default() -> Self { 73 | Self { 74 | total_credits: 0.0, 75 | total_usage: 0.0, 76 | last_updated: SystemTime::now(), 77 | } 78 | } 79 | } 80 | 81 | /// Main application state 82 | pub struct App { 83 | pub running: bool, 84 | pub mode: AppMode, 85 | pub active_panel: ActivePanel, 86 | 87 | // Configuration 88 | pub api_key: Option, 89 | pub selected_model: String, 90 | pub available_models: Vec, 91 | pub custom_model: String, 92 | 93 | // File browsing 94 | pub current_dir: PathBuf, 95 | pub file_list: Vec, 96 | pub selected_file_idx: usize, 97 | 98 | // Multi-file selection 99 | pub selected_files: Vec, 100 | pub selected_files_content: Vec<(PathBuf, String)>, 101 | 102 | // Code editing 103 | pub current_file: Option, 104 | pub current_file_content: String, 105 | pub scroll_position: usize, 106 | 107 | // Prompt 108 | pub current_prompt: String, 109 | 110 | // Results 111 | pub current_diff: Option, 112 | pub current_diff_file: Option, // Name of the file currently being viewed 113 | pub multi_file_diffs: Option>, // All diffs from a multi-file response 114 | pub explanation_text: Option, // Store the explanatory text from the LLM for the entire response 115 | 116 | // Processing state 117 | pub processing_state: ProcessingState, 118 | pub spinner_frame: usize, 119 | 120 | // Messages 121 | pub message: Option, 122 | pub message_type: MessageType, 123 | 124 | // OpenRouter credits 125 | pub credits_info: Option, 126 | 127 | /// Whether to show credits information 128 | pub show_credits: bool, 129 | 130 | /// Message timeout (for auto-clearing messages) 131 | pub message_time: Option, 132 | pub message_timeout: Duration, 133 | 134 | /// Whether to show explanation text in results view 135 | pub show_explanation: bool, 136 | 137 | /// Token count for context window 138 | pub token_count: usize, 139 | 140 | /// Context window size 141 | pub context_window_size: usize, 142 | } 143 | 144 | /// Message type for status updates 145 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 146 | pub enum MessageType { 147 | Info, 148 | Error, 149 | Success, 150 | } 151 | 152 | impl Default for App { 153 | fn default() -> Self { 154 | Self { 155 | running: true, 156 | mode: AppMode::Welcome, 157 | active_panel: ActivePanel::Left, 158 | 159 | api_key: None, 160 | selected_model: "anthropic/claude-3.7-sonnet:beta".to_string(), 161 | available_models: vec![ 162 | "google/gemini-2.0-flash-001".to_string(), 163 | "anthropic/claude-3.7-sonnet".to_string(), 164 | "custom".to_string(), // Option for custom model input 165 | ], 166 | custom_model: String::new(), 167 | 168 | current_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")), 169 | file_list: Vec::new(), 170 | selected_file_idx: 0, 171 | 172 | selected_files: Vec::new(), 173 | selected_files_content: Vec::new(), 174 | 175 | current_file: None, 176 | current_file_content: String::new(), 177 | scroll_position: 0, 178 | 179 | current_prompt: String::new(), 180 | 181 | current_diff: None, 182 | current_diff_file: None, 183 | multi_file_diffs: None, 184 | explanation_text: None, 185 | 186 | processing_state: ProcessingState::Idle, 187 | spinner_frame: 0, 188 | 189 | message: None, 190 | message_type: MessageType::Info, 191 | 192 | credits_info: None, 193 | 194 | show_credits: false, 195 | 196 | message_time: None, 197 | message_timeout: Duration::from_secs(3), 198 | 199 | show_explanation: false, 200 | 201 | token_count: 0, 202 | 203 | context_window_size: 0, 204 | } 205 | } 206 | } 207 | 208 | impl App { 209 | /// Create a new application instance 210 | pub fn new() -> Self { 211 | let mut app = Self::default(); 212 | app.update_context_window_size(); 213 | app.update_token_count(); 214 | app 215 | } 216 | 217 | /// Handle key events based on current mode 218 | pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> Result<()> { 219 | match self.mode { 220 | AppMode::Welcome => self.handle_welcome_key(key), 221 | AppMode::Configuration => self.handle_config_key(key), 222 | AppMode::ApiKeyInput => self.handle_api_key_input_key(key), 223 | AppMode::CustomModelInput => self.handle_custom_model_input_key(key), 224 | AppMode::FileBrowser => self.handle_file_browser_key(key), 225 | AppMode::FileSelection => self.handle_file_selection_key(key), 226 | AppMode::Editor => self.handle_editor_key(key), 227 | AppMode::PromptInput => { 228 | // Check if we have selected files for context 229 | if !self.selected_files.is_empty() { 230 | self.handle_prompt_key_with_context(key) 231 | } else { 232 | self.handle_prompt_key(key) 233 | } 234 | }, 235 | AppMode::Results => self.handle_results_key(key), 236 | AppMode::Help => self.handle_help_key(key), 237 | AppMode::Credits => self.handle_credits_key(key), 238 | } 239 | } 240 | 241 | fn handle_welcome_key(&mut self, key: crossterm::event::KeyEvent) -> Result<()> { 242 | use crossterm::event::KeyCode; 243 | 244 | match key.code { 245 | KeyCode::Char('q') => { 246 | self.running = false; 247 | } 248 | KeyCode::Enter => { 249 | self.mode = AppMode::Configuration; 250 | } 251 | _ => {} 252 | } 253 | 254 | Ok(()) 255 | } 256 | 257 | fn handle_config_key(&mut self, key: crossterm::event::KeyEvent) -> Result<()> { 258 | use crossterm::event::{KeyCode, KeyModifiers}; 259 | 260 | match key.code { 261 | KeyCode::Char('q') => { 262 | self.mode = AppMode::Welcome; 263 | }, 264 | KeyCode::Up => { 265 | // Get the index of the currently selected model 266 | let current_idx = self.available_models.iter().position(|m| m == &self.selected_model) 267 | .unwrap_or(0); 268 | 269 | // Move up in the list (with wraparound) 270 | if current_idx > 0 { 271 | self.selected_model = self.available_models[current_idx - 1].clone(); 272 | } else { 273 | self.selected_model = self.available_models[self.available_models.len() - 1].clone(); 274 | } 275 | }, 276 | KeyCode::Down => { 277 | // Get the index of the currently selected model 278 | let current_idx = self.available_models.iter().position(|m| m == &self.selected_model) 279 | .unwrap_or(0); 280 | 281 | // Move down in the list (with wraparound) 282 | if current_idx < self.available_models.len() - 1 { 283 | self.selected_model = self.available_models[current_idx + 1].clone(); 284 | } else { 285 | self.selected_model = self.available_models[0].clone(); 286 | } 287 | }, 288 | KeyCode::Enter => { 289 | // If "custom" is selected, go to custom model input 290 | if self.selected_model == "custom" { 291 | self.mode = AppMode::CustomModelInput; 292 | self.current_prompt = self.custom_model.clone(); 293 | return Ok(()); 294 | } 295 | 296 | // If API key is set, save the selected model and proceed 297 | if self.api_key.is_some() { 298 | // Save the selected model to config 299 | if let Some(config_dir) = dirs::config_dir() { 300 | let config_file = config_dir.join("code_ai_preferred_model.txt"); 301 | std::fs::write(config_file, &self.selected_model)?; 302 | self.set_success_message(&format!("Model set to {}", self.selected_model)); 303 | } 304 | 305 | // Proceed to file browser 306 | self.mode = AppMode::FileBrowser; 307 | self.refresh_file_list()?; 308 | } else { 309 | // If no API key, prompt for it 310 | self.mode = AppMode::ApiKeyInput; 311 | self.current_prompt = String::new(); 312 | } 313 | }, 314 | KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { 315 | // Ctrl+A to set API key 316 | self.mode = AppMode::ApiKeyInput; 317 | self.current_prompt = self.api_key.clone().unwrap_or_default(); 318 | }, 319 | _ => {} 320 | } 321 | 322 | Ok(()) 323 | } 324 | 325 | /// Handle key events for API key input 326 | fn handle_api_key_input_key(&mut self, key: crossterm::event::KeyEvent) -> Result<()> { 327 | use crossterm::event::KeyCode; 328 | 329 | match key.code { 330 | KeyCode::Enter => { 331 | // Save the API key if it's not empty 332 | if !self.current_prompt.is_empty() { 333 | self.api_key = Some(self.current_prompt.clone()); 334 | self.message = Some("API Key configured successfully".to_string()); 335 | self.message_type = MessageType::Success; 336 | 337 | // Save to config 338 | crate::config::save_api_key(&self.current_prompt)?; 339 | } 340 | 341 | // Return to configuration screen 342 | self.mode = AppMode::Configuration; 343 | } 344 | KeyCode::Esc => { 345 | // Cancel and return to configuration screen 346 | self.mode = AppMode::Configuration; 347 | } 348 | KeyCode::Char(c) => { 349 | // Add character to API key 350 | self.current_prompt.push(c); 351 | } 352 | KeyCode::Backspace => { 353 | // Remove last character 354 | self.current_prompt.pop(); 355 | } 356 | _ => {} 357 | } 358 | 359 | Ok(()) 360 | } 361 | 362 | fn handle_file_browser_key(&mut self, key: crossterm::event::KeyEvent) -> Result<()> { 363 | use crossterm::event::KeyCode; 364 | 365 | match key.code { 366 | KeyCode::Char('q') => { 367 | // Quit the file browser and return to configuration 368 | self.mode = AppMode::Welcome; 369 | } 370 | KeyCode::Char('h') => { 371 | // Show help screen 372 | self.mode = AppMode::Help; 373 | } 374 | KeyCode::Char('m') => { 375 | // Enter multi-file selection mode 376 | self.selected_files.clear(); 377 | self.selected_files_content.clear(); 378 | self.mode = AppMode::FileSelection; 379 | self.set_info_message("Multi-file selection mode. Use spacebar to select files, Enter when done."); 380 | } 381 | KeyCode::Char('s') => { 382 | // Select the current file for context without opening it 383 | if self.selected_file_idx < self.file_list.len() { 384 | let path = self.file_list[self.selected_file_idx].clone(); 385 | if path.is_file() { 386 | self.toggle_file_selection(path); 387 | } else { 388 | self.set_info_message("Can only select files, not directories."); 389 | } 390 | } 391 | } 392 | KeyCode::Char('p') => { 393 | // Enter prompt mode if files are selected 394 | if !self.selected_files.is_empty() { 395 | self.mode = AppMode::PromptInput; 396 | self.current_prompt = String::new(); 397 | self.message = Some(format!("Enter your prompt (using {} selected files as context)...", 398 | self.selected_files.len())); 399 | } else { 400 | self.set_error_message("No files selected. Use 's' to select files first."); 401 | } 402 | } 403 | KeyCode::Char('l') => { 404 | // List all selected files 405 | if self.selected_files.is_empty() { 406 | self.set_info_message("No files selected. Use 's' to select files."); 407 | } else { 408 | let files_list = self.selected_files.iter() 409 | .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string()) 410 | .collect::>() 411 | .join(", "); 412 | self.set_info_message(&format!("Selected files ({}): {}", self.selected_files.len(), files_list)); 413 | } 414 | } 415 | KeyCode::Char('C') if key.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) => { 416 | // Clear all selected files 417 | self.selected_files.clear(); 418 | self.selected_files_content.clear(); 419 | self.set_success_message("Cleared all selected files"); 420 | } 421 | KeyCode::Char('c') => { 422 | // View credits information 423 | if let Some(_api_key) = &self.api_key { 424 | self.mode = AppMode::Credits; 425 | self.fetch_credits_info(); 426 | } else { 427 | self.set_error_message("API key not configured"); 428 | } 429 | } 430 | KeyCode::Up => { 431 | if self.selected_file_idx > 0 { 432 | self.selected_file_idx -= 1; 433 | } 434 | } 435 | KeyCode::Down => { 436 | if self.selected_file_idx < self.file_list.len().saturating_sub(1) { 437 | self.selected_file_idx += 1; 438 | } 439 | } 440 | KeyCode::Enter => { 441 | if self.selected_file_idx < self.file_list.len() { 442 | let path = self.file_list[self.selected_file_idx].clone(); 443 | if path.is_dir() { 444 | // Change directory 445 | self.current_dir = path; 446 | self.refresh_file_list()?; 447 | } else { 448 | // Open file - using cloned path to avoid borrow checker issues 449 | self.open_file(&path)?; 450 | self.mode = AppMode::Editor; 451 | } 452 | } 453 | } 454 | KeyCode::Backspace => { 455 | // Go up a directory 456 | if let Some(parent) = self.current_dir.parent() { 457 | self.current_dir = parent.to_path_buf(); 458 | self.refresh_file_list()?; 459 | // Log for debugging 460 | eprintln!("Navigated to parent directory: {:?}", self.current_dir); 461 | } else { 462 | // Log for debugging 463 | eprintln!("No parent directory available"); 464 | } 465 | } 466 | // Add an alternative way to go up a directory 467 | KeyCode::Char('b') => { 468 | // Go up a directory (alternative to Backspace) 469 | if let Some(parent) = self.current_dir.parent() { 470 | self.current_dir = parent.to_path_buf(); 471 | self.refresh_file_list()?; 472 | } 473 | } 474 | _ => {} 475 | } 476 | 477 | Ok(()) 478 | } 479 | 480 | fn handle_editor_key(&mut self, key: crossterm::event::KeyEvent) -> Result<()> { 481 | use crossterm::event::KeyCode; 482 | 483 | match key.code { 484 | KeyCode::Char('q') => { 485 | self.mode = AppMode::FileBrowser; 486 | } 487 | KeyCode::Char('p') => { 488 | self.mode = AppMode::PromptInput; 489 | if !self.selected_files.is_empty() { 490 | self.message = Some(format!("Enter your prompt (using {} selected files as context)...", 491 | self.selected_files.len())); 492 | } else { 493 | self.message = Some("Enter your prompt...".to_string()); 494 | } 495 | } 496 | KeyCode::Char('c') => { 497 | // Toggle credits display 498 | self.show_credits = !self.show_credits; 499 | self.message = Some(format!("Credits display {}", if self.show_credits { "enabled" } else { "disabled" })); 500 | 501 | // If enabling credits and we don't have credits info yet, fetch it 502 | if self.show_credits && self.credits_info.is_none() { 503 | self.fetch_credits(); 504 | } 505 | } 506 | KeyCode::Char('s') => { 507 | // Fix borrowing issue by getting a clone of the path before calling toggle_file_selection 508 | if let Some(file_path) = self.current_file.clone() { 509 | self.toggle_file_selection(file_path); 510 | } 511 | } 512 | KeyCode::Char('C') if key.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) => { 513 | self.selected_files.clear(); 514 | self.selected_files_content.clear(); 515 | self.set_success_message("Cleared all selected files"); 516 | } 517 | KeyCode::Char('l') => { 518 | // List all selected files 519 | if self.selected_files.is_empty() { 520 | self.set_info_message("No files selected. Use 's' to select the current file."); 521 | } else { 522 | let files_list = self.selected_files.iter() 523 | .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string()) 524 | .collect::>() 525 | .join(", "); 526 | self.set_info_message(&format!("Selected files ({}): {}", self.selected_files.len(), files_list)); 527 | } 528 | } 529 | KeyCode::Char('r') => { 530 | self.fetch_credits(); 531 | self.message = Some("Fetching credits information...".to_string()); 532 | } 533 | KeyCode::Up => { 534 | if self.scroll_position > 0 { 535 | self.scroll_position -= 1; 536 | } 537 | } 538 | KeyCode::Down => { 539 | // This is simplified, would need to check against actual content length 540 | self.scroll_position += 1; 541 | } 542 | _ => { 543 | // No action for other keys in editor mode 544 | } 545 | } 546 | 547 | Ok(()) 548 | } 549 | 550 | fn handle_prompt_key(&mut self, key: crossterm::event::KeyEvent) -> Result<()> { 551 | use crossterm::event::KeyCode; 552 | 553 | // If we're already processing, ignore most key presses 554 | if self.processing_state == ProcessingState::Processing { 555 | // Only allow Escape to cancel the processing 556 | if key.code == KeyCode::Esc { 557 | self.processing_state = ProcessingState::Idle; 558 | self.set_info_message("Request cancelled"); 559 | } 560 | return Ok(()); 561 | } 562 | 563 | match key.code { 564 | KeyCode::Esc | KeyCode::Char('q') => { 565 | // Determine which mode to return to based on whether we're in multi-file mode 566 | if !self.selected_files.is_empty() { 567 | self.mode = AppMode::FileSelection; 568 | } else { 569 | self.mode = AppMode::Editor; 570 | } 571 | // Clear the prompt when exiting 572 | self.current_prompt = String::new(); 573 | // Update token count 574 | self.update_token_count(); 575 | } 576 | KeyCode::Enter => { 577 | // Don't process empty prompts 578 | if self.current_prompt.trim().is_empty() { 579 | self.set_error_message("Prompt cannot be empty"); 580 | return Ok(()); 581 | } 582 | 583 | // Set processing state and start API request 584 | self.processing_state = ProcessingState::Processing; 585 | 586 | // Spawn a tokio task to process the prompt asynchronously 587 | if let Some(api_key) = &self.api_key { 588 | // Check if we're in multi-file mode or single file mode 589 | if !self.selected_files.is_empty() { 590 | // Multi-file mode 591 | let prompt = self.current_prompt.clone(); 592 | let api_key = api_key.clone(); 593 | let model = self.selected_model.clone(); 594 | 595 | // Create a context string with all selected files 596 | let mut context = String::new(); 597 | for (file_path, content) in &self.selected_files_content { 598 | let file_name = file_path.file_name().unwrap_or_default().to_string_lossy(); 599 | let language = crate::api::get_file_language(file_path); 600 | context.push_str(&format!("\n\n--- File: {} ({})\n{}\n", file_name, language, content)); 601 | } 602 | 603 | // Use the first file as the primary file for diff generation 604 | if let Some((primary_file, primary_content)) = self.selected_files_content.first() { 605 | let primary_file = primary_file.clone(); 606 | let primary_content = primary_content.clone(); 607 | let language = crate::api::get_file_language(&primary_file); 608 | 609 | // Get the current Tokio runtime handle 610 | match tokio::runtime::Handle::try_current() { 611 | Ok(handle) => { 612 | // Spawn a tokio task to process the prompt 613 | handle.spawn(async move { 614 | // Process the prompt 615 | match crate::api::send_code_modification_request_with_context( 616 | &api_key, 617 | &prompt, 618 | &primary_content, 619 | &context, 620 | None, // No AST for now 621 | &model, 622 | language, 623 | ).await { 624 | Ok(response) => { 625 | // Extract code from the response 626 | let extracted_code = crate::api::extract_code_from_response(&response); 627 | 628 | // If no code was extracted, use the full response 629 | let modified_code = if extracted_code.is_empty() { 630 | response 631 | } else { 632 | extracted_code 633 | }; 634 | 635 | // Generate diff 636 | let diff = crate::files::smart_merge(&primary_content, &modified_code); 637 | 638 | // Send the result back to the main thread 639 | let _ = crate::tui::PROMPT_RESULT_TX.lock().unwrap().send( 640 | crate::tui::PromptResult::Success(diff) 641 | ); 642 | } 643 | Err(e) => { 644 | // Send an error message 645 | let _ = crate::tui::PROMPT_RESULT_TX.lock().unwrap().send( 646 | crate::tui::PromptResult::Error(e.to_string()) 647 | ); 648 | } 649 | } 650 | }); 651 | } 652 | Err(_) => { 653 | self.processing_state = ProcessingState::Error("No Tokio runtime available".to_string()); 654 | } 655 | } 656 | } else { 657 | self.processing_state = ProcessingState::Error("No files selected".to_string()); 658 | } 659 | } else if let Some(file_path) = &self.current_file { 660 | // Single file mode 661 | let prompt = self.current_prompt.clone(); 662 | let api_key = api_key.clone(); 663 | let model = self.selected_model.clone(); 664 | let code = self.current_file_content.clone(); 665 | let file_path = file_path.clone(); 666 | 667 | // Get the language from the file extension 668 | let language = crate::api::get_file_language(&file_path); 669 | 670 | // Get the current Tokio runtime handle 671 | match tokio::runtime::Handle::try_current() { 672 | Ok(handle) => { 673 | // Spawn a tokio task to process the prompt 674 | handle.spawn(async move { 675 | // Process the prompt 676 | match crate::api::send_code_modification_request( 677 | &api_key, 678 | &prompt, 679 | &code, 680 | None, // No AST for now 681 | &model, 682 | language, 683 | ).await { 684 | Ok(response) => { 685 | // Extract code from the response 686 | let extracted_code = crate::api::extract_code_from_response(&response); 687 | 688 | // If no code was extracted, use the full response 689 | let modified_code = if extracted_code.is_empty() { 690 | response 691 | } else { 692 | extracted_code 693 | }; 694 | 695 | // Generate diff 696 | let diff = crate::files::smart_merge(&code, &modified_code); 697 | 698 | // Send the result back to the main thread 699 | let _ = crate::tui::PROMPT_RESULT_TX.lock().unwrap().send( 700 | crate::tui::PromptResult::Success(diff) 701 | ); 702 | } 703 | Err(e) => { 704 | // Send an error message 705 | let _ = crate::tui::PROMPT_RESULT_TX.lock().unwrap().send( 706 | crate::tui::PromptResult::Error(e.to_string()) 707 | ); 708 | } 709 | } 710 | }); 711 | } 712 | Err(_) => { 713 | self.processing_state = ProcessingState::Error("No Tokio runtime available".to_string()); 714 | } 715 | } 716 | } else { 717 | self.processing_state = ProcessingState::Error("No file selected".to_string()); 718 | } 719 | } else { 720 | self.processing_state = ProcessingState::Error("API key not configured".to_string()); 721 | } 722 | } 723 | KeyCode::Char(c) => { 724 | self.current_prompt.push(c); 725 | // Update token count immediately 726 | self.update_token_count(); 727 | } 728 | KeyCode::Backspace => { 729 | self.current_prompt.pop(); 730 | // Update token count immediately 731 | self.update_token_count(); 732 | } 733 | _ => {} 734 | } 735 | 736 | Ok(()) 737 | } 738 | 739 | /// Handle the result of a prompt operation 740 | pub fn handle_prompt_result(&mut self, result: crate::tui::PromptResult) { 741 | match result { 742 | crate::tui::PromptResult::Success(diff) => { 743 | // Single file response 744 | self.current_diff = Some(diff.clone()); 745 | 746 | // Extract the file name from the current file 747 | if let Some(ref file_path) = self.current_file { 748 | if let Some(file_name) = file_path.file_name() { 749 | self.current_diff_file = Some(file_name.to_string_lossy().to_string()); 750 | } 751 | } 752 | 753 | // Store explanation text separately for easy access 754 | if let Some(ref explanation) = diff.explanation_text { 755 | self.explanation_text = Some(explanation.clone()); 756 | } 757 | 758 | // Switch to results mode 759 | self.mode = AppMode::Results; 760 | self.processing_state = ProcessingState::Done; 761 | 762 | // Reset scroll position 763 | self.scroll_position = 0; 764 | 765 | // Log the result 766 | info!("Received single file diff with {} changes", diff.changes.len()); 767 | if diff.explanation_text.is_some() { 768 | info!("Explanation text is available"); 769 | } 770 | }, 771 | crate::tui::PromptResult::MultiFileSuccess(diffs) => { 772 | // Multi-file response 773 | if !diffs.is_empty() { 774 | // Store all diffs 775 | self.multi_file_diffs = Some(diffs.clone()); 776 | 777 | // Set the current diff to the first file 778 | let (file_name, diff) = &diffs[0]; 779 | self.current_diff_file = Some(file_name.clone()); 780 | self.current_diff = Some(diff.clone()); 781 | 782 | // Store explanation text separately for easy access 783 | if let Some(ref explanation) = diff.explanation_text { 784 | self.explanation_text = Some(explanation.clone()); 785 | } 786 | 787 | // Switch to results mode 788 | self.mode = AppMode::Results; 789 | self.processing_state = ProcessingState::Done; 790 | 791 | // Reset scroll position 792 | self.scroll_position = 0; 793 | 794 | // Log the result 795 | info!("Received multi-file diff with {} files", diffs.len()); 796 | if diff.explanation_text.is_some() { 797 | info!("Explanation text is available"); 798 | } 799 | } else { 800 | // No diffs received 801 | self.set_error_message("No changes were generated"); 802 | self.processing_state = ProcessingState::Error("No changes were generated".to_string()); 803 | } 804 | }, 805 | crate::tui::PromptResult::Error(err) => { 806 | // Error response 807 | self.set_error_message(&format!("Error: {}", err)); 808 | self.processing_state = ProcessingState::Error(err); 809 | }, 810 | crate::tui::PromptResult::CreditsInfo(credits_info) => { 811 | // Credits info response 812 | self.credits_info = Some(credits_info); 813 | self.show_credits = true; 814 | self.mode = AppMode::Credits; 815 | self.processing_state = ProcessingState::Done; 816 | }, 817 | } 818 | 819 | // Log the state of the diff viewer for debugging 820 | self.log_diff_viewer_state(); 821 | } 822 | 823 | /// Update spinner animation frame and check message timeout 824 | pub fn update(&mut self) { 825 | // Update spinner animation 826 | if self.processing_state == ProcessingState::Processing { 827 | self.spinner_frame = (self.spinner_frame + 1) % 8; 828 | } 829 | 830 | // Check if message should be cleared 831 | if let Some(time) = self.message_time { 832 | if time.elapsed() >= self.message_timeout && self.message_type == MessageType::Success { 833 | self.message = None; 834 | self.message_time = None; 835 | } 836 | } 837 | } 838 | 839 | fn handle_results_key(&mut self, key: crossterm::event::KeyEvent) -> Result<()> { 840 | match key.code { 841 | crossterm::event::KeyCode::Char('q') => { 842 | self.mode = AppMode::FileBrowser; 843 | Ok(()) 844 | }, 845 | crossterm::event::KeyCode::Char('y') => { 846 | // Apply changes 847 | info!("User pressed 'y' to apply changes"); 848 | 849 | if let Some(ref multi_diffs) = self.multi_file_diffs { 850 | // We have multi-file diffs 851 | info!("Found multi-file diffs with {} files", multi_diffs.len()); 852 | 853 | for (filename, _) in multi_diffs { 854 | info!("Multi-file diff includes: {}", filename); 855 | } 856 | 857 | match self.apply_multi_file_changes() { 858 | Ok(_) => { 859 | info!("Successfully applied multi-file changes"); 860 | self.set_success_message("Successfully applied all changes"); 861 | // Return to file browser after applying changes 862 | self.mode = AppMode::FileBrowser; 863 | } 864 | Err(e) => { 865 | error!("Failed to apply multi-file changes: {}", e); 866 | self.set_error_message(&format!("Error applying changes: {}", e)); 867 | } 868 | } 869 | } else if let Some(ref diff) = self.current_diff { 870 | // We have a single file diff 871 | if let Some(ref file_path) = self.current_file { 872 | info!("Applying changes to single file: {:?}", file_path); 873 | let modified_content = diff.modified.clone(); 874 | match std::fs::write(file_path, &modified_content) { 875 | Ok(_) => { 876 | info!("Successfully applied changes to {:?}", file_path); 877 | self.set_success_message(&format!("Applied changes to {}", 878 | file_path.file_name().unwrap_or_default().to_string_lossy())); 879 | 880 | // Update current file content 881 | self.current_file_content = modified_content; 882 | 883 | // Return to file browser after applying changes 884 | self.mode = AppMode::FileBrowser; 885 | }, 886 | Err(e) => { 887 | error!("Failed to apply changes to {:?}: {}", file_path, e); 888 | self.set_error_message(&format!("Failed to apply changes: {}", e)); 889 | } 890 | } 891 | } else { 892 | error!("No current file selected to apply diff to"); 893 | self.set_error_message("No file selected to apply changes to"); 894 | } 895 | } else { 896 | info!("No diffs to apply"); 897 | self.set_info_message("No changes to apply"); 898 | } 899 | Ok(()) 900 | }, 901 | crossterm::event::KeyCode::Char('n') => { 902 | // Reject changes 903 | self.mode = AppMode::FileBrowser; 904 | self.set_info_message("Changes rejected"); 905 | Ok(()) 906 | }, 907 | crossterm::event::KeyCode::Char('e') => { 908 | // Toggle explanation view 909 | self.toggle_explanation_view(); 910 | Ok(()) 911 | }, 912 | crossterm::event::KeyCode::Tab => { 913 | // Switch active panel 914 | self.active_panel = match self.active_panel { 915 | ActivePanel::Left => ActivePanel::Right, 916 | ActivePanel::Right => ActivePanel::Left, 917 | }; 918 | Ok(()) 919 | }, 920 | crossterm::event::KeyCode::Right => { 921 | // Show next file diff 922 | if self.next_diff_file() { 923 | self.set_info_message("Showing next file diff"); 924 | } 925 | Ok(()) 926 | }, 927 | crossterm::event::KeyCode::Left => { 928 | // Show previous file diff 929 | if self.prev_diff_file() { 930 | self.set_info_message("Showing previous file diff"); 931 | } 932 | Ok(()) 933 | }, 934 | crossterm::event::KeyCode::Up => { 935 | // Scroll up based on active panel 936 | match self.active_panel { 937 | ActivePanel::Left => { 938 | if self.scroll_position > 0 { 939 | self.scroll_position -= 1; 940 | } 941 | }, 942 | ActivePanel::Right => { 943 | if self.scroll_position > 0 { 944 | self.scroll_position -= 1; 945 | } 946 | }, 947 | } 948 | Ok(()) 949 | }, 950 | crossterm::event::KeyCode::Down => { 951 | // Scroll down based on active panel 952 | match self.active_panel { 953 | ActivePanel::Left => { 954 | // Limit scrolling based on content length 955 | if let Some(ref diff) = self.current_diff { 956 | let line_count = diff.original.lines().count(); 957 | if self.scroll_position < line_count.saturating_sub(10) { 958 | self.scroll_position += 1; 959 | } 960 | } 961 | }, 962 | ActivePanel::Right => { 963 | // Limit scrolling based on content length 964 | if let Some(ref diff) = self.current_diff { 965 | let line_count = diff.modified.lines().count(); 966 | if self.scroll_position < line_count.saturating_sub(10) { 967 | self.scroll_position += 1; 968 | } 969 | } 970 | }, 971 | } 972 | Ok(()) 973 | }, 974 | crossterm::event::KeyCode::Home => { 975 | // Scroll to top 976 | self.scroll_position = 0; 977 | Ok(()) 978 | }, 979 | crossterm::event::KeyCode::End => { 980 | // Scroll to bottom 981 | if let Some(ref diff) = self.current_diff { 982 | let line_count = match self.active_panel { 983 | ActivePanel::Left => diff.original.lines().count(), 984 | ActivePanel::Right => diff.modified.lines().count(), 985 | }; 986 | self.scroll_position = line_count.saturating_sub(10); 987 | } 988 | Ok(()) 989 | }, 990 | crossterm::event::KeyCode::PageUp => { 991 | // Scroll up by 10 lines 992 | if self.scroll_position >= 10 { 993 | self.scroll_position -= 10; 994 | } else { 995 | self.scroll_position = 0; 996 | } 997 | Ok(()) 998 | }, 999 | crossterm::event::KeyCode::PageDown => { 1000 | // Scroll down by 10 lines 1001 | if let Some(ref diff) = self.current_diff { 1002 | let line_count = match self.active_panel { 1003 | ActivePanel::Left => diff.original.lines().count(), 1004 | ActivePanel::Right => diff.modified.lines().count(), 1005 | }; 1006 | if self.scroll_position + 10 < line_count.saturating_sub(10) { 1007 | self.scroll_position += 10; 1008 | } else { 1009 | self.scroll_position = line_count.saturating_sub(10); 1010 | } 1011 | } 1012 | Ok(()) 1013 | }, 1014 | _ => Ok(()), 1015 | } 1016 | } 1017 | 1018 | fn handle_help_key(&mut self, key: crossterm::event::KeyEvent) -> Result<()> { 1019 | use crossterm::event::KeyCode; 1020 | 1021 | match key.code { 1022 | KeyCode::Esc | KeyCode::Char('q') => { 1023 | // Return to previous mode 1024 | self.mode = AppMode::FileBrowser; 1025 | } 1026 | _ => {} 1027 | } 1028 | 1029 | Ok(()) 1030 | } 1031 | 1032 | /// Refresh the file list based on current directory 1033 | pub fn refresh_file_list(&mut self) -> Result<()> { 1034 | self.file_list.clear(); 1035 | 1036 | // Add parent directory if not at root 1037 | if self.current_dir.parent().is_some() { 1038 | self.file_list.push(self.current_dir.join("..")); 1039 | } 1040 | 1041 | // Add directories first 1042 | for entry in std::fs::read_dir(&self.current_dir)? { 1043 | let entry = entry?; 1044 | let path = entry.path(); 1045 | if path.is_dir() { 1046 | self.file_list.push(path); 1047 | } 1048 | } 1049 | 1050 | // Then add files 1051 | for entry in std::fs::read_dir(&self.current_dir)? { 1052 | let entry = entry?; 1053 | let path = entry.path(); 1054 | if path.is_file() { 1055 | self.file_list.push(path); 1056 | } 1057 | } 1058 | 1059 | self.selected_file_idx = 0; 1060 | 1061 | Ok(()) 1062 | } 1063 | 1064 | /// Open a file and load its content 1065 | pub fn open_file(&mut self, path: &Path) -> Result<()> { 1066 | let content = std::fs::read_to_string(path)?; 1067 | self.current_file = Some(path.to_path_buf()); 1068 | self.current_file_content = content; 1069 | self.scroll_position = 0; 1070 | Ok(()) 1071 | } 1072 | 1073 | /// Set an informational message 1074 | pub fn set_info_message(&mut self, msg: &str) { 1075 | self.message = Some(msg.to_string()); 1076 | self.message_type = MessageType::Info; 1077 | self.message_time = Some(Instant::now()); 1078 | } 1079 | 1080 | /// Set an error message 1081 | pub fn set_error_message(&mut self, msg: &str) { 1082 | self.message = Some(msg.to_string()); 1083 | self.message_type = MessageType::Error; 1084 | self.message_time = Some(Instant::now()); 1085 | } 1086 | 1087 | /// Set a success message 1088 | pub fn set_success_message(&mut self, msg: &str) { 1089 | self.message = Some(msg.to_string()); 1090 | self.message_type = MessageType::Success; 1091 | self.message_time = Some(Instant::now()); 1092 | } 1093 | 1094 | /// Handle key events for file selection mode 1095 | fn handle_file_selection_key(&mut self, key: crossterm::event::KeyEvent) -> Result<()> { 1096 | use crossterm::event::KeyCode; 1097 | 1098 | match key.code { 1099 | KeyCode::Char('q') => { 1100 | // Quit the file selection and return to file browser 1101 | self.mode = AppMode::FileBrowser; 1102 | // Clear selected files 1103 | self.selected_files.clear(); 1104 | self.selected_files_content.clear(); 1105 | } 1106 | KeyCode::Char('h') => { 1107 | // Show help screen 1108 | self.mode = AppMode::Help; 1109 | } 1110 | KeyCode::Up => { 1111 | if self.selected_file_idx > 0 { 1112 | self.selected_file_idx -= 1; 1113 | } 1114 | } 1115 | KeyCode::Down => { 1116 | if self.selected_file_idx < self.file_list.len().saturating_sub(1) { 1117 | self.selected_file_idx += 1; 1118 | } 1119 | } 1120 | KeyCode::Char(' ') => { 1121 | // Toggle selection of the current file 1122 | if self.selected_file_idx < self.file_list.len() { 1123 | let path = self.file_list[self.selected_file_idx].clone(); 1124 | if path.is_file() { 1125 | // Check if the file is already selected 1126 | if let Some(index) = self.selected_files.iter().position(|p| p == &path) { 1127 | // Remove from selected files 1128 | self.selected_files.remove(index); 1129 | // Also remove from content if it exists 1130 | if let Some(content_index) = self.selected_files_content.iter().position(|(p, _)| p == &path) { 1131 | self.selected_files_content.remove(content_index); 1132 | } 1133 | } else { 1134 | // Add to selected files 1135 | self.selected_files.push(path.clone()); 1136 | // Try to load the content 1137 | if let Ok(content) = std::fs::read_to_string(&path) { 1138 | self.selected_files_content.push((path, content)); 1139 | } 1140 | } 1141 | } 1142 | } 1143 | } 1144 | KeyCode::Enter => { 1145 | // If files are selected, proceed to prompt input 1146 | if !self.selected_files.is_empty() { 1147 | // Clear the prompt before entering prompt mode 1148 | self.current_prompt = String::new(); 1149 | self.mode = AppMode::PromptInput; 1150 | } else { 1151 | self.set_error_message("No files selected. Use spacebar to select files."); 1152 | } 1153 | } 1154 | KeyCode::Backspace => { 1155 | // Go up a directory 1156 | if let Some(parent) = self.current_dir.parent() { 1157 | self.current_dir = parent.to_path_buf(); 1158 | self.refresh_file_list()?; 1159 | } 1160 | } 1161 | _ => {} 1162 | } 1163 | 1164 | Ok(()) 1165 | } 1166 | 1167 | /// Handle key events for the credits screen 1168 | fn handle_credits_key(&mut self, key: crossterm::event::KeyEvent) -> Result<()> { 1169 | use crossterm::event::KeyCode; 1170 | 1171 | match key.code { 1172 | KeyCode::Esc | KeyCode::Char('q') => { 1173 | // Return to previous mode (usually configuration) 1174 | self.mode = AppMode::FileBrowser; 1175 | } 1176 | KeyCode::Char('r') => { 1177 | // Refresh credits information 1178 | self.fetch_credits_info(); 1179 | } 1180 | _ => { 1181 | // Ignore other keys 1182 | } 1183 | } 1184 | 1185 | Ok(()) 1186 | } 1187 | 1188 | /// Fetch credits information from OpenRouter 1189 | pub fn fetch_credits_info(&mut self) { 1190 | if let Some(api_key) = &self.api_key { 1191 | let api_key = api_key.clone(); 1192 | 1193 | // Set processing state 1194 | self.processing_state = ProcessingState::Processing; 1195 | self.message = Some("Fetching credits information...".to_string()); 1196 | 1197 | // Get the current Tokio runtime handle 1198 | match tokio::runtime::Handle::try_current() { 1199 | Ok(handle) => { 1200 | // Spawn a tokio task to fetch credits info 1201 | let tx = crate::tui::PROMPT_RESULT_TX.lock().unwrap().clone(); 1202 | 1203 | handle.spawn(async move { 1204 | match crate::api::fetch_openrouter_credits(&api_key).await { 1205 | Ok(credits_info) => { 1206 | // Send the result back to the main thread 1207 | let _ = tx.send( 1208 | crate::tui::PromptResult::CreditsInfo(credits_info) 1209 | ); 1210 | } 1211 | Err(e) => { 1212 | // Send an error message 1213 | let _ = tx.send( 1214 | crate::tui::PromptResult::Error(e.to_string()) 1215 | ); 1216 | } 1217 | } 1218 | }); 1219 | } 1220 | Err(_) => { 1221 | self.processing_state = ProcessingState::Error("No Tokio runtime available".to_string()); 1222 | } 1223 | } 1224 | } else { 1225 | self.processing_state = ProcessingState::Error("API key not configured".to_string()); 1226 | self.message = Some("API key not configured".to_string()); 1227 | self.message_type = MessageType::Error; 1228 | } 1229 | } 1230 | 1231 | /// Toggle file selection for multi-file context 1232 | pub fn toggle_file_selection(&mut self, file_path: PathBuf) { 1233 | // Check if the file is already selected 1234 | if let Some(idx) = self.selected_files.iter().position(|p| p == &file_path) { 1235 | // Remove the file from selected_files 1236 | self.selected_files.remove(idx); 1237 | 1238 | // Remove the file content 1239 | self.selected_files_content.retain(|(path, _)| path != &file_path); 1240 | 1241 | // Set info message 1242 | if let Some(file_name) = file_path.file_name() { 1243 | self.set_info_message(&format!("Removed '{}' from selected files", file_name.to_string_lossy())); 1244 | } 1245 | } else { 1246 | // Add the file to selected_files 1247 | self.selected_files.push(file_path.clone()); 1248 | 1249 | // Add the file content 1250 | match std::fs::read_to_string(&file_path) { 1251 | Ok(content) => { 1252 | self.selected_files_content.push((file_path.clone(), content)); 1253 | 1254 | // Set success message 1255 | if let Some(file_name) = file_path.file_name() { 1256 | self.set_success_message(&format!("Added '{}' to selected files", file_name.to_string_lossy())); 1257 | } 1258 | } 1259 | Err(e) => { 1260 | // Set error message 1261 | self.set_error_message(&format!("Failed to read file: {}", e)); 1262 | 1263 | // Remove the file from selected_files 1264 | if let Some(idx) = self.selected_files.iter().position(|p| p == &file_path) { 1265 | self.selected_files.remove(idx); 1266 | } 1267 | } 1268 | } 1269 | } 1270 | 1271 | // Update token count after modifying the selection 1272 | self.update_token_count(); 1273 | debug!("Updated token count after toggle_file_selection: {}", self.token_count); 1274 | } 1275 | 1276 | /// Fetch credits information 1277 | pub fn fetch_credits(&mut self) { 1278 | if let Some(api_key) = &self.api_key { 1279 | let api_key = api_key.clone(); 1280 | self.processing_state = ProcessingState::Processing; 1281 | self.set_info_message("Fetching credits information..."); 1282 | 1283 | let tx = crate::tui::PROMPT_RESULT_TX.lock().unwrap().clone(); 1284 | 1285 | // Get the current Tokio runtime handle 1286 | match tokio::runtime::Handle::try_current() { 1287 | Ok(handle) => { 1288 | handle.spawn(async move { 1289 | match crate::api::fetch_openrouter_credits(&api_key).await { 1290 | Ok(credits_info) => { 1291 | let _ = tx.send(crate::tui::PromptResult::CreditsInfo(credits_info)); 1292 | } 1293 | Err(e) => { 1294 | let _ = tx.send(crate::tui::PromptResult::Error(format!("Failed to fetch credits: {}", e))); 1295 | } 1296 | } 1297 | }); 1298 | } 1299 | Err(_) => { 1300 | self.processing_state = ProcessingState::Error("No Tokio runtime available".to_string()); 1301 | self.message = Some("No Tokio runtime available".to_string()); 1302 | self.message_type = MessageType::Error; 1303 | } 1304 | } 1305 | } else { 1306 | self.set_error_message("API key not set. Please configure your API key first."); 1307 | } 1308 | } 1309 | 1310 | /// Get combined content from all selected files 1311 | pub fn get_selected_files_content(&self) -> String { 1312 | let mut content = String::new(); 1313 | 1314 | for file_path in &self.selected_files { 1315 | if let Ok(file_content) = std::fs::read_to_string(file_path) { 1316 | content.push_str(&format!("File: {}\n{}\n\n", file_path.display(), file_content)); 1317 | } 1318 | } 1319 | 1320 | content 1321 | } 1322 | 1323 | /// Handle prompt key with multi-file support 1324 | pub fn handle_prompt_key_with_context(&mut self, key: crossterm::event::KeyEvent) -> Result<()> { 1325 | use crossterm::event::KeyCode; 1326 | 1327 | // If we're already processing, ignore most key presses 1328 | if self.processing_state == ProcessingState::Processing { 1329 | // Only allow Escape to cancel the processing 1330 | if key.code == KeyCode::Esc { 1331 | self.processing_state = ProcessingState::Idle; 1332 | self.set_info_message("Request cancelled"); 1333 | } 1334 | return Ok(()); 1335 | } 1336 | 1337 | match key.code { 1338 | KeyCode::Esc => { 1339 | // Return to file selection mode 1340 | self.mode = AppMode::FileSelection; 1341 | // Clear the prompt 1342 | self.current_prompt = String::new(); 1343 | // Update token count 1344 | self.update_token_count(); 1345 | } 1346 | KeyCode::Enter => { 1347 | // Submit prompt with context 1348 | if !self.current_prompt.is_empty() { 1349 | // Set processing state 1350 | self.processing_state = ProcessingState::Processing; 1351 | 1352 | // Get the API key 1353 | let api_key = match &self.api_key { 1354 | Some(key) => key.clone(), 1355 | None => { 1356 | self.set_error_message("API key not set"); 1357 | self.processing_state = ProcessingState::Error("API key not set".to_string()); 1358 | return Ok(()); 1359 | } 1360 | }; 1361 | 1362 | // Get the selected model 1363 | let model = self.selected_model.clone(); 1364 | 1365 | // Get the current file content 1366 | let current_file_content = self.current_file_content.clone(); 1367 | 1368 | // Get the current file path 1369 | let current_file = self.current_file.clone(); 1370 | 1371 | // Get the prompt 1372 | let prompt = self.current_prompt.clone(); 1373 | 1374 | // Get the selected files content 1375 | let selected_files_content = self.selected_files_content.clone(); 1376 | 1377 | // Get the context from selected files 1378 | let context = self.get_selected_files_content(); 1379 | 1380 | // Spawn a new task to handle the API request 1381 | let prompt_tx = crate::tui::PROMPT_RESULT_TX.lock().unwrap().clone(); 1382 | 1383 | tokio::spawn(async move { 1384 | // Determine the language of the current file 1385 | let language = if let Some(ref path) = current_file { 1386 | crate::api::get_file_language(path) 1387 | } else { 1388 | "plaintext" 1389 | }; 1390 | 1391 | // Send the request to the API 1392 | match crate::api::send_code_modification_request_with_context( 1393 | &api_key, 1394 | &prompt, 1395 | ¤t_file_content, 1396 | &context, 1397 | None, // No AST for now 1398 | &model, 1399 | language, 1400 | ).await { 1401 | Ok(response) => { 1402 | // Check if the response contains multiple file edits 1403 | if (response.contains("File:") || response.contains("file:")) && response.contains("```") { 1404 | // Process multi-file response 1405 | match crate::api::process_multi_file_response( 1406 | response.clone(), 1407 | &selected_files_content, 1408 | ).await { 1409 | Ok(file_edits) => { 1410 | if !file_edits.is_empty() { 1411 | // Send the multi-file diffs back to the main thread 1412 | let _ = prompt_tx.send(crate::tui::PromptResult::MultiFileSuccess(file_edits)); 1413 | return; 1414 | } 1415 | } 1416 | Err(e) => { 1417 | // If multi-file processing fails, fall back to single file 1418 | log::error!("Failed to process multi-file response: {}", e); 1419 | } 1420 | } 1421 | } 1422 | 1423 | // Process single file response 1424 | if let Some(ref path) = current_file { 1425 | match crate::api::process_response(response, ¤t_file_content, path).await { 1426 | Ok(diff) => { 1427 | let _ = prompt_tx.send(crate::tui::PromptResult::Success(diff)); 1428 | } 1429 | Err(e) => { 1430 | let _ = prompt_tx.send(crate::tui::PromptResult::Error(format!("Failed to process response: {}", e))); 1431 | } 1432 | } 1433 | } else { 1434 | let _ = prompt_tx.send(crate::tui::PromptResult::Error("No file selected".to_string())); 1435 | } 1436 | } 1437 | Err(e) => { 1438 | let _ = prompt_tx.send(crate::tui::PromptResult::Error(format!("API request failed: {}", e))); 1439 | } 1440 | }; 1441 | }); 1442 | 1443 | // Clear the prompt 1444 | self.current_prompt.clear(); 1445 | } 1446 | } 1447 | KeyCode::Char(c) => { 1448 | self.current_prompt.push(c); 1449 | // Update token count immediately 1450 | self.update_token_count(); 1451 | } 1452 | KeyCode::Backspace => { 1453 | self.current_prompt.pop(); 1454 | // Update token count immediately 1455 | self.update_token_count(); 1456 | } 1457 | _ => {} 1458 | } 1459 | 1460 | Ok(()) 1461 | } 1462 | 1463 | /// Apply changes from a multi-file response 1464 | pub fn apply_multi_file_changes(&mut self) -> Result<(), anyhow::Error> { 1465 | // Check if we have multi-file diffs 1466 | let diffs_info = if let Some(ref diffs) = self.multi_file_diffs { 1467 | if diffs.is_empty() { 1468 | self.set_info_message("No changes to apply"); 1469 | return Ok(()); 1470 | } 1471 | 1472 | // Log what we're trying to apply 1473 | info!("Attempting to apply changes to {} files", diffs.len()); 1474 | for (file_name, _) in diffs { 1475 | info!(" - {}", file_name); 1476 | } 1477 | 1478 | // Clone the necessary data to avoid borrow checker issues 1479 | let diffs_count = diffs.len(); 1480 | let diffs_clone: Vec<(String, FileDiff)> = diffs.clone(); 1481 | Some((diffs_count, diffs_clone)) 1482 | } else { 1483 | self.set_info_message("No multi-file changes to apply"); 1484 | return Ok(()); 1485 | }; 1486 | 1487 | // Unwrap the tuple since we know it's Some at this point 1488 | let (diffs_count, diffs) = diffs_info.unwrap(); 1489 | let mut success_count = 0; 1490 | let mut error_messages = Vec::new(); 1491 | 1492 | // For each file, try to apply the changes 1493 | for (file_name, diff) in diffs { 1494 | // Find the file path 1495 | let file_path = match self.find_file_path(&file_name) { 1496 | Some(path) => { 1497 | info!("Found existing file path for {}: {:?}", file_name, path); 1498 | path 1499 | }, 1500 | None => { 1501 | // For new files, create in the current directory 1502 | let new_path = if file_name.contains('/') || file_name.contains('\\') { 1503 | // If the file_name has directory components, respect those 1504 | let path = Path::new(&file_name); 1505 | if let Some(parent) = path.parent() { 1506 | std::fs::create_dir_all(parent)?; 1507 | } 1508 | path.to_path_buf() 1509 | } else { 1510 | self.current_dir.join(&file_name) 1511 | }; 1512 | 1513 | info!("Creating new file: {:?}", new_path); 1514 | // Check if the parent directory exists, and create if not 1515 | if let Some(parent) = new_path.parent() { 1516 | if !parent.exists() { 1517 | info!("Creating parent directory: {:?}", parent); 1518 | std::fs::create_dir_all(parent)?; 1519 | } 1520 | } 1521 | new_path 1522 | } 1523 | }; 1524 | 1525 | // Apply the changes 1526 | let result = std::fs::write(&file_path, &diff.modified); 1527 | 1528 | match result { 1529 | Ok(_) => { 1530 | info!("Successfully applied changes to {}", file_name); 1531 | success_count += 1; 1532 | }, 1533 | Err(e) => { 1534 | let error = format!("Failed to create new file {}: {}", file_name, e); 1535 | error!("{}", error); 1536 | error_messages.push(error); 1537 | } 1538 | } 1539 | } 1540 | 1541 | // Refresh the file list to show the new files 1542 | self.refresh_file_list()?; 1543 | 1544 | // Set a message based on the results 1545 | if success_count == diffs_count { 1546 | self.set_success_message(&format!( 1547 | "Successfully modified {} file{}", 1548 | success_count, 1549 | if success_count > 1 { "s" } else { "" } 1550 | )); 1551 | } else if success_count > 0 { 1552 | self.set_info_message(&format!( 1553 | "Applied changes to {} file{}, but failed on {} file{}", 1554 | success_count, 1555 | if success_count > 1 { "s" } else { "" }, 1556 | diffs_count - success_count, 1557 | if diffs_count - success_count > 1 { "s" } else { "" } 1558 | )); 1559 | } else { 1560 | self.set_error_message(&format!("Failed to apply any changes: {}", error_messages.join(", "))); 1561 | } 1562 | 1563 | Ok(()) 1564 | } 1565 | 1566 | /// Find a file path by name 1567 | fn find_file_path(&self, file_name: &str) -> Option { 1568 | // Log for debugging 1569 | info!("Looking for file path matching: {}", file_name); 1570 | 1571 | // Normalize the file name (remove leading ./ if present) 1572 | let normalized_name = if file_name.starts_with("./") { 1573 | &file_name[2..] 1574 | } else { 1575 | file_name 1576 | }; 1577 | 1578 | // First try exact path match in the file list and selected files 1579 | for paths in &[&self.file_list, &self.selected_files] { 1580 | for path in *paths { 1581 | let path_str = path.to_string_lossy().to_string(); 1582 | if path_str.ends_with(normalized_name) { 1583 | info!("Found exact path match: {:?}", path); 1584 | return Some(path.clone()); 1585 | } 1586 | } 1587 | } 1588 | 1589 | // Try exact filename match (just the file name, not path) 1590 | for paths in &[&self.file_list, &self.selected_files] { 1591 | for path in *paths { 1592 | if let Some(name) = path.file_name() { 1593 | let name_str = name.to_string_lossy(); 1594 | if name_str == normalized_name { 1595 | info!("Found exact filename match: {:?}", path); 1596 | return Some(path.clone()); 1597 | } 1598 | } 1599 | } 1600 | } 1601 | 1602 | // Try extracting just the basename from the file_name if it contains path separators 1603 | let basename = if normalized_name.contains('/') || normalized_name.contains('\\') { 1604 | Path::new(normalized_name) 1605 | .file_name() 1606 | .map(|n| n.to_string_lossy().to_string()) 1607 | } else { 1608 | Some(normalized_name.to_string()) 1609 | }; 1610 | 1611 | if let Some(basename) = basename { 1612 | // Try basename match 1613 | for paths in &[&self.selected_files, &self.file_list] { 1614 | for path in *paths { 1615 | if let Some(name) = path.file_name() { 1616 | if name.to_string_lossy() == basename { 1617 | info!("Found basename match: {:?}", path); 1618 | return Some(path.clone()); 1619 | } 1620 | } 1621 | } 1622 | } 1623 | } 1624 | 1625 | // Try case insensitive match 1626 | let lowercase_name = normalized_name.to_lowercase(); 1627 | for paths in &[&self.selected_files, &self.file_list] { 1628 | for path in *paths { 1629 | if let Some(name) = path.file_name() { 1630 | if name.to_string_lossy().to_lowercase() == lowercase_name { 1631 | info!("Found case-insensitive match: {:?}", path); 1632 | return Some(path.clone()); 1633 | } 1634 | } 1635 | } 1636 | } 1637 | 1638 | // If the context file is set, try to use its directory for relative paths 1639 | if let Some(ref context_file) = self.current_file { 1640 | if let Some(parent) = context_file.parent() { 1641 | let potential_path = parent.join(normalized_name); 1642 | if potential_path.exists() { 1643 | info!("Found using context file's directory: {:?}", potential_path); 1644 | return Some(potential_path); 1645 | } 1646 | } 1647 | } 1648 | 1649 | // No match found 1650 | info!("No matching file found for: {}", file_name); 1651 | None 1652 | } 1653 | 1654 | /// Navigate to the next file diff 1655 | pub fn next_diff_file(&mut self) -> bool { 1656 | if let Some(ref multi_diffs) = self.multi_file_diffs { 1657 | if multi_diffs.is_empty() { 1658 | return false; 1659 | } 1660 | 1661 | // Find the index of the current file 1662 | let current_idx = if let Some(ref current_file) = self.current_diff_file { 1663 | multi_diffs.iter().position(|(file_name, _)| file_name == current_file) 1664 | } else { 1665 | None 1666 | }; 1667 | 1668 | // Calculate the next index 1669 | let next_idx = match current_idx { 1670 | Some(idx) if idx + 1 < multi_diffs.len() => idx + 1, 1671 | Some(_) => 0, // Wrap around to the first file 1672 | None => 0, // Start with the first file 1673 | }; 1674 | 1675 | // Set the current diff to the next file 1676 | let (file_name, diff) = &multi_diffs[next_idx]; 1677 | self.current_diff_file = Some(file_name.clone()); 1678 | self.current_diff = Some(diff.clone()); 1679 | 1680 | // Reset scroll position when changing files 1681 | self.scroll_position = 0; 1682 | 1683 | // Log the navigation 1684 | info!("Navigated to next file: {}", file_name); 1685 | 1686 | true 1687 | } else { 1688 | false 1689 | } 1690 | } 1691 | 1692 | /// Navigate to the previous file diff 1693 | pub fn prev_diff_file(&mut self) -> bool { 1694 | if let Some(ref multi_diffs) = self.multi_file_diffs { 1695 | if multi_diffs.is_empty() { 1696 | return false; 1697 | } 1698 | 1699 | // Find the index of the current file 1700 | let current_idx = if let Some(ref current_file) = self.current_diff_file { 1701 | multi_diffs.iter().position(|(file_name, _)| file_name == current_file) 1702 | } else { 1703 | None 1704 | }; 1705 | 1706 | // Calculate the previous index 1707 | let prev_idx = match current_idx { 1708 | Some(0) => multi_diffs.len() - 1, // Wrap around to the last file 1709 | Some(idx) => idx - 1, 1710 | None => 0, // Start with the first file 1711 | }; 1712 | 1713 | // Set the current diff to the previous file 1714 | let (file_name, diff) = &multi_diffs[prev_idx]; 1715 | self.current_diff_file = Some(file_name.clone()); 1716 | self.current_diff = Some(diff.clone()); 1717 | 1718 | // Reset scroll position when changing files 1719 | self.scroll_position = 0; 1720 | 1721 | // Log the navigation 1722 | info!("Navigated to previous file: {}", file_name); 1723 | 1724 | true 1725 | } else { 1726 | false 1727 | } 1728 | } 1729 | 1730 | /// Toggle between explanation and diff views 1731 | pub fn toggle_explanation_view(&mut self) { 1732 | // Toggle the flag 1733 | self.show_explanation = !self.show_explanation; 1734 | 1735 | // Reset scroll position when toggling views 1736 | self.scroll_position = 0; 1737 | 1738 | // Log the toggle action 1739 | if self.show_explanation { 1740 | debug!("Switched to explanation view"); 1741 | } else { 1742 | debug!("Switched to diff view"); 1743 | } 1744 | } 1745 | 1746 | /// Handle key events for custom model input 1747 | fn handle_custom_model_input_key(&mut self, key: crossterm::event::KeyEvent) -> Result<()> { 1748 | use crossterm::event::KeyCode; 1749 | 1750 | match key.code { 1751 | KeyCode::Enter => { 1752 | // Save the custom model if it's not empty 1753 | if !self.current_prompt.is_empty() { 1754 | self.custom_model = self.current_prompt.clone(); 1755 | // Set the selected model to the custom model value 1756 | self.selected_model = self.current_prompt.clone(); 1757 | let success_message = format!("Custom model '{}' configured and selected", self.current_prompt); 1758 | self.set_success_message(&success_message); 1759 | 1760 | // Save model to config if we have an API key 1761 | if let Some(_api_key) = &self.api_key { 1762 | if let Some(_config_dir) = dirs::config_dir() { 1763 | let _ = crate::api::save_preferred_model(&self.selected_model); 1764 | } 1765 | } 1766 | } 1767 | 1768 | // Return to configuration screen 1769 | self.mode = AppMode::Configuration; 1770 | } 1771 | KeyCode::Esc => { 1772 | // Cancel and return to configuration screen 1773 | self.mode = AppMode::Configuration; 1774 | } 1775 | KeyCode::Char(c) => { 1776 | // Add character to custom model 1777 | self.current_prompt.push(c); 1778 | } 1779 | KeyCode::Backspace => { 1780 | // Remove last character 1781 | self.current_prompt.pop(); 1782 | } 1783 | _ => {} 1784 | } 1785 | 1786 | Ok(()) 1787 | } 1788 | 1789 | /// Log the current state of the diff viewer for debugging 1790 | fn log_diff_viewer_state(&self) { 1791 | if let Some(ref diff_file) = self.current_diff_file { 1792 | info!("Current diff file: {}", diff_file); 1793 | } else { 1794 | info!("No current diff file"); 1795 | } 1796 | 1797 | if let Some(ref diff) = self.current_diff { 1798 | info!("Current diff: {} original lines, {} modified lines", 1799 | diff.original.lines().count(), 1800 | diff.modified.lines().count()); 1801 | 1802 | if let Some(ref explanation) = diff.explanation_text { 1803 | info!("Explanation text available: {} chars", explanation.len()); 1804 | } else { 1805 | info!("No explanation text in current diff"); 1806 | } 1807 | } else { 1808 | info!("No current diff"); 1809 | } 1810 | 1811 | if let Some(ref multi_diffs) = self.multi_file_diffs { 1812 | info!("Multi-file diffs: {} files", multi_diffs.len()); 1813 | for (file_name, _) in multi_diffs { 1814 | info!(" - {}", file_name); 1815 | } 1816 | } else { 1817 | info!("No multi-file diffs"); 1818 | } 1819 | 1820 | info!("Active panel: {:?}", self.active_panel); 1821 | info!("Scroll position: {}", self.scroll_position); 1822 | info!("Show explanation: {}", self.show_explanation); 1823 | } 1824 | 1825 | /// Count tokens in a string (approximation) 1826 | pub fn count_tokens(&self, text: &str) -> usize { 1827 | // A more accurate token estimation that considers code structure 1828 | // For code, we count tokens more conservatively 1829 | 1830 | // First, count the characters 1831 | let char_count = text.chars().count(); 1832 | 1833 | // Different languages tokenize differently, but a reasonable estimation 1834 | // is 4-6 characters per token for code 1835 | const CHARS_PER_TOKEN: f32 = 4.0; 1836 | 1837 | // Calculate tokens and add a 10% margin for safety 1838 | let token_estimate = (char_count as f32 / CHARS_PER_TOKEN).ceil() as usize; 1839 | let with_margin = (token_estimate as f32 * 1.1).ceil() as usize; 1840 | 1841 | // Log token estimates for debugging 1842 | debug!("Token estimate for text of {} chars: {} tokens (with margin: {})", 1843 | char_count, token_estimate, with_margin); 1844 | 1845 | with_margin 1846 | } 1847 | 1848 | /// Update token count based on selected files and prompt 1849 | pub fn update_token_count(&mut self) { 1850 | let mut total_tokens = 0; 1851 | 1852 | // Count tokens in selected files 1853 | for (_, content) in &self.selected_files_content { 1854 | let file_tokens = self.count_tokens(content); 1855 | debug!("File tokens: {}", file_tokens); 1856 | total_tokens += file_tokens; 1857 | } 1858 | 1859 | // Count tokens in the prompt 1860 | let prompt_tokens = self.count_tokens(&self.current_prompt); 1861 | debug!("Prompt tokens: {}", prompt_tokens); 1862 | total_tokens += prompt_tokens; 1863 | 1864 | // Add a fixed overhead for system prompts and formatting 1865 | const SYSTEM_PROMPT_OVERHEAD: usize = 1000; 1866 | total_tokens += SYSTEM_PROMPT_OVERHEAD; 1867 | 1868 | // Update the token count 1869 | self.token_count = total_tokens; 1870 | debug!("Total token count updated: {}", self.token_count); 1871 | } 1872 | 1873 | /// Update the context window size based on the selected model 1874 | pub fn update_context_window_size(&mut self) { 1875 | // Set context window size based on the selected model 1876 | if self.selected_model.contains("gemini") { 1877 | self.context_window_size = 1_000_000; // 1M tokens for Gemini 1878 | } else if self.selected_model.contains("claude") { 1879 | self.context_window_size = 200_000; // 200K tokens for Claude 1880 | } else if self.selected_model.contains("custom") { 1881 | // For custom models, use a conservative default 1882 | self.context_window_size = 100_000; 1883 | } else { 1884 | // Default to a conservative estimate for other models 1885 | self.context_window_size = 100_000; 1886 | } 1887 | 1888 | // Log the context window size 1889 | debug!("Context window size set to {} tokens for model {}", 1890 | self.context_window_size, self.selected_model); 1891 | } 1892 | } -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use std::path::PathBuf; 3 | 4 | /// Application configuration struct 5 | pub struct Config { 6 | pub api_key: Option, 7 | pub preferred_model: String, 8 | pub enable_ast: bool, 9 | } 10 | 11 | impl Default for Config { 12 | fn default() -> Self { 13 | Self { 14 | api_key: None, 15 | preferred_model: "anthropic/claude-3.7-sonnet:beta".to_string(), 16 | enable_ast: true, 17 | } 18 | } 19 | } 20 | 21 | impl Config { 22 | /// Load configuration from disk 23 | pub fn load() -> Result { 24 | let mut config = Self::default(); 25 | 26 | // Load API key 27 | if let Some(api_key) = load_api_key() { 28 | config.api_key = Some(api_key); 29 | } 30 | 31 | // Load preferred model 32 | if let Some(model) = load_preferred_model() { 33 | config.preferred_model = model; 34 | } 35 | 36 | // Load AST preference 37 | if let Some(enable_ast) = load_ast_preference() { 38 | config.enable_ast = enable_ast; 39 | } 40 | 41 | Ok(config) 42 | } 43 | 44 | /// Save configuration to disk 45 | pub fn save(&self) -> Result<()> { 46 | // Save API key if present 47 | if let Some(api_key) = &self.api_key { 48 | save_api_key(api_key)?; 49 | } 50 | 51 | // Save preferred model 52 | save_preferred_model(&self.preferred_model)?; 53 | 54 | // Save AST preference 55 | save_ast_preference(self.enable_ast)?; 56 | 57 | Ok(()) 58 | } 59 | } 60 | 61 | /// Load API key from configuration 62 | pub fn load_api_key() -> Option { 63 | let config_dir = dirs::config_dir()?; 64 | let config_file = config_dir.join("code_ai_openrouter_api_key.txt"); 65 | 66 | if config_file.exists() { 67 | if let Ok(api_key) = std::fs::read_to_string(&config_file) { 68 | let api_key = api_key.trim(); 69 | if !api_key.is_empty() { 70 | return Some(api_key.to_string()); 71 | } 72 | } 73 | } 74 | 75 | None 76 | } 77 | 78 | /// Save API key to configuration 79 | pub fn save_api_key(api_key: &str) -> Result<()> { 80 | let config_dir = dirs::config_dir() 81 | .context("Failed to get config directory")?; 82 | let config_file = config_dir.join("code_ai_openrouter_api_key.txt"); 83 | 84 | std::fs::create_dir_all(&config_dir)?; 85 | std::fs::write(config_file, api_key)?; 86 | 87 | Ok(()) 88 | } 89 | 90 | /// Load preferred model from configuration 91 | pub fn load_preferred_model() -> Option { 92 | let config_dir = dirs::config_dir()?; 93 | let config_file = config_dir.join("code_ai_preferred_model.txt"); 94 | 95 | if config_file.exists() { 96 | if let Ok(model) = std::fs::read_to_string(&config_file) { 97 | let model = model.trim(); 98 | if !model.is_empty() { 99 | return Some(model.to_string()); 100 | } 101 | } 102 | } 103 | 104 | None 105 | } 106 | 107 | /// Save preferred model to configuration 108 | pub fn save_preferred_model(model: &str) -> Result<()> { 109 | let config_dir = dirs::config_dir() 110 | .context("Failed to get config directory")?; 111 | let config_file = config_dir.join("code_ai_preferred_model.txt"); 112 | 113 | std::fs::create_dir_all(&config_dir)?; 114 | std::fs::write(config_file, model)?; 115 | 116 | Ok(()) 117 | } 118 | 119 | /// Load AST preference from configuration 120 | pub fn load_ast_preference() -> Option { 121 | let config_dir = dirs::config_dir()?; 122 | let config_file = config_dir.join("code_ai_enable_ast.txt"); 123 | 124 | if config_file.exists() { 125 | if let Ok(enable_ast) = std::fs::read_to_string(&config_file) { 126 | let enable_ast = enable_ast.trim(); 127 | if !enable_ast.is_empty() { 128 | return Some(enable_ast == "true"); 129 | } 130 | } 131 | } 132 | 133 | None 134 | } 135 | 136 | /// Save AST preference to configuration 137 | pub fn save_ast_preference(enable_ast: bool) -> Result<()> { 138 | let config_dir = dirs::config_dir() 139 | .context("Failed to get config directory")?; 140 | let config_file = config_dir.join("code_ai_enable_ast.txt"); 141 | 142 | std::fs::create_dir_all(&config_dir)?; 143 | std::fs::write(config_file, if enable_ast { "true" } else { "false" })?; 144 | 145 | Ok(()) 146 | } 147 | 148 | /// Get the configuration directory path 149 | pub fn config_dir() -> Result { 150 | dirs::config_dir() 151 | .context("Failed to get config directory") 152 | } 153 | 154 | /// Reset all configuration 155 | pub fn reset_config() -> Result<()> { 156 | let config_dir = dirs::config_dir() 157 | .context("Failed to get config directory")?; 158 | 159 | let files = [ 160 | "code_ai_openrouter_api_key.txt", 161 | "code_ai_preferred_model.txt", 162 | "code_ai_enable_ast.txt", 163 | ]; 164 | 165 | for file in &files { 166 | let file_path = config_dir.join(file); 167 | if file_path.exists() { 168 | std::fs::remove_file(&file_path)?; 169 | } 170 | } 171 | 172 | Ok(()) 173 | } -------------------------------------------------------------------------------- /src/files.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use std::path::Path; 3 | use crate::app::{Change, ChangeType, FileDiff}; 4 | 5 | /// Smart merge algorithm for combining original and modified code 6 | pub fn smart_merge(original: &str, new: &str) -> FileDiff { 7 | let original_lines: Vec<&str> = original.lines().collect(); 8 | let new_lines: Vec<&str> = new.lines().collect(); 9 | 10 | // If the number of lines is significantly different, treat it as a full file replacement 11 | if (new_lines.len() as f32 / original_lines.len() as f32).abs() > 0.5 && 12 | (original_lines.len() as i32 - new_lines.len() as i32).abs() > 5 { 13 | return full_file_diff(&original_lines, &new_lines); 14 | } 15 | 16 | let mut updated_lines = original_lines.clone(); 17 | let mut changes = Vec::new(); 18 | 19 | // Process matching line ranges 20 | for (i, (old_line, new_line)) in original_lines.iter().zip(new_lines.iter()).enumerate() { 21 | if old_line != new_line { 22 | changes.push(Change { 23 | change_type: ChangeType::Modify, 24 | line_number: i + 1, 25 | content: new_line.to_string(), 26 | }); 27 | if i < updated_lines.len() { 28 | updated_lines[i] = new_line; 29 | } 30 | } 31 | } 32 | 33 | // Process added lines 34 | for (i, new_line) in new_lines.iter().enumerate().skip(original_lines.len()) { 35 | changes.push(Change { 36 | change_type: ChangeType::Insert, 37 | line_number: i + 1, 38 | content: new_line.to_string(), 39 | }); 40 | updated_lines.push(new_line); 41 | } 42 | 43 | // Process removed lines 44 | for i in new_lines.len()..original_lines.len() { 45 | changes.push(Change { 46 | change_type: ChangeType::Delete, 47 | line_number: i + 1, 48 | content: original_lines[i].to_string(), 49 | }); 50 | } 51 | 52 | FileDiff { 53 | original: original.to_string(), 54 | modified: updated_lines.join("\n"), 55 | changes, 56 | explanation_text: None, 57 | } 58 | } 59 | 60 | /// Full file diff for complete replacements 61 | fn full_file_diff(original_lines: &[&str], new_lines: &[&str]) -> FileDiff { 62 | let mut changes = Vec::new(); 63 | 64 | // Compare each line and record the differences 65 | for (i, line) in new_lines.iter().enumerate() { 66 | if i < original_lines.len() { 67 | if line != &original_lines[i] { 68 | changes.push(Change { 69 | change_type: ChangeType::Modify, 70 | line_number: i + 1, 71 | content: line.to_string(), 72 | }); 73 | } 74 | } else { 75 | changes.push(Change { 76 | change_type: ChangeType::Insert, 77 | line_number: i + 1, 78 | content: line.to_string(), 79 | }); 80 | } 81 | } 82 | 83 | // Mark lines that exist in original but not in new as deleted 84 | for i in new_lines.len()..original_lines.len() { 85 | changes.push(Change { 86 | change_type: ChangeType::Delete, 87 | line_number: i + 1, 88 | content: original_lines[i].to_string(), 89 | }); 90 | } 91 | 92 | FileDiff { 93 | original: original_lines.join("\n"), 94 | modified: new_lines.join("\n"), 95 | changes, 96 | explanation_text: None, 97 | } 98 | } 99 | 100 | /// Read a file and return its contents 101 | pub fn read_file(path: &Path) -> Result { 102 | let content = std::fs::read_to_string(path)?; 103 | Ok(content) 104 | } 105 | 106 | /// Write content to a file 107 | pub fn write_file(path: &Path, content: &str) -> Result<()> { 108 | std::fs::write(path, content)?; 109 | Ok(()) 110 | } 111 | 112 | /// List files in a directory 113 | pub fn list_files(dir: &Path) -> Result> { 114 | let mut entries = Vec::new(); 115 | 116 | for entry in std::fs::read_dir(dir)? { 117 | let entry = entry?; 118 | let path = entry.path(); 119 | entries.push(path); 120 | } 121 | 122 | // Sort directories first, then files 123 | entries.sort_by(|a, b| { 124 | let a_is_dir = a.is_dir(); 125 | let b_is_dir = b.is_dir(); 126 | 127 | match (a_is_dir, b_is_dir) { 128 | (true, false) => std::cmp::Ordering::Less, 129 | (false, true) => std::cmp::Ordering::Greater, 130 | _ => a.file_name().cmp(&b.file_name()), 131 | } 132 | }); 133 | 134 | Ok(entries) 135 | } 136 | 137 | /// Calculate diff between original and modified code 138 | pub fn calculate_diff(original: &str, modified: &str) -> Vec { 139 | let mut changes = Vec::new(); 140 | 141 | // Split the original and modified code into lines 142 | let original_lines: Vec<&str> = original.lines().collect(); 143 | let modified_lines: Vec<&str> = modified.lines().collect(); 144 | 145 | // Use a simple line-by-line comparison for now 146 | // This is a basic implementation and could be improved with a proper diff algorithm 147 | let max_lines = std::cmp::max(original_lines.len(), modified_lines.len()); 148 | 149 | for i in 0..max_lines { 150 | let original_line = original_lines.get(i).map(|s| *s).unwrap_or(""); 151 | let modified_line = modified_lines.get(i).map(|s| *s).unwrap_or(""); 152 | 153 | if i >= original_lines.len() { 154 | // Line was added 155 | changes.push(crate::app::Change { 156 | change_type: crate::app::ChangeType::Insert, 157 | line_number: i, 158 | content: modified_line.to_string(), 159 | }); 160 | } else if i >= modified_lines.len() { 161 | // Line was deleted 162 | changes.push(crate::app::Change { 163 | change_type: crate::app::ChangeType::Delete, 164 | line_number: i, 165 | content: original_line.to_string(), 166 | }); 167 | } else if original_line != modified_line { 168 | // Line was modified 169 | changes.push(crate::app::Change { 170 | change_type: crate::app::ChangeType::Modify, 171 | line_number: i, 172 | content: modified_line.to_string(), 173 | }); 174 | } 175 | } 176 | 177 | changes 178 | } 179 | 180 | /// Apply a patch to a file 181 | pub fn apply_patch(file_path: &std::path::Path, patch: &str) -> anyhow::Result<()> { 182 | // Read the original file content 183 | let original_content = std::fs::read_to_string(file_path)?; 184 | 185 | // Apply the patch to get the modified content 186 | let modified_content = apply_patch_to_string(&original_content, patch)?; 187 | 188 | // Write the modified content back to the file 189 | std::fs::write(file_path, modified_content)?; 190 | 191 | Ok(()) 192 | } 193 | 194 | /// Apply a patch to a string 195 | pub fn apply_patch_to_string(original: &str, patch: &str) -> anyhow::Result { 196 | // Parse the patch 197 | let diff = parse_diff(patch)?; 198 | 199 | // Apply the diff to the original content 200 | let modified = apply_diff(original, &diff)?; 201 | 202 | Ok(modified) 203 | } 204 | 205 | /// Apply a diff to a string 206 | pub fn apply_diff(original: &str, diff: &crate::app::FileDiff) -> anyhow::Result { 207 | // If there are no changes, return the original content 208 | if diff.changes.is_empty() { 209 | return Ok(original.to_string()); 210 | } 211 | 212 | // Split the original content into lines 213 | let original_lines: Vec<&str> = original.lines().collect(); 214 | let mut result_lines: Vec = original_lines.iter().map(|&s| s.to_string()).collect(); 215 | 216 | // Sort changes by line number in reverse order to avoid index shifting 217 | let mut sorted_changes = diff.changes.clone(); 218 | sorted_changes.sort_by(|a, b| b.line_number.cmp(&a.line_number)); 219 | 220 | // Apply each change 221 | for change in sorted_changes { 222 | let line_idx = change.line_number; 223 | 224 | match change.change_type { 225 | crate::app::ChangeType::Insert => { 226 | // Insert a new line 227 | if line_idx >= result_lines.len() { 228 | result_lines.push(change.content); 229 | } else { 230 | result_lines.insert(line_idx, change.content); 231 | } 232 | }, 233 | crate::app::ChangeType::Delete => { 234 | // Delete a line if it exists 235 | if line_idx < result_lines.len() { 236 | result_lines.remove(line_idx); 237 | } 238 | }, 239 | crate::app::ChangeType::Modify => { 240 | // Modify a line if it exists 241 | if line_idx < result_lines.len() { 242 | result_lines[line_idx] = change.content; 243 | } 244 | }, 245 | } 246 | } 247 | 248 | // Join the lines back into a string 249 | Ok(result_lines.join("\n")) 250 | } 251 | 252 | /// Parse a diff string into a FileDiff struct 253 | pub fn parse_diff(diff_str: &str) -> anyhow::Result { 254 | // Extract explanation text if present (text before any code blocks) 255 | let explanation_text = extract_explanation_text(diff_str); 256 | 257 | // For now, we'll create a simple diff that contains the entire content 258 | // In a real implementation, this would parse a proper diff format 259 | Ok(FileDiff { 260 | original: String::new(), 261 | modified: diff_str.to_string(), 262 | changes: Vec::new(), 263 | explanation_text, 264 | }) 265 | } 266 | 267 | /// Extract explanation text from a diff string 268 | fn extract_explanation_text(diff_str: &str) -> Option { 269 | // Simple heuristic: extract text before the first code block or file marker 270 | let lines: Vec<&str> = diff_str.lines().collect(); 271 | let mut explanation = Vec::new(); 272 | 273 | for line in lines { 274 | // Stop at code block markers or file markers 275 | if line.starts_with("```") || line.starts_with("File:") { 276 | break; 277 | } 278 | explanation.push(line); 279 | } 280 | 281 | if explanation.is_empty() { 282 | None 283 | } else { 284 | Some(explanation.join("\n")) 285 | } 286 | } 287 | 288 | /// Create a diff between two files 289 | pub fn create_diff(original_path: &std::path::Path, modified_path: &std::path::Path) -> anyhow::Result { 290 | // Read the original and modified file content 291 | let original = std::fs::read_to_string(original_path)?; 292 | let modified = std::fs::read_to_string(modified_path)?; 293 | 294 | // Calculate the diff 295 | let changes = calculate_diff(&original, &modified); 296 | 297 | Ok(FileDiff { 298 | original, 299 | modified, 300 | changes, 301 | explanation_text: None, 302 | }) 303 | } -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | pub mod app; 4 | pub mod ui; 5 | pub mod tui; 6 | pub mod config; 7 | pub mod api; 8 | pub mod files; 9 | 10 | /// Initialize the application 11 | pub fn run() -> Result<()> { 12 | // This will be our main entry point for the application 13 | tui::run() 14 | } -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all)] 2 | 3 | use anyhow::Result; 4 | use std::fs; 5 | use log::{info, LevelFilter}; 6 | use log4rs::{ 7 | append::{ 8 | file::FileAppender, 9 | }, 10 | config::{Appender, Config, Root}, 11 | encode::pattern::PatternEncoder, 12 | }; 13 | 14 | fn setup_logging() -> Result<()> { 15 | // Ensure the logs directory exists 16 | let log_dir = "logs"; 17 | fs::create_dir_all(log_dir)?; 18 | 19 | // Create a file appender 20 | let file_appender = FileAppender::builder() 21 | .encoder(Box::new(PatternEncoder::new("{d(%Y-%m-%d %H:%M:%S)} [{l}] - {t} - {m}{n}"))) 22 | .build(format!("{}/code_ai.log", log_dir))?; 23 | 24 | // Configure the logging system - file only, no console output 25 | let config = Config::builder() 26 | .appender(Appender::builder().build("file", Box::new(file_appender))) 27 | .build(Root::builder() 28 | .appender("file") 29 | .build(LevelFilter::Debug))?; 30 | 31 | // Initialize the logging system 32 | log4rs::init_config(config)?; 33 | 34 | info!("Logging initialized"); 35 | Ok(()) 36 | } 37 | 38 | #[tokio::main] 39 | async fn main() -> Result<()> { 40 | // Initialize logger 41 | setup_logging()?; 42 | 43 | info!("Starting Code-AI Assistant"); 44 | 45 | // Run the TUI application 46 | match code_ai::run() { 47 | Ok(_) => { 48 | info!("Application exited normally"); 49 | Ok(()) 50 | } 51 | Err(e) => { 52 | log::error!("Application error: {}", e); 53 | Err(e) 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /src/tui.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use crate::app::App; 3 | use crate::ui; 4 | use crossterm::{ 5 | event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, 6 | execute, 7 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 8 | }; 9 | use ratatui::{backend::CrosstermBackend, Terminal}; 10 | use std::io; 11 | use std::sync::{mpsc, Mutex}; 12 | use std::time::{Duration, Instant}; 13 | use once_cell::sync::Lazy; 14 | 15 | /// Channel for sending prompt results from async tasks to the main thread 16 | pub static PROMPT_RESULT_TX: Lazy>> = Lazy::new(|| { 17 | let (tx, _) = mpsc::channel(); 18 | Mutex::new(tx) 19 | }); 20 | 21 | /// Result of a prompt operation 22 | #[derive(Debug)] 23 | pub enum PromptResult { 24 | Success(crate::app::FileDiff), 25 | MultiFileSuccess(Vec<(String, crate::app::FileDiff)>), // (filename, diff) pairs 26 | Error(String), 27 | CreditsInfo(crate::app::CreditsInfo), 28 | } 29 | 30 | /// Run the terminal UI 31 | pub fn run() -> Result<()> { 32 | // Setup terminal 33 | enable_raw_mode()?; 34 | let mut stdout = io::stdout(); 35 | execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 36 | let backend = CrosstermBackend::new(stdout); 37 | let mut terminal = Terminal::new(backend)?; 38 | 39 | // Create app state 40 | let mut app = App::new(); 41 | 42 | // Initialize app (load config, etc) 43 | initialize_app(&mut app)?; 44 | 45 | // Create a channel for prompt results 46 | let (tx, rx) = mpsc::channel(); 47 | 48 | // Store the sender in the static variable 49 | *PROMPT_RESULT_TX.lock().unwrap() = tx; 50 | 51 | // Run the event loop 52 | let res = run_event_loop(&mut terminal, &mut app, rx); 53 | 54 | // Restore terminal 55 | disable_raw_mode()?; 56 | execute!( 57 | terminal.backend_mut(), 58 | LeaveAlternateScreen, 59 | DisableMouseCapture 60 | )?; 61 | terminal.show_cursor()?; 62 | 63 | // Handle any errors from the event loop 64 | if let Err(err) = res { 65 | println!("Error: {}", err); 66 | } 67 | 68 | Ok(()) 69 | } 70 | 71 | /// Initialize the app state 72 | fn initialize_app(app: &mut App) -> Result<()> { 73 | // Load saved API key if available 74 | if let Some(api_key) = load_api_key() { 75 | app.api_key = Some(api_key); 76 | } 77 | 78 | // Load configuration if available 79 | if let Some(model) = load_preferred_model() { 80 | app.selected_model = model; 81 | } 82 | 83 | // Initialize file list 84 | app.refresh_file_list()?; 85 | 86 | Ok(()) 87 | } 88 | 89 | /// Run the main event loop 90 | pub fn run_event_loop( 91 | terminal: &mut Terminal>, 92 | app: &mut App, 93 | rx: mpsc::Receiver, 94 | ) -> Result<()> { 95 | let tick_rate = Duration::from_millis(100); 96 | let mut last_tick = Instant::now(); 97 | 98 | while app.running { 99 | // Show cursor for input modes, hide for others 100 | match app.mode { 101 | crate::app::AppMode::ApiKeyInput | crate::app::AppMode::PromptInput => { 102 | terminal.show_cursor()?; 103 | }, 104 | _ => { 105 | terminal.hide_cursor()?; 106 | } 107 | } 108 | 109 | // Render the UI 110 | terminal.draw(|f| ui::render(f, app))?; 111 | 112 | // Check for prompt results 113 | if let Ok(result) = rx.try_recv() { 114 | // Handle prompt result 115 | match result { 116 | PromptResult::Success(ref _diff) => { 117 | app.handle_prompt_result(result); 118 | } 119 | PromptResult::MultiFileSuccess(ref _diffs) => { 120 | app.handle_prompt_result(result); 121 | } 122 | PromptResult::Error(ref _err) => { 123 | app.handle_prompt_result(result); 124 | } 125 | PromptResult::CreditsInfo(ref _credits_info) => { 126 | app.handle_prompt_result(result); 127 | } 128 | } 129 | } 130 | 131 | // Check for events with timeout 132 | let timeout = tick_rate 133 | .checked_sub(last_tick.elapsed()) 134 | .unwrap_or_else(|| Duration::from_secs(0)); 135 | 136 | if crossterm::event::poll(timeout)? { 137 | if let Event::Key(key) = event::read()? { 138 | // Only handle key press events (not releases) 139 | if key.kind == KeyEventKind::Press { 140 | // Quit on Ctrl+C or Ctrl+D from any screen 141 | if key.code == KeyCode::Char('c') && key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) || 142 | key.code == KeyCode::Char('d') && key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) { 143 | app.running = false; 144 | continue; 145 | } 146 | 147 | // Forward to the app's key handler 148 | app.handle_key(key)?; 149 | } 150 | } 151 | } 152 | 153 | // Tick update for animations or periodic tasks 154 | if last_tick.elapsed() >= tick_rate { 155 | last_tick = Instant::now(); 156 | 157 | // Update app state (spinner animation and message timeout) 158 | app.update(); 159 | } 160 | } 161 | 162 | Ok(()) 163 | } 164 | 165 | /// Load API key from configuration 166 | fn load_api_key() -> Option { 167 | let config_dir = dirs::config_dir()?; 168 | let config_file = config_dir.join("code_ai_openrouter_api_key.txt"); 169 | 170 | if config_file.exists() { 171 | if let Ok(api_key) = std::fs::read_to_string(&config_file) { 172 | let api_key = api_key.trim(); 173 | if !api_key.is_empty() { 174 | return Some(api_key.to_string()); 175 | } 176 | } 177 | } 178 | 179 | None 180 | } 181 | 182 | /// Load preferred model from configuration 183 | fn load_preferred_model() -> Option { 184 | let config_dir = dirs::config_dir()?; 185 | let config_file = config_dir.join("code_ai_preferred_model.txt"); 186 | 187 | if config_file.exists() { 188 | if let Ok(model) = std::fs::read_to_string(&config_file) { 189 | let model = model.trim(); 190 | if !model.is_empty() { 191 | return Some(model.to_string()); 192 | } 193 | } 194 | } 195 | 196 | None 197 | } -------------------------------------------------------------------------------- /src/ui.rs: -------------------------------------------------------------------------------- 1 | use crate::app::{App, AppMode, MessageType, ProcessingState}; 2 | use std::path::Path; 3 | use ratatui::{ 4 | prelude::*, 5 | style::{Color, Modifier, Style}, 6 | widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph, Wrap, Gauge, ListState}, 7 | layout::{Layout, Constraint, Direction, Alignment, Rect}, 8 | Frame, 9 | }; 10 | 11 | // Color theme 12 | const PRIMARY_COLOR: Color = Color::Cyan; 13 | const SECONDARY_COLOR: Color = Color::Yellow; 14 | const ACCENT_COLOR: Color = Color::Rgb(147, 112, 219); // Medium Purple 15 | const HIGHLIGHT_COLOR: Color = Color::Green; 16 | const BG_COLOR: Color = Color::Rgb(25, 25, 40); // Dark Blue-Grey 17 | const SUCCESS_COLOR: Color = Color::Green; 18 | const ERROR_COLOR: Color = Color::Red; 19 | const INFO_COLOR: Color = Color::Blue; 20 | 21 | /// Render the main UI 22 | pub fn render(f: &mut Frame, app: &App) { 23 | // Create a layout for the main UI components 24 | let chunks = Layout::default() 25 | .direction(Direction::Vertical) 26 | .constraints([ 27 | Constraint::Length(1), // Status bar 28 | Constraint::Min(1), // Main content 29 | Constraint::Length(1), // Message bar (only shown if there's a message) 30 | Constraint::Length(1), // Command bar 31 | ]) 32 | .split(f.size()); 33 | 34 | // Render the appropriate screen based on the current mode 35 | match app.mode { 36 | AppMode::Welcome => render_welcome(f, app, chunks[1]), 37 | AppMode::Configuration => render_config(f, app, chunks[1]), 38 | AppMode::ApiKeyInput => render_api_key_input(f, app, chunks[1]), 39 | AppMode::CustomModelInput => render_custom_model_input(f, app, chunks[1]), 40 | AppMode::FileBrowser => render_file_browser(f, app, chunks[1]), 41 | AppMode::FileSelection => render_file_selection(f, app, chunks[1]), 42 | AppMode::Editor => render_editor(f, app, chunks[1]), 43 | AppMode::PromptInput => render_prompt_input(f, app, chunks[1]), 44 | AppMode::Results => render_results(f, app, chunks[1]), 45 | AppMode::Help => render_help(f, app, chunks[1]), 46 | AppMode::Credits => render_credits_screen(f, app, chunks[1]), 47 | } 48 | 49 | // Render message bar if there's a message 50 | if app.message.is_some() { 51 | render_message_bar(f, app, "", chunks[2]); 52 | } 53 | 54 | // Render command bar 55 | render_command_bar(f, app, chunks[3]); 56 | 57 | // Render credits overlay if enabled (regardless of the current mode) 58 | if app.show_credits && app.mode != AppMode::Credits { 59 | render_credits(f, app, f.size()); 60 | } 61 | } 62 | 63 | /// Render the welcome screen 64 | pub fn render_welcome(f: &mut Frame, _app: &App, area: Rect) { 65 | let block = Block::default() 66 | .borders(Borders::ALL) 67 | .border_style(Style::default().fg(Color::Blue)) 68 | .title(" Welcome "); 69 | 70 | let inner_area = block.inner(area); 71 | 72 | let welcome_text = vec![ 73 | Line::from(vec![ 74 | Span::styled("Welcome", Style::default().fg(Color::Blue)), 75 | ]), 76 | Line::from(vec![ 77 | Span::styled("Code-AI Assistant", Style::default().fg(Color::Magenta)), 78 | Span::raw(" - "), 79 | Span::raw("An AI-powered code editing assistant"), 80 | ]), 81 | Line::from(vec![ 82 | Span::styled("Press ", Style::default()), 83 | Span::styled("ENTER", Style::default().fg(Color::Magenta)), 84 | Span::raw(" to start"), 85 | ]), 86 | Line::from(vec![ 87 | Span::styled("Press ", Style::default()), 88 | Span::styled("q", Style::default().fg(Color::Magenta)), 89 | Span::raw(" to quit"), 90 | ]), 91 | ]; 92 | 93 | let welcome_paragraph = Paragraph::new(welcome_text) 94 | .block(Block::default()) 95 | .alignment(Alignment::Center) 96 | .wrap(Wrap { trim: true }); 97 | 98 | f.render_widget(block, area); 99 | f.render_widget(welcome_paragraph, inner_area); 100 | } 101 | 102 | /// Renders the configuration screen 103 | pub fn render_config(f: &mut Frame, app: &App, area: Rect) { 104 | // Split the area into sections 105 | let chunks = Layout::default() 106 | .direction(Direction::Vertical) 107 | .constraints([ 108 | Constraint::Length(3), // Title 109 | Constraint::Length(3), // API Key 110 | Constraint::Length(1), // Spacer 111 | Constraint::Length(2), // Model selection title 112 | Constraint::Min(5), // Model selection list 113 | Constraint::Length(3), // Instructions 114 | ]) 115 | .split(area); 116 | 117 | // Title 118 | let title = Paragraph::new("Configuration") 119 | .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) 120 | .alignment(Alignment::Center); 121 | f.render_widget(title, chunks[0]); 122 | 123 | // API Key 124 | let api_key_text = if let Some(key) = &app.api_key { 125 | format!("API Key: {}", mask_api_key(key)) 126 | } else { 127 | "API Key: Not configured".to_string() 128 | }; 129 | 130 | let api_key = Paragraph::new(api_key_text) 131 | .style(Style::default().fg(Color::White)) 132 | .block(Block::default().borders(Borders::ALL).title("API Key")); 133 | f.render_widget(api_key, chunks[1]); 134 | 135 | // Model selection title 136 | let model_title = Paragraph::new("Select a model:") 137 | .style(Style::default().fg(Color::White)); 138 | f.render_widget(model_title, chunks[3]); 139 | 140 | // Model selection list 141 | let models: Vec = app.available_models 142 | .iter() 143 | .map(|m| { 144 | let display_name = if m == "custom" { 145 | if app.custom_model.is_empty() { 146 | "Custom model (not set)".to_string() 147 | } else { 148 | format!("Custom model: {}", app.custom_model) 149 | } 150 | } else { 151 | m.clone() 152 | }; 153 | 154 | let style = if m == &app.selected_model || 155 | (m == "custom" && app.selected_model == app.custom_model && !app.custom_model.is_empty()) { 156 | Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) 157 | } else { 158 | Style::default().fg(Color::White) 159 | }; 160 | 161 | ListItem::new(display_name).style(style) 162 | }) 163 | .collect(); 164 | 165 | let models_list = List::new(models) 166 | .block(Block::default().borders(Borders::ALL).title("Available Models")) 167 | .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); 168 | f.render_widget(models_list, chunks[4]); 169 | 170 | // Instructions 171 | let instructions = if app.api_key.is_some() { 172 | "Press Enter to continue, q to go back, Ctrl+A to change API key" 173 | } else { 174 | "Press Enter to set API key, q to go back" 175 | }; 176 | 177 | // Add custom model instructions if "custom" is selected 178 | let instructions = if app.selected_model == "custom" { 179 | "Press Enter to input custom model, q to go back" 180 | } else { 181 | instructions 182 | }; 183 | 184 | let instructions_widget = Paragraph::new(instructions) 185 | .style(Style::default().fg(Color::Yellow)) 186 | .alignment(Alignment::Center); 187 | f.render_widget(instructions_widget, chunks[5]); 188 | } 189 | 190 | /// Renders the API key input screen 191 | fn render_api_key_input(f: &mut Frame, app: &App, _area: Rect) { 192 | let area = f.size(); 193 | 194 | // Create a centered area for the API key input 195 | let input_area = centered_rect(60, 20, area); 196 | 197 | // Split the input area into sections 198 | let chunks = Layout::default() 199 | .direction(Direction::Vertical) 200 | .margin(1) 201 | .constraints([ 202 | Constraint::Length(3), // Title 203 | Constraint::Length(3), // Instructions 204 | Constraint::Length(3), // Input field 205 | Constraint::Min(0), // Spacer 206 | ].as_ref()) 207 | .split(input_area); 208 | 209 | // Title 210 | let title = Paragraph::new("Enter OpenRouter API Key") 211 | .style(Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD)) 212 | .alignment(ratatui::layout::Alignment::Center); 213 | f.render_widget(title, chunks[0]); 214 | 215 | // Instructions 216 | let instructions = Paragraph::new("Your API key will be saved securely in your config directory") 217 | .style(Style::default().fg(SECONDARY_COLOR)) 218 | .alignment(ratatui::layout::Alignment::Center); 219 | f.render_widget(instructions, chunks[1]); 220 | 221 | // Input field 222 | let input = Paragraph::new(app.current_prompt.as_str()) 223 | .style(Style::default().fg(HIGHLIGHT_COLOR)) 224 | .block(Block::default() 225 | .borders(Borders::ALL) 226 | .border_style(Style::default().fg(ACCENT_COLOR)) 227 | .title("API Key")) 228 | .alignment(ratatui::layout::Alignment::Left); 229 | f.render_widget(input, chunks[2]); 230 | 231 | // Calculate cursor position correctly 232 | // The cursor should be positioned at the start of the input area + the length of the prompt 233 | // We need to account for the border and padding 234 | let cursor_x = chunks[2].x + 1 + app.current_prompt.len() as u16; 235 | let cursor_y = chunks[2].y + 1; // Position at the first line inside the block 236 | 237 | // Set cursor position 238 | f.set_cursor(cursor_x, cursor_y); 239 | } 240 | 241 | /// Renders the file browser 242 | fn render_file_browser(f: &mut Frame, app: &App, _area: Rect) { 243 | let area = f.size(); 244 | 245 | // Split the screen into sections 246 | let chunks = Layout::default() 247 | .direction(Direction::Vertical) 248 | .margin(1) 249 | .constraints([ 250 | Constraint::Length(3), // Title with current directory 251 | Constraint::Min(1), // File list 252 | Constraint::Length(5), // Selected files summary 253 | Constraint::Length(3), // Instructions 254 | ].as_ref()) 255 | .split(area); 256 | 257 | // Title with current directory 258 | let current_dir = app.current_dir.to_string_lossy(); 259 | let title = Paragraph::new(format!("Directory: {}", current_dir)) 260 | .block(Block::default().borders(Borders::ALL)) 261 | .style(Style::default().fg(PRIMARY_COLOR)); 262 | f.render_widget(title, chunks[0]); 263 | 264 | // File list 265 | let items: Vec = app.file_list 266 | .iter() 267 | .enumerate() 268 | .map(|(i, path)| { 269 | let is_selected = i == app.selected_file_idx; 270 | let is_dir = path.is_dir(); 271 | let is_file_selected = !is_dir && app.selected_files.contains(path); 272 | 273 | let name = if path.file_name().map_or(false, |name| name == "..") { 274 | "[Parent Directory]".to_string() 275 | } else { 276 | path.file_name() 277 | .map(|name| name.to_string_lossy().to_string()) 278 | .unwrap_or_else(|| "[Unknown]".to_string()) 279 | }; 280 | 281 | let display_name = if is_dir { 282 | format!("📁 {}/", name) 283 | } else if is_file_selected { 284 | format!("✓ 📄 {}", name) 285 | } else { 286 | format!("📄 {}", name) 287 | }; 288 | 289 | let style = if is_selected { 290 | Style::default().fg(HIGHLIGHT_COLOR).add_modifier(Modifier::BOLD) 291 | } else if is_file_selected { 292 | Style::default().fg(SUCCESS_COLOR) 293 | } else if is_dir { 294 | Style::default().fg(SECONDARY_COLOR) 295 | } else { 296 | Style::default().fg(Color::White) 297 | }; 298 | 299 | ListItem::new(Line::from(Span::styled(display_name, style))) 300 | }) 301 | .collect(); 302 | 303 | let files_list = List::new(items) 304 | .block(Block::default().borders(Borders::ALL).title("Files")) 305 | .highlight_style(Style::default().fg(HIGHLIGHT_COLOR).add_modifier(Modifier::BOLD)); 306 | 307 | f.render_widget(files_list, chunks[1]); 308 | 309 | // Selected files summary 310 | let selected_files_text = if app.selected_files.is_empty() { 311 | vec![Line::from(Span::styled( 312 | "No files selected. Use 's' to select files for context.", 313 | Style::default().fg(SECONDARY_COLOR) 314 | ))] 315 | } else { 316 | let mut lines = vec![Line::from(Span::styled( 317 | format!("{} files selected for context:", app.selected_files.len()), 318 | Style::default().fg(SUCCESS_COLOR).add_modifier(Modifier::BOLD) 319 | ))]; 320 | 321 | // Show up to 3 selected files with ellipsis if more 322 | for (i, path) in app.selected_files.iter().take(3).enumerate() { 323 | if let Some(file_name) = path.file_name() { 324 | lines.push(Line::from(Span::styled( 325 | format!("- {}", file_name.to_string_lossy()), 326 | Style::default().fg(ACCENT_COLOR) 327 | ))); 328 | } 329 | 330 | // Add ellipsis if there are more files 331 | if i == 2 && app.selected_files.len() > 3 { 332 | lines.push(Line::from(Span::styled( 333 | format!("... and {} more", app.selected_files.len() - 3), 334 | Style::default().fg(ACCENT_COLOR) 335 | ))); 336 | } 337 | } 338 | 339 | lines 340 | }; 341 | 342 | let selected_files = Paragraph::new(selected_files_text) 343 | .block(Block::default().borders(Borders::ALL).title("Selected Files")) 344 | .style(Style::default().fg(Color::White)); 345 | 346 | f.render_widget(selected_files, chunks[2]); 347 | 348 | // Instructions 349 | let instructions = Paragraph::new("[↑/↓] Navigate | [Enter] Open | [S] Select file | [P] Prompt with selected | [L] List selected | [Shift+C] Clear") 350 | .block(Block::default().borders(Borders::ALL)) 351 | .style(Style::default().fg(SECONDARY_COLOR)); 352 | f.render_widget(instructions, chunks[3]); 353 | } 354 | 355 | /// Renders the file selection screen 356 | fn render_file_selection(f: &mut Frame, app: &App, _area: Rect) { 357 | let area = f.size(); 358 | 359 | // Split the screen into sections 360 | let chunks = Layout::default() 361 | .direction(Direction::Vertical) 362 | .margin(1) 363 | .constraints([ 364 | Constraint::Length(3), // Title with current directory 365 | Constraint::Min(1), // File list 366 | Constraint::Length(5), // Selected files 367 | Constraint::Length(3), // Token count progress bar (using our new component) 368 | Constraint::Length(3), // Instructions 369 | ].as_ref()) 370 | .split(area); 371 | 372 | // Title with current directory 373 | let current_dir = app.current_dir.to_string_lossy(); 374 | let title = Paragraph::new(format!("Directory: {}", current_dir)) 375 | .block(Block::default().borders(Borders::ALL)) 376 | .style(Style::default().fg(PRIMARY_COLOR)); 377 | f.render_widget(title, chunks[0]); 378 | 379 | // File list 380 | let items: Vec = app.file_list 381 | .iter() 382 | .enumerate() 383 | .map(|(i, path)| { 384 | let is_selected = i == app.selected_file_idx; 385 | let is_dir = path.is_dir(); 386 | let is_file_selected = !is_dir && app.selected_files.contains(path); 387 | 388 | let name = if path.file_name().map_or(false, |name| name == "..") { 389 | "[Parent Directory]".to_string() 390 | } else { 391 | path.file_name() 392 | .map(|name| name.to_string_lossy().to_string()) 393 | .unwrap_or_else(|| "[Unknown]".to_string()) 394 | }; 395 | 396 | let prefix = if is_dir { 397 | "📁 " 398 | } else { 399 | "📄 " 400 | }; 401 | 402 | let style = if is_selected { 403 | Style::default().fg(Color::Black).bg(PRIMARY_COLOR) 404 | } else if is_file_selected { 405 | Style::default().fg(Color::Green) 406 | } else { 407 | Style::default() 408 | }; 409 | 410 | ListItem::new(format!("{}{}", prefix, name)).style(style) 411 | }) 412 | .collect(); 413 | 414 | let file_list = List::new(items) 415 | .block(Block::default().borders(Borders::ALL).title(" Files ")) 416 | .highlight_style(Style::default().fg(Color::Black).bg(PRIMARY_COLOR)); 417 | 418 | f.render_stateful_widget(file_list, chunks[1], &mut ListState::default().with_selected(Some(app.selected_file_idx))); 419 | 420 | // Selected files 421 | let selected_files_text = if app.selected_files.is_empty() { 422 | vec![ 423 | Line::from("No files selected for context"), 424 | ] 425 | } else { 426 | let mut lines = vec![ 427 | Line::from(vec![ 428 | Span::styled( 429 | format!("{} files selected for context:", app.selected_files.len()), 430 | Style::default().fg(Color::Green) 431 | ), 432 | ]), 433 | ]; 434 | 435 | for (i, path) in app.selected_files.iter().take(3).enumerate() { 436 | let file_name = path.file_name() 437 | .map(|name| name.to_string_lossy().to_string()) 438 | .unwrap_or_else(|| "[Unknown]".to_string()); 439 | 440 | lines.push(Line::from(format!(" {}. {}", i + 1, file_name))); 441 | } 442 | 443 | if app.selected_files.len() > 3 { 444 | lines.push(Line::from( 445 | format!(" ... and {} more", app.selected_files.len() - 3), 446 | )); 447 | } 448 | 449 | lines 450 | }; 451 | 452 | let selected_files = Paragraph::new(selected_files_text) 453 | .block(Block::default().borders(Borders::ALL).title(" Selected Files ")) 454 | .wrap(Wrap { trim: true }); 455 | 456 | f.render_widget(selected_files, chunks[2]); 457 | 458 | // Token count progress bar (using our new component) 459 | render_token_progress_bar(f, app, chunks[3]); 460 | 461 | // Instructions 462 | let instructions = Paragraph::new( 463 | "↑/↓: Navigate | Enter: Open | Space: Select/Deselect | Esc: Back | p: Proceed with selected files" 464 | ) 465 | .block(Block::default().borders(Borders::ALL)) 466 | .style(Style::default().fg(Color::Gray)); 467 | 468 | f.render_widget(instructions, chunks[4]); 469 | } 470 | 471 | /// Renders the code editor 472 | fn render_editor(f: &mut Frame, app: &App, area: Rect) { 473 | // Split the screen into sections 474 | let main_chunks = Layout::default() 475 | .direction(Direction::Vertical) 476 | .margin(1) 477 | .constraints([ 478 | Constraint::Length(3), // File info 479 | Constraint::Min(1), // Code viewer and sidebar 480 | Constraint::Length(3), // Status bar 481 | ].as_ref()) 482 | .split(area); 483 | 484 | // File info 485 | let file_name = app.current_file 486 | .as_ref() 487 | .and_then(|p| p.file_name()) 488 | .map(|n| n.to_string_lossy().to_string()) 489 | .unwrap_or_else(|| "[No File]".to_string()); 490 | 491 | let language = app.current_file 492 | .as_ref() 493 | .map(|p| get_file_language(p)) 494 | .unwrap_or("unknown"); 495 | 496 | let title = Paragraph::new(format!("File: {} ({})", file_name, language)) 497 | .block(Block::default().borders(Borders::ALL)) 498 | .style(Style::default().fg(PRIMARY_COLOR)); 499 | f.render_widget(title, main_chunks[0]); 500 | 501 | // Split the main area into code viewer and sidebar 502 | let content_chunks = Layout::default() 503 | .direction(Direction::Horizontal) 504 | .constraints([ 505 | Constraint::Percentage(75), // Code viewer 506 | Constraint::Percentage(25), // Sidebar 507 | ].as_ref()) 508 | .split(main_chunks[1]); 509 | 510 | // Code content 511 | let mut lines = Text::default(); 512 | 513 | for (i, line) in app.current_file_content.lines().enumerate() { 514 | lines.lines.push(Line::from(vec![ 515 | Span::styled( 516 | format!("{:4} ", i + 1), 517 | Style::default().fg(SECONDARY_COLOR) 518 | ), 519 | Span::raw(line), 520 | ])); 521 | } 522 | 523 | let code_paragraph = Paragraph::new(lines) 524 | .block(Block::default().borders(Borders::ALL)) 525 | .scroll((app.scroll_position as u16, 0)); 526 | 527 | f.render_widget(code_paragraph, content_chunks[0]); 528 | 529 | // Sidebar with selected model and files 530 | render_sidebar(f, app, content_chunks[1]); 531 | 532 | // Status bar 533 | let status_text = format!( 534 | "Mode: {} | File: {} | Model: {}", 535 | format!("{:?}", app.mode), 536 | file_name, 537 | app.selected_model 538 | ); 539 | 540 | let status_bar = Paragraph::new(status_text) 541 | .block(Block::default().borders(Borders::ALL)) 542 | .style(Style::default().fg(SECONDARY_COLOR)); 543 | 544 | f.render_widget(status_bar, main_chunks[2]); 545 | 546 | // Credits info is now handled by the main render function 547 | // We don't need to render message bar here as it's handled by the main render function 548 | } 549 | 550 | /// Render the sidebar with model info, selected files, and context usage 551 | fn render_sidebar(f: &mut Frame, app: &App, area: Rect) { 552 | let chunks = Layout::default() 553 | .direction(Direction::Vertical) 554 | .constraints([ 555 | Constraint::Length(3), // Model info 556 | Constraint::Length(3), // Token count progress bar 557 | Constraint::Min(1), // Selected files 558 | ].as_ref()) 559 | .split(area); 560 | 561 | // Model info 562 | let model_text = format!("Model: {}", app.selected_model); 563 | let model_paragraph = Paragraph::new(model_text) 564 | .block(Block::default().borders(Borders::ALL).title("Configuration")) 565 | .style(Style::default().fg(SECONDARY_COLOR)); 566 | 567 | f.render_widget(model_paragraph, chunks[0]); 568 | 569 | // Token progress bar 570 | render_token_progress_bar(f, app, chunks[1]); 571 | 572 | // Selected files 573 | let selected_files_text = if app.selected_files.is_empty() { 574 | "No files selected for context".to_string() 575 | } else { 576 | let mut text = format!("{} files selected:\n", app.selected_files.len()); 577 | for file_path in &app.selected_files { 578 | let file_name = Path::new(file_path) 579 | .file_name() 580 | .map(|n| n.to_string_lossy().to_string()) 581 | .unwrap_or_default(); 582 | text.push_str(&format!("- {}\n", file_name)); 583 | } 584 | text 585 | }; 586 | 587 | let selected_files_paragraph = Paragraph::new(selected_files_text) 588 | .block(Block::default().borders(Borders::ALL).title("Selected Files")) 589 | .style(Style::default().fg(ACCENT_COLOR)) 590 | .wrap(Wrap { trim: true }); 591 | 592 | f.render_widget(selected_files_paragraph, chunks[2]); 593 | } 594 | 595 | /// Render the message bar 596 | fn render_message_bar(f: &mut Frame, app: &App, message: &str, area: Rect) { 597 | let style = match app.message_type { 598 | MessageType::Info => Style::default().fg(Color::Blue), 599 | MessageType::Error => Style::default().fg(Color::Red), 600 | MessageType::Success => Style::default().fg(Color::Green), 601 | }; 602 | 603 | // Create a paragraph with the message 604 | let message_paragraph = Paragraph::new(message.to_string()) 605 | .style(style) 606 | .wrap(Wrap { trim: true }); 607 | 608 | f.render_widget(message_paragraph, area); 609 | } 610 | 611 | /// Render credits information 612 | fn render_credits(f: &mut Frame, app: &App, _area: Rect) { 613 | if app.show_credits { 614 | if let Some(credits_info) = &app.credits_info { 615 | // Format the credits information 616 | let remaining = credits_info.total_credits - credits_info.total_usage; 617 | let percentage_used = if credits_info.total_credits > 0.0 { 618 | (credits_info.total_usage / credits_info.total_credits) * 100.0 619 | } else { 620 | 0.0 621 | }; 622 | 623 | // Create a block for the credits information 624 | let block = Block::default() 625 | .title("OpenRouter Credits") 626 | .borders(Borders::ALL) 627 | .border_style(Style::default().fg(Color::Cyan)); 628 | 629 | // Create the text for the credits information 630 | let text = vec![ 631 | Line::from(vec![ 632 | Span::styled("Total Credits: ", Style::default().fg(Color::Green)), 633 | Span::raw(format!("${:.2}", credits_info.total_credits)), 634 | ]), 635 | Line::from(vec![ 636 | Span::styled("Used: ", Style::default().fg(Color::Yellow)), 637 | Span::raw(format!("${:.2} ({:.1}%)", credits_info.total_usage, percentage_used)), 638 | ]), 639 | Line::from(vec![ 640 | Span::styled("Remaining: ", Style::default().fg(Color::Blue)), 641 | Span::raw(format!("${:.2}", remaining)), 642 | ]), 643 | Line::from(vec![ 644 | Span::styled("Last Updated: ", Style::default().fg(Color::Gray)), 645 | Span::raw(format_system_time(credits_info.last_updated)), 646 | ]), 647 | ]; 648 | 649 | // Create a paragraph with the text 650 | let paragraph = Paragraph::new(text) 651 | .block(block) 652 | .wrap(Wrap { trim: true }); 653 | 654 | // Calculate the area for the credits information 655 | // Use a small popup in the corner, ensuring it stays within bounds 656 | let width = 40.min(f.size().width.saturating_sub(2)); 657 | let height = 6.min(f.size().height.saturating_sub(2)); 658 | 659 | // Ensure we don't position outside the screen bounds 660 | let x = f.size().width.saturating_sub(width + 1); 661 | let y = 1; // Position at the top with a small margin 662 | 663 | let credits_area = Rect { 664 | x, 665 | y, 666 | width, 667 | height, 668 | }; 669 | 670 | // Render the paragraph 671 | f.render_widget(paragraph, credits_area); 672 | } 673 | } 674 | } 675 | 676 | /// Format a SystemTime for display 677 | fn format_system_time(time: std::time::SystemTime) -> String { 678 | match time.duration_since(std::time::UNIX_EPOCH) { 679 | Ok(duration) => { 680 | let datetime = chrono::DateTime::::from_timestamp( 681 | duration.as_secs() as i64, 682 | duration.subsec_nanos(), 683 | ); 684 | 685 | if let Some(dt) = datetime { 686 | dt.format("%Y-%m-%d %H:%M:%S").to_string() 687 | } else { 688 | "Invalid time".to_string() 689 | } 690 | } 691 | Err(_) => "Invalid time".to_string(), 692 | } 693 | } 694 | 695 | /// Render the prompt input screen 696 | pub fn render_prompt_input(f: &mut Frame, app: &App, area: Rect) { 697 | let block = Block::default() 698 | .borders(Borders::ALL) 699 | .border_style(Style::default().fg(Color::Blue)) 700 | .title(" Prompt Input "); 701 | 702 | // First render the main block 703 | f.render_widget(block.clone(), area); 704 | let inner_area = block.inner(area); 705 | 706 | // Split the area into sections 707 | let chunks = Layout::default() 708 | .direction(Direction::Vertical) 709 | .constraints([ 710 | Constraint::Length(3), // Token count progress bar (more prominent at the top) 711 | Constraint::Min(3), // Prompt input area 712 | Constraint::Length(3), // Instructions 713 | ]) 714 | .split(inner_area); 715 | 716 | // Determine if we're processing or waiting for input 717 | if app.processing_state == ProcessingState::Processing { 718 | // Create a centered spinner overlay 719 | let spinner_area = centered_rect(60, 40, area); 720 | 721 | let spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; 722 | let spinner = spinner_chars[app.spinner_frame % spinner_chars.len()]; 723 | 724 | // Select a cooking message based on spinner frame 725 | let cooking_messages = [ 726 | "Thinking...", 727 | "Processing your request...", 728 | "Analyzing code...", 729 | "Generating response...", 730 | "Cooking up some code...", 731 | "Brewing a solution...", 732 | "Crunching algorithms...", 733 | "Consulting the AI oracle...", 734 | ]; 735 | 736 | // Use a slower rotation for messages 737 | let message_index = (app.spinner_frame / 10) % cooking_messages.len(); 738 | let cooking_message = cooking_messages[message_index]; 739 | 740 | // Create a clear block to overlay 741 | f.render_widget(Clear, spinner_area); 742 | 743 | // Create the spinner text with multiple lines 744 | let text = vec![ 745 | Line::from(vec![ 746 | Span::styled(format!(" {} ", spinner), 747 | Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), 748 | Span::styled(cooking_message, 749 | Style::default().fg(Color::Yellow)), 750 | ]), 751 | Line::from(""), 752 | Line::from(vec![ 753 | Span::styled("Please wait while your code is being processed...", 754 | Style::default().fg(Color::Gray)), 755 | ]), 756 | ]; 757 | 758 | // Render the spinner in a block with borders 759 | let spinner_widget = Paragraph::new(text) 760 | .block(Block::default() 761 | .borders(Borders::ALL) 762 | .border_style(Style::default().fg(Color::Yellow)) 763 | .title(" Processing ")) 764 | .alignment(Alignment::Center); 765 | 766 | f.render_widget(spinner_widget, spinner_area); 767 | } else { 768 | // Render token count progress bar (always shown at the top) 769 | render_token_progress_bar(f, app, chunks[0]); 770 | 771 | // Show the prompt input area 772 | let prompt_text = if app.current_prompt.is_empty() { 773 | vec![ 774 | Line::from(vec![ 775 | Span::styled( 776 | "Enter your prompt here. Describe what you want to do with the code.", 777 | Style::default().fg(Color::DarkGray) 778 | ), 779 | ]), 780 | ] 781 | } else { 782 | // Split the prompt into lines for proper wrapping 783 | app.current_prompt.lines() 784 | .map(|line| Line::from(line.to_string())) 785 | .collect() 786 | }; 787 | 788 | let prompt_paragraph = Paragraph::new(prompt_text) 789 | .style(Style::default().fg(Color::White)) 790 | .block(Block::default().borders(Borders::ALL).title(" Your Prompt ")) 791 | .wrap(Wrap { trim: true }); 792 | 793 | f.render_widget(prompt_paragraph, chunks[1]); 794 | 795 | // Calculate cursor position 796 | if app.mode == AppMode::PromptInput && app.processing_state == ProcessingState::Idle { 797 | // Count lines and characters to determine cursor position 798 | let lines: Vec<&str> = app.current_prompt.split('\n').collect(); 799 | let line_count = lines.len(); 800 | 801 | if line_count > 0 { 802 | let last_line = lines[line_count - 1]; 803 | let last_line_width = last_line.len() as u16; 804 | 805 | // Calculate cursor position within the visible area 806 | let x = last_line_width.min(chunks[1].width.saturating_sub(2)) + 1; 807 | let y = (line_count as u16 - 1).min(chunks[1].height.saturating_sub(2)) + 1; 808 | 809 | // Set cursor position 810 | f.set_cursor( 811 | chunks[1].x + x, 812 | chunks[1].y + y 813 | ); 814 | } else { 815 | // Default cursor position at the beginning 816 | f.set_cursor(chunks[1].x + 1, chunks[1].y + 1); 817 | } 818 | } 819 | 820 | // Instructions 821 | let instructions = if !app.selected_files.is_empty() { 822 | format!("Enter: Submit | Esc: Back to file selection | Selected Files: {}", app.selected_files.len()) 823 | } else { 824 | "Enter: Submit | Esc: Back to editor".to_string() 825 | }; 826 | 827 | let instructions_paragraph = Paragraph::new(instructions) 828 | .style(Style::default().fg(Color::Gray)) 829 | .block(Block::default().borders(Borders::ALL)) 830 | .alignment(Alignment::Center); 831 | 832 | f.render_widget(instructions_paragraph, chunks[2]); 833 | } 834 | } 835 | 836 | /// Render a token progress bar 837 | fn render_token_progress_bar(f: &mut Frame, app: &App, area: Rect) { 838 | // Calculate token percentage 839 | let token_percentage = if app.context_window_size > 0 { 840 | (app.token_count as f64 / app.context_window_size as f64 * 100.0).min(100.0) 841 | } else { 842 | 0.0 843 | }; 844 | 845 | // Create a formatted token display 846 | let token_model_info = match app.selected_model.as_str() { 847 | m if m.contains("gemini") => "Gemini (1M tokens)", 848 | m if m.contains("claude") => "Claude (200K tokens)", 849 | _ => "Custom model", 850 | }; 851 | 852 | // Format the token count with thousands separator for readability 853 | let formatted_token_count = format!("{}", app.token_count); 854 | let formatted_context_size = format!("{}", app.context_window_size); 855 | 856 | let token_count_text = format!( 857 | "Context Window Usage: {}/{} tokens ({:.1}%) - {}", 858 | formatted_token_count, 859 | formatted_context_size, 860 | token_percentage, 861 | token_model_info 862 | ); 863 | 864 | // Set color based on token usage percentage 865 | let progress_color = if token_percentage < 50.0 { 866 | Color::Green 867 | } else if token_percentage < 80.0 { 868 | Color::Yellow 869 | } else if token_percentage < 95.0 { 870 | Color::Red 871 | } else { 872 | Color::Rgb(255, 0, 0) // Bright red for critical (>95%) 873 | }; 874 | 875 | // Create a gauge widget 876 | let gauge = Gauge::default() 877 | .block(Block::default() 878 | .borders(Borders::ALL) 879 | .title(" Context Window Usage ")) 880 | .gauge_style(Style::default() 881 | .fg(progress_color) 882 | .bg(Color::Black)) 883 | .ratio(token_percentage / 100.0) 884 | .label(token_count_text); 885 | 886 | f.render_widget(gauge, area); 887 | } 888 | 889 | /// Render a spinner overlay 890 | pub fn render_spinner_overlay(f: &mut Frame, app: &App, area: Rect) { 891 | // Only show the spinner overlay if we're not in prompt input mode 892 | // (since prompt input has its own spinner) 893 | if app.mode == AppMode::PromptInput { 894 | return; 895 | } 896 | 897 | let spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; 898 | let spinner = spinner_chars[app.spinner_frame % spinner_chars.len()]; 899 | 900 | let processing_messages = [ 901 | "Processing...", 902 | "Working on it...", 903 | "Thinking...", 904 | "Analyzing...", 905 | "Computing...", 906 | "Generating...", 907 | ]; 908 | 909 | let message = processing_messages[app.spinner_frame % processing_messages.len()]; 910 | 911 | // Create a centered box for the spinner 912 | let width = 40; 913 | let height = 3; 914 | let spinner_area = Rect::new( 915 | (area.width.saturating_sub(width)) / 2, 916 | (area.height.saturating_sub(height)) / 2, 917 | width, 918 | height, 919 | ); 920 | 921 | let spinner_block = Block::default() 922 | .borders(Borders::ALL) 923 | .border_style(Style::default().fg(Color::Yellow)) 924 | .style(Style::default().bg(Color::Black)); 925 | 926 | let spinner_text = Paragraph::new(format!("{} {}", spinner, message)) 927 | .block(spinner_block) 928 | .alignment(Alignment::Center) 929 | .style(Style::default().fg(Color::Yellow)); 930 | 931 | f.render_widget(Clear, spinner_area); // Clear the area first 932 | f.render_widget(spinner_text, spinner_area); 933 | } 934 | 935 | /// Render the results view 936 | pub fn render_results(f: &mut Frame, app: &App, area: Rect) { 937 | let chunks = Layout::default() 938 | .direction(Direction::Vertical) 939 | .constraints([ 940 | Constraint::Length(3), // Title 941 | Constraint::Min(5), // Content 942 | Constraint::Length(3), // Help 943 | ]) 944 | .split(area); 945 | 946 | // Render title 947 | let title = if let Some(ref file_name) = app.current_diff_file { 948 | format!(" Diff for {} ", file_name) 949 | } else { 950 | " Diff ".to_string() 951 | }; 952 | 953 | let title_block = Block::default() 954 | .title(title) 955 | .borders(Borders::ALL) 956 | .border_type(BorderType::Rounded) 957 | .style(Style::default().fg(PRIMARY_COLOR)); 958 | f.render_widget(title_block, chunks[0]); 959 | 960 | // Render content 961 | if app.show_explanation { 962 | // Show explanation text if available 963 | let explanation_text = if let Some(ref diff) = app.current_diff { 964 | diff.explanation_text.as_deref().unwrap_or("No explanation available.") 965 | } else if let Some(ref explanation) = app.explanation_text { 966 | explanation 967 | } else { 968 | "No explanation available." 969 | }; 970 | 971 | let explanation_paragraph = Paragraph::new(explanation_text) 972 | .block(Block::default() 973 | .borders(Borders::ALL) 974 | .title(" Explanation ") 975 | .style(Style::default().fg(PRIMARY_COLOR))) 976 | .style(Style::default().fg(Color::White)) 977 | .wrap(Wrap { trim: true }); 978 | 979 | f.render_widget(explanation_paragraph, chunks[1]); 980 | } else { 981 | // Show diff view 982 | let diff_chunks = Layout::default() 983 | .direction(Direction::Horizontal) 984 | .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) 985 | .split(chunks[1]); 986 | 987 | if let Some(ref diff) = app.current_diff { 988 | // Original code panel 989 | let original_title = match app.active_panel { 990 | crate::app::ActivePanel::Left => " Original (ACTIVE) ", 991 | _ => " Original ", 992 | }; 993 | 994 | let original_block = Block::default() 995 | .title(original_title) 996 | .borders(Borders::ALL) 997 | .border_type(if app.active_panel == crate::app::ActivePanel::Left { 998 | BorderType::Double 999 | } else { 1000 | BorderType::Rounded 1001 | }) 1002 | .style(Style::default().fg(if app.active_panel == crate::app::ActivePanel::Left { 1003 | SECONDARY_COLOR 1004 | } else { 1005 | PRIMARY_COLOR 1006 | })); 1007 | 1008 | let original_text = Paragraph::new(diff.original.clone()) 1009 | .block(original_block) 1010 | .style(Style::default().fg(Color::White)) 1011 | .scroll((app.scroll_position as u16, 0)); 1012 | 1013 | f.render_widget(original_text, diff_chunks[0]); 1014 | 1015 | // Modified code panel 1016 | let modified_title = match app.active_panel { 1017 | crate::app::ActivePanel::Right => " Modified (ACTIVE) ", 1018 | _ => " Modified ", 1019 | }; 1020 | 1021 | let modified_block = Block::default() 1022 | .title(modified_title) 1023 | .borders(Borders::ALL) 1024 | .border_type(if app.active_panel == crate::app::ActivePanel::Right { 1025 | BorderType::Double 1026 | } else { 1027 | BorderType::Rounded 1028 | }) 1029 | .style(Style::default().fg(if app.active_panel == crate::app::ActivePanel::Right { 1030 | SECONDARY_COLOR 1031 | } else { 1032 | PRIMARY_COLOR 1033 | })); 1034 | 1035 | let modified_text = Paragraph::new(diff.modified.clone()) 1036 | .block(modified_block) 1037 | .style(Style::default().fg(Color::White)) 1038 | .scroll((app.scroll_position as u16, 0)); 1039 | 1040 | f.render_widget(modified_text, diff_chunks[1]); 1041 | } else { 1042 | // No diff available 1043 | let no_diff_block = Block::default() 1044 | .title(" No Diff Available ") 1045 | .borders(Borders::ALL) 1046 | .style(Style::default().fg(PRIMARY_COLOR)); 1047 | 1048 | f.render_widget(no_diff_block, chunks[1]); 1049 | } 1050 | } 1051 | 1052 | // Render help 1053 | let help_text = if app.show_explanation { 1054 | "Press [e] to show diff | [q] to quit | [y] to accept | [n] to reject" 1055 | } else { 1056 | "Press [e] to show explanation | [Tab] to switch panels | [←/→] to navigate files | [↑/↓] to scroll | [y] to accept | [n] to reject | [q] to quit" 1057 | }; 1058 | 1059 | let help_paragraph = Paragraph::new(help_text) 1060 | .block(Block::default() 1061 | .borders(Borders::ALL) 1062 | .title(" Help ") 1063 | .style(Style::default().fg(PRIMARY_COLOR))) 1064 | .style(Style::default().fg(SECONDARY_COLOR)) 1065 | .alignment(Alignment::Center); 1066 | 1067 | f.render_widget(help_paragraph, chunks[2]); 1068 | } 1069 | 1070 | /// Renders the help screen 1071 | fn render_help(f: &mut Frame, _app: &App, _area: Rect) { 1072 | let area = f.size(); 1073 | 1074 | // Create a centered area for the help content 1075 | let help_area = centered_rect(70, 70, area); 1076 | 1077 | // Create the help text with Lines instead of Vec 1078 | let help_text = vec![ 1079 | Line::from(vec![Span::styled( 1080 | "Code-AI Assistant Help", 1081 | Style::default() 1082 | .fg(HIGHLIGHT_COLOR) 1083 | .add_modifier(Modifier::BOLD) 1084 | )]), 1085 | Line::from(""), 1086 | Line::from(""), 1087 | Line::from(vec![Span::styled("Global Commands:", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD))]), 1088 | Line::from(" [Q] - Quit the current screen or application"), 1089 | Line::from(" [H] - Show this help screen"), 1090 | Line::from(""), 1091 | Line::from(vec![Span::styled("File Browser:", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD))]), 1092 | Line::from(" [↑/↓] - Navigate files"), 1093 | Line::from(" [Enter] - Open file or directory"), 1094 | Line::from(" [Backspace] - Go to parent directory"), 1095 | Line::from(""), 1096 | Line::from(vec![Span::styled("Code Editor:", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD))]), 1097 | Line::from(" [↑/↓] - Scroll code"), 1098 | Line::from(" [P] - Enter prompt mode"), 1099 | Line::from(" [B] - Return to file browser"), 1100 | Line::from(" [C] - Toggle credits display"), 1101 | Line::from(" [S] - Toggle selection of current file for multi-file context"), 1102 | Line::from(" [Shift+C] - Clear all selected files"), 1103 | Line::from(" [R] - Refresh OpenRouter credits information"), 1104 | Line::from(""), 1105 | Line::from(vec![Span::styled("Prompt Mode:", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD))]), 1106 | Line::from(" [Enter] - Submit prompt"), 1107 | Line::from(" [Esc] - Cancel and return to editor"), 1108 | Line::from(""), 1109 | Line::from(vec![Span::styled("Results View:", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD))]), 1110 | Line::from(" [Y] - Apply changes"), 1111 | Line::from(" [N] - Discard changes"), 1112 | Line::from(" [E] - Toggle between explanation text and code diff"), 1113 | Line::from(" [←/→] - Switch between original and modified code panels"), 1114 | Line::from(" [Tab/Shift+Tab] - Navigate between files (for multi-file changes)"), 1115 | Line::from(" [↑/↓] - Scroll through code or explanation"), 1116 | Line::from(""), 1117 | Line::from(vec![Span::styled("Multi-File Context:", Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD))]), 1118 | Line::from(" Use [S] in editor mode to select files for context"), 1119 | Line::from(" Selected files will be included as context when submitting prompts"), 1120 | Line::from(" This helps the AI understand related code across multiple files"), 1121 | ]; 1122 | 1123 | let help_paragraph = Paragraph::new(help_text) 1124 | .block(Block::default() 1125 | .borders(Borders::ALL) 1126 | .border_style(Style::default().fg(PRIMARY_COLOR)) 1127 | .title("Help") 1128 | .title_style(Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD))) 1129 | .style(Style::default()) 1130 | .wrap(Wrap { trim: true }); 1131 | 1132 | f.render_widget(help_paragraph, help_area); 1133 | } 1134 | 1135 | /// Helper function to create a centered rect 1136 | fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { 1137 | let popup_layout = Layout::default() 1138 | .direction(Direction::Vertical) 1139 | .constraints([ 1140 | Constraint::Percentage((100 - percent_y) / 2), 1141 | Constraint::Percentage(percent_y), 1142 | Constraint::Percentage((100 - percent_y) / 2), 1143 | ].as_ref()) 1144 | .split(r); 1145 | 1146 | Layout::default() 1147 | .direction(Direction::Horizontal) 1148 | .constraints([ 1149 | Constraint::Percentage((100 - percent_x) / 2), 1150 | Constraint::Percentage(percent_x), 1151 | Constraint::Percentage((100 - percent_x) / 2), 1152 | ].as_ref()) 1153 | .split(popup_layout[1])[1] 1154 | } 1155 | 1156 | /// Helper function to determine file language based on extension 1157 | fn get_file_language(file_path: &Path) -> &'static str { 1158 | let extension = file_path 1159 | .extension() 1160 | .and_then(std::ffi::OsStr::to_str) 1161 | .unwrap_or(""); 1162 | 1163 | match extension { 1164 | "js" => "javascript", 1165 | "ts" => "typescript", 1166 | "py" => "python", 1167 | "rs" => "rust", 1168 | "go" => "go", 1169 | "java" => "java", 1170 | "cpp" | "cc" | "cxx" => "c++", 1171 | "c" => "c", 1172 | "cs" => "c#", 1173 | "php" => "php", 1174 | "rb" => "ruby", 1175 | "swift" => "swift", 1176 | "kt" | "kts" => "kotlin", 1177 | "scala" => "scala", 1178 | "hs" => "haskell", 1179 | "lua" => "lua", 1180 | "pl" => "perl", 1181 | "r" => "r", 1182 | "sh" => "shell", 1183 | "sql" => "sql", 1184 | "html" => "html", 1185 | "css" => "css", 1186 | "md" | "markdown" => "markdown", 1187 | "json" => "json", 1188 | "xml" => "xml", 1189 | "yaml" | "yml" => "yaml", 1190 | _ => "plaintext", 1191 | } 1192 | } 1193 | 1194 | /// Mask API key for display, showing only first 4 and last 4 characters 1195 | fn mask_api_key(key: &str) -> String { 1196 | if key.len() <= 8 { 1197 | return "****".to_string(); 1198 | } 1199 | 1200 | let visible_chars = 4; 1201 | let first = &key[0..visible_chars]; 1202 | let last = &key[key.len() - visible_chars..]; 1203 | format!("{}****{}", first, last) 1204 | } 1205 | 1206 | /// Render the status bar 1207 | fn render_status_bar(f: &mut Frame, app: &App, area: Rect) { 1208 | let status_text = format!( 1209 | "Mode: {:?} | Model: {}", 1210 | app.mode, app.selected_model 1211 | ); 1212 | 1213 | let status_bar = Paragraph::new(status_text) 1214 | .style(Style::default().fg(SECONDARY_COLOR)); 1215 | 1216 | f.render_widget(status_bar, area); 1217 | } 1218 | 1219 | /// Render a spinner for processing state 1220 | fn render_spinner(f: &mut Frame, app: &App, area: Rect) { 1221 | // Show a spinner in the center of the screen 1222 | let spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; 1223 | let spinner = spinner_chars[app.spinner_frame % spinner_chars.len()]; 1224 | 1225 | let spinner_area = Rect { 1226 | x: area.width / 2 - 15, 1227 | y: area.height / 2 - 1, 1228 | width: 30, 1229 | height: 3, 1230 | }; 1231 | 1232 | let spinner_text = format!("{} Cooking...", spinner); 1233 | let spinner_widget = Paragraph::new(spinner_text) 1234 | .block(Block::default().borders(Borders::ALL)) 1235 | .style(Style::default().fg(HIGHLIGHT_COLOR)) 1236 | .alignment(ratatui::layout::Alignment::Center); 1237 | 1238 | f.render_widget(spinner_widget, spinner_area); 1239 | } 1240 | 1241 | /// Render the credits screen 1242 | fn render_credits_screen(f: &mut Frame, app: &App, area: Rect) { 1243 | // Split the screen into sections 1244 | let chunks = Layout::default() 1245 | .direction(Direction::Vertical) 1246 | .margin(2) 1247 | .constraints([ 1248 | Constraint::Length(3), // Title 1249 | Constraint::Min(10), // Credits info 1250 | Constraint::Length(3), // Instructions 1251 | ].as_ref()) 1252 | .split(area); 1253 | 1254 | // Title 1255 | let title = Paragraph::new("OpenRouter Credits Information") 1256 | .style(Style::default().fg(PRIMARY_COLOR).add_modifier(Modifier::BOLD)) 1257 | .alignment(ratatui::layout::Alignment::Center); 1258 | f.render_widget(title, chunks[0]); 1259 | 1260 | // Credits information 1261 | if let Some(credits_info) = &app.credits_info { 1262 | let remaining = credits_info.total_credits - credits_info.total_usage; 1263 | let percentage_used = if credits_info.total_credits > 0.0 { 1264 | (credits_info.total_usage / credits_info.total_credits) * 100.0 1265 | } else { 1266 | 0.0 1267 | }; 1268 | 1269 | let credits_text = vec![ 1270 | Line::from(vec![ 1271 | Span::styled("Total Credits: ", Style::default().fg(SUCCESS_COLOR).add_modifier(Modifier::BOLD)), 1272 | Span::raw(format!("${:.2}", credits_info.total_credits)), 1273 | ]), 1274 | Line::from(""), 1275 | Line::from(vec![ 1276 | Span::styled("Used Credits: ", Style::default().fg(ACCENT_COLOR).add_modifier(Modifier::BOLD)), 1277 | Span::raw(format!("${:.2}", credits_info.total_usage)), 1278 | ]), 1279 | Line::from(""), 1280 | Line::from(vec![ 1281 | Span::styled("Remaining Credits: ", Style::default().fg(HIGHLIGHT_COLOR).add_modifier(Modifier::BOLD)), 1282 | Span::raw(format!("${:.2}", remaining)), 1283 | ]), 1284 | Line::from(""), 1285 | Line::from(vec![ 1286 | Span::styled("Usage Percentage: ", Style::default().fg(SECONDARY_COLOR).add_modifier(Modifier::BOLD)), 1287 | Span::raw(format!("{:.1}%", percentage_used)), 1288 | ]), 1289 | Line::from(""), 1290 | Line::from(vec![ 1291 | Span::styled("Last Updated: ", Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD)), 1292 | Span::raw(format_system_time(credits_info.last_updated)), 1293 | ]), 1294 | ]; 1295 | 1296 | let credits_paragraph = Paragraph::new(credits_text) 1297 | .block(Block::default().borders(Borders::ALL).title("Credits")) 1298 | .style(Style::default().fg(Color::White)) 1299 | .alignment(ratatui::layout::Alignment::Center); 1300 | 1301 | f.render_widget(credits_paragraph, chunks[1]); 1302 | } else if app.processing_state == crate::app::ProcessingState::Processing { 1303 | // Show loading message 1304 | let loading_text = "Fetching credits information..."; 1305 | let loading_paragraph = Paragraph::new(loading_text) 1306 | .block(Block::default().borders(Borders::ALL)) 1307 | .style(Style::default().fg(SECONDARY_COLOR)) 1308 | .alignment(ratatui::layout::Alignment::Center); 1309 | 1310 | f.render_widget(loading_paragraph, chunks[1]); 1311 | } else { 1312 | // Show error or no data message 1313 | let error_text = match &app.processing_state { 1314 | crate::app::ProcessingState::Error(err) => format!("Error: {}", err), 1315 | _ => "No credits information available. Press 'r' to refresh.".to_string(), 1316 | }; 1317 | 1318 | let error_paragraph = Paragraph::new(error_text) 1319 | .block(Block::default().borders(Borders::ALL)) 1320 | .style(Style::default().fg(ERROR_COLOR)) 1321 | .alignment(ratatui::layout::Alignment::Center); 1322 | 1323 | f.render_widget(error_paragraph, chunks[1]); 1324 | } 1325 | 1326 | // Instructions 1327 | let instructions = Paragraph::new("[R] Refresh credits | [Q] Return to previous screen") 1328 | .block(Block::default().borders(Borders::ALL)) 1329 | .style(Style::default().fg(SECONDARY_COLOR)); 1330 | f.render_widget(instructions, chunks[2]); 1331 | } 1332 | 1333 | /// Render the command bar 1334 | fn render_command_bar(f: &mut Frame, app: &App, area: Rect) { 1335 | let command_text = match app.mode { 1336 | AppMode::Welcome => "Enter: Continue | q: Quit", 1337 | AppMode::Configuration => "Enter: Continue | Ctrl+A: Set API Key | q: Back", 1338 | AppMode::ApiKeyInput => "Enter: Save API Key | Esc: Cancel", 1339 | AppMode::CustomModelInput => "Enter: Save Custom Model | Esc: Cancel", 1340 | AppMode::FileBrowser => "Enter: Open | Backspace/b: Up | s: Select | m: Multi-select | p: Prompt | h: Help | q: Back", 1341 | AppMode::FileSelection => "Space: Toggle Selection | Enter: Continue | Esc/q: Cancel", 1342 | AppMode::Editor => "p: Prompt | s: Select for Context | l: List Selected | q: Back", 1343 | AppMode::PromptInput => "Enter: Submit | Esc: Cancel", 1344 | AppMode::Results => { 1345 | if app.multi_file_diffs.is_some() { 1346 | "y: Apply All Changes | n: Discard | Left/Right: Switch Panels | Tab: Next File | e: Toggle Explanation | q: Back" 1347 | } else { 1348 | "y: Apply Changes | n: Discard | e: Toggle Explanation | q: Back" 1349 | } 1350 | }, 1351 | AppMode::Help => "q: Back", 1352 | AppMode::Credits => "r: Refresh | q: Back", 1353 | }; 1354 | 1355 | let command_bar = Paragraph::new(command_text) 1356 | .style(Style::default().fg(Color::White).bg(Color::Blue)) 1357 | .alignment(Alignment::Center); 1358 | 1359 | f.render_widget(command_bar, area); 1360 | } 1361 | 1362 | /// Render the custom model input screen 1363 | pub fn render_custom_model_input(f: &mut Frame, app: &App, _area: Rect) { 1364 | let size = f.size(); 1365 | 1366 | // Create a block for the custom model input screen 1367 | let block = Block::default() 1368 | .title("Custom Model Input") 1369 | .borders(Borders::ALL); 1370 | 1371 | // Create a layout for the input area 1372 | let chunks = Layout::default() 1373 | .direction(Direction::Vertical) 1374 | .margin(2) 1375 | .constraints([ 1376 | Constraint::Length(3), // Title 1377 | Constraint::Length(3), // Input field 1378 | Constraint::Length(3), // Instructions 1379 | ]) 1380 | .split(size); 1381 | 1382 | // Render title 1383 | let title = Paragraph::new("Enter your custom model identifier") 1384 | .style(Style::default().fg(Color::Cyan)) 1385 | .alignment(Alignment::Center); 1386 | f.render_widget(title, chunks[0]); 1387 | 1388 | // Render input field 1389 | let input = Paragraph::new(app.current_prompt.as_str()) 1390 | .style(Style::default()) 1391 | .block(Block::default().borders(Borders::ALL).title("Custom Model")); 1392 | f.render_widget(input, chunks[1]); 1393 | 1394 | // Set cursor position 1395 | f.set_cursor( 1396 | chunks[1].x + app.current_prompt.len() as u16 + 1, 1397 | chunks[1].y + 1, 1398 | ); 1399 | 1400 | // Render instructions 1401 | let instructions = Paragraph::new( 1402 | "Enter a model identifier like 'google/gemini-2.0-flash-lite-001' | Press Enter to save | Esc to cancel" 1403 | ) 1404 | .style(Style::default().fg(Color::Gray)) 1405 | .alignment(Alignment::Center); 1406 | f.render_widget(instructions, chunks[2]); 1407 | 1408 | // Render the main block 1409 | f.render_widget(block, size); 1410 | } --------------------------------------------------------------------------------