diff --git a/Cargo.lock b/Cargo.lock index 8766883..f0af383 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -338,6 +338,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "dirs" version = "5.0.1" @@ -1084,6 +1093,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "num-traits" version = "0.2.19" @@ -1093,6 +1108,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1225,6 +1249,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "prettyplease" version = "0.2.37" @@ -1387,7 +1417,7 @@ dependencies = [ [[package]] name = "routecode-cli" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "async-trait", @@ -1399,13 +1429,14 @@ dependencies = [ "ratatui", "routecode-sdk", "serde_json", + "simplelog", "tokio", "tui-textarea", ] [[package]] name = "routecode-sdk" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "async-stream", @@ -1418,6 +1449,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "similar", "tempfile", "thiserror", "tiktoken-rs", @@ -1635,6 +1667,23 @@ dependencies = [ "libc", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "simplelog" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" +dependencies = [ + "log", + "termcolor", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -1778,6 +1827,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1813,6 +1871,39 @@ dependencies = [ "rustc-hash", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2250,6 +2341,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/README.md b/README.md index ff65baa..e82a958 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![CI](https://github.com/anasx07/routecode/actions/workflows/ci.yml/badge.svg)](https://github.com/anasx07/routecode/actions/workflows/ci.yml) [![Release](https://github.com/anasx07/routecode/actions/workflows/release.yml/badge.svg)](https://github.com/anasx07/routecode/actions/workflows/release.yml) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) +[![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white)](https://discord.gg/RFVNKQUXY2) --- @@ -130,6 +131,19 @@ libs/sdk/ # Core logic and AI orchestrator --- +## Community + +Join the RouteCode Discord — where bugs get caught, features get shaped, and contributors find each other. + +[![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white)](https://discord.gg/RFVNKQUXY2) + +- 💬 General chat and questions +- 🐛 Bug reports and help +- 💡 Feature requests and roadmap discussion +- 🎉 Show and tell — share what you built + +--- + ## Contributing PRs are welcome. Please open an issue first for significant changes. diff --git a/apps/cli/Cargo.toml b/apps/cli/Cargo.toml index fd1b4550..bb3e556 100644 --- a/apps/cli/Cargo.toml +++ b/apps/cli/Cargo.toml @@ -18,3 +18,4 @@ futures = "0.3" log = "0.4" chrono = { version = "0.4", features = ["serde"] } async-trait = "0.1" +simplelog = "0.12" diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index 00883c5..bbbe8b2 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -52,14 +52,58 @@ use routecode_sdk::tools::file_ops::{FileEditTool, FileReadTool, FileWriteTool}; use routecode_sdk::tools::navigation::{GrepTool, LsTool}; use routecode_sdk::tools::ToolRegistry; use std::io; +use std::process::Command; use std::sync::Arc; use tokio::sync::Mutex; use ui::{run_app, App}; +use simplelog::*; +use std::fs::File; #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); + // Initialize logging + let base_dir = routecode_sdk::utils::storage::get_base_dir(); + if !base_dir.exists() { + std::fs::create_dir_all(&base_dir)?; + } + let log_path = base_dir.join("routecode.log"); + + let log_level = if cli.debug { LevelFilter::Debug } else { LevelFilter::Info }; + + CombinedLogger::init(vec![ + WriteLogger::new( + log_level, + ConfigBuilder::new().set_time_format_rfc3339().build(), + File::create(&log_path)?, + ), + ])?; + + if cli.debug { + log::debug!("Debug mode active. Spawning log window..."); + // Spawn a new terminal window to tail the log file + #[cfg(target_os = "windows")] + { + let _ = Command::new("cmd") + .args(["/C", "start", "powershell", "-NoExit", "-Command", &format!("Get-Content -Path '{}' -Wait", log_path.display())]) + .spawn(); + } + #[cfg(target_os = "macos")] + { + let _ = Command::new("osascript") + .args(["-e", &format!("tell application \"Terminal\" to do script \"tail -f '{}'\"", log_path.display())]) + .spawn(); + } + #[cfg(target_os = "linux")] + { + // Try common terminal emulators + let _ = Command::new("x-terminal-emulator") + .args(["-e", "tail", "-f", &log_path.display().to_string()]) + .spawn(); + } + } + if let Some(Commands::Version) = cli.command { println!("routecode {}", env!("CARGO_PKG_VERSION")); println!("Rust based"); diff --git a/apps/cli/src/ui/mod.rs b/apps/cli/src/ui/mod.rs index e27daff..24ebf9b 100644 --- a/apps/cli/src/ui/mod.rs +++ b/apps/cli/src/ui/mod.rs @@ -1,4 +1,4 @@ -use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, MouseEventKind}; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, @@ -197,335 +197,345 @@ pub async fn run_app( .unwrap_or_else(|| std::time::Duration::from_secs(0)); if event::poll(timeout)? { - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press { - match key.code { - KeyCode::Char('p') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { - app.show_menu = true; - app.menu_state.select(Some(0)); - app.update_filtered_commands(); - } - KeyCode::Char('a') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { - if app.show_model_menu { app.show_model_menu = false; } - app.show_provider_menu = true; - app.menu_state.select(Some(0)); - } - KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { - if app.is_generating { - if let Some(handle) = app.current_task.take() { handle.abort(); } - app.is_generating = false; - app.active_tool = None; + match event::read()? { + Event::Key(key) => { + if key.kind == KeyEventKind::Press { + match key.code { + KeyCode::Char('p') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { + app.show_menu = true; + app.menu_state.select(Some(0)); + app.update_filtered_commands(); } - } - KeyCode::Char('l') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { - app.history.clear(); - app.screen = Screen::Welcome; - app.history_scroll = 0; - } - KeyCode::Enter if key.modifiers.contains(event::KeyModifiers::SHIFT) => { - app.input.insert_newline(); - } - KeyCode::Enter => { - if app.show_menu { - if let Some(selected) = app.menu_state.selected() { - if let Some(cmd) = app.filtered_commands.get(selected) { - let name = cmd.name.to_string(); - app.show_menu = false; - app.input = TextArea::default(); - handle_command(&mut app, &name).await; - } + KeyCode::Char('a') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { + if app.show_model_menu { app.show_model_menu = false; } + app.show_provider_menu = true; + app.menu_state.select(Some(0)); + } + KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { + if app.is_generating { + if let Some(handle) = app.current_task.take() { handle.abort(); } + app.is_generating = false; + app.active_tool = None; } - } else if app.show_provider_menu { - if let Some(selected) = app.menu_state.selected() { - if let Some(p) = PROVIDERS.get(selected) { - app.pending_provider_id = Some(p.id.to_string()); - app.is_inputting_api_key = true; - app.api_key_input = TextArea::default(); - app.show_provider_menu = false; - if p.id == "cloudflare-workers" || p.id == "cloudflare-gateway" { - app.api_key_input_stage = ApiKeyInputStage::CloudflareAccountId; - } else { - app.api_key_input_stage = ApiKeyInputStage::ApiKey; + } + KeyCode::Char('l') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { + app.history.clear(); + app.screen = Screen::Welcome; + app.history_scroll = 0; + } + KeyCode::Enter if key.modifiers.contains(event::KeyModifiers::SHIFT) => { + app.input.insert_newline(); + } + KeyCode::Enter => { + if app.show_menu { + if let Some(selected) = app.menu_state.selected() { + if let Some(cmd) = app.filtered_commands.get(selected) { + let name = cmd.name.to_string(); + app.show_menu = false; + app.input = TextArea::default(); + handle_command(&mut app, &name).await; } } - } - } else if app.show_model_menu { - if let Some(selected) = app.menu_state.selected() { - if let Some(ModelMenuItem::Model(model_info)) = app.filtered_models.get(selected).cloned() { - let provider_id = &model_info.provider_id; - let model_name = &model_info.name; - let mut config = app.orchestrator.config.lock().await; - let env_key = format!("{}_API_KEY", provider_id.to_uppercase().replace("-", "_")); - let api_key = std::env::var(env_key).ok().or_else(|| config.api_keys.get(provider_id).cloned()); - if let Some(key) = api_key { - config.model = model_name.clone(); - config.provider = provider_id.clone(); - config.recent_models.retain(|m| m.name != *model_name || m.provider_id != *provider_id); - config.recent_models.insert(0, model_info.clone()); - config.recent_models.truncate(3); - let _ = routecode_sdk::utils::storage::save_config(&config); - if app.provider_name.to_lowercase() != *provider_id { - let provider = routecode_sdk::agents::resolve_provider(provider_id, key); - app.provider_name = provider.name().to_string(); - app.current_provider_id = provider_id.clone(); - drop(config); - app.orchestrator.change_provider(provider).await; - } else { drop(config); } - app.current_model = model_name.clone(); - app.history.push(Message::system(format!("Switched to {} on {}", model_name, app.provider_name))); - app.show_model_menu = false; - } else { - app.history.push(Message::system(format!("Error: No API key for {}", provider_id))); + } else if app.show_provider_menu { + if let Some(selected) = app.menu_state.selected() { + if let Some(p) = PROVIDERS.get(selected) { + app.pending_provider_id = Some(p.id.to_string()); + app.is_inputting_api_key = true; + app.api_key_input = TextArea::default(); + app.show_provider_menu = false; + if p.id == "cloudflare-workers" || p.id == "cloudflare-gateway" { + app.api_key_input_stage = ApiKeyInputStage::CloudflareAccountId; + } else { + app.api_key_input_stage = ApiKeyInputStage::ApiKey; + } } } - } - } else if app.is_inputting_api_key { - let input_value = app.api_key_input.lines().join("\n").trim().to_string(); - if !input_value.is_empty() { - match app.api_key_input_stage { - ApiKeyInputStage::ApiKey => { - if let Some(provider_id) = app.pending_provider_id.take() { - let mut config = app.orchestrator.config.lock().await; - config.api_keys.insert(provider_id.clone(), input_value); + } else if app.show_model_menu { + if let Some(selected) = app.menu_state.selected() { + if let Some(ModelMenuItem::Model(model_info)) = app.filtered_models.get(selected).cloned() { + let provider_id = &model_info.provider_id; + let model_name = &model_info.name; + let mut config = app.orchestrator.config.lock().await; + let env_key = format!("{}_API_KEY", provider_id.to_uppercase().replace("-", "_")); + let api_key = std::env::var(env_key).ok().or_else(|| config.api_keys.get(provider_id).cloned()); + if let Some(key) = api_key { + config.model = model_name.clone(); + config.provider = provider_id.clone(); + config.recent_models.retain(|m| m.name != *model_name || m.provider_id != *provider_id); + config.recent_models.insert(0, model_info.clone()); + config.recent_models.truncate(3); let _ = routecode_sdk::utils::storage::save_config(&config); - app.history.push(Message::system(format!("API Key saved for {}", provider_id))); + if app.provider_name.to_lowercase() != *provider_id { + let provider = routecode_sdk::agents::resolve_provider(provider_id, key); + app.provider_name = provider.name().to_string(); + app.current_provider_id = provider_id.clone(); + drop(config); + app.orchestrator.change_provider(provider).await; + } else { drop(config); } + app.current_model = model_name.clone(); + app.history.push(Message::system(format!("Switched to {} on {}", model_name, app.provider_name))); + app.show_model_menu = false; + } else { + app.history.push(Message::system(format!("Error: No API key for {}", provider_id))); } - app.is_inputting_api_key = false; - app.api_key_input_stage = ApiKeyInputStage::None; } - ApiKeyInputStage::CloudflareAccountId => { - app.pending_account_id = Some(input_value); - app.api_key_input = TextArea::default(); - if app.pending_provider_id.as_deref() == Some("cloudflare-gateway") { - app.api_key_input_stage = ApiKeyInputStage::CloudflareGatewayId; - } else { app.api_key_input_stage = ApiKeyInputStage::CloudflareApiKey; } - } - ApiKeyInputStage::CloudflareGatewayId => { - app.pending_gateway_id = Some(input_value); - app.api_key_input = TextArea::default(); - app.api_key_input_stage = ApiKeyInputStage::CloudflareApiKey; - } - ApiKeyInputStage::CloudflareApiKey => { - if let Some(provider_id) = app.pending_provider_id.take() { - let account_id = app.pending_account_id.take().unwrap_or_default(); - let final_key = if provider_id == "cloudflare-gateway" { - let gateway_id = app.pending_gateway_id.take().unwrap_or_default(); - format!("{}:{}:{}", account_id, gateway_id, input_value) - } else { format!("{}:{}", account_id, input_value) }; - let mut config = app.orchestrator.config.lock().await; - config.api_keys.insert(provider_id.clone(), final_key); - let _ = routecode_sdk::utils::storage::save_config(&config); - app.history.push(Message::system(format!("Credentials saved for {}", provider_id))); + } + } else if app.is_inputting_api_key { + let input_value = app.api_key_input.lines().join("\n").trim().to_string(); + if !input_value.is_empty() { + match app.api_key_input_stage { + ApiKeyInputStage::ApiKey => { + if let Some(provider_id) = app.pending_provider_id.take() { + let mut config = app.orchestrator.config.lock().await; + config.api_keys.insert(provider_id.clone(), input_value); + let _ = routecode_sdk::utils::storage::save_config(&config); + app.history.push(Message::system(format!("API Key saved for {}", provider_id))); + } + app.is_inputting_api_key = false; + app.api_key_input_stage = ApiKeyInputStage::None; + } + ApiKeyInputStage::CloudflareAccountId => { + app.pending_account_id = Some(input_value); + app.api_key_input = TextArea::default(); + if app.pending_provider_id.as_deref() == Some("cloudflare-gateway") { + app.api_key_input_stage = ApiKeyInputStage::CloudflareGatewayId; + } else { app.api_key_input_stage = ApiKeyInputStage::CloudflareApiKey; } } - app.is_inputting_api_key = false; - app.api_key_input_stage = ApiKeyInputStage::None; + ApiKeyInputStage::CloudflareGatewayId => { + app.pending_gateway_id = Some(input_value); + app.api_key_input = TextArea::default(); + app.api_key_input_stage = ApiKeyInputStage::CloudflareApiKey; + } + ApiKeyInputStage::CloudflareApiKey => { + if let Some(provider_id) = app.pending_provider_id.take() { + let account_id = app.pending_account_id.take().unwrap_or_default(); + let final_key = if provider_id == "cloudflare-gateway" { + let gateway_id = app.pending_gateway_id.take().unwrap_or_default(); + format!("{}:{}:{}", account_id, gateway_id, input_value) + } else { format!("{}:{}", account_id, input_value) }; + let mut config = app.orchestrator.config.lock().await; + config.api_keys.insert(provider_id.clone(), final_key); + let _ = routecode_sdk::utils::storage::save_config(&config); + app.history.push(Message::system(format!("Credentials saved for {}", provider_id))); + } + app.is_inputting_api_key = false; + app.api_key_input_stage = ApiKeyInputStage::None; + } + _ => { app.is_inputting_api_key = false; } } - _ => { app.is_inputting_api_key = false; } + } else { + app.is_inputting_api_key = false; + app.api_key_input_stage = ApiKeyInputStage::None; } } else { - app.is_inputting_api_key = false; - app.api_key_input_stage = ApiKeyInputStage::None; - } - } else { - let input_text = app.input.lines().join("\n"); - if !input_text.trim().is_empty() { - if input_text.starts_with('/') { - handle_command(&mut app, &input_text).await; - } else { - app.history.push(Message::user(input_text.clone())); - app.prompt_history.push(input_text.clone()); - app.prompt_history_index = None; + let input_text = app.input.lines().join("\n"); + if !input_text.trim().is_empty() { + if input_text.starts_with('/') { + handle_command(&mut app, &input_text).await; + } else { + app.history.push(Message::user(input_text.clone())); + app.prompt_history.push(input_text.clone()); + app.prompt_history_index = None; + app.input = TextArea::default(); + app.screen = Screen::Session; + app.is_generating = true; + let orchestrator = app.orchestrator.clone(); + let mut history = app.history.clone(); + let model = app.current_model.clone(); + let tx = app.tx.clone(); + let task = tokio::spawn(async move { + let _ = orchestrator.run(&mut history, &model, Some(tx)).await; + }); + app.current_task = Some(task); + } app.input = TextArea::default(); - app.screen = Screen::Session; - app.is_generating = true; - let orchestrator = app.orchestrator.clone(); - let mut history = app.history.clone(); - let model = app.current_model.clone(); - let tx = app.tx.clone(); - let task = tokio::spawn(async move { - let _ = orchestrator.run(&mut history, &model, Some(tx)).await; - }); - app.current_task = Some(task); } - app.input = TextArea::default(); } } - } - KeyCode::Esc => { - if app.show_menu { app.show_menu = false; } - else if app.show_provider_menu { app.show_provider_menu = false; } - else if app.show_model_menu { app.show_model_menu = false; } - else if app.is_inputting_api_key { - app.is_inputting_api_key = false; - app.api_key_input_stage = ApiKeyInputStage::None; - app.pending_account_id = None; - app.pending_gateway_id = None; - } else if app.is_generating { - if let Some(handle) = app.current_task.take() { handle.abort(); } - app.is_generating = false; - app.active_tool = None; - } else { - if !app.history.is_empty() { - let session = routecode_sdk::utils::storage::Session { - messages: app.history.clone(), - model: app.current_model.clone(), - usage: app.orchestrator.usage.lock().await.clone(), - timestamp: chrono::Utc::now().timestamp(), - }; - let _ = routecode_sdk::utils::storage::save_session("last_session", &session); + KeyCode::Esc => { + if app.show_menu { app.show_menu = false; } + else if app.show_provider_menu { app.show_provider_menu = false; } + else if app.show_model_menu { app.show_model_menu = false; } + else if app.is_inputting_api_key { + app.is_inputting_api_key = false; + app.api_key_input_stage = ApiKeyInputStage::None; + app.pending_account_id = None; + app.pending_gateway_id = None; + } else if app.is_generating { + if let Some(handle) = app.current_task.take() { handle.abort(); } + app.is_generating = false; + app.active_tool = None; + } else { + if !app.history.is_empty() { + let session = routecode_sdk::utils::storage::Session { + messages: app.history.clone(), + model: app.current_model.clone(), + usage: app.orchestrator.usage.lock().await.clone(), + timestamp: chrono::Utc::now().timestamp(), + }; + let _ = routecode_sdk::utils::storage::save_session("last_session", &session); + } + return Ok(()); } - return Ok(()); } - } - KeyCode::Up => { - if app.show_menu || app.show_provider_menu || app.show_model_menu { - let items_len = if app.show_menu { app.filtered_commands.len() } - else if app.show_provider_menu { PROVIDERS.len() } - else { app.filtered_models.len() }; - if items_len > 0 { - let selected = app.menu_state.selected().unwrap_or(0); - let mut new_selected = if selected == 0 { items_len - 1 } else { selected - 1 }; - if app.show_model_menu { - while let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(new_selected) { - new_selected = if new_selected == 0 { items_len - 1 } else { new_selected - 1 }; - if new_selected == selected { break; } + KeyCode::Up => { + if app.show_menu || app.show_provider_menu || app.show_model_menu { + let items_len = if app.show_menu { app.filtered_commands.len() } + else if app.show_provider_menu { PROVIDERS.len() } + else { app.filtered_models.len() }; + if items_len > 0 { + let selected = app.menu_state.selected().unwrap_or(0); + let mut new_selected = if selected == 0 { items_len - 1 } else { selected - 1 }; + if app.show_model_menu { + while let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(new_selected) { + new_selected = if new_selected == 0 { items_len - 1 } else { new_selected - 1 }; + if new_selected == selected { break; } + } } + app.menu_state.select(Some(new_selected)); } - app.menu_state.select(Some(new_selected)); + } else if key.modifiers.contains(event::KeyModifiers::SHIFT) { + app.history_scroll = app.history_scroll.saturating_sub(1); + } else { + let (row, _) = app.input.cursor(); + if row == 0 && !app.prompt_history.is_empty() { + let idx = match app.prompt_history_index { + Some(i) => if i == 0 { 0 } else { i - 1 }, + None => app.prompt_history.len() - 1, + }; + app.prompt_history_index = Some(idx); + let prev = app.prompt_history[idx].clone(); + app.input = TextArea::from(prev.lines().map(|s| s.to_string())); + app.input.move_cursor(tui_textarea::CursorMove::End); + } else { app.input.input(key); } } - } else if key.modifiers.contains(event::KeyModifiers::SHIFT) { - app.history_scroll = app.history_scroll.saturating_sub(1); - } else { - let (row, _) = app.input.cursor(); - if row == 0 && !app.prompt_history.is_empty() { - let idx = match app.prompt_history_index { - Some(i) => if i == 0 { 0 } else { i - 1 }, - None => app.prompt_history.len() - 1, - }; - app.prompt_history_index = Some(idx); - let prev = app.prompt_history[idx].clone(); - app.input = TextArea::from(prev.lines().map(|s| s.to_string())); - app.input.move_cursor(tui_textarea::CursorMove::End); - } else { app.input.input(key); } } - } - KeyCode::Down => { - if app.show_menu || app.show_provider_menu || app.show_model_menu { - let items_len = if app.show_menu { app.filtered_commands.len() } - else if app.show_provider_menu { PROVIDERS.len() } - else { app.filtered_models.len() }; - if items_len > 0 { - let selected = app.menu_state.selected().unwrap_or(0); - let mut new_selected = if selected >= items_len - 1 { 0 } else { selected + 1 }; - if app.show_model_menu { - while let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(new_selected) { - new_selected = if new_selected >= items_len - 1 { 0 } else { new_selected + 1 }; - if new_selected == selected { break; } + KeyCode::Down => { + if app.show_menu || app.show_provider_menu || app.show_model_menu { + let items_len = if app.show_menu { app.filtered_commands.len() } + else if app.show_provider_menu { PROVIDERS.len() } + else { app.filtered_models.len() }; + if items_len > 0 { + let selected = app.menu_state.selected().unwrap_or(0); + let mut new_selected = if selected >= items_len - 1 { 0 } else { selected + 1 }; + if app.show_model_menu { + while let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(new_selected) { + new_selected = if new_selected >= items_len - 1 { 0 } else { new_selected + 1 }; + if new_selected == selected { break; } + } } + app.menu_state.select(Some(new_selected)); } - app.menu_state.select(Some(new_selected)); + } else if key.modifiers.contains(event::KeyModifiers::SHIFT) { + app.history_scroll = app.history_scroll.saturating_add(1); + } else { + let (row, _) = app.input.cursor(); + let lines_len = app.input.lines().len(); + if row >= lines_len - 1 && app.prompt_history_index.is_some() { + let idx = app.prompt_history_index.unwrap(); + if idx >= app.prompt_history.len() - 1 { + app.prompt_history_index = None; + app.input = TextArea::default(); + } else { + let new_idx = idx + 1; + app.prompt_history_index = Some(new_idx); + let next = app.prompt_history[new_idx].clone(); + app.input = TextArea::from(next.lines().map(|s| s.to_string())); + app.input.move_cursor(tui_textarea::CursorMove::End); + } + } else { app.input.input(key); } } - } else if key.modifiers.contains(event::KeyModifiers::SHIFT) { - app.history_scroll = app.history_scroll.saturating_add(1); - } else { - let (row, _) = app.input.cursor(); - let lines_len = app.input.lines().len(); - if row >= lines_len - 1 && app.prompt_history_index.is_some() { - let idx = app.prompt_history_index.unwrap(); - if idx >= app.prompt_history.len() - 1 { - app.prompt_history_index = None; - app.input = TextArea::default(); - } else { - let new_idx = idx + 1; - app.prompt_history_index = Some(new_idx); - let next = app.prompt_history[new_idx].clone(); - app.input = TextArea::from(next.lines().map(|s| s.to_string())); - app.input.move_cursor(tui_textarea::CursorMove::End); - } - } else { app.input.input(key); } } - } - KeyCode::Right if app.show_model_menu => { - let len = app.filtered_models.len(); - if len > 0 { - let current = app.menu_state.selected().unwrap_or(0); - let mut next_header_idx = None; - for i in (current + 1)..len { - if let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(i) { - next_header_idx = Some(i); break; - } - } - if next_header_idx.is_none() { - for i in 0..current { + KeyCode::Right if app.show_model_menu => { + let len = app.filtered_models.len(); + if len > 0 { + let current = app.menu_state.selected().unwrap_or(0); + let mut next_header_idx = None; + for i in (current + 1)..len { if let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(i) { next_header_idx = Some(i); break; } } - } - if let Some(h_idx) = next_header_idx { - let mut target = (h_idx + 1) % len; - while let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(target) { - target = (target + 1) % len; - if target == h_idx { break; } + if next_header_idx.is_none() { + for i in 0..current { + if let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(i) { + next_header_idx = Some(i); break; + } + } + } + if let Some(h_idx) = next_header_idx { + let mut target = (h_idx + 1) % len; + while let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(target) { + target = (target + 1) % len; + if target == h_idx { break; } + } + app.menu_state.select(Some(target)); } - app.menu_state.select(Some(target)); } } - } - KeyCode::Left if app.show_model_menu => { - let len = app.filtered_models.len(); - if len > 0 { - let current = app.menu_state.selected().unwrap_or(0); - let mut headers = Vec::new(); - for (i, item) in app.filtered_models.iter().enumerate() { - if let ModelMenuItem::Header(_) = item { headers.push(i); } - } - if !headers.is_empty() { - let current_header_idx_in_headers = headers.iter().enumerate().rev().find(|(_, &h_idx)| h_idx < current).map(|(i, _)| i); - let target_header_idx = match current_header_idx_in_headers { - Some(i) => if i == 0 { *headers.last().unwrap() } else { headers[i - 1] }, - None => *headers.last().unwrap() - }; - let mut target = (target_header_idx + 1) % len; - while let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(target) { - target = (target + 1) % len; - if target == target_header_idx { break; } + KeyCode::Left if app.show_model_menu => { + let len = app.filtered_models.len(); + if len > 0 { + let current = app.menu_state.selected().unwrap_or(0); + let mut headers = Vec::new(); + for (i, item) in app.filtered_models.iter().enumerate() { + if let ModelMenuItem::Header(_) = item { headers.push(i); } + } + if !headers.is_empty() { + let current_header_idx_in_headers = headers.iter().enumerate().rev().find(|(_, &h_idx)| h_idx < current).map(|(i, _)| i); + let target_header_idx = match current_header_idx_in_headers { + Some(i) => if i == 0 { *headers.last().unwrap() } else { headers[i - 1] }, + None => *headers.last().unwrap() + }; + let mut target = (target_header_idx + 1) % len; + while let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(target) { + target = (target + 1) % len; + if target == target_header_idx { break; } + } + app.menu_state.select(Some(target)); } - app.menu_state.select(Some(target)); } } - } - KeyCode::Char('f') if key.modifiers.contains(event::KeyModifiers::CONTROL) && app.show_model_menu => { - if let Some(selected) = app.menu_state.selected() { - if let Some(ModelMenuItem::Model(model_info)) = app.filtered_models.get(selected).cloned() { - let mut config = app.orchestrator.config.lock().await; - if config.favorites.iter().any(|m| m.name == model_info.name && m.provider_id == model_info.provider_id) { - config.favorites.retain(|m| m.name != model_info.name || m.provider_id != model_info.provider_id); - app.history.push(Message::system(format!("Removed {} from favorites", model_info.name))); - } else { - config.favorites.push(model_info.clone()); - app.history.push(Message::system(format!("Added {} to favorites", model_info.name))); + KeyCode::Char('f') if key.modifiers.contains(event::KeyModifiers::CONTROL) && app.show_model_menu => { + if let Some(selected) = app.menu_state.selected() { + if let Some(ModelMenuItem::Model(model_info)) = app.filtered_models.get(selected).cloned() { + let mut config = app.orchestrator.config.lock().await; + if config.favorites.iter().any(|m| m.name == model_info.name && m.provider_id == model_info.provider_id) { + config.favorites.retain(|m| m.name != model_info.name || m.provider_id != model_info.provider_id); + app.history.push(Message::system(format!("Removed {} from favorites", model_info.name))); + } else { + config.favorites.push(model_info.clone()); + app.history.push(Message::system(format!("Added {} to favorites", model_info.name))); + } + let _ = routecode_sdk::utils::storage::save_config(&config); } - let _ = routecode_sdk::utils::storage::save_config(&config); } } - } - _ => { - let event = event::Event::Key(key); - if app.is_inputting_api_key { - app.api_key_input.input(event); - } else if app.show_model_menu { - if app.model_search_input.input(event) { - let search = app.model_search_input.lines()[0].to_lowercase().trim().to_string(); - handle_model_search(&mut app, &search, true).await; + _ => { + let event = event::Event::Key(key); + if app.is_inputting_api_key { + app.api_key_input.input(event); + } else if app.show_model_menu { + if app.model_search_input.input(event) { + let search = app.model_search_input.lines()[0].to_lowercase().trim().to_string(); + handle_model_search(&mut app, &search, true).await; + } + } else { + app.input.input(event); + app.update_filtered_commands(); } - } else { - app.input.input(event); - app.update_filtered_commands(); } } } } + Event::Mouse(mouse) => { + match mouse.kind { + MouseEventKind::ScrollUp => { app.history_scroll = app.history_scroll.saturating_sub(2); } + MouseEventKind::ScrollDown => { app.history_scroll = app.history_scroll.saturating_add(2); } + _ => {} + } + } + _ => {} } } @@ -1018,7 +1028,22 @@ fn ui_session(f: &mut Frame, app: &mut App, usage: &Usage, area: Rect) -> Rect { .constraints([Constraint::Min(1), Constraint::Length(input_height), Constraint::Length(1)]) .split(area); - let history = render_history(app); + let history = render_history(&app.history); + + // 1. Auto-scroll logic + let mut total_height = 0; + let available_width = chunks[0].width.saturating_sub(4).max(1); // Account for some margin + for line in &history.lines { + let line_width: usize = line.spans.iter().map(|s| s.content.len()).sum(); + let wrapped_height = (line_width as u16 / available_width) + 1; + total_height += wrapped_height; + } + + // Pin to bottom if generating + if app.is_generating { + app.history_scroll = total_height.saturating_sub(chunks[0].height); + } + f.render_widget(Paragraph::new(history).wrap(Wrap { trim: false }).scroll((app.history_scroll, 0)), chunks[0]); f.render_widget(Block::default().style(Style::default().bg(COLOR_INPUT_BG)), chunks[1]); let inner_input_area = Rect::new(chunks[1].x + 1, chunks[1].y + 1, chunks[1].width.saturating_sub(2), chunks[1].height.saturating_sub(2)); @@ -1044,9 +1069,9 @@ fn ui_session(f: &mut Frame, app: &mut App, usage: &Usage, area: Rect) -> Rect { chunks[1] } -fn render_history(app: &App) -> Text<'_> { +fn render_history(history: &[Message]) -> Text<'_> { let mut lines = Vec::new(); - for m in &app.history { + for m in history { match m.role { Role::User => { lines.push(Line::from(vec![Span::styled(" ● User", Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD))])); @@ -1061,7 +1086,18 @@ fn render_history(app: &App) -> Text<'_> { } if let Some(tool_calls) = &m.tool_calls { for tc in tool_calls { - lines.push(Line::from(vec![Span::styled(" 🛠 ", Style::default().fg(COLOR_PRIMARY)), Span::styled(format!("Using {} ", tc.function.name), Style::default().fg(COLOR_TEXT)), Span::styled(format!("({})", tc.function.arguments), Style::default().fg(COLOR_SECONDARY).add_modifier(Modifier::DIM))])); + let args: serde_json::Value = serde_json::from_str(&tc.function.arguments).unwrap_or(serde_json::json!({})); + let arg_preview = if let Some(path) = args["path"].as_str() { + format!("({})", path) + } else { + format!("({})", tc.function.name) + }; + + lines.push(Line::from(vec![ + Span::styled(" 🛠 ", Style::default().fg(COLOR_PRIMARY)), + Span::styled(format!("Using {} ", tc.function.name), Style::default().fg(COLOR_TEXT)), + Span::styled(arg_preview, Style::default().fg(COLOR_SECONDARY).add_modifier(Modifier::DIM)), + ])); } } if let Some(content) = &m.content { @@ -1074,8 +1110,28 @@ fn render_history(app: &App) -> Text<'_> { Role::Tool => { lines.push(Line::from(vec![Span::styled(format!(" ✓ Tool ({})", m.name.as_deref().unwrap_or("result")), Style::default().fg(COLOR_SECONDARY))])); if let Some(content) = &m.content { - let preview = if content.len() > 100 { format!("{}...", &content[..100]) } else { content.clone() }; - lines.push(Line::from(vec![Span::styled(format!(" {}", preview), Style::default().fg(COLOR_DIM).add_modifier(Modifier::DIM))])); + if let Ok(res) = serde_json::from_str::(content) { + if let Some(diff) = res.diff { + for line in diff.lines() { + let style = if line.starts_with('+') { + Style::default().fg(COLOR_SUCCESS) + } else if line.starts_with('-') { + Style::default().fg(Color::Red) + } else { + Style::default().fg(COLOR_DIM) + }; + lines.push(Line::from(vec![Span::raw(" "), Span::styled(line.to_string(), style)])); + } + } else if let Some(out) = res.content { + let preview = if out.len() > 100 { format!("{}...", &out[..100]) } else { out }; + lines.push(Line::from(vec![Span::styled(format!(" {}", preview), Style::default().fg(COLOR_DIM).add_modifier(Modifier::DIM))])); + } else if let Some(err) = res.error { + lines.push(Line::from(vec![Span::styled(format!(" Error: {}", err), Style::default().fg(Color::Red))])); + } + } else { + let preview = if content.len() > 100 { format!("{}...", &content[..100]) } else { content.clone() }; + lines.push(Line::from(vec![Span::styled(format!(" {}", preview), Style::default().fg(COLOR_DIM).add_modifier(Modifier::DIM))])); + } } } Role::System => { diff --git a/libs/sdk/Cargo.toml b/libs/sdk/Cargo.toml index 4766ecb..f9143c4 100644 --- a/libs/sdk/Cargo.toml +++ b/libs/sdk/Cargo.toml @@ -22,6 +22,7 @@ chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.8", features = ["v4", "serde"] } log = "0.4" env_logger = "0.11" +similar = "2.5" [dev-dependencies] tempfile = "3.10" diff --git a/libs/sdk/src/core/orchestrator.rs b/libs/sdk/src/core/orchestrator.rs index e2e686c..a52f0f3 100644 --- a/libs/sdk/src/core/orchestrator.rs +++ b/libs/sdk/src/core/orchestrator.rs @@ -82,6 +82,8 @@ impl AgentOrchestrator { let tools = Some(self.tool_registry.get_all_schemas()); let messages = self.prepare_messages(history).await; + log::debug!("Sending AI request to model: {} (messages: {})", model, messages.len()); + let stream = { let p = self.provider.lock().await; p.ask(messages, model, tools).await? @@ -95,6 +97,7 @@ impl AgentOrchestrator { while let Some(chunk_res) = stream.next().await { let chunk = chunk_res?; + log::debug!("Received chunk: {:?}", chunk); if let Some(ref tx) = tx { if let Err(e) = tx.send(chunk.clone()) { diff --git a/libs/sdk/src/core/tool_result.rs b/libs/sdk/src/core/tool_result.rs index f368f0f..dcd8156 100644 --- a/libs/sdk/src/core/tool_result.rs +++ b/libs/sdk/src/core/tool_result.rs @@ -5,6 +5,8 @@ pub struct ToolResult { pub success: bool, pub content: Option, pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub diff: Option, } impl ToolResult { @@ -13,14 +15,21 @@ impl ToolResult { success: true, content: Some(content.into()), error: None, + diff: None, } } + pub fn with_diff(mut self, diff: String) -> Self { + self.diff = Some(diff); + self + } + pub fn error(error: impl Into) -> Self { Self { success: false, content: None, error: Some(error.into()), + diff: None, } } } diff --git a/libs/sdk/src/tools/file_ops.rs b/libs/sdk/src/tools/file_ops.rs index ef530d7..9478dc5 100644 --- a/libs/sdk/src/tools/file_ops.rs +++ b/libs/sdk/src/tools/file_ops.rs @@ -3,6 +3,44 @@ use crate::tools::traits::Tool; use async_trait::async_trait; use serde_json::{json, Value}; use std::fs; +use std::path::{Path, PathBuf}; +use similar::{ChangeTag, TextDiff}; + +fn normalize_path(path: &str) -> PathBuf { + let mut p = path; + if p.starts_with("/workspace/") { + p = &p[11..]; + } else if p.starts_with("/workspace") { + p = &p[10..]; + } else if p.starts_with("/") { + p = &p[1..]; + } + PathBuf::from(p) +} + +fn ensure_parent_dir(path: &Path) -> Result<(), std::io::Error> { + if let Some(parent) = path.parent() { + if !parent.exists() && !parent.as_os_str().is_empty() { + fs::create_dir_all(parent)?; + } + } + Ok(()) +} + +fn generate_diff(old: &str, new: &str) -> String { + let mut diff_str = String::new(); + let diff = TextDiff::from_lines(old, new); + + for change in diff.iter_all_changes() { + let sign = match change.tag() { + ChangeTag::Delete => "-", + ChangeTag::Insert => "+", + ChangeTag::Equal => " ", + }; + diff_str.push_str(&format!("{}{}", sign, change)); + } + diff_str +} pub struct FileReadTool; @@ -25,12 +63,13 @@ impl Tool for FileReadTool { } async fn execute(&self, args: Value) -> Result { - let path = args["path"] + let raw_path = args["path"] .as_str() .ok_or_else(|| anyhow::anyhow!("Missing path"))?; - match fs::read_to_string(path) { + let path = normalize_path(raw_path); + match fs::read_to_string(&path) { Ok(content) => Ok(ToolResult::success(content)), - Err(e) => Ok(ToolResult::error(format!("Failed to read file: {}", e))), + Err(e) => Ok(ToolResult::error(format!("Failed to read file '{}': {}", path.display(), e))), } } } @@ -57,15 +96,24 @@ impl Tool for FileWriteTool { } async fn execute(&self, args: Value) -> Result { - let path = args["path"] + let raw_path = args["path"] .as_str() .ok_or_else(|| anyhow::anyhow!("Missing path"))?; let content = args["content"] .as_str() .ok_or_else(|| anyhow::anyhow!("Missing content"))?; - match fs::write(path, content) { - Ok(_) => Ok(ToolResult::success("File written successfully")), - Err(e) => Ok(ToolResult::error(format!("Failed to write file: {}", e))), + + let path = normalize_path(raw_path); + let old_content = fs::read_to_string(&path).unwrap_or_default(); + let diff = generate_diff(&old_content, content); + + if let Err(e) = ensure_parent_dir(&path) { + return Ok(ToolResult::error(format!("Failed to create directories for '{}': {}", path.display(), e))); + } + + match fs::write(&path, content) { + Ok(_) => Ok(ToolResult::success(format!("File '{}' written successfully", path.display())).with_diff(diff)), + Err(e) => Ok(ToolResult::error(format!("Failed to write file '{}': {}", path.display(), e))), } } } @@ -94,7 +142,7 @@ impl Tool for FileEditTool { } async fn execute(&self, args: Value) -> Result { - let path = args["path"] + let raw_path = args["path"] .as_str() .ok_or_else(|| anyhow::anyhow!("Missing path"))?; let old_string = args["old_string"] @@ -105,20 +153,21 @@ impl Tool for FileEditTool { .ok_or_else(|| anyhow::anyhow!("Missing new_string"))?; let allow_multiple = args["allow_multiple"].as_bool().unwrap_or(false); - let content = match fs::read_to_string(path) { + let path = normalize_path(raw_path); + let content = match fs::read_to_string(&path) { Ok(c) => c, - Err(e) => return Ok(ToolResult::error(format!("Failed to read file: {}", e))), + Err(e) => return Ok(ToolResult::error(format!("Failed to read file '{}': {}", path.display(), e))), }; let matches = content.matches(old_string).count(); if matches == 0 { return Ok(ToolResult::error(format!( "Could not find exact match for 'old_string' in {}", - path + path.display() ))); } if matches > 1 && !allow_multiple { - return Ok(ToolResult::error(format!("Found {} occurrences of 'old_string', but 'allow_multiple' is false. Please provide more context.", matches))); + return Ok(ToolResult::error(format!("Found {} occurrences of 'old_string' in {}, but 'allow_multiple' is false. Please provide more context.", matches, path.display()))); } let new_content = if allow_multiple { @@ -127,12 +176,14 @@ impl Tool for FileEditTool { content.replacen(old_string, new_string, 1) }; - match fs::write(path, new_content) { + let diff = generate_diff(old_string, new_string); + + match fs::write(&path, new_content) { Ok(_) => Ok(ToolResult::success(format!( "Successfully replaced {} occurrence(s) in {}", - matches, path - ))), - Err(e) => Ok(ToolResult::error(format!("Failed to write file: {}", e))), + matches, path.display() + )).with_diff(diff)), + Err(e) => Ok(ToolResult::error(format!("Failed to write file '{}': {}", path.display(), e))), } } } diff --git a/libs/sdk/src/utils/storage.rs b/libs/sdk/src/utils/storage.rs index e90292c..fd18a04 100644 --- a/libs/sdk/src/utils/storage.rs +++ b/libs/sdk/src/utils/storage.rs @@ -12,7 +12,7 @@ pub struct Session { pub timestamp: i64, } -fn get_base_dir() -> PathBuf { +pub fn get_base_dir() -> PathBuf { dirs::home_dir() .map(|p| p.join(".routecode")) .unwrap_or_else(|| PathBuf::from(".routecode"))