From 4731b3e41d0626f245e61612b8841ef3d25fc6af Mon Sep 17 00:00:00 2001 From: wsp Date: Wed, 8 Apr 2026 20:53:35 +0800 Subject: [PATCH] chore: cargo fmt --- src/apps/cli/src/ui/startup.rs | 14 +- src/apps/desktop/src/api/browser_api.rs | 1 - src/apps/desktop/src/api/computer_use_api.rs | 10 +- src/apps/desktop/src/api/editor_ai_api.rs | 5 +- src/apps/desktop/src/api/insights_api.rs | 28 +- .../desktop/src/api/remote_connect_api.rs | 5 +- .../desktop/src/api/session_storage_path.rs | 4 +- src/apps/desktop/src/api/ssh_api.rs | 127 ++++-- .../desktop/src/computer_use/desktop_host.rs | 423 +++++++++--------- .../desktop/src/computer_use/linux_ax_ui.rs | 8 +- .../desktop/src/computer_use/macos_ax_ui.rs | 165 +++++-- src/apps/desktop/src/computer_use/mod.rs | 8 +- .../desktop/src/computer_use/windows_ax_ui.rs | 53 ++- src/apps/relay-server/src/relay/room.rs | 17 +- src/apps/relay-server/src/routes/websocket.rs | 9 +- src/crates/core/src/agentic/core/session.rs | 4 +- .../src/agentic/image_analysis/processor.rs | 1 - .../core/src/agentic/insights/collector.rs | 89 ++-- .../core/src/agentic/insights/facet_cache.rs | 14 +- src/crates/core/src/agentic/insights/html.rs | 157 +++++-- .../core/src/agentic/insights/service.rs | 260 ++++++----- .../src/agentic/tools/computer_use_host.rs | 23 +- .../agentic/tools/computer_use_optimizer.rs | 46 +- .../tools/computer_use_verification.rs | 27 +- .../implementations/computer_use_input.rs | 16 +- .../implementations/computer_use_locate.rs | 2 +- .../computer_use_mouse_click_tool.rs | 12 +- .../computer_use_mouse_precise_tool.rs | 12 +- .../computer_use_mouse_step_tool.rs | 12 +- .../implementations/computer_use_result.rs | 11 +- .../implementations/computer_use_tool.rs | 258 +++++++---- .../agentic/tools/implementations/git_tool.rs | 15 +- .../mermaid_interactive_tool.rs | 6 +- .../ai/providers/openai/message_converter.rs | 2 +- src/crates/core/src/infrastructure/mod.rs | 5 +- src/crates/core/src/miniapp/runtime_detect.rs | 4 +- .../core/src/service/git/git_service.rs | 5 +- src/crates/core/src/service/git/graph.rs | 9 +- src/crates/core/src/service/lsp/file_sync.rs | 5 +- .../core/src/service/lsp/project_detector.rs | 1 - src/crates/core/src/service/mcp/auth.rs | 30 +- .../src/service/mcp/config/cursor_format.rs | 5 +- .../src/service/mcp/config/json_config.rs | 3 +- .../service/mcp/protocol/transport_remote.rs | 30 +- .../src/service/mcp/server/manager/mod.rs | 4 +- .../remote_connect/bot/command_router.rs | 73 ++- .../src/service/remote_connect/bot/feishu.rs | 14 +- .../src/service/remote_connect/bot/mod.rs | 22 +- .../service/remote_connect/bot/telegram.rs | 8 +- .../src/service/remote_connect/bot/weixin.rs | 152 ++++--- .../service/remote_connect/remote_server.rs | 48 +- .../core/src/service/remote_ssh/manager.rs | 391 +++++++++++----- src/crates/core/src/service/remote_ssh/mod.rs | 8 +- .../src/service/remote_ssh/password_vault.rs | 22 +- .../core/src/service/remote_ssh/remote_fs.rs | 65 ++- .../src/service/remote_ssh/remote_terminal.rs | 62 ++- .../core/src/service/remote_ssh/types.rs | 4 +- .../src/service/remote_ssh/workspace_state.rs | 87 ++-- .../src/service/snapshot/isolation_manager.rs | 13 +- .../service/workspace/context_generator.rs | 9 +- .../core/tests/remote_mcp_streamable_http.rs | 15 +- .../webdriver/src/executor/element/actions.rs | 9 +- .../webdriver/src/executor/element/lookup.rs | 2 +- .../webdriver/src/executor/element/read.rs | 15 +- .../webdriver/src/executor/element/shadow.rs | 8 +- .../webdriver/src/executor/navigation.rs | 3 +- src/crates/webdriver/src/executor/session.rs | 13 +- src/crates/webdriver/src/executor/window.rs | 10 +- .../webdriver/src/platform/evaluator/mod.rs | 8 +- .../src/platform/evaluator/windows.rs | 26 +- src/crates/webdriver/src/platform/mod.rs | 2 +- .../src/runtime/script/pointer/mod.rs | 2 +- .../webdriver/src/server/handlers/alert.rs | 12 +- .../webdriver/src/server/handlers/mod.rs | 2 +- .../webdriver/src/server/handlers/shadow.rs | 4 +- src/crates/webdriver/src/server/response.rs | 7 +- src/crates/webdriver/src/server/router.rs | 5 +- 77 files changed, 1847 insertions(+), 1219 deletions(-) diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index a679d6ba..408ec19c 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -309,12 +309,14 @@ impl StartupPage { lines.push(Line::from("")); if use_fancy_logo { - let logo = [" ██████╗ ██╗████████╗███████╗██╗ ██╗███╗ ██╗", + let logo = [ + " ██████╗ ██╗████████╗███████╗██╗ ██╗███╗ ██╗", " ██╔══██╗██║╚══██╔══╝██╔════╝██║ ██║████╗ ██║", " ██████╔╝██║ ██║ █████╗ ██║ ██║██╔██╗ ██║", " ██╔══██╗██║ ██║ ██╔══╝ ██║ ██║██║╚██╗██║", " ██████╔╝██║ ██║ ██║ ╚██████╔╝██║ ╚████║", - " ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝"]; + " ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝", + ]; let colors = [ Color::Rgb(255, 0, 100), @@ -334,11 +336,13 @@ impl StartupPage { ))); } } else { - let logo = [" ____ _ _ _____ ", + let logo = [ + " ____ _ _ _____ ", " | __ )(_) |_| ___| _ _ __ ", " | _ \\| | __| |_ | | | | '_ \\ ", " | |_) | | |_| _|| |_| | | | |", - " |____/|_|\\__|_| \\__,_|_| |_|"]; + " |____/|_|\\__|_| \\__,_|_| |_|", + ]; let colors = [ Color::Cyan, @@ -883,8 +887,6 @@ impl StartupPage { PageState::Finished(StartupResult::Exit), ); - - match page_state { PageState::MainMenu => { self.page_state = PageState::MainMenu; diff --git a/src/apps/desktop/src/api/browser_api.rs b/src/apps/desktop/src/api/browser_api.rs index 93884c01..b0189e15 100644 --- a/src/apps/desktop/src/api/browser_api.rs +++ b/src/apps/desktop/src/api/browser_api.rs @@ -57,4 +57,3 @@ pub async fn browser_get_url( Err(_) => Err("url unavailable (webview URL is nil)".to_string()), } } - diff --git a/src/apps/desktop/src/api/computer_use_api.rs b/src/apps/desktop/src/api/computer_use_api.rs index 71def918..ae84c177 100644 --- a/src/apps/desktop/src/api/computer_use_api.rs +++ b/src/apps/desktop/src/api/computer_use_api.rs @@ -88,13 +88,11 @@ pub async fn computer_use_open_system_settings( #[cfg(target_os = "linux")] { let _ = request; - return Err("Open system settings: use your desktop environment privacy settings.".to_string()); + return Err( + "Open system settings: use your desktop environment privacy settings.".to_string(), + ); } - #[cfg(not(any( - target_os = "macos", - target_os = "windows", - target_os = "linux" - )))] + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { let _ = request; Err("Unsupported platform.".to_string()) diff --git a/src/apps/desktop/src/api/editor_ai_api.rs b/src/apps/desktop/src/api/editor_ai_api.rs index b9202378..60c5657e 100644 --- a/src/apps/desktop/src/api/editor_ai_api.rs +++ b/src/apps/desktop/src/api/editor_ai_api.rs @@ -72,7 +72,10 @@ pub async fn editor_ai_cancel( return Err("requestId is required".to_string()); } - state.side_question_runtime.cancel(&request.request_id).await; + state + .side_question_runtime + .cancel(&request.request_id) + .await; Ok(()) } diff --git a/src/apps/desktop/src/api/insights_api.rs b/src/apps/desktop/src/api/insights_api.rs index 6ddde659..76a07178 100644 --- a/src/apps/desktop/src/api/insights_api.rs +++ b/src/apps/desktop/src/api/insights_api.rs @@ -15,18 +15,14 @@ pub struct LoadInsightsReportRequest { } #[tauri::command] -pub async fn generate_insights( - request: GenerateInsightsRequest, -) -> Result { +pub async fn generate_insights(request: GenerateInsightsRequest) -> Result { let days = request.days.unwrap_or(30); info!("Generating insights for the last {} days", days); - InsightsService::generate(days) - .await - .map_err(|e| { - error!("Failed to generate insights: {}", e); - format!("Failed to generate insights: {}", e) - }) + InsightsService::generate(days).await.map_err(|e| { + error!("Failed to generate insights: {}", e); + format!("Failed to generate insights: {}", e) + }) } #[tauri::command] @@ -41,16 +37,16 @@ pub async fn get_latest_insights() -> Result, String> { pub async fn load_insights_report( request: LoadInsightsReportRequest, ) -> Result { - InsightsService::load_report(&request.path).await.map_err(|e| { - error!("Failed to load insights report: {}", e); - format!("Failed to load insights report: {}", e) - }) + InsightsService::load_report(&request.path) + .await + .map_err(|e| { + error!("Failed to load insights report: {}", e); + format!("Failed to load insights report: {}", e) + }) } #[tauri::command] -pub async fn has_insights_data( - request: GenerateInsightsRequest, -) -> Result { +pub async fn has_insights_data(request: GenerateInsightsRequest) -> Result { let days = request.days.unwrap_or(30); InsightsService::has_data(days).await.map_err(|e| { error!("Failed to check insights data: {}", e); diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index da834fc6..4634d11c 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -586,7 +586,10 @@ pub async fn remote_connect_get_bot_verbose_mode() -> Result { #[tauri::command] pub async fn remote_connect_set_bot_verbose_mode(verbose: bool) -> Result<(), String> { - log::info!("remote_connect_set_bot_verbose_mode called with verbose={}", verbose); + log::info!( + "remote_connect_set_bot_verbose_mode called with verbose={}", + verbose + ); let mut data = bot::load_bot_persistence(); data.verbose_mode = verbose; bot::save_bot_persistence(&data); diff --git a/src/apps/desktop/src/api/session_storage_path.rs b/src/apps/desktop/src/api/session_storage_path.rs index 18048842..9ff0a4a3 100644 --- a/src/apps/desktop/src/api/session_storage_path.rs +++ b/src/apps/desktop/src/api/session_storage_path.rs @@ -9,7 +9,9 @@ pub async fn desktop_effective_session_storage_path( remote_connection_id: Option<&str>, remote_ssh_host: Option<&str>, ) -> std::path::PathBuf { - let conn = remote_connection_id.map(str::trim).filter(|s| !s.is_empty()); + let conn = remote_connection_id + .map(str::trim) + .filter(|s| !s.is_empty()); let host_from_request = remote_ssh_host .map(str::trim) .filter(|s| !s.is_empty()) diff --git a/src/apps/desktop/src/api/ssh_api.rs b/src/apps/desktop/src/api/ssh_api.rs index edb80e45..e28b6244 100644 --- a/src/apps/desktop/src/api/ssh_api.rs +++ b/src/apps/desktop/src/api/ssh_api.rs @@ -4,12 +4,12 @@ use tauri::State; -use bitfun_core::service::remote_ssh::{ - SSHAuthMethod, SSHConnectionConfig, SSHConnectionResult, SavedConnection, RemoteTreeNode, - SSHConfigLookupResult, SSHConfigEntry, ServerInfo, -}; use crate::api::app_state::SSHServiceError; use crate::AppState; +use bitfun_core::service::remote_ssh::{ + RemoteTreeNode, SSHAuthMethod, SSHConfigEntry, SSHConfigLookupResult, SSHConnectionConfig, + SSHConnectionResult, SavedConnection, ServerInfo, +}; impl From for String { fn from(e: SSHServiceError) -> Self { @@ -25,9 +25,18 @@ pub async fn ssh_list_saved_connections( ) -> Result, String> { let manager = state.get_ssh_manager_async().await?; let connections = manager.get_saved_connections().await; - log::info!("ssh_list_saved_connections returning {} connections", connections.len()); + log::info!( + "ssh_list_saved_connections returning {} connections", + connections.len() + ); for conn in &connections { - log::info!(" - id={}, name={}, host={}:{}", conn.id, conn.name, conn.host, conn.port); + log::info!( + " - id={}, name={}, host={}:{}", + conn.id, + conn.name, + conn.host, + conn.port + ); } Ok(connections) } @@ -37,10 +46,17 @@ pub async fn ssh_save_connection( state: State<'_, AppState>, config: SSHConnectionConfig, ) -> Result<(), String> { - log::info!("ssh_save_connection called: id={}, host={}, port={}, username={}", - config.id, config.host, config.port, config.username); + log::info!( + "ssh_save_connection called: id={}, host={}, port={}, username={}", + config.id, + config.host, + config.port, + config.username + ); let manager = state.get_ssh_manager_async().await?; - manager.save_connection(&config).await + manager + .save_connection(&config) + .await .map_err(|e| e.to_string()) } @@ -50,7 +66,9 @@ pub async fn ssh_delete_connection( connection_id: String, ) -> Result<(), String> { let manager = state.get_ssh_manager_async().await?; - manager.delete_saved_connection(&connection_id).await + manager + .delete_saved_connection(&connection_id) + .await .map_err(|e| e.to_string()) } @@ -68,8 +86,13 @@ pub async fn ssh_connect( state: State<'_, AppState>, mut config: SSHConnectionConfig, ) -> Result { - log::info!("ssh_connect called: id={}, host={}, port={}, username={}", - config.id, config.host, config.port, config.username); + log::info!( + "ssh_connect called: id={}, host={}, port={}, username={}", + config.id, + config.host, + config.port, + config.username + ); let manager = match state.get_ssh_manager_async().await { Ok(m) => { @@ -90,7 +113,8 @@ pub async fn ssh_connect( } Ok(None) => { return Err( - "SSH password is required (no saved password for this connection)".to_string(), + "SSH password is required (no saved password for this connection)" + .to_string(), ); } Err(e) => return Err(e.to_string()), @@ -101,15 +125,17 @@ pub async fn ssh_connect( // First save the connection config so it persists across restarts log::info!("ssh_connect: about to save connection config"); if let Err(e) = manager.save_connection(&config).await { - log::warn!("ssh_connect: Failed to save connection config before connect: {}", e); + log::warn!( + "ssh_connect: Failed to save connection config before connect: {}", + e + ); // Continue anyway - connection might still work } else { log::info!("ssh_connect: Connection config saved successfully"); } log::info!("ssh_connect: about to establish connection"); - let result = manager.connect(config).await - .map_err(|e| e.to_string()); + let result = manager.connect(config).await.map_err(|e| e.to_string()); log::info!("ssh_connect result: {:?}", result); result } @@ -120,14 +146,14 @@ pub async fn ssh_disconnect( connection_id: String, ) -> Result<(), String> { let manager = state.get_ssh_manager_async().await?; - manager.disconnect(&connection_id).await + manager + .disconnect(&connection_id) + .await .map_err(|e| e.to_string()) } #[tauri::command] -pub async fn ssh_disconnect_all( - state: State<'_, AppState>, -) -> Result<(), String> { +pub async fn ssh_disconnect_all(state: State<'_, AppState>) -> Result<(), String> { let manager = state.get_ssh_manager_async().await?; manager.disconnect_all().await; Ok(()) @@ -140,7 +166,11 @@ pub async fn ssh_is_connected( ) -> Result { let manager = state.get_ssh_manager_async().await?; let is_connected = manager.is_connected(&connection_id).await; - log::info!("ssh_is_connected: connection_id={}, is_connected={}", connection_id, is_connected); + log::info!( + "ssh_is_connected: connection_id={}, is_connected={}", + connection_id, + is_connected + ); Ok(is_connected) } @@ -179,7 +209,9 @@ pub async fn remote_read_file( path: String, ) -> Result { let remote_fs = state.get_remote_file_service_async().await?; - let bytes = remote_fs.read_file(&connection_id, &path).await + let bytes = remote_fs + .read_file(&connection_id, &path) + .await .map_err(|e| e.to_string())?; String::from_utf8(bytes).map_err(|e| e.to_string()) } @@ -192,7 +224,9 @@ pub async fn remote_write_file( content: String, ) -> Result<(), String> { let remote_fs = state.get_remote_file_service_async().await?; - remote_fs.write_file(&connection_id, &path, content.as_bytes()).await + remote_fs + .write_file(&connection_id, &path, content.as_bytes()) + .await .map_err(|e| e.to_string()) } @@ -203,7 +237,9 @@ pub async fn remote_exists( path: String, ) -> Result { let remote_fs = state.get_remote_file_service_async().await?; - remote_fs.exists(&connection_id, &path).await + remote_fs + .exists(&connection_id, &path) + .await .map_err(|e| e.to_string()) } @@ -214,7 +250,9 @@ pub async fn remote_read_dir( path: String, ) -> Result, String> { let remote_fs = state.get_remote_file_service_async().await?; - remote_fs.read_dir(&connection_id, &path).await + remote_fs + .read_dir(&connection_id, &path) + .await .map_err(|e| e.to_string()) } @@ -226,7 +264,9 @@ pub async fn remote_get_tree( depth: Option, ) -> Result { let remote_fs = state.get_remote_file_service_async().await?; - remote_fs.build_tree(&connection_id, &path, depth).await + remote_fs + .build_tree(&connection_id, &path, depth) + .await .map_err(|e| e.to_string()) } @@ -282,7 +322,9 @@ pub async fn remote_rename( new_path: String, ) -> Result<(), String> { let remote_fs = state.get_remote_file_service_async().await?; - remote_fs.rename(&connection_id, &old_path, &new_path).await + remote_fs + .rename(&connection_id, &old_path, &new_path) + .await .map_err(|e| e.to_string()) } @@ -321,11 +363,10 @@ pub async fn remote_upload_from_local_path( local_path: String, remote_path: String, ) -> Result<(), String> { - let bytes = tokio::task::spawn_blocking(move || { - std::fs::read(&local_path).map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())??; + let bytes = + tokio::task::spawn_blocking(move || std::fs::read(&local_path).map_err(|e| e.to_string())) + .await + .map_err(|e| e.to_string())??; let remote_fs = state.get_remote_file_service_async().await?; remote_fs @@ -341,7 +382,9 @@ pub async fn remote_execute( command: String, ) -> Result<(String, String, i32), String> { let manager = state.get_ssh_manager_async().await?; - manager.execute_command(&connection_id, &command).await + manager + .execute_command(&connection_id, &command) + .await .map_err(|e| e.to_string()) } @@ -364,7 +407,9 @@ pub async fn remote_open_workspace( // Verify remote path exists let remote_fs = state.get_remote_file_service_async().await?; - let exists = remote_fs.exists(&connection_id, &remote_path).await + let exists = remote_fs + .exists(&connection_id, &remote_path) + .await .map_err(|e| e.to_string())?; if !exists { @@ -388,17 +433,21 @@ pub async fn remote_open_workspace( ssh_host, }; - state.set_remote_workspace(workspace).await + state + .set_remote_workspace(workspace) + .await .map_err(|e| e.to_string())?; - log::info!("Opened remote workspace: {} on connection {}", remote_path, connection_id); + log::info!( + "Opened remote workspace: {} on connection {}", + remote_path, + connection_id + ); Ok(()) } #[tauri::command] -pub async fn remote_close_workspace( - state: State<'_, AppState>, -) -> Result<(), String> { +pub async fn remote_close_workspace(state: State<'_, AppState>) -> Result<(), String> { state.clear_remote_workspace().await; log::info!("Closed remote workspace"); Ok(()) diff --git a/src/apps/desktop/src/computer_use/desktop_host.rs b/src/apps/desktop/src/computer_use/desktop_host.rs index 2313795f..ca44e291 100644 --- a/src/apps/desktop/src/computer_use/desktop_host.rs +++ b/src/apps/desktop/src/computer_use/desktop_host.rs @@ -11,15 +11,13 @@ use bitfun_core::agentic::tools::computer_use_host::{ COMPUTER_USE_POINT_CROP_HALF_DEFAULT, COMPUTER_USE_QUADRANT_CLICK_READY_MAX_LONG_EDGE, COMPUTER_USE_QUADRANT_EDGE_EXPAND_PX, }; -use bitfun_core::agentic::tools::computer_use_optimizer::ComputerUseOptimizer; #[cfg(any(target_os = "macos", target_os = "windows"))] use bitfun_core::agentic::tools::computer_use_host::{ ComputerUseForegroundApplication, ComputerUsePointerGlobal, }; +use bitfun_core::agentic::tools::computer_use_optimizer::ComputerUseOptimizer; use bitfun_core::util::errors::{BitFunError, BitFunResult}; -use enigo::{ - Axis, Button, Coordinate, Direction, Enigo, Key, Keyboard, Mouse, Settings, -}; +use enigo::{Axis, Button, Coordinate, Direction, Enigo, Key, Keyboard, Mouse, Settings}; use fontdue::{Font, FontSettings}; use image::codecs::jpeg::JpegEncoder; use image::{DynamicImage, Rgb, RgbImage}; @@ -63,16 +61,18 @@ static POINTER_PIXMAP_CACHE: OnceLock> = OnceLock::ne fn pointer_pixmap_cache() -> Option<&'static PointerPixmapCache> { POINTER_PIXMAP_CACHE - .get_or_init(|| match rasterize_pointer_svg(POINTER_OVERLAY_SVG, 0.3375) { - Ok(p) => Some(p), - Err(e) => { - warn!( - "computer_use: pointer SVG rasterize failed ({}); using fallback cross", - e - ); - None - } - }) + .get_or_init( + || match rasterize_pointer_svg(POINTER_OVERLAY_SVG, 0.3375) { + Ok(p) => Some(p), + Err(e) => { + warn!( + "computer_use: pointer SVG rasterize failed ({}); using fallback cross", + e + ); + None + } + }, + ) .as_ref() } @@ -228,7 +228,14 @@ fn coord_measure_str_width(text: &str, px: f32) -> i32 { } /// Left-to-right string on one baseline. -fn coord_draw_text_h(img: &mut RgbImage, mut baseline_x: i32, baseline_y: i32, text: &str, fg: Rgb, px: f32) { +fn coord_draw_text_h( + img: &mut RgbImage, + mut baseline_x: i32, + baseline_y: i32, + text: &str, + fg: Rgb, + px: f32, +) { let font = coord_axis_font(); for c in text.chars() { let (m, bmp) = font.rasterize(c, px); @@ -327,7 +334,10 @@ fn compose_computer_use_frame( (content, 0, 0) } -fn implicit_confirmation_should_apply(click_needs: bool, params: &ComputerUseScreenshotParams) -> bool { +fn implicit_confirmation_should_apply( + click_needs: bool, + params: &ComputerUseScreenshotParams, +) -> bool { // Applies on **every** bare `screenshot` while confirmation is required — including the // first capture in a session (`last_shot_refinement` may still be `None`), so click/Enter // guards get a ~500×500 around the mouse (or `text_caret` when requested) instead of full screen. @@ -338,9 +348,7 @@ fn implicit_confirmation_should_apply(click_needs: bool, params: &ComputerUseScr if !click_needs { return false; } - if params.crop_center.is_some() - || params.navigate_quadrant.is_some() - || params.reset_navigation + if params.crop_center.is_some() || params.navigate_quadrant.is_some() || params.reset_navigation { return false; } @@ -492,16 +500,8 @@ fn expand_navigation_rect_edges( ) -> ComputerUseNavigationRect { let x0 = r.x0.saturating_sub(pad); let y0 = r.y0.saturating_sub(pad); - let x1 = r - .x0 - .saturating_add(r.width) - .saturating_add(pad) - .min(max_w); - let y1 = r - .y0 - .saturating_add(r.height) - .saturating_add(pad) - .min(max_h); + let x1 = r.x0.saturating_add(r.width).saturating_add(pad).min(max_w); + let y1 = r.y0.saturating_add(r.height).saturating_add(pad).min(max_h); let width = x1.saturating_sub(x0).max(1); let height = y1.saturating_sub(y0).max(1); ComputerUseNavigationRect { @@ -588,7 +588,9 @@ impl MacPointerGeo { /// Map **continuous** framebuffer pixel center `(cx, cy)` (0.5 = middle of left/top pixel) to CG global. fn full_pixel_center_to_global_f64(&self, cx: f64, cy: f64) -> BitFunResult<(f64, f64)> { if self.disp_w <= 0.0 || self.disp_h <= 0.0 || self.full_px_w == 0 || self.full_px_h == 0 { - return Err(BitFunError::tool("Invalid macOS pointer geometry.".to_string())); + return Err(BitFunError::tool( + "Invalid macOS pointer geometry.".to_string(), + )); } let px_w = self.full_px_w as f64; let px_h = self.full_px_h as f64; @@ -602,7 +604,13 @@ impl MacPointerGeo { } /// `CGEventGetLocation` global mouse -> full-buffer pixel; then optional crop to view. - fn global_to_view_pixel(&self, mx: f64, my: f64, view_w: u32, view_h: u32) -> Option<(i32, i32)> { + fn global_to_view_pixel( + &self, + mx: f64, + my: f64, + view_w: u32, + view_h: u32, + ) -> Option<(i32, i32)> { if self.disp_w <= 0.0 || self.disp_h <= 0.0 || self.full_px_w == 0 || self.full_px_h == 0 { return None; } @@ -714,12 +722,8 @@ impl PointerMap { #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ComputerUseNavFocus { FullDisplay, - Quadrant { - rect: ComputerUseNavigationRect, - }, - PointCrop { - rect: ComputerUseNavigationRect, - }, + Quadrant { rect: ComputerUseNavigationRect }, + PointCrop { rect: ComputerUseNavigationRect }, } /// Unified mutable session state for computer use — one mutex instead of five. @@ -805,7 +809,8 @@ pub struct DesktopComputerUseHost { impl std::fmt::Debug for DesktopComputerUseHost { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("DesktopComputerUseHost").finish_non_exhaustive() + f.debug_struct("DesktopComputerUseHost") + .finish_non_exhaustive() } } @@ -842,11 +847,7 @@ impl DesktopComputerUseHost { { return Self::session_snapshot_linux(); } - #[cfg(not(any( - target_os = "macos", - target_os = "windows", - target_os = "linux" - )))] + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { ComputerUseSessionSnapshot::default() } @@ -854,7 +855,9 @@ impl DesktopComputerUseHost { #[cfg(target_os = "macos")] fn session_snapshot_macos() -> ComputerUseSessionSnapshot { - let pointer = macos::quartz_mouse_location().ok().map(|(x, y)| ComputerUsePointerGlobal { x, y }); + let pointer = macos::quartz_mouse_location() + .ok() + .map(|(x, y)| ComputerUsePointerGlobal { x, y }); let foreground = Self::macos_foreground_application(); ComputerUseSessionSnapshot { foreground_application: foreground, @@ -921,11 +924,7 @@ end tell"#]) String::new() }; Some(ComputerUseForegroundApplication { - name: if title.is_empty() { - None - } else { - Some(title) - }, + name: if title.is_empty() { None } else { Some(title) }, bundle_id: None, process_id: Some(pid as i32), }) @@ -998,8 +997,8 @@ end tell"#]) { Self::ensure_input_automation_allowed()?; let settings = Settings::default(); - let mut enigo = Enigo::new(&settings) - .map_err(|e| BitFunError::tool(format!("enigo init: {}", e)))?; + let mut enigo = + Enigo::new(&settings).map_err(|e| BitFunError::tool(format!("enigo init: {}", e)))?; f(&mut enigo) } @@ -1027,17 +1026,13 @@ end tell"#]) use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; use core_graphics::geometry::CGPoint; - let source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState).map_err(|_| { - BitFunError::tool("CGEventSource create failed (mouse_move)".to_string()) - })?; + let source = + CGEventSource::new(CGEventSourceStateID::CombinedSessionState).map_err(|_| { + BitFunError::tool("CGEventSource create failed (mouse_move)".to_string()) + })?; let pt = CGPoint { x, y }; - let ev = CGEvent::new_mouse_event( - source, - CGEventType::MouseMoved, - pt, - CGMouseButton::Left, - ) - .map_err(|_| BitFunError::tool("CGEvent MouseMoved failed".to_string()))?; + let ev = CGEvent::new_mouse_event(source, CGEventType::MouseMoved, pt, CGMouseButton::Left) + .map_err(|_| BitFunError::tool("CGEvent MouseMoved failed".to_string()))?; ev.post(CGEventTapLocation::HID); Ok(()) } @@ -1169,10 +1164,7 @@ end tell"#]) Key::Unicode(c) } _ => { - return Err(BitFunError::tool(format!( - "Unknown key name: {}", - name - ))); + return Err(BitFunError::tool(format!("Unknown key name: {}", name))); } }) } @@ -1180,9 +1172,13 @@ end tell"#]) fn encode_jpeg(rgb: &RgbImage, quality: u8) -> BitFunResult> { let mut buf = Vec::new(); let mut enc = JpegEncoder::new_with_quality(&mut buf, quality); - enc - .encode(rgb.as_raw(), rgb.width(), rgb.height(), image::ColorType::Rgb8) - .map_err(|e| BitFunError::tool(format!("JPEG encode: {}", e)))?; + enc.encode( + rgb.as_raw(), + rgb.width(), + rgb.height(), + image::ColorType::Rgb8, + ) + .map_err(|e| BitFunError::tool(format!("JPEG encode: {}", e)))?; Ok(buf) } @@ -1241,14 +1237,21 @@ end tell"#]) } /// Region to OCR: explicit `ocr_region_native`, else (macOS) frontmost window from AX, else full primary display. - fn ocr_resolve_region_for_capture(region_native: Option) -> BitFunResult { + fn ocr_resolve_region_for_capture( + region_native: Option, + ) -> BitFunResult { if let Some(r) = region_native { return Ok(r); } #[cfg(target_os = "macos")] { match crate::computer_use::macos_ax_ui::frontmost_window_bounds_global() { - Ok((x0, y0, w, h)) => Ok(OcrRegionNative { x0, y0, width: w, height: h }), + Ok((x0, y0, w, h)) => Ok(OcrRegionNative { + x0, + y0, + width: w, + height: h, + }), Err(e) => { warn!( "computer_use OCR: frontmost window bounds failed ({}); falling back to full primary display.", @@ -1292,9 +1295,9 @@ end tell"#]) let screen = Screen::from_point(cx, cy) .or_else(|_| Screen::from_point(0, 0)) .map_err(|e| BitFunError::tool(format!("Screen capture init (OCR raw): {}", e)))?; - let rgba = screen.capture().map_err(|e| { - BitFunError::tool(format!("Screenshot failed (OCR raw): {}", e)) - })?; + let rgba = screen + .capture() + .map_err(|e| BitFunError::tool(format!("Screenshot failed (OCR raw): {}", e)))?; let (full_px_w, full_px_h) = rgba.dimensions(); let d = screen.display_info; let disp_w = d.width as f64; @@ -1338,8 +1341,12 @@ end tell"#]) let crop_w = px1 - px0; let crop_h = py1 - py0; let cropped = Self::crop_rgb(&full_rgb, px0, py0, crop_w, crop_h)?; - let span_w = ((crop_w as f64 / full_px_w as f64) * disp_w).round().max(1.0) as u32; - let span_h = ((crop_h as f64 / full_px_h as f64) * disp_h).round().max(1.0) as u32; + let span_w = ((crop_w as f64 / full_px_w as f64) * disp_w) + .round() + .max(1.0) as u32; + let span_h = ((crop_h as f64 / full_px_h as f64) * disp_h) + .round() + .max(1.0) as u32; let origin_gx = (ox + (px0 as f64 / full_px_w as f64) * disp_w).round() as i32; let origin_gy = (oy + (py0 as f64 / full_px_h as f64) * disp_h).round() as i32; Self::raw_shot_from_rgb_crop(cropped, origin_gx, origin_gy, span_w, span_h) @@ -1403,11 +1410,7 @@ end tell"#]) screen: Screen, som_elements: Vec, implicit_confirmation_crop_applied: bool, - ) -> BitFunResult<( - ComputerScreenshot, - PointerMap, - Option, - )> { + ) -> BitFunResult<(ComputerScreenshot, PointerMap, Option)> { if params.crop_center.is_some() && params.navigate_quadrant.is_some() { return Err(BitFunError::tool( "Use either screenshot_crop_center_* or screenshot_navigate_quadrant, not both." @@ -1434,16 +1437,12 @@ end tell"#]) let focus = match focus_in { None => None, Some(ComputerUseNavFocus::FullDisplay) => Some(ComputerUseNavFocus::FullDisplay), - Some(ComputerUseNavFocus::Quadrant { rect }) => { - Some(ComputerUseNavFocus::Quadrant { - rect: intersect_navigation_rect(rect, full_rect).unwrap_or(full_rect), - }) - } - Some(ComputerUseNavFocus::PointCrop { rect }) => { - Some(ComputerUseNavFocus::PointCrop { - rect: intersect_navigation_rect(rect, full_rect).unwrap_or(full_rect), - }) - } + Some(ComputerUseNavFocus::Quadrant { rect }) => Some(ComputerUseNavFocus::Quadrant { + rect: intersect_navigation_rect(rect, full_rect).unwrap_or(full_rect), + }), + Some(ComputerUseNavFocus::PointCrop { rect }) => Some(ComputerUseNavFocus::PointCrop { + rect: intersect_navigation_rect(rect, full_rect).unwrap_or(full_rect), + }), }; let ( @@ -1492,9 +1491,8 @@ end tell"#]) } else if let Some(q) = params.navigate_quadrant { let base = match focus { None | Some(ComputerUseNavFocus::FullDisplay) => full_rect, - Some(ComputerUseNavFocus::Quadrant { rect }) | Some(ComputerUseNavFocus::PointCrop { rect }) => { - rect - } + Some(ComputerUseNavFocus::Quadrant { rect }) + | Some(ComputerUseNavFocus::PointCrop { rect }) => rect, }; let Some(base) = intersect_navigation_rect(base, full_rect) else { return Err(BitFunError::tool( @@ -1507,13 +1505,24 @@ end tell"#]) )); } let split = quadrant_split_rect(base, q); - let expanded = - expand_navigation_rect_edges(split, COMPUTER_USE_QUADRANT_EDGE_EXPAND_PX, native_w, native_h); + let expanded = expand_navigation_rect_edges( + split, + COMPUTER_USE_QUADRANT_EDGE_EXPAND_PX, + native_w, + native_h, + ); let Some(new_rect) = intersect_navigation_rect(expanded, full_rect) else { - return Err(BitFunError::tool("Quadrant crop out of bounds.".to_string())); + return Err(BitFunError::tool( + "Quadrant crop out of bounds.".to_string(), + )); }; - let cropped = - Self::crop_rgb(&full_frame, new_rect.x0, new_rect.y0, new_rect.width, new_rect.height)?; + let cropped = Self::crop_rgb( + &full_frame, + new_rect.x0, + new_rect.y0, + new_rect.width, + new_rect.height, + )?; let ox = origin_x + new_rect.x0 as i32; let oy = origin_y + new_rect.y0 as i32; let long_edge = new_rect.width.max(new_rect.height); @@ -1546,10 +1555,8 @@ end tell"#]) (full_rect, Some(ComputerUseNavFocus::FullDisplay)) } }; - let is_full = base.x0 == 0 - && base.y0 == 0 - && base.width == native_w - && base.height == native_h; + let is_full = + base.x0 == 0 && base.y0 == 0 && base.width == native_w && base.height == native_h; let ( content_rgb, map_origin_x, @@ -1562,14 +1569,7 @@ end tell"#]) ruler_origin_native_y, ) = if is_full { ( - full_frame, - origin_x, - origin_y, - native_w, - native_h, - native_w, - native_h, - 0u32, + full_frame, origin_x, origin_y, native_w, native_h, native_w, native_h, 0u32, 0u32, ) } else { @@ -1609,11 +1609,8 @@ end tell"#]) ) }; - let (mut frame, margin_l, margin_t) = compose_computer_use_frame( - content_rgb, - ruler_origin_native_x, - ruler_origin_native_y, - ); + let (mut frame, margin_l, margin_t) = + compose_computer_use_frame(content_rgb, ruler_origin_native_x, ruler_origin_native_y); let image_content_rect = ComputerUseImageContentRect { left: margin_l, top: margin_t, @@ -1684,44 +1681,36 @@ end tell"#]) #[cfg(target_os = "macos")] { let geo = macos_map_geo; - draw_som_labels( - &mut frame, - &som_elements, - margin_l, - margin_t, - |gx, gy| geo.global_to_view_pixel(gx, gy, content_w, content_h), - ); + draw_som_labels(&mut frame, &som_elements, margin_l, margin_t, |gx, gy| { + geo.global_to_view_pixel(gx, gy, content_w, content_h) + }); } #[cfg(not(target_os = "macos"))] { // On non-macOS: map global -> content pixel using the linear mapping - draw_som_labels( - &mut frame, - &som_elements, - margin_l, - margin_t, - |gx, gy| { - let ox = map_origin_x as f64; - let oy = map_origin_y as f64; - let nw = map_native_w as f64; - let nh = map_native_h as f64; - if nw <= 0.0 || nh <= 0.0 { return None; } - let rx = (gx - ox) / nw * content_w as f64; - let ry = (gy - oy) / nh * content_h as f64; - if rx < 0.0 || ry < 0.0 || rx >= content_w as f64 || ry >= content_h as f64 { - return None; - } - Some((rx.round() as i32, ry.round() as i32)) - }, - ); + draw_som_labels(&mut frame, &som_elements, margin_l, margin_t, |gx, gy| { + let ox = map_origin_x as f64; + let oy = map_origin_y as f64; + let nw = map_native_w as f64; + let nh = map_native_h as f64; + if nw <= 0.0 || nh <= 0.0 { + return None; + } + let rx = (gx - ox) / nw * content_w as f64; + let ry = (gy - oy) / nh * content_h as f64; + if rx < 0.0 || ry < 0.0 || rx >= content_w as f64 || ry >= content_h as f64 { + return None; + } + Some((rx.round() as i32, ry.round() as i32)) + }); } } let jpeg_bytes = Self::encode_jpeg(&frame, JPEG_QUALITY)?; - let point_crop_half_extent_native = params.crop_center.map(|_| { - clamp_point_crop_half_extent(params.point_crop_half_extent_native) - }); + let point_crop_half_extent_native = params + .crop_center + .map(|_| clamp_point_crop_half_extent(params.point_crop_half_extent_native)); let shot = ComputerScreenshot { bytes: jpeg_bytes, @@ -1816,11 +1805,7 @@ end tell"#]) }, } } - #[cfg(not(any( - target_os = "macos", - target_os = "windows", - target_os = "linux" - )))] + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { ComputerUsePermissionSnapshot { accessibility_granted: false, @@ -1914,12 +1899,8 @@ end tell"#]) } fn chord_includes_return_or_enter(keys: &[String]) -> bool { - keys.iter().any(|s| { - matches!( - s.to_lowercase().as_str(), - "return" | "enter" | "kp_enter" - ) - }) + keys.iter() + .any(|s| matches!(s.to_lowercase().as_str(), "return" | "enter" | "kp_enter")) } } @@ -2044,33 +2025,36 @@ impl ComputerUseHost for DesktopComputerUseHost { let click_needs_fresh = s.click_needs_fresh_screenshot; let pending_verify = s.pending_verify_screenshot; - let (click_ready, screenshot_kind, mut recommended_next_action) = match last_ref { - Some(ComputerUseScreenshotRefinement::RegionAroundPoint { .. }) => ( - !click_needs_fresh, - Some(ComputerUseInteractionScreenshotKind::RegionCrop), - None, - ), - Some(ComputerUseScreenshotRefinement::QuadrantNavigation { click_ready, .. }) if click_ready => ( - !click_needs_fresh, - Some(ComputerUseInteractionScreenshotKind::QuadrantTerminal), - None, - ), - Some(ComputerUseScreenshotRefinement::QuadrantNavigation { .. }) => ( - false, - Some(ComputerUseInteractionScreenshotKind::QuadrantDrill), - Some("screenshot_navigate_quadrant_until_click_ready".to_string()), - ), - Some(ComputerUseScreenshotRefinement::FullDisplay) => ( - !click_needs_fresh, - Some(ComputerUseInteractionScreenshotKind::FullDisplay), - if click_needs_fresh { - Some("screenshot".to_string()) - } else { - None - }, - ), - None => (false, None, Some("screenshot".to_string())), - }; + let (click_ready, screenshot_kind, mut recommended_next_action) = + match last_ref { + Some(ComputerUseScreenshotRefinement::RegionAroundPoint { .. }) => ( + !click_needs_fresh, + Some(ComputerUseInteractionScreenshotKind::RegionCrop), + None, + ), + Some(ComputerUseScreenshotRefinement::QuadrantNavigation { + click_ready, .. + }) if click_ready => ( + !click_needs_fresh, + Some(ComputerUseInteractionScreenshotKind::QuadrantTerminal), + None, + ), + Some(ComputerUseScreenshotRefinement::QuadrantNavigation { .. }) => ( + false, + Some(ComputerUseInteractionScreenshotKind::QuadrantDrill), + Some("screenshot_navigate_quadrant_until_click_ready".to_string()), + ), + Some(ComputerUseScreenshotRefinement::FullDisplay) => ( + !click_needs_fresh, + Some(ComputerUseInteractionScreenshotKind::FullDisplay), + if click_needs_fresh { + Some("screenshot".to_string()) + } else { + None + }, + ), + None => (false, None, Some("screenshot".to_string())), + }; if pending_verify && recommended_next_action.is_none() { recommended_next_action = Some("screenshot".to_string()); @@ -2202,9 +2186,9 @@ impl ComputerUseHost for DesktopComputerUseHost { let (shot, _map, _) = tokio::task::spawn_blocking(|| { let screen = Screen::from_point(0, 0) .map_err(|e| BitFunError::tool(format!("Screen capture init (peek): {}", e)))?; - let rgba = screen.capture().map_err(|e| { - BitFunError::tool(format!("Screenshot failed (peek): {}", e)) - })?; + let rgba = screen + .capture() + .map_err(|e| BitFunError::tool(format!("Screenshot failed (peek): {}", e)))?; Self::screenshot_sync_tool_with_capture( ComputerUseScreenshotParams::default(), None, @@ -2239,16 +2223,18 @@ impl ComputerUseHost for DesktopComputerUseHost { .map_err(|e| BitFunError::tool(e.to_string()))??; Ok(desktop_matches .into_iter() - .map(|m| bitfun_core::agentic::tools::computer_use_host::OcrTextMatch { - text: m.text, - confidence: m.confidence, - center_x: m.center_x, - center_y: m.center_y, - bounds_left: m.bounds_left, - bounds_top: m.bounds_top, - bounds_width: m.bounds_width, - bounds_height: m.bounds_height, - }) + .map( + |m| bitfun_core::agentic::tools::computer_use_host::OcrTextMatch { + text: m.text, + confidence: m.confidence, + center_x: m.center_x, + center_y: m.center_y, + bounds_left: m.bounds_left, + bounds_top: m.bounds_top, + bounds_width: m.bounds_width, + bounds_height: m.bounds_height, + }, + ) .collect()) } @@ -2280,11 +2266,7 @@ impl ComputerUseHost for DesktopComputerUseHost { let _ = (gx, gy); Ok(None) } - #[cfg(not(any( - target_os = "macos", - target_os = "windows", - target_os = "linux" - )))] + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { let _ = (gx, gy); Ok(None) @@ -2333,11 +2315,7 @@ impl ComputerUseHost for DesktopComputerUseHost { { return crate::computer_use::linux_ax_ui::locate_ui_element_center(query).await; } - #[cfg(not(any( - target_os = "macos", - target_os = "windows", - target_os = "linux" - )))] + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { Err(BitFunError::tool( "Native UI element (accessibility) lookup is not available on this platform." @@ -2476,9 +2454,8 @@ impl ComputerUseHost for DesktopComputerUseHost { let dpt_y = dy as f64 * geo.disp_h / px_h; let nx = (cx + dpt_x).round() as i32; let ny = (cy + dpt_y).round() as i32; - e.move_mouse(nx, ny, Coordinate::Abs).map_err(|err| { - BitFunError::tool(format!("pointer_move_relative: {}", err)) - }) + e.move_mouse(nx, ny, Coordinate::Abs) + .map_err(|err| BitFunError::tool(format!("pointer_move_relative: {}", err))) }) }) .await @@ -2491,9 +2468,8 @@ impl ComputerUseHost for DesktopComputerUseHost { { tokio::task::spawn_blocking(move || { Self::run_enigo_job(|e| { - e.move_mouse(dx, dy, Coordinate::Rel).map_err(|err| { - BitFunError::tool(format!("pointer_move_relative: {}", err)) - }) + e.move_mouse(dx, dy, Coordinate::Rel) + .map_err(|err| BitFunError::tool(format!("pointer_move_relative: {}", err))) }) }) .await @@ -2553,9 +2529,8 @@ impl ComputerUseHost for DesktopComputerUseHost { tokio::task::spawn_blocking(move || { Self::run_enigo_job(|e| { if delta_x != 0 { - e.scroll(delta_x, Axis::Horizontal).map_err(|err| { - BitFunError::tool(format!("scroll horizontal: {}", err)) - })?; + e.scroll(delta_x, Axis::Horizontal) + .map_err(|err| BitFunError::tool(format!("scroll horizontal: {}", err)))?; } if delta_y != 0 { e.scroll(delta_y, Axis::Vertical) @@ -2589,7 +2564,15 @@ impl ComputerUseHost for DesktopComputerUseHost { let chord_has_modifier = keys_for_job.iter().any(|s| { matches!( s.to_lowercase().as_str(), - "command" | "meta" | "super" | "win" | "control" | "ctrl" | "shift" | "alt" | "option" + "command" + | "meta" + | "super" + | "win" + | "control" + | "ctrl" + | "shift" + | "alt" + | "option" ) }); if mapped.len() == 1 { @@ -2710,8 +2693,7 @@ impl ComputerUseHost for DesktopComputerUseHost { match s.last_shot_refinement { Some(ComputerUseScreenshotRefinement::RegionAroundPoint { .. }) => {} Some(ComputerUseScreenshotRefinement::QuadrantNavigation { - click_ready: true, - .. + click_ready: true, .. }) => {} // Fresh full-screen JPEG matches the display — valid for image-space `mouse_move` then // guarded `click` as long as `click_needs_fresh_screenshot` is false above. @@ -2735,11 +2717,8 @@ impl ComputerUseHost for DesktopComputerUseHost { fn record_action(&self, action_type: &str, action_params: &str, success: bool) { if let Ok(mut s) = self.state.lock() { - s.optimizer.record_action( - action_type.to_string(), - action_params.to_string(), - success, - ); + s.optimizer + .record_action(action_type.to_string(), action_params.to_string(), success); } } diff --git a/src/apps/desktop/src/computer_use/linux_ax_ui.rs b/src/apps/desktop/src/computer_use/linux_ax_ui.rs index 400390f9..f23e6e27 100644 --- a/src/apps/desktop/src/computer_use/linux_ax_ui.rs +++ b/src/apps/desktop/src/computer_use/linux_ax_ui.rs @@ -4,10 +4,10 @@ use crate::computer_use::ui_locate_common; use atspi::connection::P2P; -use atspi::AccessibilityConnection; -use atspi::CoordType; use atspi::proxy::accessible::AccessibleProxy; use atspi::proxy::proxy_ext::ProxyExt; +use atspi::AccessibilityConnection; +use atspi::CoordType; use bitfun_core::agentic::tools::computer_use_host::{UiElementLocateQuery, UiElementLocateResult}; use bitfun_core::util::errors::{BitFunError, BitFunResult}; use std::collections::VecDeque; @@ -29,7 +29,9 @@ async fn role_match_string(acc: &AccessibleProxy<'_>) -> String { } /// Registry application roots → BFS until first match with non-empty screen extents. -pub async fn locate_ui_element_center(query: UiElementLocateQuery) -> BitFunResult { +pub async fn locate_ui_element_center( + query: UiElementLocateQuery, +) -> BitFunResult { ui_locate_common::validate_query(&query)?; let max_depth = query.max_depth.unwrap_or(48).clamp(1, 200); let max_nodes = 12_000usize; diff --git a/src/apps/desktop/src/computer_use/macos_ax_ui.rs b/src/apps/desktop/src/computer_use/macos_ax_ui.rs index bfef6dd9..37228051 100644 --- a/src/apps/desktop/src/computer_use/macos_ax_ui.rs +++ b/src/apps/desktop/src/computer_use/macos_ax_ui.rs @@ -120,7 +120,9 @@ unsafe fn ax_value_to_size(v: CFTypeRef) -> Option { Some(sz) } -unsafe fn read_role_title_id(elem: AXUIElementRef) -> (Option, Option, Option) { +unsafe fn read_role_title_id( + elem: AXUIElementRef, +) -> (Option, Option, Option) { let role = ax_copy_attr(elem, "AXRole").and_then(|v| { let s = cfstring_to_string(v); ax_release(v); @@ -193,7 +195,10 @@ impl CandidateMatch { // Off-screen penalty if !ui_locate_common::is_element_on_screen( - self.gx, self.gy, self.bounds_width, self.bounds_height, + self.gx, + self.gy, + self.bounds_width, + self.bounds_height, ) { score -= 5000; } @@ -214,7 +219,10 @@ impl CandidateMatch { // Bonus for elements in focused/active contexts if let Some(ref pd) = self.parent_desc { let pd_lower = pd.to_lowercase(); - if pd_lower.contains("sheet") || pd_lower.contains("dialog") || pd_lower.contains("popover") { + if pd_lower.contains("sheet") + || pd_lower.contains("dialog") + || pd_lower.contains("popover") + { score += 200; // Prefer elements in modal dialogs / sheets } } @@ -246,8 +254,13 @@ impl CandidateMatch { let parent_str = self.parent_desc.as_deref().unwrap_or("?"); format!( "role={} title={:?} at ({:.0},{:.0}) size={:.0}x{:.0} parent=[{}]", - self.role, title_str, self.gx, self.gy, - self.bounds_width, self.bounds_height, parent_str + self.role, + title_str, + self.gx, + self.gy, + self.bounds_width, + self.bounds_height, + parent_str ) } } @@ -276,31 +289,45 @@ const MAX_CANDIDATES: usize = 10; /// Search the **frontmost** app's accessibility tree (BFS) for elements matching filters. /// Collects all matches, filters invisible/off-screen ones, ranks by relevance, returns the best. -pub fn locate_ui_element_center(query: &UiElementLocateQuery) -> BitFunResult { +pub fn locate_ui_element_center( + query: &UiElementLocateQuery, +) -> BitFunResult { ui_locate_common::validate_query(query)?; let max_depth = query.max_depth.unwrap_or(48).clamp(1, 200); let pid = frontmost_pid()?; let root = unsafe { AXUIElementCreateApplication(pid) }; if root.is_null() { - return Err(BitFunError::tool("AXUIElementCreateApplication returned null.".to_string())); + return Err(BitFunError::tool( + "AXUIElementCreateApplication returned null.".to_string(), + )); } let mut bfs_queue = VecDeque::new(); - bfs_queue.push_back(Queued { ax: root, depth: 0, parent_desc: None }); + bfs_queue.push_back(Queued { + ax: root, + depth: 0, + parent_desc: None, + }); let mut visited = 0usize; let max_nodes = 12_000usize; let mut candidates: Vec = Vec::new(); while let Some(cur) = bfs_queue.pop_front() { if cur.depth > max_depth { - unsafe { ax_release(cur.ax as CFTypeRef); } + unsafe { + ax_release(cur.ax as CFTypeRef); + } continue; } visited += 1; if visited > max_nodes { - unsafe { ax_release(cur.ax as CFTypeRef); } + unsafe { + ax_release(cur.ax as CFTypeRef); + } // Drain remaining queue while let Some(c) = bfs_queue.pop_front() { - unsafe { ax_release(c.ax as CFTypeRef); } + unsafe { + ax_release(c.ax as CFTypeRef); + } } break; } @@ -315,8 +342,12 @@ pub fn locate_ui_element_center(query: &UiElementLocateQuery) -> BitFunResult BitFunResult= MAX_CANDIDATES { - unsafe { ax_release(cur.ax as CFTypeRef); } + unsafe { + ax_release(cur.ax as CFTypeRef); + } while let Some(c) = bfs_queue.pop_front() { - unsafe { ax_release(c.ax as CFTypeRef); } + unsafe { + ax_release(c.ax as CFTypeRef); + } } break; } @@ -340,7 +375,9 @@ pub fn locate_ui_element_center(query: &UiElementLocateQuery) -> BitFunResult BitFunResult::wrap_under_create_rule(ch as CFArrayRef); let n = arr.len(); for i in 0..n { - let Some(child_ref) = arr.get(i) else { continue; }; + let Some(child_ref) = arr.get(i) else { + continue; + }; let child = *child_ref; - if child.is_null() { continue; } + if child.is_null() { + continue; + } let retained = CFRetain(child as CFTypeRef) as AXUIElementRef; if !retained.is_null() { bfs_queue.push_back(Queued { @@ -393,15 +434,20 @@ pub fn locate_ui_element_center(query: &UiElementLocateQuery) -> BitFunResult = candidates.iter() + let other_matches: Vec = candidates + .iter() .skip(1) .take(4) .map(|c| c.short_description()) .collect(); ui_locate_common::ok_result_with_context( - best.gx, best.gy, - best.bounds_left, best.bounds_top, best.bounds_width, best.bounds_height, + best.gx, + best.gy, + best.bounds_left, + best.bounds_top, + best.bounds_width, + best.bounds_height, best.role.clone(), best.title.clone(), best.identifier.clone(), @@ -411,19 +457,36 @@ pub fn locate_ui_element_center(query: &UiElementLocateQuery) -> BitFunResult bool { - SOM_INTERACTIVE_ROLES.iter().any(|r| role.contains(r) || r.contains(role)) + SOM_INTERACTIVE_ROLES + .iter() + .any(|r| role.contains(r) || r.contains(role)) } /// Enumerate all visible interactive elements in the frontmost app's AX tree. @@ -452,14 +515,20 @@ pub fn enumerate_interactive_elements(max_elements: usize) -> Vec { while let Some(cur) = queue.pop_front() { if cur.depth > max_depth || results.len() >= max_elements { - unsafe { ax_release(cur.ax as CFTypeRef); } + unsafe { + ax_release(cur.ax as CFTypeRef); + } continue; } visited += 1; if visited > max_nodes { - unsafe { ax_release(cur.ax as CFTypeRef); } + unsafe { + ax_release(cur.ax as CFTypeRef); + } while let Some(c) = queue.pop_front() { - unsafe { ax_release(c.ax as CFTypeRef); } + unsafe { + ax_release(c.ax as CFTypeRef); + } } break; } @@ -490,9 +559,13 @@ pub fn enumerate_interactive_elements(max_elements: usize) -> Vec { bounds_height: bh, }); if results.len() >= max_elements { - unsafe { ax_release(cur.ax as CFTypeRef); } + unsafe { + ax_release(cur.ax as CFTypeRef); + } while let Some(c) = queue.pop_front() { - unsafe { ax_release(c.ax as CFTypeRef); } + unsafe { + ax_release(c.ax as CFTypeRef); + } } break; } @@ -505,19 +578,30 @@ pub fn enumerate_interactive_elements(max_elements: usize) -> Vec { // Enqueue children let children_ref = unsafe { ax_copy_attr(cur.ax, "AXChildren") }; let next_depth = cur.depth + 1; - unsafe { ax_release(cur.ax as CFTypeRef); } + unsafe { + ax_release(cur.ax as CFTypeRef); + } - let Some(ch) = children_ref else { continue; }; + let Some(ch) = children_ref else { + continue; + }; unsafe { let arr = CFArray::<*const c_void>::wrap_under_create_rule(ch as CFArrayRef); let n = arr.len(); for i in 0..n { - let Some(child_ref) = arr.get(i) else { continue; }; + let Some(child_ref) = arr.get(i) else { + continue; + }; let child = *child_ref; - if child.is_null() { continue; } + if child.is_null() { + continue; + } let retained = CFRetain(child as CFTypeRef) as AXUIElementRef; if !retained.is_null() { - queue.push_back(BfsItem { ax: retained, depth: next_depth }); + queue.push_back(BfsItem { + ax: retained, + depth: next_depth, + }); } } } @@ -591,7 +675,8 @@ pub fn frontmost_window_bounds_global() -> BitFunResult<(i32, i32, u32, u32)> { ax_release(app as CFTypeRef); let Some(win) = win else { return Err(BitFunError::tool( - "No AX window for foreground app (try AXFocusedWindow / AXMainWindow / AXWindows).".to_string(), + "No AX window for foreground app (try AXFocusedWindow / AXMainWindow / AXWindows)." + .to_string(), )); }; let frame = element_frame_global(win).ok_or_else(|| { diff --git a/src/apps/desktop/src/computer_use/mod.rs b/src/apps/desktop/src/computer_use/mod.rs index efe34a36..b70741e1 100644 --- a/src/apps/desktop/src/computer_use/mod.rs +++ b/src/apps/desktop/src/computer_use/mod.rs @@ -1,13 +1,13 @@ //! Desktop Computer use host (screenshots + enigo). mod desktop_host; -mod ui_locate_common; -mod screen_ocr; +#[cfg(target_os = "linux")] +mod linux_ax_ui; #[cfg(target_os = "macos")] mod macos_ax_ui; +mod screen_ocr; +mod ui_locate_common; #[cfg(target_os = "windows")] mod windows_ax_ui; -#[cfg(target_os = "linux")] -mod linux_ax_ui; pub use desktop_host::DesktopComputerUseHost; diff --git a/src/apps/desktop/src/computer_use/windows_ax_ui.rs b/src/apps/desktop/src/computer_use/windows_ax_ui.rs index fc44b326..53235d48 100644 --- a/src/apps/desktop/src/computer_use/windows_ax_ui.rs +++ b/src/apps/desktop/src/computer_use/windows_ax_ui.rs @@ -6,9 +6,13 @@ use bitfun_core::agentic::tools::computer_use_host::{ }; use bitfun_core::util::errors::{BitFunError, BitFunResult}; use std::collections::VecDeque; -use windows::Win32::System::Com::{CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED}; -use windows::Win32::UI::Accessibility::{CUIAutomation, IUIAutomation, IUIAutomationElement, IUIAutomationTreeWalker}; use windows::Win32::Foundation::POINT; +use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, +}; +use windows::Win32::UI::Accessibility::{ + CUIAutomation, IUIAutomation, IUIAutomationElement, IUIAutomationTreeWalker, +}; use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow; fn bstr_to_string(b: windows_core::BSTR) -> String { @@ -44,7 +48,9 @@ fn localized_control_type_string(elem: &IUIAutomationElement) -> String { } /// Foreground window root, then UIA RawViewWalker BFS. -pub fn locate_ui_element_center(query: &UiElementLocateQuery) -> BitFunResult { +pub fn locate_ui_element_center( + query: &UiElementLocateQuery, +) -> BitFunResult { ui_locate_common::validate_query(query)?; let max_depth = query.max_depth.unwrap_or(48).clamp(1, 200); let max_nodes = 12_000usize; @@ -71,10 +77,7 @@ pub fn locate_ui_element_center(query: &UiElementLocateQuery) -> BitFunResult BitFunResult max_nodes { return Err(BitFunError::tool( - "UI Automation search limit reached; narrow title/role/identifier filters.".to_string(), + "UI Automation search limit reached; narrow title/role/identifier filters." + .to_string(), )); } - let name = unsafe { cur.el.CurrentName().ok().map(bstr_to_string).unwrap_or_default() }; + let name = unsafe { + cur.el + .CurrentName() + .ok() + .map(bstr_to_string) + .unwrap_or_default() + }; let ident = unsafe { cur.el .CurrentAutomationId() @@ -163,14 +173,16 @@ pub fn locate_ui_element_center(query: &UiElementLocateQuery) -> BitFunResult BitFunResult> { +pub fn accessibility_hit_at_global_point( + gx: f64, + gy: f64, +) -> BitFunResult> { unsafe { let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); } let automation: IUIAutomation = unsafe { - CoCreateInstance(&CUIAutomation, None, CLSCTX_INPROC_SERVER).map_err(|e| { - BitFunError::tool(format!("UI Automation (CoCreateInstance): {}.", e)) - })? + CoCreateInstance(&CUIAutomation, None, CLSCTX_INPROC_SERVER) + .map_err(|e| BitFunError::tool(format!("UI Automation (CoCreateInstance): {}.", e)))? }; let pt = POINT { x: gx.round() as i32, @@ -181,7 +193,12 @@ pub fn accessibility_hit_at_global_point(gx: f64, gy: f64) -> BitFunResult e, Err(_) => return Ok(None), }; - let name = unsafe { elem.CurrentName().ok().map(bstr_to_string).unwrap_or_default() }; + let name = unsafe { + elem.CurrentName() + .ok() + .map(bstr_to_string) + .unwrap_or_default() + }; let ident = unsafe { elem.CurrentAutomationId() .ok() @@ -193,7 +210,13 @@ pub fn accessibility_hit_at_global_point(gx: f64, gy: f64) -> BitFunResult bool { if let Some(room_id) = self.conn_to_room.get(&conn_id) { if let Some(mut room) = self.rooms.get_mut(room_id.value()) { - let is_match = room - .desktop - .as_ref() - .is_some_and(|d| d.conn_id == conn_id); + let is_match = room.desktop.as_ref().is_some_and(|d| d.conn_id == conn_id); if is_match { let now = Utc::now().timestamp(); room.last_activity = now; @@ -243,9 +236,7 @@ impl RoomManager { } pub fn has_desktop(&self, room_id: &str) -> bool { - self.rooms - .get(room_id) - .is_some_and(|r| r.desktop.is_some()) + self.rooms.get(room_id).is_some_and(|r| r.desktop.is_some()) } pub fn room_count(&self) -> usize { diff --git a/src/apps/relay-server/src/routes/websocket.rs b/src/apps/relay-server/src/routes/websocket.rs index 0843160e..45751b70 100644 --- a/src/apps/relay-server/src/routes/websocket.rs +++ b/src/apps/relay-server/src/routes/websocket.rs @@ -1,4 +1,4 @@ -//! WebSocket handler for the relay server. +//! WebSocket handler for the relay server. //! //! Only desktop clients connect via WebSocket. Mobile clients use HTTP. //! The relay bridges HTTP requests to the desktop via WebSocket using @@ -82,10 +82,9 @@ async fn handle_socket(socket: WebSocket, state: AppState) { let write_task = tokio::spawn(async move { while let Some(msg) = out_rx.recv().await { - if !msg.text.is_empty() - && ws_sender.send(Message::Text(msg.text)).await.is_err() { - break; - } + if !msg.text.is_empty() && ws_sender.send(Message::Text(msg.text)).await.is_err() { + break; + } } }); diff --git a/src/crates/core/src/agentic/core/session.rs b/src/crates/core/src/agentic/core/session.rs index 7992e079..76bc0c30 100644 --- a/src/crates/core/src/agentic/core/session.rs +++ b/src/crates/core/src/agentic/core/session.rs @@ -56,8 +56,7 @@ pub struct Session { } /// Context compression state -#[derive(Debug, Clone, Serialize, Deserialize)] -#[derive(Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct CompressionState { /// Time of last compression pub last_compression_at: Option, @@ -65,7 +64,6 @@ pub struct CompressionState { pub compression_count: usize, } - impl CompressionState { pub fn increment_compression_count(&mut self) { self.last_compression_at = Some(SystemTime::now()); diff --git a/src/crates/core/src/agentic/image_analysis/processor.rs b/src/crates/core/src/agentic/image_analysis/processor.rs index fd8c8011..e72639a9 100644 --- a/src/crates/core/src/agentic/image_analysis/processor.rs +++ b/src/crates/core/src/agentic/image_analysis/processor.rs @@ -253,5 +253,4 @@ impl ImageAnalyzer { analysis_time_ms: 0, } } - } diff --git a/src/crates/core/src/agentic/insights/collector.rs b/src/crates/core/src/agentic/insights/collector.rs index 15ba1f3c..04bb3be9 100644 --- a/src/crates/core/src/agentic/insights/collector.rs +++ b/src/crates/core/src/agentic/insights/collector.rs @@ -67,26 +67,27 @@ impl InsightsCollector { .await .unwrap_or_default(); - let messages = - match Self::load_session_messages_with_turns( - &pm, ws_path, &summary.session_id, &turns, - ).await { - Ok(m) if !m.is_empty() => m, - Ok(_) => { - debug!( - "Skipping session {}: no messages found", - summary.session_id - ); - continue; - } - Err(e) => { - warn!( - "Skipping session {}: load messages failed: {}", - summary.session_id, e - ); - continue; - } - }; + let messages = match Self::load_session_messages_with_turns( + &pm, + ws_path, + &summary.session_id, + &turns, + ) + .await + { + Ok(m) if !m.is_empty() => m, + Ok(_) => { + debug!("Skipping session {}: no messages found", summary.session_id); + continue; + } + Err(e) => { + warn!( + "Skipping session {}: load messages failed: {}", + summary.session_id, e + ); + continue; + } + }; let mut transcript = Self::build_transcript(&summary.session_id, &session, &messages); @@ -115,8 +116,7 @@ impl InsightsCollector { if !base_stats.response_times_raw.is_empty() { base_stats.response_time_buckets = bucket_response_times(&base_stats.response_times_raw); - let (median, avg) = - compute_response_time_stats(&base_stats.response_times_raw); + let (median, avg) = compute_response_time_stats(&base_stats.response_times_raw); base_stats.median_response_time_secs = Some(median); base_stats.avg_response_time_secs = Some(avg); } @@ -139,9 +139,9 @@ impl InsightsCollector { session_id: &str, turns: &[DialogTurnData], ) -> BitFunResult> { - if let Ok(Some((_turn_index, messages))) = - pm.load_latest_turn_context_snapshot(workspace_path, session_id) - .await + if let Ok(Some((_turn_index, messages))) = pm + .load_latest_turn_context_snapshot(workspace_path, session_id) + .await { if !messages.is_empty() { return Ok(messages); @@ -272,10 +272,7 @@ impl InsightsCollector { .. } => { if *is_error { - *base_stats - .tool_errors - .entry(tool_name.clone()) - .or_insert(0) += 1; + *base_stats.tool_errors.entry(tool_name.clone()).or_insert(0) += 1; } } _ => {} @@ -342,13 +339,9 @@ impl InsightsCollector { *friction.entry(k.clone()).or_insert(0) += v; } if !facet.primary_success.is_empty() && facet.primary_success != "none" { - *success - .entry(facet.primary_success.clone()) - .or_insert(0) += 1; + *success.entry(facet.primary_success.clone()).or_insert(0) += 1; } - *session_types - .entry(facet.session_type.clone()) - .or_insert(0) += 1; + *session_types.entry(facet.session_type.clone()).or_insert(0) += 1; if !facet.brief_summary.is_empty() { session_summaries.push(facet.brief_summary.clone()); @@ -363,24 +356,23 @@ impl InsightsCollector { } } - let mut top_tools: Vec<(String, u32)> = base_stats.tool_usage.iter().map(|(k, v)| (k.clone(), *v)).collect(); + let mut top_tools: Vec<(String, u32)> = base_stats + .tool_usage + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect(); top_tools.sort_by(|a, b| b.1.cmp(&a.1)); top_tools.truncate(15); - let mut top_goals: Vec<(String, u32)> = goals.iter().map(|(k, v)| (k.clone(), *v)).collect(); + let mut top_goals: Vec<(String, u32)> = + goals.iter().map(|(k, v)| (k.clone(), *v)).collect(); top_goals.sort_by(|a, b| b.1.cmp(&a.1)); top_goals.truncate(10); let hours = base_stats.total_duration_minutes as f32 / 60.0; let date_range = DateRange { - start: base_stats - .first_session_at - .clone() - .unwrap_or_default(), - end: base_stats - .last_session_at - .clone() - .unwrap_or_default(), + start: base_stats.first_session_at.clone().unwrap_or_default(), + end: base_stats.last_session_at.clone().unwrap_or_default(), }; let days_covered = compute_days_covered(&date_range); @@ -469,8 +461,7 @@ fn rebuild_messages_from_turns(turns: &[DialogTurnData]) -> Vec { }; if !tool_calls.is_empty() { - let mut msg = - Message::assistant_with_tools(assistant_text.clone(), tool_calls); + let mut msg = Message::assistant_with_tools(assistant_text.clone(), tool_calls); msg.timestamp = round_ts; messages.push(msg); } else if !assistant_text.trim().is_empty() { @@ -629,7 +620,9 @@ fn compute_response_time_stats(raw: &[f64]) -> (f64, f64) { fn compute_days_covered(range: &DateRange) -> u32 { let parse = |s: &str| -> Option> { - DateTime::parse_from_rfc3339(s).ok().map(|d| d.with_timezone(&Utc)) + DateTime::parse_from_rfc3339(s) + .ok() + .map(|d| d.with_timezone(&Utc)) }; match (parse(&range.start), parse(&range.end)) { diff --git a/src/crates/core/src/agentic/insights/facet_cache.rs b/src/crates/core/src/agentic/insights/facet_cache.rs index cdbf6d32..b632d80e 100644 --- a/src/crates/core/src/agentic/insights/facet_cache.rs +++ b/src/crates/core/src/agentic/insights/facet_cache.rs @@ -34,10 +34,15 @@ fn cache_file_path(session_id: &str) -> BitFunResult { .chars() .map(|c| if "/\\:*?\"<>|".contains(c) { '_' } else { c }) .collect::(); - Ok(pm.user_data_dir().join(CACHE_SUBDIR).join(format!("{safe}.json"))) + Ok(pm + .user_data_dir() + .join(CACHE_SUBDIR) + .join(format!("{safe}.json"))) } -pub async fn try_load_cached_facet(transcript: &SessionTranscript) -> BitFunResult> { +pub async fn try_load_cached_facet( + transcript: &SessionTranscript, +) -> BitFunResult> { let path = match cache_file_path(&transcript.session_id) { Ok(p) => p, Err(_) => return Ok(None), @@ -57,7 +62,10 @@ pub async fn try_load_cached_facet(transcript: &SessionTranscript) -> BitFunResu Ok(Some(parsed.facet)) } -pub async fn save_cached_facet(transcript: &SessionTranscript, facet: &SessionFacet) -> BitFunResult<()> { +pub async fn save_cached_facet( + transcript: &SessionTranscript, + facet: &SessionFacet, +) -> BitFunResult<()> { let path = cache_file_path(&transcript.session_id)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent).await?; diff --git a/src/crates/core/src/agentic/insights/html.rs b/src/crates/core/src/agentic/insights/html.rs index 44896a54..998e92a5 100644 --- a/src/crates/core/src/agentic/insights/html.rs +++ b/src/crates/core/src/agentic/insights/html.rs @@ -139,7 +139,8 @@ impl HtmlLabels { pub fn zh() -> Self { HtmlLabels { title: "BitFun 洞察", - subtitle_template: "{msgs} 条消息,{sessions} 个会话({analyzed} 个已分析)| {start} 至 {end}", + subtitle_template: + "{msgs} 条消息,{sessions} 个会话({analyzed} 个已分析)| {start} 至 {end}", at_a_glance: "概览", whats_working: "做得好的:", whats_hindering: "遇到的阻碍:", @@ -204,12 +205,19 @@ impl HtmlLabels { pub fn generate_html(report: &InsightsReport, locale: &str) -> String { let l = HtmlLabels::for_locale(locale); - let subtitle = l.subtitle_template + let subtitle = l + .subtitle_template .replace("{msgs}", &report.total_messages.to_string()) .replace("{sessions}", &report.total_sessions.to_string()) .replace("{analyzed}", &report.analyzed_sessions.to_string()) - .replace("{start}", &report.date_range.start[..10.min(report.date_range.start.len())]) - .replace("{end}", &report.date_range.end[..10.min(report.date_range.end.len())]); + .replace( + "{start}", + &report.date_range.start[..10.min(report.date_range.start.len())], + ) + .replace( + "{end}", + &report.date_range.end[..10.min(report.date_range.end.len())], + ); let at_a_glance = render_at_a_glance(&report.at_a_glance, &l); let nav_toc = render_nav_toc(&l); @@ -392,8 +400,7 @@ fn render_stats_row(report: &InsightsReport, l: &HtmlLabels) -> String { _ => String::new(), }; - let code_stats = if report.stats.total_lines_added > 0 || report.stats.total_lines_removed > 0 - { + let code_stats = if report.stats.total_lines_added > 0 || report.stats.total_lines_removed > 0 { format!( r#"
+{}/-{}
{}
{}
{}
"#, @@ -452,7 +459,10 @@ fn format_number(n: usize) -> String { fn render_project_areas(areas: &[ProjectArea], l: &HtmlLabels) -> String { if areas.is_empty() { - return format!(r#"
{}
"#, html_escape(l.no_project_areas)); + return format!( + r#"
{}
"#, + html_escape(l.no_project_areas) + ); } let items: Vec = areas @@ -474,10 +484,7 @@ fn render_project_areas(areas: &[ProjectArea], l: &HtmlLabels) -> String { }) .collect(); - format!( - r#"
{}
"#, - items.join("\n") - ) + format!(r#"
{}
"#, items.join("\n")) } // ============ Charts split by section ============ @@ -486,12 +493,20 @@ fn render_basic_charts(stats: &InsightsStats, l: &HtmlLabels) -> String { let goals_chart = render_bar_chart(l.chart_goals, &stats.top_goals, "#2563eb", 6); let tools_chart = render_bar_chart(l.chart_tools, &stats.top_tools, "#0891b2", 6); - let mut lang_items: Vec<(String, u32)> = stats.languages.iter().map(|(k, v)| (k.clone(), *v)).collect(); + let mut lang_items: Vec<(String, u32)> = stats + .languages + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect(); lang_items.sort_by(|a, b| b.1.cmp(&a.1)); lang_items.truncate(6); let lang_chart = render_bar_chart(l.chart_languages, &lang_items, "#10b981", 6); - let mut type_items: Vec<(String, u32)> = stats.session_types.iter().map(|(k, v)| (k.clone(), *v)).collect(); + let mut type_items: Vec<(String, u32)> = stats + .session_types + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect(); type_items.sort_by(|a, b| b.1.cmp(&a.1)); type_items.truncate(6); let types_chart = render_bar_chart(l.chart_session_types, &type_items, "#8b5cf6", 6); @@ -505,20 +520,29 @@ fn render_usage_charts(stats: &InsightsStats, l: &HtmlLabels) -> String { let mut html = String::new(); if !stats.response_time_buckets.is_empty() { - let response_time_chart = render_response_time_chart(&stats.response_time_buckets, stats, l); + let response_time_chart = + render_response_time_chart(&stats.response_time_buckets, stats, l); html.push_str(&response_time_chart); } let time_of_day_chart = render_time_of_day_chart(&stats.hour_counts, l); - let mut tool_error_items: Vec<(String, u32)> = stats.tool_errors.iter().map(|(k, v)| (k.clone(), *v)).collect(); + let mut tool_error_items: Vec<(String, u32)> = stats + .tool_errors + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect(); tool_error_items.sort_by(|a, b| b.1.cmp(&a.1)); tool_error_items.truncate(6); let tool_errors_chart = render_bar_chart(l.chart_tool_errors, &tool_error_items, "#dc2626", 6); let mut agent_types_chart = String::new(); if !stats.agent_types.is_empty() { - let mut agent_type_items: Vec<(String, u32)> = stats.agent_types.iter().map(|(k, v)| (k.clone(), *v)).collect(); + let mut agent_type_items: Vec<(String, u32)> = stats + .agent_types + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect(); agent_type_items.sort_by(|a, b| b.1.cmp(&a.1)); agent_type_items.truncate(6); agent_types_chart = render_bar_chart(l.chart_agent_types, &agent_type_items, "#f97316", 6); @@ -540,12 +564,17 @@ fn render_outcome_charts(stats: &InsightsStats, l: &HtmlLabels) -> String { return String::new(); } - let mut success_items: Vec<(String, u32)> = stats.success.iter().map(|(k, v)| (k.clone(), *v)).collect(); + let mut success_items: Vec<(String, u32)> = + stats.success.iter().map(|(k, v)| (k.clone(), *v)).collect(); success_items.sort_by(|a, b| b.1.cmp(&a.1)); success_items.truncate(6); let success_chart = render_bar_chart(l.chart_what_helped, &success_items, "#16a34a", 6); - let mut outcome_items: Vec<(String, u32)> = stats.outcomes.iter().map(|(k, v)| (k.clone(), *v)).collect(); + let mut outcome_items: Vec<(String, u32)> = stats + .outcomes + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect(); outcome_items.sort_by(|a, b| b.1.cmp(&a.1)); outcome_items.truncate(6); let outcomes_chart = render_bar_chart(l.chart_outcomes, &outcome_items, "#8b5cf6", 6); @@ -561,15 +590,24 @@ fn render_friction_charts(stats: &InsightsStats, l: &HtmlLabels) -> String { return String::new(); } - let mut friction_items: Vec<(String, u32)> = stats.friction.iter().map(|(k, v)| (k.clone(), *v)).collect(); + let mut friction_items: Vec<(String, u32)> = stats + .friction + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect(); friction_items.sort_by(|a, b| b.1.cmp(&a.1)); friction_items.truncate(6); let friction_chart = render_bar_chart(l.chart_friction_types, &friction_items, "#dc2626", 6); - let mut satisfaction_items: Vec<(String, u32)> = stats.satisfaction.iter().map(|(k, v)| (k.clone(), *v)).collect(); + let mut satisfaction_items: Vec<(String, u32)> = stats + .satisfaction + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect(); satisfaction_items.sort_by(|a, b| b.1.cmp(&a.1)); satisfaction_items.truncate(6); - let satisfaction_chart = render_bar_chart(l.chart_satisfaction, &satisfaction_items, "#eab308", 6); + let satisfaction_chart = + render_bar_chart(l.chart_satisfaction, &satisfaction_items, "#eab308", 6); wrap_charts_row(&friction_chart, &satisfaction_chart) } @@ -581,20 +619,36 @@ fn render_friction_charts(stats: &InsightsStats, l: &HtmlLabels) -> String { fn wrap_charts_row(card_a: &str, card_b: &str) -> String { match (card_a.is_empty(), card_b.is_empty()) { (true, true) => String::new(), - (false, true) => format!(r#"
{}
"#, card_a), - (true, false) => format!(r#"
{}
"#, card_b), + (false, true) => format!( + r#"
{}
"#, + card_a + ), + (true, false) => format!( + r#"
{}
"#, + card_b + ), (false, false) => format!(r#"
{}{}
"#, card_a, card_b), } } // ============ Chart helpers ============ -fn render_response_time_chart(buckets: &std::collections::HashMap, stats: &InsightsStats, l: &HtmlLabels) -> String { +fn render_response_time_chart( + buckets: &std::collections::HashMap, + stats: &InsightsStats, + l: &HtmlLabels, +) -> String { let bucket_order = ["2-10s", "10-30s", "30s-1m", "1-2m", "2-5m", "5-15m", ">15m"]; let ordered_items: Vec<(String, u32)> = bucket_order .iter() .filter_map(|&label| { - buckets.get(label).and_then(|&v| if v > 0 { Some((label.to_string(), v)) } else { None }) + buckets.get(label).and_then(|&v| { + if v > 0 { + Some((label.to_string(), v)) + } else { + None + } + }) }) .collect(); @@ -611,25 +665,37 @@ fn render_response_time_chart(buckets: &std::collections::HashMap, ) }).collect(); - let footer = match (stats.median_response_time_secs, stats.avg_response_time_secs) { + let footer = match ( + stats.median_response_time_secs, + stats.avg_response_time_secs, + ) { (Some(median), Some(avg)) => format!( r#"
{}: {:.1}s • {}: {:.1}s
"#, - html_escape(l.median_label), median, html_escape(l.average_label), avg, + html_escape(l.median_label), + median, + html_escape(l.average_label), + avg, ), _ => String::new(), }; format!( r#"
{}
{}{}
"#, - html_escape(l.chart_response_time), bars, footer, + html_escape(l.chart_response_time), + bars, + footer, ) } -fn render_time_of_day_chart(hour_counts: &std::collections::HashMap, l: &HtmlLabels) -> String { +fn render_time_of_day_chart( + hour_counts: &std::collections::HashMap, + l: &HtmlLabels, +) -> String { if hour_counts.is_empty() { return format!( r#"
{}
{}
"#, - html_escape(l.chart_time_of_day), html_escape(l.no_data), + html_escape(l.chart_time_of_day), + html_escape(l.no_data), ); } @@ -709,7 +775,10 @@ fn render_bar_chart(title: &str, items: &[(String, u32)], color: &str, max_items fn render_interaction_style(style: &InteractionStyle, l: &HtmlLabels) -> String { if style.narrative.is_empty() && style.key_patterns.is_empty() { - return format!(r#"
{}
"#, html_escape(l.no_interaction_style)); + return format!( + r#"
{}
"#, + html_escape(l.no_interaction_style) + ); } let patterns_html = if style.key_patterns.is_empty() { @@ -959,10 +1028,7 @@ fn render_horizon(intro: &str, workflows: &[HorizonWorkflow], l: &HtmlLabels) -> let intro_html = if intro.is_empty() { String::new() } else { - format!( - r#"

{}

"#, - markdown_inline(intro) - ) + format!(r#"

{}

"#, markdown_inline(intro)) }; let items: Vec = workflows @@ -981,7 +1047,11 @@ fn render_horizon(intro: &str, workflows: &[HorizonWorkflow], l: &HtmlLabels) -> String::new() } else { let escaped = html_escape(&h.copyable_prompt); - let js_escaped = h.copyable_prompt.replace('\\', "\\\\").replace('\'', "\\'").replace('\n', "\\n"); + let js_escaped = h + .copyable_prompt + .replace('\\', "\\\\") + .replace('\'', "\\'") + .replace('\n', "\\n"); format!( r#"
{try_prompt}
@@ -1085,10 +1155,9 @@ fn find_closing_double_star(chars: &[char], start: usize) -> Option { let len = chars.len(); let mut i = start; while i + 1 < len { - if chars[i] == '*' && chars[i + 1] == '*' - && i > start { - return Some(i); - } + if chars[i] == '*' && chars[i + 1] == '*' && i > start { + return Some(i); + } i += 1; } None @@ -1098,11 +1167,9 @@ fn find_closing_single_star(chars: &[char], start: usize) -> Option { let len = chars.len(); let mut i = start; while i < len { - if chars[i] == '*' - && (i + 1 >= len || chars[i + 1] != '*') - && i > start { - return Some(i); - } + if chars[i] == '*' && (i + 1 >= len || chars[i + 1] != '*') && i > start { + return Some(i); + } i += 1; } None diff --git a/src/crates/core/src/agentic/insights/service.rs b/src/crates/core/src/agentic/insights/service.rs index 5e459b57..10535c39 100644 --- a/src/crates/core/src/agentic/insights/service.rs +++ b/src/crates/core/src/agentic/insights/service.rs @@ -40,12 +40,10 @@ pub struct InsightsService; impl InsightsService { async fn get_user_language() -> String { match get_global_config_service().await { - Ok(config_service) => { - match config_service.get_config::(Some("app")).await { - Ok(app_config) => app_config.language, - Err(_) => "en-US".to_string(), - } - } + Ok(config_service) => match config_service.get_config::(Some("app")).await { + Ok(app_config) => app_config.language, + Err(_) => "en-US".to_string(), + }, Err(_) => "en-US".to_string(), } } @@ -92,10 +90,7 @@ impl InsightsService { cancellation::cancel().await } - async fn generate_inner( - days: u32, - token: &CancellationToken, - ) -> BitFunResult { + async fn generate_inner(days: u32, token: &CancellationToken) -> BitFunResult { let user_lang = Self::get_user_language().await; let lang_instruction = Self::build_language_instruction(&user_lang); debug!("Insights generation using language: {}", user_lang); @@ -154,12 +149,8 @@ impl InsightsService { Self::emit_progress("Analyzing patterns...", "analysis", 0, 0).await; let (suggestions, areas, wins_friction, interaction, horizon, fun_ending) = - Self::generate_analysis_parallel( - &ai_client_primary, - &aggregate, - &lang_instruction, - ) - .await; + Self::generate_analysis_parallel(&ai_client_primary, &aggregate, &lang_instruction) + .await; Self::check_cancelled(token)?; @@ -259,8 +250,7 @@ impl InsightsService { ) .await; - let result = - Self::extract_single_facet(&client, &transcript, &lang).await; + let result = Self::extract_single_facet(&client, &transcript, &lang).await; if let Err(ref e) = result { if is_rate_limit_error(e) { @@ -329,18 +319,11 @@ impl InsightsService { ) .await; - match Self::extract_single_facet( - ai_client, - &transcripts[*idx], - lang_instruction, - ) - .await + match Self::extract_single_facet(ai_client, &transcripts[*idx], lang_instruction) + .await { Ok(facet) => facets.push(facet), - Err(e) => warn!( - "Sequential retry also failed for session {}: {}", - idx, e - ), + Err(e) => warn!("Sequential retry also failed for session {}: {}", idx, e), } if i + 1 < retry_count { @@ -390,12 +373,12 @@ impl InsightsService { let facet = SessionFacet { session_id: transcript.session_id.clone(), - underlying_goal: value["underlying_goal"] + underlying_goal: value["underlying_goal"].as_str().unwrap_or("").to_string(), + goal_categories: parse_string_u32_map(&value["goal_categories"]), + outcome: value["outcome"] .as_str() - .unwrap_or("") + .unwrap_or("unclear_from_transcript") .to_string(), - goal_categories: parse_string_u32_map(&value["goal_categories"]), - outcome: value["outcome"].as_str().unwrap_or("unclear_from_transcript").to_string(), user_satisfaction_counts: parse_string_u32_map(&value["user_satisfaction_counts"]), claude_helpfulness: value["claude_helpfulness"] .as_str() @@ -406,18 +389,9 @@ impl InsightsService { .unwrap_or("single_task") .to_string(), friction_counts: parse_string_u32_map(&value["friction_counts"]), - friction_detail: value["friction_detail"] - .as_str() - .unwrap_or("") - .to_string(), - primary_success: value["primary_success"] - .as_str() - .unwrap_or("") - .to_string(), - brief_summary: value["brief_summary"] - .as_str() - .unwrap_or("") - .to_string(), + friction_detail: value["friction_detail"].as_str().unwrap_or("").to_string(), + primary_success: value["primary_success"].as_str().unwrap_or("").to_string(), + brief_summary: value["brief_summary"].as_str().unwrap_or("").to_string(), languages_used: value["languages_used"] .as_array() .map(|arr| { @@ -447,7 +421,14 @@ impl InsightsService { ai_client: &Arc, aggregate: &InsightsAggregate, lang_instruction: &str, - ) -> (InsightsSuggestions, Vec, WinsFrictionResult, InteractionStyleResult, HorizonResult, Option) { + ) -> ( + InsightsSuggestions, + Vec, + WinsFrictionResult, + InteractionStyleResult, + HorizonResult, + Option, + ) { let aggregate_json = aggregate_stats_json_for_prompt(aggregate); let summaries_text = summaries_block(aggregate); let friction_text = friction_block(aggregate); @@ -494,7 +475,14 @@ impl InsightsService { let sem_3b = semaphore.clone(); let friction_handle = tokio::spawn(async move { let _permit = sem_3b.acquire().await.unwrap(); - Self::analyze_friction(&client_3b, &agg_json_3b, &summaries_3b, &friction_3b, &lang_3b).await + Self::analyze_friction( + &client_3b, + &agg_json_3b, + &summaries_3b, + &friction_3b, + &lang_3b, + ) + .await }); // Task 4: Interaction Style @@ -537,14 +525,16 @@ impl InsightsService { "Suggestions", || async { Self::generate_suggestions(ai_client, aggregate, lang_instruction).await }, default_suggestions, - ).await; + ) + .await; let areas = Self::resolve_with_retry( areas_handle, "Areas", || async { Self::identify_areas(ai_client, aggregate, lang_instruction).await }, Vec::new, - ).await; + ) + .await; let wins_result = Self::resolve_with_retry( wins_handle, @@ -555,10 +545,12 @@ impl InsightsService { &aggregate_stats_json_for_prompt(aggregate), &summaries_block(aggregate), lang_instruction, - ).await + ) + .await }, WinsResult::default, - ).await; + ) + .await; let friction_result = Self::resolve_with_retry( friction_handle, @@ -570,10 +562,12 @@ impl InsightsService { &summaries_block(aggregate), &friction_block(aggregate), lang_instruction, - ).await + ) + .await }, FrictionResult::default, - ).await; + ) + .await; let wins_friction = WinsFrictionResult { wins_intro: wins_result.intro, @@ -591,10 +585,12 @@ impl InsightsService { &aggregate_stats_json_for_prompt(aggregate), &summaries_block(aggregate), lang_instruction, - ).await + ) + .await }, InteractionStyleResult::default, - ).await; + ) + .await; let horizon = Self::resolve_with_retry( horizon_handle, @@ -606,10 +602,12 @@ impl InsightsService { &summaries_block(aggregate), &friction_block(aggregate), lang_instruction, - ).await + ) + .await }, HorizonResult::default, - ).await; + ) + .await; let fun_ending = Self::resolve_with_retry( fun_ending_handle, @@ -620,12 +618,21 @@ impl InsightsService { &aggregate_stats_json_for_prompt(aggregate), &summaries_block(aggregate), lang_instruction, - ).await + ) + .await }, || None, - ).await; + ) + .await; - (suggestions, areas, wins_friction, interaction, horizon, fun_ending) + ( + suggestions, + areas, + wins_friction, + interaction, + horizon, + fun_ending, + ) } /// Generic helper to resolve a spawned task with retry on transient failures. @@ -746,7 +753,11 @@ impl InsightsService { .await .map_err(|e| BitFunError::service(format!("Suggestions AI call failed: {}", e)))?; - info!("Suggestions response: len={}, finish={:?}", response.text.len(), response.finish_reason); + info!( + "Suggestions response: len={}, finish={:?}", + response.text.len(), + response.finish_reason + ); debug!("Suggestions text: {}", safe_truncate(&response.text, 300)); let json_str = extract_json_from_response(&response.text)?; @@ -760,9 +771,18 @@ impl InsightsService { debug!( "Suggestions parsed: md_additions={}, features={}, patterns={}", - value["bitfun_md_additions"].as_array().map(|a| a.len()).unwrap_or(0), - value["features_to_try"].as_array().map(|a| a.len()).unwrap_or(0), - value["usage_patterns"].as_array().map(|a| a.len()).unwrap_or(0), + value["bitfun_md_additions"] + .as_array() + .map(|a| a.len()) + .unwrap_or(0), + value["features_to_try"] + .as_array() + .map(|a| a.len()) + .unwrap_or(0), + value["usage_patterns"] + .as_array() + .map(|a| a.len()) + .unwrap_or(0), ); Ok(InsightsSuggestions { @@ -861,7 +881,11 @@ impl InsightsService { .await .map_err(|e| BitFunError::service(format!("Areas AI call failed: {}", e)))?; - info!("Areas response: len={}, finish={:?}", response.text.len(), response.finish_reason); + info!( + "Areas response: len={}, finish={:?}", + response.text.len(), + response.finish_reason + ); debug!("Areas text: {}", safe_truncate(&response.text, 300)); let json_str = extract_json_from_response(&response.text)?; @@ -905,7 +929,11 @@ impl InsightsService { .await .map_err(|e| BitFunError::service(format!("Wins AI call failed: {}", e)))?; - info!("Wins response: len={}, finish={:?}", response.text.len(), response.finish_reason); + info!( + "Wins response: len={}, finish={:?}", + response.text.len(), + response.finish_reason + ); debug!("Wins text: {}", safe_truncate(&response.text, 300)); let json_str = extract_json_from_response(&response.text)?; @@ -954,7 +982,11 @@ impl InsightsService { .await .map_err(|e| BitFunError::service(format!("Friction AI call failed: {}", e)))?; - info!("Friction response: len={}, finish={:?}", response.text.len(), response.finish_reason); + info!( + "Friction response: len={}, finish={:?}", + response.text.len(), + response.finish_reason + ); debug!("Friction text: {}", safe_truncate(&response.text, 300)); let json_str = extract_json_from_response(&response.text)?; @@ -1005,13 +1037,19 @@ impl InsightsService { ); let messages = vec![Message::user(prompt)]; - let response = ai_client - .send_message(messages, None) - .await - .map_err(|e| BitFunError::service(format!("Interaction Style AI call failed: {}", e)))?; + let response = ai_client.send_message(messages, None).await.map_err(|e| { + BitFunError::service(format!("Interaction Style AI call failed: {}", e)) + })?; - info!("Interaction Style response: len={}, finish={:?}", response.text.len(), response.finish_reason); - debug!("Interaction Style text: {}", safe_truncate(&response.text, 300)); + info!( + "Interaction Style response: len={}, finish={:?}", + response.text.len(), + response.finish_reason + ); + debug!( + "Interaction Style text: {}", + safe_truncate(&response.text, 300) + ); let json_str = extract_json_from_response(&response.text)?; let value: Value = serde_json::from_str(&json_str).map_err(|e| { @@ -1057,7 +1095,11 @@ impl InsightsService { .await .map_err(|e| BitFunError::service(format!("At a Glance AI call failed: {}", e)))?; - info!("At a Glance response: len={}, finish={:?}", response.text.len(), response.finish_reason); + info!( + "At a Glance response: len={}, finish={:?}", + response.text.len(), + response.finish_reason + ); debug!("At a Glance text: {}", safe_truncate(&response.text, 300)); let json_str = extract_json_from_response(&response.text)?; @@ -1104,7 +1146,11 @@ impl InsightsService { .await .map_err(|e| BitFunError::service(format!("Horizon AI call failed: {}", e)))?; - info!("Horizon response: len={}, finish={:?}", response.text.len(), response.finish_reason); + info!( + "Horizon response: len={}, finish={:?}", + response.text.len(), + response.finish_reason + ); debug!("Horizon text: {}", safe_truncate(&response.text, 300)); let json_str = extract_json_from_response(&response.text)?; @@ -1123,7 +1169,10 @@ impl InsightsService { title: v["title"].as_str()?.to_string(), whats_possible: v["whats_possible"].as_str()?.to_string(), how_to_try: v["how_to_try"].as_str().unwrap_or("").to_string(), - copyable_prompt: v["copyable_prompt"].as_str().unwrap_or("").to_string(), + copyable_prompt: v["copyable_prompt"] + .as_str() + .unwrap_or("") + .to_string(), }) }) .collect() @@ -1152,7 +1201,11 @@ impl InsightsService { .await .map_err(|e| BitFunError::service(format!("Fun Ending AI call failed: {}", e)))?; - info!("Fun Ending response: len={}, finish={:?}", response.text.len(), response.finish_reason); + info!( + "Fun Ending response: len={}, finish={:?}", + response.text.len(), + response.finish_reason + ); debug!("Fun Ending text: {}", safe_truncate(&response.text, 300)); let json_str = extract_json_from_response(&response.text)?; @@ -1188,27 +1241,26 @@ impl InsightsService { horizon: HorizonResult, fun_ending: Option, ) -> InsightsReport { - let days_covered = if !aggregate.date_range.start.is_empty() - && !aggregate.date_range.end.is_empty() - { - let parse = |s: &str| -> Option> { - chrono::DateTime::parse_from_rfc3339(s) - .ok() - .map(|d| d.with_timezone(&chrono::Utc)) - }; - match ( - parse(&aggregate.date_range.start), - parse(&aggregate.date_range.end), - ) { - (Some(start), Some(end)) => { - end.signed_duration_since(start).num_days().unsigned_abs() as u32 + let days_covered = + if !aggregate.date_range.start.is_empty() && !aggregate.date_range.end.is_empty() { + let parse = |s: &str| -> Option> { + chrono::DateTime::parse_from_rfc3339(s) + .ok() + .map(|d| d.with_timezone(&chrono::Utc)) + }; + match ( + parse(&aggregate.date_range.start), + parse(&aggregate.date_range.end), + ) { + (Some(start), Some(end)) => { + end.signed_duration_since(start).num_days().unsigned_abs() as u32 + } + _ => 1, } - _ => 1, - } - .max(1) - } else { - 1 - }; + .max(1) + } else { + 1 + }; InsightsReport { generated_at: SystemTime::now() @@ -1279,8 +1331,9 @@ impl InsightsService { report.html_report_path = Some(html_path.to_string_lossy().to_string()); let json_path = usage_dir.join(format!("insights-{}.json", timestamp)); - let json_str = serde_json::to_string_pretty(&report) - .map_err(|e| BitFunError::serialization(format!("Failed to serialize report: {}", e)))?; + let json_str = serde_json::to_string_pretty(&report).map_err(|e| { + BitFunError::serialization(format!("Failed to serialize report: {}", e)) + })?; tokio::fs::write(&json_path, &json_str) .await .map_err(|e| BitFunError::io(format!("Failed to write report JSON: {}", e)))?; @@ -1380,10 +1433,8 @@ impl InsightsService { .take(3) .map(|(name, _)| name.clone()) .collect(); - let mut lang_entries: Vec<_> = - report.stats.languages.iter().collect(); - lang_entries - .sort_by(|(_, a), (_, b)| b.cmp(a)); + let mut lang_entries: Vec<_> = report.stats.languages.iter().collect(); + lang_entries.sort_by(|(_, a), (_, b)| b.cmp(a)); let languages: Vec = lang_entries .iter() .take(3) @@ -1574,9 +1625,8 @@ fn safe_truncate(s: &str, max_bytes: usize) -> &str { } fn extract_json_from_response(response: &str) -> BitFunResult { - crate::util::extract_json_from_ai_response(response).ok_or_else(|| { - BitFunError::service("Cannot extract JSON from AI response") - }) + crate::util::extract_json_from_ai_response(response) + .ok_or_else(|| BitFunError::service("Cannot extract JSON from AI response")) } /// Extract a string from a JSON value that may be a plain string or a nested object. diff --git a/src/crates/core/src/agentic/tools/computer_use_host.rs b/src/crates/core/src/agentic/tools/computer_use_host.rs index 27f546d8..e447c98d 100644 --- a/src/crates/core/src/agentic/tools/computer_use_host.rs +++ b/src/crates/core/src/agentic/tools/computer_use_host.rs @@ -74,16 +74,17 @@ pub const COMPUTER_USE_POINT_CROP_HALF_MAX: u32 = 250; #[inline] pub fn clamp_point_crop_half_extent(requested: Option) -> u32 { let v = requested.unwrap_or(COMPUTER_USE_POINT_CROP_HALF_DEFAULT); - v.clamp(COMPUTER_USE_POINT_CROP_HALF_MIN, COMPUTER_USE_POINT_CROP_HALF_MAX) + v.clamp( + COMPUTER_USE_POINT_CROP_HALF_MIN, + COMPUTER_USE_POINT_CROP_HALF_MAX, + ) } /// Suggest a tighter half-extent from AX **native** bounds size (smaller controls → smaller JPEG). #[inline] pub fn suggested_point_crop_half_extent_from_native_bounds(native_w: u32, native_h: u32) -> u32 { let max_edge = native_w.max(native_h).max(1); - let half = max_edge - .saturating_div(2) - .saturating_add(32); + let half = max_edge.saturating_div(2).saturating_add(32); clamp_point_crop_half_extent(Some(half)) } @@ -382,13 +383,17 @@ pub trait ComputerUseHost: Send + Sync + std::fmt::Debug { /// Press a mouse button and hold it at the current pointer position. /// `button`: "left" | "right" | "middle" async fn mouse_down(&self, _button: &str) -> BitFunResult<()> { - Err(BitFunError::tool("mouse_down is not supported on this host.".to_string())) + Err(BitFunError::tool( + "mouse_down is not supported on this host.".to_string(), + )) } /// Release a mouse button at the current pointer position. /// `button`: "left" | "right" | "middle" async fn mouse_up(&self, _button: &str) -> BitFunResult<()> { - Err(BitFunError::tool("mouse_up is not supported on this host.".to_string())) + Err(BitFunError::tool( + "mouse_up is not supported on this host.".to_string(), + )) } async fn scroll(&self, delta_x: i32, delta_y: i32) -> BitFunResult<()>; @@ -523,12 +528,14 @@ pub struct SomElement { pub bounds_height: f64, } - /// Whether the latest screenshot JPEG was the full display, a point crop, or a quadrant-drill region. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ComputerUseScreenshotRefinement { FullDisplay, - RegionAroundPoint { center_x: u32, center_y: u32 }, + RegionAroundPoint { + center_x: u32, + center_y: u32, + }, /// Partial-screen view from hierarchical quadrant navigation. QuadrantNavigation { x0: u32, diff --git a/src/crates/core/src/agentic/tools/computer_use_optimizer.rs b/src/crates/core/src/agentic/tools/computer_use_optimizer.rs index 7716ccfc..ad128a97 100644 --- a/src/crates/core/src/agentic/tools/computer_use_optimizer.rs +++ b/src/crates/core/src/agentic/tools/computer_use_optimizer.rs @@ -48,12 +48,7 @@ impl ComputerUseOptimizer { } /// Record an action in history - pub fn record_action( - &mut self, - action_type: String, - action_params: String, - success: bool, - ) { + pub fn record_action(&mut self, action_type: String, action_params: String, success: bool) { let timestamp_ms = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64) @@ -157,12 +152,21 @@ impl ComputerUseOptimizer { } fn check_pattern_repetition(&self, pattern_len: usize) -> Option { - let recent: Vec<_> = self.action_history.iter().rev().take(LOOP_DETECTION_WINDOW).collect(); + let recent: Vec<_> = self + .action_history + .iter() + .rev() + .take(LOOP_DETECTION_WINDOW) + .collect(); if recent.len() < pattern_len * MAX_LOOP_REPETITIONS { return None; } - let pattern: Vec<_> = recent.iter().take(pattern_len).map(|r| &r.action_type).collect(); + let pattern: Vec<_> = recent + .iter() + .take(pattern_len) + .map(|r| &r.action_type) + .collect(); let mut reps = 1; for chunk in recent.chunks(pattern_len).skip(1) { @@ -200,7 +204,10 @@ impl ComputerUseOptimizer { // Check if last 6 actions had same screenshot hash (no visual change) if let Some(first_hash) = recent[0].screenshot_hash { - recent.iter().skip(1).all(|r| r.screenshot_hash == Some(first_hash)) + recent + .iter() + .skip(1) + .all(|r| r.screenshot_hash == Some(first_hash)) } else { false } @@ -214,13 +221,14 @@ impl ComputerUseOptimizer { } let mouse_actions = ["click", "mouse_move", "scroll", "drag", "pointer_move_rel"]; - let has_keyboard = recent.iter().any(|r| - r.action_type == "key_chord" || r.action_type == "type_text" - ); - - let mouse_count = recent.iter().filter(|r| - mouse_actions.contains(&r.action_type.as_str()) - ).count(); + let has_keyboard = recent + .iter() + .any(|r| r.action_type == "key_chord" || r.action_type == "type_text"); + + let mouse_count = recent + .iter() + .filter(|r| mouse_actions.contains(&r.action_type.as_str())) + .count(); // If 8+ of last 10 actions are mouse and no keyboard usage !has_keyboard && mouse_count >= 8 @@ -300,7 +308,11 @@ impl ComputerUseOptimizer { } // Many screenshots + mouse moves, but no clicks/keyboard/move_to_text - screenshot_count >= 3 && mouse_move_count >= 2 && !has_click && !has_keyboard && !has_move_to_text + screenshot_count >= 3 + && mouse_move_count >= 2 + && !has_click + && !has_keyboard + && !has_move_to_text } /// Get action history for backtracking diff --git a/src/crates/core/src/agentic/tools/computer_use_verification.rs b/src/crates/core/src/agentic/tools/computer_use_verification.rs index 5e9d31d0..d2618e9e 100644 --- a/src/crates/core/src/agentic/tools/computer_use_verification.rs +++ b/src/crates/core/src/agentic/tools/computer_use_verification.rs @@ -45,10 +45,10 @@ impl RetryStrategy { /// Compare two screenshot hashes to detect visual changes pub fn detect_visual_change(hash_before: u64, hash_after: u64) -> VerificationResult { let changed = hash_before != hash_after; - + // Simple change detection based on hash difference let change_pct = if changed { 100.0 } else { 0.0 }; - + VerificationResult { verified: changed, visual_change_detected: changed, @@ -64,24 +64,29 @@ pub fn detect_visual_change(hash_before: u64, hash_after: u64) -> VerificationRe /// Determine if an action should be retried based on error type pub fn should_retry_action(error: &BitFunError, action_type: &str) -> bool { let error_msg = error.to_string().to_lowercase(); - + // Retry on transient errors - if error_msg.contains("timeout") - || error_msg.contains("not found") + if error_msg.contains("timeout") + || error_msg.contains("not found") || error_msg.contains("element moved") - || error_msg.contains("stale") { + || error_msg.contains("stale") + { return true; } - + // Don't retry on permission or configuration errors - if error_msg.contains("permission") + if error_msg.contains("permission") || error_msg.contains("not enabled") - || error_msg.contains("not available") { + || error_msg.contains("not available") + { return false; } - + // Retry click/locate actions by default - matches!(action_type, "click" | "click_element" | "click_label" | "locate") + matches!( + action_type, + "click" | "click_element" | "click_label" | "locate" + ) } /// Generate retry suggestion based on failure context diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_input.rs b/src/crates/core/src/agentic/tools/implementations/computer_use_input.rs index 2533058f..13a85bb1 100644 --- a/src/crates/core/src/agentic/tools/implementations/computer_use_input.rs +++ b/src/crates/core/src/agentic/tools/implementations/computer_use_input.rs @@ -63,13 +63,15 @@ pub fn parse_screenshot_crop_half_extent_native(input: &Value) -> BitFunResult Ok(None), Some(v) if v.is_null() => Ok(None), Some(v) => { - let n = v - .as_u64() - .ok_or_else(|| BitFunError::tool("screenshot_crop_half_extent_native must be a non-negative integer.".to_string()))?; - Ok(Some( - u32::try_from(n) - .map_err(|_| BitFunError::tool("screenshot_crop_half_extent_native is too large.".to_string()))?, - )) + let n = v.as_u64().ok_or_else(|| { + BitFunError::tool( + "screenshot_crop_half_extent_native must be a non-negative integer." + .to_string(), + ) + })?; + Ok(Some(u32::try_from(n).map_err(|_| { + BitFunError::tool("screenshot_crop_half_extent_native is too large.".to_string()) + })?)) } } } diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_locate.rs b/src/crates/core/src/agentic/tools/implementations/computer_use_locate.rs index f063eebe..d9767520 100644 --- a/src/crates/core/src/agentic/tools/implementations/computer_use_locate.rs +++ b/src/crates/core/src/agentic/tools/implementations/computer_use_locate.rs @@ -4,8 +4,8 @@ use crate::agentic::tools::computer_use_capability::computer_use_desktop_availab use crate::agentic::tools::computer_use_host::{ suggested_point_crop_half_extent_from_native_bounds, UiElementLocateQuery, }; -use crate::agentic::tools::implementations::computer_use_tool::computer_use_augment_result_json; use crate::agentic::tools::framework::{ToolResult, ToolUseContext}; +use crate::agentic::tools::implementations::computer_use_tool::computer_use_augment_result_json; use crate::service::config::global::GlobalConfigManager; use crate::util::errors::{BitFunError, BitFunResult}; use serde_json::{json, Value}; diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_click_tool.rs b/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_click_tool.rs index 7581835f..a5f40771 100644 --- a/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_click_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_click_tool.rs @@ -1,8 +1,8 @@ //! Mouse button click and wheel at the current pointer (Computer use). use crate::agentic::tools::computer_use_capability::computer_use_desktop_available; -use crate::agentic::tools::implementations::computer_use_tool::computer_use_execute_mouse_click_tool; use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; +use crate::agentic::tools::implementations::computer_use_tool::computer_use_execute_mouse_click_tool; use crate::service::config::global::GlobalConfigManager; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; @@ -93,7 +93,11 @@ impl Tool for ComputerUseMouseClickTool { ai.computer_use_enabled } - async fn call_impl(&self, input: &Value, context: &ToolUseContext) -> BitFunResult> { + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { if context.is_remote() { return Err(BitFunError::tool( "ComputerUseMouseClick cannot run while the session workspace is remote (SSH)." @@ -101,7 +105,9 @@ impl Tool for ComputerUseMouseClickTool { )); } let host = context.computer_use_host.as_ref().ok_or_else(|| { - BitFunError::tool("Computer use is only available in the BitFun desktop app.".to_string()) + BitFunError::tool( + "Computer use is only available in the BitFun desktop app.".to_string(), + ) })?; computer_use_execute_mouse_click_tool(host.as_ref(), input).await diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_precise_tool.rs b/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_precise_tool.rs index 6757d262..d6e4eb1d 100644 --- a/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_precise_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_precise_tool.rs @@ -1,8 +1,8 @@ //! Absolute pointer positioning for Computer use. use crate::agentic::tools::computer_use_capability::computer_use_desktop_available; -use crate::agentic::tools::implementations::computer_use_tool::computer_use_execute_mouse_precise; use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; +use crate::agentic::tools::implementations::computer_use_tool::computer_use_execute_mouse_precise; use crate::service::config::global::GlobalConfigManager; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; @@ -82,7 +82,11 @@ impl Tool for ComputerUseMousePreciseTool { ai.computer_use_enabled } - async fn call_impl(&self, input: &Value, context: &ToolUseContext) -> BitFunResult> { + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { if context.is_remote() { return Err(BitFunError::tool( "ComputerUseMousePrecise cannot run while the session workspace is remote (SSH)." @@ -90,7 +94,9 @@ impl Tool for ComputerUseMousePreciseTool { )); } let host = context.computer_use_host.as_ref().ok_or_else(|| { - BitFunError::tool("Computer use is only available in the BitFun desktop app.".to_string()) + BitFunError::tool( + "Computer use is only available in the BitFun desktop app.".to_string(), + ) })?; computer_use_execute_mouse_precise(host.as_ref(), input).await diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_step_tool.rs b/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_step_tool.rs index e05508d7..317de688 100644 --- a/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_step_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/computer_use_mouse_step_tool.rs @@ -1,8 +1,8 @@ //! Cardinal pointer step (up/down/left/right) for Computer use. use crate::agentic::tools::computer_use_capability::computer_use_desktop_available; -use crate::agentic::tools::implementations::computer_use_tool::computer_use_execute_mouse_step; use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; +use crate::agentic::tools::implementations::computer_use_tool::computer_use_execute_mouse_step; use crate::service::config::global::GlobalConfigManager; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; @@ -77,7 +77,11 @@ impl Tool for ComputerUseMouseStepTool { ai.computer_use_enabled } - async fn call_impl(&self, input: &Value, context: &ToolUseContext) -> BitFunResult> { + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { if context.is_remote() { return Err(BitFunError::tool( "ComputerUseMouseStep cannot run while the session workspace is remote (SSH)." @@ -85,7 +89,9 @@ impl Tool for ComputerUseMouseStepTool { )); } let host = context.computer_use_host.as_ref().ok_or_else(|| { - BitFunError::tool("Computer use is only available in the BitFun desktop app.".to_string()) + BitFunError::tool( + "Computer use is only available in the BitFun desktop app.".to_string(), + ) })?; computer_use_execute_mouse_step(host.as_ref(), input).await diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_result.rs b/src/crates/core/src/agentic/tools/implementations/computer_use_result.rs index 07256460..93421f56 100644 --- a/src/crates/core/src/agentic/tools/implementations/computer_use_result.rs +++ b/src/crates/core/src/agentic/tools/implementations/computer_use_result.rs @@ -1,14 +1,9 @@ -use crate::agentic::tools::computer_use_host::{ - ComputerScreenshot, ComputerUseInteractionState, -}; +use crate::agentic::tools::computer_use_host::{ComputerScreenshot, ComputerUseInteractionState}; use serde_json::{json, Value}; pub fn append_interaction_state(body: &mut Value, interaction: &ComputerUseInteractionState) { if let Value::Object(map) = body { - map.insert( - "interaction_state".to_string(), - json!(interaction), - ); + map.insert("interaction_state".to_string(), json!(interaction)); } } @@ -46,7 +41,7 @@ pub fn build_screenshot_body( mod tests { use super::*; use crate::agentic::tools::computer_use_host::{ - ComputerUseInteractionScreenshotKind, ComputerUseImageContentRect, + ComputerUseImageContentRect, ComputerUseInteractionScreenshotKind, }; #[test] diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_tool.rs b/src/crates/core/src/agentic/tools/implementations/computer_use_tool.rs index 441e6961..b00ddfd1 100644 --- a/src/crates/core/src/agentic/tools/implementations/computer_use_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/computer_use_tool.rs @@ -7,8 +7,8 @@ use super::computer_use_input::{ use super::computer_use_locate::execute_computer_use_locate; use crate::agentic::tools::computer_use_capability::computer_use_desktop_available; use crate::agentic::tools::computer_use_host::{ - ComputerScreenshot, ComputerUseHost, ComputerUseNavigateQuadrant, ComputerUseScreenshotRefinement, - OcrRegionNative, ScreenshotCropCenter, UiElementLocateQuery, + ComputerScreenshot, ComputerUseHost, ComputerUseNavigateQuadrant, + ComputerUseScreenshotRefinement, OcrRegionNative, ScreenshotCropCenter, UiElementLocateQuery, COMPUTER_USE_POINT_CROP_HALF_MAX, COMPUTER_USE_POINT_CROP_HALF_MIN, COMPUTER_USE_QUADRANT_CLICK_READY_MAX_LONG_EDGE, COMPUTER_USE_QUADRANT_EDGE_EXPAND_PX, }; @@ -61,10 +61,7 @@ pub(crate) async fn computer_use_augment_result_json( "input_coordinates": input_coordinates, }), ); - map.insert( - "interaction_state".to_string(), - json!(interaction), - ); + map.insert("interaction_state".to_string(), json!(interaction)); // Add loop detection warning if a loop is detected if loop_result.is_loop { @@ -247,7 +244,11 @@ The **primary model cannot consume images** in tool results — **do not** use * matches.len(), take ); - Ok(vec![ToolResult::ok_with_images(body, Some(hint), attachments)]) + Ok(vec![ToolResult::ok_with_images( + body, + Some(hint), + attachments, + )]) } /// Same as [`Self::move_to_text_disambiguation_response`] but **no image attachments** (primary model is text-only). @@ -583,19 +584,22 @@ The **primary model cannot consume images** in tool results — **do not** use * let som_labels = shot .som_labels .iter() - .map(|e| json!({ - "label": e.label, - "role": e.role, - "title": e.title, - "identifier": e.identifier, - })) + .map(|e| { + json!({ + "label": e.label, + "role": e.role, + "title": e.title, + "identifier": e.identifier, + }) + }) .collect::>(); obj.insert("som_labels".to_string(), Value::Array(som_labels)); obj.insert( "recommended_next_for_click_targeting".to_string(), Value::String("click_label".to_string()), ); - } else if shot.screenshot_crop_center.is_none() && !shot.quadrant_navigation_click_ready { + } else if shot.screenshot_crop_center.is_none() && !shot.quadrant_navigation_click_ready + { if Self::shot_covers_full_display(shot) { obj.insert( "recommended_next_for_click_targeting".to_string(), @@ -696,7 +700,6 @@ The **primary model cannot consume images** in tool results — **do not** use * } } } - } /// JSON for `snapshot_coordinate_basis` in mouse tool results (last screenshot refinement). @@ -707,10 +710,7 @@ fn computer_use_snapshot_coordinate_basis( match last_ref { None => serde_json::Value::Null, Some(ComputerUseScreenshotRefinement::FullDisplay) => json!("full_display"), - Some(ComputerUseScreenshotRefinement::RegionAroundPoint { - center_x, - center_y, - }) => { + Some(ComputerUseScreenshotRefinement::RegionAroundPoint { center_x, center_y }) => { json!({ "region_crop_center_full_display_native": { "x": center_x, "y": center_y } }) @@ -873,7 +873,10 @@ pub(crate) async fn computer_use_execute_mouse_click_tool( Some(input_coords), ) .await; - let summary = format!("{} {} click at current pointer (does not move).", button, click_label); + let summary = format!( + "{} {} click at current pointer (does not move).", + button, click_label + ); Ok(vec![ToolResult::ok(body, Some(summary))]) } "wheel" => { @@ -915,18 +918,35 @@ pub(crate) async fn computer_use_execute_mouse_click_tool( /// Helper: build `UiElementLocateQuery` from tool input JSON. fn parse_locate_query(input: &Value) -> UiElementLocateQuery { UiElementLocateQuery { - title_contains: input.get("title_contains").and_then(|v| v.as_str()).map(|s| s.to_string()), - role_substring: input.get("role_substring").and_then(|v| v.as_str()).map(|s| s.to_string()), - identifier_contains: input.get("identifier_contains").and_then(|v| v.as_str()).map(|s| s.to_string()), - max_depth: input.get("max_depth").and_then(|v| v.as_u64()).map(|v| v as u32), - filter_combine: input.get("filter_combine").and_then(|v| v.as_str()).map(|s| s.to_string()), + title_contains: input + .get("title_contains") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + role_substring: input + .get("role_substring") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + identifier_contains: input + .get("identifier_contains") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + max_depth: input + .get("max_depth") + .and_then(|v| v.as_u64()) + .map(|v| v as u32), + filter_combine: input + .get("filter_combine") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), } } fn parse_ocr_region_native( input: &Value, ) -> BitFunResult> { - let v = input.get("ocr_region_native").or_else(|| input.get("ocr_region")); + let v = input + .get("ocr_region_native") + .or_else(|| input.get("ocr_region")); let Some(val) = v else { return Ok(None); }; @@ -939,28 +959,18 @@ fn parse_ocr_region_native( .to_string(), ) })?; - let x0 = o - .get("x0") - .and_then(|x| x.as_i64()) - .ok_or_else(|| BitFunError::tool("ocr_region_native.x0 (integer) is required.".to_string()))? - as i32; - let y0 = o - .get("y0") - .and_then(|x| x.as_i64()) - .ok_or_else(|| BitFunError::tool("ocr_region_native.y0 (integer) is required.".to_string()))? - as i32; - let width = o - .get("width") - .and_then(|x| x.as_u64()) - .ok_or_else(|| { - BitFunError::tool("ocr_region_native.width (positive integer) is required.".to_string()) - })? as u32; - let height = o - .get("height") - .and_then(|x| x.as_u64()) - .ok_or_else(|| { - BitFunError::tool("ocr_region_native.height (positive integer) is required.".to_string()) - })? as u32; + let x0 = o.get("x0").and_then(|x| x.as_i64()).ok_or_else(|| { + BitFunError::tool("ocr_region_native.x0 (integer) is required.".to_string()) + })? as i32; + let y0 = o.get("y0").and_then(|x| x.as_i64()).ok_or_else(|| { + BitFunError::tool("ocr_region_native.y0 (integer) is required.".to_string()) + })? as i32; + let width = o.get("width").and_then(|x| x.as_u64()).ok_or_else(|| { + BitFunError::tool("ocr_region_native.width (positive integer) is required.".to_string()) + })? as u32; + let height = o.get("height").and_then(|x| x.as_u64()).ok_or_else(|| { + BitFunError::tool("ocr_region_native.height (positive integer) is required.".to_string()) + })? as u32; if width == 0 || height == 0 { return Err(BitFunError::tool( "ocr_region_native width and height must be greater than zero.".to_string(), @@ -1074,10 +1084,7 @@ impl Tool for ComputerUseTool { }) } - async fn input_schema_for_model_with_context( - &self, - context: Option<&ToolUseContext>, - ) -> Value { + async fn input_schema_for_model_with_context(&self, context: Option<&ToolUseContext>) -> Value { let vision = context .map(|c| c.primary_model_supports_image_understanding()) .unwrap_or(true); @@ -1112,14 +1119,20 @@ impl Tool for ComputerUseTool { ai.computer_use_enabled } - async fn call_impl(&self, input: &Value, context: &ToolUseContext) -> BitFunResult> { + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { if context.is_remote() { return Err(BitFunError::tool( "ComputerUse cannot run while the session workspace is remote (SSH).".to_string(), )); } let host = context.computer_use_host.as_ref().ok_or_else(|| { - BitFunError::tool("Computer use is only available in the BitFun desktop app.".to_string()) + BitFunError::tool( + "Computer use is only available in the BitFun desktop app.".to_string(), + ) })?; let host_ref = host.as_ref(); @@ -1135,18 +1148,32 @@ impl Tool for ComputerUseTool { // ---- NEW: click_element (locate + move + click in one call) ---- "click_element" => { let query = parse_locate_query(input); - if query.title_contains.is_none() && query.role_substring.is_none() && query.identifier_contains.is_none() { + if query.title_contains.is_none() + && query.role_substring.is_none() + && query.identifier_contains.is_none() + { return Err(BitFunError::tool( "click_element requires at least one of title_contains, role_substring, or identifier_contains.".to_string(), )); } - let button = input.get("button").and_then(|v| v.as_str()).unwrap_or("left"); - let num_clicks = input.get("num_clicks").and_then(|v| v.as_u64()).unwrap_or(1).clamp(1, 3) as u32; + let button = input + .get("button") + .and_then(|v| v.as_str()) + .unwrap_or("left"); + let num_clicks = input + .get("num_clicks") + .and_then(|v| v.as_u64()) + .unwrap_or(1) + .clamp(1, 3) as u32; - let res = host_ref.locate_ui_element_screen_center(query.clone()).await?; + let res = host_ref + .locate_ui_element_screen_center(query.clone()) + .await?; // Move pointer to AX center using global screen coordinates (authoritative). - host_ref.mouse_move_global_f64(res.global_center_x, res.global_center_y).await?; + host_ref + .mouse_move_global_f64(res.global_center_x, res.global_center_y) + .await?; // Relaxed guard: AX coordinates are authoritative, no fine-screenshot needed. host_ref.computer_use_guard_click_allowed_relaxed()?; @@ -1155,7 +1182,11 @@ impl Tool for ComputerUseTool { host_ref.mouse_click_authoritative(button).await?; } - let click_label = match num_clicks { 2 => "double", 3 => "triple", _ => "single" }; + let click_label = match num_clicks { + 2 => "double", + 3 => "triple", + _ => "single", + }; let input_coords = json!({ "kind": "click_element", "query": { @@ -1191,12 +1222,9 @@ impl Tool for ComputerUseTool { if !res.other_matches.is_empty() { result_json["other_matches"] = json!(res.other_matches); } - let body = computer_use_augment_result_json( - host_ref, - result_json, - Some(input_coords), - ) - .await; + let body = + computer_use_augment_result_json(host_ref, result_json, Some(input_coords)) + .await; let match_info = if res.total_matches > 1 { format!(" ({} matches)", res.total_matches) } else { @@ -1204,7 +1232,11 @@ impl Tool for ComputerUseTool { }; let summary = format!( "AX click_element: {} {} click on role={} at ({:.0}, {:.0}).{}", - button, click_label, res.matched_role, res.global_center_x, res.global_center_y, + button, + click_label, + res.matched_role, + res.global_center_x, + res.global_center_y, match_info, ); Ok(vec![ToolResult::ok(body, Some(summary))]) @@ -1216,16 +1248,23 @@ impl Tool for ComputerUseTool { "click_label requires Set-of-Mark labels from a screenshot; the primary model is text-only. Use `click_element`, `move_to_text`, `locate`, or `mouse_move` with globals from tool JSON, then `click`.".to_string(), )); } - let label = input - .get("label") - .and_then(|v| v.as_u64()) - .ok_or_else(|| BitFunError::tool("click_label requires integer field `label`.".to_string()))? - as u32; + let label = input.get("label").and_then(|v| v.as_u64()).ok_or_else(|| { + BitFunError::tool("click_label requires integer field `label`.".to_string()) + })? as u32; if label == 0 { - return Err(BitFunError::tool("click_label label must be >= 1.".to_string())); + return Err(BitFunError::tool( + "click_label label must be >= 1.".to_string(), + )); } - let button = input.get("button").and_then(|v| v.as_str()).unwrap_or("left"); - let num_clicks = input.get("num_clicks").and_then(|v| v.as_u64()).unwrap_or(1).clamp(1, 3) as u32; + let button = input + .get("button") + .and_then(|v| v.as_str()) + .unwrap_or("left"); + let num_clicks = input + .get("num_clicks") + .and_then(|v| v.as_u64()) + .unwrap_or(1) + .clamp(1, 3) as u32; let latest_shot = host_ref.screenshot_peek_full_display().await?; let matched = latest_shot @@ -1238,7 +1277,9 @@ impl Tool for ComputerUseTool { label )))?; - host_ref.mouse_move_global_f64(matched.global_center_x, matched.global_center_y).await?; + host_ref + .mouse_move_global_f64(matched.global_center_x, matched.global_center_y) + .await?; host_ref.computer_use_guard_click_allowed_relaxed()?; for _ in 0..num_clicks { host_ref.mouse_click_authoritative(button).await?; @@ -1282,7 +1323,8 @@ impl Tool for ComputerUseTool { .filter(|s| !s.is_empty()) .ok_or_else(|| { BitFunError::tool( - "move_to_text requires non-empty string field `text_query`.".to_string(), + "move_to_text requires non-empty string field `text_query`." + .to_string(), ) })?; let ocr_region_native = parse_ocr_region_native(input)?; @@ -1292,12 +1334,9 @@ impl Tool for ComputerUseTool { .map(|u| u as u32); { - let matches = Self::find_text_on_screen( - host_ref, - text_query, - ocr_region_native.clone(), - ) - .await?; + let matches = + Self::find_text_on_screen(host_ref, text_query, ocr_region_native.clone()) + .await?; if matches.is_empty() { return Err(BitFunError::tool(format!( "move_to_text found no visible OCR match for {:?}. Take a fresh screenshot and try a shorter or more distinctive substring, or use click_label / click_element.", @@ -1405,8 +1444,15 @@ impl Tool for ComputerUseTool { "click" => { Self::ensure_click_has_no_coordinate_fields(input)?; - let button = input.get("button").and_then(|v| v.as_str()).unwrap_or("left"); - let num_clicks = input.get("num_clicks").and_then(|v| v.as_u64()).unwrap_or(1).clamp(1, 3) as u32; + let button = input + .get("button") + .and_then(|v| v.as_str()) + .unwrap_or("left"); + let num_clicks = input + .get("num_clicks") + .and_then(|v| v.as_u64()) + .unwrap_or(1) + .clamp(1, 3) as u32; host_ref.computer_use_guard_click_allowed()?; @@ -1414,7 +1460,11 @@ impl Tool for ComputerUseTool { host_ref.mouse_click_authoritative(button).await?; } - let click_label = match num_clicks { 2 => "double", 3 => "triple", _ => "single" }; + let click_label = match num_clicks { + 2 => "double", + 3 => "triple", + _ => "single", + }; let input_coords = json!({ "kind": "click", "button": button, @@ -1469,7 +1519,8 @@ impl Tool for ComputerUseTool { .await; let summary = format!( "Moved pointer to (~{}, ~{}).", - sx64.round() as i32, sy64.round() as i32 + sx64.round() as i32, + sy64.round() as i32 ); Ok(vec![ToolResult::ok(body, Some(summary))]) } @@ -1502,7 +1553,10 @@ impl Tool for ComputerUseTool { let start_y = req_i32(input, "start_y")?; let end_x = req_i32(input, "end_x")?; let end_y = req_i32(input, "end_y")?; - let button = input.get("button").and_then(|v| v.as_str()).unwrap_or("left"); + let button = input + .get("button") + .and_then(|v| v.as_str()) + .unwrap_or("left"); let (sx0, sy0) = Self::resolve_xy_f64(host_ref, input, start_x, start_y)?; let (sx1, sy1) = Self::resolve_xy_f64(host_ref, input, end_x, end_y)?; @@ -1537,8 +1591,10 @@ impl Tool for ComputerUseTool { .await; let summary = format!( "Dragged from (~{}, ~{}) to (~{}, ~{}).", - sx0.round() as i32, sy0.round() as i32, - sx1.round() as i32, sy1.round() as i32, + sx0.round() as i32, + sy0.round() as i32, + sx1.round() as i32, + sy1.round() as i32, ); Ok(vec![ToolResult::ok(body, Some(summary))]) } @@ -1582,7 +1638,10 @@ impl Tool for ComputerUseTool { let (mut data, attach, mut hint) = Self::pack_screenshot_tool_output(&shot, debug_rel).await?; if let Some(obj) = data.as_object_mut() { - obj.insert("action".to_string(), Value::String("screenshot".to_string())); + obj.insert( + "action".to_string(), + Value::String("screenshot".to_string()), + ); if ignored_crop_for_quadrant { obj.insert( "screenshot_crop_center_ignored".to_string(), @@ -1601,8 +1660,13 @@ impl Tool for ComputerUseTool { ); } } - let data = computer_use_augment_result_json(host_ref, data, Some(input_coords)).await; - Ok(vec![ToolResult::ok_with_images(data, Some(hint), vec![attach])]) + let data = + computer_use_augment_result_json(host_ref, data, Some(input_coords)).await; + Ok(vec![ToolResult::ok_with_images( + data, + Some(hint), + vec![attach], + )]) } "pointer_move_rel" => { @@ -1665,14 +1729,18 @@ impl Tool for ComputerUseTool { .and_then(|v| v.as_str()) .ok_or_else(|| BitFunError::tool("text is required".to_string()))?; host_ref.type_text(text).await?; - let input_coords = json!({ "kind": "type_text", "char_count": text.chars().count() }); + let input_coords = + json!({ "kind": "type_text", "char_count": text.chars().count() }); let body = computer_use_augment_result_json( host_ref, json!({ "success": true, "action": "type_text", "chars": text.chars().count() }), Some(input_coords), ) .await; - let summary = format!("Typed {} character(s) into the focused target.", text.chars().count()); + let summary = format!( + "Typed {} character(s) into the focused target.", + text.chars().count() + ); Ok(vec![ToolResult::ok(body, Some(summary))]) } "wait" => { diff --git a/src/crates/core/src/agentic/tools/implementations/git_tool.rs b/src/crates/core/src/agentic/tools/implementations/git_tool.rs index 82cc9bfd..1995bbdf 100644 --- a/src/crates/core/src/agentic/tools/implementations/git_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/git_tool.rs @@ -100,9 +100,9 @@ impl GitTool { args: Option<&str>, context: &ToolUseContext, ) -> BitFunResult { - let shell = context - .ws_shell() - .ok_or_else(|| BitFunError::tool("Remote Git requires workspace shell (SSH)".to_string()))?; + let shell = context.ws_shell().ok_or_else(|| { + BitFunError::tool("Remote Git requires workspace shell (SSH)".to_string()) + })?; let args_str = args.unwrap_or("").trim(); let cmd = if args_str.is_empty() { @@ -427,13 +427,15 @@ impl GitTool { // Extract branch name let branch_name = args_str - .split_whitespace().rfind(|s| !s.starts_with('-')) + .split_whitespace() + .rfind(|s| !s.starts_with('-')) .ok_or_else(|| BitFunError::tool("Branch name is required".to_string()))?; let result = if create_branch { // Create and switch to new branch let start_point = args_str - .split_whitespace().rfind(|s| !s.starts_with('-') && *s != branch_name); + .split_whitespace() + .rfind(|s| !s.starts_with('-') && *s != branch_name); GitService::create_branch(repo_path, branch_name, start_point).await } else { // Switch to existing branch @@ -489,7 +491,8 @@ impl GitTool { // Delete branch let force = args_str.contains("-D"); let branch_name = args_str - .split_whitespace().find(|s| !s.starts_with('-')) + .split_whitespace() + .find(|s| !s.starts_with('-')) .ok_or_else(|| { BitFunError::tool("Branch name is required for deletion".to_string()) })?; diff --git a/src/crates/core/src/agentic/tools/implementations/mermaid_interactive_tool.rs b/src/crates/core/src/agentic/tools/implementations/mermaid_interactive_tool.rs index 9cc80caa..becb5914 100644 --- a/src/crates/core/src/agentic/tools/implementations/mermaid_interactive_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/mermaid_interactive_tool.rs @@ -58,10 +58,10 @@ impl MermaidInteractiveTool { if !starts_with_valid { return (false, Some(format!( "Mermaid code must start with a valid diagram type. Supported diagram types: graph, flowchart, sequenceDiagram, classDiagram, stateDiagram, erDiagram, gantt, pie, journey, timeline, mindmap, etc.\nCurrent code start: {}", - if trimmed.len() > 50 { + if trimmed.len() > 50 { format!("{}...", &trimmed[..50]) - } else { - trimmed.to_string() + } else { + trimmed.to_string() } ))); } diff --git a/src/crates/core/src/infrastructure/ai/providers/openai/message_converter.rs b/src/crates/core/src/infrastructure/ai/providers/openai/message_converter.rs index 7db6edd0..b75acdd1 100644 --- a/src/crates/core/src/infrastructure/ai/providers/openai/message_converter.rs +++ b/src/crates/core/src/infrastructure/ai/providers/openai/message_converter.rs @@ -284,7 +284,7 @@ impl OpenAIMessageConverter { } else { error!( "[OpenAI] Message content is empty and violates API spec: role={}, has_tool_calls={}", - msg.role, + msg.role, has_tool_calls ); diff --git a/src/crates/core/src/infrastructure/mod.rs b/src/crates/core/src/infrastructure/mod.rs index fc47c92d..b3405815 100644 --- a/src/crates/core/src/infrastructure/mod.rs +++ b/src/crates/core/src/infrastructure/mod.rs @@ -14,8 +14,7 @@ pub use filesystem::{ file_watcher, get_path_manager_arc, initialize_file_watcher, try_get_path_manager_arc, BatchedFileSearchProgressSink, FileContentSearchOptions, FileInfo, FileNameSearchOptions, FileOperationOptions, FileOperationService, FileReadResult, FileSearchOutcome, - FileSearchProgressSink, FileSearchResult, FileSearchResultGroup, FileTreeNode, - FileTreeOptions, FileTreeService, FileTreeStatistics, FileWriteResult, PathManager, - SearchMatchType, + FileSearchProgressSink, FileSearchResult, FileSearchResultGroup, FileTreeNode, FileTreeOptions, + FileTreeService, FileTreeStatistics, FileWriteResult, PathManager, SearchMatchType, }; // pub use storage::{}; diff --git a/src/crates/core/src/miniapp/runtime_detect.rs b/src/crates/core/src/miniapp/runtime_detect.rs index 5b877ec3..3a4fa00e 100644 --- a/src/crates/core/src/miniapp/runtime_detect.rs +++ b/src/crates/core/src/miniapp/runtime_detect.rs @@ -45,8 +45,6 @@ fn get_version(executable: &std::path::Path) -> Result { let v = String::from_utf8_lossy(&out.stdout); Ok(v.trim().to_string()) } else { - Err(std::io::Error::other( - "version check failed", - )) + Err(std::io::Error::other("version check failed")) } } diff --git a/src/crates/core/src/service/git/git_service.rs b/src/crates/core/src/service/git/git_service.rs index e4adfde1..591b1cb6 100644 --- a/src/crates/core/src/service/git/git_service.rs +++ b/src/crates/core/src/service/git/git_service.rs @@ -851,10 +851,7 @@ impl GitService { } /// Gets commit statistics. - fn get_commit_stats( - _repo: &Repository, - _commit: &Commit, - ) -> Result { + fn get_commit_stats(_repo: &Repository, _commit: &Commit) -> Result { Ok((None, None, None)) } diff --git a/src/crates/core/src/service/git/graph.rs b/src/crates/core/src/service/git/graph.rs index 46d62517..45bae413 100644 --- a/src/crates/core/src/service/git/graph.rs +++ b/src/crates/core/src/service/git/graph.rs @@ -252,9 +252,7 @@ fn collect_refs(repo: &Repository) -> Result>, git if let Some(oid) = reference.target() { let hash = oid.to_string(); let is_current = current_branch.as_ref().is_some_and(|cb| cb == name); - let is_head = head - .as_ref() - .and_then(|h| h.target()) == Some(oid); + let is_head = head.as_ref().and_then(|h| h.target()) == Some(oid); let graph_ref = GraphRef { name: name.to_string(), @@ -263,10 +261,7 @@ fn collect_refs(repo: &Repository) -> Result>, git is_head, }; - refs_map - .entry(hash) - .or_default() - .push(graph_ref); + refs_map.entry(hash).or_default().push(graph_ref); } } diff --git a/src/crates/core/src/service/lsp/file_sync.rs b/src/crates/core/src/service/lsp/file_sync.rs index 83d564bd..fc37a045 100644 --- a/src/crates/core/src/service/lsp/file_sync.rs +++ b/src/crates/core/src/service/lsp/file_sync.rs @@ -341,10 +341,7 @@ impl LspFileSync { let workspace = managers.keys().find(|ws| path.starts_with(ws)); if let Some(ws) = workspace { - grouped - .entry(ws.clone()) - .or_default() - .push(path); + grouped.entry(ws.clone()).or_default().push(path); } } diff --git a/src/crates/core/src/service/lsp/project_detector.rs b/src/crates/core/src/service/lsp/project_detector.rs index c1976f02..76a8b65f 100644 --- a/src/crates/core/src/service/lsp/project_detector.rs +++ b/src/crates/core/src/service/lsp/project_detector.rs @@ -31,7 +31,6 @@ pub struct ProjectInfo { pub total_files: usize, } - /// Project type detector. pub struct ProjectDetector; diff --git a/src/crates/core/src/service/mcp/auth.rs b/src/crates/core/src/service/mcp/auth.rs index f105ec01..718cef6b 100644 --- a/src/crates/core/src/service/mcp/auth.rs +++ b/src/crates/core/src/service/mcp/auth.rs @@ -4,11 +4,9 @@ use aes_gcm::aead::{Aead, KeyInit}; use aes_gcm::{Aes256Gcm, Nonce}; use anyhow::{Context, Result}; use async_trait::async_trait; -use base64::{Engine, engine::general_purpose::STANDARD as B64}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; use rand::RngCore; -use rmcp::transport::auth::{ - AuthorizationManager, CredentialStore, OAuthState, StoredCredentials, -}; +use rmcp::transport::auth::{AuthorizationManager, CredentialStore, OAuthState, StoredCredentials}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; @@ -117,10 +115,8 @@ impl MCPRemoteOAuthCredentialVault { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions( - &self.key_path, - std::fs::Permissions::from_mode(0o600), - ); + let _ = + std::fs::set_permissions(&self.key_path, std::fs::Permissions::from_mode(0o600)); } Ok(key) @@ -222,10 +218,8 @@ impl MCPRemoteOAuthCredentialVault { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions( - &self.vault_path, - std::fs::Permissions::from_mode(0o600), - ); + let _ = + std::fs::set_permissions(&self.vault_path, std::fs::Permissions::from_mode(0o600)); } Ok(()) @@ -390,7 +384,12 @@ pub async fn prepare_remote_oauth_authorization( )) })? .port(); - let redirect_uri = format!("http://{}:{}{}", host, port, normalize_callback_path(&oauth)); + let redirect_uri = format!( + "http://{}:{}{}", + host, + port, + normalize_callback_path(&oauth) + ); let scopes = oauth.scopes.iter().map(String::as_str).collect::>(); let mut state = OAuthState::new(server_url, None) @@ -420,7 +419,10 @@ pub async fn prepare_remote_oauth_authorization( } } - let authorization_url = state.get_authorization_url().await.map_err(map_auth_error)?; + let authorization_url = state + .get_authorization_url() + .await + .map_err(map_auth_error)?; Ok(PreparedMCPRemoteOAuthAuthorization { state, diff --git a/src/crates/core/src/service/mcp/config/cursor_format.rs b/src/crates/core/src/service/mcp/config/cursor_format.rs index 955ea22d..0bd25f3c 100644 --- a/src/crates/core/src/service/mcp/config/cursor_format.rs +++ b/src/crates/core/src/service/mcp/config/cursor_format.rs @@ -176,10 +176,7 @@ pub(super) fn parse_cursor_format( Some(value) => match parse_legacy_type(value) { Some(parsed) => Some(parsed), None => { - warn!( - "Unsupported MCP type for server '{}': {}", - server_id, value - ); + warn!("Unsupported MCP type for server '{}': {}", server_id, value); continue; } }, diff --git a/src/crates/core/src/service/mcp/config/json_config.rs b/src/crates/core/src/service/mcp/config/json_config.rs index 4f6e0200..10f0787f 100644 --- a/src/crates/core/src/service/mcp/config/json_config.rs +++ b/src/crates/core/src/service/mcp/config/json_config.rs @@ -91,7 +91,8 @@ impl MCPConfigService { if config_value .get("mcpServers") - .and_then(|v| v.as_object()).is_none() + .and_then(|v| v.as_object()) + .is_none() { let error_msg = "'mcpServers' field must be an object"; error!("{}", error_msg); diff --git a/src/crates/core/src/service/mcp/protocol/transport_remote.rs b/src/crates/core/src/service/mcp/protocol/transport_remote.rs index 536d0cf0..fcabdc1e 100644 --- a/src/crates/core/src/service/mcp/protocol/transport_remote.rs +++ b/src/crates/core/src/service/mcp/protocol/transport_remote.rs @@ -3,12 +3,11 @@ //! Uses the official `rmcp` Rust SDK to implement the MCP Streamable HTTP client transport. use super::types::{ - InitializeResult as BitFunInitializeResult, MCPCapability, MCPAnnotations, MCPPrompt, + InitializeResult as BitFunInitializeResult, MCPAnnotations, MCPCapability, MCPPrompt, MCPPromptArgument, MCPPromptMessage, MCPPromptMessageContent, MCPPromptMessageContentBlock, - MCPResource, MCPResourceContent, MCPResourceIcon, MCPServerInfo, MCPTool, - MCPToolAnnotations, MCPToolResult, MCPToolResultContent, - PromptsGetResult, PromptsListResult, ResourcesListResult, ResourcesReadResult, - ToolsListResult, + MCPResource, MCPResourceContent, MCPResourceIcon, MCPServerInfo, MCPTool, MCPToolAnnotations, + MCPToolResult, MCPToolResultContent, PromptsGetResult, PromptsListResult, ResourcesListResult, + ResourcesReadResult, ToolsListResult, }; use crate::service::mcp::auth::build_authorization_manager; use crate::util::errors::{BitFunError, BitFunResult}; @@ -731,7 +730,9 @@ fn map_tool(tool: rmcp::model::Tool) -> MCPTool { title: tool.title, description: tool.description.map(|d| d.to_string()), input_schema: schema, - output_schema: tool.output_schema.map(|schema| Value::Object((*schema).clone())), + output_schema: tool + .output_schema + .map(|schema| Value::Object((*schema).clone())), icons: map_icons(tool.icons.as_ref()), annotations: tool.annotations.map(map_tool_annotations), meta: map_optional_via_json(tool.meta.as_ref()), @@ -841,10 +842,7 @@ fn map_prompt_message(message: rmcp::model::PromptMessage) -> MCPPromptMessage { } }; - MCPPromptMessage { - role, - content, - } + MCPPromptMessage { role, content } } fn map_tool_result(result: rmcp::model::CallToolResult) -> MCPToolResult { @@ -897,13 +895,7 @@ fn map_icons(icons: Option<&Vec>) -> Option>(), - ) + Value::Array(sizes.iter().cloned().map(Value::String).collect::>()) }), }) .collect() @@ -917,7 +909,9 @@ fn map_annotations(annotations: Option<&rmcp::model::Annotations>) -> Option String Some(s) => s, None => { return if language.is_chinese() { - "自动准备未能完成:工作区服务不可用。请稍后在 BitFun 桌面端打开工作区后再试。".to_string() + "自动准备未能完成:工作区服务不可用。请稍后在 BitFun 桌面端打开工作区后再试。" + .to_string() } else { "Auto-setup incomplete: workspace service unavailable. Open a workspace in BitFun Desktop and try again." .to_string() @@ -380,7 +381,9 @@ pub async fn bootstrap_im_chat_after_pairing(state: &mut BotChatState) -> String Ok(w) => assistants.push(w), Err(e) => { return if language.is_chinese() { - format!("自动准备未能完成:无法创建助理工作区({e})。请使用 /switch_assistant。") + format!( + "自动准备未能完成:无法创建助理工作区({e})。请使用 /switch_assistant。" + ) } else { format!( "Auto-setup incomplete: could not create assistant workspace ({e}). Use /switch_assistant." @@ -464,9 +467,7 @@ pub async fn bootstrap_im_chat_after_pairing(state: &mut BotChatState) -> String state.current_session_id = Some(m.session_id.clone()); let name = m.session_name.as_str(); return if language.is_chinese() { - format!( - "已为你进入助理模式,并恢复最近会话「{name}」。直接发送消息即可继续对话。" - ) + format!("已为你进入助理模式,并恢复最近会话「{name}」。直接发送消息即可继续对话。") } else { format!( "Assistant mode is on; resumed your latest session \"{name}\". Send a message to continue." @@ -1190,7 +1191,8 @@ async fn handle_switch_assistant(state: &mut BotChatState) -> HandleResult { reply: if language.is_chinese() { "未找到助理。请先在 BitFun Desktop 中创建助理。".to_string() } else { - "No assistants found. Please create an assistant in BitFun Desktop first.".to_string() + "No assistants found. Please create an assistant in BitFun Desktop first." + .to_string() }, actions: assistant_mode_actions(language), forward_to_session: None, @@ -1303,7 +1305,8 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR if all_meta.is_empty() { let reply = if language.is_chinese() { if state.display_mode == BotDisplayMode::Pro { - "当前工作区没有会话。请使用 /new_code_session 或 /new_cowork_session 创建一个。".to_string() + "当前工作区没有会话。请使用 /new_code_session 或 /new_cowork_session 创建一个。" + .to_string() } else { "当前工作区没有会话。请使用 /new_claw_session 创建一个。".to_string() } @@ -1311,7 +1314,8 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR if state.display_mode == BotDisplayMode::Pro { "No sessions found in this workspace. Use /new_code_session or /new_cowork_session to create one.".to_string() } else { - "No sessions found in this workspace. Use /new_claw_session to create one.".to_string() + "No sessions found in this workspace. Use /new_claw_session to create one." + .to_string() } }; return HandleResult { @@ -1764,7 +1768,8 @@ async fn select_workspace(state: &mut BotChatState, path: &str, name: &str) -> H info!("Bot switched workspace to: {path}"); let session_count = count_workspace_sessions(path).await; - let reply = build_workspace_switched_reply(language, name, session_count, state.display_mode); + let reply = + build_workspace_switched_reply(language, name, session_count, state.display_mode); let actions = if session_count > 0 { session_entry_actions(language, state.display_mode) } else { @@ -1826,7 +1831,10 @@ async fn select_assistant(state: &mut BotChatState, path: &str, name: &str) -> H let reply = if language.is_chinese() { format!("已切换到助理:{}\n\n会话数:{}", name, session_count) } else { - format!("Switched to assistant: {}\n\nSessions: {}", name, session_count) + format!( + "Switched to assistant: {}\n\nSessions: {}", + name, session_count + ) }; let actions = if session_count > 0 { session_entry_actions(language, state.display_mode) @@ -1879,15 +1887,26 @@ fn build_workspace_switched_reply( ) -> String { let is_pro = display_mode == BotDisplayMode::Pro; let mode_label = if is_pro { - if language.is_chinese() { "专业模式" } else { "Expert Mode" } + if language.is_chinese() { + "专业模式" + } else { + "Expert Mode" + } } else { - if language.is_chinese() { "助理模式" } else { "Assistant Mode" } + if language.is_chinese() { + "助理模式" + } else { + "Assistant Mode" + } }; let mut reply = if language.is_chinese() { format!("已切换到工作区:{}\n当前模式:{}\n\n", name, mode_label) } else { - format!("Switched to workspace: {}\nCurrent mode: {}\n\n", name, mode_label) + format!( + "Switched to workspace: {}\nCurrent mode: {}\n\n", + name, mode_label + ) }; if session_count > 0 { @@ -1915,14 +1934,14 @@ fn build_workspace_switched_reply( "/resume_session - 恢复已有会话\n\ /new_code_session - 开始新的编码会话\n\ /new_cowork_session - 开始新的协作会话\n\ - /assistant - 切换到助理模式" + /assistant - 切换到助理模式", ); } else { reply.push_str( "/resume_session - Resume an existing session\n\ /new_code_session - Start a new coding session\n\ /new_cowork_session - Start a new cowork session\n\ - /assistant - Switch to Assistant mode" + /assistant - Switch to Assistant mode", ); } } else { @@ -1930,13 +1949,13 @@ fn build_workspace_switched_reply( reply.push_str( "/resume_session - 恢复已有会话\n\ /new_claw_session - 开始新的助理会话\n\ - /pro - 切换到专业模式" + /pro - 切换到专业模式", ); } else { reply.push_str( "/resume_session - Resume an existing session\n\ /new_claw_session - Start a new claw session\n\ - /pro - Switch to Expert mode" + /pro - Switch to Expert mode", ); } } @@ -1970,10 +1989,7 @@ async fn select_session( "{}: {user_text}\n\n", if language.is_chinese() { "你" } else { "You" } )); - reply.push_str(&format!( - "{}: {assistant_text}\n\n", - "AI" - )); + reply.push_str(&format!("{}: {assistant_text}\n\n", "AI")); reply.push_str(if language.is_chinese() { "你可以继续对话。" } else { @@ -2877,12 +2893,21 @@ mod parse_command_tests { #[test] fn numeric_menu_with_trailing_dot() { - assert!(matches!(parse_command("1."), BotCommand::NumberSelection(1))); - assert!(matches!(parse_command("2。"), BotCommand::NumberSelection(2))); + assert!(matches!( + parse_command("1."), + BotCommand::NumberSelection(1) + )); + assert!(matches!( + parse_command("2。"), + BotCommand::NumberSelection(2) + )); } #[test] fn fullwidth_digit_one() { - assert!(matches!(parse_command("1"), BotCommand::NumberSelection(1))); + assert!(matches!( + parse_command("1"), + BotCommand::NumberSelection(1) + )); } } diff --git a/src/crates/core/src/service/remote_connect/bot/feishu.rs b/src/crates/core/src/service/remote_connect/bot/feishu.rs index abca311b..46d0798e 100644 --- a/src/crates/core/src/service/remote_connect/bot/feishu.rs +++ b/src/crates/core/src/service/remote_connect/bot/feishu.rs @@ -15,15 +15,13 @@ use tokio_tungstenite::tungstenite::Message as WsMessage; use super::command_router::{ complete_im_bot_pairing, current_bot_language, execute_forwarded_turn, handle_command, - parse_command, welcome_message, BotAction, BotActionStyle, - BotChatState, BotInteractionHandler, BotInteractiveRequest, BotLanguage, BotMessageSender, - HandleResult, + parse_command, welcome_message, BotAction, BotActionStyle, BotChatState, BotInteractionHandler, + BotInteractiveRequest, BotLanguage, BotMessageSender, HandleResult, }; use super::{load_bot_persistence, save_bot_persistence, BotConfig, SavedBotConnection}; -type FeishuWsStream = tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, ->; +type FeishuWsStream = + tokio_tungstenite::WebSocketStream>; type FeishuWsWrite = futures::stream::SplitSink; type SharedFeishuWsWrite = Arc>; @@ -1559,7 +1557,9 @@ impl FeishuBot { }) }); let verbose_mode = load_bot_persistence().verbose_mode; - let result = execute_forwarded_turn(forward, Some(handler), Some(sender), verbose_mode).await; + let result = + execute_forwarded_turn(forward, Some(handler), Some(sender), verbose_mode) + .await; if !result.display_text.is_empty() { if let Err(err) = bot.send_message(&cid, &result.display_text).await { warn!("Failed to send Feishu final message to {cid}: {err}"); diff --git a/src/crates/core/src/service/remote_connect/bot/mod.rs b/src/crates/core/src/service/remote_connect/bot/mod.rs index afe8d037..fa11ac7d 100644 --- a/src/crates/core/src/service/remote_connect/bot/mod.rs +++ b/src/crates/core/src/service/remote_connect/bot/mod.rs @@ -17,8 +17,13 @@ pub use command_router::{BotChatState, ForwardRequest, ForwardedTurnResult, Hand #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "bot_type", rename_all = "snake_case")] pub enum BotConfig { - Feishu { app_id: String, app_secret: String }, - Telegram { bot_token: String }, + Feishu { + app_id: String, + app_secret: String, + }, + Telegram { + bot_token: String, + }, Weixin { ilink_token: String, base_url: String, @@ -384,8 +389,7 @@ pub fn extract_computer_file_paths( let end = rest .find(|c: char| c.is_whitespace() || matches!(c, '<' | '>' | '(' | ')' | '"' | '\'')) .unwrap_or(rest.len()); - let raw_suffix = - rest[..end].trim_end_matches(['.', ',', ';', ':', ')', ']']); + let raw_suffix = rest[..end].trim_end_matches(['.', ',', ';', ':', ')', ']']); if !raw_suffix.is_empty() { push_if_existing_file(&format!("{PREFIX}{raw_suffix}"), &mut paths, workspace_root); } @@ -436,8 +440,7 @@ pub fn extract_downloadable_file_paths( c.is_whitespace() || matches!(c, '<' | '>' | '(' | ')' | '"' | '\'') }) .unwrap_or(rest.len()); - let raw_suffix = rest[..end] - .trim_end_matches(['.', ',', ';', ':', ')', ']']); + let raw_suffix = rest[..end].trim_end_matches(['.', ',', ';', ':', ')', ']']); if !raw_suffix.is_empty() { let resolve_input = if prefix == "computer://" { format!("{prefix}{raw_suffix}") @@ -469,9 +472,10 @@ pub fn extract_downloadable_file_paths( && !href.starts_with("tel:") && !href.starts_with('#') && !href.starts_with("//") - && is_downloadable_by_extension(href) { - push_if_existing_file(href, &mut paths, workspace_root); - } + && is_downloadable_by_extension(href) + { + push_if_existing_file(href, &mut paths, workspace_root); + } i = href_start + rel_end + 1; } else { i += 2; diff --git a/src/crates/core/src/service/remote_connect/bot/telegram.rs b/src/crates/core/src/service/remote_connect/bot/telegram.rs index 0688b651..5f06cf71 100644 --- a/src/crates/core/src/service/remote_connect/bot/telegram.rs +++ b/src/crates/core/src/service/remote_connect/bot/telegram.rs @@ -13,8 +13,8 @@ use tokio::sync::RwLock; use super::command_router::{ complete_im_bot_pairing, current_bot_language, execute_forwarded_turn, handle_command, - parse_command, welcome_message, BotAction, BotChatState, - BotInteractionHandler, BotInteractiveRequest, BotLanguage, BotMessageSender, HandleResult, + parse_command, welcome_message, BotAction, BotChatState, BotInteractionHandler, + BotInteractiveRequest, BotLanguage, BotMessageSender, HandleResult, }; use super::{load_bot_persistence, save_bot_persistence, BotConfig, SavedBotConnection}; use crate::service::remote_connect::remote_server::ImageAttachment; @@ -704,7 +704,9 @@ impl TelegramBot { }) }); let verbose_mode = load_bot_persistence().verbose_mode; - let result = execute_forwarded_turn(forward, Some(handler), Some(sender), verbose_mode).await; + let result = + execute_forwarded_turn(forward, Some(handler), Some(sender), verbose_mode) + .await; if !result.display_text.is_empty() { bot.send_message(chat_id, &result.display_text).await.ok(); } diff --git a/src/crates/core/src/service/remote_connect/bot/weixin.rs b/src/crates/core/src/service/remote_connect/bot/weixin.rs index 986c6fd3..274a83a8 100644 --- a/src/crates/core/src/service/remote_connect/bot/weixin.rs +++ b/src/crates/core/src/service/remote_connect/bot/weixin.rs @@ -4,9 +4,9 @@ //! `@tencent-weixin/openclaw-weixin`. Login is QR-based; after login the same 6-digit //! pairing flow as Telegram/Feishu binds the Weixin user to this desktop. -use anyhow::{anyhow, Result}; use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit}; use aes::Aes128; +use anyhow::{anyhow, Result}; use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use log::{debug, error, info, warn}; use rand::Rng; @@ -15,9 +15,9 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::HashMap; use std::path::PathBuf; +use std::sync::Arc; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use std::sync::Arc; use tokio::sync::RwLock; use super::command_router::{ @@ -95,10 +95,7 @@ fn build_cdn_download_url(cdn_base: &str, encrypted_query_param: &str) -> String fn decrypt_aes_128_ecb_pkcs7(ciphertext: &[u8], key: &[u8; 16]) -> Result> { if ciphertext.is_empty() || !ciphertext.len().is_multiple_of(16) { - return Err(anyhow!( - "invalid ciphertext length {}", - ciphertext.len() - )); + return Err(anyhow!("invalid ciphertext length {}", ciphertext.len())); } let cipher = Aes128::new_from_slice(key).expect("AES-128 key len"); let mut out = Vec::with_capacity(ciphertext.len()); @@ -152,9 +149,7 @@ fn sniff_image_mime(bytes: &[u8]) -> &'static str { if bytes.len() >= 3 && bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff { return "image/jpeg"; } - if bytes.len() >= 8 - && bytes[..8] == [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] - { + if bytes.len() >= 8 && bytes[..8] == [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] { return "image/png"; } if bytes.len() >= 6 @@ -231,15 +226,17 @@ fn ensure_trailing_slash(url: &str) -> String { fn sync_buf_path(bot_account_id: &str) -> PathBuf { let base = dirs::home_dir().unwrap_or_else(std::env::temp_dir); - base - .join(".bitfun") + base.join(".bitfun") .join("weixin") .join(format!("{bot_account_id}_get_updates_buf.txt")) } fn load_sync_buf(bot_account_id: &str) -> String { let p = sync_buf_path(bot_account_id); - std::fs::read_to_string(&p).unwrap_or_default().trim().to_string() + std::fs::read_to_string(&p) + .unwrap_or_default() + .trim() + .to_string() } fn save_sync_buf(bot_account_id: &str, buf: &str) { @@ -688,9 +685,11 @@ impl WeixinBot { ); h.insert( HeaderName::from_static("x-wechat-uin"), - HeaderValue::from_str(&random_wechat_uin_header()).unwrap_or(HeaderValue::from_static("MA==")), + HeaderValue::from_str(&random_wechat_uin_header()) + .unwrap_or(HeaderValue::from_static("MA==")), ); - if let Ok(v) = HeaderValue::from_str(&format!("Bearer {}", self.config.ilink_token.trim())) { + if let Ok(v) = HeaderValue::from_str(&format!("Bearer {}", self.config.ilink_token.trim())) + { h.insert(HeaderName::from_static("authorization"), v); } h @@ -740,20 +739,21 @@ impl WeixinBot { .filter(|s| !s.is_empty()) .ok_or_else(|| anyhow!("image: missing encrypt_query_param"))?; - let key: Option<[u8; 16]> = - if let Some(hex_s) = img["aeskey"].as_str().filter(|s| !s.is_empty()) { - let bytes = hex::decode(hex_s.trim()).map_err(|e| anyhow!("image aeskey hex: {e}"))?; - if bytes.len() != 16 { - return Err(anyhow!("image aeskey must decode to 16 bytes")); - } - let mut k = [0u8; 16]; - k.copy_from_slice(&bytes); - Some(k) - } else if let Some(b64) = img["media"]["aes_key"].as_str().filter(|s| !s.is_empty()) { - Some(parse_weixin_cdn_aes_key(b64)?) - } else { - None - }; + let key: Option<[u8; 16]> = if let Some(hex_s) = + img["aeskey"].as_str().filter(|s| !s.is_empty()) + { + let bytes = hex::decode(hex_s.trim()).map_err(|e| anyhow!("image aeskey hex: {e}"))?; + if bytes.len() != 16 { + return Err(anyhow!("image aeskey must decode to 16 bytes")); + } + let mut k = [0u8; 16]; + k.copy_from_slice(&bytes); + Some(k) + } else if let Some(b64) = img["media"]["aes_key"].as_str().filter(|s| !s.is_empty()) { + Some(parse_weixin_cdn_aes_key(b64)?) + } else { + None + }; let enc = self.fetch_weixin_cdn_bytes(param).await?; match key { @@ -763,7 +763,10 @@ impl WeixinBot { } /// Collect up to [`MAX_INBOUND_IMAGES`] images from `item_list` as Feishu-style `ImageAttachment` data URLs. - async fn inbound_image_attachments_from_message(&self, msg: &Value) -> (Vec, usize) { + async fn inbound_image_attachments_from_message( + &self, + msg: &Value, + ) -> (Vec, usize) { const MAX_BYTES: usize = 1024 * 1024; let Some(items) = msg["item_list"].as_array() else { return (vec![], 0); @@ -861,11 +864,7 @@ impl WeixinBot { .ok_or_else(|| anyhow!("getuploadurl: missing upload_param")) } - async fn post_weixin_cdn_upload( - &self, - cdn_url: &str, - ciphertext: &[u8], - ) -> Result { + async fn post_weixin_cdn_upload(&self, cdn_url: &str, ciphertext: &[u8]) -> Result { let client = reqwest::Client::builder() .timeout(Duration::from_secs(120)) .build()?; @@ -906,9 +905,8 @@ impl WeixinBot { .and_then(|h| h.to_str().ok()) .map(|s| s.to_string()) .filter(|s| !s.is_empty()); - return download_param.ok_or_else(|| { - anyhow!("CDN response missing x-encrypted-param header") - }); + return download_param + .ok_or_else(|| anyhow!("CDN response missing x-encrypted-param header")); } Err(last_err.unwrap_or_else(|| anyhow!("CDN upload failed"))) } @@ -949,7 +947,8 @@ impl WeixinBot { "weixin CDN upload: media_type={media_type} rawsize={rawsize} cipher_len={}", ciphertext.len() ); - let download_encrypted_query_param = self.post_weixin_cdn_upload(&cdn_url, &ciphertext).await?; + let download_encrypted_query_param = + self.post_weixin_cdn_upload(&cdn_url, &ciphertext).await?; Ok(UploadedMediaInfo { download_encrypted_query_param, @@ -1004,7 +1003,8 @@ impl WeixinBot { raw_path: &str, workspace_root: Option<&std::path::Path>, ) -> Result<()> { - let content = super::read_workspace_file(raw_path, MAX_WEIXIN_FILE_BYTES, workspace_root).await?; + let content = + super::read_workspace_file(raw_path, MAX_WEIXIN_FILE_BYTES, workspace_root).await?; let mime = super::detect_mime_type(std::path::Path::new(&content.name)); let token = { @@ -1065,7 +1065,8 @@ impl WeixinBot { }) }; - self.send_message_with_items(peer_id, &token, vec![item]).await?; + self.send_message_with_items(peer_id, &token, vec![item]) + .await?; info!("Weixin file sent to peer={peer_id} name={}", content.name); Ok(()) } @@ -1131,7 +1132,11 @@ impl WeixinBot { let _ = self .send_text( peer_id, - &Self::send_file_failed_message(language, &file_name, &e.to_string()), + &Self::send_file_failed_message( + language, + &file_name, + &e.to_string(), + ), ) .await; } @@ -1166,7 +1171,12 @@ impl WeixinBot { Ok(v) } - async fn send_message_raw(&self, to_user_id: &str, context_token: &str, text: &str) -> Result<()> { + async fn send_message_raw( + &self, + to_user_id: &str, + context_token: &str, + text: &str, + ) -> Result<()> { let client_id = format!("bitfun-wx-{}", uuid::Uuid::new_v4()); let item_list = if text.is_empty() { None @@ -1441,7 +1451,9 @@ impl WeixinBot { let ret = resp["ret"].as_i64().unwrap_or(0); let errcode = resp["errcode"].as_i64().unwrap_or(0); - if (ret != 0 && ret != SESSION_EXPIRED_ERRCODE) || (errcode != 0 && errcode != SESSION_EXPIRED_ERRCODE) { + if (ret != 0 && ret != SESSION_EXPIRED_ERRCODE) + || (errcode != 0 && errcode != SESSION_EXPIRED_ERRCODE) + { if errcode == SESSION_EXPIRED_ERRCODE || ret == SESSION_EXPIRED_ERRCODE { tokio::time::sleep(Duration::from_secs(5)).await; continue; @@ -1461,12 +1473,11 @@ impl WeixinBot { if !Self::is_user_message(msg) { continue; } - let Some(peer) = Self::peer_id(msg) else { continue }; + let Some(peer) = Self::peer_id(msg) else { + continue; + }; if let Some(ct) = Self::context_token(msg) { - self.context_tokens - .write() - .await - .insert(peer.clone(), ct); + self.context_tokens.write().await.insert(peer.clone(), ct); } let text = Self::body_from_message(msg).trim().to_string(); let language = current_bot_language().await; @@ -1487,8 +1498,7 @@ impl WeixinBot { .insert(peer.clone(), state.clone()); self.persist_chat_state(&peer, &state).await; - let footer = - Self::format_actions_footer(language, &result.actions); + let footer = Self::format_actions_footer(language, &result.actions); let _ = self .send_text(&peer, &format!("{}{}", result.reply, footer)) .await; @@ -1550,7 +1560,9 @@ impl WeixinBot { let ret = resp["ret"].as_i64().unwrap_or(0); let errcode = resp["errcode"].as_i64().unwrap_or(0); - if (ret != 0 && ret != SESSION_EXPIRED_ERRCODE) || (errcode != 0 && errcode != SESSION_EXPIRED_ERRCODE) { + if (ret != 0 && ret != SESSION_EXPIRED_ERRCODE) + || (errcode != 0 && errcode != SESSION_EXPIRED_ERRCODE) + { if errcode == SESSION_EXPIRED_ERRCODE || ret == SESSION_EXPIRED_ERRCODE { tokio::time::sleep(Duration::from_secs(5)).await; continue; @@ -1564,18 +1576,19 @@ impl WeixinBot { save_sync_buf(&self.config.bot_account_id, &buf); } - let Some(msgs) = resp["msgs"].as_array() else { continue }; + let Some(msgs) = resp["msgs"].as_array() else { + continue; + }; for msg in msgs { if !Self::is_user_message(msg) { continue; } - let Some(peer) = Self::peer_id(msg) else { continue }; + let Some(peer) = Self::peer_id(msg) else { + continue; + }; if let Some(ct) = Self::context_token(msg) { - self.context_tokens - .write() - .await - .insert(peer.clone(), ct); + self.context_tokens.write().await.insert(peer.clone(), ct); } let msg_value = msg.clone(); let bot = self.clone(); @@ -1641,8 +1654,7 @@ impl WeixinBot { let result = complete_im_bot_pairing(state).await; self.persist_chat_state(&peer_id, state).await; drop(states); - let footer = - Self::format_actions_footer(language, &result.actions); + let footer = Self::format_actions_footer(language, &result.actions); let _ = self .send_text(&peer_id, &format!("{}{}", result.reply, footer)) .await; @@ -1691,15 +1703,16 @@ impl WeixinBot { tokio::spawn(async move { let interaction_bot = bot.clone(); let peer_c = peer.clone(); - let handler: BotInteractionHandler = Arc::new(move |interaction: BotInteractiveRequest| { - let interaction_bot = interaction_bot.clone(); - let peer_i = peer_c.clone(); - Box::pin(async move { - interaction_bot - .deliver_interaction(peer_i, interaction) - .await; - }) - }); + let handler: BotInteractionHandler = + Arc::new(move |interaction: BotInteractiveRequest| { + let interaction_bot = interaction_bot.clone(); + let peer_i = peer_c.clone(); + Box::pin(async move { + interaction_bot + .deliver_interaction(peer_i, interaction) + .await; + }) + }); let msg_bot = bot.clone(); let peer_m = peer.clone(); let sender: BotMessageSender = Arc::new(move |t: String| { @@ -1711,7 +1724,8 @@ impl WeixinBot { }); let verbose_mode = load_bot_persistence().verbose_mode; let turn_result = - execute_forwarded_turn(forward, Some(handler), Some(sender), verbose_mode).await; + execute_forwarded_turn(forward, Some(handler), Some(sender), verbose_mode) + .await; if !turn_result.display_text.is_empty() { let _ = bot.send_text(&peer, &turn_result.display_text).await; } diff --git a/src/crates/core/src/service/remote_connect/remote_server.rs b/src/crates/core/src/service/remote_connect/remote_server.rs index 21f8d5c0..c626a03c 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -969,9 +969,16 @@ pub enum TrackerEvent { duration_ms: Option, success: bool, }, - TurnCompleted { turn_id: String }, - TurnFailed { turn_id: String, error: String }, - TurnCancelled { turn_id: String }, + TurnCompleted { + turn_id: String, + }, + TurnFailed { + turn_id: String, + error: String, + }, + TurnCancelled { + turn_id: String, + }, } /// Tracks the real-time state of a session for polling by the mobile client. @@ -1269,10 +1276,10 @@ impl RemoteSessionStateTracker { self.bump_version(); let _ = self.event_tx.send(TrackerEvent::TextChunk(text.clone())); } - AE::ThinkingChunk { content, is_end, .. } => { - let clean = content - .replace("", "") - .replace("", ""); + AE::ThinkingChunk { + content, is_end, .. + } => { + let clean = content.replace("", "").replace("", ""); let subagent_marker = if is_subagent { Some(true) } else { None }; let mut s = self.state.write().unwrap(); if !is_subagent { @@ -1695,8 +1702,8 @@ impl RemoteExecutionDispatcher { // start. When BashTool eventually calls get_or_create, the binding already // exists and the 30-second readiness wait is skipped entirely. { - use terminal_core::{TerminalApi, TerminalBindingOptions}; use terminal_core::session::SessionSource; + use terminal_core::{TerminalApi, TerminalBindingOptions}; let sid = session_id.to_string(); let binding_workspace_for_terminal = binding_workspace.clone(); tokio::spawn(async move { @@ -2406,7 +2413,9 @@ impl RemoteServer { assistant_id: w.assistant_id.clone(), }) .collect(); - RemoteResponse::AssistantList { assistants: entries } + RemoteResponse::AssistantList { + assistants: entries, + } } RemoteCommand::SetAssistant { path } => { let ws_service = match get_global_workspace_service() { @@ -2550,14 +2559,15 @@ impl RemoteServer { let agent = resolve_agent_type(agent_type.as_deref()); let is_claw = agent == "Claw"; - let session_name = custom_name - .as_deref() - .filter(|n| !n.is_empty()) - .unwrap_or(match agent { - "Cowork" => "Remote Cowork Session", - "Claw" => "Remote Claw Session", - _ => "Remote Code Session", - }); + let session_name = + custom_name + .as_deref() + .filter(|n| !n.is_empty()) + .unwrap_or(match agent { + "Cowork" => "Remote Cowork Session", + "Claw" => "Remote Claw Session", + _ => "Remote Code Session", + }); let binding_ws_str = if is_claw { // For Claw sessions, get or create default assistant workspace @@ -2573,7 +2583,9 @@ impl RemoteServer { }; let workspaces = ws_service.get_assistant_workspaces().await; - if let Some(default_ws) = workspaces.into_iter().find(|w| w.assistant_id.is_none()) { + if let Some(default_ws) = + workspaces.into_iter().find(|w| w.assistant_id.is_none()) + { Some(default_ws.root_path.to_string_lossy().to_string()) } else { match ws_service.create_assistant_workspace(None).await { diff --git a/src/crates/core/src/service/remote_ssh/manager.rs b/src/crates/core/src/service/remote_ssh/manager.rs index 1e4b0e64..db3260bd 100644 --- a/src/crates/core/src/service/remote_ssh/manager.rs +++ b/src/crates/core/src/service/remote_ssh/manager.rs @@ -4,21 +4,21 @@ use crate::service::remote_ssh::password_vault::SSHPasswordVault; use crate::service::remote_ssh::types::{ - SavedConnection, ServerInfo, SSHConnectionConfig, SSHConnectionResult, SSHAuthMethod, - SSHConfigEntry, SSHConfigLookupResult, + SSHAuthMethod, SSHConfigEntry, SSHConfigLookupResult, SSHConnectionConfig, SSHConnectionResult, + SavedConnection, ServerInfo, }; use anyhow::{anyhow, Context}; +use async_trait::async_trait; use russh::client::{DisconnectReason, Handle, Handler, Msg}; use russh_keys::key::PublicKey; use russh_keys::PublicKeyBase64; use russh_sftp::client::fs::ReadDir; use russh_sftp::client::SftpSession; +#[cfg(feature = "ssh_config")] +use ssh_config::SSHConfig; use std::collections::HashMap; use std::sync::Arc; use tokio::net::TcpStream; -use async_trait::async_trait; -#[cfg(feature = "ssh_config")] -use ssh_config::SSHConfig; /// OpenSSH keyword matching is case-insensitive, but `ssh_config` stores keys as written in the file /// (e.g. `HostName` vs `Hostname`). Resolve by ASCII case-insensitive compare. @@ -177,11 +177,19 @@ impl Handler for SSHHandler { log::debug!("Server key matches expected key for {}:{}", host, port); return Ok(true); } - log::warn!("Server key mismatch for {}:{}. Expected fingerprint: {}, got: {}", - host, port, expected.fingerprint(), server_fingerprint); + log::warn!( + "Server key mismatch for {}:{}. Expected fingerprint: {}, got: {}", + host, + port, + expected.fingerprint(), + server_fingerprint + ); return Err(HandlerError(format!( "Host key mismatch for {}:{}: expected {}, got {}", - host, port, expected.fingerprint(), server_fingerprint + host, + port, + expected.fingerprint(), + server_fingerprint ))); } @@ -200,7 +208,10 @@ impl Handler for SSHHandler { } else { log::warn!( "Host key changed for {}:{}. Expected: {}, got: {}", - host, port, stored_fingerprint, server_fingerprint + host, + port, + stored_fingerprint, + server_fingerprint ); return Err(HandlerError(format!( "Host key changed for {}:{} — stored fingerprint {} does not match server fingerprint {}. \ @@ -220,7 +231,9 @@ impl Handler for SSHHandler { log::debug!("Server key verified via callback for {}:{}", host, port); return Ok(true); } - return Err(HandlerError("Host key rejected by verify callback".to_string())); + return Err(HandlerError( + "Host key rejected by verify callback".to_string(), + )); } // 4. First time connection - accept the key (like standard SSH client's StrictHostKeyChecking=accept-new) @@ -249,7 +262,12 @@ impl Handler for SSHHandler { format!("Connection closed with error: {}", e) } }; - log::warn!("SSH disconnected ({}:{}): {}", self.host.as_deref().unwrap_or("?"), self.port.unwrap_or(22), msg); + log::warn!( + "SSH disconnected ({}:{}): {}", + self.host.as_deref().unwrap_or("?"), + self.port.unwrap_or(22), + msg + ); if let Ok(mut guard) = self.disconnect_reason.lock() { *guard = Some(msg); } @@ -271,7 +289,8 @@ pub struct SSHConnectionManager { known_hosts: Arc>>, known_hosts_path: std::path::PathBuf, /// Remote workspace persistence (multiple workspaces) - remote_workspaces: Arc>>, + remote_workspaces: + Arc>>, remote_workspace_path: std::path::PathBuf, password_vault: std::sync::Arc, } @@ -302,8 +321,8 @@ impl SSHConnectionManager { } let content = tokio::fs::read_to_string(&self.known_hosts_path).await?; - let entries: Vec = serde_json::from_str(&content) - .context("Failed to parse known hosts")?; + let entries: Vec = + serde_json::from_str(&content).context("Failed to parse known hosts")?; let mut guard = self.known_hosts.write().await; for entry in entries { @@ -329,13 +348,23 @@ impl SSHConnectionManager { } /// Add a known host - pub async fn add_known_host(&self, host: String, port: u16, key: &PublicKey) -> anyhow::Result<()> { + pub async fn add_known_host( + &self, + host: String, + port: u16, + key: &PublicKey, + ) -> anyhow::Result<()> { let entry = KnownHostEntry { host: host.clone(), port, key_type: format!("{:?}", key.name()), fingerprint: key.fingerprint(), - public_key: key.public_key_bytes().to_vec().iter().map(|b| format!("{:02x}", b)).collect(), + public_key: key + .public_key_bytes() + .to_vec() + .iter() + .map(|b| format!("{:02x}", b)) + .collect(), }; let key = format!("{}:{}", host, port); @@ -391,8 +420,10 @@ impl SSHConnectionManager { serde_json::from_str(&content) .or_else(|_| { // Legacy: single workspace object - serde_json::from_str::(&content) - .map(|ws| vec![ws]) + serde_json::from_str::( + &content, + ) + .map(|ws| vec![ws]) }) .context("Failed to parse remote workspace(s)")?; @@ -425,7 +456,10 @@ impl SSHConnectionManager { } /// Add/update a persisted remote workspace (key = `connection_id` + `remote_path`). - pub async fn set_remote_workspace(&self, mut workspace: crate::service::remote_ssh::types::RemoteWorkspace) -> anyhow::Result<()> { + pub async fn set_remote_workspace( + &self, + mut workspace: crate::service::remote_ssh::types::RemoteWorkspace, + ) -> anyhow::Result<()> { workspace.remote_path = crate::service::remote_ssh::workspace_state::normalize_remote_workspace_path( &workspace.remote_path, @@ -446,18 +480,28 @@ impl SSHConnectionManager { } /// Get all persisted remote workspaces - pub async fn get_remote_workspaces(&self) -> Vec { + pub async fn get_remote_workspaces( + &self, + ) -> Vec { self.remote_workspaces.read().await.clone() } /// Get first persisted remote workspace (legacy compat) - pub async fn get_remote_workspace(&self) -> Option { + pub async fn get_remote_workspace( + &self, + ) -> Option { self.remote_workspaces.read().await.first().cloned() } /// Remove a specific remote workspace by **connection** + **remote path** (not path alone). - pub async fn remove_remote_workspace(&self, connection_id: &str, remote_path: &str) -> anyhow::Result<()> { - let rp = crate::service::remote_ssh::workspace_state::normalize_remote_workspace_path(remote_path); + pub async fn remove_remote_workspace( + &self, + connection_id: &str, + remote_path: &str, + ) -> anyhow::Result<()> { + let rp = crate::service::remote_ssh::workspace_state::normalize_remote_workspace_path( + remote_path, + ); { let mut guard = self.remote_workspaces.write().await; guard.retain(|w| { @@ -494,14 +538,20 @@ impl SSHConnectionManager { if !ssh_config_path.exists() { log::debug!("SSH config not found at {:?}", ssh_config_path); - return SSHConfigLookupResult { found: false, config: None }; + return SSHConfigLookupResult { + found: false, + config: None, + }; } let config_content = match tokio::fs::read_to_string(&ssh_config_path).await { Ok(c) => c, Err(e) => { log::warn!("Failed to read SSH config: {:?}", e); - return SSHConfigLookupResult { found: false, config: None }; + return SSHConfigLookupResult { + found: false, + config: None, + }; } }; @@ -509,7 +559,10 @@ impl SSHConnectionManager { Ok(c) => c, Err(e) => { log::warn!("Failed to parse SSH config: {:?}", e); - return SSHConfigLookupResult { found: false, config: None }; + return SSHConfigLookupResult { + found: false, + config: None, + }; } }; @@ -518,18 +571,24 @@ impl SSHConnectionManager { if host_settings.is_empty() { log::debug!("No SSH config found for host: {}", host); - return SSHConfigLookupResult { found: false, config: None }; + return SSHConfigLookupResult { + found: false, + config: None, + }; } - log::debug!("Found SSH config for host: {} with {} settings", host, host_settings.len()); + log::debug!( + "Found SSH config for host: {} with {} settings", + host, + host_settings.len() + ); // Canonical OpenSSH names; lookup is case-insensitive (see ssh_cfg_get). let hostname = ssh_cfg_get(&host_settings, "HostName").map(|s| s.to_string()); let user = ssh_cfg_get(&host_settings, "User").map(|s| s.to_string()); - let port = ssh_cfg_get(&host_settings, "Port") - .and_then(|s| s.parse::().ok()); - let identity_file = ssh_cfg_get(&host_settings, "IdentityFile") - .map(|f| shellexpand::tilde(f).to_string()); + let port = ssh_cfg_get(&host_settings, "Port").and_then(|s| s.parse::().ok()); + let identity_file = + ssh_cfg_get(&host_settings, "IdentityFile").map(|f| shellexpand::tilde(f).to_string()); let has_proxy_command = ssh_cfg_has(&host_settings, "ProxyCommand"); @@ -548,7 +607,10 @@ impl SSHConnectionManager { #[cfg(not(feature = "ssh_config"))] pub async fn get_ssh_config(&self, _host: &str) -> SSHConfigLookupResult { - SSHConfigLookupResult { found: false, config: None } + SSHConfigLookupResult { + found: false, + config: None, + } } /// List all hosts defined in ~/.ssh/config @@ -607,8 +669,7 @@ impl SSHConnectionManager { let hostname = ssh_cfg_get(&settings, "HostName").map(|s| s.to_string()); let user = ssh_cfg_get(&settings, "User").map(|s| s.to_string()); - let port = ssh_cfg_get(&settings, "Port") - .and_then(|s| s.parse::().ok()); + let port = ssh_cfg_get(&settings, "Port").and_then(|s| s.parse::().ok()); hosts.push(SSHConfigEntry { host: alias.to_string(), @@ -632,7 +693,11 @@ impl SSHConnectionManager { /// Load saved connections from disk pub async fn load_saved_connections(&self) -> anyhow::Result<()> { - log::info!("load_saved_connections: config_path={:?}, exists={}", self.config_path, self.config_path.exists()); + log::info!( + "load_saved_connections: config_path={:?}, exists={}", + self.config_path, + self.config_path.exists() + ); if !self.config_path.exists() { return Ok(()); @@ -640,8 +705,8 @@ impl SSHConnectionManager { let content = tokio::fs::read_to_string(&self.config_path).await?; log::info!("load_saved_connections: content={}", content); - let saved: Vec = serde_json::from_str(&content) - .context("Failed to parse saved SSH connections")?; + let saved: Vec = + serde_json::from_str(&content).context("Failed to parse saved SSH connections")?; let mut guard = self.saved_connections.write().await; *guard = saved; @@ -663,7 +728,11 @@ impl SSHConnectionManager { } tokio::fs::write(&self.config_path, content).await?; - log::info!("save_connections: saved {} connections to {:?}", guard.len(), self.config_path); + log::info!( + "save_connections: saved {} connections to {:?}", + guard.len(), + self.config_path + ); Ok(()) } @@ -694,7 +763,9 @@ impl SSHConnectionManager { // Remove existing entry with same id OR same host+port+username (dedup) guard.retain(|c| { c.id != config.id - && !(c.host == config.host && c.port == config.port && c.username == config.username) + && !(c.host == config.host + && c.port == config.port + && c.username == config.username) }); // Add new entry @@ -705,7 +776,9 @@ impl SSHConnectionManager { port: config.port, username: config.username.clone(), auth_type: match &config.auth { - SSHAuthMethod::Password { .. } => crate::service::remote_ssh::types::SavedAuthType::Password, + SSHAuthMethod::Password { .. } => { + crate::service::remote_ssh::types::SavedAuthType::Password + } SSHAuthMethod::PrivateKey { key_path, .. } => { crate::service::remote_ssh::types::SavedAuthType::PrivateKey { key_path: key_path.clone(), @@ -736,7 +809,10 @@ impl SSHConnectionManager { } /// Decrypt stored password for password-based saved connections (auto-reconnect). - pub async fn load_stored_password(&self, connection_id: &str) -> anyhow::Result> { + pub async fn load_stored_password( + &self, + connection_id: &str, + ) -> anyhow::Result> { self.password_vault.load(connection_id).await } @@ -765,7 +841,10 @@ impl SSHConnectionManager { /// # Arguments /// * `config` - SSH connection configuration /// * `timeout_secs` - Connection timeout in seconds (default: 30) - pub async fn connect(&self, config: SSHConnectionConfig) -> anyhow::Result { + pub async fn connect( + &self, + config: SSHConnectionConfig, + ) -> anyhow::Result { self.connect_with_timeout(config, 30).await } @@ -789,8 +868,15 @@ impl SSHConnectionManager { // Create SSH transport config let key_pair = match &config.auth { SSHAuthMethod::Password { .. } => None, - SSHAuthMethod::PrivateKey { key_path, passphrase } => { - log::info!("Attempting private key auth with key_path: {}, passphrase provided: {}", key_path, passphrase.is_some()); + SSHAuthMethod::PrivateKey { + key_path, + passphrase, + } => { + log::info!( + "Attempting private key auth with key_path: {}, passphrase provided: {}", + key_path, + passphrase.is_some() + ); // Try to read the specified key file let expanded = shellexpand::tilde(key_path); log::info!("Expanded key path: {}", expanded); @@ -801,12 +887,22 @@ impl SSHConnectionManager { } Err(e) => { // If specified key fails, try default ~/.ssh/id_rsa - log::warn!("Failed to read private key at '{}': {}, trying default ~/.ssh/id_rsa", expanded, e); + log::warn!( + "Failed to read private key at '{}': {}, trying default ~/.ssh/id_rsa", + expanded, + e + ); if let Ok(home) = std::env::var("HOME") { let default_key = format!("{}/.ssh/id_rsa", home); log::info!("Trying default key at: {}", default_key); - std::fs::read_to_string(&default_key) - .map_err(|e| anyhow!("Failed to read private key '{}' and default key '{}': {}", key_path, default_key, e))? + std::fs::read_to_string(&default_key).map_err(|e| { + anyhow!( + "Failed to read private key '{}' and default key '{}': {}", + key_path, + default_key, + e + ) + })? } else { return Err(anyhow!("Failed to read private key '{}': {}, and could not determine home directory", key_path, e)); } @@ -836,8 +932,8 @@ impl SSHConnectionManager { russh::kex::CURVE25519_PRE_RFC_8731, russh::kex::DH_G16_SHA512, russh::kex::DH_G14_SHA256, - russh::kex::DH_G14_SHA1, // legacy servers - russh::kex::DH_G1_SHA1, // very old servers + russh::kex::DH_G14_SHA1, // legacy servers + russh::kex::DH_G1_SHA1, // very old servers russh::kex::EXTENSION_SUPPORT_AS_CLIENT, russh::kex::EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT, ]), @@ -848,7 +944,7 @@ impl SSHConnectionManager { russh_keys::key::ECDSA_SHA2_NISTP521, russh_keys::key::RSA_SHA2_256, russh_keys::key::RSA_SHA2_512, - russh_keys::key::SSH_RSA, // legacy servers that only advertise ssh-rsa + russh_keys::key::SSH_RSA, // legacy servers that only advertise ssh-rsa ]), ..russh::Preferred::DEFAULT }, @@ -904,14 +1000,24 @@ impl SSHConnectionManager { let auth_success: bool = match &config.auth { SSHAuthMethod::Password { password } => { log::debug!("Using password authentication"); - handle.authenticate_password(&config.username, password.clone()).await + handle + .authenticate_password(&config.username, password.clone()) + .await .map_err(|e| anyhow!("Password authentication failed: {:?}", e))? } - SSHAuthMethod::PrivateKey { key_path, passphrase: _ } => { + SSHAuthMethod::PrivateKey { + key_path, + passphrase: _, + } => { log::info!("Using public key authentication with key: {}", key_path); if let Some(ref key) = key_pair { - log::info!("Attempting to authenticate user '{}' with public key", config.username); - let result = handle.authenticate_publickey(&config.username, Arc::new(key.clone())).await; + log::info!( + "Attempting to authenticate user '{}' with public key", + config.username + ); + let result = handle + .authenticate_publickey(&config.username, Arc::new(key.clone())) + .await; log::info!("Public key auth result: {:?}", result); match result { Ok(true) => { @@ -919,7 +1025,10 @@ impl SSHConnectionManager { true } Ok(false) => { - log::warn!("Public key authentication rejected by server for user '{}'", config.username); + log::warn!( + "Public key authentication rejected by server for user '{}'", + config.username + ); false } Err(e) => { @@ -935,7 +1044,10 @@ impl SSHConnectionManager { if !auth_success { log::warn!("Authentication returned false for user {}", config.username); - return Err(anyhow!("Authentication failed for user {}", config.username)); + return Err(anyhow!( + "Authentication failed for user {}", + config.username + )); } log::info!("Authentication successful for user {}", config.username); @@ -985,9 +1097,10 @@ impl SSHConnectionManager { /// Get server information (partial lines allowed so we can still fill `home_dir` via [`Self::probe_remote_home_dir`]). async fn get_server_info_internal(handle: &Handle) -> Option { - let (stdout, _stderr, exit_status) = Self::execute_command_internal(handle, "uname -s && hostname && echo $HOME") - .await - .ok()?; + let (stdout, _stderr, exit_status) = + Self::execute_command_internal(handle, "uname -s && hostname && echo $HOME") + .await + .ok()?; if exit_status != 0 { return None; @@ -1050,7 +1163,9 @@ impl SSHConnectionManager { Some(russh::ChannelMsg::ExtendedData { ref data, .. }) => { stderr.push_str(&String::from_utf8_lossy(data)); } - Some(russh::ChannelMsg::ExitStatus { exit_status: status }) => { + Some(russh::ChannelMsg::ExitStatus { + exit_status: status, + }) => { exit_status = status as i32; } Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) => { @@ -1159,7 +1274,11 @@ impl SSHConnectionManager { // ============================================================================ /// Expand leading `~` using the remote user's home from [`ServerInfo`] (SFTP paths are not shell-expanded). - pub async fn resolve_sftp_path(&self, connection_id: &str, path: &str) -> anyhow::Result { + pub async fn resolve_sftp_path( + &self, + connection_id: &str, + path: &str, + ) -> anyhow::Result { let path = path.trim(); if path.is_empty() { return Err(anyhow!("Empty remote path")); @@ -1215,12 +1334,17 @@ impl SSHConnectionManager { }; // Open a channel and request SFTP subsystem - let channel = handle.channel_open_session().await + let channel = handle + .channel_open_session() + .await .map_err(|e| anyhow!("Failed to open channel for SFTP: {}", e))?; - channel.request_subsystem(true, "sftp").await + channel + .request_subsystem(true, "sftp") + .await .map_err(|e| anyhow!("Failed to request SFTP subsystem: {}", e))?; - let sftp = SftpSession::new(channel.into_stream()).await + let sftp = SftpSession::new(channel.into_stream()) + .await .map_err(|e| anyhow!("Failed to create SFTP session: {}", e))?; let sftp = Arc::new(sftp); @@ -1241,29 +1365,41 @@ impl SSHConnectionManager { pub async fn sftp_read(&self, connection_id: &str, path: &str) -> anyhow::Result> { let path = self.resolve_sftp_path(connection_id, path).await?; let sftp = self.get_sftp(connection_id).await?; - let mut file = sftp.open(&path).await + let mut file = sftp + .open(&path) + .await .map_err(|e| anyhow!("Failed to open remote file '{}': {}", path, e))?; let mut buffer = Vec::new(); use tokio::io::AsyncReadExt; - file.read_to_end(&mut buffer).await + file.read_to_end(&mut buffer) + .await .map_err(|e| anyhow!("Failed to read remote file '{}': {}", path, e))?; Ok(buffer) } /// Write a file via SFTP - pub async fn sftp_write(&self, connection_id: &str, path: &str, content: &[u8]) -> anyhow::Result<()> { + pub async fn sftp_write( + &self, + connection_id: &str, + path: &str, + content: &[u8], + ) -> anyhow::Result<()> { let path = self.resolve_sftp_path(connection_id, path).await?; let sftp = self.get_sftp(connection_id).await?; - let mut file = sftp.create(&path).await + let mut file = sftp + .create(&path) + .await .map_err(|e| anyhow!("Failed to create remote file '{}': {}", path, e))?; use tokio::io::AsyncWriteExt; - file.write_all(content).await + file.write_all(content) + .await .map_err(|e| anyhow!("Failed to write remote file '{}': {}", path, e))?; - file.flush().await + file.flush() + .await .map_err(|e| anyhow!("Failed to flush remote file '{}': {}", path, e))?; Ok(()) @@ -1273,7 +1409,9 @@ impl SSHConnectionManager { pub async fn sftp_read_dir(&self, connection_id: &str, path: &str) -> anyhow::Result { let path = self.resolve_sftp_path(connection_id, path).await?; let sftp = self.get_sftp(connection_id).await?; - let entries = sftp.read_dir(&path).await + let entries = sftp + .read_dir(&path) + .await .map_err(|e| anyhow!("Failed to read directory '{}': {}", path, e))?; Ok(entries) } @@ -1282,7 +1420,8 @@ impl SSHConnectionManager { pub async fn sftp_mkdir(&self, connection_id: &str, path: &str) -> anyhow::Result<()> { let path = self.resolve_sftp_path(connection_id, path).await?; let sftp = self.get_sftp(connection_id).await?; - sftp.create_dir(&path).await + sftp.create_dir(&path) + .await .map_err(|e| anyhow!("Failed to create directory '{}': {}", path, e))?; Ok(()) } @@ -1300,7 +1439,9 @@ impl SSHConnectionManager { } // Try to create - sftp.as_ref().create_dir(&path).await + sftp.as_ref() + .create_dir(&path) + .await .map_err(|e| anyhow!("Failed to create directory '{}': {}", path, e))?; Ok(()) } @@ -1309,7 +1450,8 @@ impl SSHConnectionManager { pub async fn sftp_remove(&self, connection_id: &str, path: &str) -> anyhow::Result<()> { let path = self.resolve_sftp_path(connection_id, path).await?; let sftp = self.get_sftp(connection_id).await?; - sftp.remove_file(&path).await + sftp.remove_file(&path) + .await .map_err(|e| anyhow!("Failed to remove file '{}': {}", path, e))?; Ok(()) } @@ -1318,17 +1460,24 @@ impl SSHConnectionManager { pub async fn sftp_rmdir(&self, connection_id: &str, path: &str) -> anyhow::Result<()> { let path = self.resolve_sftp_path(connection_id, path).await?; let sftp = self.get_sftp(connection_id).await?; - sftp.remove_dir(&path).await + sftp.remove_dir(&path) + .await .map_err(|e| anyhow!("Failed to remove directory '{}': {}", path, e))?; Ok(()) } /// Rename/move via SFTP - pub async fn sftp_rename(&self, connection_id: &str, old_path: &str, new_path: &str) -> anyhow::Result<()> { + pub async fn sftp_rename( + &self, + connection_id: &str, + old_path: &str, + new_path: &str, + ) -> anyhow::Result<()> { let old_path = self.resolve_sftp_path(connection_id, old_path).await?; let new_path = self.resolve_sftp_path(connection_id, new_path).await?; let sftp = self.get_sftp(connection_id).await?; - sftp.rename(&old_path, &new_path).await + sftp.rename(&old_path, &new_path) + .await .map_err(|e| anyhow!("Failed to rename '{}' to '{}': {}", old_path, new_path, e))?; Ok(()) } @@ -1337,15 +1486,23 @@ impl SSHConnectionManager { pub async fn sftp_exists(&self, connection_id: &str, path: &str) -> anyhow::Result { let path = self.resolve_sftp_path(connection_id, path).await?; let sftp = self.get_sftp(connection_id).await?; - sftp.as_ref().try_exists(&path).await + sftp.as_ref() + .try_exists(&path) + .await .map_err(|e| anyhow!("Failed to check if '{}' exists: {}", path, e)) } /// Get file metadata via SFTP - pub async fn sftp_stat(&self, connection_id: &str, path: &str) -> anyhow::Result { + pub async fn sftp_stat( + &self, + connection_id: &str, + path: &str, + ) -> anyhow::Result { let path = self.resolve_sftp_path(connection_id, path).await?; let sftp = self.get_sftp(connection_id).await?; - sftp.as_ref().metadata(&path).await + sftp.as_ref() + .metadata(&path) + .await .map_err(|e| anyhow!("Failed to stat '{}': {}", path, e)) } @@ -1366,23 +1523,22 @@ impl SSHConnectionManager { .ok_or_else(|| anyhow!("Connection {} not found", connection_id))?; // Open a session channel - let channel = conn.handle.channel_open_session().await + let channel = conn + .handle + .channel_open_session() + .await .map_err(|e| anyhow!("Failed to open channel: {}", e))?; // Request PTY — `false` = don't wait for reply (reply handled in reader loop) - channel.request_pty( - false, - "xterm-256color", - cols, - rows, - 0, - 0, - &[], - ).await + channel + .request_pty(false, "xterm-256color", cols, rows, 0, 0, &[]) + .await .map_err(|e| anyhow!("Failed to request PTY: {}", e))?; // Start shell — `false` = don't wait for reply - channel.request_shell(false).await + channel + .request_shell(false) + .await .map_err(|e| anyhow!("Failed to start shell: {}", e))?; Ok(PTYSession { @@ -1401,7 +1557,10 @@ impl SSHConnectionManager { // Return a fingerprint based on connection info // Note: Actual server key fingerprint requires access to the SSH transport layer // For security verification, the server key is verified during connection via SSHHandler - let fingerprint = format!("{}:{}:{}", conn.config.host, conn.config.port, conn.config.username); + let fingerprint = format!( + "{}:{}:{}", + conn.config.host, conn.config.port, conn.config.username + ); Ok(fingerprint) } } @@ -1429,7 +1588,9 @@ impl PTYSession { /// Write data to PTY pub async fn write(&self, data: &[u8]) -> anyhow::Result<()> { let channel = self.channel.lock().await; - channel.data(data).await + channel + .data(data) + .await .map_err(|e| anyhow!("Failed to write to PTY: {}", e))?; Ok(()) } @@ -1438,7 +1599,9 @@ impl PTYSession { pub async fn resize(&self, cols: u32, rows: u32) -> anyhow::Result<()> { let channel = self.channel.lock().await; // Use default pixel dimensions (80x24 characters) - channel.window_change(cols, rows, 0, 0).await + channel + .window_change(cols, rows, 0, 0) + .await .map_err(|e| anyhow!("Failed to resize PTY: {}", e))?; Ok(()) } @@ -1451,7 +1614,9 @@ impl PTYSession { loop { match channel.wait().await { Some(russh::ChannelMsg::Data { data }) => return Ok(Some(data.to_vec())), - Some(russh::ChannelMsg::ExtendedData { data, .. }) => return Ok(Some(data.to_vec())), + Some(russh::ChannelMsg::ExtendedData { data, .. }) => { + return Ok(Some(data.to_vec())) + } Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) => return Ok(None), Some(russh::ChannelMsg::ExitStatus { .. }) => return Ok(None), Some(_) => { @@ -1466,9 +1631,13 @@ impl PTYSession { /// Close PTY session pub async fn close(self) -> anyhow::Result<()> { let channel = self.channel.lock().await; - channel.eof().await + channel + .eof() + .await .map_err(|e| anyhow!("Failed to close PTY: {}", e))?; - channel.close().await + channel + .close() + .await .map_err(|e| anyhow!("Failed to close channel: {}", e))?; Ok(()) } @@ -1495,8 +1664,8 @@ pub struct PortForward { #[derive(Debug, Clone, Copy, PartialEq)] pub enum PortForwardDirection { - Local, // -L: forward local port to remote - Remote, // -R: forward remote port to local + Local, // -L: forward local port to remote + Remote, // -R: forward remote port to local Dynamic, // -D: dynamic SOCKS proxy } @@ -1555,8 +1724,12 @@ impl PortForwardManager { let mut guard = self.forwards.write().await; guard.insert(id.clone(), forward); - log::info!("[TODO] Local port forward registered: localhost:{} -> {}:{}", - local_port, remote_host, remote_port); + log::info!( + "[TODO] Local port forward registered: localhost:{} -> {}:{}", + local_port, + remote_host, + remote_port + ); log::warn!("Port forwarding is not fully implemented - connections will not be forwarded"); Ok(id) @@ -1592,8 +1765,12 @@ impl PortForwardManager { let mut guard = self.forwards.write().await; guard.insert(id.clone(), forward); - log::info!("Started remote port forward (placeholder): *:{} -> {}:{}", - remote_port, local_host, local_port); + log::info!( + "Started remote port forward (placeholder): *:{} -> {}:{}", + remote_port, + local_host, + local_port + ); // TODO: Implement actual SSH reverse port forwarding log::warn!("Remote port forwarding is not fully implemented - data will not be forwarded"); @@ -1605,7 +1782,8 @@ impl PortForwardManager { pub async fn stop_forward(&self, forward_id: &str) -> anyhow::Result<()> { let mut guard = self.forwards.write().await; if let Some(forward) = guard.remove(forward_id) { - log::info!("Stopped port forward: {} ({}:{} -> {}:{})", + log::info!( + "Stopped port forward: {} ({}:{} -> {}:{})", forward.id, match forward.direction { PortForwardDirection::Local => "local", @@ -1614,7 +1792,8 @@ impl PortForwardManager { }, forward.local_port, forward.remote_host, - forward.remote_port); + forward.remote_port + ); } Ok(()) } diff --git a/src/crates/core/src/service/remote_ssh/mod.rs b/src/crates/core/src/service/remote_ssh/mod.rs index 5d749c1d..ed662518 100644 --- a/src/crates/core/src/service/remote_ssh/mod.rs +++ b/src/crates/core/src/service/remote_ssh/mod.rs @@ -12,16 +12,16 @@ pub mod types; pub mod workspace_state; pub use manager::{ - KnownHostEntry, PortForward, PortForwardDirection, PortForwardManager, PTYSession, + KnownHostEntry, PTYSession, PortForward, PortForwardDirection, PortForwardManager, SSHConnectionManager, }; pub use remote_fs::RemoteFileService; pub use remote_terminal::{RemoteTerminalManager, RemoteTerminalSession, SessionStatus}; pub use types::*; pub use workspace_state::{ - canonicalize_local_workspace_root, get_remote_workspace_manager, - init_remote_workspace_manager, is_remote_path, is_remote_workspace_active, - local_workspace_roots_equal, local_workspace_stable_storage_id, lookup_remote_connection, + canonicalize_local_workspace_root, get_remote_workspace_manager, init_remote_workspace_manager, + is_remote_path, is_remote_workspace_active, local_workspace_roots_equal, + local_workspace_stable_storage_id, lookup_remote_connection, lookup_remote_connection_with_hint, normalize_local_workspace_root_for_stable_id, normalize_remote_workspace_path, remote_workspace_stable_id, workspace_logical_key, RemoteWorkspaceEntry, RemoteWorkspaceState, RemoteWorkspaceStateManager, diff --git a/src/crates/core/src/service/remote_ssh/password_vault.rs b/src/crates/core/src/service/remote_ssh/password_vault.rs index 6fdc052a..7f85301d 100644 --- a/src/crates/core/src/service/remote_ssh/password_vault.rs +++ b/src/crates/core/src/service/remote_ssh/password_vault.rs @@ -58,10 +58,8 @@ impl SSHPasswordVault { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions( - &self.key_path, - std::fs::Permissions::from_mode(0o600), - ); + let _ = + std::fs::set_permissions(&self.key_path, std::fs::Permissions::from_mode(0o600)); } Ok(key) } @@ -117,10 +115,8 @@ impl SSHPasswordVault { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions( - &self.vault_path, - std::fs::Permissions::from_mode(0o600), - ); + let _ = + std::fs::set_permissions(&self.vault_path, std::fs::Permissions::from_mode(0o600)); } Ok(()) } @@ -164,17 +160,15 @@ impl SSHPasswordVault { if !self.vault_path.exists() { return Ok(()); } - let s = tokio::fs::read_to_string(&self.vault_path).await.unwrap_or_default(); + let s = tokio::fs::read_to_string(&self.vault_path) + .await + .unwrap_or_default(); let mut file: VaultFile = serde_json::from_str(&s).unwrap_or_default(); file.entries.remove(connection_id); if file.entries.is_empty() { let _ = tokio::fs::remove_file(&self.vault_path).await; } else { - tokio::fs::write( - &self.vault_path, - serde_json::to_string_pretty(&file)?, - ) - .await?; + tokio::fs::write(&self.vault_path, serde_json::to_string_pretty(&file)?).await?; } Ok(()) } diff --git a/src/crates/core/src/service/remote_ssh/remote_fs.rs b/src/crates/core/src/service/remote_ssh/remote_fs.rs index 47ded685..cf89b49c 100644 --- a/src/crates/core/src/service/remote_ssh/remote_fs.rs +++ b/src/crates/core/src/service/remote_ssh/remote_fs.rs @@ -30,20 +30,27 @@ fn should_skip_dir_in_prompt_preview(name: &str) -> bool { /// Remote file service using SFTP protocol #[derive(Clone)] pub struct RemoteFileService { - manager: Arc>>, + manager: + Arc>>, } impl RemoteFileService { pub fn new( - manager: Arc>>, + manager: Arc< + tokio::sync::RwLock>, + >, ) -> Self { Self { manager } } /// Get the SSH manager - async fn get_manager(&self, _connection_id: &str) -> anyhow::Result { + async fn get_manager( + &self, + _connection_id: &str, + ) -> anyhow::Result { let guard = self.manager.read().await; - guard.as_ref() + guard + .as_ref() .cloned() .ok_or_else(|| anyhow!("SSH manager not initialized")) } @@ -55,7 +62,12 @@ impl RemoteFileService { } /// Write content to a remote file via SFTP - pub async fn write_file(&self, connection_id: &str, path: &str, content: &[u8]) -> anyhow::Result<()> { + pub async fn write_file( + &self, + connection_id: &str, + path: &str, + content: &[u8], + ) -> anyhow::Result<()> { let manager = self.get_manager(connection_id).await?; manager.sftp_write(connection_id, path, content).await } @@ -83,7 +95,11 @@ impl RemoteFileService { } /// Read directory contents via SFTP - pub async fn read_dir(&self, connection_id: &str, path: &str) -> anyhow::Result> { + pub async fn read_dir( + &self, + connection_id: &str, + path: &str, + ) -> anyhow::Result> { let manager = self.get_manager(connection_id).await?; let path_resolved = manager.resolve_sftp_path(connection_id, path).await?; let mut entries = manager.sftp_read_dir(connection_id, path).await?; @@ -166,12 +182,10 @@ impl RemoteFileService { true } }); - entries.sort_by(|a, b| { - match (a.is_dir, b.is_dir) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => a.name.cmp(&b.name), - } + entries.sort_by(|a, b| match (a.is_dir, b.is_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.name.cmp(&b.name), }); entries.truncate(MAX_ENTRIES); @@ -247,7 +261,9 @@ impl RemoteFileService { &entry.path, current_depth + 1, max_depth, - )).await { + )) + .await + { Ok(child) => children.push(child), Err(_) => { children.push(RemoteTreeNode { @@ -326,7 +342,11 @@ impl RemoteFileService { } /// Get file metadata via SFTP - pub async fn stat(&self, connection_id: &str, path: &str) -> anyhow::Result> { + pub async fn stat( + &self, + connection_id: &str, + path: &str, + ) -> anyhow::Result> { let manager = self.get_manager(connection_id).await?; match manager.sftp_stat(connection_id, path).await { @@ -368,13 +388,13 @@ fn format_permissions(mode: Option) -> String { }; let file_type = match mode & 0o170000 { - 0o040000 => 'd', // directory - 0o120000 => 'l', // symbolic link - 0o060000 => 'b', // block device - 0o020000 => 'c', // character device - 0o010000 => 'p', // FIFO - 0o140000 => 's', // socket - _ => '-', // regular file + 0o040000 => 'd', // directory + 0o120000 => 'l', // symbolic link + 0o060000 => 'b', // block device + 0o020000 => 'c', // character device + 0o010000 => 'p', // FIFO + 0o140000 => 's', // socket + _ => '-', // regular file }; let perms = [ @@ -389,7 +409,8 @@ fn format_permissions(mode: Option) -> String { (mode & 0o001 != 0, 'x'), ]; - let perm_str: String = perms.iter() + let perm_str: String = perms + .iter() .map(|(set, c)| if *set { *c } else { '-' }) .collect(); diff --git a/src/crates/core/src/service/remote_ssh/remote_terminal.rs b/src/crates/core/src/service/remote_ssh/remote_terminal.rs index 8b8f6e10..d1533661 100644 --- a/src/crates/core/src/service/remote_ssh/remote_terminal.rs +++ b/src/crates/core/src/service/remote_ssh/remote_terminal.rs @@ -20,7 +20,9 @@ use tokio::time::{timeout, Duration}; const REMOTE_PWD_PROBE_TIMEOUT: Duration = Duration::from_secs(5); fn shell_escape(s: &str) -> String { - if s.chars().all(|c| c.is_alphanumeric() || c == '/' || c == '.' || c == '-' || c == '_') { + if s.chars() + .all(|c| c.is_alphanumeric() || c == '/' || c == '.' || c == '-' || c == '_') + { s.to_string() } else { format!("'{}'", s.replace('\'', "'\\''")) @@ -104,9 +106,12 @@ impl RemoteTerminalManager { let name = name.unwrap_or_else(|| format!("Remote Terminal {}", &session_id[..8])); // Open PTY via manager, then extract the raw Channel - let pty = manager.open_pty(connection_id, cols as u32, rows as u32).await?; - let mut channel = pty.into_channel().await - .ok_or_else(|| anyhow::anyhow!("Failed to extract channel from PTYSession — multiple references exist"))?; + let pty = manager + .open_pty(connection_id, cols as u32, rows as u32) + .await?; + let mut channel = pty.into_channel().await.ok_or_else(|| { + anyhow::anyhow!("Failed to extract channel from PTYSession — multiple references exist") + })?; let cwd = if let Some(dir) = initial_cwd { dir.to_string() @@ -173,10 +178,13 @@ impl RemoteTerminalManager { } { let mut handles = self.handles.write().await; - handles.insert(session_id.clone(), ActiveHandle { - output_tx: output_tx.clone(), - cmd_tx, - }); + handles.insert( + session_id.clone(), + ActiveHandle { + output_tx: output_tx.clone(), + cmd_tx, + }, + ); } let mut writer = channel.make_writer(); @@ -186,7 +194,10 @@ impl RemoteTerminalManager { let task_sessions = self.sessions.clone(); tokio::spawn(async move { - log::info!("Remote PTY owner task started: session_id={}", task_session_id); + log::info!( + "Remote PTY owner task started: session_id={}", + task_session_id + ); // cd to workspace directory silently (avoid `/` default — some hosts block listing `/`) if initial_cd != "/" && !initial_cd.is_empty() { @@ -264,7 +275,10 @@ impl RemoteTerminalManager { s.status = SessionStatus::Closed; } } - log::info!("Remote PTY owner task exited: session_id={}", task_session_id); + log::info!( + "Remote PTY owner task exited: session_id={}", + task_session_id + ); }); Ok(CreateSessionResult { session, output_rx }) @@ -275,7 +289,10 @@ impl RemoteTerminalManager { } pub async fn list_sessions(&self) -> Vec { - self.sessions.read().await.values() + self.sessions + .read() + .await + .values() .filter(|s| s.status != SessionStatus::Closed) .cloned() .collect() @@ -283,8 +300,13 @@ impl RemoteTerminalManager { pub async fn write(&self, session_id: &str, data: &[u8]) -> anyhow::Result<()> { let handles = self.handles.read().await; - let handle = handles.get(session_id).context("Session not found or PTY not active")?; - handle.cmd_tx.send(PtyCommand::Write(data.to_vec())).await + let handle = handles + .get(session_id) + .context("Session not found or PTY not active")?; + handle + .cmd_tx + .send(PtyCommand::Write(data.to_vec())) + .await .map_err(|_| anyhow::anyhow!("PTY task has exited")) } @@ -298,7 +320,10 @@ impl RemoteTerminalManager { } let handles = self.handles.read().await; if let Some(handle) = handles.get(session_id) { - handle.cmd_tx.send(PtyCommand::Resize(cols as u32, rows as u32)).await + handle + .cmd_tx + .send(PtyCommand::Resize(cols as u32, rows as u32)) + .await .map_err(|_| anyhow::anyhow!("PTY task has exited"))?; } Ok(()) @@ -324,9 +349,14 @@ impl RemoteTerminalManager { self.handles.read().await.contains_key(session_id) } - pub async fn subscribe_output(&self, session_id: &str) -> anyhow::Result>> { + pub async fn subscribe_output( + &self, + session_id: &str, + ) -> anyhow::Result>> { let handles = self.handles.read().await; - let handle = handles.get(session_id).context("Session not found or PTY not active")?; + let handle = handles + .get(session_id) + .context("Session not found or PTY not active")?; Ok(handle.output_tx.subscribe()) } } diff --git a/src/crates/core/src/service/remote_ssh/types.rs b/src/crates/core/src/service/remote_ssh/types.rs index 48c486ba..7003bff9 100644 --- a/src/crates/core/src/service/remote_ssh/types.rs +++ b/src/crates/core/src/service/remote_ssh/types.rs @@ -51,9 +51,7 @@ pub struct SSHConnectionConfig { #[serde(tag = "type")] pub enum SSHAuthMethod { /// Password authentication - Password { - password: String, - }, + Password { password: String }, /// Private key authentication PrivateKey { /// Path to private key file on local machine diff --git a/src/crates/core/src/service/remote_ssh/workspace_state.rs b/src/crates/core/src/service/remote_ssh/workspace_state.rs index 070212a0..93885c53 100644 --- a/src/crates/core/src/service/remote_ssh/workspace_state.rs +++ b/src/crates/core/src/service/remote_ssh/workspace_state.rs @@ -64,7 +64,8 @@ pub fn workspace_session_identity( }); } - let local_root = normalize_local_workspace_root_for_stable_id(Path::new(workspace_path)).ok()?; + let local_root = + normalize_local_workspace_root_for_stable_id(Path::new(workspace_path)).ok()?; Some(WorkspaceSessionIdentity { hostname: LOCAL_WORKSPACE_SSH_HOST.to_string(), workspace_path: local_root, @@ -88,7 +89,9 @@ pub async fn resolve_workspace_session_identity( return workspace_session_identity(workspace_path, Some(connection_id), Some(host)); } - if let Some(entry) = lookup_remote_connection_with_hint(workspace_path, Some(connection_id)).await { + if let Some(entry) = + lookup_remote_connection_with_hint(workspace_path, Some(connection_id)).await + { return Some(WorkspaceSessionIdentity { hostname: entry.ssh_host, workspace_path: entry.remote_root, @@ -264,7 +267,10 @@ pub fn remote_workspace_stable_id(ssh_host: &str, remote_root_norm: &str) -> Str /// legacy per-connection tree (it is not the same layout as `remote_ssh/{host}/.../sessions`). /// This returns a dedicated stub under `~/.bitfun/remote_ssh/_unresolved/.../sessions` that is /// usually absent, so session listing is empty until host can be resolved. -pub fn unresolved_remote_session_storage_dir(connection_id: &str, workspace_path_norm: &str) -> PathBuf { +pub fn unresolved_remote_session_storage_dir( + connection_id: &str, + workspace_path_norm: &str, +) -> PathBuf { let mut hasher = Sha256::new(); hasher.update(b"unresolved_remote_session\x01"); hasher.update(connection_id.trim().as_bytes()); @@ -387,9 +393,7 @@ impl RemoteWorkspaceStateManager { let remote_root = normalize_remote_workspace_path(&remote_path); let ssh_host = ssh_host.trim().to_string(); let mut guard = self.registrations.write().await; - guard.retain(|r| { - !(r.connection_id == connection_id && r.remote_root == remote_root) - }); + guard.retain(|r| !(r.connection_id == connection_id && r.remote_root == remote_root)); guard.push(RegisteredRemoteWorkspace { connection_id, remote_root, @@ -494,13 +498,8 @@ impl RemoteWorkspaceStateManager { remote_path: String, connection_name: String, ) { - self.register_remote_workspace( - remote_path, - connection_id, - connection_name, - String::new(), - ) - .await; + self.register_remote_workspace(remote_path, connection_id, connection_name, String::new()) + .await; } /// **Compat** — old code calls `deactivate_remote_workspace`. @@ -553,7 +552,11 @@ impl RemoteWorkspaceStateManager { // ── Session storage ──────────────────────────────────────────── /// Local mirror directory for persisted sessions (`~/.bitfun/remote_ssh/.../sessions`). - pub fn get_remote_session_mirror_path(&self, ssh_host: &str, remote_root_norm: &str) -> PathBuf { + pub fn get_remote_session_mirror_path( + &self, + ssh_host: &str, + remote_root_norm: &str, + ) -> PathBuf { remote_workspace_session_mirror_dir(ssh_host, remote_root_norm) } @@ -566,7 +569,9 @@ impl RemoteWorkspaceStateManager { remote_connection_id: Option<&str>, remote_ssh_host: Option<&str>, ) -> PathBuf { - let remote_id = remote_connection_id.map(str::trim).filter(|s| !s.is_empty()); + let remote_id = remote_connection_id + .map(str::trim) + .filter(|s| !s.is_empty()); if remote_id.is_none() { return PathBuf::from(workspace_path); } @@ -574,10 +579,7 @@ impl RemoteWorkspaceStateManager { if let Some(host) = remote_ssh_host.map(str::trim).filter(|s| !s.is_empty()) { return remote_workspace_session_mirror_dir(host, &path_norm); } - if let Some(entry) = self - .lookup_connection(workspace_path, remote_id) - .await - { + if let Some(entry) = self.lookup_connection(workspace_path, remote_id).await { if !entry.ssh_host.trim().is_empty() { return remote_workspace_session_mirror_dir(&entry.ssh_host, &entry.remote_root); } @@ -615,16 +617,16 @@ pub async fn get_effective_session_path( remote_connection_id: Option<&str>, remote_ssh_host: Option<&str>, ) -> std::path::PathBuf { - if let Some(identity) = resolve_workspace_session_identity( - workspace_path, - remote_connection_id, - remote_ssh_host, - ) - .await + if let Some(identity) = + resolve_workspace_session_identity(workspace_path, remote_connection_id, remote_ssh_host) + .await { if identity.hostname == "_unresolved" { if let Some(connection_id) = identity.remote_connection_id.as_deref() { - return unresolved_remote_session_storage_dir(connection_id, &identity.workspace_path); + return unresolved_remote_session_storage_dir( + connection_id, + &identity.workspace_path, + ); } } return identity.session_storage_path(); @@ -681,8 +683,13 @@ mod tests { .to_string_lossy() .to_string(); let m = super::RemoteWorkspaceStateManager::new(); - m.register_remote_workspace("/".to_string(), "conn".to_string(), "S".to_string(), "h1".to_string()) - .await; + m.register_remote_workspace( + "/".to_string(), + "conn".to_string(), + "S".to_string(), + "h1".to_string(), + ) + .await; assert!( m.lookup_connection(&assistant_path, None).await.is_none(), "assistant workspace must not bind to SSH when remote_connection_id is omitted" @@ -712,10 +719,12 @@ mod tests { "host-b".to_string(), ) .await; - m.set_active_connection_hint(Some("conn-a".to_string())).await; + m.set_active_connection_hint(Some("conn-a".to_string())) + .await; let a = m.lookup_connection("/tmp", None).await.unwrap(); assert_eq!(a.connection_id, "conn-a"); - m.set_active_connection_hint(Some("conn-b".to_string())).await; + m.set_active_connection_hint(Some("conn-b".to_string())) + .await; let b = m.lookup_connection("/tmp", None).await.unwrap(); assert_eq!(b.connection_id, "conn-b"); } @@ -723,10 +732,20 @@ mod tests { #[tokio::test] async fn preferred_connection_wins_over_hint() { let m = super::RemoteWorkspaceStateManager::new(); - m.register_remote_workspace("/".to_string(), "c1".to_string(), "A".to_string(), "h1".to_string()) - .await; - m.register_remote_workspace("/".to_string(), "c2".to_string(), "B".to_string(), "h1".to_string()) - .await; + m.register_remote_workspace( + "/".to_string(), + "c1".to_string(), + "A".to_string(), + "h1".to_string(), + ) + .await; + m.register_remote_workspace( + "/".to_string(), + "c2".to_string(), + "B".to_string(), + "h1".to_string(), + ) + .await; m.set_active_connection_hint(Some("c1".to_string())).await; let x = m.lookup_connection("/x", Some("c2")).await.unwrap(); assert_eq!(x.connection_id, "c2"); diff --git a/src/crates/core/src/service/snapshot/isolation_manager.rs b/src/crates/core/src/service/snapshot/isolation_manager.rs index 381e5741..a89ea31d 100644 --- a/src/crates/core/src/service/snapshot/isolation_manager.rs +++ b/src/crates/core/src/service/snapshot/isolation_manager.rs @@ -1,4 +1,4 @@ -use crate::service::snapshot::types::{SnapshotError, SnapshotResult}; +use crate::service::snapshot::types::{SnapshotError, SnapshotResult}; use log::{debug, info}; use std::fs::{self, OpenOptions}; use std::io::Write; @@ -97,12 +97,11 @@ impl IsolationManager { /// Verifies no Git operations are impacted. async fn verify_no_git_operations(&self) -> SnapshotResult<()> { let git_dir = self.workspace_dir.join(".git"); - if git_dir.exists() - && self.bitfun_dir.starts_with(&git_dir) { - return Err(SnapshotError::GitIsolationFailure( - ".bitfun directory should not be inside .git directory".to_string(), - )); - } + if git_dir.exists() && self.bitfun_dir.starts_with(&git_dir) { + return Err(SnapshotError::GitIsolationFailure( + ".bitfun directory should not be inside .git directory".to_string(), + )); + } self.verify_isolation_integrity().await?; diff --git a/src/crates/core/src/service/workspace/context_generator.rs b/src/crates/core/src/service/workspace/context_generator.rs index 31c6b6cc..c67b495a 100644 --- a/src/crates/core/src/service/workspace/context_generator.rs +++ b/src/crates/core/src/service/workspace/context_generator.rs @@ -552,13 +552,14 @@ impl WorkspaceContextGenerator { in_package = false; } - if in_package && line.contains('=') + if in_package + && line.contains('=') && (line.starts_with("name") || line.starts_with("description") || line.starts_with("version")) - { - info.push_str(&format!("{}\n", line)); - } + { + info.push_str(&format!("{}\n", line)); + } } if !info.is_empty() { diff --git a/src/crates/core/tests/remote_mcp_streamable_http.rs b/src/crates/core/tests/remote_mcp_streamable_http.rs index 2c45d6d0..fd8d4b95 100644 --- a/src/crates/core/tests/remote_mcp_streamable_http.rs +++ b/src/crates/core/tests/remote_mcp_streamable_http.rs @@ -76,9 +76,7 @@ async fn post_handler( state.saw_roots_capability.store(true, Ordering::SeqCst); } if capabilities.get("sampling").is_some() { - state - .saw_sampling_capability - .store(true, Ordering::SeqCst); + state.saw_sampling_capability.store(true, Ordering::SeqCst); } if capabilities.get("elicitation").is_some() { state @@ -189,14 +187,9 @@ async fn remote_mcp_streamable_http_accepts_202_and_delivers_response_via_sse() }); let url = format!("http://{addr}/mcp"); - let connection = MCPConnection::new_remote( - "test-server", - url, - Default::default(), - false, - ) - .await - .expect("remote connection should be created"); + let connection = MCPConnection::new_remote("test-server", url, Default::default(), false) + .await + .expect("remote connection should be created"); connection .initialize("BitFunTest", "0.0.0") diff --git a/src/crates/webdriver/src/executor/element/actions.rs b/src/crates/webdriver/src/executor/element/actions.rs index 9f208d43..cbc010f5 100644 --- a/src/crates/webdriver/src/executor/element/actions.rs +++ b/src/crates/webdriver/src/executor/element/actions.rs @@ -48,9 +48,12 @@ impl BridgeExecutor { &self, element_id: &str, ) -> Result { - let metadata = - api::element::exec_screenshot_metadata(self.state.clone(), &self.session.id, element_id) - .await?; + let metadata = api::element::exec_screenshot_metadata( + self.state.clone(), + &self.session.id, + element_id, + ) + .await?; let metadata: ElementScreenshotMetadata = serde_json::from_value(metadata).map_err(|error| { WebDriverErrorResponse::unknown_error(format!( diff --git a/src/crates/webdriver/src/executor/element/lookup.rs b/src/crates/webdriver/src/executor/element/lookup.rs index db1bbb9b..06051a34 100644 --- a/src/crates/webdriver/src/executor/element/lookup.rs +++ b/src/crates/webdriver/src/executor/element/lookup.rs @@ -28,7 +28,7 @@ impl BridgeExecutor { strategy.as_str(), value, ) - .await?; + .await?; if !result.is_empty() || Instant::now() >= deadline { return Ok(result); diff --git a/src/crates/webdriver/src/executor/element/read.rs b/src/crates/webdriver/src/executor/element/read.rs index 8db132a8..4fae2f1f 100644 --- a/src/crates/webdriver/src/executor/element/read.rs +++ b/src/crates/webdriver/src/executor/element/read.rs @@ -76,7 +76,10 @@ impl BridgeExecutor { .await } - pub async fn get_element_text(&self, element_id: &str) -> Result { + pub async fn get_element_text( + &self, + element_id: &str, + ) -> Result { api::element::exec_element_value( self.state.clone(), &self.session.id, @@ -112,7 +115,10 @@ impl BridgeExecutor { .await } - pub async fn get_element_name(&self, element_id: &str) -> Result { + pub async fn get_element_name( + &self, + element_id: &str, + ) -> Result { api::element::exec_element_value( self.state.clone(), &self.session.id, @@ -122,7 +128,10 @@ impl BridgeExecutor { .await } - pub async fn get_element_rect(&self, element_id: &str) -> Result { + pub async fn get_element_rect( + &self, + element_id: &str, + ) -> Result { api::element::exec_element_value( self.state.clone(), &self.session.id, diff --git a/src/crates/webdriver/src/executor/element/shadow.rs b/src/crates/webdriver/src/executor/element/shadow.rs index 8c74480a..a104ddd8 100644 --- a/src/crates/webdriver/src/executor/element/shadow.rs +++ b/src/crates/webdriver/src/executor/element/shadow.rs @@ -39,11 +39,7 @@ impl BridgeExecutor { &self, element_id: &str, ) -> Result<(), WebDriverErrorResponse> { - api::element::exec_validate_frame_element( - self.state.clone(), - &self.session.id, - element_id, - ) - .await + api::element::exec_validate_frame_element(self.state.clone(), &self.session.id, element_id) + .await } } diff --git a/src/crates/webdriver/src/executor/navigation.rs b/src/crates/webdriver/src/executor/navigation.rs index 123d2fd0..1caec6ec 100644 --- a/src/crates/webdriver/src/executor/navigation.rs +++ b/src/crates/webdriver/src/executor/navigation.rs @@ -81,7 +81,8 @@ impl BridgeExecutor { &self.session.id, api::navigation::ready_state(), ) - .await { + .await + { Ok(Value::String(ready_state)) if ready_state == "complete" => return Ok(()), Ok(_) => {} Err(error) if should_retry_page_load(&error) => {} diff --git a/src/crates/webdriver/src/executor/session.rs b/src/crates/webdriver/src/executor/session.rs index d3fcc469..a9b90e9b 100644 --- a/src/crates/webdriver/src/executor/session.rs +++ b/src/crates/webdriver/src/executor/session.rs @@ -13,10 +13,7 @@ impl BridgeExecutor { Ok(cookies.iter().map(to_webdriver_cookie).collect()) } - pub async fn get_cookie( - &self, - name: &str, - ) -> Result, WebDriverErrorResponse> { + pub async fn get_cookie(&self, name: &str) -> Result, WebDriverErrorResponse> { let cookies = self.get_all_cookies().await?; Ok(cookies.into_iter().find(|cookie| cookie.name == name)) } @@ -48,9 +45,7 @@ impl BridgeExecutor { for cookie in cookies.into_iter().filter(|cookie| cookie.name() == name) { window.delete_cookie(cookie).map_err(|error| { - WebDriverErrorResponse::unknown_error(format!( - "Failed to delete cookie: {error}" - )) + WebDriverErrorResponse::unknown_error(format!("Failed to delete cookie: {error}")) })?; } @@ -65,9 +60,7 @@ impl BridgeExecutor { for cookie in cookies { window.delete_cookie(cookie).map_err(|error| { - WebDriverErrorResponse::unknown_error(format!( - "Failed to delete cookie: {error}" - )) + WebDriverErrorResponse::unknown_error(format!("Failed to delete cookie: {error}")) })?; } diff --git a/src/crates/webdriver/src/executor/window.rs b/src/crates/webdriver/src/executor/window.rs index 05e24cfb..0e78c8b9 100644 --- a/src/crates/webdriver/src/executor/window.rs +++ b/src/crates/webdriver/src/executor/window.rs @@ -86,9 +86,13 @@ impl BridgeExecutor { } pub async fn fullscreen_window(&self) -> Result { - self.webview_window()?.set_fullscreen(true).map_err(|error| { - WebDriverErrorResponse::unknown_error(format!("Failed to fullscreen window: {error}")) - })?; + self.webview_window()? + .set_fullscreen(true) + .map_err(|error| { + WebDriverErrorResponse::unknown_error(format!( + "Failed to fullscreen window: {error}" + )) + })?; tokio::time::sleep(Duration::from_millis(100)).await; self.get_window_rect().await } diff --git a/src/crates/webdriver/src/platform/evaluator/mod.rs b/src/crates/webdriver/src/platform/evaluator/mod.rs index bfcc382a..30132a51 100644 --- a/src/crates/webdriver/src/platform/evaluator/mod.rs +++ b/src/crates/webdriver/src/platform/evaluator/mod.rs @@ -2,16 +2,16 @@ use std::sync::Arc; #[cfg(not(any(target_os = "macos", target_os = "windows")))] use std::time::Duration; -use serde_json::Value; -use tauri::Webview; #[cfg(not(any(target_os = "macos", target_os = "windows")))] use crate::runtime::script; #[cfg(not(any(target_os = "macos", target_os = "windows")))] use crate::runtime::BridgeError; -#[cfg(not(any(target_os = "macos", target_os = "windows")))] -use tokio::sync::oneshot; use crate::server::response::WebDriverErrorResponse; use crate::server::AppState; +use serde_json::Value; +use tauri::Webview; +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +use tokio::sync::oneshot; #[cfg(target_os = "macos")] mod macos; diff --git a/src/crates/webdriver/src/platform/evaluator/windows.rs b/src/crates/webdriver/src/platform/evaluator/windows.rs index 1cd6e99d..7e3ee37c 100644 --- a/src/crates/webdriver/src/platform/evaluator/windows.rs +++ b/src/crates/webdriver/src/platform/evaluator/windows.rs @@ -96,27 +96,23 @@ fn ensure_message_handler(webview: &Webview) -> Result<(), WebDri let registration_result = std::sync::Arc::new(std::sync::Mutex::new(Ok::<(), String>(()))); let registration_result_slot = registration_result.clone(); - let result = webview.with_webview(move |platform_webview| { - unsafe { - let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + let result = webview.with_webview(move |platform_webview| unsafe { + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); - let outcome = match platform_webview.controller().CoreWebView2() { - Ok(webview2) => register_message_handler(&webview2).map_err(|error| error.message), - Err(error) => Err(format!("Failed to access CoreWebView2: {error:?}")), - }; + let outcome = match platform_webview.controller().CoreWebView2() { + Ok(webview2) => register_message_handler(&webview2).map_err(|error| error.message), + Err(error) => Err(format!("Failed to access CoreWebView2: {error:?}")), + }; - if let Ok(mut guard) = registration_result_slot.lock() { - *guard = outcome; - } + if let Ok(mut guard) = registration_result_slot.lock() { + *guard = outcome; } }); match result { Ok(()) => { let outcome = registration_result.lock().map_err(|_| { - WebDriverErrorResponse::unknown_error( - "Failed to read WebView2 registration result", - ) + WebDriverErrorResponse::unknown_error("Failed to read WebView2 registration result") })?; if let Err(error) = &*outcome { return Err(WebDriverErrorResponse::unknown_error(error.clone())); @@ -174,9 +170,7 @@ impl ICoreWebView2WebMessageReceivedEventHandler_Impl for WebMessageReceivedHand } } -unsafe fn register_message_handler( - webview: &ICoreWebView2, -) -> Result<(), WebDriverErrorResponse> { +unsafe fn register_message_handler(webview: &ICoreWebView2) -> Result<(), WebDriverErrorResponse> { let handler: ICoreWebView2WebMessageReceivedEventHandler = WebMessageReceivedHandler.into(); let mut token = std::mem::zeroed(); webview diff --git a/src/crates/webdriver/src/platform/mod.rs b/src/crates/webdriver/src/platform/mod.rs index 32c3e806..cab1a3f3 100644 --- a/src/crates/webdriver/src/platform/mod.rs +++ b/src/crates/webdriver/src/platform/mod.rs @@ -1,5 +1,5 @@ -pub(crate) mod evaluator; mod capture; +pub(crate) mod evaluator; mod image; mod types; diff --git a/src/crates/webdriver/src/runtime/script/pointer/mod.rs b/src/crates/webdriver/src/runtime/script/pointer/mod.rs index fd39c0b9..c1d655a5 100644 --- a/src/crates/webdriver/src/runtime/script/pointer/mod.rs +++ b/src/crates/webdriver/src/runtime/script/pointer/mod.rs @@ -3,8 +3,8 @@ mod mouse; mod perform; mod pointer_source; mod release; -mod wheel_source; mod wheel; +mod wheel_source; pub(super) fn script() -> String { format!( diff --git a/src/crates/webdriver/src/server/handlers/alert.rs b/src/crates/webdriver/src/server/handlers/alert.rs index 870fc301..11cf606b 100644 --- a/src/crates/webdriver/src/server/handlers/alert.rs +++ b/src/crates/webdriver/src/server/handlers/alert.rs @@ -66,11 +66,11 @@ pub async fn send_text( .send_alert_text(&request.text) .await .map_err(|error| { - if error.error == "javascript error" { - WebDriverErrorResponse::no_such_alert("No prompt is currently open") - } else { - error - } - })?; + if error.error == "javascript error" { + WebDriverErrorResponse::no_such_alert("No prompt is currently open") + } else { + error + } + })?; Ok(WebDriverResponse::null()) } diff --git a/src/crates/webdriver/src/server/handlers/mod.rs b/src/crates/webdriver/src/server/handlers/mod.rs index bb900fc9..7257c89b 100644 --- a/src/crates/webdriver/src/server/handlers/mod.rs +++ b/src/crates/webdriver/src/server/handlers/mod.rs @@ -16,9 +16,9 @@ pub mod logs; pub mod navigation; pub mod print; pub mod screenshot; -pub mod shadow; pub mod script; pub mod session; +pub mod shadow; pub mod timeouts; pub mod window; diff --git a/src/crates/webdriver/src/server/handlers/shadow.rs b/src/crates/webdriver/src/server/handlers/shadow.rs index c219dc25..31ecb3ff 100644 --- a/src/crates/webdriver/src/server/handlers/shadow.rs +++ b/src/crates/webdriver/src/server/handlers/shadow.rs @@ -28,8 +28,8 @@ pub async fn get_shadow_root( .get_shadow_root(&element_id) .await .map_err(|_| { - WebDriverErrorResponse::no_such_shadow_root("Element does not have a shadow root") - })?; + WebDriverErrorResponse::no_such_shadow_root("Element does not have a shadow root") + })?; if value.is_null() { return Err(WebDriverErrorResponse::no_such_shadow_root( diff --git a/src/crates/webdriver/src/server/response.rs b/src/crates/webdriver/src/server/response.rs index 062a7fa3..c80dc357 100644 --- a/src/crates/webdriver/src/server/response.rs +++ b/src/crates/webdriver/src/server/response.rs @@ -106,7 +106,12 @@ impl WebDriverErrorResponse { } pub fn unknown_error(message: impl Into) -> Self { - Self::new(StatusCode::INTERNAL_SERVER_ERROR, "unknown error", message, None) + Self::new( + StatusCode::INTERNAL_SERVER_ERROR, + "unknown error", + message, + None, + ) } pub fn invalid_argument(message: impl Into) -> Self { diff --git a/src/crates/webdriver/src/server/router.rs b/src/crates/webdriver/src/server/router.rs index 9dd6fba3..d43d589d 100644 --- a/src/crates/webdriver/src/server/router.rs +++ b/src/crates/webdriver/src/server/router.rs @@ -176,10 +176,7 @@ pub fn create_router(state: Arc) -> Router { "/session/:session_id/execute/async", post(handlers::script::execute_async), ) - .route( - "/session/:session_id/print", - post(handlers::print::print), - ) + .route("/session/:session_id/print", post(handlers::print::print)) .route( "/session/:session_id/actions", post(handlers::actions::perform).delete(handlers::actions::release),