diff --git a/Cargo.lock b/Cargo.lock index a264e3d..43b8b1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3521,7 +3521,7 @@ dependencies = [ [[package]] name = "routecode-cli" -version = "0.1.9" +version = "0.1.10" dependencies = [ "anyhow", "async-trait", @@ -3543,7 +3543,7 @@ dependencies = [ [[package]] name = "routecode-sdk" -version = "0.1.9" +version = "0.1.10" dependencies = [ "anyhow", "async-stream", diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index 4e365d1..417a4ef 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -39,7 +39,11 @@ pub struct Cli { /// Override the retry policy for this run. Accepted values: /// `disabled`, `qir`, `exponential_backoff`. Does not persist to /// config.json. Takes precedence over `--qir` if both are set. - #[arg(long, value_name = "STRATEGY", help = "Retry policy for this run: disabled | qir | exponential_backoff")] + #[arg( + long, + value_name = "STRATEGY", + help = "Retry policy for this run: disabled | qir | exponential_backoff" + )] pub retry_policy: Option, #[command(subcommand)] @@ -55,27 +59,26 @@ pub enum Commands { mod ui; use crossterm::{ - event::{EnableBracketedPaste, DisableBracketedPaste, EnableMouseCapture, DisableMouseCapture}, + event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{backend::CrosstermBackend, Terminal}; use routecode_sdk::core::AgentOrchestrator; use routecode_sdk::tools::bash::BashTool; -use routecode_sdk::tools::file_ops::{FileEditTool, FileReadTool, FileWriteTool, ApplyPatchTool}; +use routecode_sdk::tools::file_ops::{ApplyPatchTool, FileEditTool, FileReadTool, FileWriteTool}; use routecode_sdk::tools::lsp_tool::LspTool; use routecode_sdk::tools::mcp::manager::McpManager; use routecode_sdk::tools::navigation::{GrepTool, LsTool, TreeTool}; use routecode_sdk::tools::subagent::SubAgentTool; use routecode_sdk::tools::web::{fetch::WebFetchTool, search::WebSearchTool}; use routecode_sdk::tools::ToolRegistry; +use simplelog::{CombinedLogger, ConfigBuilder, LevelFilter, SharedLogger, WriteLogger}; use std::io; use std::process::Command; use std::sync::Arc; use tokio::sync::Mutex; use ui::{run_app, App}; -use simplelog::{CombinedLogger, ConfigBuilder, LevelFilter, SharedLogger, WriteLogger}; - fn restore_terminal() { use crossterm::terminal::disable_raw_mode; @@ -132,15 +135,20 @@ async fn main() -> anyhow::Result<()> { } let log_path = base_dir.join("routecode.log"); - let log_level = if cli.debug { LevelFilter::Debug } else { LevelFilter::Info }; + let log_level = if cli.debug { + LevelFilter::Debug + } else { + LevelFilter::Info + }; - let loggers: Vec> = vec![ - WriteLogger::new( - log_level, - ConfigBuilder::default().set_time_format_rfc3339().build(), - std::fs::OpenOptions::new().create(true).append(true).open(&log_path)?, - ), - ]; + let loggers: Vec> = vec![WriteLogger::new( + log_level, + ConfigBuilder::default().set_time_format_rfc3339().build(), + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path)?, + )]; CombinedLogger::init(loggers)?; @@ -159,14 +167,27 @@ async fn main() -> anyhow::Result<()> { #[cfg(target_os = "windows")] { Command::new("cmd") - .args(["/C", "start", "powershell", "-NoExit", "-Command", &format!("Get-Content -Path \"{}\" -Wait", log_path.display())]) + .args([ + "/C", + "start", + "powershell", + "-NoExit", + "-Command", + &format!("Get-Content -Path \"{}\" -Wait", log_path.display()), + ]) .spawn() .map(|_| ()) } #[cfg(target_os = "macos")] { Command::new("osascript") - .args(["-e", &format!("tell application \"Terminal\" to do script \"tail -f '{}'\"", log_path.display())]) + .args([ + "-e", + &format!( + "tell application \"Terminal\" to do script \"tail -f '{}'\"", + log_path.display() + ), + ]) .spawn() .map(|_| ()) } @@ -206,7 +227,10 @@ async fn main() -> anyhow::Result<()> { let json = std::fs::read_to_string(path) .map_err(|e| anyhow::anyhow!("Failed to read '{}': {}", path.display(), e))?; let session: routecode_sdk::utils::storage::Session = serde_json::from_str(&json)?; - let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("imported"); + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("imported"); routecode_sdk::utils::storage::save_session(name, &session)?; if let Ok(mut config) = routecode_sdk::utils::storage::load_session_config(name) { config.allow_all_commands = false; @@ -267,7 +291,10 @@ async fn main() -> anyhow::Result<()> { // Initialize MCP Manager and load dynamic tools let mcp_manager = McpManager::new(); - if let Err(e) = mcp_manager.load_and_register_tools(&mut tool_registry).await { + if let Err(e) = mcp_manager + .load_and_register_tools(&mut tool_registry) + .await + { eprintln!("Warning: Failed to load MCP tools: {}", e); } @@ -292,7 +319,12 @@ async fn main() -> anyhow::Result<()> { // Setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste)?; + execute!( + stdout, + EnterAlternateScreen, + EnableMouseCapture, + EnableBracketedPaste + )?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; @@ -304,16 +336,19 @@ async fn main() -> anyhow::Result<()> { let mut config_clone = config.clone(); let update_handle = tokio::spawn(async move { if routecode_sdk::update::should_check(config_clone.last_update_check, 24) { - match routecode_sdk::update::check_for_update(¤t_version, "anasx07/routecode").await { + match routecode_sdk::update::check_for_update(¤t_version, "anasx07/routecode") + .await + { Ok(info) => { config_clone.last_update_check = routecode_sdk::update::now_timestamp(); let _ = routecode_sdk::utils::storage::save_config(&config_clone); if info.is_update_available { - let _ = tx.send(routecode_sdk::agents::types::StreamChunk::UpdateAvailable { - version: info.version, - changelog: info.changelog, - published_at: info.published_at, - }); + let _ = + tx.send(routecode_sdk::agents::types::StreamChunk::UpdateAvailable { + version: info.version, + changelog: info.changelog, + published_at: info.published_at, + }); } } Err(e) => { @@ -322,7 +357,7 @@ async fn main() -> anyhow::Result<()> { } } }); - + let models_handle = tokio::spawn(async move { if let Err(e) = routecode_sdk::utils::models::fetch_and_cache_models().await { log::warn!("Failed to fetch models in background: {}", e); @@ -339,18 +374,32 @@ async fn main() -> anyhow::Result<()> { let mut u = app.orchestrator.usage.lock().await; *u = session.usage; app.session_id = resume_name.clone(); - if let Ok(config) = routecode_sdk::utils::storage::load_session_config(&resume_name) { - app.orchestrator.allow_session_commands.store(config.allow_all_commands, std::sync::atomic::Ordering::SeqCst); - app.orchestrator.allow_session_outside_access.store(config.allow_all_outside_access, std::sync::atomic::Ordering::SeqCst); + if let Ok(config) = routecode_sdk::utils::storage::load_session_config(&resume_name) + { + app.orchestrator.allow_session_commands.store( + config.allow_all_commands, + std::sync::atomic::Ordering::SeqCst, + ); + app.orchestrator.allow_session_outside_access.store( + config.allow_all_outside_access, + std::sync::atomic::Ordering::SeqCst, + ); } } - Err(e) => app.history.push(routecode_sdk::core::Message::system(format!("Failed to resume session '{}': {}", resume_name, e))), + Err(e) => app + .history + .push(routecode_sdk::core::Message::system(format!( + "Failed to resume session '{}': {}", + resume_name, e + ))), } } if let Ok(workspace_config) = routecode_sdk::utils::storage::load_workspace_config() { if workspace_config.allow_all_outside_access { - app.orchestrator.allow_session_outside_access.store(true, std::sync::atomic::Ordering::SeqCst); + app.orchestrator + .allow_session_outside_access + .store(true, std::sync::atomic::Ordering::SeqCst); } } @@ -431,8 +480,12 @@ async fn main() -> anyhow::Result<()> { } // Don't block shutdown on slow update checks — timeout after 1 second - tokio::time::timeout(std::time::Duration::from_secs(1), update_handle).await.ok(); - tokio::time::timeout(std::time::Duration::from_secs(1), models_handle).await.ok(); + tokio::time::timeout(std::time::Duration::from_secs(1), update_handle) + .await + .ok(); + tokio::time::timeout(std::time::Duration::from_secs(1), models_handle) + .await + .ok(); Ok(()) } @@ -463,7 +516,10 @@ mod tests { #[test] fn override_disabled_from_policy() { let cli = cli_with(&["--retry-policy", "disabled"]); - assert_eq!(apply_retry_policy_override(&cli), Some(RetryPolicy::Disabled)); + assert_eq!( + apply_retry_policy_override(&cli), + Some(RetryPolicy::Disabled) + ); } #[test] @@ -477,7 +533,11 @@ mod tests { let cli = cli_with(&["--retry-policy", "exponential_backoff"]); let p = apply_retry_policy_override(&cli).unwrap(); match p { - RetryPolicy::ExponentialBackoff { max_attempts, base_secs, jitter } => { + RetryPolicy::ExponentialBackoff { + max_attempts, + base_secs, + jitter, + } => { assert!(max_attempts > 0); assert!(base_secs > 0.0); assert!(jitter); @@ -490,7 +550,10 @@ mod tests { fn override_policy_wins_over_qir_flag() { let cli = cli_with(&["--qir", "--retry-policy", "disabled"]); // --retry-policy is more specific, so it wins. - assert_eq!(apply_retry_policy_override(&cli), Some(RetryPolicy::Disabled)); + assert_eq!( + apply_retry_policy_override(&cli), + Some(RetryPolicy::Disabled) + ); } #[test] diff --git a/apps/cli/src/ui/app.rs b/apps/cli/src/ui/app.rs index 1dd3ef0..f2e59f3 100644 --- a/apps/cli/src/ui/app.rs +++ b/apps/cli/src/ui/app.rs @@ -3,10 +3,12 @@ use routecode_sdk::agents::StreamChunk; use routecode_sdk::core::{AgentOrchestrator, DynamicModelInfo, Message}; use routecode_sdk::utils::costs::Usage; use std::sync::Arc; -use tui_textarea::TextArea; use tokio::task::JoinSet; +use tui_textarea::TextArea; -use super::types::{ApprovalMode, Command, QirStatus, Screen, SettingsMenuItem, COMMANDS, ApiKeyInputStage}; +use super::types::{ + ApiKeyInputStage, ApprovalMode, Command, QirStatus, Screen, SettingsMenuItem, COMMANDS, +}; pub struct App { pub screen: Screen, @@ -88,7 +90,11 @@ pub struct App { } impl App { - pub fn new(orchestrator: Arc, provider_name: String, default_model: String) -> Self { + pub fn new( + orchestrator: Arc, + provider_name: String, + default_model: String, + ) -> Self { let mut input = TextArea::default(); input.set_cursor_line_style(Style::default()); input.set_placeholder_style(Style::default().fg(super::components::COLOR_SECONDARY)); @@ -101,7 +107,8 @@ impl App { let mut model_search_input = TextArea::default(); model_search_input.set_cursor_line_style(Style::default()); model_search_input.set_placeholder_text(" Search models..."); - model_search_input.set_placeholder_style(Style::default().fg(super::components::COLOR_SECONDARY)); + model_search_input + .set_placeholder_style(Style::default().fg(super::components::COLOR_SECONDARY)); let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); @@ -207,7 +214,12 @@ impl App { }, SettingsMenuItem::Option { name: "Show Context Summary".to_string(), - val: if self.hide_context_summary { "hide" } else { "show" }.to_string(), + val: if self.hide_context_summary { + "hide" + } else { + "show" + } + .to_string(), key: "hide_context_summary".to_string(), }, SettingsMenuItem::Option { @@ -218,14 +230,24 @@ impl App { SettingsMenuItem::Header("Advanced".to_string()), SettingsMenuItem::Option { name: "Enable Sub-Agents".to_string(), - val: if config.sub_agents_enabled { "on" } else { "off" }.to_string(), + val: if config.sub_agents_enabled { + "on" + } else { + "off" + } + .to_string(), key: "sub_agents_enabled".to_string(), }, ]; } pub fn update_filtered_commands(&mut self) { - let input_line = self.input.lines().first().map(|l| l.to_lowercase()).unwrap_or_default(); + let input_line = self + .input + .lines() + .first() + .map(|l| l.to_lowercase()) + .unwrap_or_default(); if input_line.starts_with('/') { self.filtered_commands = COMMANDS .iter() @@ -375,9 +397,19 @@ mod tests { struct MockProvider; #[async_trait] impl AIProvider for MockProvider { - fn name(&self) -> &str { "Mock" } - async fn list_models(&self) -> Result, anyhow::Error> { Ok(vec![]) } - async fn ask(&self, _: Arc>, _: &str, _: Arc>>, _: Option<&str>) -> Result { + fn name(&self) -> &str { + "Mock" + } + async fn list_models(&self) -> Result, anyhow::Error> { + Ok(vec![]) + } + async fn ask( + &self, + _: Arc>, + _: &str, + _: Arc>>, + _: Option<&str>, + ) -> Result { Err(anyhow::anyhow!("Not implemented")) } } diff --git a/apps/cli/src/ui/components.rs b/apps/cli/src/ui/components.rs index 26eb747..22cf187 100644 --- a/apps/cli/src/ui/components.rs +++ b/apps/cli/src/ui/components.rs @@ -6,13 +6,13 @@ use ratatui::Frame; // --- Theme --- pub const COLOR_PRIMARY: Color = Color::Rgb(0, 150, 255); // Ocean Blue -pub const COLOR_BG: Color = Color::Rgb(25, 25, 25); // Midnight Charcoal -pub const COLOR_INPUT_BG: Color = Color::Rgb(35, 35, 35);// Soft Obsidian -pub const COLOR_SECONDARY: Color = Color::DarkGray; // Slate Gray -pub const COLOR_SYSTEM: Color = Color::Yellow; // Amber Yellow -pub const COLOR_SUCCESS: Color = Color::Green; // Emerald Green -pub const COLOR_TEXT: Color = Color::White; // Primary Text -pub const COLOR_DIM: Color = Color::Rgb(50, 50, 50); // Very Dim Text/Lines +pub const COLOR_BG: Color = Color::Rgb(25, 25, 25); // Midnight Charcoal +pub const COLOR_INPUT_BG: Color = Color::Rgb(35, 35, 35); // Soft Obsidian +pub const COLOR_SECONDARY: Color = Color::DarkGray; // Slate Gray +pub const COLOR_SYSTEM: Color = Color::Yellow; // Amber Yellow +pub const COLOR_SUCCESS: Color = Color::Green; // Emerald Green +pub const COLOR_TEXT: Color = Color::White; // Primary Text +pub const COLOR_DIM: Color = Color::Rgb(50, 50, 50); // Very Dim Text/Lines pub fn clean_model_name(name: &str, provider_id: &str) -> String { if (provider_id.starts_with("cloudflare") && name.starts_with("@cf/")) @@ -24,7 +24,15 @@ pub fn clean_model_name(name: &str, provider_id: &str) -> String { } } -pub fn draw_modal(f: &mut Frame, title: &str, width: u16, height: u16, mouse_col: Option, mouse_row: Option, footer: Vec) -> Rect { +pub fn draw_modal( + f: &mut Frame, + title: &str, + width: u16, + height: u16, + mouse_col: Option, + mouse_row: Option, + footer: Vec, +) -> Rect { let area = f.size(); let modal_area = Rect::new( (area.width.saturating_sub(width)) / 2, @@ -33,7 +41,10 @@ pub fn draw_modal(f: &mut Frame, title: &str, width: u16, height: u16, mouse_col height, ); f.render_widget(Clear, modal_area); - f.render_widget(Block::default().style(Style::default().bg(COLOR_BG)), modal_area); + f.render_widget( + Block::default().style(Style::default().bg(COLOR_BG)), + modal_area, + ); let main_layout = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) @@ -55,12 +66,18 @@ pub fn draw_modal(f: &mut Frame, title: &str, width: u16, height: u16, mouse_col .split(main_layout[0]); f.render_widget( - ratatui::widgets::Paragraph::new(Span::styled(title, Style::default().add_modifier(Modifier::BOLD))), + ratatui::widgets::Paragraph::new(Span::styled( + title, + Style::default().add_modifier(Modifier::BOLD), + )), header_layout[0], ); let mut esc_style = Style::default().fg(COLOR_SECONDARY); if let (Some(col), Some(row)) = (mouse_col, mouse_row) { - if row <= modal_area.y + 2 && col >= modal_area.x + width.saturating_sub(10) && col <= modal_area.x + width { + if row <= modal_area.y + 2 + && col >= modal_area.x + width.saturating_sub(10) + && col <= modal_area.x + width + { esc_style = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD); } } @@ -70,7 +87,10 @@ pub fn draw_modal(f: &mut Frame, title: &str, width: u16, height: u16, mouse_col header_layout[1], ); - f.render_widget(ratatui::widgets::Paragraph::new(Line::from(footer)), main_layout[3]); + f.render_widget( + ratatui::widgets::Paragraph::new(Line::from(footer)), + main_layout[3], + ); main_layout[1] } diff --git a/apps/cli/src/ui/events.rs b/apps/cli/src/ui/events.rs index 2a311d3..b558dd5 100644 --- a/apps/cli/src/ui/events.rs +++ b/apps/cli/src/ui/events.rs @@ -9,7 +9,7 @@ use super::app::{apply_settings_toggle, compute_message_hover, compute_thinking_ use super::logic::{handle_command, handle_model_search}; use super::render::copy_to_clipboard; use super::types::{ - ApiKeyInputStage, ApprovalMode, ModelMenuItem, PROVIDERS, Screen, SettingsMenuItem, + ApiKeyInputStage, ApprovalMode, ModelMenuItem, Screen, SettingsMenuItem, PROVIDERS, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -49,13 +49,25 @@ pub(crate) async fn handle_key_event( if let Some(msg_idx) = app.show_user_msg_modal { match key.code { KeyCode::Up | KeyCode::Char('k') => { - app.user_msg_modal_selected = if app.user_msg_modal_selected == 0 { 1 } else { 0 }; + app.user_msg_modal_selected = if app.user_msg_modal_selected == 0 { + 1 + } else { + 0 + }; } KeyCode::Down | KeyCode::Char('j') => { - app.user_msg_modal_selected = if app.user_msg_modal_selected == 1 { 0 } else { 1 }; + app.user_msg_modal_selected = if app.user_msg_modal_selected == 1 { + 0 + } else { + 1 + }; } KeyCode::Enter => { - let text = app.history[msg_idx].content.as_ref().map(|s| s.to_string()).unwrap_or_default(); + let text = app.history[msg_idx] + .content + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_default(); if app.user_msg_modal_selected == 0 { let text_clone = text.clone(); tokio::task::spawn_blocking(move || { @@ -63,7 +75,8 @@ pub(crate) async fn handle_key_event( log::error!("Clipboard copy failed: {}", e); } }); - app.history.push(Message::system("Message copied to clipboard!".to_string())); + app.history + .push(Message::system("Message copied to clipboard!".to_string())); } else { app.history.truncate(msg_idx); app.input = tui_textarea::TextArea::from(text.lines().map(|s| s.to_string())); @@ -88,7 +101,8 @@ pub(crate) async fn handle_key_event( app.input.move_cursor(tui_textarea::CursorMove::Head); app.input.delete_line_by_head(); } - app.input.set_placeholder_text(" Ask anything... \"How do I use this?\""); + app.input + .set_placeholder_text(" Ask anything... \"How do I use this?\""); } KeyCode::Enter => { if let Some((_, _, tx_mutex)) = app.pending_command_confirmation.take() { @@ -98,10 +112,15 @@ pub(crate) async fn handle_key_event( app.input.move_cursor(tui_textarea::CursorMove::Head); app.input.delete_line_by_head(); } - app.input.set_placeholder_text(" Ask anything... \"How do I use this?\""); + app.input + .set_placeholder_text(" Ask anything... \"How do I use this?\""); let msg = lines.join("\n").trim().to_string(); - let feedback = if msg.is_empty() { "Command cancelled.".to_string() } else { msg }; + let feedback = if msg.is_empty() { + "Command cancelled.".to_string() + } else { + msg + }; let mut tx_opt = tx_mutex.lock().await; if let Some(tx) = tx_opt.take() { @@ -125,9 +144,14 @@ pub(crate) async fn handle_key_event( } } KeyCode::Char('s') | KeyCode::Char('S') => { - let mut config = routecode_sdk::utils::storage::load_session_config(&app.session_id).unwrap_or_default(); + let mut config = + routecode_sdk::utils::storage::load_session_config(&app.session_id) + .unwrap_or_default(); config.allow_all_commands = true; - let _ = routecode_sdk::utils::storage::save_session_config(&app.session_id, &config); + let _ = routecode_sdk::utils::storage::save_session_config( + &app.session_id, + &config, + ); if let Some((_, _, tx_mutex)) = app.pending_command_confirmation.take() { let mut tx_opt = tx_mutex.lock().await; @@ -137,7 +161,8 @@ pub(crate) async fn handle_key_event( } } KeyCode::Char('w') | KeyCode::Char('W') => { - let mut config = routecode_sdk::utils::storage::load_workspace_config().unwrap_or_default(); + let mut config = + routecode_sdk::utils::storage::load_workspace_config().unwrap_or_default(); config.allow_all_outside_access = true; let _ = routecode_sdk::utils::storage::save_workspace_config(&config); @@ -158,7 +183,8 @@ pub(crate) async fn handle_key_event( } KeyCode::Char('f') | KeyCode::Char('F') => { app.inputting_command_feedback = true; - app.input.set_placeholder_text(" Tell agent (e.g. 'don't run without backup')..."); + app.input + .set_placeholder_text(" Tell agent (e.g. 'don't run without backup')..."); } _ => {} } @@ -212,7 +238,9 @@ pub(crate) async fn handle_key_event( app.update_filtered_commands(); } KeyCode::Char('a') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { - if app.show_model_menu { app.show_model_menu = false; } + if app.show_model_menu { + app.show_model_menu = false; + } app.show_provider_menu = true; app.menu_state.select(Some(0)); } @@ -228,7 +256,10 @@ pub(crate) async fn handle_key_event( app.screen = Screen::Welcome; app.history_scroll = 0; } - KeyCode::Enter if key.modifiers.contains(event::KeyModifiers::SHIFT) || key.modifiers.contains(event::KeyModifiers::ALT) => { + KeyCode::Enter + if key.modifiers.contains(event::KeyModifiers::SHIFT) + || key.modifiers.contains(event::KeyModifiers::ALT) => + { app.input.insert_newline(); } KeyCode::Enter => { @@ -271,24 +302,33 @@ pub(crate) async fn handle_key_event( } } else if app.show_settings_menu { if let Some(selected) = app.menu_state.selected() { - if let Some(SettingsMenuItem::Option { key, val: _, .. }) = app.settings_items.get(selected) { + if let Some(SettingsMenuItem::Option { key, val: _, .. }) = + app.settings_items.get(selected) + { let key = key.clone(); apply_settings_toggle(app, &key).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) { + if let Some(ModelMenuItem::Model(model_info)) = + app.filtered_models.get(selected) + { let model_info = model_info.clone(); 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()); + 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 + .retain(|m| m.name != *model_name || m.provider_id != *provider_id); config.recent_models.insert(0, model_info.clone()); config.recent_models.truncate(3); if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { @@ -299,19 +339,32 @@ pub(crate) async fn handle_key_event( let vertex_location = config.vertex_location.clone(); drop(config); let provider = if provider_id == "vertex" { - routecode_sdk::agents::resolve_provider_with_config(provider_id, key, &vertex_project, &vertex_location) + routecode_sdk::agents::resolve_provider_with_config( + provider_id, + key, + &vertex_project, + &vertex_location, + ) } else { routecode_sdk::agents::resolve_provider(provider_id, key) }; app.provider_name = provider.name().to_string(); app.current_provider_id = provider_id.clone(); app.orchestrator.change_provider(provider).await; - } else { drop(config); } + } else { + drop(config); + } app.current_model = model_name.clone(); - app.history.push(Message::system(format!("Switched to {} on {}", model_name, app.provider_name))); + 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.history.push(Message::system(format!( + "Error: No API key for {}", + provider_id + ))); } } } @@ -324,12 +377,15 @@ pub(crate) async fn handle_key_event( if provider_id == "vertex" { app.api_key_input_stage = ApiKeyInputStage::VertexLocation; app.api_key_input = TextArea::default(); - app.api_key_input.set_placeholder_text(" Location (e.g. us-central1)..."); + app.api_key_input + .set_placeholder_text(" Location (e.g. us-central1)..."); } else { app.pending_provider_id.take(); let mut config = app.orchestrator.config.lock().await; config.api_keys.insert(provider_id, input_value); - if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { + if let Err(e) = + routecode_sdk::utils::storage::save_config(&config) + { log::error!("Failed to save config: {}", e); } app.history.push(Message::system("API Key saved")); @@ -344,15 +400,18 @@ pub(crate) async fn handle_key_event( ApiKeyInputStage::VertexLocation => { if let Some(provider_id) = app.pending_provider_id.take() { let location = input_value; - let api_key = app.api_key_input.lines().join("\n").trim().to_string(); + let api_key = + app.api_key_input.lines().join("\n").trim().to_string(); let mut config = app.orchestrator.config.lock().await; config.vertex_project = "".to_string(); config.vertex_location = location; config.api_keys.insert(provider_id, api_key); - if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { + if let Err(e) = routecode_sdk::utils::storage::save_config(&config) + { log::error!("Failed to save config: {}", e); } - app.history.push(Message::system("Vertex AI credentials saved")); + app.history + .push(Message::system("Vertex AI credentials saved")); } app.is_inputting_api_key = false; app.api_key_input_stage = ApiKeyInputStage::None; @@ -362,7 +421,9 @@ pub(crate) async fn handle_key_event( 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; } + } else { + app.api_key_input_stage = ApiKeyInputStage::CloudflareApiKey; + } } ApiKeyInputStage::CloudflareGatewayId => { app.pending_gateway_id = Some(input_value); @@ -373,20 +434,29 @@ pub(crate) async fn handle_key_event( 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(); + let gateway_id = + app.pending_gateway_id.take().unwrap_or_default(); format!("{}:{}:{}", account_id, gateway_id, input_value) - } else { format!("{}:{}", account_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); - if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { + if let Err(e) = routecode_sdk::utils::storage::save_config(&config) + { log::error!("Failed to save config: {}", e); } - app.history.push(Message::system(format!("Credentials saved for {}", provider_id))); + 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; @@ -399,11 +469,13 @@ pub(crate) async fn handle_key_event( handle_command(app, &input_text).await; } else if !app.startup_ready { app.startup_input_buffer.push(input_text.clone()); - app.history.push(Message::system(format!("Queued: {}", input_text))); + app.history + .push(Message::system(format!("Queued: {}", input_text))); app.input = TextArea::default(); } else { let provider_id = &app.current_provider_id; - let env_key = format!("{}_API_KEY", provider_id.to_uppercase().replace("-", "_")); + let env_key = + format!("{}_API_KEY", provider_id.to_uppercase().replace("-", "_")); let mut api_key = std::env::var(&env_key).ok(); if api_key.is_none() && provider_id.starts_with("cloudflare") { api_key = std::env::var("CLOUDFLARE_API_KEY").ok(); @@ -416,7 +488,10 @@ pub(crate) async fn handle_key_event( let has_valid_key = api_key.is_some_and(|k| !k.trim().is_empty()); if !has_valid_key && super::types::provider_requires_api_key(provider_id) { - app.history.push(Message::system(format!("No API key found for {}. Please enter it to continue.", provider_id))); + app.history.push(Message::system(format!( + "No API key found for {}. Please enter it to continue.", + provider_id + ))); app.show_provider_menu = true; if let Some(pos) = PROVIDERS.iter().position(|p| p.id == *provider_id) { app.menu_state.select(Some(pos)); @@ -440,7 +515,9 @@ pub(crate) async fn handle_key_event( let model = app.current_model.clone(); let tx = app.tx.clone(); app.tasks.spawn(async move { - if let Err(e) = orchestrator.run(&mut history, &model, Some(tx), None).await { + if let Err(e) = + orchestrator.run(&mut history, &model, Some(tx), None).await + { log::error!("Orchestrator run failed: {}", e); } }); @@ -449,11 +526,15 @@ pub(crate) async fn handle_key_event( } } 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.show_settings_menu { app.show_settings_menu = false; } - else if app.is_inputting_api_key { + 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.show_settings_menu { + app.show_settings_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; @@ -468,7 +549,14 @@ pub(crate) async fn handle_key_event( } KeyCode::Char('t') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { app.auto_scroll = !app.auto_scroll; - app.history.push(Message::system(format!("Auto-scroll {}", if app.auto_scroll { "enabled" } else { "disabled" }))); + app.history.push(Message::system(format!( + "Auto-scroll {}", + if app.auto_scroll { + "enabled" + } else { + "disabled" + } + ))); } KeyCode::Char('o') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { app.collapse_thinking = !app.collapse_thinking; @@ -479,9 +567,19 @@ pub(crate) async fn handle_key_event( } KeyCode::Up if key.modifiers.contains(event::KeyModifiers::CONTROL) => { let (row, _) = app.input.cursor(); - if row == 0 && app.input.lines().len() == 1 && app.input.lines()[0].is_empty() && !app.prompt_history.is_empty() { + if row == 0 + && app.input.lines().len() == 1 + && app.input.lines()[0].is_empty() + && !app.prompt_history.is_empty() + { let idx = match app.prompt_history_index { - Some(i) => if i == 0 { 0 } else { i - 1 }, + Some(i) => { + if i == 0 { + 0 + } else { + i - 1 + } + } None => app.prompt_history.len() - 1, }; app.prompt_history_index = Some(idx); @@ -508,28 +606,61 @@ pub(crate) async fn handle_key_event( } } KeyCode::Up => { - if app.show_menu || app.show_provider_menu || app.show_model_menu || app.show_settings_menu { - let items_len = if app.show_menu { app.filtered_commands.len() } - else if app.show_provider_menu { PROVIDERS.len() } - else if app.show_settings_menu { app.settings_items.len() } - else { app.filtered_models.len() }; + if app.show_menu + || app.show_provider_menu + || app.show_model_menu + || app.show_settings_menu + { + let items_len = if app.show_menu { + app.filtered_commands.len() + } else if app.show_provider_menu { + PROVIDERS.len() + } else if app.show_settings_menu { + app.settings_items.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 }; + 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; } + 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; + } } } else if app.show_settings_menu { - while let Some(SettingsMenuItem::Header(_)) = app.settings_items.get(new_selected) { - new_selected = if new_selected == 0 { items_len - 1 } else { new_selected - 1 }; - if new_selected == selected { break; } + while let Some(SettingsMenuItem::Header(_)) = + app.settings_items.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)); } - } else if app.input.lines().len() == 1 && app.input.lines()[0].is_empty() || app.history_scroll > 0 || app.is_generating || key.modifiers.contains(event::KeyModifiers::SHIFT) { + } else if app.input.lines().len() == 1 && app.input.lines()[0].is_empty() + || app.history_scroll > 0 + || app.is_generating + || key.modifiers.contains(event::KeyModifiers::SHIFT) + { app.history_scroll = app.history_scroll.saturating_sub(15); app.auto_scroll = false; } else { @@ -537,30 +668,65 @@ pub(crate) async fn handle_key_event( } } KeyCode::Down => { - if app.show_menu || app.show_provider_menu || app.show_model_menu || app.show_settings_menu { - let items_len = if app.show_menu { app.filtered_commands.len() } - else if app.show_provider_menu { PROVIDERS.len() } - else if app.show_settings_menu { app.settings_items.len() } - else { app.filtered_models.len() }; + if app.show_menu + || app.show_provider_menu + || app.show_model_menu + || app.show_settings_menu + { + let items_len = if app.show_menu { + app.filtered_commands.len() + } else if app.show_provider_menu { + PROVIDERS.len() + } else if app.show_settings_menu { + app.settings_items.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 }; + 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; } + 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; + } } } else if app.show_settings_menu { - while let Some(SettingsMenuItem::Header(_)) = app.settings_items.get(new_selected) { - new_selected = if new_selected >= items_len - 1 { 0 } else { new_selected + 1 }; - if new_selected == selected { break; } + while let Some(SettingsMenuItem::Header(_)) = + app.settings_items.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)); } - } else if app.input.lines().len() == 1 && app.input.lines()[0].is_empty() || app.history_scroll < app.max_scroll || app.is_generating || key.modifiers.contains(event::KeyModifiers::SHIFT) { + } else if app.input.lines().len() == 1 && app.input.lines()[0].is_empty() + || app.history_scroll < app.max_scroll + || app.is_generating + || key.modifiers.contains(event::KeyModifiers::SHIFT) + { app.history_scroll = app.history_scroll.saturating_add(15); - if app.history_scroll >= app.max_scroll { app.auto_scroll = true; } + if app.history_scroll >= app.max_scroll { + app.auto_scroll = true; + } } else { app.input.input(Event::Key(key)); } @@ -570,11 +736,28 @@ pub(crate) async fn handle_key_event( 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 { if let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(i) { next_header_idx = Some(i); break; } } } + 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 { + 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; } } + 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)); } } @@ -584,23 +767,63 @@ pub(crate) async fn handle_key_event( 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); } } + 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 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; } } + 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)); } } } - KeyCode::Char('f') if key.modifiers.contains(event::KeyModifiers::CONTROL) && app.show_model_menu => { + 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) { let model_info = model_info.clone(); 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))); } + 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 + ))); + } if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { log::error!("Failed to save config: {}", e); } @@ -619,9 +842,22 @@ pub(crate) async fn handle_key_event( } _ => { let 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().first().map(|l| l.trim().to_lowercase()).unwrap_or_default(); handle_model_search(app, &search, true).await; } } - else { app.input.input(event); app.update_filtered_commands(); } + 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() + .first() + .map(|l| l.trim().to_lowercase()) + .unwrap_or_default(); + handle_model_search(app, &search, true).await; + } + } else { + app.input.input(event); + app.update_filtered_commands(); + } } } Ok(KeyEventResult::Continue) @@ -640,16 +876,30 @@ pub(crate) async fn handle_mouse_event( MouseEventKind::Moved => { app.mouse_moved = true; } - MouseEventKind::ScrollUp => { - if app.show_menu || app.show_provider_menu || app.show_model_menu || app.show_settings_menu { + MouseEventKind::ScrollUp => { + if app.show_menu + || app.show_provider_menu + || app.show_model_menu + || app.show_settings_menu + { let mut current = app.menu_state.selected().unwrap_or(0); current = current.saturating_sub(3); if app.show_model_menu { - while current > 0 && matches!(app.filtered_models.get(current), Some(ModelMenuItem::Header(_))) { + while current > 0 + && matches!( + app.filtered_models.get(current), + Some(ModelMenuItem::Header(_)) + ) + { current -= 1; } } else if app.show_settings_menu { - while current > 0 && matches!(app.settings_items.get(current), Some(SettingsMenuItem::Header(_))) { + while current > 0 + && matches!( + app.settings_items.get(current), + Some(SettingsMenuItem::Header(_)) + ) + { current -= 1; } } @@ -660,7 +910,11 @@ pub(crate) async fn handle_mouse_event( } } MouseEventKind::ScrollDown => { - if app.show_menu || app.show_provider_menu || app.show_model_menu || app.show_settings_menu { + if app.show_menu + || app.show_provider_menu + || app.show_model_menu + || app.show_settings_menu + { let current = app.menu_state.selected().unwrap_or(0); let max = if app.show_menu { app.filtered_commands.len() @@ -673,18 +927,30 @@ pub(crate) async fn handle_mouse_event( }; let mut next = current.saturating_add(3).min(max.saturating_sub(1)); if app.show_model_menu { - while next < max - 1 && matches!(app.filtered_models.get(next), Some(ModelMenuItem::Header(_))) { + while next < max - 1 + && matches!( + app.filtered_models.get(next), + Some(ModelMenuItem::Header(_)) + ) + { next += 1; } } else if app.show_settings_menu { - while next < max - 1 && matches!(app.settings_items.get(next), Some(SettingsMenuItem::Header(_))) { + while next < max - 1 + && matches!( + app.settings_items.get(next), + Some(SettingsMenuItem::Header(_)) + ) + { next += 1; } } app.menu_state.select(Some(next)); } else { app.history_scroll = app.history_scroll.saturating_add(15); - if app.history_scroll >= app.max_scroll { app.auto_scroll = true; } + if app.history_scroll >= app.max_scroll { + app.auto_scroll = true; + } } } MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) => { @@ -695,7 +961,10 @@ pub(crate) async fn handle_mouse_event( let modal_x = (size.width.saturating_sub(width)) / 2; let modal_y = (size.height.saturating_sub(height)) / 2; - let is_outside = mouse.column < modal_x || mouse.column >= modal_x + width || mouse.row < modal_y || mouse.row >= modal_y + height; + let is_outside = mouse.column < modal_x + || mouse.column >= modal_x + width + || mouse.row < modal_y + || mouse.row >= modal_y + height; if is_outside { if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { @@ -705,20 +974,30 @@ pub(crate) async fn handle_mouse_event( let click_row = mouse.row; if click_row == modal_y + 2 { app.user_msg_modal_selected = 0; - let text = app.history[msg_idx].content.as_ref().map(|s| s.to_string()).unwrap_or_default(); + let text = app.history[msg_idx] + .content + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_default(); let text_clone = text.clone(); tokio::task::spawn_blocking(move || { if let Err(e) = copy_to_clipboard(&text_clone) { log::error!("Clipboard copy failed: {}", e); } }); - app.history.push(Message::system("Message copied to clipboard!".to_string())); + app.history + .push(Message::system("Message copied to clipboard!".to_string())); app.show_user_msg_modal = None; } else if click_row == modal_y + 3 { app.user_msg_modal_selected = 1; - let text = app.history[msg_idx].content.as_ref().map(|s| s.to_string()).unwrap_or_default(); + let text = app.history[msg_idx] + .content + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_default(); app.history.truncate(msg_idx); - app.input = tui_textarea::TextArea::from(text.lines().map(|s| s.to_string())); + app.input = + tui_textarea::TextArea::from(text.lines().map(|s| s.to_string())); app.input.move_cursor(tui_textarea::CursorMove::End); app.show_user_msg_modal = None; } @@ -731,22 +1010,34 @@ pub(crate) async fn handle_mouse_event( let modal_x = (size.width.saturating_sub(width)) / 2; let modal_y = (size.height.saturating_sub(height)) / 2; - let is_outside = mouse.column < modal_x || mouse.column >= modal_x + width || mouse.row < modal_y || mouse.row >= modal_y + height; + let is_outside = mouse.column < modal_x + || mouse.column >= modal_x + width + || mouse.row < modal_y + || mouse.row >= modal_y + height; if is_outside { if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { app.pending_update = None; } } else if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) - && mouse.row == modal_y + height.saturating_sub(2) { - if mouse.column >= modal_x + width.saturating_sub(25) && mouse.column < modal_x + width.saturating_sub(15) { - app.pending_update = None; - } else if mouse.column >= modal_x + width.saturating_sub(15) && mouse.column < modal_x + width { - app.pending_update_install = true; - } + && mouse.row == modal_y + height.saturating_sub(2) + { + if mouse.column >= modal_x + width.saturating_sub(25) + && mouse.column < modal_x + width.saturating_sub(15) + { + app.pending_update = None; + } else if mouse.column >= modal_x + width.saturating_sub(15) + && mouse.column < modal_x + width + { + app.pending_update_install = true; } + } } - } else if app.show_menu || app.show_provider_menu || app.show_model_menu || app.show_settings_menu { + } else if app.show_menu + || app.show_provider_menu + || app.show_model_menu + || app.show_settings_menu + { if let Ok(size) = terminal.size() { let (width, height) = if app.show_menu { (60, (app.filtered_commands.len() + 6).min(15) as u16) @@ -760,25 +1051,37 @@ pub(crate) async fn handle_mouse_event( let modal_x = (size.width.saturating_sub(width)) / 2; let modal_y = (size.height.saturating_sub(height)) / 2; - let is_outside = mouse.column < modal_x || mouse.column >= modal_x + width || mouse.row < modal_y || mouse.row >= modal_y + height; - let is_esc = mouse.row <= modal_y + 2 && mouse.column >= modal_x + width.saturating_sub(10) && mouse.column <= modal_x + width; - let is_inside_list = mouse.row >= modal_y + 2 && mouse.row < modal_y + height - 1 && mouse.column > modal_x && mouse.column < modal_x + width - 1; + let is_outside = mouse.column < modal_x + || mouse.column >= modal_x + width + || mouse.row < modal_y + || mouse.row >= modal_y + height; + let is_esc = mouse.row <= modal_y + 2 + && mouse.column >= modal_x + width.saturating_sub(10) + && mouse.column <= modal_x + width; + let is_inside_list = mouse.row >= modal_y + 2 + && mouse.row < modal_y + height - 1 + && mouse.column > modal_x + && mouse.column < modal_x + width - 1; if is_outside || is_esc { app.show_menu = false; app.show_provider_menu = false; app.show_model_menu = false; app.show_settings_menu = false; - } else if is_inside_list && matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) - && app.show_settings_menu { - let idx = (mouse.row - (modal_y + 2)) as usize + app.menu_state.offset(); - if idx < app.settings_items.len() { - if let Some(SettingsMenuItem::Option { key, val: _, .. }) = app.settings_items.get(idx) { - let key = key.clone(); - apply_settings_toggle(app, &key).await; - } + } else if is_inside_list + && matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) + && app.show_settings_menu + { + let idx = (mouse.row - (modal_y + 2)) as usize + app.menu_state.offset(); + if idx < app.settings_items.len() { + if let Some(SettingsMenuItem::Option { key, val: _, .. }) = + app.settings_items.get(idx) + { + let key = key.clone(); + apply_settings_toggle(app, &key).await; } } + } } } else if app.screen == Screen::Session { let has_thinking = app.history.iter().any(|m| m.thought.is_some()); @@ -795,13 +1098,18 @@ pub(crate) async fn handle_mouse_event( } if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - let in_cooldown = app.last_toggle_time.is_some_and(|t| t.elapsed() < std::time::Duration::from_millis(400)); + let in_cooldown = app + .last_toggle_time + .is_some_and(|t| t.elapsed() < std::time::Duration::from_millis(400)); if !in_cooldown && has_thinking { - let is_double_click = if let Some((last_time, col, row)) = app.last_click_up { + let is_double_click = if let Some((last_time, col, row)) = app.last_click_up + { let col_diff = (col as i32 - mouse.column as i32).abs(); let row_diff = (row as i32 - mouse.row as i32).abs(); - last_time.elapsed() < std::time::Duration::from_millis(600) && col_diff <= 4 && row_diff <= 3 + last_time.elapsed() < std::time::Duration::from_millis(600) + && col_diff <= 4 + && row_diff <= 3 } else { false }; @@ -815,8 +1123,10 @@ pub(crate) async fn handle_mouse_event( // Compute hover FRESH with current mouse position let hover = compute_thinking_hover(app, size); if hover { - app.last_click_up = Some((std::time::Instant::now(), mouse.column, mouse.row)); - app.mouse_down_start = Some((std::time::Instant::now(), mouse.column, mouse.row)); + app.last_click_up = + Some((std::time::Instant::now(), mouse.column, mouse.row)); + app.mouse_down_start = + Some((std::time::Instant::now(), mouse.column, mouse.row)); } else { app.last_click_up = None; } @@ -827,11 +1137,16 @@ pub(crate) async fn handle_mouse_event( app.mouse_down_start = None; app.temp_expand_thinking = false; } - } else if app.screen == Screen::Welcome && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + } else if app.screen == Screen::Welcome + && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + { if let Ok(size) = terminal.size() { let logo_height = if size.height < 20 { 0 } else { 6 }; let spacer_height = if size.height < 15 { 0 } else { size.height / 3 }; - if logo_height > 0 && mouse.row >= spacer_height && mouse.row < spacer_height + logo_height { + if logo_height > 0 + && mouse.row >= spacer_height + && mouse.row < spacer_height + logo_height + { app.logo_anim_frames = 20; // 2 seconds at 100ms tick } } @@ -846,8 +1161,8 @@ pub(crate) async fn handle_mouse_event( mod tests { use super::*; use async_trait::async_trait; - use routecode_sdk::agents::AIProvider; use routecode_sdk::agents::traits::StreamResponse; + use routecode_sdk::agents::AIProvider; use routecode_sdk::core::{AgentOrchestrator, Config}; use routecode_sdk::tools::ToolRegistry; use std::sync::Arc; @@ -856,9 +1171,19 @@ mod tests { struct MockProvider; #[async_trait] impl AIProvider for MockProvider { - fn name(&self) -> &str { "Mock" } - async fn list_models(&self) -> Result, anyhow::Error> { Ok(vec![]) } - async fn ask(&self, _: Arc>, _: &str, _: Arc>>, _: Option<&str>) -> Result { + fn name(&self) -> &str { + "Mock" + } + async fn list_models(&self) -> Result, anyhow::Error> { + Ok(vec![]) + } + async fn ask( + &self, + _: Arc>, + _: &str, + _: Arc>>, + _: Option<&str>, + ) -> Result { Err(anyhow::anyhow!("Not implemented")) } } @@ -873,8 +1198,13 @@ mod tests { let mut app = App::new(orchestrator, "Mock".to_string(), "gpt-4o".to_string()); app.history.push(Message::user("First message".to_string())); - app.history.push(Message::assistant(Some("Assistant reply".into()), None, None)); - app.history.push(Message::user("Second message".to_string())); + app.history.push(Message::assistant( + Some("Assistant reply".into()), + None, + None, + )); + app.history + .push(Message::user("Second message".to_string())); app.show_user_msg_modal = Some(2); app.user_msg_modal_selected = 1; diff --git a/apps/cli/src/ui/logic.rs b/apps/cli/src/ui/logic.rs index 476c802..ed05d68 100644 --- a/apps/cli/src/ui/logic.rs +++ b/apps/cli/src/ui/logic.rs @@ -1,35 +1,51 @@ +use crate::ui::{App, ModelMenuItem, Screen, COLOR_SECONDARY, PROVIDERS}; use ratatui::style::Style; -use tui_textarea::TextArea; use routecode_sdk::agents::StreamChunk; use routecode_sdk::core::{DynamicModelInfo, Message}; -use crate::ui::{App, PROVIDERS, ModelMenuItem, Screen, COLOR_SECONDARY}; +use tui_textarea::TextArea; pub async fn handle_model_search(app: &mut App, search: &str, force_reset: bool) { let mut sections: Vec = Vec::new(); let config = app.orchestrator.config.lock().await.clone(); - let recent: Vec = config.recent_models.iter() - .filter(|m| m.name.to_lowercase().contains(search) || m.provider_id.to_lowercase().contains(search)) + let recent: Vec = config + .recent_models + .iter() + .filter(|m| { + m.name.to_lowercase().contains(search) || m.provider_id.to_lowercase().contains(search) + }) .cloned() .collect(); if !recent.is_empty() { sections.push(ModelMenuItem::Header("Recently Used".to_string())); - for m in recent { sections.push(ModelMenuItem::Model(m)); } + for m in recent { + sections.push(ModelMenuItem::Model(m)); + } } - let favorites: Vec = config.favorites.iter() - .filter(|m| m.name.to_lowercase().contains(search) || m.provider_id.to_lowercase().contains(search)) + let favorites: Vec = config + .favorites + .iter() + .filter(|m| { + m.name.to_lowercase().contains(search) || m.provider_id.to_lowercase().contains(search) + }) .cloned() .collect(); if !favorites.is_empty() { sections.push(ModelMenuItem::Header("Favorite Models".to_string())); - for m in favorites { sections.push(ModelMenuItem::Model(m)); } + for m in favorites { + sections.push(ModelMenuItem::Model(m)); + } } - let mut by_provider: std::collections::HashMap> = std::collections::HashMap::new(); + let mut by_provider: std::collections::HashMap> = + std::collections::HashMap::new(); for m in &app.all_available_models { if m.name.to_lowercase().contains(search) || m.provider_id.to_lowercase().contains(search) { - by_provider.entry(m.provider_id.clone()).or_default().push(m.clone()); + by_provider + .entry(m.provider_id.clone()) + .or_default() + .push(m.clone()); } } @@ -38,28 +54,41 @@ pub async fn handle_model_search(app: &mut App, search: &str, force_reset: bool) for p_id in provider_ids { if let Some(models) = by_provider.get(&p_id) { - let p_name = PROVIDERS.iter().find(|p| p.id == p_id).map(|p| p.name).unwrap_or(&p_id); + let p_name = PROVIDERS + .iter() + .find(|p| p.id == p_id) + .map(|p| p.name) + .unwrap_or(&p_id); sections.push(ModelMenuItem::Header(p_name.to_string())); - for m in models { sections.push(ModelMenuItem::Model(m.clone())); } + for m in models { + sections.push(ModelMenuItem::Model(m.clone())); + } } } app.filtered_models = sections; - + if force_reset { if !app.filtered_models.is_empty() { let mut first_model = None; for (i, item) in app.filtered_models.iter().enumerate() { - if let ModelMenuItem::Model(_) = item { first_model = Some(i); break; } + if let ModelMenuItem::Model(_) = item { + first_model = Some(i); + break; + } } app.menu_state.select(first_model); - } else { app.menu_state.select(None); } + } else { + app.menu_state.select(None); + } } } pub async fn handle_command(app: &mut App, input: &str) { let parts: Vec<&str> = input.split_whitespace().collect(); - if parts.is_empty() { return; } + if parts.is_empty() { + return; + } let command = parts[0]; let args = &parts[1..]; @@ -69,9 +98,12 @@ pub async fn handle_command(app: &mut App, input: &str) { app.is_fetching_models = true; app.all_available_models.clear(); app.model_search_input = TextArea::default(); - app.model_search_input.set_cursor_line_style(Style::default()); - app.model_search_input.set_placeholder_text(" Search models..."); - app.model_search_input.set_placeholder_style(Style::default().fg(COLOR_SECONDARY)); + app.model_search_input + .set_cursor_line_style(Style::default()); + app.model_search_input + .set_placeholder_text(" Search models..."); + app.model_search_input + .set_placeholder_style(Style::default().fg(COLOR_SECONDARY)); handle_model_search(app, "", true).await; let config_mutex = app.orchestrator.config.clone(); let tx = app.tx.clone(); @@ -80,14 +112,20 @@ pub async fn handle_command(app: &mut App, input: &str) { let mut set = tokio::task::JoinSet::new(); for p_info in PROVIDERS { let env_key = format!("{}_API_KEY", p_info.id.to_uppercase().replace("-", "_")); - let mut api_key = std::env::var(env_key).ok().or_else(|| config.api_keys.get(p_info.id).cloned()); - if api_key.is_none() && p_info.id.starts_with("cloudflare") { api_key = std::env::var("CLOUDFLARE_API_KEY").ok(); } + let mut api_key = std::env::var(env_key) + .ok() + .or_else(|| config.api_keys.get(p_info.id).cloned()); + if api_key.is_none() && p_info.id.starts_with("cloudflare") { + api_key = std::env::var("CLOUDFLARE_API_KEY").ok(); + } if let Some(key) = api_key { let provider_id = p_info.id.to_string(); let provider = if provider_id == "vertex" { routecode_sdk::agents::resolve_provider_with_config( - &provider_id, key, - &config.vertex_project, &config.vertex_location, + &provider_id, + key, + &config.vertex_project, + &config.vertex_location, ) } else { routecode_sdk::agents::resolve_provider(&provider_id, key) @@ -95,15 +133,19 @@ pub async fn handle_command(app: &mut App, input: &str) { set.spawn(async move { match provider.list_models().await { Ok(models) => { - let dynamic_models: Vec = models.into_iter() - .map(|m| DynamicModelInfo { name: m, provider_id: provider_id.clone() }) + let dynamic_models: Vec = models + .into_iter() + .map(|m| DynamicModelInfo { + name: m, + provider_id: provider_id.clone(), + }) .collect(); Ok(dynamic_models) } Err(e) => { log::error!("Failed to list models for {}: {}", provider_id, e); Err(e) - }, + } } }); } @@ -125,29 +167,48 @@ pub async fn handle_command(app: &mut App, input: &str) { *u = session.usage; app.session_id = name.to_string(); if let Ok(config) = routecode_sdk::utils::storage::load_session_config(name) { - app.orchestrator.allow_session_commands.store(config.allow_all_commands, std::sync::atomic::Ordering::SeqCst); - app.orchestrator.allow_session_outside_access.store(config.allow_all_outside_access, std::sync::atomic::Ordering::SeqCst); + app.orchestrator.allow_session_commands.store( + config.allow_all_commands, + std::sync::atomic::Ordering::SeqCst, + ); + app.orchestrator.allow_session_outside_access.store( + config.allow_all_outside_access, + std::sync::atomic::Ordering::SeqCst, + ); } - if let Ok(workspace_config) = routecode_sdk::utils::storage::load_workspace_config() { + if let Ok(workspace_config) = + routecode_sdk::utils::storage::load_workspace_config() + { if workspace_config.allow_all_outside_access { - app.orchestrator.allow_session_outside_access.store(true, std::sync::atomic::Ordering::SeqCst); + app.orchestrator + .allow_session_outside_access + .store(true, std::sync::atomic::Ordering::SeqCst); } } - app.history.push(Message::system(format!("Session resumed: {}", name))); + app.history + .push(Message::system(format!("Session resumed: {}", name))); app.screen = Screen::Session; } else { - app.history.push(Message::system(format!("Error: Session '{}' not found", name))); + app.history.push(Message::system(format!( + "Error: Session '{}' not found", + name + ))); } } else { - app.history.push(Message::system("Usage: /resume ")); + app.history + .push(Message::system("Usage: /resume ")); } } "/export" => { if app.history.is_empty() { - app.history.push(Message::system("No messages to export in current session.")); + app.history + .push(Message::system("No messages to export in current session.")); return; } - let name = args.first().map(|s| s.to_string()).unwrap_or_else(|| app.session_id.clone()); + let name = args + .first() + .map(|s| s.to_string()) + .unwrap_or_else(|| app.session_id.clone()); let session = routecode_sdk::utils::storage::Session { messages: app.history.clone(), model: app.current_model.clone(), @@ -155,8 +216,12 @@ pub async fn handle_command(app: &mut App, input: &str) { timestamp: chrono::Utc::now().timestamp(), }; match routecode_sdk::utils::storage::save_session(&name, &session) { - Ok(_) => app.history.push(Message::system(format!("Session exported as '{}'", name))), - Err(e) => app.history.push(Message::system(format!("Export failed: {}", e))), + Ok(_) => app + .history + .push(Message::system(format!("Session exported as '{}'", name))), + Err(e) => app + .history + .push(Message::system(format!("Export failed: {}", e))), } } "/import" => { @@ -169,9 +234,12 @@ pub async fn handle_command(app: &mut App, input: &str) { *u = session.usage; app.session_id = path.to_string(); app.screen = Screen::Session; - app.history.push(Message::system(format!("Session imported from '{}'", path))); + app.history + .push(Message::system(format!("Session imported from '{}'", path))); } - Err(e) => app.history.push(Message::system(format!("Import failed: {}", e))), + Err(e) => app + .history + .push(Message::system(format!("Import failed: {}", e))), } } else { app.history.push(Message::system("Usage: /import ")); @@ -179,8 +247,15 @@ pub async fn handle_command(app: &mut App, input: &str) { } "/sessions" => { if let Ok(sessions) = routecode_sdk::utils::storage::list_sessions() { - if sessions.is_empty() { app.history.push(Message::system("No saved sessions found.")); } - else { app.history.push(Message::system(format!("Saved sessions:\n {}", sessions.join("\n ")))); } + if sessions.is_empty() { + app.history + .push(Message::system("No saved sessions found.")); + } else { + app.history.push(Message::system(format!( + "Saved sessions:\n {}", + sessions.join("\n ") + ))); + } } } "/clear" => { @@ -205,20 +280,39 @@ pub async fn handle_command(app: &mut App, input: &str) { let mut config = app.orchestrator.config.lock().await; config.thinking_level = level.clone(); if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { - log::error!("Failed to save config: {}", e); -} - app.history.push(Message::system(format!("Thinking level set to: {}", level))); - } else { app.history.push(Message::system(format!("Invalid level. Valid: {}", valid.join(", ")))); } + log::error!("Failed to save config: {}", e); + } + app.history + .push(Message::system(format!("Thinking level set to: {}", level))); + } else { + app.history.push(Message::system(format!( + "Invalid level. Valid: {}", + valid.join(", ") + ))); + } } else { let config = app.orchestrator.config.lock().await; - app.history.push(Message::system(format!("Current thinking level: {}", config.thinking_level))); + app.history.push(Message::system(format!( + "Current thinking level: {}", + config.thinking_level + ))); } } - "/provider" => { app.show_provider_menu = true; app.menu_state.select(Some(0)); } - "/settings" => { app.populate_settings().await; app.show_settings_menu = true; app.menu_state.select(Some(1)); } + "/provider" => { + app.show_provider_menu = true; + app.menu_state.select(Some(0)); + } + "/settings" => { + app.populate_settings().await; + app.show_settings_menu = true; + app.menu_state.select(Some(1)); + } "/exit" => { app.pending_exit = true; } - _ => { app.history.push(Message::system(format!("Unknown command: {}", command))); } + _ => { + app.history + .push(Message::system(format!("Unknown command: {}", command))); + } } } diff --git a/apps/cli/src/ui/menus.rs b/apps/cli/src/ui/menus.rs index 9e39de1..895d1dc 100644 --- a/apps/cli/src/ui/menus.rs +++ b/apps/cli/src/ui/menus.rs @@ -1,41 +1,61 @@ +use crate::ui::components::{ + clean_model_name, draw_modal, COLOR_PRIMARY, COLOR_SECONDARY, COLOR_SUCCESS, COLOR_TEXT, +}; +use crate::ui::{ApiKeyInputStage, App, ModelMenuItem, PROVIDERS}; use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::{Modifier, Style, Color}; +use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; use ratatui::Frame; use routecode_sdk::core::DynamicModelInfo; -use crate::ui::{App, PROVIDERS, ModelMenuItem, ApiKeyInputStage}; -use crate::ui::components::{COLOR_PRIMARY, COLOR_SECONDARY, COLOR_TEXT, COLOR_SUCCESS, draw_modal, clean_model_name}; pub fn render_menu(f: &mut Frame, app: &mut App, _input_area: Rect) { let height = (app.filtered_commands.len() + 6).min(15) as u16; - let body_area = draw_modal(f, "Commands", 60, height, app.mouse_col, app.mouse_row, vec![ - Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" select command") - ]); - - let items: Vec = app.filtered_commands.iter().map(|cmd| { - let total_width = body_area.width.saturating_sub(4); - let left = cmd.name.to_string(); - let right = cmd.description.to_string(); - let padding = total_width.saturating_sub(left.len() as u16).saturating_sub(right.len() as u16); - let spaces = " ".repeat(padding as usize); - ListItem::new(Line::from(vec![ - Span::raw(format!(" {}", left)), - Span::raw(spaces), - Span::styled(right, Style::default().fg(COLOR_SECONDARY)), - Span::raw(" ") - ])) - }).collect(); + let body_area = draw_modal( + f, + "Commands", + 60, + height, + app.mouse_col, + app.mouse_row, + vec![ + Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" select command"), + ], + ); + + let items: Vec = app + .filtered_commands + .iter() + .map(|cmd| { + let total_width = body_area.width.saturating_sub(4); + let left = cmd.name.to_string(); + let right = cmd.description.to_string(); + let padding = total_width + .saturating_sub(left.len() as u16) + .saturating_sub(right.len() as u16); + let spaces = " ".repeat(padding as usize); + ListItem::new(Line::from(vec![ + Span::raw(format!(" {}", left)), + Span::raw(spaces), + Span::styled(right, Style::default().fg(COLOR_SECONDARY)), + Span::raw(" "), + ])) + }) + .collect(); let list = List::new(items) .highlight_style(Style::default().bg(COLOR_PRIMARY).fg(Color::Black)) .highlight_symbol(""); - + let items_len = app.filtered_commands.len(); if app.mouse_moved { if let (Some(col), Some(row)) = (app.mouse_col, app.mouse_row) { - if col >= body_area.x && col < body_area.x + body_area.width && row >= body_area.y && row < body_area.y + body_area.height { + if col >= body_area.x + && col < body_area.x + body_area.width + && row >= body_area.y + && row < body_area.y + body_area.height + { let idx = (row - body_area.y) as usize + app.menu_state.offset(); if idx < items_len { app.menu_state.select(Some(idx)); @@ -51,11 +71,20 @@ pub fn render_api_key_dialog(f: &mut Frame, app: &mut App) { let provider_id = app.pending_provider_id.as_deref().unwrap_or("provider"); let p_info = PROVIDERS.iter().find(|p| p.id == provider_id); let provider_name = p_info.map(|p| p.name).unwrap_or(provider_id); - + let title = format!("Connect {}", provider_name); - let body_area = draw_modal(f, &title, 60, 10, app.mouse_col, app.mouse_row, vec![ - Span::styled("Press Enter to save", Style::default().add_modifier(Modifier::BOLD)) - ]); + let body_area = draw_modal( + f, + &title, + 60, + 10, + app.mouse_col, + app.mouse_row, + vec![Span::styled( + "Press Enter to save", + Style::default().add_modifier(Modifier::BOLD), + )], + ); let layout = Layout::default() .direction(ratatui::layout::Direction::Vertical) @@ -67,18 +96,36 @@ pub fn render_api_key_dialog(f: &mut Frame, app: &mut App) { .split(body_area); let (prompt, placeholder) = match app.api_key_input_stage { - ApiKeyInputStage::CloudflareAccountId => ("Enter Cloudflare Account ID:".to_string(), " Account ID..."), - ApiKeyInputStage::CloudflareGatewayId => ("Enter Cloudflare Gateway ID:".to_string(), " Gateway ID..."), - ApiKeyInputStage::CloudflareApiKey => ("Enter Cloudflare API Token:".to_string(), " API Token..."), - ApiKeyInputStage::VertexLocation => ("Enter GCP location (us-central1, europe-west4, us):".to_string(), " us-central1..."), - _ => (format!("Enter API key for {}:", provider_name), " Paste your API key here..."), + ApiKeyInputStage::CloudflareAccountId => { + ("Enter Cloudflare Account ID:".to_string(), " Account ID...") + } + ApiKeyInputStage::CloudflareGatewayId => { + ("Enter Cloudflare Gateway ID:".to_string(), " Gateway ID...") + } + ApiKeyInputStage::CloudflareApiKey => { + ("Enter Cloudflare API Token:".to_string(), " API Token...") + } + ApiKeyInputStage::VertexLocation => ( + "Enter GCP location (us-central1, europe-west4, us):".to_string(), + " us-central1...", + ), + _ => ( + format!("Enter API key for {}:", provider_name), + " Paste your API key here...", + ), }; f.render_widget(Paragraph::new(prompt), layout[0]); - + app.api_key_input.set_placeholder_text(placeholder); - app.api_key_input.set_block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(COLOR_SECONDARY))); - if app.api_key_input_stage == ApiKeyInputStage::ApiKey || app.api_key_input_stage == ApiKeyInputStage::CloudflareApiKey { + app.api_key_input.set_block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(COLOR_SECONDARY)), + ); + if app.api_key_input_stage == ApiKeyInputStage::ApiKey + || app.api_key_input_stage == ApiKeyInputStage::CloudflareApiKey + { app.api_key_input.set_mask_char('\u{2022}'); } else { app.api_key_input.set_mask_char('\0'); @@ -91,59 +138,85 @@ pub fn render_api_key_dialog(f: &mut Frame, app: &mut App) { pub fn render_provider_menu(f: &mut Frame, app: &mut App, _input_area: Rect) { let height = (PROVIDERS.len() + 6).min(15) as u16; - let body_area = draw_modal(f, "AI Providers", 60, height, app.mouse_col, app.mouse_row, vec![ - Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" configure API key") - ]); + let body_area = draw_modal( + f, + "AI Providers", + 60, + height, + app.mouse_col, + app.mouse_row, + vec![ + Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" configure API key"), + ], + ); let items: Vec = { let config = crate::ui::try_lock_config(app); let config = match config.as_ref() { Some(c) => c, None => { - let items: Vec = vec![ListItem::new(Line::from(vec![ - Span::styled(" Loading...", Style::default().fg(COLOR_SECONDARY)) - ]))]; - let list = List::new(items) - .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(COLOR_SECONDARY))); + let items: Vec = vec![ListItem::new(Line::from(vec![Span::styled( + " Loading...", + Style::default().fg(COLOR_SECONDARY), + )]))]; + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(COLOR_SECONDARY)), + ); f.render_widget(list, body_area); return; } }; - PROVIDERS.iter().map(|p| { - let env_key = format!("{}_API_KEY", p.id.to_uppercase().replace("-", "_")); - let is_connected = config.api_keys.contains_key(p.id) || std::env::var(env_key).is_ok(); - - let status = if is_connected { - Span::styled(" ✔ connected", Style::default().fg(COLOR_SUCCESS)) - } else { - Span::styled(" ✖ disconnected", Style::default().fg(COLOR_SECONDARY)) - }; + PROVIDERS + .iter() + .map(|p| { + let env_key = format!("{}_API_KEY", p.id.to_uppercase().replace("-", "_")); + let is_connected = + config.api_keys.contains_key(p.id) || std::env::var(env_key).is_ok(); - let total_width = body_area.width.saturating_sub(4); - let left = p.name.to_string(); - let status_str = if is_connected { "✔ connected" } else { "✖ disconnected" }; - let padding = total_width.saturating_sub(left.len() as u16).saturating_sub(status_str.len() as u16); - let spaces = " ".repeat(padding as usize); + let status = if is_connected { + Span::styled(" ✔ connected", Style::default().fg(COLOR_SUCCESS)) + } else { + Span::styled(" ✖ disconnected", Style::default().fg(COLOR_SECONDARY)) + }; - ListItem::new(Line::from(vec![ - Span::raw(format!(" {}", left)), - Span::raw(spaces), - status, - Span::raw(" ") - ])) - }).collect() + let total_width = body_area.width.saturating_sub(4); + let left = p.name.to_string(); + let status_str = if is_connected { + "✔ connected" + } else { + "✖ disconnected" + }; + let padding = total_width + .saturating_sub(left.len() as u16) + .saturating_sub(status_str.len() as u16); + let spaces = " ".repeat(padding as usize); + + ListItem::new(Line::from(vec![ + Span::raw(format!(" {}", left)), + Span::raw(spaces), + status, + Span::raw(" "), + ])) + }) + .collect() }; let list = List::new(items) .highlight_style(Style::default().bg(COLOR_PRIMARY).fg(Color::Black)) .highlight_symbol(""); - + let items_len = PROVIDERS.len(); if app.mouse_moved { if let (Some(col), Some(row)) = (app.mouse_col, app.mouse_row) { - if col >= body_area.x && col < body_area.x + body_area.width && row >= body_area.y && row < body_area.y + body_area.height { + if col >= body_area.x + && col < body_area.x + body_area.width + && row >= body_area.y + && row < body_area.y + body_area.height + { let idx = (row - body_area.y) as usize + app.menu_state.offset(); if idx < items_len { app.menu_state.select(Some(idx)); @@ -159,7 +232,10 @@ pub fn render_provider_menu(f: &mut Frame, app: &mut App, _input_area: Rect) { pub fn render_model_menu(f: &mut Frame, app: &mut App, _input_area: Rect) { let height = (app.filtered_models.len() + 7).min(18) as u16; let mut footer = vec![ - Span::styled("Connect provider ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + "Connect provider ", + Style::default().add_modifier(Modifier::BOLD), + ), Span::styled("ctrl+a", Style::default().fg(COLOR_SECONDARY)), Span::raw(" "), Span::styled("Favorite ", Style::default().add_modifier(Modifier::BOLD)), @@ -170,22 +246,40 @@ pub fn render_model_menu(f: &mut Frame, app: &mut App, _input_area: Rect) { let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let frame = spinner[(app.tick_count % spinner.len() as u64) as usize]; footer.push(Span::raw(" ")); - footer.push(Span::styled(format!("{} Fetching models...", frame), Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD))); + footer.push(Span::styled( + format!("{} Fetching models...", frame), + Style::default() + .fg(COLOR_PRIMARY) + .add_modifier(Modifier::BOLD), + )); } - let body_area = draw_modal(f, "Select model", 70, height, app.mouse_col, app.mouse_row, footer); + let body_area = draw_modal( + f, + "Select model", + 70, + height, + app.mouse_col, + app.mouse_row, + footer, + ); let layout = Layout::default() .direction(ratatui::layout::Direction::Vertical) - .constraints([ - Constraint::Length(2), - Constraint::Min(0), - ]) + .constraints([Constraint::Length(2), Constraint::Min(0)]) .split(body_area); - let search_text = app.model_search_input.lines().first().cloned().unwrap_or_default(); + let search_text = app + .model_search_input + .lines() + .first() + .cloned() + .unwrap_or_default(); let search_para = if search_text.is_empty() { - Paragraph::new(Span::styled("search models...", Style::default().fg(COLOR_SECONDARY))) + Paragraph::new(Span::styled( + "search models...", + Style::default().fg(COLOR_SECONDARY), + )) } else { Paragraph::new(Span::styled(&search_text, Style::default().fg(COLOR_TEXT))) }; @@ -200,11 +294,15 @@ pub fn render_model_menu(f: &mut Frame, app: &mut App, _input_area: Rect) { let config = crate::ui::try_lock_config(app); match config.as_ref() { None => { - let items: Vec = vec![ListItem::new(Line::from(vec![ - Span::styled(" Loading...", Style::default().fg(COLOR_SECONDARY)) - ]))]; - let list = List::new(items) - .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(COLOR_SECONDARY))); + let items: Vec = vec![ListItem::new(Line::from(vec![Span::styled( + " Loading...", + Style::default().fg(COLOR_SECONDARY), + )]))]; + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(COLOR_SECONDARY)), + ); f.render_widget(list, body_area); return; } @@ -212,34 +310,46 @@ pub fn render_model_menu(f: &mut Frame, app: &mut App, _input_area: Rect) { } }; - let items: Vec = app.filtered_models.iter().map(|item| { - match item { - ModelMenuItem::Header(title) => { - ListItem::new(Line::from(vec![ - Span::styled(format!(" {}", title), Style::default().fg(COLOR_SECONDARY).add_modifier(Modifier::DIM)) - ])) - } + let items: Vec = app + .filtered_models + .iter() + .map(|item| match item { + ModelMenuItem::Header(title) => ListItem::new(Line::from(vec![Span::styled( + format!(" {}", title), + Style::default() + .fg(COLOR_SECONDARY) + .add_modifier(Modifier::DIM), + )])), ModelMenuItem::Model(m) => { - let is_fav = favorites.iter().any(|fav| fav.name == m.name && fav.provider_id == m.provider_id); + let is_fav = favorites + .iter() + .any(|fav| fav.name == m.name && fav.provider_id == m.provider_id); let fav_star = if is_fav { " ★" } else { "" }; - let display_name = clean_model_name(&m.name, &m.provider_id).replace(":free", " Free"); - let p_name = PROVIDERS.iter().find(|p| p.id == m.provider_id).map(|p| p.name).unwrap_or(&m.provider_id); - + let display_name = + clean_model_name(&m.name, &m.provider_id).replace(":free", " Free"); + let p_name = PROVIDERS + .iter() + .find(|p| p.id == m.provider_id) + .map(|p| p.name) + .unwrap_or(&m.provider_id); + let left = format!("{}{}", display_name, fav_star); let right = p_name.to_string(); let total_width = layout[1].width.saturating_sub(4); - let padding = total_width.saturating_sub(left.len() as u16).saturating_sub(right.len() as u16); + let padding = total_width + .saturating_sub(left.len() as u16) + .saturating_sub(right.len() as u16); let spaces = " ".repeat(padding as usize); ListItem::new(Line::from(vec![ Span::raw(format!(" {}", left)), Span::raw(spaces), Span::raw(right), - Span::raw(" ") + Span::raw(" "), ])) } - } - }).collect(); + }) + .collect(); let list = List::new(items) .highlight_style(Style::default().bg(COLOR_PRIMARY).fg(Color::Black)) @@ -248,7 +358,11 @@ pub fn render_model_menu(f: &mut Frame, app: &mut App, _input_area: Rect) { let items_len = app.filtered_models.len(); if app.mouse_moved { if let (Some(col), Some(row)) = (app.mouse_col, app.mouse_row) { - if col >= layout[1].x && col < layout[1].x + layout[1].width && row >= layout[1].y && row < layout[1].y + layout[1].height { + if col >= layout[1].x + && col < layout[1].x + layout[1].width + && row >= layout[1].y + && row < layout[1].y + layout[1].height + { let idx = (row - layout[1].y) as usize + app.menu_state.offset(); if idx < items_len { app.menu_state.select(Some(idx)); @@ -264,33 +378,51 @@ use crate::ui::SettingsMenuItem; pub fn render_settings_menu(f: &mut Frame, app: &mut App, _input_area: Rect) { let height = (app.settings_items.len() + 6).min(15) as u16; - let body_area = draw_modal(f, "Settings", 60, height, app.mouse_col, app.mouse_row, vec![ - Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" toggle setting") - ]); - - let items: Vec = app.settings_items.iter().map(|item| { - match item { - SettingsMenuItem::Header(title) => { - ListItem::new(Line::from(vec![ - Span::styled(format!("[{}]", title), Style::default().fg(COLOR_SECONDARY).add_modifier(Modifier::BOLD)) - ])) - } + let body_area = draw_modal( + f, + "Settings", + 60, + height, + app.mouse_col, + app.mouse_row, + vec![ + Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" toggle setting"), + ], + ); + + let items: Vec = app + .settings_items + .iter() + .map(|item| match item { + SettingsMenuItem::Header(title) => ListItem::new(Line::from(vec![Span::styled( + format!("[{}]", title), + Style::default() + .fg(COLOR_SECONDARY) + .add_modifier(Modifier::BOLD), + )])), SettingsMenuItem::Option { name, val, .. } => { let total_width = body_area.width.saturating_sub(4); let left = format!(" {}", name); let right = val.to_string(); - let padding = total_width.saturating_sub(left.len() as u16).saturating_sub(right.len() as u16); + let padding = total_width + .saturating_sub(left.len() as u16) + .saturating_sub(right.len() as u16); let spaces = " ".repeat(padding as usize); ListItem::new(Line::from(vec![ Span::raw(left), Span::raw(spaces), - Span::styled(right, Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD)), - Span::raw(" ") + Span::styled( + right, + Style::default() + .fg(COLOR_PRIMARY) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), ])) } - } - }).collect(); + }) + .collect(); let list = List::new(items) .highlight_style(Style::default().bg(COLOR_PRIMARY).fg(Color::Black)) @@ -299,12 +431,20 @@ pub fn render_settings_menu(f: &mut Frame, app: &mut App, _input_area: Rect) { let items_len = app.settings_items.len(); if app.mouse_moved { if let (Some(col), Some(row)) = (app.mouse_col, app.mouse_row) { - if col >= body_area.x && col < body_area.x + body_area.width && row >= body_area.y && row < body_area.y + body_area.height { + if col >= body_area.x + && col < body_area.x + body_area.width + && row >= body_area.y + && row < body_area.y + body_area.height + { let idx = (row - body_area.y) as usize + app.menu_state.offset(); if idx < items_len - && !matches!(app.settings_items.get(idx), Some(SettingsMenuItem::Header(_))) { - app.menu_state.select(Some(idx)); - } + && !matches!( + app.settings_items.get(idx), + Some(SettingsMenuItem::Header(_)) + ) + { + app.menu_state.select(Some(idx)); + } } } } diff --git a/apps/cli/src/ui/mod.rs b/apps/cli/src/ui/mod.rs index f1ed86c..7f05818 100644 --- a/apps/cli/src/ui/mod.rs +++ b/apps/cli/src/ui/mod.rs @@ -1,16 +1,16 @@ pub mod app; -pub mod types; pub mod events; -pub mod streaming; pub mod render; +pub mod streaming; +pub mod types; pub mod components; -pub mod welcome; -pub mod session; -pub mod menus; pub mod logic; +pub mod menus; +pub mod session; +pub mod welcome; pub use app::*; -pub use types::*; -pub use render::*; pub use components::*; +pub use render::*; +pub use types::*; diff --git a/apps/cli/src/ui/render.rs b/apps/cli/src/ui/render.rs index 0c6409c..c9fa54b 100644 --- a/apps/cli/src/ui/render.rs +++ b/apps/cli/src/ui/render.rs @@ -9,9 +9,12 @@ use ratatui::{ use std::io; use super::app::App; -use super::components::{COLOR_BG, COLOR_PRIMARY, COLOR_SECONDARY, COLOR_TEXT, COLOR_DIM}; +use super::components::{COLOR_BG, COLOR_DIM, COLOR_PRIMARY, COLOR_SECONDARY, COLOR_TEXT}; use super::events::{handle_key_event, handle_mouse_event, KeyEventResult}; -use super::menus::{render_api_key_dialog, render_menu, render_model_menu, render_provider_menu, render_settings_menu}; +use super::menus::{ + render_api_key_dialog, render_menu, render_model_menu, render_provider_menu, + render_settings_menu, +}; use super::streaming::handle_stream_chunks; use super::types::{ApprovalMode, Screen}; use super::welcome::ui_welcome; @@ -47,7 +50,9 @@ pub async fn run_app( } } } - Event::Paste(text) => { app.input.insert_str(&text); } + Event::Paste(text) => { + app.input.insert_str(&text); + } Event::Mouse(mouse) => { handle_mouse_event(&mut app, mouse, terminal).await?; } @@ -66,9 +71,10 @@ pub async fn run_app( if app.screen == Screen::Session { if let Some((start_time, _, _)) = app.mouse_down_start { if start_time.elapsed() >= std::time::Duration::from_millis(400) - && app.thinking_hover_rendered { - app.temp_expand_thinking = true; - } + && app.thinking_hover_rendered + { + app.temp_expand_thinking = true; + } } } @@ -85,15 +91,29 @@ pub async fn run_app( fn ui(f: &mut Frame, app: &mut App) { let area = f.size(); f.render_widget(Block::default().style(Style::default().bg(COLOR_BG)), area); - let main_layout = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(1), Constraint::Min(0)]).split(area); - let current_dir = std::env::current_dir().map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string()).unwrap_or_else(|_| "workspace".to_string()); + let main_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(0)]) + .split(area); + let current_dir = std::env::current_dir() + .map(|p| { + p.file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() + }) + .unwrap_or_else(|_| "workspace".to_string()); let mode_label = app.approval_mode.label(); let mode_style = match app.approval_mode { ApprovalMode::Normal => Style::default().fg(COLOR_SECONDARY), - ApprovalMode::Plan => Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ApprovalMode::Plan => Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), ApprovalMode::YOLO => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - ApprovalMode::Shell => Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + ApprovalMode::Shell => Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), }; let mode_indicator = format!("[{}]", mode_label); @@ -103,32 +123,58 @@ fn ui(f: &mut Frame, app: &mut App) { header_left.push(Span::raw(" ")); } if !app.hide_cwd { - header_left.push(Span::styled(format!("{} ", current_dir), Style::default().fg(COLOR_SECONDARY))); + header_left.push(Span::styled( + format!("{} ", current_dir), + Style::default().fg(COLOR_SECONDARY), + )); } let header_right_len = if app.hide_model_info { 0u16 } else { 25u16 }; - let header_layout = Layout::default().direction(Direction::Horizontal).constraints([Constraint::Min(0), Constraint::Length(header_right_len)]).split(main_layout[0]); + let header_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(header_right_len)]) + .split(main_layout[0]); f.render_widget(Paragraph::new(Line::from(header_left)), header_layout[0]); if !app.hide_model_info { let version = env!("CARGO_PKG_VERSION"); let header_title = format!(" RouteCode v{} ", version); - f.render_widget(Paragraph::new(Span::styled(header_title, Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD))).alignment(ratatui::layout::Alignment::Right), header_layout[1]); + f.render_widget( + Paragraph::new(Span::styled( + header_title, + Style::default() + .fg(COLOR_PRIMARY) + .add_modifier(Modifier::BOLD), + )) + .alignment(ratatui::layout::Alignment::Right), + header_layout[1], + ); } let input_area = match app.screen { Screen::Welcome => ui_welcome(f, app, main_layout[1]), Screen::Session => super::session::ui_session(f, app, main_layout[1]), }; - if app.show_menu { render_menu(f, app, input_area); } - else if app.show_provider_menu { render_provider_menu(f, app, input_area); } - else if app.show_model_menu { render_model_menu(f, app, input_area); } - else if app.show_settings_menu { render_settings_menu(f, app, input_area); } - else if app.is_inputting_api_key { render_api_key_dialog(f, app); } - else if app.pending_clear { render_confirmation_dialog(f, "Are you sure you want to clear all history? (y/n)"); } - else if app.pending_exit { render_confirmation_dialog(f, "Are you sure you want to exit RouteCode? (y/n)"); } - else if app.pending_command_confirmation.is_some() { render_command_confirmation_dialog(f, app); } - else if app.show_user_msg_modal.is_some() { render_user_msg_modal(f, app); } - else if app.pending_update.is_some() { render_update_modal(f, app); } + if app.show_menu { + render_menu(f, app, input_area); + } else if app.show_provider_menu { + render_provider_menu(f, app, input_area); + } else if app.show_model_menu { + render_model_menu(f, app, input_area); + } else if app.show_settings_menu { + render_settings_menu(f, app, input_area); + } else if app.is_inputting_api_key { + render_api_key_dialog(f, app); + } else if app.pending_clear { + render_confirmation_dialog(f, "Are you sure you want to clear all history? (y/n)"); + } else if app.pending_exit { + render_confirmation_dialog(f, "Are you sure you want to exit RouteCode? (y/n)"); + } else if app.pending_command_confirmation.is_some() { + render_command_confirmation_dialog(f, app); + } else if app.show_user_msg_modal.is_some() { + render_user_msg_modal(f, app); + } else if app.pending_update.is_some() { + render_update_modal(f, app); + } app.mouse_moved = false; } @@ -164,28 +210,61 @@ fn render_command_confirmation_dialog(f: &mut Frame, app: &mut App) { let mut lines = vec![ Line::from(vec![Span::styled(message, Style::default().fg(COLOR_TEXT))]), - Line::from(vec![Span::styled(format!("> {}", target), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))]), + Line::from(vec![Span::styled( + format!("> {}", target), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )]), Line::from(""), ]; if app.inputting_command_feedback { - lines.push(Line::from(vec![Span::styled("Please type your feedback below and press Enter (Esc to cancel):", Style::default().fg(COLOR_SECONDARY))])); + lines.push(Line::from(vec![Span::styled( + "Please type your feedback below and press Enter (Esc to cancel):", + Style::default().fg(COLOR_SECONDARY), + )])); } else { lines.push(Line::from(vec![ - Span::styled("[Y]", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::styled( + "[Y]", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), Span::raw(" Allow once "), - Span::styled("[S]", Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD)), + Span::styled( + "[S]", + Style::default() + .fg(COLOR_PRIMARY) + .add_modifier(Modifier::BOLD), + ), Span::raw(" Allow for session "), - Span::styled("[W]", Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD)), + Span::styled( + "[W]", + Style::default() + .fg(COLOR_PRIMARY) + .add_modifier(Modifier::BOLD), + ), Span::raw(" Allow for Workspace "), - Span::styled("[F]", Style::default().fg(COLOR_SECONDARY).add_modifier(Modifier::BOLD)), + Span::styled( + "[F]", + Style::default() + .fg(COLOR_SECONDARY) + .add_modifier(Modifier::BOLD), + ), Span::raw(" Tell Agent something else "), - Span::styled("[D] or [Esc]", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Span::styled( + "[D] or [Esc]", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), Span::raw(" Deny"), ])); } - let paragraph = Paragraph::new(lines).block(block).wrap(ratatui::widgets::Wrap { trim: false }); + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(ratatui::widgets::Wrap { trim: false }); f.render_widget(ratatui::widgets::Clear, inner_area); f.render_widget(paragraph, inner_area); @@ -196,10 +275,15 @@ fn render_command_confirmation_dialog(f: &mut Frame, app: &mut App) { width: inner_area.width.saturating_sub(4), height: 3, }; - let input_block = Block::default().borders(ratatui::widgets::Borders::ALL).border_style(Style::default().fg(COLOR_PRIMARY)); + let input_block = Block::default() + .borders(ratatui::widgets::Borders::ALL) + .border_style(Style::default().fg(COLOR_PRIMARY)); app.input.set_block(input_block); f.render_widget(app.input.widget(), input_rect); - f.set_cursor(input_rect.x + app.input.cursor().1 as u16 + 1, input_rect.y + app.input.cursor().0 as u16 + 1); + f.set_cursor( + input_rect.x + app.input.cursor().1 as u16 + 1, + input_rect.y + app.input.cursor().0 as u16 + 1, + ); } } @@ -228,9 +312,12 @@ fn render_confirmation_dialog(f: &mut Frame, message: &str) { .borders(ratatui::widgets::Borders::ALL) .border_style(Style::default().fg(COLOR_PRIMARY)); - let p = Paragraph::new(Span::styled(message, Style::default().fg(COLOR_TEXT).add_modifier(Modifier::BOLD))) - .alignment(ratatui::layout::Alignment::Center) - .block(block); + let p = Paragraph::new(Span::styled( + message, + Style::default().fg(COLOR_TEXT).add_modifier(Modifier::BOLD), + )) + .alignment(ratatui::layout::Alignment::Center) + .block(block); f.render_widget(ratatui::widgets::Clear, popup_horiz[1]); f.render_widget(p, popup_horiz[1]); @@ -246,7 +333,10 @@ pub(crate) fn copy_to_clipboard(text: &str) -> std::io::Result<()> { } else { ("xclip", &["-selection", "clipboard"]) }; - let mut child = Command::new(prog).args(args).stdin(Stdio::piped()).spawn()?; + let mut child = Command::new(prog) + .args(args) + .stdin(Stdio::piped()) + .spawn()?; if let Some(mut stdin) = child.stdin.take() { stdin.write_all(text.as_bytes())?; } @@ -288,7 +378,10 @@ fn render_user_msg_modal(f: &mut Frame, app: &mut App) { let options = ["Copy Message", "Rewind & Edit"]; let mut lines = vec![ - Line::from(vec![Span::styled(" Choose an action:", Style::default().fg(COLOR_SECONDARY))]), + Line::from(vec![Span::styled( + " Choose an action:", + Style::default().fg(COLOR_SECONDARY), + )]), Line::from(""), ]; @@ -296,7 +389,9 @@ fn render_user_msg_modal(f: &mut Frame, app: &mut App) { let is_selected = idx == app.user_msg_modal_selected; let prefix = if is_selected { " -> " } else { " " }; let style = if is_selected { - Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD) + Style::default() + .fg(COLOR_PRIMARY) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(COLOR_TEXT) }; @@ -307,7 +402,10 @@ fn render_user_msg_modal(f: &mut Frame, app: &mut App) { } lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled(" Press Enter/Click to select, Esc to close", Style::default().fg(COLOR_DIM))])); + lines.push(Line::from(vec![Span::styled( + " Press Enter/Click to select, Esc to close", + Style::default().fg(COLOR_DIM), + )])); let paragraph = Paragraph::new(lines).block(block); f.render_widget(ratatui::widgets::Clear, inner_area); @@ -322,9 +420,15 @@ fn render_update_modal(f: &mut Frame, app: &mut App) { let popup_layout = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Percentage(((area.height as f32 * 0.3) as u16).min(area.height.saturating_sub(modal_height + 2))), + Constraint::Percentage( + ((area.height as f32 * 0.3) as u16) + .min(area.height.saturating_sub(modal_height + 2)), + ), Constraint::Length(modal_height), - Constraint::Percentage(((area.height as f32 * 0.3) as u16).min(area.height.saturating_sub(modal_height + 2))), + Constraint::Percentage( + ((area.height as f32 * 0.3) as u16) + .min(area.height.saturating_sub(modal_height + 2)), + ), ]) .split(area); @@ -344,8 +448,20 @@ fn render_update_modal(f: &mut Frame, app: &mut App) { let block = Block::default() .borders(ratatui::widgets::Borders::ALL) .border_style(Style::default().fg(COLOR_PRIMARY)) - .title(ratatui::widgets::block::Title::from(Span::styled(" Update Available ", Style::default().fg(COLOR_TEXT).add_modifier(Modifier::BOLD))).alignment(ratatui::layout::Alignment::Left)) - .title(ratatui::widgets::block::Title::from(Span::styled(" esc ", Style::default().fg(COLOR_DIM))).alignment(ratatui::layout::Alignment::Right)) + .title( + ratatui::widgets::block::Title::from(Span::styled( + " Update Available ", + Style::default().fg(COLOR_TEXT).add_modifier(Modifier::BOLD), + )) + .alignment(ratatui::layout::Alignment::Left), + ) + .title( + ratatui::widgets::block::Title::from(Span::styled( + " esc ", + Style::default().fg(COLOR_DIM), + )) + .alignment(ratatui::layout::Alignment::Right), + ) .style(Style::default().bg(COLOR_BG)); f.render_widget(block, inner_area); @@ -358,15 +474,29 @@ fn render_update_modal(f: &mut Frame, app: &mut App) { }; let mut lines = vec![ - Line::from(vec![Span::styled(format!("Version {} is available (current: {})", version, env!("CARGO_PKG_VERSION")), Style::default().fg(COLOR_TEXT))]), + Line::from(vec![Span::styled( + format!( + "Version {} is available (current: {})", + version, + env!("CARGO_PKG_VERSION") + ), + Style::default().fg(COLOR_TEXT), + )]), Line::from(""), ]; if !app.pending_update_changelog.is_empty() { let changelog_lines: Vec<&str> = app.pending_update_changelog.lines().take(5).collect(); for line in changelog_lines { - let trimmed = if line.len() > 60 { format!("{}...", &line[..57]) } else { line.to_string() }; - lines.push(Line::from(vec![Span::styled(trimmed.to_string(), Style::default().fg(COLOR_SECONDARY))])); + let trimmed = if line.len() > 60 { + format!("{}...", &line[..57]) + } else { + line.to_string() + }; + lines.push(Line::from(vec![Span::styled( + trimmed.to_string(), + Style::default().fg(COLOR_SECONDARY), + )])); } } @@ -380,8 +510,22 @@ fn render_update_modal(f: &mut Frame, app: &mut App) { height: 1, }; - let skip_style = if app.update_modal_selected == 0 { Style::default().fg(Color::Black).bg(COLOR_TEXT).add_modifier(Modifier::BOLD) } else { Style::default().fg(COLOR_DIM) }; - let confirm_style = if app.update_modal_selected == 1 { Style::default().fg(Color::Black).bg(Color::Rgb(255, 179, 138)).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Rgb(255, 179, 138)) }; + let skip_style = if app.update_modal_selected == 0 { + Style::default() + .fg(Color::Black) + .bg(COLOR_TEXT) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(COLOR_DIM) + }; + let confirm_style = if app.update_modal_selected == 1 { + Style::default() + .fg(Color::Black) + .bg(Color::Rgb(255, 179, 138)) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Rgb(255, 179, 138)) + }; let buttons = Line::from(vec![ Span::styled(" Skip ", skip_style), @@ -396,7 +540,9 @@ fn render_update_modal(f: &mut Frame, app: &mut App) { /// Try to lock config with short retry loop for use in sync render paths. /// Rare contention from config saves resolves within microseconds; this avoids /// silently showing "Loading..." or stale fallbacks. -pub fn try_lock_config(app: &App) -> Option> { +pub fn try_lock_config( + app: &App, +) -> Option> { for _ in 0..10 { match app.orchestrator.config.try_lock() { Ok(guard) => return Some(guard), diff --git a/apps/cli/src/ui/session.rs b/apps/cli/src/ui/session.rs index 6655397..82c56ce 100644 --- a/apps/cli/src/ui/session.rs +++ b/apps/cli/src/ui/session.rs @@ -1,18 +1,25 @@ +use crate::ui::components::{ + clean_model_name, COLOR_DIM, COLOR_INPUT_BG, COLOR_PRIMARY, COLOR_SECONDARY, COLOR_SUCCESS, + COLOR_SYSTEM, COLOR_TEXT, +}; +use crate::ui::App; use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style, Color}; +use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::Frame; use routecode_sdk::core::{Message, Role}; use unicode_width::UnicodeWidthStr; -use crate::ui::App; -use crate::ui::components::{COLOR_INPUT_BG, COLOR_PRIMARY, COLOR_SECONDARY, COLOR_TEXT, COLOR_DIM, COLOR_SYSTEM, COLOR_SUCCESS, clean_model_name}; pub fn ui_session(f: &mut Frame, app: &mut App, area: Rect) -> Rect { let input_height = (app.input.lines().len() as u16 + 2).min(12); let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Min(1), Constraint::Length(input_height), Constraint::Length(1)]) + .constraints([ + Constraint::Min(1), + Constraint::Length(input_height), + Constraint::Length(1), + ]) .split(area); // Compute thinking hover at render time using actual frame dimensions @@ -43,27 +50,37 @@ pub fn ui_session(f: &mut Frame, app: &mut App, area: Rect) -> Rect { for (msg_idx, m) in app.history.iter().enumerate() { let msg_slice = std::slice::from_ref(m); - let msg_text = render_history(msg_slice, is_collapsed, thinking_hovered, hovered_msg_idx, msg_idx); - + let msg_text = render_history( + msg_slice, + is_collapsed, + thinking_hovered, + hovered_msg_idx, + msg_idx, + ); + for line in msg_text.lines { let line_width: usize = line.spans.iter().map(|s| s.content.width()).sum(); - let wrapped_height = if line_width == 0 { 1 } else { + let wrapped_height = if line_width == 0 { + 1 + } else { (line_width + calc_width - 1) / calc_width.max(1) }; - + let is_thinking = line.spans.iter().any(|span| { - span.content.contains('\u{2502}') || span.content.contains('\u{2503}') || span.content.contains("Thinking...") + span.content.contains('\u{2502}') + || span.content.contains('\u{2503}') + || span.content.contains("Thinking...") }); for _ in 0..wrapped_height { layout.push((msg_idx, is_thinking)); } - + total_height += wrapped_height; lines.push(line); } } - + let history = Text::from(lines); app.cached_layout = layout; // Safety buffer @@ -80,10 +97,12 @@ pub fn ui_session(f: &mut Frame, app: &mut App, area: Rect) -> Rect { let history_text = app.cached_text.as_ref().unwrap().clone(); let total_height = app.cached_total_height; - - let max_scroll = total_height.saturating_sub(chunks[0].height as usize).min(u16::MAX as usize) as u16; + + let max_scroll = total_height + .saturating_sub(chunks[0].height as usize) + .min(u16::MAX as usize) as u16; app.max_scroll = max_scroll; - + if app.auto_scroll { app.history_scroll = max_scroll; } else { @@ -94,24 +113,35 @@ pub fn ui_session(f: &mut Frame, app: &mut App, area: Rect) -> Rect { } } - f.render_widget(Paragraph::new(history_text).wrap(Wrap { trim: false }).scroll((app.history_scroll, 0)), chunks[0]); + f.render_widget( + Paragraph::new(history_text) + .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], + ); - 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) + chunks[1].height.saturating_sub(2), ); app.input.set_block(Block::default().borders(Borders::NONE)); f.render_widget(app.input.widget(), inner_input_area); - f.set_cursor(inner_input_area.x + app.input.cursor().1 as u16, inner_input_area.y + app.input.cursor().0 as u16); + f.set_cursor( + inner_input_area.x + app.input.cursor().1 as u16, + inner_input_area.y + app.input.cursor().0 as u16, + ); let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let frame = spinner[(app.tick_count % spinner.len() as u64) as usize]; - + let generating_text = if app.is_generating { if let Some(tool) = &app.active_tool { format!(" {} [Running {}...] ", frame, tool) @@ -123,11 +153,11 @@ pub fn ui_session(f: &mut Frame, app: &mut App, area: Rect) -> Rect { }; let cleaned_model = clean_model_name(&app.current_model, &app.current_provider_id); - + let config_thinking = crate::ui::try_lock_config(app) .map(|c| c.thinking_level.clone()) .unwrap_or("default".to_string()); - + let thinking_tag = if config_thinking != "default" { format!(" • [{}] ", config_thinking) } else { @@ -144,8 +174,18 @@ pub fn ui_session(f: &mut Frame, app: &mut App, area: Rect) -> Rect { let mut left_spans: Vec = Vec::new(); if !app.hide_model_info { - left_spans.push(Span::styled(format!(" {} ", cleaned_model), Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD))); - left_spans.push(Span::styled(thinking_tag, Style::default().fg(COLOR_SYSTEM).add_modifier(Modifier::BOLD))); + left_spans.push(Span::styled( + format!(" {} ", cleaned_model), + Style::default() + .fg(COLOR_PRIMARY) + .add_modifier(Modifier::BOLD), + )); + left_spans.push(Span::styled( + thinking_tag, + Style::default() + .fg(COLOR_SYSTEM) + .add_modifier(Modifier::BOLD), + )); } if let Some(qir) = app.qir_retry_status { let (color, label) = if qir.is_recovered() { @@ -153,26 +193,48 @@ pub fn ui_session(f: &mut Frame, app: &mut App, area: Rect) -> Rect { } else { (COLOR_SYSTEM, format!(" ∞ {} ", qir.label())) }; - left_spans.push(Span::styled(label, Style::default().fg(color).add_modifier(Modifier::BOLD))); + left_spans.push(Span::styled( + label, + Style::default().fg(color).add_modifier(Modifier::BOLD), + )); } if !app.hide_context_summary { - let mut summary = format!(" • Tokens: {} • Cost: ${:.4}", app.usage.total_tokens, app.usage.total_cost); + let mut summary = format!( + " • Tokens: {} • Cost: ${:.4}", + app.usage.total_tokens, app.usage.total_cost + ); if app.usage.qir_attempts > 0 { summary.push_str(&format!(" • Retries: {}", app.usage.qir_attempts)); } left_spans.push(Span::styled(summary, Style::default().fg(COLOR_SECONDARY))); - left_spans.push(Span::styled(format!(" • Scroll: {}/{} ", app.history_scroll, app.max_scroll), Style::default().fg(COLOR_SECONDARY).add_modifier(Modifier::DIM))); + left_spans.push(Span::styled( + format!(" • Scroll: {}/{} ", app.history_scroll, app.max_scroll), + Style::default() + .fg(COLOR_SECONDARY) + .add_modifier(Modifier::DIM), + )); } - left_spans.push(Span::styled(generating_text, Style::default().fg(COLOR_SYSTEM))); + left_spans.push(Span::styled( + generating_text, + Style::default().fg(COLOR_SYSTEM), + )); if !app.hide_context_summary { - left_spans.push(Span::styled(" • ctrl+o toggle thinking • ctrl+p help ", Style::default().fg(COLOR_SECONDARY).add_modifier(Modifier::DIM))); + left_spans.push(Span::styled( + " • ctrl+o toggle thinking • ctrl+p help ", + Style::default() + .fg(COLOR_SECONDARY) + .add_modifier(Modifier::DIM), + )); } let left_status = Line::from(left_spans); let right_status = Paragraph::new(Span::styled( format!(" {} ", app.provider_name), - Style::default().fg(COLOR_SECONDARY).add_modifier(Modifier::BOLD) - )).alignment(ratatui::layout::Alignment::Right); + Style::default() + .fg(COLOR_SECONDARY) + .add_modifier(Modifier::BOLD), + )) + .alignment(ratatui::layout::Alignment::Right); f.render_widget(Paragraph::new(left_status), status_layout[0]); f.render_widget(right_status, status_layout[1]); @@ -194,34 +256,43 @@ pub fn render_history( Role::User => { let is_hovered = Some(original_idx) == hovered_msg_idx; let user_style = if is_hovered { - Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD).add_modifier(Modifier::UNDERLINED) + Style::default() + .fg(COLOR_PRIMARY) + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::UNDERLINED) } else { - Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD) + Style::default() + .fg(COLOR_PRIMARY) + .add_modifier(Modifier::BOLD) }; let tag = if is_hovered { " ● User (Click to edit/copy)" } else { " ● User" }; - lines.push(Line::from(vec![ - Span::styled(tag, user_style), - ])); + lines.push(Line::from(vec![Span::styled(tag, user_style)])); if let Some(content) = &m.content { for line in content.lines() { let text_style = if is_hovered { - Style::default().fg(COLOR_TEXT).add_modifier(Modifier::UNDERLINED) + Style::default() + .fg(COLOR_TEXT) + .add_modifier(Modifier::UNDERLINED) } else { Style::default().fg(COLOR_TEXT) }; - lines.push(Line::from(vec![Span::raw(" "), Span::styled(line.to_string(), text_style)])); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(line.to_string(), text_style), + ])); } } } Role::Assistant => { - lines.push(Line::from(vec![ - Span::styled(" ● RouteCode", Style::default().fg(COLOR_TEXT).add_modifier(Modifier::BOLD)), - ])); - + lines.push(Line::from(vec![Span::styled( + " ● RouteCode", + Style::default().fg(COLOR_TEXT).add_modifier(Modifier::BOLD), + )])); + if let Some(thought) = &m.thought { if collapse_thinking { if thinking_hovered { @@ -232,33 +303,68 @@ pub fn render_history( } else { lines.push(Line::from(vec![ Span::styled(" │ ", Style::default().fg(COLOR_DIM)), - Span::styled("▶ Thinking... (ctrl+o to expand)", Style::default().fg(COLOR_SECONDARY).add_modifier(Modifier::ITALIC)), + Span::styled( + "▶ Thinking... (ctrl+o to expand)", + Style::default() + .fg(COLOR_SECONDARY) + .add_modifier(Modifier::ITALIC), + ), ])); } } else { if thinking_hovered { lines.push(Line::from(vec![ - Span::styled(" ┃ ", Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD)), - Span::styled("▼ Thinking... (Double click to collapse)", Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD)), + Span::styled( + " ┃ ", + Style::default() + .fg(COLOR_PRIMARY) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "▼ Thinking... (Double click to collapse)", + Style::default() + .fg(COLOR_PRIMARY) + .add_modifier(Modifier::BOLD), + ), ])); } else { lines.push(Line::from(vec![ Span::styled(" │ ", Style::default().fg(COLOR_DIM)), - Span::styled("▼ Thinking... (ctrl+o to collapse)", Style::default().fg(COLOR_SECONDARY).add_modifier(Modifier::ITALIC)), + Span::styled( + "▼ Thinking... (ctrl+o to collapse)", + Style::default() + .fg(COLOR_SECONDARY) + .add_modifier(Modifier::ITALIC), + ), ])); } let guide = if thinking_hovered { - Span::styled(" ┃ ", Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD)) + Span::styled( + " ┃ ", + Style::default() + .fg(COLOR_PRIMARY) + .add_modifier(Modifier::BOLD), + ) } else { Span::styled(" │ ", Style::default().fg(COLOR_DIM)) }; - + for line in thought.lines() { let text = if thinking_hovered { - Span::styled(line.to_string(), Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::ITALIC)) + Span::styled( + line.to_string(), + Style::default() + .fg(COLOR_PRIMARY) + .add_modifier(Modifier::ITALIC), + ) } else { - Span::styled(line.to_string(), Style::default().fg(COLOR_SECONDARY).add_modifier(Modifier::ITALIC)) + Span::styled( + line.to_string(), + Style::default() + .fg(COLOR_SECONDARY) + .add_modifier(Modifier::ITALIC), + ) }; lines.push(Line::from(vec![guide.clone(), text])); } @@ -267,17 +373,26 @@ pub fn render_history( if let Some(tool_calls) = &m.tool_calls { for tc in tool_calls { - let args: serde_json::Value = serde_json::from_str(&tc.function.arguments).unwrap_or(serde_json::json!({})); + 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)), + 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), + ), ])); } } @@ -288,19 +403,24 @@ pub fn render_history( let mut i = 0; while i < raw_lines.len() { let line = raw_lines[i]; - + let mut table_start = None; if !in_code_block && line.contains('|') { if i + 1 < raw_lines.len() && is_delimiter_row(raw_lines[i + 1]) { table_start = Some((i + 1, vec![line])); - } else if i + 2 < raw_lines.len() && raw_lines[i + 1].contains('|') && is_delimiter_row(raw_lines[i + 2]) { + } else if i + 2 < raw_lines.len() + && raw_lines[i + 1].contains('|') + && is_delimiter_row(raw_lines[i + 2]) + { table_start = Some((i + 2, vec![line, raw_lines[i + 1]])); } } if let Some((delimiter_idx, header_lines)) = table_start { - let parsed_headers: Vec> = header_lines.iter().map(|l| parse_table_row(l)).collect(); - let num_cols = parsed_headers.iter().map(|h| h.len()).max().unwrap_or(0); + let parsed_headers: Vec> = + header_lines.iter().map(|l| parse_table_row(l)).collect(); + let num_cols = + parsed_headers.iter().map(|h| h.len()).max().unwrap_or(0); let mut header_row = vec![String::new(); num_cols]; for h in &parsed_headers { for col_idx in 0..num_cols { @@ -315,30 +435,41 @@ pub fn render_history( } } } - + let delimiter_row = parse_table_row(raw_lines[delimiter_idx]); let mut alignments = Vec::new(); for cell in delimiter_row { alignments.push(parse_alignment(&cell)); } - + while alignments.len() < num_cols { alignments.push(TableAlignment::Left); } - + let mut rows = Vec::new(); let mut j = delimiter_idx + 1; - while j < raw_lines.len() && raw_lines[j].contains('|') && !is_delimiter_row(raw_lines[j]) { + while j < raw_lines.len() + && raw_lines[j].contains('|') + && !is_delimiter_row(raw_lines[j]) + { rows.push(parse_table_row(raw_lines[j])); j += 1; } - + render_table_spans(&header_row, &alignments, &rows, &mut lines); i = j; } else { if line.trim().starts_with("```") { in_code_block = !in_code_block; - lines.push(Line::from(vec![Span::raw(" "), Span::styled(line.to_string(), Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD))])); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + line.to_string(), + Style::default() + .fg(COLOR_PRIMARY) + .add_modifier(Modifier::BOLD), + ), + ])); } else { let mut line_spans = vec![Span::raw(" ")]; line_spans.extend(parse_markdown_line(line, in_code_block)); @@ -350,11 +481,14 @@ pub fn render_history( } } Role::Tool => { - lines.push(Line::from(vec![ - Span::styled(format!(" ✓ Tool ({})", m.name.as_deref().unwrap_or("result")), Style::default().fg(COLOR_SECONDARY)), - ])); + 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 { - if let Ok(res) = serde_json::from_str::(content) { + 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('+') { @@ -364,32 +498,66 @@ pub fn render_history( } else { Style::default().fg(COLOR_DIM) }; - lines.push(Line::from(vec![Span::raw(" "), Span::styled(line.to_string(), style)])); + 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 { - let end = out.char_indices().map(|(i, _)| i).take_while(|&i| i <= 100).last().unwrap_or(0); + let end = out + .char_indices() + .map(|(i, _)| i) + .take_while(|&i| i <= 100) + .last() + .unwrap_or(0); format!("{}...", &out[..end]) - } else { out }; - lines.push(Line::from(vec![Span::styled(format!(" {}", preview), Style::default().fg(COLOR_DIM).add_modifier(Modifier::DIM))])); + } 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))])); + lines.push(Line::from(vec![Span::styled( + format!(" Error: {}", err), + Style::default().fg(Color::Red), + )])); } } else { let preview = if content.len() > 100 { - let end = content.char_indices().map(|(i, _)| i).take_while(|&i| i <= 100).last().unwrap_or(0); + let end = content + .char_indices() + .map(|(i, _)| i) + .take_while(|&i| i <= 100) + .last() + .unwrap_or(0); format!("{}...", &content[..end]) - } else { content.to_string() }; - lines.push(Line::from(vec![Span::styled(format!(" {}", preview), Style::default().fg(COLOR_DIM).add_modifier(Modifier::DIM))])); + } else { + content.to_string() + }; + lines.push(Line::from(vec![Span::styled( + format!(" {}", preview), + Style::default().fg(COLOR_DIM).add_modifier(Modifier::DIM), + )])); } } } Role::System => { - lines.push(Line::from(vec![ - Span::styled(" ● System", Style::default().fg(COLOR_SYSTEM).add_modifier(Modifier::DIM)), - ])); + lines.push(Line::from(vec![Span::styled( + " ● System", + Style::default() + .fg(COLOR_SYSTEM) + .add_modifier(Modifier::DIM), + )])); if let Some(content) = &m.content { - lines.push(Line::from(vec![Span::styled(format!(" {}", content), Style::default().fg(COLOR_SYSTEM).add_modifier(Modifier::DIM))])); + lines.push(Line::from(vec![Span::styled( + format!(" {}", content), + Style::default() + .fg(COLOR_SYSTEM) + .add_modifier(Modifier::DIM), + )])); } } } @@ -400,20 +568,37 @@ pub fn render_history( fn parse_markdown_line(line: &str, in_code_block: bool) -> Vec> { if in_code_block { - return vec![Span::styled(line.to_string(), Style::default().fg(COLOR_SECONDARY))]; + return vec![Span::styled( + line.to_string(), + Style::default().fg(COLOR_SECONDARY), + )]; } let trimmed = line.trim_start(); let indent = &line[..line.len() - trimmed.len()]; - - if trimmed.starts_with("# ") || trimmed.starts_with("## ") || trimmed.starts_with("### ") || trimmed.starts_with("#### ") { + + if trimmed.starts_with("# ") + || trimmed.starts_with("## ") + || trimmed.starts_with("### ") + || trimmed.starts_with("#### ") + { let mut spans = vec![Span::raw(indent.to_string())]; - spans.push(Span::styled(trimmed.to_string(), Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD))); + spans.push(Span::styled( + trimmed.to_string(), + Style::default() + .fg(COLOR_PRIMARY) + .add_modifier(Modifier::BOLD), + )); return spans; } else if trimmed.starts_with("- ") || trimmed.starts_with("* ") { let mut spans = vec![ Span::raw(indent.to_string()), - Span::styled(trimmed[..2].to_string(), Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD)), + Span::styled( + trimmed[..2].to_string(), + Style::default() + .fg(COLOR_PRIMARY) + .add_modifier(Modifier::BOLD), + ), ]; spans.extend(parse_inline_markdown(&trimmed[2..])); return spans; @@ -440,8 +625,12 @@ fn parse_inline_markdown(text: &str) -> Vec> { if code { style = style.fg(COLOR_PRIMARY); } else { - if bold { style = style.add_modifier(Modifier::BOLD); } - if italic { style = style.add_modifier(Modifier::ITALIC); } + if bold { + style = style.add_modifier(Modifier::BOLD); + } + if italic { + style = style.add_modifier(Modifier::ITALIC); + } } spans.push(Span::styled(current.clone(), style)); current.clear(); @@ -456,8 +645,12 @@ fn parse_inline_markdown(text: &str) -> Vec> { chars.next(); // Consume second '*' if !current.is_empty() { let mut style = Style::default(); - if bold { style = style.add_modifier(Modifier::BOLD); } - if italic { style = style.add_modifier(Modifier::ITALIC); } + if bold { + style = style.add_modifier(Modifier::BOLD); + } + if italic { + style = style.add_modifier(Modifier::ITALIC); + } spans.push(Span::styled(current.clone(), style)); current.clear(); } @@ -467,8 +660,12 @@ fn parse_inline_markdown(text: &str) -> Vec> { } if !current.is_empty() { let mut style = Style::default(); - if bold { style = style.add_modifier(Modifier::BOLD); } - if italic { style = style.add_modifier(Modifier::ITALIC); } + if bold { + style = style.add_modifier(Modifier::BOLD); + } + if italic { + style = style.add_modifier(Modifier::ITALIC); + } spans.push(Span::styled(current.clone(), style)); current.clear(); } @@ -484,8 +681,12 @@ fn parse_inline_markdown(text: &str) -> Vec> { if code { style = style.fg(COLOR_PRIMARY); } else { - if bold { style = style.add_modifier(Modifier::BOLD); } - if italic { style = style.add_modifier(Modifier::ITALIC); } + if bold { + style = style.add_modifier(Modifier::BOLD); + } + if italic { + style = style.add_modifier(Modifier::ITALIC); + } } spans.push(Span::styled(current, style)); } @@ -505,7 +706,9 @@ fn is_delimiter_row(line: &str) -> bool { if trimmed.is_empty() { return false; } - trimmed.chars().all(|c| c == '-' || c == ':' || c == '|' || c.is_whitespace()) + trimmed + .chars() + .all(|c| c == '-' || c == ':' || c == '|' || c.is_whitespace()) } fn parse_table_row(line: &str) -> Vec { @@ -514,7 +717,7 @@ fn parse_table_row(line: &str) -> Vec { .split('|') .map(|s| s.trim().to_string()) .collect(); - + if trimmed_line.starts_with('|') && !cells.is_empty() { cells.remove(0); } @@ -542,7 +745,7 @@ fn pad_cell(text: &str, width: usize, alignment: TableAlignment) -> String { if text_len >= width { return text.to_string(); } - + let total_pad = width - text_len; match alignment { TableAlignment::Left => { @@ -573,7 +776,7 @@ fn render_table_spans( if num_cols == 0 { return; } - + let mut col_widths = vec![0; num_cols]; for (col_idx, cell) in header_row.iter().enumerate() { col_widths[col_idx] = col_widths[col_idx].max(cell.chars().count()); @@ -585,11 +788,11 @@ fn render_table_spans( } } } - + for w in col_widths.iter_mut() { *w += 2; } - + let mut top_line = String::from(" ┌"); for (idx, &w) in col_widths.iter().enumerate() { top_line.push_str(&"─".repeat(w)); @@ -599,17 +802,25 @@ fn render_table_spans( top_line.push('┐'); } } - lines.push(Line::from(vec![Span::styled(top_line, Style::default().fg(COLOR_DIM))])); - + lines.push(Line::from(vec![Span::styled( + top_line, + Style::default().fg(COLOR_DIM), + )])); + let mut header_spans = vec![Span::styled(" │", Style::default().fg(COLOR_DIM))]; for (idx, cell) in header_row.iter().enumerate() { let width = col_widths[idx]; let padded = pad_cell(cell, width, alignments[idx]); - header_spans.push(Span::styled(padded, Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD))); + header_spans.push(Span::styled( + padded, + Style::default() + .fg(COLOR_PRIMARY) + .add_modifier(Modifier::BOLD), + )); header_spans.push(Span::styled("│", Style::default().fg(COLOR_DIM))); } lines.push(Line::from(header_spans)); - + let mut mid_line = String::from(" ├"); for (idx, &w) in col_widths.iter().enumerate() { mid_line.push_str(&"─".repeat(w)); @@ -619,29 +830,44 @@ fn render_table_spans( mid_line.push('┤'); } } - lines.push(Line::from(vec![Span::styled(mid_line, Style::default().fg(COLOR_DIM))])); - + lines.push(Line::from(vec![Span::styled( + mid_line, + Style::default().fg(COLOR_DIM), + )])); + for row in rows { let mut row_spans = vec![Span::styled(" │", Style::default().fg(COLOR_DIM))]; for col_idx in 0..num_cols { let width = col_widths[col_idx]; - let cell_text = if col_idx < row.len() { &row[col_idx] } else { "" }; + let cell_text = if col_idx < row.len() { + &row[col_idx] + } else { + "" + }; let padded = pad_cell(cell_text, width, alignments[col_idx]); - - let cell_style = if cell_text.contains("Complete") || cell_text.contains("Active") || cell_text.contains("[x]") || cell_text.contains("✅") { - Style::default().fg(COLOR_SUCCESS).add_modifier(Modifier::BOLD) + + let cell_style = if cell_text.contains("Complete") + || cell_text.contains("Active") + || cell_text.contains("[x]") + || cell_text.contains("✅") + { + Style::default() + .fg(COLOR_SUCCESS) + .add_modifier(Modifier::BOLD) } else if cell_text.contains("Pending") || cell_text.contains("Waiting") { - Style::default().fg(COLOR_SYSTEM).add_modifier(Modifier::BOLD) + Style::default() + .fg(COLOR_SYSTEM) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(COLOR_TEXT) }; - + row_spans.push(Span::styled(padded, cell_style)); row_spans.push(Span::styled("│", Style::default().fg(COLOR_DIM))); } lines.push(Line::from(row_spans)); } - + let mut bot_line = String::from(" └"); for (idx, &w) in col_widths.iter().enumerate() { bot_line.push_str(&"─".repeat(w)); @@ -651,5 +877,8 @@ fn render_table_spans( bot_line.push('┘'); } } - lines.push(Line::from(vec![Span::styled(bot_line, Style::default().fg(COLOR_DIM))])); + lines.push(Line::from(vec![Span::styled( + bot_line, + Style::default().fg(COLOR_DIM), + )])); } diff --git a/apps/cli/src/ui/streaming.rs b/apps/cli/src/ui/streaming.rs index a46bb50..191e99e 100644 --- a/apps/cli/src/ui/streaming.rs +++ b/apps/cli/src/ui/streaming.rs @@ -23,40 +23,102 @@ pub(crate) async fn handle_stream_chunks(app: &mut App) { StreamChunk::Text { content } => { if let Some(last) = app.history.last_mut() { if last.role == Role::Assistant { - let mut current = last.content.as_ref().map(|s| s.to_string()).unwrap_or_default(); + let mut current = last + .content + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_default(); current.push_str(&content); last.content = Some(std::sync::Arc::from(current)); - } else { app.history.push(Message::assistant(Some(std::sync::Arc::from(content)), None, None)); } - } else { app.history.push(Message::assistant(Some(std::sync::Arc::from(content)), None, None)); } + } else { + app.history.push(Message::assistant( + Some(std::sync::Arc::from(content)), + None, + None, + )); + } + } else { + app.history.push(Message::assistant( + Some(std::sync::Arc::from(content)), + None, + None, + )); + } } StreamChunk::Thought { content } => { if let Some(last) = app.history.last_mut() { if last.role == Role::Assistant { - let mut current = last.thought.as_ref().map(|s| s.to_string()).unwrap_or_default(); + let mut current = last + .thought + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_default(); current.push_str(&content); last.thought = Some(std::sync::Arc::from(current)); - } else { app.history.push(Message::assistant(None, Some(std::sync::Arc::from(content)), None)); } - } else { app.history.push(Message::assistant(None, Some(std::sync::Arc::from(content)), None)); } + } else { + app.history.push(Message::assistant( + None, + Some(std::sync::Arc::from(content)), + None, + )); + } + } else { + app.history.push(Message::assistant( + None, + Some(std::sync::Arc::from(content)), + None, + )); + } } StreamChunk::ToolCall { tool_call } => { app.active_tool = Some(tool_call.function.name.clone()); if let Some(last) = app.history.last_mut() { if last.role == Role::Assistant { let mut calls = last.tool_calls.clone().unwrap_or_default(); - if let Some(idx) = tool_call.index { if let Some(existing) = calls.iter_mut().find(|tc| tc.index == Some(idx)) { *existing = tool_call; } else { calls.push(tool_call); } } - else if !calls.iter().any(|tc| tc.id == tool_call.id && !tc.id.is_empty()) { calls.push(tool_call); } + if let Some(idx) = tool_call.index { + if let Some(existing) = + calls.iter_mut().find(|tc| tc.index == Some(idx)) + { + *existing = tool_call; + } else { + calls.push(tool_call); + } + } else if !calls + .iter() + .any(|tc| tc.id == tool_call.id && !tc.id.is_empty()) + { + calls.push(tool_call); + } last.tool_calls = Some(calls); - } else { app.history.push(Message::assistant(None, None, Some(vec![tool_call]))); } - } else { app.history.push(Message::assistant(None, None, Some(vec![tool_call]))); } + } else { + app.history + .push(Message::assistant(None, None, Some(vec![tool_call]))); + } + } else { + app.history + .push(Message::assistant(None, None, Some(vec![tool_call]))); + } + } + StreamChunk::ToolResult { + name, + content, + tool_call_id, + } => { + app.active_tool = None; + app.history.push(Message::tool(tool_call_id, name, content)); } - StreamChunk::ToolResult { name, content, tool_call_id } => { app.active_tool = None; app.history.push(Message::tool(tool_call_id, name, content)); } StreamChunk::Status { content } => { if let Some(qir) = parse_qir_status(&content) { app.qir_retry_status = Some(qir); } - app.history.push(Message::system(format!("[QIR] {}", content))); + app.history + .push(Message::system(format!("[QIR] {}", content))); } - StreamChunk::SessionStats { total_tokens, total_cost, qir_attempts } => { + StreamChunk::SessionStats { + total_tokens, + total_cost, + qir_attempts, + } => { app.usage.total_tokens = total_tokens; app.usage.total_cost = total_cost; app.usage.qir_attempts = qir_attempts; @@ -72,21 +134,29 @@ pub(crate) async fn handle_stream_chunks(app: &mut App) { usage: app.orchestrator.usage.lock().await.clone(), timestamp: chrono::Utc::now().timestamp(), }; - if let Err(e) = routecode_sdk::utils::storage::save_session(&app.session_id, &session) { + if let Err(e) = + routecode_sdk::utils::storage::save_session(&app.session_id, &session) + { log::error!("Failed to auto-save session: {}", e); } } } StreamChunk::Error { content } => { let display = format_error_for_display(&content); - app.history.push(Message::system(format!("Error: {}", display))); + app.history + .push(Message::system(format!("Error: {}", display))); app.is_generating = false; app.active_tool = None; app.qir_retry_status = None; } StreamChunk::Models { models } => { app.all_available_models.extend(models); - let search = app.model_search_input.lines().first().map(|l| l.trim().to_lowercase()).unwrap_or_default(); + let search = app + .model_search_input + .lines() + .first() + .map(|l| l.trim().to_lowercase()) + .unwrap_or_default(); super::logic::handle_model_search(app, &search, false).await; } StreamChunk::ModelsDone => { @@ -107,42 +177,52 @@ pub(crate) async fn handle_stream_chunks(app: &mut App) { let model = app.current_model.clone(); let tx = app.tx.clone(); app.tasks.spawn(async move { - if let Err(e) = orchestrator.run(&mut history, &model, Some(tx), None).await { + if let Err(e) = + orchestrator.run(&mut history, &model, Some(tx), None).await + { log::error!("Orchestrator run failed: {}", e); } }); } } } - StreamChunk::FinalHistory { history } => { app.history = history; } - StreamChunk::RequestConfirmation { message, target, tx } => { - match app.approval_mode { - ApprovalMode::YOLO | ApprovalMode::Shell => { - if let Some(sender) = tx { - let mut tx_opt = sender.lock().await; - if let Some(tx) = tx_opt.take() { - let _ = tx.send(ConfirmationResponse::AllowOnce); - } + StreamChunk::FinalHistory { history } => { + app.history = history; + } + StreamChunk::RequestConfirmation { + message, + target, + tx, + } => match app.approval_mode { + ApprovalMode::YOLO | ApprovalMode::Shell => { + if let Some(sender) = tx { + let mut tx_opt = sender.lock().await; + if let Some(tx) = tx_opt.take() { + let _ = tx.send(ConfirmationResponse::AllowOnce); } } - ApprovalMode::Plan => { - if let Some(sender) = tx { - let mut tx_opt = sender.lock().await; - if let Some(tx) = tx_opt.take() { - let _ = tx.send(ConfirmationResponse::Deny); - } + } + ApprovalMode::Plan => { + if let Some(sender) = tx { + let mut tx_opt = sender.lock().await; + if let Some(tx) = tx_opt.take() { + let _ = tx.send(ConfirmationResponse::Deny); } } - ApprovalMode::Normal => { - if let Some(sender) = tx { - app.pending_command_confirmation = Some((message, target, sender)); - } else { - log::error!("RequestConfirmation received without a response channel"); - } + } + ApprovalMode::Normal => { + if let Some(sender) = tx { + app.pending_command_confirmation = Some((message, target, sender)); + } else { + log::error!("RequestConfirmation received without a response channel"); } } - } - StreamChunk::UpdateAvailable { version, changelog, published_at } => { + }, + StreamChunk::UpdateAvailable { + version, + changelog, + published_at, + } => { app.pending_update = Some(version); app.pending_update_changelog = changelog; app.pending_update_published_at = published_at; diff --git a/apps/cli/src/ui/types.rs b/apps/cli/src/ui/types.rs index 1455661..fec2262 100644 --- a/apps/cli/src/ui/types.rs +++ b/apps/cli/src/ui/types.rs @@ -1,7 +1,8 @@ use routecode_sdk::agents::types::ConfirmationResponse; use routecode_sdk::core::DynamicModelInfo; -pub type ConfirmationSender = std::sync::Arc>>>; +pub type ConfirmationSender = + std::sync::Arc>>>; pub struct ProviderInfo { pub id: &'static str, @@ -12,17 +13,61 @@ pub struct ProviderInfo { } pub const PROVIDERS: &[ProviderInfo] = &[ - ProviderInfo { id: "openrouter", name: "OpenRouter", requires_api_key: true }, - ProviderInfo { id: "nvidia", name: "NVIDIA", requires_api_key: true }, - ProviderInfo { id: "opencode-zen", name: "OpenCode Zen", requires_api_key: false }, - ProviderInfo { id: "opencode-go", name: "OpenCode Go", requires_api_key: false }, - ProviderInfo { id: "openai", name: "OpenAI", requires_api_key: true }, - ProviderInfo { id: "anthropic", name: "Anthropic", requires_api_key: true }, - ProviderInfo { id: "gemini", name: "Google Gemini", requires_api_key: true }, - ProviderInfo { id: "deepseek", name: "DeepSeek", requires_api_key: true }, - ProviderInfo { id: "cloudflare-workers", name: "Cloudflare Workers AI", requires_api_key: true }, - ProviderInfo { id: "cloudflare-gateway", name: "Cloudflare AI Gateway", requires_api_key: true }, - ProviderInfo { id: "vertex", name: "Google Vertex AI", requires_api_key: true }, + ProviderInfo { + id: "openrouter", + name: "OpenRouter", + requires_api_key: true, + }, + ProviderInfo { + id: "nvidia", + name: "NVIDIA", + requires_api_key: true, + }, + ProviderInfo { + id: "opencode-zen", + name: "OpenCode Zen", + requires_api_key: false, + }, + ProviderInfo { + id: "opencode-go", + name: "OpenCode Go", + requires_api_key: false, + }, + ProviderInfo { + id: "openai", + name: "OpenAI", + requires_api_key: true, + }, + ProviderInfo { + id: "anthropic", + name: "Anthropic", + requires_api_key: true, + }, + ProviderInfo { + id: "gemini", + name: "Google Gemini", + requires_api_key: true, + }, + ProviderInfo { + id: "deepseek", + name: "DeepSeek", + requires_api_key: true, + }, + ProviderInfo { + id: "cloudflare-workers", + name: "Cloudflare Workers AI", + requires_api_key: true, + }, + ProviderInfo { + id: "cloudflare-gateway", + name: "Cloudflare AI Gateway", + requires_api_key: true, + }, + ProviderInfo { + id: "vertex", + name: "Google Vertex AI", + requires_api_key: true, + }, ]; /// Look up a `ProviderInfo` by id. Returns `None` for unknown providers @@ -51,16 +96,46 @@ pub struct Command { } pub const COMMANDS: &[Command] = &[ - Command { name: "/model", description: "Switch model" }, - Command { name: "/resume", description: "Resume a session" }, - Command { name: "/sessions", description: "List saved sessions" }, - Command { name: "/clear", description: "Clear history" }, - Command { name: "/thinking", description: "Set thinking level (low/max)" }, - Command { name: "/help", description: "Show help" }, - Command { name: "/stop", description: "Stop AI generation" }, - Command { name: "/provider", description: "Manage providers" }, - Command { name: "/settings", description: "Manage settings" }, - Command { name: "/exit", description: "Exit application" }, + Command { + name: "/model", + description: "Switch model", + }, + Command { + name: "/resume", + description: "Resume a session", + }, + Command { + name: "/sessions", + description: "List saved sessions", + }, + Command { + name: "/clear", + description: "Clear history", + }, + Command { + name: "/thinking", + description: "Set thinking level (low/max)", + }, + Command { + name: "/help", + description: "Show help", + }, + Command { + name: "/stop", + description: "Stop AI generation", + }, + Command { + name: "/provider", + description: "Manage providers", + }, + Command { + name: "/settings", + description: "Manage settings", + }, + Command { + name: "/exit", + description: "Exit application", + }, ]; #[derive(Debug, PartialEq, Clone, Copy)] @@ -111,7 +186,11 @@ pub enum ApiKeyInputStage { #[derive(Clone, Debug, PartialEq)] pub enum SettingsMenuItem { Header(String), - Option { name: String, val: String, key: String }, + Option { + name: String, + val: String, + key: String, + }, } /// State of the most recent QIR retry event emitted by the SDK's @@ -162,11 +241,17 @@ pub fn parse_qir_status(content: &str) -> Option { rest[..end].parse().ok() }; if content.starts_with("QIR recovered") { - Some(QirStatus::Recovered { attempt: attempt_of(content)? }) + Some(QirStatus::Recovered { + attempt: attempt_of(content)?, + }) } else if content.starts_with("QIR stream interrupted") { - Some(QirStatus::StreamInterrupted { attempt: attempt_of(content)? }) + Some(QirStatus::StreamInterrupted { + attempt: attempt_of(content)?, + }) } else if content.starts_with("QIR retrying") { - Some(QirStatus::Retrying { attempt: attempt_of(content)? }) + Some(QirStatus::Retrying { + attempt: attempt_of(content)?, + }) } else { None } @@ -274,7 +359,11 @@ mod tests { let s = format_error_for_display(body); assert!(s.contains("401 Unauthorized"), "{}", s); assert!(s.contains("Incorrect API key provided"), "{}", s); - assert!(!s.contains("invalid_request_error"), "raw JSON leaked: {}", s); + assert!( + !s.contains("invalid_request_error"), + "raw JSON leaked: {}", + s + ); } #[test] @@ -311,8 +400,14 @@ mod tests { #[test] fn test_format_error_non_http_passthrough() { // Transport errors, mid-stream "Provider error: ..." etc. - assert_eq!(format_error_for_display("Provider error: rate-limited"), "Provider error: rate-limited"); - assert_eq!(format_error_for_display("connection reset"), "connection reset"); + assert_eq!( + format_error_for_display("Provider error: rate-limited"), + "Provider error: rate-limited" + ); + assert_eq!( + format_error_for_display("connection reset"), + "connection reset" + ); assert_eq!(format_error_for_display(""), ""); } @@ -324,7 +419,17 @@ mod tests { #[test] fn test_known_providers_require_key() { - for id in ["openrouter", "openai", "anthropic", "gemini", "deepseek", "vertex", "cloudflare-workers", "cloudflare-gateway", "nvidia"] { + for id in [ + "openrouter", + "openai", + "anthropic", + "gemini", + "deepseek", + "vertex", + "cloudflare-workers", + "cloudflare-gateway", + "nvidia", + ] { assert!( provider_requires_api_key(id), "{} should require an API key", @@ -347,6 +452,10 @@ mod tests { seen.sort(); let original_len = seen.len(); seen.dedup(); - assert_eq!(seen.len(), original_len, "duplicate provider id in PROVIDERS"); + assert_eq!( + seen.len(), + original_len, + "duplicate provider id in PROVIDERS" + ); } } diff --git a/apps/cli/src/ui/welcome.rs b/apps/cli/src/ui/welcome.rs index a1b9e9c..524f3ca 100644 --- a/apps/cli/src/ui/welcome.rs +++ b/apps/cli/src/ui/welcome.rs @@ -1,17 +1,19 @@ +use crate::ui::components::{ + clean_model_name, COLOR_INPUT_BG, COLOR_PRIMARY, COLOR_SECONDARY, COLOR_TEXT, +}; +use crate::ui::App; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; -use crate::ui::App; -use crate::ui::components::{COLOR_INPUT_BG, COLOR_PRIMARY, COLOR_SECONDARY, COLOR_TEXT, clean_model_name}; pub fn ui_welcome(f: &mut Frame, app: &mut App, area: Rect) -> Rect { let logo_height = if area.height < 20 { 0 } else { 6 }; let spacer_height = if area.height < 15 { 0 } else { area.height / 3 }; let input_lines = app.input.lines().len() as u16; let input_height = (input_lines + 2).min(12); - + let chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints([ @@ -21,14 +23,17 @@ pub fn ui_welcome(f: &mut Frame, app: &mut App, area: Rect) -> Rect { ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Min(0), - ratatui::layout::Constraint::Length(1) + ratatui::layout::Constraint::Length(1), ]) .split(area); if logo_height > 0 { let config_guard = crate::ui::try_lock_config(app); let (animation_mode, animation_color) = if let Some(ref config) = config_guard { - (config.logo_animation.clone(), config.logo_animation_color.clone()) + ( + config.logo_animation.clone(), + config.logo_animation_color.clone(), + ) } else { ("always".to_string(), "rainbow".to_string()) }; @@ -72,18 +77,22 @@ pub fn ui_welcome(f: &mut Frame, app: &mut App, area: Rect) -> Rect { let small_logo = [ " __ _ ", " |__) _|_ _ _/ _ _| _ ", - " | \\(_|(_(- \\__(_)(_|(/_ " + " | \\(_|(_(- \\__(_)(_|(/_ ", ]; - + let large_logo = [ " ____ _ ____ _ ", " | _ \\ ___ _ _| |_ ___ / ___|___ __| | ___ ", " | |_) / _ \\| | | | __/ _ \\ | / _ \\ / _` |/ _ \\", " | _ < (_) | |_| | || __/ |__| (_) | (_| | __/", - " |_| \\_\\___/ \\__,_|\\__\\___|\\____\\___/ \\__,_|\\___|" + " |_| \\_\\___/ \\__,_|\\__\\___|\\____\\___/ \\__,_|\\___|", ]; - let logo_lines = if area.width < 60 { &small_logo[..] } else { &large_logo[..] }; + let logo_lines = if area.width < 60 { + &small_logo[..] + } else { + &large_logo[..] + }; let logo_width = logo_lines[0].len() as u16; let start_x = area.x + (area.width.saturating_sub(logo_width)) / 2; let end_x = start_x + logo_width; @@ -108,35 +117,70 @@ pub fn ui_welcome(f: &mut Frame, app: &mut App, area: Rect) -> Rect { for (i, line) in logo_lines.iter().enumerate() { let style = if is_animating { let color_idx = (app.tick_count as usize + i * 2) % colors.len(); - Style::default().fg(colors[color_idx]).add_modifier(Modifier::BOLD) + Style::default() + .fg(colors[color_idx]) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(COLOR_TEXT).add_modifier(Modifier::BOLD) }; logo_text.push(Line::from(Span::styled(*line, style))); } - f.render_widget(Paragraph::new(logo_text).alignment(ratatui::layout::Alignment::Center), chunks[1]); + f.render_widget( + Paragraph::new(logo_text).alignment(ratatui::layout::Alignment::Center), + chunks[1], + ); } - let input_width_percent = if area.width < 50 { 0.95 } else if area.width < 100 { 0.8 } else { 0.6 }; + let input_width_percent = if area.width < 50 { + 0.95 + } else if area.width < 100 { + 0.8 + } else { + 0.6 + }; let input_width = (area.width as f32 * input_width_percent) as u16; - let input_area = Rect::new((area.width - input_width) / 2, chunks[2].y, input_width, input_height); + let input_area = Rect::new( + (area.width - input_width) / 2, + chunks[2].y, + input_width, + input_height, + ); + + f.render_widget( + Block::default().style(Style::default().bg(COLOR_INPUT_BG)), + input_area, + ); - f.render_widget(Block::default().style(Style::default().bg(COLOR_INPUT_BG)), input_area); - - let inner_input_area = Rect::new(input_area.x + 1, input_area.y + 1, input_area.width.saturating_sub(2), input_area.height.saturating_sub(2)); + let inner_input_area = Rect::new( + input_area.x + 1, + input_area.y + 1, + input_area.width.saturating_sub(2), + input_area.height.saturating_sub(2), + ); app.input.set_block(Block::default().borders(Borders::NONE)); f.render_widget(app.input.widget(), inner_input_area); - f.set_cursor(inner_input_area.x + app.input.cursor().1 as u16, inner_input_area.y + app.input.cursor().0 as u16); + f.set_cursor( + inner_input_area.x + app.input.cursor().1 as u16, + inner_input_area.y + app.input.cursor().0 as u16, + ); let cleaned_model = clean_model_name(&app.current_model, &app.current_provider_id); let provider_info = vec![ Span::styled("Model ", Style::default().fg(COLOR_SECONDARY)), - Span::styled(cleaned_model, Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD)), + Span::styled( + cleaned_model, + Style::default() + .fg(COLOR_PRIMARY) + .add_modifier(Modifier::BOLD), + ), Span::styled(" • Provider ", Style::default().fg(COLOR_SECONDARY)), Span::styled(&app.provider_name, Style::default().fg(COLOR_TEXT)), ]; - f.render_widget(Paragraph::new(Line::from(provider_info)).alignment(ratatui::layout::Alignment::Center), chunks[4]); + f.render_widget( + Paragraph::new(Line::from(provider_info)).alignment(ratatui::layout::Alignment::Center), + chunks[4], + ); let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let frame = spinner[(app.tick_count % spinner.len() as u64) as usize]; @@ -145,7 +189,16 @@ pub fn ui_welcome(f: &mut Frame, app: &mut App, area: Rect) -> Rect { } else { "ctrl+p help | esc show exit prompt".to_string() }; - f.render_widget(Paragraph::new(tip_text).alignment(ratatui::layout::Alignment::Center).style(Style::default().fg(COLOR_SECONDARY).add_modifier(Modifier::DIM)), chunks[6]); + f.render_widget( + Paragraph::new(tip_text) + .alignment(ratatui::layout::Alignment::Center) + .style( + Style::default() + .fg(COLOR_SECONDARY) + .add_modifier(Modifier::DIM), + ), + chunks[6], + ); input_area } diff --git a/apps/desktop-t/src-tauri/src/lib.rs b/apps/desktop-t/src-tauri/src/lib.rs index cd4cd4a..f879d28 100644 --- a/apps/desktop-t/src-tauri/src/lib.rs +++ b/apps/desktop-t/src-tauri/src/lib.rs @@ -1,23 +1,28 @@ use std::sync::Arc; +use tauri::{AppHandle, Emitter, Manager, State}; use tokio::sync::Mutex; -use tauri::{AppHandle, Emitter, State, Manager}; use tokio_util::sync::CancellationToken; -use routecode_sdk::core::{AgentOrchestrator, Message, Config}; use routecode_sdk::agents::types::{ConfirmationResponse, StreamChunk}; +use routecode_sdk::core::{AgentOrchestrator, Config, Message}; use routecode_sdk::tools::{ - bash::BashTool, file_ops::{FileEditTool, FileReadTool, FileWriteTool, ApplyPatchTool}, - lsp_tool::LspTool, mcp::manager::McpManager, navigation::{GrepTool, LsTool, TreeTool}, - subagent::SubAgentTool, web::{fetch::WebFetchTool, search::WebSearchTool}, + bash::BashTool, + file_ops::{ApplyPatchTool, FileEditTool, FileReadTool, FileWriteTool}, + lsp_tool::LspTool, + mcp::manager::McpManager, + navigation::{GrepTool, LsTool, TreeTool}, + subagent::SubAgentTool, + web::{fetch::WebFetchTool, search::WebSearchTool}, ToolRegistry, }; use routecode_sdk::utils::storage::{ - Session, SessionConfig, WorkspaceConfig, save_session, load_session, list_sessions, - load_session_config, save_session_config, load_workspace_config, save_workspace_config, - sanitize_session_name, find_project_root, get_base_dir + find_project_root, get_base_dir, list_sessions, load_session, load_session_config, + load_workspace_config, sanitize_session_name, save_session, save_session_config, + save_workspace_config, Session, SessionConfig, WorkspaceConfig, }; -type PendingConfirmation = Arc>>>; +type PendingConfirmation = + Arc>>>; // Define the Shared Application State pub struct AppState { @@ -53,7 +58,10 @@ async fn get_config() -> Result { // 2. Save Persistent Config Command #[tauri::command] async fn save_config(config: Config) -> Result { - println!("Saving persistent RouteCode configuration: provider={}, model={}", config.provider, config.model); + println!( + "Saving persistent RouteCode configuration: provider={}, model={}", + config.provider, config.model + ); routecode_sdk::utils::storage::save_config(&config) .map_err(|e| format!("Failed to save configuration: {}", e))?; Ok("Configuration saved successfully".to_string()) @@ -63,8 +71,7 @@ async fn save_config(config: Config) -> Result { #[tauri::command] async fn list_saved_sessions() -> Result, String> { println!("Listing saved sessions..."); - let sessions = list_sessions() - .map_err(|e| format!("Failed to list sessions: {}", e))?; + let sessions = list_sessions().map_err(|e| format!("Failed to list sessions: {}", e))?; Ok(sessions) } @@ -72,15 +79,22 @@ async fn list_saved_sessions() -> Result, String> { #[tauri::command] async fn load_saved_session(name: String) -> Result { println!("Loading saved session: {}", name); - let session = load_session(&name) - .map_err(|e| format!("Failed to load session: {}", e))?; + let session = load_session(&name).map_err(|e| format!("Failed to load session: {}", e))?; Ok(session) } // 5. Save/Update Session Command #[tauri::command] -async fn save_saved_session(name: String, messages: Vec, model: String) -> Result { - println!("Saving session: {} (message count={})", name, messages.len()); +async fn save_saved_session( + name: String, + messages: Vec, + model: String, +) -> Result { + println!( + "Saving session: {} (message count={})", + name, + messages.len() + ); let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -91,8 +105,7 @@ async fn save_saved_session(name: String, messages: Vec, model: String) model, timestamp, }; - save_session(&name, &session) - .map_err(|e| format!("Failed to save session: {}", e))?; + save_session(&name, &session).map_err(|e| format!("Failed to save session: {}", e))?; Ok("Session saved successfully".to_string()) } @@ -113,7 +126,9 @@ async fn delete_session(name: String) -> Result { .map_err(|e| format!("Failed to delete workspace session directory: {}", e))?; } - let old_path = get_base_dir().join("sessions").join(format!("{}.json", safe_name)); + let old_path = get_base_dir() + .join("sessions") + .join(format!("{}.json", safe_name)); if old_path.exists() { std::fs::remove_file(&old_path) .map_err(|e| format!("Failed to delete legacy session file: {}", e))?; @@ -129,7 +144,10 @@ async fn init_engine( provider_name: String, model_name: String, ) -> Result { - println!("Initializing RouteCode Engine: provider={}, model={}", provider_name, model_name); + println!( + "Initializing RouteCode Engine: provider={}, model={}", + provider_name, model_name + ); // Load persistent configuration let mut config = routecode_sdk::utils::storage::load_config().unwrap_or_default(); @@ -152,14 +170,15 @@ async fn init_engine( // Resolve Provider Agent interface let provider = if provider_name == "vertex" { routecode_sdk::agents::resolve_provider_with_config( - &provider_name, api_key, - &config.vertex_project, &config.vertex_location, + &provider_name, + api_key, + &config.vertex_project, + &config.vertex_location, ) } else { routecode_sdk::agents::resolve_provider(&provider_name, api_key) }; - // Register Secure Tools into Registry let mut tool_registry = ToolRegistry::new(); tool_registry.register(Arc::new(FileReadTool)); @@ -176,7 +195,10 @@ async fn init_engine( // Initialize MCP Manager and load dynamic tools let mcp_manager = McpManager::new(); - if let Err(e) = mcp_manager.load_and_register_tools(&mut tool_registry).await { + if let Err(e) = mcp_manager + .load_and_register_tools(&mut tool_registry) + .await + { println!("Warning: Failed to load MCP tools: {}", e); } @@ -240,7 +262,10 @@ async fn send_message( history: Vec, model: String, ) -> Result { - println!("Received prompt from frontend. Message history length: {}", history.len()); + println!( + "Received prompt from frontend. Message history length: {}", + history.len() + ); // Resolve the active orchestrator from state let orchestrator = { @@ -269,23 +294,29 @@ async fn send_message( let mut history_mut = history.clone(); let cancel_for_task = cancel_token.clone(); tokio::spawn(async move { - let _ = orchestrator.run(&mut history_mut, &model, Some(tx), Some(cancel_for_task)).await; + let _ = orchestrator + .run(&mut history_mut, &model, Some(tx), Some(cancel_for_task)) + .await; }); // Listen to the unbounded channel and stream to the frontend let app_clone = app.clone(); - + tokio::spawn(async move { let state_clone = app_clone.state::(); while let Some(chunk) = rx.recv().await { match chunk.clone() { - StreamChunk::RequestConfirmation { message: _, target: _, tx: oneshot_tx } => { + StreamChunk::RequestConfirmation { + message: _, + target: _, + tx: oneshot_tx, + } => { // Stash the oneshot channel sender in the global AppState for allow/deny confirmation if let Some(oneshot) = oneshot_tx { let mut pending_guard = state_clone.pending_confirmation.lock().await; *pending_guard = Some(oneshot); } - + // Emit RequestConfirmation event to trigger frontend modal dialog let _ = app_clone.emit("agent-chunk", chunk); } @@ -359,7 +390,8 @@ async fn respond_confirmation( if let Some(orch) = orch_guard.as_ref() { use std::sync::atomic::Ordering; orch.allow_session_commands.store(true, Ordering::SeqCst); - orch.allow_session_outside_access.store(true, Ordering::SeqCst); + orch.allow_session_outside_access + .store(true, Ordering::SeqCst); } if let Some(name) = session_name.as_deref() { @@ -445,16 +477,20 @@ async fn set_session_permissions( state: State<'_, AppState>, name: String, ) -> Result { - let sc = load_session_config(&name).map_err(|e| format!("Failed to load session config: {}", e))?; + let sc = + load_session_config(&name).map_err(|e| format!("Failed to load session config: {}", e))?; let wc = load_workspace_config().unwrap_or_default(); use std::sync::atomic::Ordering; let orch_guard = state.orchestrator.lock().await; if let Some(orch) = orch_guard.as_ref() { let allow_commands = sc.allow_all_commands || wc.allow_all_outside_access; - orch.allow_session_commands.store(allow_commands, Ordering::SeqCst); - orch.allow_session_outside_access - .store(sc.allow_all_outside_access || wc.allow_all_outside_access, Ordering::SeqCst); + orch.allow_session_commands + .store(allow_commands, Ordering::SeqCst); + orch.allow_session_outside_access.store( + sc.allow_all_outside_access || wc.allow_all_outside_access, + Ordering::SeqCst, + ); } Ok(sc) } @@ -477,9 +513,7 @@ async fn check_update(app: AppHandle) -> Result { Ok(Some(update)) => { let update_version = update.version.clone(); let body = update.body.clone().unwrap_or_default(); - let date = update.date - .map(|d| d.to_string()) - .unwrap_or_default(); + let date = update.date.map(|d| d.to_string()).unwrap_or_default(); let info = routecode_sdk::update::types::UpdateInfo { version: update_version.clone(), @@ -519,12 +553,10 @@ async fn install_update(app: AppHandle) -> Result { let updater = app.updater().map_err(|e| e.to_string())?; match updater.check().await { - Ok(Some(update)) => { - match update.download_and_install(|_, _| {}, || {}).await { - Ok(()) => Ok("Update installed. Please restart the application.".to_string()), - Err(e) => Err(format!("Update installation failed: {}", e)), - } - } + Ok(Some(update)) => match update.download_and_install(|_, _| {}, || {}).await { + Ok(()) => Ok("Update installed. Please restart the application.".to_string()), + Err(e) => Err(format!("Update installation failed: {}", e)), + }, Ok(None) => Err("No update available".to_string()), Err(e) => Err(format!("Update check failed: {}", e)), } diff --git a/libs/sdk/src/agents/anthropic.rs b/libs/sdk/src/agents/anthropic.rs index 6256a79..fa86212 100644 --- a/libs/sdk/src/agents/anthropic.rs +++ b/libs/sdk/src/agents/anthropic.rs @@ -85,7 +85,8 @@ impl AIProvider for AnthropicProvider { } if let Some(calls) = &msg.tool_calls { for tc in calls { - let input: Value = serde_json::from_str(&tc.function.arguments).unwrap_or(json!({})); + let input: Value = + serde_json::from_str(&tc.function.arguments).unwrap_or(json!({})); content.push(json!({ "type": "tool_use", "id": tc.id, @@ -145,7 +146,7 @@ impl AIProvider for AnthropicProvider { url = format!("{}/v1/messages", url.trim_end_matches('/')); } } - + let response = self .client .post(&url) diff --git a/libs/sdk/src/agents/cloudflare.rs b/libs/sdk/src/agents/cloudflare.rs index eab4a33..81636f2 100644 --- a/libs/sdk/src/agents/cloudflare.rs +++ b/libs/sdk/src/agents/cloudflare.rs @@ -60,12 +60,14 @@ impl AIProvider for CloudflareWorkersAI { let val: Value = match response.json().await { Ok(v) => v, - Err(_) => return Ok(vec![ - "@cf/meta/llama-3-8b-instruct".to_string(), - "@cf/meta/llama-3-70b-instruct".to_string(), - "@cf/mistral/mistral-7b-instruct-v0.1".to_string(), - "@cf/qwen/qwen1.5-7b-chat-awq".to_string(), - ]), + Err(_) => { + return Ok(vec![ + "@cf/meta/llama-3-8b-instruct".to_string(), + "@cf/meta/llama-3-70b-instruct".to_string(), + "@cf/mistral/mistral-7b-instruct-v0.1".to_string(), + "@cf/qwen/qwen1.5-7b-chat-awq".to_string(), + ]) + } }; if let Some(result) = val["result"].as_array() { @@ -74,7 +76,7 @@ impl AIProvider for CloudflareWorkersAI { .filter_map(|m| m["name"].as_str().map(|s| s.to_string())) .collect(); if models.is_empty() { - Ok(vec![ + Ok(vec![ "@cf/meta/llama-3-8b-instruct".to_string(), "@cf/meta/llama-3-70b-instruct".to_string(), "@cf/mistral/mistral-7b-instruct-v0.1".to_string(), diff --git a/libs/sdk/src/agents/gemini.rs b/libs/sdk/src/agents/gemini.rs index dfd07fc..3fd80c8 100644 --- a/libs/sdk/src/agents/gemini.rs +++ b/libs/sdk/src/agents/gemini.rs @@ -23,7 +23,8 @@ impl GeminiProvider { .timeout(std::time::Duration::from_secs(60)) .build() .unwrap_or_else(|_| Client::new()), - base_url: base_url.unwrap_or_else(|| "https://generativelanguage.googleapis.com/v1beta".to_string()), + base_url: base_url + .unwrap_or_else(|| "https://generativelanguage.googleapis.com/v1beta".to_string()), } } } @@ -41,7 +42,8 @@ impl AIProvider for GeminiProvider { let url = format!( "{}/models?key={}", - self.base_url.trim_end_matches('/'), self.api_key + self.base_url.trim_end_matches('/'), + self.api_key ); let response = self.client.get(&url).send().await?; @@ -72,7 +74,9 @@ impl AIProvider for GeminiProvider { ) -> Result { let url = format!( "{}/models/{}:streamGenerateContent?key={}", - self.base_url.trim_end_matches('/'), model, self.api_key + self.base_url.trim_end_matches('/'), + model, + self.api_key ); let mut contents = Vec::new(); @@ -98,7 +102,8 @@ impl AIProvider for GeminiProvider { } if let Some(calls) = &msg.tool_calls { for tc in calls { - let args: Value = serde_json::from_str(&tc.function.arguments).unwrap_or(json!({})); + let args: Value = + serde_json::from_str(&tc.function.arguments).unwrap_or(json!({})); parts.push(json!({ "functionCall": { "name": tc.function.name, @@ -161,12 +166,7 @@ impl AIProvider for GeminiProvider { } } - let response = self - .client - .post(&url) - .json(&body) - .send() - .await?; + let response = self.client.post(&url).json(&body).send().await?; let response = crate::utils::error::check_status(response).await?; diff --git a/libs/sdk/src/agents/mod.rs b/libs/sdk/src/agents/mod.rs index 79464ba..b309996 100644 --- a/libs/sdk/src/agents/mod.rs +++ b/libs/sdk/src/agents/mod.rs @@ -1,8 +1,8 @@ pub mod anthropic; pub mod cloudflare; pub mod gemini; -pub mod opencode; pub mod openai; +pub mod opencode; pub mod openrouter; pub mod retry; pub mod traits; @@ -13,8 +13,8 @@ pub mod vertex; pub use anthropic::AnthropicProvider; pub use cloudflare::{CloudflareAIGateway, CloudflareWorkersAI}; pub use gemini::GeminiProvider; -pub use opencode::OpenCodeProvider; pub use openai::OpenAIProvider; +pub use opencode::OpenCodeProvider; pub use openrouter::OpenRouter; pub use retry::{run_retry_loop, RetryingProvider}; pub use traits::AIProvider; @@ -43,7 +43,10 @@ pub fn resolve_provider_with_config( // If api_key contains a colon, it might be account_id:api_token if api_key.contains(':') { let parts: Vec<&str> = api_key.split(':').collect(); - std::sync::Arc::new(CloudflareWorkersAI::new(parts[0].to_string(), parts[1].to_string())) + std::sync::Arc::new(CloudflareWorkersAI::new( + parts[0].to_string(), + parts[1].to_string(), + )) } else { std::sync::Arc::new(CloudflareWorkersAI::new(account_id, api_key)) } @@ -54,7 +57,11 @@ pub fn resolve_provider_with_config( // If api_key contains colons, it might be account_id:gateway_id:api_token let parts: Vec<&str> = api_key.split(':').collect(); if parts.len() == 3 { - std::sync::Arc::new(CloudflareAIGateway::new(parts[0].to_string(), parts[1].to_string(), parts[2].to_string())) + std::sync::Arc::new(CloudflareAIGateway::new( + parts[0].to_string(), + parts[1].to_string(), + parts[2].to_string(), + )) } else { std::sync::Arc::new(CloudflareAIGateway::new(account_id, gateway_id, api_key)) } @@ -91,7 +98,7 @@ pub fn resolve_provider_with_config( .or_else(|_| std::env::var("GCP_PROJECT")) .or_else(|_| std::env::var("GCLOUD_PROJECT")) .unwrap_or_default(); - + let final_project = if vertex_project.is_empty() { &env_project } else { diff --git a/libs/sdk/src/agents/openai.rs b/libs/sdk/src/agents/openai.rs index 750a088..caf858e 100644 --- a/libs/sdk/src/agents/openai.rs +++ b/libs/sdk/src/agents/openai.rs @@ -44,7 +44,8 @@ impl AIProvider for OpenAIProvider { format!("{}/models", self.base_url) }; - let response = self.client + let response = self + .client .get(&url) .header("Authorization", format!("Bearer {}", self.api_key)) .send() @@ -83,7 +84,7 @@ impl AIProvider for OpenAIProvider { if let Some(t) = tools.as_ref() { body["tools"] = json!(t); } - + if let Some(level) = thinking_level { if level != "default" { body["thinking_level"] = json!(level); @@ -111,21 +112,21 @@ impl AIProvider for OpenAIProvider { let mut active_tool_calls: HashMap = HashMap::new(); let s = stream! { - while let Some(item) = bytes_stream.next().await { - match item { - Ok(bytes) => { - let chunks = parse_sse_buffer(&mut buffer, &mut active_tool_calls, &String::from_utf8_lossy(&bytes)); - for chunk in chunks { - yield Ok(chunk); - } - } - Err(e) => { - yield Err(anyhow::Error::from(e)); - } - } - } - yield Ok(StreamChunk::Done); - }; + while let Some(item) = bytes_stream.next().await { + match item { + Ok(bytes) => { + let chunks = parse_sse_buffer(&mut buffer, &mut active_tool_calls, &String::from_utf8_lossy(&bytes)); + for chunk in chunks { + yield Ok(chunk); + } + } + Err(e) => { + yield Err(anyhow::Error::from(e)); + } + } + } + yield Ok(StreamChunk::Done); + }; Ok(Box::pin(s)) } } diff --git a/libs/sdk/src/agents/opencode.rs b/libs/sdk/src/agents/opencode.rs index f7c18b5..21b3933 100644 --- a/libs/sdk/src/agents/opencode.rs +++ b/libs/sdk/src/agents/opencode.rs @@ -1,6 +1,6 @@ use crate::agents::traits::{AIProvider, StreamResponse}; -use crate::agents::utils::{parse_sse_buffer, parse_anthropic_sse}; -use crate::core::{Message, ToolCall, Role}; +use crate::agents::utils::{parse_anthropic_sse, parse_sse_buffer}; +use crate::core::{Message, Role, ToolCall}; use async_stream::stream; use async_trait::async_trait; use futures::StreamExt; @@ -32,8 +32,8 @@ impl OpenCodeProvider { } fn get_prefixed_model(&self, model: &str) -> String { - // Based on documentation and error reports, the OpenCode API - // expects the raw model ID (e.g. "gpt-5.5") because the + // Based on documentation and error reports, the OpenCode API + // expects the raw model ID (e.g. "gpt-5.5") because the // provider (Zen/Go) is already determined by the base URL. model.to_string() } @@ -48,7 +48,8 @@ impl AIProvider for OpenCodeProvider { async fn list_models(&self) -> Result, anyhow::Error> { let url = format!("{}/models", self.base_url); - let response = self.client + let response = self + .client .get(&url) .header("Authorization", format!("Bearer {}", self.api_key)) .send() @@ -61,7 +62,10 @@ impl AIProvider for OpenCodeProvider { if let Some(data) = val["data"].as_array() { for model in data { if let Some(id) = model["id"].as_str() { - let clean_id = id.strip_prefix("opencode-zen/").or_else(|| id.strip_prefix("opencode-go/")).unwrap_or(id); + let clean_id = id + .strip_prefix("opencode-zen/") + .or_else(|| id.strip_prefix("opencode-go/")) + .unwrap_or(id); models.push(clean_id.to_string()); } } @@ -144,7 +148,7 @@ impl AIProvider for OpenCodeProvider { ) -> Result { let prefixed_model = self.get_prefixed_model(model); let model_lower = model.to_lowercase(); - + // Routing logic based on documentation screenshots let endpoint = if model_lower.starts_with("claude") { format!("{}/messages", self.base_url) @@ -152,7 +156,10 @@ impl AIProvider for OpenCodeProvider { format!("{}/responses", self.base_url) } else if model_lower.starts_with("gemini") { // Google style endpoint - append streaming suffix - format!("{}/models/{}:streamGenerateContent", self.base_url, prefixed_model) + format!( + "{}/models/{}:streamGenerateContent", + self.base_url, prefixed_model + ) } else if !self.is_zen && model_lower.contains("minimax") { // MiniMax in Go uses /messages format!("{}/messages", self.base_url) @@ -169,7 +176,9 @@ impl AIProvider for OpenCodeProvider { for msg in messages.iter() { match msg.role { Role::System => { - if let Some(c) = &msg.content { global_system.push_str(c); } + if let Some(c) = &msg.content { + global_system.push_str(c); + } } Role::User => { anthropic_messages.push(json!({ "role": "user", "content": msg.content.as_deref().unwrap_or_default() })); @@ -184,7 +193,8 @@ impl AIProvider for OpenCodeProvider { } if let Some(calls) = &msg.tool_calls { for tc in calls { - let input: Value = serde_json::from_str(&tc.function.arguments).unwrap_or(json!({})); + let input: Value = serde_json::from_str(&tc.function.arguments) + .unwrap_or(json!({})); content.push(json!({ "type": "tool_use", "id": tc.id, @@ -209,10 +219,14 @@ impl AIProvider for OpenCodeProvider { } } let mut body = json!({ "model": prefixed_model, "messages": anthropic_messages, "stream": true, "max_tokens": 16384 }); - if !global_system.is_empty() { body["system"] = json!(global_system); } - + if !global_system.is_empty() { + body["system"] = json!(global_system); + } + if let Some(level) = thinking_level { - if level != "default" { body["thinking_level"] = json!(level); } + if level != "default" { + body["thinking_level"] = json!(level); + } } if let Some(t) = _tools.as_ref() { @@ -229,7 +243,13 @@ impl AIProvider for OpenCodeProvider { body["tools"] = json!(anthropic_tools); } - let response = self.client.post(&endpoint).header("Authorization", format!("Bearer {}", self.api_key)).json(&body).send().await?; + let response = self + .client + .post(&endpoint) + .header("Authorization", format!("Bearer {}", self.api_key)) + .json(&body) + .send() + .await?; let response = crate::utils::error::check_status(response).await?; let mut bytes_stream = response.bytes_stream(); @@ -254,13 +274,19 @@ impl AIProvider for OpenCodeProvider { // Gemini/Google Format let mut contents = Vec::new(); for msg in messages.iter() { - let role = match msg.role { Role::User => "user", Role::Assistant => "model", _ => "user" }; + let role = match msg.role { + Role::User => "user", + Role::Assistant => "model", + _ => "user", + }; contents.push(json!({ "role": role, "parts": [{"text": msg.content.as_deref().unwrap_or_default()}] })); } let mut body = json!({ "contents": contents }); - + if let Some(level) = thinking_level { - if level != "default" { body["thinking_level"] = json!(level); } + if level != "default" { + body["thinking_level"] = json!(level); + } } if let Some(t) = _tools.as_ref() { @@ -277,7 +303,13 @@ impl AIProvider for OpenCodeProvider { body["tools"] = json!([{ "function_declarations": gemini_tools }]); } - let response = self.client.post(&endpoint).header("Authorization", format!("Bearer {}", self.api_key)).json(&body).send().await?; + let response = self + .client + .post(&endpoint) + .header("Authorization", format!("Bearer {}", self.api_key)) + .json(&body) + .send() + .await?; let response = crate::utils::error::check_status(response).await?; let mut bytes_stream = response.bytes_stream(); @@ -307,13 +339,23 @@ impl AIProvider for OpenCodeProvider { } else { // OpenAI Format (Default + GPT /responses) let mut body = json!({ "model": prefixed_model, "messages": &*messages, "stream": true, "max_tokens": 16384 }); - if let Some(t) = _tools.as_ref() { body["tools"] = json!(t); } - + if let Some(t) = _tools.as_ref() { + body["tools"] = json!(t); + } + if let Some(level) = thinking_level { - if level != "default" { body["thinking_level"] = json!(level); } + if level != "default" { + body["thinking_level"] = json!(level); + } } - let response = self.client.post(&endpoint).header("Authorization", format!("Bearer {}", self.api_key)).json(&body).send().await?; + let response = self + .client + .post(&endpoint) + .header("Authorization", format!("Bearer {}", self.api_key)) + .json(&body) + .send() + .await?; let response = crate::utils::error::check_status(response).await?; let mut bytes_stream = response.bytes_stream(); diff --git a/libs/sdk/src/agents/openrouter.rs b/libs/sdk/src/agents/openrouter.rs index 7be5a4c..9633492 100644 --- a/libs/sdk/src/agents/openrouter.rs +++ b/libs/sdk/src/agents/openrouter.rs @@ -34,7 +34,8 @@ impl AIProvider for OpenRouter { } async fn list_models(&self) -> Result, anyhow::Error> { - let response = self.client + let response = self + .client .get("https://openrouter.ai/api/v1/models") .header("Authorization", format!("Bearer {}", self.api_key)) .send() @@ -98,21 +99,21 @@ impl AIProvider for OpenRouter { let mut active_tool_calls: HashMap = HashMap::new(); let s = stream! { - while let Some(item) = bytes_stream.next().await { - match item { - Ok(bytes) => { - let chunks = parse_sse_buffer(&mut buffer, &mut active_tool_calls, &String::from_utf8_lossy(&bytes)); - for chunk in chunks { - yield Ok(chunk); - } - } - Err(e) => { - yield Err(anyhow::Error::from(e)); - } - } - } - yield Ok(StreamChunk::Done); - }; + while let Some(item) = bytes_stream.next().await { + match item { + Ok(bytes) => { + let chunks = parse_sse_buffer(&mut buffer, &mut active_tool_calls, &String::from_utf8_lossy(&bytes)); + for chunk in chunks { + yield Ok(chunk); + } + } + Err(e) => { + yield Err(anyhow::Error::from(e)); + } + } + } + yield Ok(StreamChunk::Done); + }; Ok(Box::pin(s)) } } diff --git a/libs/sdk/src/agents/retry.rs b/libs/sdk/src/agents/retry.rs index 45a2f29..eb4b048 100644 --- a/libs/sdk/src/agents/retry.rs +++ b/libs/sdk/src/agents/retry.rs @@ -145,7 +145,8 @@ pub async fn run_retry_loop( if classify_error(&e) == RetryClass::Permanent { log::info!( "RetryPolicy: permanent error on attempt {}, bailing out: {}", - attempt, e + attempt, + e ); let _ = tx.send(Err(e)); break; @@ -193,7 +194,8 @@ pub async fn run_retry_loop( if let Some(err) = stream_err { log::warn!( "RetryPolicy: stream interrupted on attempt {} (retrying per policy): {}", - attempt, err + attempt, + err ); let status = StreamChunk::Status { content: format!("QIR stream interrupted (attempt {}) -- {}", attempt, err), @@ -257,17 +259,19 @@ mod tests { _tools: Arc>>, _thinking_level: Option<&str>, ) -> Result { - let prev = self.failures_remaining.fetch_update( - Ordering::SeqCst, - Ordering::SeqCst, - |v| Some(v.saturating_sub(1)), - ); + let prev = + self.failures_remaining + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |v| { + Some(v.saturating_sub(1)) + }); // We always succeed (returning a single Text chunk); the // "flakiness" is injected via the failures_remaining counter // which the test uses to assert call count. let _ = prev; let chunks = vec![ - Ok(StreamChunk::Text { content: "ok".to_string() }), + Ok(StreamChunk::Text { + content: "ok".to_string(), + }), Ok(StreamChunk::Done), ]; Ok(Box::pin(stream::iter(chunks))) @@ -328,8 +332,12 @@ mod tests { .unwrap(); let chunks: Vec<_> = s.collect().await; // Should not see "QIR retrying" since policy is Disabled. - assert!(!chunks.iter().any(|c| matches!(c, Ok(StreamChunk::Status { content }) if content.contains("QIR")))); - assert!(chunks.iter().any(|c| matches!(c, Ok(StreamChunk::Text { content }) if content == "ok"))); + assert!(!chunks + .iter() + .any(|c| matches!(c, Ok(StreamChunk::Status { content }) if content.contains("QIR")))); + assert!(chunks + .iter() + .any(|c| matches!(c, Ok(StreamChunk::Text { content }) if content == "ok"))); } // Note: tests for the QIR-retry path (qir_retries_on_ask_error, diff --git a/libs/sdk/src/agents/types.rs b/libs/sdk/src/agents/types.rs index 7f629b4..ae2fb1f 100644 --- a/libs/sdk/src/agents/types.rs +++ b/libs/sdk/src/agents/types.rs @@ -1,4 +1,4 @@ -use crate::core::{ToolCall, Message}; +use crate::core::{Message, ToolCall}; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -45,7 +45,9 @@ pub enum StreamChunk { message: String, target: String, #[serde(skip)] - tx: Option>>>>, + tx: Option< + Arc>>>, + >, }, UpdateAvailable { version: String, diff --git a/libs/sdk/src/agents/utils.rs b/libs/sdk/src/agents/utils.rs index 5ae78c2..b3b50ce 100644 --- a/libs/sdk/src/agents/utils.rs +++ b/libs/sdk/src/agents/utils.rs @@ -31,9 +31,10 @@ pub fn parse_sse_buffer( content: content.to_string(), }); } - if let Some(thought) = - delta.get("reasoning_content").and_then(|v| v.as_str()) - .or_else(|| delta.get("thought").and_then(|v| v.as_str())) + if let Some(thought) = delta + .get("reasoning_content") + .and_then(|v| v.as_str()) + .or_else(|| delta.get("thought").and_then(|v| v.as_str())) { chunks.push(StreamChunk::Thought { content: thought.to_string(), @@ -45,14 +46,16 @@ pub fn parse_sse_buffer( if let Some(idx_val) = tc_delta.get("index") { let index = idx_val.as_u64().unwrap_or(0) as usize; let entry = - active_tool_calls.entry(index).or_insert_with(|| ToolCall { - index: Some(index), - id: String::new(), - r#type: "function".to_string(), - function: FunctionCall { - name: String::new(), - arguments: String::new(), - }, + active_tool_calls.entry(index).or_insert_with(|| { + ToolCall { + index: Some(index), + id: String::new(), + r#type: "function".to_string(), + function: FunctionCall { + name: String::new(), + arguments: String::new(), + }, + } }); if let Some(id) = tc_delta.get("id").and_then(|v| v.as_str()) { @@ -62,7 +65,9 @@ pub fn parse_sse_buffer( if let Some(name) = f.get("name").and_then(|v| v.as_str()) { entry.function.name = name.to_string(); } - if let Some(args) = f.get("arguments").and_then(|v| v.as_str()) { + if let Some(args) = + f.get("arguments").and_then(|v| v.as_str()) + { entry.function.arguments.push_str(args); } } diff --git a/libs/sdk/src/agents/vertex.rs b/libs/sdk/src/agents/vertex.rs index 3b58c84..21a0202 100644 --- a/libs/sdk/src/agents/vertex.rs +++ b/libs/sdk/src/agents/vertex.rs @@ -21,7 +21,11 @@ impl VertexAIProvider { Self { api_key, project, - location: if location.is_empty() { "us-central1".to_string() } else { location }, + location: if location.is_empty() { + "us-central1".to_string() + } else { + location + }, client: Client::builder() .timeout(std::time::Duration::from_secs(120)) .build() @@ -107,7 +111,8 @@ impl AIProvider for VertexAIProvider { } if let Some(calls) = &msg.tool_calls { for tc in calls { - let args: Value = serde_json::from_str(&tc.function.arguments).unwrap_or(json!({})); + let args: Value = + serde_json::from_str(&tc.function.arguments).unwrap_or(json!({})); parts.push(json!({ "functionCall": { "name": tc.function.name, diff --git a/libs/sdk/src/core/config.rs b/libs/sdk/src/core/config.rs index 580c4ac..2f18b67 100644 --- a/libs/sdk/src/core/config.rs +++ b/libs/sdk/src/core/config.rs @@ -15,10 +15,11 @@ pub struct DynamicModelInfo { /// implemented at the call site — the orchestrator currently only honors /// `Qir` vs not-`Qir`, so picking `ExponentialBackoff` is reserved for /// future use). -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(tag = "strategy", rename_all = "snake_case")] pub enum RetryPolicy { /// No automatic retry on failure. Default. + #[default] Disabled, /// Quick Infinite Retry: re-send immediately with no delay and no limit. /// Experimental. Use at your own risk — aggressive retrying can @@ -44,28 +45,17 @@ pub enum RetryPolicy { /// * `Yolo`: tool calls are auto-allowed without any UI prompt. Use for /// trusted, sandboxed runs. The LLM can still ask for input via the /// `/plan` sub-agent flow. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(tag = "strategy", rename_all = "snake_case")] pub enum ApprovalMode { /// Confirm every tool call (default). + #[default] Normal, /// Auto-allow every tool call. The user is responsible for the agent's /// actions; nothing is sandboxed beyond what the OS already provides. Yolo, } -impl Default for RetryPolicy { - fn default() -> Self { - RetryPolicy::Disabled - } -} - -impl Default for ApprovalMode { - fn default() -> Self { - ApprovalMode::Normal - } -} - impl RetryPolicy { /// True if this policy will retry on a transient failure. /// `Disabled` returns false; `Qir` and `ExponentialBackoff` return true. @@ -213,7 +203,11 @@ impl Config { b, if b { "qir" } else { "disabled" } ); - self.retry_policy = if b { RetryPolicy::Qir } else { RetryPolicy::Disabled }; + self.retry_policy = if b { + RetryPolicy::Qir + } else { + RetryPolicy::Disabled + }; } } } @@ -235,7 +229,11 @@ mod tests { assert!(!RetryPolicy::Disabled.is_retry_enabled()); assert!(RetryPolicy::Qir.is_retry_enabled()); assert!(RetryPolicy::Qir.is_qir()); - let eb = RetryPolicy::ExponentialBackoff { max_attempts: 5, base_secs: 1.0, jitter: true }; + let eb = RetryPolicy::ExponentialBackoff { + max_attempts: 5, + base_secs: 1.0, + jitter: true, + }; assert!(eb.is_retry_enabled()); assert!(!eb.is_qir()); } @@ -264,10 +262,15 @@ mod tests { #[test] fn retry_policy_deserializes_exponential_backoff() { - let json = r#"{"strategy":"exponential_backoff","max_attempts":5,"base_secs":1.5,"jitter":true}"#; + let json = + r#"{"strategy":"exponential_backoff","max_attempts":5,"base_secs":1.5,"jitter":true}"#; let p: RetryPolicy = serde_json::from_str(json).unwrap(); match p { - RetryPolicy::ExponentialBackoff { max_attempts, base_secs, jitter } => { + RetryPolicy::ExponentialBackoff { + max_attempts, + base_secs, + jitter, + } => { assert_eq!(max_attempts, 5); assert!((base_secs - 1.5).abs() < f64::EPSILON); assert!(jitter); diff --git a/libs/sdk/src/core/orchestrator.rs b/libs/sdk/src/core/orchestrator.rs index 340fd5a..fb8cf52 100644 --- a/libs/sdk/src/core/orchestrator.rs +++ b/libs/sdk/src/core/orchestrator.rs @@ -121,7 +121,10 @@ impl AgentOrchestrator { tx: Option>, cancel: Option, ) -> Result<(), anyhow::Error> { - match self.run_with_depth(history, model, tx.clone(), 0, cancel.clone()).await { + match self + .run_with_depth(history, model, tx.clone(), 0, cancel.clone()) + .await + { Ok(_) => Ok(()), Err(e) => { let was_cancelled = cancel.as_ref().is_some_and(|c| c.is_cancelled()); @@ -131,7 +134,9 @@ impl AgentOrchestrator { content: "Request cancelled by user".to_string(), }); } else { - let _ = tx.send(StreamChunk::Error { content: e.to_string() }); + let _ = tx.send(StreamChunk::Error { + content: e.to_string(), + }); } let _ = tx.send(StreamChunk::Done); } @@ -149,7 +154,9 @@ impl AgentOrchestrator { cancel: Option, ) -> Result<(), anyhow::Error> { if depth >= 25 { - return Err(anyhow::anyhow!("Maximum tool recursion depth (25) reached. Aborting to prevent infinite loop.")); + return Err(anyhow::anyhow!( + "Maximum tool recursion depth (25) reached. Aborting to prevent infinite loop." + )); } if cancel.as_ref().is_some_and(|c| c.is_cancelled()) { return Err(anyhow::anyhow!("Request cancelled by user")); @@ -163,7 +170,11 @@ impl AgentOrchestrator { Arc::new(Some(self.tool_registry.get_all_schemas())); let messages: Arc> = Arc::new(self.prepare_messages(history).await); - log::debug!("Sending AI request to model: {} (messages: {})", model, messages.len()); + log::debug!( + "Sending AI request to model: {} (messages: {})", + model, + messages.len() + ); // Snapshot the retry policy, thinking-level, and provider at the start // of the run. A mid-request policy change is intentionally NOT honored @@ -239,7 +250,8 @@ impl AgentOrchestrator { // non-buffered flows too). if let StreamChunk::Usage { usage } = &chunk { let mut u = self.usage.lock().await; - u.add(usage.prompt_tokens, usage.completion_tokens, model).await; + u.add(usage.prompt_tokens, usage.completion_tokens, model) + .await; } // Accumulate the final assistant message. @@ -252,9 +264,8 @@ impl AgentOrchestrator { } StreamChunk::ToolCall { tool_call } => { if let Some(idx) = tool_call.index { - if let Some(existing) = local_tool_calls - .iter_mut() - .find(|tc| tc.index == Some(idx)) + if let Some(existing) = + local_tool_calls.iter_mut().find(|tc| tc.index == Some(idx)) { *existing = tool_call.clone(); } else { @@ -334,7 +345,8 @@ impl AgentOrchestrator { if !tool_calls.is_empty() { for tc in tool_calls { if let Some(tool) = self.tool_registry.get(&tc.function.name) { - let args: serde_json::Value = match serde_json::from_str(&tc.function.arguments) { + let args: serde_json::Value = match serde_json::from_str(&tc.function.arguments) + { Ok(a) => a, Err(e) => { return Err(anyhow::anyhow!( @@ -347,18 +359,22 @@ impl AgentOrchestrator { }; let mut execute_allowed = true; let mut custom_error_msg = None; - + use std::sync::atomic::Ordering; if tc.function.name == "bash" { if !self.allow_session_commands.load(Ordering::SeqCst) { if let Some(ref sender) = tx { - let command_str = args["command"].as_str().unwrap_or("").to_string(); + let command_str = + args["command"].as_str().unwrap_or("").to_string(); let (oneshot_tx, oneshot_rx) = tokio::sync::oneshot::channel(); - let tx_wrapped = Arc::new(tokio::sync::Mutex::new(Some(oneshot_tx))); - + let tx_wrapped = + Arc::new(tokio::sync::Mutex::new(Some(oneshot_tx))); + if let Err(e) = sender.send(StreamChunk::RequestConfirmation { - message: "The AI agent wants to execute the following bash command:".to_string(), + message: + "The AI agent wants to execute the following bash command:" + .to_string(), target: command_str, tx: Some(tx_wrapped), }) { @@ -367,16 +383,27 @@ impl AgentOrchestrator { match oneshot_rx.await { Ok(crate::agents::types::ConfirmationResponse::AllowOnce) => {} - Ok(crate::agents::types::ConfirmationResponse::AllowSession) | Ok(crate::agents::types::ConfirmationResponse::AllowWorkspace) => { + Ok( + crate::agents::types::ConfirmationResponse::AllowSession, + ) + | Ok( + crate::agents::types::ConfirmationResponse::AllowWorkspace, + ) => { self.allow_session_commands.store(true, Ordering::SeqCst); } Ok(crate::agents::types::ConfirmationResponse::Deny) => { execute_allowed = false; - custom_error_msg = Some("Command execution denied by user.".to_string()); + custom_error_msg = + Some("Command execution denied by user.".to_string()); } - Ok(crate::agents::types::ConfirmationResponse::Feedback(msg)) => { + Ok(crate::agents::types::ConfirmationResponse::Feedback( + msg, + )) => { execute_allowed = false; - custom_error_msg = Some(format!("Command execution denied by user with feedback: {}", msg)); + custom_error_msg = Some(format!( + "Command execution denied by user with feedback: {}", + msg + )); } Err(_) => { execute_allowed = false; @@ -385,14 +412,16 @@ impl AgentOrchestrator { } } } - } else if ["file_read", "file_write", "file_edit", "ls", "tree", "grep"].contains(&tc.function.name.as_str()) + } else if ["file_read", "file_write", "file_edit", "ls", "tree", "grep"] + .contains(&tc.function.name.as_str()) && !self.allow_session_outside_access.load(Ordering::SeqCst) { let path_str = args["path"].as_str().unwrap_or("."); if crate::utils::storage::is_path_outside_workspace(path_str) { if let Some(ref sender) = tx { let (oneshot_tx, oneshot_rx) = tokio::sync::oneshot::channel(); - let tx_wrapped = Arc::new(tokio::sync::Mutex::new(Some(oneshot_tx))); + let tx_wrapped = + Arc::new(tokio::sync::Mutex::new(Some(oneshot_tx))); if let Err(e) = sender.send(StreamChunk::RequestConfirmation { message: "The AI agent wants to access a path OUTSIDE the current workspace:".to_string(), @@ -404,20 +433,34 @@ impl AgentOrchestrator { match oneshot_rx.await { Ok(crate::agents::types::ConfirmationResponse::AllowOnce) => {} - Ok(crate::agents::types::ConfirmationResponse::AllowSession) | Ok(crate::agents::types::ConfirmationResponse::AllowWorkspace) => { - self.allow_session_outside_access.store(true, Ordering::SeqCst); + Ok( + crate::agents::types::ConfirmationResponse::AllowSession, + ) + | Ok( + crate::agents::types::ConfirmationResponse::AllowWorkspace, + ) => { + self.allow_session_outside_access + .store(true, Ordering::SeqCst); } Ok(crate::agents::types::ConfirmationResponse::Deny) => { execute_allowed = false; - custom_error_msg = Some(format!("Access to outside path '{}' denied by user.", path_str)); + custom_error_msg = Some(format!( + "Access to outside path '{}' denied by user.", + path_str + )); } - Ok(crate::agents::types::ConfirmationResponse::Feedback(msg)) => { + Ok(crate::agents::types::ConfirmationResponse::Feedback( + msg, + )) => { execute_allowed = false; custom_error_msg = Some(format!("Access to outside path '{}' denied by user with feedback: {}", path_str, msg)); } Err(_) => { execute_allowed = false; - custom_error_msg = Some("Access cancelled (confirmation channel closed).".to_string()); + custom_error_msg = Some( + "Access cancelled (confirmation channel closed)." + .to_string(), + ); } } } else { @@ -431,7 +474,10 @@ impl AgentOrchestrator { let result = if execute_allowed { match tool.execute(args).await { Ok(res) => res, - Err(e) => crate::core::ToolResult::error(format!("Tool execution failed: {}", e)), + Err(e) => crate::core::ToolResult::error(format!( + "Tool execution failed: {}", + e + )), } } else { crate::core::ToolResult::error(custom_error_msg.unwrap_or_default()) @@ -458,7 +504,9 @@ impl AgentOrchestrator { } if let Some(ref tx) = tx { - let _ = tx.send(StreamChunk::FinalHistory { history: history.clone() }); + let _ = tx.send(StreamChunk::FinalHistory { + history: history.clone(), + }); if let Err(e) = tx.send(StreamChunk::Done) { log::error!("Failed to send Done chunk to UI: {}", e); } @@ -472,7 +520,7 @@ impl AgentOrchestrator { mod tests { use super::*; use crate::agents::types::StreamChunk; - use crate::core::{Message, Role, ToolCall, FunctionCall, ToolResult}; + use crate::core::{FunctionCall, Message, Role, ToolCall, ToolResult}; use crate::tools::traits::Tool; use async_trait::async_trait; use futures::stream; @@ -484,9 +532,19 @@ mod tests { #[async_trait] impl AIProvider for MockProvider { - fn name(&self) -> &str { "Mock" } - async fn list_models(&self) -> Result, anyhow::Error> { Ok(vec!["mock".to_string()]) } - async fn ask(&self, _msgs: Arc>, _model: &str, _tools: Arc>>, _thinking_level: Option<&str>) -> Result { + fn name(&self) -> &str { + "Mock" + } + async fn list_models(&self) -> Result, anyhow::Error> { + Ok(vec!["mock".to_string()]) + } + async fn ask( + &self, + _msgs: Arc>, + _model: &str, + _tools: Arc>>, + _thinking_level: Option<&str>, + ) -> Result { let mut resps = self.responses.lock().await; if resps.is_empty() { return Err(anyhow::anyhow!("No more mock responses")); @@ -500,9 +558,15 @@ mod tests { struct MockTool; #[async_trait] impl Tool for MockTool { - fn name(&self) -> &str { "mock_tool" } - fn description(&self) -> &str { "A mock tool" } - fn parameters(&self) -> serde_json::Value { json!({}) } + fn name(&self) -> &str { + "mock_tool" + } + fn description(&self) -> &str { + "A mock tool" + } + fn parameters(&self) -> serde_json::Value { + json!({}) + } async fn execute(&self, _args: serde_json::Value) -> Result { Ok(ToolResult::success("success")) } @@ -512,7 +576,9 @@ mod tests { async fn test_orchestrator_simple_chat() { let provider = Arc::new(MockProvider { responses: Mutex::new(vec![vec![ - StreamChunk::Text { content: "Hello!".to_string() }, + StreamChunk::Text { + content: "Hello!".to_string(), + }, StreamChunk::Done, ]]), }); @@ -521,7 +587,10 @@ mod tests { let orchestrator = AgentOrchestrator::new(provider, Arc::new(tool_registry), config); let mut history = vec![Message::user("Hi")]; - orchestrator.run(&mut history, "mock", None, None).await.unwrap(); + orchestrator + .run(&mut history, "mock", None, None) + .await + .unwrap(); assert_eq!(history.len(), 2); assert_eq!(history[1].role, Role::Assistant); @@ -543,25 +612,30 @@ mod tests { name: "mock_tool".to_string(), arguments: "{}".to_string(), }, - } + }, }, StreamChunk::Done, ], // Second response: finalize vec![ - StreamChunk::Text { content: "Tool executed!".to_string() }, + StreamChunk::Text { + content: "Tool executed!".to_string(), + }, StreamChunk::Done, - ] + ], ]), }); - + let mut tool_registry = ToolRegistry::new(); tool_registry.register(Arc::new(MockTool)); let config = Arc::new(Mutex::new(crate::core::Config::default())); let orchestrator = AgentOrchestrator::new(provider, Arc::new(tool_registry), config); let mut history = vec![Message::user("Run tool")]; - orchestrator.run(&mut history, "mock", None, None).await.unwrap(); + orchestrator + .run(&mut history, "mock", None, None) + .await + .unwrap(); // History: User -> Assistant (ToolCall) -> ToolResult -> Assistant (Final) assert_eq!(history.len(), 4); diff --git a/libs/sdk/src/tools/file_ops.rs b/libs/sdk/src/tools/file_ops.rs index da2d43f..312b3ce 100644 --- a/libs/sdk/src/tools/file_ops.rs +++ b/libs/sdk/src/tools/file_ops.rs @@ -2,9 +2,9 @@ use crate::core::ToolResult; use crate::tools::traits::Tool; use async_trait::async_trait; use serde_json::{json, Value}; +use similar::{ChangeTag, TextDiff}; use std::fs; use std::path::{Path, PathBuf}; -use similar::{ChangeTag, TextDiff}; fn normalize_path(path: &str) -> PathBuf { let mut p = path; @@ -87,12 +87,27 @@ impl Tool for FileReadTool { let path = normalize_path(raw_path); match is_within_workspace(&path) { Ok(true) => {} - Ok(false) => return Ok(ToolResult::error(format!("Access denied: Path '{}' is outside the workspace boundary", path.display()))), - Err(e) => return Ok(ToolResult::error(format!("Failed to verify path '{}': {}", path.display(), e))), + Ok(false) => { + return Ok(ToolResult::error(format!( + "Access denied: Path '{}' is outside the workspace boundary", + path.display() + ))) + } + Err(e) => { + return Ok(ToolResult::error(format!( + "Failed to verify path '{}': {}", + path.display(), + e + ))) + } } match fs::read_to_string(&path) { Ok(content) => Ok(ToolResult::success(content)), - Err(e) => Ok(ToolResult::error(format!("Failed to read file '{}': {}", path.display(), e))), + Err(e) => Ok(ToolResult::error(format!( + "Failed to read file '{}': {}", + path.display(), + e + ))), } } } @@ -125,23 +140,46 @@ impl Tool for FileWriteTool { let content = args["content"] .as_str() .ok_or_else(|| anyhow::anyhow!("Missing content"))?; - + let path = normalize_path(raw_path); match is_within_workspace(&path) { Ok(true) => {} - Ok(false) => return Ok(ToolResult::error(format!("Access denied: Path '{}' is outside the workspace boundary", path.display()))), - Err(e) => return Ok(ToolResult::error(format!("Failed to verify path '{}': {}", path.display(), e))), + Ok(false) => { + return Ok(ToolResult::error(format!( + "Access denied: Path '{}' is outside the workspace boundary", + path.display() + ))) + } + Err(e) => { + return Ok(ToolResult::error(format!( + "Failed to verify path '{}': {}", + path.display(), + e + ))) + } } 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))); + 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))), + 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 + ))), } } } @@ -184,12 +222,29 @@ impl Tool for FileEditTool { let path = normalize_path(raw_path); match is_within_workspace(&path) { Ok(true) => {} - Ok(false) => return Ok(ToolResult::error(format!("Access denied: Path '{}' is outside the workspace boundary", path.display()))), - Err(e) => return Ok(ToolResult::error(format!("Failed to verify path '{}': {}", path.display(), e))), + Ok(false) => { + return Ok(ToolResult::error(format!( + "Access denied: Path '{}' is outside the workspace boundary", + path.display() + ))) + } + Err(e) => { + return Ok(ToolResult::error(format!( + "Failed to verify path '{}': {}", + path.display(), + e + ))) + } } let content = match fs::read_to_string(&path) { Ok(c) => c, - Err(e) => return Ok(ToolResult::error(format!("Failed to read file '{}': {}", path.display(), e))), + Err(e) => { + return Ok(ToolResult::error(format!( + "Failed to read file '{}': {}", + path.display(), + e + ))) + } }; let matches = content.matches(old_string).count(); @@ -214,9 +269,15 @@ impl Tool for FileEditTool { match fs::write(&path, new_content) { Ok(_) => Ok(ToolResult::success(format!( "Successfully replaced {} occurrence(s) in {}", - matches, path.display() - )).with_diff(diff)), - Err(e) => Ok(ToolResult::error(format!("Failed to write file '{}': {}", path.display(), e))), + matches, + path.display() + )) + .with_diff(diff)), + Err(e) => Ok(ToolResult::error(format!( + "Failed to write file '{}': {}", + path.display(), + e + ))), } } } @@ -255,13 +316,30 @@ impl Tool for ApplyPatchTool { let path = normalize_path(raw_path); match is_within_workspace(&path) { Ok(true) => {} - Ok(false) => return Ok(ToolResult::error(format!("Access denied: Path '{}' is outside the workspace boundary", path.display()))), - Err(e) => return Ok(ToolResult::error(format!("Failed to verify path '{}': {}", path.display(), e))), + Ok(false) => { + return Ok(ToolResult::error(format!( + "Access denied: Path '{}' is outside the workspace boundary", + path.display() + ))) + } + Err(e) => { + return Ok(ToolResult::error(format!( + "Failed to verify path '{}': {}", + path.display(), + e + ))) + } } let content = match fs::read_to_string(&path) { Ok(c) => c, - Err(e) => return Ok(ToolResult::error(format!("Failed to read file '{}': {}", path.display(), e))), + Err(e) => { + return Ok(ToolResult::error(format!( + "Failed to read file '{}': {}", + path.display(), + e + ))) + } }; let patch = match diffy::Patch::from_str(patch_text) { @@ -277,8 +355,16 @@ impl Tool for ApplyPatchTool { let diff = generate_diff(&content, &new_content); match fs::write(&path, new_content) { - Ok(_) => Ok(ToolResult::success(format!("Successfully applied patch to {}", path.display())).with_diff(diff)), - Err(e) => Ok(ToolResult::error(format!("Failed to write file '{}': {}", path.display(), e))), + Ok(_) => Ok(ToolResult::success(format!( + "Successfully applied patch to {}", + path.display() + )) + .with_diff(diff)), + Err(e) => Ok(ToolResult::error(format!( + "Failed to write file '{}': {}", + path.display(), + e + ))), } } } diff --git a/libs/sdk/src/tools/lsp/client.rs b/libs/sdk/src/tools/lsp/client.rs index 12aa232..f6e584c 100644 --- a/libs/sdk/src/tools/lsp/client.rs +++ b/libs/sdk/src/tools/lsp/client.rs @@ -22,10 +22,17 @@ impl LspClient { .stderr(Stdio::null()) .spawn()?; - let mut stdin = child.stdin.take().ok_or_else(|| anyhow!("Failed to open stdin"))?; - let stdout = child.stdout.take().ok_or_else(|| anyhow!("Failed to open stdout"))?; - - let (request_tx, mut request_rx) = mpsc::channel::<(Value, Option>)>(32); + let mut stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("Failed to open stdin"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("Failed to open stdout"))?; + + let (request_tx, mut request_rx) = + mpsc::channel::<(Value, Option>)>(32); let pending_requests: Arc>>> = Arc::new(Mutex::new(HashMap::new())); @@ -75,7 +82,10 @@ impl LspClient { while let Some((req, response_tx_opt)) = request_rx.recv().await { if let Some(response_tx) = response_tx_opt { if let Some(id) = req.get("id").and_then(|id| id.as_u64()) { - pending_writer_clone.lock().await.insert(id as usize, response_tx); + pending_writer_clone + .lock() + .await + .insert(id as usize, response_tx); } } @@ -110,7 +120,7 @@ impl LspClient { if let Some(error) = resp.get("error") { return Err(anyhow!("LSP Error: {}", error)); } - + Ok(resp.get("result").cloned().unwrap_or(Value::Null)) } diff --git a/libs/sdk/src/tools/lsp/manager.rs b/libs/sdk/src/tools/lsp/manager.rs index 918483a..3ad9778 100644 --- a/libs/sdk/src/tools/lsp/manager.rs +++ b/libs/sdk/src/tools/lsp/manager.rs @@ -7,6 +7,7 @@ use std::path::Path; use std::sync::Arc; use tokio::sync::Mutex; +#[derive(Default)] pub struct LspManager { clients: Mutex>>, } @@ -43,7 +44,8 @@ impl LspManager { let client = LspClient::spawn(program, &args).await.map_err(|e| { anyhow!( "Failed to spawn {} (is it installed?). Error: {}", - program, e + program, + e ) })?; @@ -57,17 +59,18 @@ impl LspManager { process_id: Some(std::process::id()), workspace_folders: Some(vec![WorkspaceFolder { uri: root_uri.clone(), - name: root_dir.file_name().unwrap_or_default().to_string_lossy().to_string(), + name: root_dir + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(), }]), capabilities: ClientCapabilities::default(), ..Default::default() }; let _init_result = client_arc - .request( - "initialize", - serde_json::to_value(init_params)?, - ) + .request("initialize", serde_json::to_value(init_params)?) .await?; // Send initialized notification diff --git a/libs/sdk/src/tools/lsp_tool.rs b/libs/sdk/src/tools/lsp_tool.rs index f974ed0..e34b907 100644 --- a/libs/sdk/src/tools/lsp_tool.rs +++ b/libs/sdk/src/tools/lsp_tool.rs @@ -1,15 +1,17 @@ -use crate::tools::lsp::manager::LspManager; use crate::core::ToolResult; +use crate::tools::lsp::manager::LspManager; use crate::tools::traits::Tool; use anyhow::{anyhow, Result}; use async_trait::async_trait; use lsp_types::{ - GotoDefinitionParams, HoverParams, Position, ReferenceContext, ReferenceParams, TextDocumentIdentifier, TextDocumentPositionParams, Url, + GotoDefinitionParams, HoverParams, Position, ReferenceContext, ReferenceParams, + TextDocumentIdentifier, TextDocumentPositionParams, Url, }; use serde_json::json; use std::path::PathBuf; use std::sync::Arc; +#[derive(Default)] pub struct LspTool { manager: Arc, } @@ -72,18 +74,27 @@ impl Tool for LspTool { // Open Document notification (LSP servers require the file to be "opened" to answer queries) let client = self.manager.get_or_spawn_client(&path).await?; - + let content = std::fs::read_to_string(&path)?; let did_open_params = lsp_types::DidOpenTextDocumentParams { text_document: lsp_types::TextDocumentItem { uri: uri.clone(), - language_id: path.extension().unwrap_or_default().to_string_lossy().to_string(), + language_id: path + .extension() + .unwrap_or_default() + .to_string_lossy() + .to_string(), version: 1, text: content, }, }; // We notify but don't await response, it's just a notification - let _ = client.notify("textDocument/didOpen", serde_json::to_value(did_open_params)?).await; + let _ = client + .notify( + "textDocument/didOpen", + serde_json::to_value(did_open_params)?, + ) + .await; let text_doc_pos = TextDocumentPositionParams { text_document: TextDocumentIdentifier { uri: uri.clone() }, @@ -97,23 +108,31 @@ impl Tool for LspTool { work_done_progress_params: Default::default(), partial_result_params: Default::default(), }; - client.request("textDocument/definition", serde_json::to_value(params)?).await? + client + .request("textDocument/definition", serde_json::to_value(params)?) + .await? } "findReferences" => { let params = ReferenceParams { text_document_position: text_doc_pos, - context: ReferenceContext { include_declaration: true }, + context: ReferenceContext { + include_declaration: true, + }, work_done_progress_params: Default::default(), partial_result_params: Default::default(), }; - client.request("textDocument/references", serde_json::to_value(params)?).await? + client + .request("textDocument/references", serde_json::to_value(params)?) + .await? } "hover" => { let params = HoverParams { text_document_position_params: text_doc_pos, work_done_progress_params: Default::default(), }; - client.request("textDocument/hover", serde_json::to_value(params)?).await? + client + .request("textDocument/hover", serde_json::to_value(params)?) + .await? } _ => return Ok(ToolResult::error("Unsupported operation")), }; diff --git a/libs/sdk/src/tools/mcp/client.rs b/libs/sdk/src/tools/mcp/client.rs index 183eb56..e1006f4 100644 --- a/libs/sdk/src/tools/mcp/client.rs +++ b/libs/sdk/src/tools/mcp/client.rs @@ -14,20 +14,31 @@ pub struct McpClient { } impl McpClient { - pub async fn spawn(command: &str, args: &[String], envs: &HashMap) -> Result { + pub async fn spawn( + command: &str, + args: &[String], + envs: &HashMap, + ) -> Result { let mut cmd = Command::new(command); cmd.args(args) - .envs(envs) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()); // Pass stderr to console for debugging + .envs(envs) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); // Pass stderr to console for debugging let mut child = cmd.spawn()?; - let mut stdin = child.stdin.take().ok_or_else(|| anyhow!("Failed to open stdin"))?; - let stdout = child.stdout.take().ok_or_else(|| anyhow!("Failed to open stdout"))?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("Failed to open stdin"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("Failed to open stdout"))?; - let (request_tx, mut request_rx) = mpsc::channel::<(Value, Option>)>(32); + let (request_tx, mut request_rx) = + mpsc::channel::<(Value, Option>)>(32); let pending_requests: Arc>>> = Arc::new(Mutex::new(HashMap::new())); @@ -47,7 +58,7 @@ impl McpClient { if trimmed.is_empty() { continue; } - + if let Ok(msg) = serde_json::from_str::(trimmed) { if let Some(id) = msg.get("id").and_then(|id| id.as_u64()) { let mut pending = pending_clone.lock().await; @@ -65,13 +76,16 @@ impl McpClient { while let Some((req, response_tx_opt)) = request_rx.recv().await { if let Some(response_tx) = response_tx_opt { if let Some(id) = req.get("id").and_then(|id| id.as_u64()) { - pending_writer_clone.lock().await.insert(id as usize, response_tx); + pending_writer_clone + .lock() + .await + .insert(id as usize, response_tx); } } let mut body = serde_json::to_string(&req).unwrap(); body.push('\n'); // MCP requires \n - + if stdin.write_all(body.as_bytes()).await.is_err() { break; } @@ -103,7 +117,7 @@ impl McpClient { if let Some(error) = resp.get("error") { return Err(anyhow!("MCP Error: {}", error)); } - + Ok(resp.get("result").cloned().unwrap_or(Value::Null)) } diff --git a/libs/sdk/src/tools/mcp/manager.rs b/libs/sdk/src/tools/mcp/manager.rs index a00ff8a..474a06f 100644 --- a/libs/sdk/src/tools/mcp/manager.rs +++ b/libs/sdk/src/tools/mcp/manager.rs @@ -1,11 +1,11 @@ use super::client::McpClient; +use crate::tools::mcp_tool::DynamicMcpTool; use anyhow::Result; use serde::Deserialize; +use serde_json::json; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; -use serde_json::json; -use crate::tools::mcp_tool::DynamicMcpTool; #[derive(Deserialize, Debug, Clone)] pub struct McpServerConfig { @@ -22,6 +22,7 @@ pub struct McpConfig { pub mcp_servers: HashMap, } +#[derive(Default)] pub struct McpManager { clients: Mutex>>, } @@ -33,10 +34,13 @@ impl McpManager { } } - pub async fn load_and_register_tools(&self, registry: &mut crate::tools::ToolRegistry) -> Result<()> { + pub async fn load_and_register_tools( + &self, + registry: &mut crate::tools::ToolRegistry, + ) -> Result<()> { let root = crate::utils::storage::find_project_root(); let config_path = root.join(".routecode").join("mcp.json"); - + if !config_path.exists() { return Ok(()); } @@ -51,19 +55,20 @@ impl McpManager { if let Some(tools) = tools_res.get("tools").and_then(|t| t.as_array()) { for tool_info in tools { let name = tool_info["name"].as_str().unwrap_or("").to_string(); - let description = tool_info["description"].as_str().unwrap_or("").to_string(); + let description = + tool_info["description"].as_str().unwrap_or("").to_string(); let input_schema = tool_info["inputSchema"].clone(); - + let prefixed_name = format!("{}_{}", server_name, name); - + let dynamic_tool = DynamicMcpTool::new( prefixed_name, name, description, input_schema, - client.clone() + client.clone(), ); - + registry.register(Arc::new(dynamic_tool)); } } @@ -74,14 +79,14 @@ impl McpManager { } } } - + Ok(()) } async fn boot_server(&self, name: &str, config: McpServerConfig) -> Result> { let client = McpClient::spawn(&config.command, &config.args, &config.env).await?; let client_arc = Arc::new(client); - + let init_params = json!({ "protocolVersion": "2024-11-05", "capabilities": { @@ -95,10 +100,10 @@ impl McpManager { client_arc.request("initialize", Some(init_params)).await?; client_arc.notify("notifications/initialized", None).await?; - + let mut clients = self.clients.lock().await; clients.insert(name.to_string(), client_arc.clone()); - + Ok(client_arc) } } diff --git a/libs/sdk/src/tools/mcp_tool.rs b/libs/sdk/src/tools/mcp_tool.rs index 3b81597..c5ed9b8 100644 --- a/libs/sdk/src/tools/mcp_tool.rs +++ b/libs/sdk/src/tools/mcp_tool.rs @@ -54,8 +54,11 @@ impl Tool for DynamicMcpTool { match self.client.request("tools/call", Some(params)).await { Ok(res) => { - let is_error = res.get("isError").and_then(|e| e.as_bool()).unwrap_or(false); - + let is_error = res + .get("isError") + .and_then(|e| e.as_bool()) + .unwrap_or(false); + let mut output = String::new(); if let Some(content) = res.get("content").and_then(|c| c.as_array()) { for item in content { @@ -67,7 +70,7 @@ impl Tool for DynamicMcpTool { } else { output = serde_json::to_string_pretty(&res)?; } - + let result_text = output.trim().to_string(); if is_error { Ok(ToolResult::error(&result_text)) @@ -75,7 +78,10 @@ impl Tool for DynamicMcpTool { Ok(ToolResult::success(result_text)) } } - Err(e) => Ok(ToolResult::error(&format!("MCP Tool Execution Failed: {}", e))), + Err(e) => Ok(ToolResult::error(format!( + "MCP Tool Execution Failed: {}", + e + ))), } } } diff --git a/libs/sdk/src/tools/navigation.rs b/libs/sdk/src/tools/navigation.rs index c9e1278..bcc6b23 100644 --- a/libs/sdk/src/tools/navigation.rs +++ b/libs/sdk/src/tools/navigation.rs @@ -67,17 +67,28 @@ impl Tool for TreeTool { async fn execute(&self, args: Value) -> Result { let path_str = args["path"].as_str().unwrap_or("."); let max_depth = args["depth"].as_u64().unwrap_or(3) as usize; - + let mut output = String::new(); let path = std::path::Path::new(path_str); - + if !path.exists() { - return Ok(ToolResult::error(format!("Path '{}' does not exist", path_str))); + return Ok(ToolResult::error(format!( + "Path '{}' does not exist", + path_str + ))); } - fn walk(dir: &std::path::Path, prefix: &str, current_depth: usize, max_depth: usize, output: &mut String) -> std::io::Result<()> { - if current_depth > max_depth { return Ok(()); } - + fn walk( + dir: &std::path::Path, + prefix: &str, + current_depth: usize, + max_depth: usize, + output: &mut String, + ) -> std::io::Result<()> { + if current_depth > max_depth { + return Ok(()); + } + let entries: Vec<_> = fs::read_dir(dir)? .flatten() .filter(|entry| { @@ -85,7 +96,7 @@ impl Tool for TreeTool { name != ".git" && name != "node_modules" && name != "target" }) .collect(); - + let count = entries.len(); for (idx, entry) in entries.into_iter().enumerate() { let is_last = idx == count - 1; @@ -94,7 +105,7 @@ impl Tool for TreeTool { let connector = if is_last { "└── " } else { "├── " }; output.push_str(&format!("{}{}{}\n", prefix, connector, name)); - + if path.is_dir() { let new_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " }); walk(&path, &new_prefix, current_depth + 1, max_depth, output)?; @@ -105,7 +116,10 @@ impl Tool for TreeTool { output.push_str(&format!("{}\n", path_str)); if let Err(e) = walk(path, "", 1, max_depth, &mut output) { - return Ok(ToolResult::error(format!("Failed to walk directory: {}", e))); + return Ok(ToolResult::error(format!( + "Failed to walk directory: {}", + e + ))); } Ok(ToolResult::success(output)) @@ -142,7 +156,10 @@ impl Tool for GrepTool { let include = args["include"].as_str(); let glob_pattern = if let Some(inc) = include { - Some(glob::Pattern::new(inc).map_err(|e| anyhow::anyhow!("Invalid glob pattern '{}': {}", inc, e))?) + Some( + glob::Pattern::new(inc) + .map_err(|e| anyhow::anyhow!("Invalid glob pattern '{}': {}", inc, e))?, + ) } else { None }; @@ -168,7 +185,14 @@ impl Tool for GrepTool { continue; } if path.is_dir() { - walk_and_search(&path, search_root, pattern, regex_pattern, glob_pattern, results)?; + walk_and_search( + &path, + search_root, + pattern, + regex_pattern, + glob_pattern, + results, + )?; } else { if let Some(glob_pat) = glob_pattern { let mut matches = false; @@ -232,7 +256,14 @@ impl Tool for GrepTool { use std::io; let search_root = std::path::Path::new(path); - if let Err(e) = walk_and_search(search_root, search_root, pattern, regex_pattern.as_ref(), glob_pattern.as_ref(), &mut results) { + if let Err(e) = walk_and_search( + search_root, + search_root, + pattern, + regex_pattern.as_ref(), + glob_pattern.as_ref(), + &mut results, + ) { return Ok(ToolResult::error(format!("Search failed: {}", e))); } @@ -283,14 +314,10 @@ mod tests { .unwrap(); let file_path_rs = dir.path().join("test.rs"); - fs::write( - &file_path_rs, - "line 1: hello in rust", - ) - .unwrap(); + fs::write(&file_path_rs, "line 1: hello in rust").unwrap(); let tool = GrepTool; - + // Test normal grep without include filter let args = json!({ "pattern": "hello", diff --git a/libs/sdk/src/tools/subagent.rs b/libs/sdk/src/tools/subagent.rs index d04ec7b..a50e8d7 100644 --- a/libs/sdk/src/tools/subagent.rs +++ b/libs/sdk/src/tools/subagent.rs @@ -1,9 +1,9 @@ +use crate::agents::AIProvider; +use crate::core::orchestrator::AgentOrchestrator; +use crate::core::Config; use crate::core::ToolResult; use crate::tools::registry::ToolRegistry; use crate::tools::traits::Tool; -use crate::core::orchestrator::AgentOrchestrator; -use crate::core::Config; -use crate::agents::AIProvider; use anyhow::Result; use async_trait::async_trait; use serde_json::{json, Value}; @@ -66,10 +66,15 @@ impl Tool for SubAgentTool { let approve_handle = tokio::spawn(async move { while let Some(chunk) = rx.recv().await { - if let crate::agents::types::StreamChunk::RequestConfirmation { tx: Some(resp_tx), .. } = chunk { + if let crate::agents::types::StreamChunk::RequestConfirmation { + tx: Some(resp_tx), + .. + } = chunk + { let mut lock = resp_tx.lock().await; if let Some(sender) = lock.take() { - let _ = sender.send(crate::agents::types::ConfirmationResponse::AllowSession); + let _ = + sender.send(crate::agents::types::ConfirmationResponse::AllowSession); } } } @@ -78,7 +83,7 @@ impl Tool for SubAgentTool { let config = self.config.lock().await; let model = config.model.clone(); drop(config); - + match orchestrator.run(&mut history, &model, Some(tx), None).await { Ok(_) => { let _ = approve_handle.await; @@ -86,7 +91,9 @@ impl Tool for SubAgentTool { let content_str = msg.content.as_deref().unwrap_or_default().to_string(); Ok(ToolResult::success(content_str)) } else { - Ok(ToolResult::error("Sub-agent completed but returned no final response.")) + Ok(ToolResult::error( + "Sub-agent completed but returned no final response.", + )) } } Err(e) => Ok(ToolResult::error(format!("Sub-agent failed: {}", e))), diff --git a/libs/sdk/src/tools/web/fetch.rs b/libs/sdk/src/tools/web/fetch.rs index 86d854c..39f9060 100644 --- a/libs/sdk/src/tools/web/fetch.rs +++ b/libs/sdk/src/tools/web/fetch.rs @@ -40,58 +40,59 @@ impl Tool for WebFetchTool { .timeout(std::time::Duration::from_secs(15)) .build()?; - let response = client.get(url) + let response = client + .get(url) .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") - .send().await; - + .send() + .await; + match response { Ok(resp) => { if !resp.status().is_success() { - return Ok(ToolResult::error(&format!("HTTP Error: {}", resp.status()))); + return Ok(ToolResult::error(format!("HTTP Error: {}", resp.status()))); } - + let html = resp.text().await?; - + // Extremely simple HTML to text conversion using regex // 1. Remove script tags and their contents let re_script = Regex::new(r"(?is)]*>.*?").unwrap(); let text = re_script.replace_all(&html, ""); - + // 2. Remove style tags and their contents let re_style = Regex::new(r"(?is)]*>.*?").unwrap(); let text = re_style.replace_all(&text, ""); - + // 3. Replace typical block elements with newlines let re_block = Regex::new(r"(?i)").unwrap(); let text = re_block.replace_all(&text, "\n"); - + // 4. Strip all remaining HTML tags let re_tags = Regex::new(r"(?is)<[^>]+>").unwrap(); let text = re_tags.replace_all(&text, ""); - + // 5. Decode basic HTML entities - let text = text.replace("<", "<") + let text = text + .replace("<", "<") .replace(">", ">") .replace("&", "&") .replace(""", "\"") .replace("'", "'") .replace(" ", " "); - + // 6. Condense multiple newlines and spaces let re_newlines = Regex::new(r"\n\s*\n").unwrap(); let mut text = re_newlines.replace_all(&text, "\n\n").to_string(); - + // Truncate if it's too large (e.g. > 100k characters) if text.len() > 100_000 { text.truncate(100_000); text.push_str("\n\n...[Content truncated due to length]"); } - + Ok(ToolResult::success(text.trim().to_string())) } - Err(e) => { - Ok(ToolResult::error(&format!("Failed to fetch URL: {}", e))) - } + Err(e) => Ok(ToolResult::error(format!("Failed to fetch URL: {}", e))), } } } diff --git a/libs/sdk/src/tools/web/search.rs b/libs/sdk/src/tools/web/search.rs index 37f020d..37b10c2 100644 --- a/libs/sdk/src/tools/web/search.rs +++ b/libs/sdk/src/tools/web/search.rs @@ -40,57 +40,63 @@ impl Tool for WebSearchTool { .timeout(std::time::Duration::from_secs(15)) .build()?; - let response = client.post("https://lite.duckduckgo.com/lite/") + let response = client + .post("https://lite.duckduckgo.com/lite/") .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") .form(&[("q", query)]) - .send().await; - + .send() + .await; + match response { Ok(resp) => { if !resp.status().is_success() { - return Ok(ToolResult::error(&format!("HTTP Error: {}", resp.status()))); + return Ok(ToolResult::error(format!("HTTP Error: {}", resp.status()))); } - + let html = resp.text().await?; - + let mut results = vec![]; - + // Matches TITLE let re_link = Regex::new(r#"(?is)]*class=['"]result-link['"][^>]*href=['"]([^'"]+)['"][^>]*>(.*?)"#).unwrap(); - + let re_tags = Regex::new(r"(?is)<[^>]+>").unwrap(); + for cap in re_link.captures_iter(&html) { let url = &cap[1]; let title = &cap[2]; - - if url.starts_with("/") || url.contains("duckduckgo.com") || url.starts_with("?q=") { + + if url.starts_with("/") + || url.contains("duckduckgo.com") + || url.starts_with("?q=") + { continue; } - - let re_tags = Regex::new(r"(?is)<[^>]+>").unwrap(); + let clean_title = re_tags.replace_all(title, "").to_string(); - let clean_title = clean_title.replace(""", "\"").replace("'", "'").replace("&", "&"); - + let clean_title = clean_title + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&"); + if !clean_title.trim().is_empty() { results.push(json!({ "title": clean_title.trim(), "url": url, })); } - + if results.len() >= 10 { break; } } - + if results.is_empty() { return Ok(ToolResult::success("No search results found.".to_string())); } - + Ok(ToolResult::success(serde_json::to_string_pretty(&results)?)) } - Err(e) => { - Ok(ToolResult::error(&format!("Search failed: {}", e))) - } + Err(e) => Ok(ToolResult::error(format!("Search failed: {}", e))), } } } diff --git a/libs/sdk/src/update/checker.rs b/libs/sdk/src/update/checker.rs index 23d1505..eada59d 100644 --- a/libs/sdk/src/update/checker.rs +++ b/libs/sdk/src/update/checker.rs @@ -1,25 +1,31 @@ -use super::types::{get_platform_asset_name, get_platform_checksum_asset_name, GitHubRelease, UpdateInfo}; +use super::types::{ + get_platform_asset_name, get_platform_checksum_asset_name, GitHubRelease, UpdateInfo, +}; use anyhow::{Context, Result}; use semver::Version; -pub async fn check_for_update( - current_version: &str, - repo: &str, -) -> Result { +pub async fn check_for_update(current_version: &str, repo: &str) -> Result { let client = reqwest::Client::builder() .user_agent(format!("routecode-updater/{}", current_version)) .build()?; let url = format!("https://api.github.com/repos/{}/releases/latest", repo); let resp = client.get(&url).send().await?; - let release: GitHubRelease = resp.json().await + let release: GitHubRelease = resp + .json() + .await .context("Failed to parse GitHub release JSON")?; let tag = release.tag_name.trim_start_matches('v'); let latest_version = Version::parse(tag) .map_err(|e| anyhow::anyhow!("Failed to parse latest version '{}': {}", tag, e))?; - let current = Version::parse(current_version) - .map_err(|e| anyhow::anyhow!("Failed to parse current version '{}': {}", current_version, e))?; + let current = Version::parse(current_version).map_err(|e| { + anyhow::anyhow!( + "Failed to parse current version '{}': {}", + current_version, + e + ) + })?; let is_update_available = latest_version > current; @@ -28,20 +34,32 @@ pub async fn check_for_update( let download_url = platform_asset .and_then(|name| { - release.assets.iter().find(|a| a.name == name) + release + .assets + .iter() + .find(|a| a.name == name) .map(|a| a.browser_download_url.clone()) }) - .unwrap_or_else(|| release.assets.first() - .map(|a| a.browser_download_url.clone()) - .unwrap_or_default()); + .unwrap_or_else(|| { + release + .assets + .first() + .map(|a| a.browser_download_url.clone()) + .unwrap_or_default() + }); let checksum_url = checksum_asset .and_then(|name| { - release.assets.iter().find(|a| a.name == name) + release + .assets + .iter() + .find(|a| a.name == name) .map(|a| a.browser_download_url.clone()) }) .unwrap_or_else(|| { - release.assets.iter() + release + .assets + .iter() .find(|a| a.name == "checksums.txt") .map(|a| a.browser_download_url.clone()) .unwrap_or_default() diff --git a/libs/sdk/src/utils/costs.rs b/libs/sdk/src/utils/costs.rs index 4166193..e306722 100644 --- a/libs/sdk/src/utils/costs.rs +++ b/libs/sdk/src/utils/costs.rs @@ -1,7 +1,7 @@ +use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::{Arc, RwLock}; -use once_cell::sync::Lazy; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Usage { @@ -39,9 +39,8 @@ struct ModelsDevCost { pub output: f64, } -static RATE_CACHE: Lazy>>> = Lazy::new(|| { - Arc::new(RwLock::new(HashMap::new())) -}); +static RATE_CACHE: Lazy>>> = + Lazy::new(|| Arc::new(RwLock::new(HashMap::new()))); impl Usage { pub async fn add(&mut self, input: u32, output: u32, model: &str) { @@ -73,7 +72,10 @@ pub async fn refresh_rates() -> anyhow::Result<()> { let response = client.get("https://models.dev/api.json").send().await?; if !response.status().is_success() { - return Err(anyhow::anyhow!("Failed to fetch rates: {}", response.status())); + return Err(anyhow::anyhow!( + "Failed to fetch rates: {}", + response.status() + )); } let data: HashMap = response.json().await?; @@ -82,10 +84,13 @@ pub async fn refresh_rates() -> anyhow::Result<()> { for (_provider_id, provider) in data { for (model_id, model) in provider.models { if let Some(cost) = model.cost { - new_rates.insert(model_id, ModelRates { - input_per_1m: cost.input, - output_per_1m: cost.output, - }); + new_rates.insert( + model_id, + ModelRates { + input_per_1m: cost.input, + output_per_1m: cost.output, + }, + ); } } } @@ -93,7 +98,10 @@ pub async fn refresh_rates() -> anyhow::Result<()> { if !new_rates.is_empty() { let mut cache = RATE_CACHE.write().unwrap_or_else(|e| e.into_inner()); *cache = new_rates; - log::info!("Successfully updated {} model rates from models.dev", cache.len()); + log::info!( + "Successfully updated {} model rates from models.dev", + cache.len() + ); } Ok(()) @@ -110,7 +118,7 @@ async fn get_model_rates(model: &str) -> ModelRates { if let Some(rates) = cache.get(model) { return rates.clone(); } - + // Try fuzzy match if exact match fails (e.g. "gpt-4o-2024-05-13" vs "gpt-4o") for (cached_id, rates) in cache.iter() { if model.contains(cached_id) || cached_id.contains(model) { @@ -137,18 +145,36 @@ async fn get_model_rates(model: &str) -> ModelRates { fn get_fallback_rates(model: &str) -> ModelRates { if model.contains("gpt-4o-mini") { - ModelRates { input_per_1m: 0.15, output_per_1m: 0.60 } + ModelRates { + input_per_1m: 0.15, + output_per_1m: 0.60, + } } else if model.contains("gpt-4o") { - ModelRates { input_per_1m: 5.0, output_per_1m: 15.0 } + ModelRates { + input_per_1m: 5.0, + output_per_1m: 15.0, + } } else if model.contains("claude-3-5-sonnet") { - ModelRates { input_per_1m: 3.0, output_per_1m: 15.0 } + ModelRates { + input_per_1m: 3.0, + output_per_1m: 15.0, + } } else if model.contains("deepseek-v3") || model.contains("deepseek-chat") { - ModelRates { input_per_1m: 0.14, output_per_1m: 0.28 } + ModelRates { + input_per_1m: 0.14, + output_per_1m: 0.28, + } } else { if !model.is_empty() { - log::warn!("Unknown model '{}' for cost calculation. Using default fallback rates.", model); + log::warn!( + "Unknown model '{}' for cost calculation. Using default fallback rates.", + model + ); } // Default GPT-4o style fallback - ModelRates { input_per_1m: 5.0, output_per_1m: 15.0 } + ModelRates { + input_per_1m: 5.0, + output_per_1m: 15.0, + } } } diff --git a/libs/sdk/src/utils/error.rs b/libs/sdk/src/utils/error.rs index d454c92..4682d72 100644 --- a/libs/sdk/src/utils/error.rs +++ b/libs/sdk/src/utils/error.rs @@ -94,10 +94,7 @@ pub fn classify_error(err: &anyhow::Error) -> RetryClass { if let Some(status) = reqwest_err.status() { return classify_status(status); } - if reqwest_err.is_connect() - || reqwest_err.is_timeout() - || reqwest_err.is_request() - { + if reqwest_err.is_connect() || reqwest_err.is_timeout() || reqwest_err.is_request() { return RetryClass::Transient; } } @@ -112,27 +109,63 @@ mod tests { #[test] fn classify_status_4xx_permanent() { - assert_eq!(classify_status(StatusCode::BAD_REQUEST), RetryClass::Permanent); - assert_eq!(classify_status(StatusCode::UNAUTHORIZED), RetryClass::Permanent); - assert_eq!(classify_status(StatusCode::FORBIDDEN), RetryClass::Permanent); - assert_eq!(classify_status(StatusCode::NOT_FOUND), RetryClass::Permanent); - assert_eq!(classify_status(StatusCode::UNPROCESSABLE_ENTITY), RetryClass::Permanent); + assert_eq!( + classify_status(StatusCode::BAD_REQUEST), + RetryClass::Permanent + ); + assert_eq!( + classify_status(StatusCode::UNAUTHORIZED), + RetryClass::Permanent + ); + assert_eq!( + classify_status(StatusCode::FORBIDDEN), + RetryClass::Permanent + ); + assert_eq!( + classify_status(StatusCode::NOT_FOUND), + RetryClass::Permanent + ); + assert_eq!( + classify_status(StatusCode::UNPROCESSABLE_ENTITY), + RetryClass::Permanent + ); } #[test] fn classify_status_4xx_transient() { - assert_eq!(classify_status(StatusCode::REQUEST_TIMEOUT), RetryClass::Transient); + assert_eq!( + classify_status(StatusCode::REQUEST_TIMEOUT), + RetryClass::Transient + ); assert_eq!(classify_status(StatusCode::CONFLICT), RetryClass::Transient); - assert_eq!(classify_status(StatusCode::TOO_MANY_REQUESTS), RetryClass::Transient); - assert_eq!(classify_status(StatusCode::from_u16(425).unwrap()), RetryClass::Transient); + assert_eq!( + classify_status(StatusCode::TOO_MANY_REQUESTS), + RetryClass::Transient + ); + assert_eq!( + classify_status(StatusCode::from_u16(425).unwrap()), + RetryClass::Transient + ); } #[test] fn classify_status_5xx_transient() { - assert_eq!(classify_status(StatusCode::INTERNAL_SERVER_ERROR), RetryClass::Transient); - assert_eq!(classify_status(StatusCode::BAD_GATEWAY), RetryClass::Transient); - assert_eq!(classify_status(StatusCode::SERVICE_UNAVAILABLE), RetryClass::Transient); - assert_eq!(classify_status(StatusCode::GATEWAY_TIMEOUT), RetryClass::Transient); + assert_eq!( + classify_status(StatusCode::INTERNAL_SERVER_ERROR), + RetryClass::Transient + ); + assert_eq!( + classify_status(StatusCode::BAD_GATEWAY), + RetryClass::Transient + ); + assert_eq!( + classify_status(StatusCode::SERVICE_UNAVAILABLE), + RetryClass::Transient + ); + assert_eq!( + classify_status(StatusCode::GATEWAY_TIMEOUT), + RetryClass::Transient + ); } #[test] @@ -155,8 +188,7 @@ mod tests { #[test] fn classify_error_walks_source_chain() { - let err = http_error(StatusCode::FORBIDDEN, "x".to_string()) - .context("calling anthropic"); + let err = http_error(StatusCode::FORBIDDEN, "x".to_string()).context("calling anthropic"); assert_eq!(classify_error(&err), RetryClass::Permanent); } diff --git a/libs/sdk/src/utils/mod.rs b/libs/sdk/src/utils/mod.rs index 0550781..d78e21a 100644 --- a/libs/sdk/src/utils/mod.rs +++ b/libs/sdk/src/utils/mod.rs @@ -1,5 +1,5 @@ pub mod costs; pub mod error; +pub mod models; pub mod storage; pub mod tokens; -pub mod models; diff --git a/libs/sdk/src/utils/models.rs b/libs/sdk/src/utils/models.rs index 07051df..964d719 100644 --- a/libs/sdk/src/utils/models.rs +++ b/libs/sdk/src/utils/models.rs @@ -29,7 +29,7 @@ pub fn get_models_cache_path() -> PathBuf { pub async fn fetch_and_cache_models() -> anyhow::Result<()> { let url = std::env::var("ROUTECODE_MODELS_URL") .unwrap_or_else(|_| "https://models.dev/api.json".to_string()); - + // Check if cache is fresh enough (e.g., < 24 hours old) let cache_path = get_models_cache_path(); if cache_path.exists() { @@ -48,24 +48,24 @@ pub async fn fetch_and_cache_models() -> anyhow::Result<()> { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build()?; - + let response = client.get(&url).send().await?; - + if response.status().is_success() { let text = response.text().await?; // Verify it parses correctly before saving let _parsed: HashMap = serde_json::from_str(&text)?; - + let dir = crate::utils::storage::get_base_dir(); if !dir.exists() { fs::create_dir_all(&dir)?; } - + fs::write(cache_path, text)?; } else { anyhow::bail!("Failed to fetch models: HTTP {}", response.status()); } - + Ok(()) } @@ -74,10 +74,10 @@ pub fn get_models_for_provider(provider_id: &str) -> Option> { if !cache_path.exists() { return None; } - + let content = fs::read_to_string(cache_path).ok()?; let registry: HashMap = serde_json::from_str(&content).ok()?; - + // opencode sometimes uses provider ids like "google-vertex" // Let's find the provider that matches or contains the provider_id for (id, provider) in registry { @@ -88,7 +88,7 @@ pub fn get_models_for_provider(provider_id: &str) -> Option> { return Some(models); } } - + None } @@ -97,15 +97,15 @@ pub fn get_provider_info(provider_id: &str) -> Option { if !cache_path.exists() { return None; } - + let content = fs::read_to_string(cache_path).ok()?; let registry: HashMap = serde_json::from_str(&content).ok()?; - + for (id, provider) in registry { if id == provider_id || id.contains(provider_id) || provider_id.contains(&id) { return Some(provider); } } - + None } diff --git a/libs/sdk/src/utils/storage.rs b/libs/sdk/src/utils/storage.rs index b3e8778..ffeffaa 100644 --- a/libs/sdk/src/utils/storage.rs +++ b/libs/sdk/src/utils/storage.rs @@ -1,10 +1,10 @@ use crate::core::{Config, Message}; use crate::utils::costs::Usage; use serde::{Deserialize, Serialize}; +use std::collections::hash_map::DefaultHasher; use std::fs; -use std::path::PathBuf; use std::hash::{Hash, Hasher}; -use std::collections::hash_map::DefaultHasher; +use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { @@ -61,12 +61,12 @@ pub fn get_base_dir() -> PathBuf { pub fn find_project_root() -> PathBuf { let mut current = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - + loop { if current.join(".git").exists() || current.join("ROUTECODE.md").exists() { return current; } - + if let Some(parent) = current.parent() { current = parent.to_path_buf(); } else { @@ -79,39 +79,40 @@ pub fn find_project_root() -> PathBuf { pub fn get_workspace_dir() -> PathBuf { let root = find_project_root(); let root_str = root.to_string_lossy().to_string(); - + let mut hasher = DefaultHasher::new(); root_str.hash(&mut hasher); let hash = format!("{:x}", hasher.finish()); - - let folder_name = root.file_name() + + let folder_name = root + .file_name() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| "workspace".to_string()); - + let safe_folder_name = folder_name.replace(|c: char| !c.is_alphanumeric(), "_"); let workspace_id = format!("{}_{}", safe_folder_name, &hash[..8]); - + get_base_dir().join("workspaces").join(workspace_id) } pub fn is_path_outside_workspace(path_str: &str) -> bool { let root = find_project_root(); let root_canon = root.canonicalize().unwrap_or(root.clone()); - + let mut p_str = path_str; if p_str.starts_with("/workspace/") { p_str = &p_str[11..]; } else if p_str.starts_with("/workspace") { p_str = &p_str[10..]; } - + let path = PathBuf::from(p_str); let absolute_path = if path.is_absolute() { path } else { root.join(path) }; - + let mut p = absolute_path; while !p.exists() { if let Some(parent) = p.parent() { @@ -120,13 +121,13 @@ pub fn is_path_outside_workspace(path_str: &str) -> bool { break; } } - + if p.exists() { if let Ok(canon) = p.canonicalize() { return !canon.starts_with(&root_canon); } } - + false } @@ -138,15 +139,17 @@ pub fn sanitize_session_name(name: &str) -> String { pub fn save_session(name: &str, session: &Session) -> anyhow::Result<()> { let safe_name = sanitize_session_name(name); - if safe_name.is_empty() { return Err(anyhow::anyhow!("Invalid session name")); } - + if safe_name.is_empty() { + return Err(anyhow::anyhow!("Invalid session name")); + } + let workspace_dir = get_workspace_dir(); let session_dir = workspace_dir.join("sessions").join(&safe_name); - + if !session_dir.exists() { fs::create_dir_all(&session_dir)?; } - + let history_path = session_dir.join("history.json"); let json = serde_json::to_string_pretty(session)?; fs::write(history_path, json)?; @@ -155,15 +158,26 @@ pub fn save_session(name: &str, session: &Session) -> anyhow::Result<()> { pub fn load_session(name: &str) -> anyhow::Result { let safe_name = sanitize_session_name(name); - if safe_name.is_empty() { return Err(anyhow::anyhow!("Invalid session name")); } - + if safe_name.is_empty() { + return Err(anyhow::anyhow!("Invalid session name")); + } + let workspace_dir = get_workspace_dir(); - - let old_path = get_base_dir().join("sessions").join(format!("{}.json", safe_name)); - let new_path = workspace_dir.join("sessions").join(&safe_name).join("history.json"); - - let path = if new_path.exists() { new_path } else { old_path }; - + + let old_path = get_base_dir() + .join("sessions") + .join(format!("{}.json", safe_name)); + let new_path = workspace_dir + .join("sessions") + .join(&safe_name) + .join("history.json"); + + let path = if new_path.exists() { + new_path + } else { + old_path + }; + let json = fs::read_to_string(path)?; let session = serde_json::from_str(&json)?; Ok(session) @@ -173,9 +187,9 @@ pub fn list_sessions() -> anyhow::Result> { let workspace_dir = get_workspace_dir(); let new_sessions_dir = workspace_dir.join("sessions"); let old_sessions_dir = get_base_dir().join("sessions"); - + let mut sessions = Vec::new(); - + if new_sessions_dir.exists() { for entry in fs::read_dir(new_sessions_dir)? { let entry = entry?; @@ -187,7 +201,7 @@ pub fn list_sessions() -> anyhow::Result> { } } } - + if old_sessions_dir.exists() { for entry in fs::read_dir(old_sessions_dir)? { let entry = entry?; @@ -201,21 +215,26 @@ pub fn list_sessions() -> anyhow::Result> { } } } - + Ok(sessions) } pub fn load_session_config(name: &str) -> anyhow::Result { let safe_name = sanitize_session_name(name); - if safe_name.is_empty() { return Err(anyhow::anyhow!("Invalid session name")); } - + if safe_name.is_empty() { + return Err(anyhow::anyhow!("Invalid session name")); + } + let workspace_dir = get_workspace_dir(); - let config_path = workspace_dir.join("sessions").join(&safe_name).join("session_config.json"); - + let config_path = workspace_dir + .join("sessions") + .join(&safe_name) + .join("session_config.json"); + if !config_path.exists() { return Ok(SessionConfig::default()); } - + let json = fs::read_to_string(config_path)?; let config = serde_json::from_str(&json).unwrap_or_default(); Ok(config) @@ -223,15 +242,17 @@ pub fn load_session_config(name: &str) -> anyhow::Result { pub fn save_session_config(name: &str, config: &SessionConfig) -> anyhow::Result<()> { let safe_name = sanitize_session_name(name); - if safe_name.is_empty() { return Err(anyhow::anyhow!("Invalid session name")); } - + if safe_name.is_empty() { + return Err(anyhow::anyhow!("Invalid session name")); + } + let workspace_dir = get_workspace_dir(); let session_dir = workspace_dir.join("sessions").join(&safe_name); - + if !session_dir.exists() { fs::create_dir_all(&session_dir)?; } - + let config_path = session_dir.join("session_config.json"); let json = serde_json::to_string_pretty(config)?; fs::write(config_path, json)?; diff --git a/libs/sdk/src/utils/tokens.rs b/libs/sdk/src/utils/tokens.rs index 6129a1c..691df3e 100644 --- a/libs/sdk/src/utils/tokens.rs +++ b/libs/sdk/src/utils/tokens.rs @@ -2,13 +2,14 @@ use crate::core::Message; use once_cell::sync::Lazy; use tiktoken_rs::{cl100k_base, CoreBPE}; -static BPE: Lazy> = Lazy::new(|| { - match cl100k_base() { - Ok(b) => Some(b), - Err(e) => { - log::warn!("tiktoken cl100k_base initialization failed: {}. Fallback estimation will be used.", e); - None - } +static BPE: Lazy> = Lazy::new(|| match cl100k_base() { + Ok(b) => Some(b), + Err(e) => { + log::warn!( + "tiktoken cl100k_base initialization failed: {}. Fallback estimation will be used.", + e + ); + None } }); @@ -38,8 +39,12 @@ pub fn count_tokens(messages: &[Message]) -> usize { let mut total = 0; for m in messages { total += 4; // Role overhead - if let Some(content) = &m.content { total += content.len() / 4; } - if let Some(thought) = &m.thought { total += thought.len() / 4; } + if let Some(content) = &m.content { + total += content.len() / 4; + } + if let Some(thought) = &m.thought { + total += thought.len() / 4; + } if let Some(tool_calls) = &m.tool_calls { for tc in tool_calls { total += tc.function.name.len() / 4; diff --git a/libs/sdk/tests/integration_test.rs b/libs/sdk/tests/integration_test.rs index 2065054..04cdea4 100644 --- a/libs/sdk/tests/integration_test.rs +++ b/libs/sdk/tests/integration_test.rs @@ -1,6 +1,12 @@ +use async_trait::async_trait; +use futures::stream; use routecode_sdk::agents::resolve_provider; +use routecode_sdk::agents::types::StreamChunk; +use routecode_sdk::agents::AIProvider; use routecode_sdk::core::orchestrator::AgentOrchestrator; -use routecode_sdk::core::{Config, DynamicModelInfo, Message, Role, ToolCall, FunctionCall, ToolResult}; +use routecode_sdk::core::{ + Config, DynamicModelInfo, FunctionCall, Message, Role, ToolCall, ToolResult, +}; use routecode_sdk::tools::bash::BashTool; use routecode_sdk::tools::file_ops::{FileEditTool, FileReadTool, FileWriteTool}; use routecode_sdk::tools::navigation::{GrepTool, LsTool, TreeTool}; @@ -10,20 +16,15 @@ use routecode_sdk::utils::costs::{calculate_cost, Usage}; use routecode_sdk::utils::storage::{ find_project_root, is_path_outside_workspace, load_config, sanitize_session_name, save_config, }; -use routecode_sdk::agents::types::StreamChunk; -use routecode_sdk::agents::AIProvider; -use async_trait::async_trait; -use futures::stream; use serde_json::json; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; use std::fs; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; use tempfile::TempDir; fn workspace_temp_dir() -> TempDir { let cwd = std::env::current_dir().expect("current dir"); let dir = cwd.join(".routecode_test_tmp"); - let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); TempDir::new_in(&dir).unwrap() } @@ -45,7 +46,10 @@ async fn test_tool_file_write_read_edit_pipeline() { let read_args = json!({ "path": file_path.to_str().unwrap() }); let res = read_tool.execute(read_args.clone()).await.unwrap(); assert!(res.success, "FileRead after write failed: {:?}", res.error); - assert_eq!(res.content.unwrap(), "line 1: hello\nline 2: world\nline 3: goodbye"); + assert_eq!( + res.content.unwrap(), + "line 1: hello\nline 2: world\nline 3: goodbye" + ); let edit_tool = FileEditTool; let edit_args = json!({ @@ -58,7 +62,10 @@ async fn test_tool_file_write_read_edit_pipeline() { let res = read_tool.execute(read_args.clone()).await.unwrap(); assert!(res.success, "FileRead after edit failed: {:?}", res.error); - assert_eq!(res.content.unwrap(), "line 1: hello\nline 2: world\nline 3: aloha"); + assert_eq!( + res.content.unwrap(), + "line 1: hello\nline 2: world\nline 3: aloha" + ); } #[tokio::test] @@ -70,7 +77,10 @@ async fn test_tool_ls_tree_grep() { fs::write(dir.path().join("sub").join("gamma.rs"), "fn gamma() {}").unwrap(); let ls_tool = LsTool; - let res = ls_tool.execute(json!({ "path": dir.path().to_str().unwrap() })).await.unwrap(); + let res = ls_tool + .execute(json!({ "path": dir.path().to_str().unwrap() })) + .await + .unwrap(); assert!(res.success); let content = res.content.unwrap(); assert!(content.contains("alpha.rs")); @@ -78,34 +88,46 @@ async fn test_tool_ls_tree_grep() { assert!(content.contains("sub")); let tree_tool = TreeTool; - let res = tree_tool.execute(json!({ - "path": dir.path().to_str().unwrap(), - "depth": 2 - })).await.unwrap(); + let res = tree_tool + .execute(json!({ + "path": dir.path().to_str().unwrap(), + "depth": 2 + })) + .await + .unwrap(); assert!(res.success); assert!(res.content.unwrap().contains("gamma.rs")); let grep_tool = GrepTool; - let res = grep_tool.execute(json!({ - "pattern": "gamma", - "path": dir.path().to_str().unwrap() - })).await.unwrap(); + let res = grep_tool + .execute(json!({ + "pattern": "gamma", + "path": dir.path().to_str().unwrap() + })) + .await + .unwrap(); assert!(res.success); assert!(res.content.unwrap().contains("gamma.rs")); - let res = grep_tool.execute(json!({ - "pattern": "alpha", - "path": dir.path().to_str().unwrap(), - "include": "*.rs" - })).await.unwrap(); + let res = grep_tool + .execute(json!({ + "pattern": "alpha", + "path": dir.path().to_str().unwrap(), + "include": "*.rs" + })) + .await + .unwrap(); assert!(res.success); assert!(res.content.unwrap().contains("alpha.rs")); - let res = grep_tool.execute(json!({ - "pattern": "beta", - "path": dir.path().to_str().unwrap(), - "include": "*.rs" - })).await.unwrap(); + let res = grep_tool + .execute(json!({ + "pattern": "beta", + "path": dir.path().to_str().unwrap(), + "include": "*.rs" + })) + .await + .unwrap(); assert!(res.success); assert_eq!(res.content.unwrap(), "No matches found."); } @@ -154,12 +176,18 @@ async fn test_tool_edit_ambiguous() { async fn test_tool_bash_simple() { if cfg!(target_os = "windows") { let tool = BashTool; - let res = tool.execute(json!({"command": "echo hello"})).await.unwrap(); + let res = tool + .execute(json!({"command": "echo hello"})) + .await + .unwrap(); assert!(res.success); assert!(res.content.unwrap_or_default().contains("hello")); } else { let tool = BashTool; - let res = tool.execute(json!({"command": "echo hello"})).await.unwrap(); + let res = tool + .execute(json!({"command": "echo hello"})) + .await + .unwrap(); assert!(res.success); assert!(res.content.unwrap_or_default().contains("hello")); } @@ -220,15 +248,30 @@ async fn test_orchestrator_recursion_depth_limit() { struct InfiniteLoopProvider; #[async_trait] impl AIProvider for InfiniteLoopProvider { - fn name(&self) -> &str { "InfiniteLoop" } - async fn list_models(&self) -> Result, anyhow::Error> { Ok(vec!["mock".into()]) } - async fn ask(&self, _msgs: Arc>, _model: &str, _tools: Arc>>, _thinking_level: Option<&str>) -> Result { + fn name(&self) -> &str { + "InfiniteLoop" + } + async fn list_models(&self) -> Result, anyhow::Error> { + Ok(vec!["mock".into()]) + } + async fn ask( + &self, + _msgs: Arc>, + _model: &str, + _tools: Arc>>, + _thinking_level: Option<&str>, + ) -> Result { let chunks = vec![ Ok(StreamChunk::ToolCall { tool_call: ToolCall { - id: "call_inf".into(), r#type: "function".into(), index: Some(0), - function: FunctionCall { name: "mock_tool".into(), arguments: "{}".into() }, - } + id: "call_inf".into(), + r#type: "function".into(), + index: Some(0), + function: FunctionCall { + name: "mock_tool".into(), + arguments: "{}".into(), + }, + }, }), Ok(StreamChunk::Done), ]; @@ -239,9 +282,15 @@ async fn test_orchestrator_recursion_depth_limit() { struct RecursiveMockTool; #[async_trait] impl Tool for RecursiveMockTool { - fn name(&self) -> &str { "mock_tool" } - fn description(&self) -> &str { "recursive mock" } - fn parameters(&self) -> serde_json::Value { json!({}) } + fn name(&self) -> &str { + "mock_tool" + } + fn description(&self) -> &str { + "recursive mock" + } + fn parameters(&self) -> serde_json::Value { + json!({}) + } async fn execute(&self, _args: serde_json::Value) -> Result { Ok(ToolResult::success("ok")) } @@ -256,7 +305,10 @@ async fn test_orchestrator_recursion_depth_limit() { let mut history = vec![Message::user("loop")]; let result = orchestrator.run(&mut history, "mock", None, None).await; assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Maximum tool recursion depth (25)")); + assert!(result + .unwrap_err() + .to_string() + .contains("Maximum tool recursion depth (25)")); } #[tokio::test] @@ -266,13 +318,27 @@ async fn test_orchestrator_stream_channel() { } #[async_trait] impl AIProvider for StreamingProvider { - fn name(&self) -> &str { "Streaming" } - async fn list_models(&self) -> Result, anyhow::Error> { Ok(vec!["mock".into()]) } - async fn ask(&self, _msgs: Arc>, _model: &str, _tools: Arc>>, _thinking_level: Option<&str>) -> Result { + fn name(&self) -> &str { + "Streaming" + } + async fn list_models(&self) -> Result, anyhow::Error> { + Ok(vec!["mock".into()]) + } + async fn ask( + &self, + _msgs: Arc>, + _model: &str, + _tools: Arc>>, + _thinking_level: Option<&str>, + ) -> Result { self.call_count.fetch_add(1, Ordering::SeqCst); let chunks = vec![ - Ok(StreamChunk::Text { content: "Hello ".to_string() }), - Ok(StreamChunk::Text { content: "World!".to_string() }), + Ok(StreamChunk::Text { + content: "Hello ".to_string(), + }), + Ok(StreamChunk::Text { + content: "World!".to_string(), + }), Ok(StreamChunk::Done), ]; Ok(Box::pin(stream::iter(chunks))) @@ -280,13 +346,18 @@ async fn test_orchestrator_stream_channel() { } let call_count = Arc::new(AtomicUsize::new(0)); - let provider = Arc::new(StreamingProvider { call_count: call_count.clone() }); + let provider = Arc::new(StreamingProvider { + call_count: call_count.clone(), + }); let config = Arc::new(tokio::sync::Mutex::new(Config::default())); let orchestrator = AgentOrchestrator::new(provider, Arc::new(ToolRegistry::new()), config); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); let mut history = vec![Message::user("hi")]; - orchestrator.run(&mut history, "mock", Some(tx), None).await.unwrap(); + orchestrator + .run(&mut history, "mock", Some(tx), None) + .await + .unwrap(); let mut text_parts = Vec::new(); while let Some(chunk) = rx.recv().await { @@ -314,8 +385,12 @@ async fn test_orchestrator_handles_mid_stream_error_chunk() { struct MidStreamErrorProvider; #[async_trait] impl AIProvider for MidStreamErrorProvider { - fn name(&self) -> &str { "MidStreamError" } - async fn list_models(&self) -> Result, anyhow::Error> { Ok(vec!["mock".into()]) } + fn name(&self) -> &str { + "MidStreamError" + } + async fn list_models(&self) -> Result, anyhow::Error> { + Ok(vec!["mock".into()]) + } async fn ask( &self, _msgs: Arc>, @@ -324,8 +399,12 @@ async fn test_orchestrator_handles_mid_stream_error_chunk() { _thinking_level: Option<&str>, ) -> Result { let chunks = vec![ - Ok(StreamChunk::Text { content: "partial".to_string() }), - Ok(StreamChunk::Error { content: "rate-limited".to_string() }), + Ok(StreamChunk::Text { + content: "partial".to_string(), + }), + Ok(StreamChunk::Error { + content: "rate-limited".to_string(), + }), Ok(StreamChunk::Done), ]; Ok(Box::pin(stream::iter(chunks))) @@ -346,9 +425,16 @@ async fn test_orchestrator_handles_mid_stream_error_chunk() { // StreamChunk::Error and StreamChunk::Done to the UI so the consumer // can finalize cleanly without hanging. let result = orchestrator.run(&mut history, "mock", Some(tx), None).await; - assert!(result.is_err(), "orchestrator should propagate mid-stream error"); + assert!( + result.is_err(), + "orchestrator should propagate mid-stream error" + ); let err_msg = result.unwrap_err().to_string(); - assert!(err_msg.contains("rate-limited"), "error should carry provider content: {}", err_msg); + assert!( + err_msg.contains("rate-limited"), + "error should carry provider content: {}", + err_msg + ); // Drain the UI channel and assert the cleanup shape. let mut got_error = false; @@ -359,7 +445,11 @@ async fn test_orchestrator_handles_mid_stream_error_chunk() { StreamChunk::Text { content } => text_parts.push(content), StreamChunk::Error { content } => { got_error = true; - assert!(content.contains("rate-limited"), "UI error content: {}", content); + assert!( + content.contains("rate-limited"), + "UI error content: {}", + content + ); } StreamChunk::Done => { got_done = true; @@ -367,7 +457,10 @@ async fn test_orchestrator_handles_mid_stream_error_chunk() { _ => {} } } - assert!(got_error, "UI should receive StreamChunk::Error after abort"); + assert!( + got_error, + "UI should receive StreamChunk::Error after abort" + ); assert!(got_done, "UI should receive StreamChunk::Done after abort"); // The Text("partial") that arrived before the error may or may not be // flushed depending on buffering; the contract is just that the @@ -387,7 +480,10 @@ async fn test_message_serialization_roundtrip() { index: Some(0), id: "call_1".into(), r#type: "function".into(), - function: FunctionCall { name: "file_read".into(), arguments: r#"{"path":"."}"#.into() }, + function: FunctionCall { + name: "file_read".into(), + arguments: r#"{"path":"."}"#.into(), + }, }]), ), Message::tool("call_1".into(), "file_read".into(), "file content"), @@ -412,7 +508,10 @@ async fn test_config_serialization_roundtrip() { config.model = "gpt-4o-mini".into(); config.provider = "openai".into(); config.thinking_level = "deep".into(); - config.favorites.push(DynamicModelInfo { name: "claude-3-5-sonnet".into(), provider_id: "anthropic".into() }); + config.favorites.push(DynamicModelInfo { + name: "claude-3-5-sonnet".into(), + provider_id: "anthropic".into(), + }); let json = serde_json::to_string_pretty(&config).unwrap(); let deserialized: Config = serde_json::from_str(&json).unwrap();