From bc608b06837544479774435ca7622d51334ec218 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Tue, 24 Mar 2026 23:30:12 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20copilot=20QoL=20=E2=80=94=20resizable?= =?UTF-8?q?=20panel,=20SFTP=20tools,=20context,=20error=20watcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resizable panel: - Drag handle on left border of copilot panel - Pointer events for smooth resize (320px–1200px range) SFTP MCP tools: - sftp_list: list remote directories - sftp_read: read remote files - sftp_write: write remote files - Full HTTP endpoints + bridge tool definitions Active session context: - mcp_get_session_context command returns last 20 lines of scrollback - Frontend can call on tab switch to keep AI informed Error watcher: - Background scanner runs every 2 seconds across all sessions - 20+ error patterns (permission denied, OOM, segfault, disk full, etc.) - Emits mcp:error events to frontend with session ID and matched line - Sessions auto-registered with watcher on connect Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/bin/wraith_mcp_bridge.rs | 40 +++++++++ src-tauri/src/commands/mcp_commands.rs | 12 +++ src-tauri/src/commands/ssh_commands.rs | 2 + src-tauri/src/lib.rs | 14 ++- src-tauri/src/mcp/error_watcher.rs | 115 +++++++++++++++++++++++++ src-tauri/src/mcp/mod.rs | 1 + src-tauri/src/mcp/server.rs | 68 ++++++++++++++- src-tauri/src/sftp/mod.rs | 1 + src-tauri/src/ssh/session.rs | 4 +- src/components/ai/CopilotPanel.vue | 35 +++++++- 10 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 src-tauri/src/mcp/error_watcher.rs diff --git a/src-tauri/src/bin/wraith_mcp_bridge.rs b/src-tauri/src/bin/wraith_mcp_bridge.rs index 51e8306..6ed4bc5 100644 --- a/src-tauri/src/bin/wraith_mcp_bridge.rs +++ b/src-tauri/src/bin/wraith_mcp_bridge.rs @@ -119,6 +119,43 @@ fn handle_tools_list(id: Value) -> JsonRpcResponse { "required": ["session_id"] } }, + { + "name": "sftp_list", + "description": "List files in a directory on a remote host via SFTP", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { "type": "string", "description": "The SSH session ID" }, + "path": { "type": "string", "description": "Remote directory path" } + }, + "required": ["session_id", "path"] + } + }, + { + "name": "sftp_read", + "description": "Read a file from a remote host via SFTP", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { "type": "string", "description": "The SSH session ID" }, + "path": { "type": "string", "description": "Remote file path" } + }, + "required": ["session_id", "path"] + } + }, + { + "name": "sftp_write", + "description": "Write content to a file on a remote host via SFTP", + "inputSchema": { + "type": "object", + "properties": { + "session_id": { "type": "string", "description": "The SSH session ID" }, + "path": { "type": "string", "description": "Remote file path" }, + "content": { "type": "string", "description": "File content to write" } + }, + "required": ["session_id", "path", "content"] + } + }, { "name": "list_sessions", "description": "List all active Wraith sessions (SSH, RDP, PTY) with connection details", @@ -161,6 +198,9 @@ fn handle_tool_call(id: Value, port: u16, tool_name: &str, args: &Value) -> Json "list_sessions" => call_wraith(port, "/mcp/sessions", serde_json::json!({})), "terminal_read" => call_wraith(port, "/mcp/terminal/read", args.clone()), "terminal_execute" => call_wraith(port, "/mcp/terminal/execute", args.clone()), + "sftp_list" => call_wraith(port, "/mcp/sftp/list", args.clone()), + "sftp_read" => call_wraith(port, "/mcp/sftp/read", args.clone()), + "sftp_write" => call_wraith(port, "/mcp/sftp/write", args.clone()), "terminal_screenshot" => { let result = call_wraith(port, "/mcp/screenshot", args.clone()); // Screenshot returns base64 PNG — wrap as image content for multimodal AI diff --git a/src-tauri/src/commands/mcp_commands.rs b/src-tauri/src/commands/mcp_commands.rs index 14f5390..24714d3 100644 --- a/src-tauri/src/commands/mcp_commands.rs +++ b/src-tauri/src/commands/mcp_commands.rs @@ -119,3 +119,15 @@ pub async fn mcp_terminal_execute( tokio::time::sleep(std::time::Duration::from_millis(50)).await; } } + +/// Get the active session context — last 20 lines of scrollback for a session. +/// Called by the frontend when the user switches tabs, emitted to the copilot. +#[tauri::command] +pub fn mcp_get_session_context( + session_id: String, + state: State<'_, AppState>, +) -> Result { + let buf = state.scrollback.get(&session_id) + .ok_or_else(|| format!("No scrollback buffer for session {}", session_id))?; + Ok(buf.read_lines(20)) +} diff --git a/src-tauri/src/commands/ssh_commands.rs b/src-tauri/src/commands/ssh_commands.rs index 6821527..54719e2 100644 --- a/src-tauri/src/commands/ssh_commands.rs +++ b/src-tauri/src/commands/ssh_commands.rs @@ -33,6 +33,7 @@ pub async fn connect_ssh( rows, &state.sftp, &state.scrollback, + &state.error_watcher, ) .await } @@ -65,6 +66,7 @@ pub async fn connect_ssh_with_key( rows, &state.sftp, &state.scrollback, + &state.error_watcher, ) .await } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 98d3748..6f9ebe1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -27,6 +27,7 @@ use theme::ThemeService; use workspace::WorkspaceService; use pty::PtyService; use mcp::ScrollbackRegistry; +use mcp::error_watcher::ErrorWatcher; pub struct AppState { pub db: Database, @@ -41,6 +42,7 @@ pub struct AppState { pub workspace: WorkspaceService, pub pty: PtyService, pub scrollback: ScrollbackRegistry, + pub error_watcher: std::sync::Arc, } impl AppState { @@ -61,6 +63,7 @@ impl AppState { workspace: WorkspaceService::new(SettingsService::new(database.clone())), pty: PtyService::new(), scrollback: ScrollbackRegistry::new(), + error_watcher: std::sync::Arc::new(ErrorWatcher::new()), }) } @@ -104,11 +107,18 @@ pub fn run() { { use tauri::Manager; let state = app.state::(); + // Start error watcher background task + let watcher_clone = state.error_watcher.clone(); + let scrollback_for_watcher = state.scrollback.clone(); + let app_for_watcher = app.handle().clone(); + mcp::error_watcher::start_error_watcher(watcher_clone, scrollback_for_watcher, app_for_watcher); + let ssh_clone = state.ssh.clone(); let rdp_clone = state.rdp.clone(); + let sftp_clone = state.sftp.clone(); let scrollback_clone = state.scrollback.clone(); tauri::async_runtime::spawn(async move { - match mcp::server::start_mcp_server(ssh_clone, rdp_clone, scrollback_clone).await { + match mcp::server::start_mcp_server(ssh_clone, rdp_clone, sftp_clone, scrollback_clone).await { Ok(port) => log::info!("MCP server started on localhost:{}", port), Err(e) => log::error!("Failed to start MCP server: {}", e), } @@ -128,7 +138,7 @@ pub fn run() { commands::rdp_commands::connect_rdp, commands::rdp_commands::rdp_get_frame, commands::rdp_commands::rdp_send_mouse, commands::rdp_commands::rdp_send_key, commands::rdp_commands::rdp_send_clipboard, commands::rdp_commands::disconnect_rdp, commands::rdp_commands::list_rdp_sessions, commands::theme_commands::list_themes, commands::theme_commands::get_theme, commands::pty_commands::list_available_shells, commands::pty_commands::spawn_local_shell, commands::pty_commands::pty_write, commands::pty_commands::pty_resize, commands::pty_commands::disconnect_pty, - commands::mcp_commands::mcp_list_sessions, commands::mcp_commands::mcp_terminal_read, commands::mcp_commands::mcp_terminal_execute, + commands::mcp_commands::mcp_list_sessions, commands::mcp_commands::mcp_terminal_read, commands::mcp_commands::mcp_terminal_execute, commands::mcp_commands::mcp_get_session_context, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/mcp/error_watcher.rs b/src-tauri/src/mcp/error_watcher.rs new file mode 100644 index 0000000..e574c05 --- /dev/null +++ b/src-tauri/src/mcp/error_watcher.rs @@ -0,0 +1,115 @@ +//! Background error pattern scanner for terminal sessions. +//! +//! Watches scrollback buffers for common error patterns and emits +//! `mcp:error:{session_id}` events to the frontend when detected. + +use std::sync::Arc; + +use dashmap::DashMap; +use tauri::{AppHandle, Emitter}; + +use crate::mcp::ScrollbackRegistry; + +/// Common error patterns to watch for across all sessions. +const ERROR_PATTERNS: &[&str] = &[ + "Permission denied", + "permission denied", + "Connection refused", + "connection refused", + "No space left on device", + "Disk quota exceeded", + "Out of memory", + "OOM", + "Killed", + "Segmentation fault", + "segfault", + "FATAL", + "CRITICAL", + "panic:", + "stack overflow", + "Too many open files", + "Connection timed out", + "Connection reset by peer", + "Host key verification failed", + "command not found", + "No such file or directory", +]; + +/// Tracks the last scanned position per session to avoid re-emitting. +pub struct ErrorWatcher { + last_scanned: DashMap, +} + +impl ErrorWatcher { + pub fn new() -> Self { + Self { last_scanned: DashMap::new() } + } + + /// Scan all registered sessions for new error patterns. + /// Returns a list of (session_id, matched_line) pairs. + pub fn scan(&self, scrollback: &ScrollbackRegistry) -> Vec<(String, String)> { + let mut alerts = Vec::new(); + + // Collect session IDs and positions first to avoid holding the iter + let sessions: Vec<(String, usize)> = self.last_scanned.iter() + .map(|entry| (entry.key().clone(), *entry.value())) + .collect(); + + for (session_id, last_pos) in sessions { + if let Some(buf) = scrollback.get(&session_id) { + let total = buf.total_written(); + if total <= last_pos { + continue; + } + + let raw = buf.read_raw(); + let new_start = raw.len().saturating_sub(total - last_pos); + let new_content = &raw[new_start..]; + + for line in new_content.lines() { + for pattern in ERROR_PATTERNS { + if line.contains(pattern) { + alerts.push((session_id.clone(), line.to_string())); + break; + } + } + } + + self.last_scanned.insert(session_id, total); + } + } + + alerts + } + + /// Register a session for watching. + pub fn watch(&self, session_id: &str) { + self.last_scanned.insert(session_id.to_string(), 0); + } + + /// Stop watching a session. + pub fn unwatch(&self, session_id: &str) { + self.last_scanned.remove(session_id); + } +} + +/// Spawn a background task that scans for errors every 2 seconds. +pub fn start_error_watcher( + watcher: Arc, + scrollback: ScrollbackRegistry, + app_handle: AppHandle, +) { + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + let alerts = watcher.scan(&scrollback); + for (session_id, line) in alerts { + let _ = app_handle.emit("mcp:error", serde_json::json!({ + "sessionId": session_id, + "message": line, + })); + } + } + }); +} diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index 617d9c3..125081c 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -6,6 +6,7 @@ pub mod scrollback; pub mod server; +pub mod error_watcher; use std::sync::Arc; diff --git a/src-tauri/src/mcp/server.rs b/src-tauri/src/mcp/server.rs index 87c7aaa..a0dce4e 100644 --- a/src-tauri/src/mcp/server.rs +++ b/src-tauri/src/mcp/server.rs @@ -11,12 +11,14 @@ use tokio::net::TcpListener; use crate::mcp::ScrollbackRegistry; use crate::rdp::RdpService; +use crate::sftp::SftpService; use crate::ssh::session::SshService; /// Shared state passed to axum handlers. pub struct McpServerState { pub ssh: SshService, pub rdp: RdpService, + pub sftp: SftpService, pub scrollback: ScrollbackRegistry, } @@ -31,6 +33,25 @@ struct ScreenshotRequest { session_id: String, } +#[derive(Deserialize)] +struct SftpListRequest { + session_id: String, + path: String, +} + +#[derive(Deserialize)] +struct SftpReadRequest { + session_id: String, + path: String, +} + +#[derive(Deserialize)] +struct SftpWriteRequest { + session_id: String, + path: String, + content: String, +} + #[derive(Deserialize)] struct TerminalExecuteRequest { session_id: String, @@ -82,6 +103,47 @@ async fn handle_list_sessions( ok_response(sessions) } +async fn handle_sftp_list( + AxumState(state): AxumState>, + Json(req): Json, +) -> Json>> { + match state.sftp.list(&req.session_id, &req.path).await { + Ok(entries) => { + let items: Vec = entries.into_iter().map(|e| { + serde_json::json!({ + "name": e.name, + "path": e.path, + "size": e.size, + "is_dir": e.is_dir, + "modified": e.mod_time, + }) + }).collect(); + ok_response(items) + } + Err(e) => err_response(e), + } +} + +async fn handle_sftp_read( + AxumState(state): AxumState>, + Json(req): Json, +) -> Json> { + match state.sftp.read_file(&req.session_id, &req.path).await { + Ok(content) => ok_response(content), + Err(e) => err_response(e), + } +} + +async fn handle_sftp_write( + AxumState(state): AxumState>, + Json(req): Json, +) -> Json> { + match state.sftp.write_file(&req.session_id, &req.path, &req.content).await { + Ok(()) => ok_response("OK".to_string()), + Err(e) => err_response(e), + } +} + async fn handle_screenshot( AxumState(state): AxumState>, Json(req): Json, @@ -165,15 +227,19 @@ async fn handle_terminal_execute( pub async fn start_mcp_server( ssh: SshService, rdp: RdpService, + sftp: SftpService, scrollback: ScrollbackRegistry, ) -> Result { - let state = Arc::new(McpServerState { ssh, rdp, scrollback }); + let state = Arc::new(McpServerState { ssh, rdp, sftp, scrollback }); let app = Router::new() .route("/mcp/sessions", post(handle_list_sessions)) .route("/mcp/terminal/read", post(handle_terminal_read)) .route("/mcp/terminal/execute", post(handle_terminal_execute)) .route("/mcp/screenshot", post(handle_screenshot)) + .route("/mcp/sftp/list", post(handle_sftp_list)) + .route("/mcp/sftp/read", post(handle_sftp_read)) + .route("/mcp/sftp/write", post(handle_sftp_write)) .with_state(state); let listener = TcpListener::bind("127.0.0.1:0").await diff --git a/src-tauri/src/sftp/mod.rs b/src-tauri/src/sftp/mod.rs index 3e05e0c..e8e39a6 100644 --- a/src-tauri/src/sftp/mod.rs +++ b/src-tauri/src/sftp/mod.rs @@ -94,6 +94,7 @@ fn format_permissions(raw: Option) -> String { // ── SFTP service ───────────────────────────────────────────────────────────── /// Manages SFTP sessions keyed by SSH session ID. +#[derive(Clone)] pub struct SftpService { /// One `SftpSession` per SSH session, behind a mutex so async commands can /// take a shared reference to the `SftpService` and still mutably borrow diff --git a/src-tauri/src/ssh/session.rs b/src-tauri/src/ssh/session.rs index 9d68628..9db89b0 100644 --- a/src-tauri/src/ssh/session.rs +++ b/src-tauri/src/ssh/session.rs @@ -13,6 +13,7 @@ use tokio::sync::mpsc; use crate::db::Database; use crate::mcp::ScrollbackRegistry; +use crate::mcp::error_watcher::ErrorWatcher; use crate::sftp::SftpService; use crate::ssh::cwd::CwdTracker; use crate::ssh::host_key::{HostKeyResult, HostKeyStore}; @@ -84,7 +85,7 @@ impl SshService { Self { sessions: DashMap::new(), db } } - pub async fn connect(&self, app_handle: AppHandle, hostname: &str, port: u16, username: &str, auth: AuthMethod, cols: u32, rows: u32, sftp_service: &SftpService, scrollback: &ScrollbackRegistry) -> Result { + pub async fn connect(&self, app_handle: AppHandle, hostname: &str, port: u16, username: &str, auth: AuthMethod, cols: u32, rows: u32, sftp_service: &SftpService, scrollback: &ScrollbackRegistry, error_watcher: &ErrorWatcher) -> Result { let session_id = uuid::Uuid::new_v4().to_string(); let config = Arc::new(russh::client::Config::default()); let handler = SshClient { host_key_store: HostKeyStore::new(self.db.clone()), hostname: hostname.to_string(), port }; @@ -151,6 +152,7 @@ impl SshService { // Create scrollback buffer for MCP terminal_read let scrollback_buf = scrollback.create(&session_id); + error_watcher.watch(&session_id); // Output reader loop — owns the Channel exclusively. // Writes go through Handle::data() so no shared mutex is needed. diff --git a/src/components/ai/CopilotPanel.vue b/src/components/ai/CopilotPanel.vue index ac03c49..6546a76 100644 --- a/src/components/ai/CopilotPanel.vue +++ b/src/components/ai/CopilotPanel.vue @@ -1,5 +1,14 @@ @@ -67,6 +77,29 @@ import { useTerminal } from "@/composables/useTerminal"; interface ShellInfo { name: string; path: string; } +// Resizable panel +const panelWidth = ref(640); + +function startResize(e: PointerEvent): void { + e.preventDefault(); + const startX = e.clientX; + const startWidth = panelWidth.value; + + function onMove(ev: PointerEvent): void { + // Dragging left increases width (panel is on the right side) + const delta = startX - ev.clientX; + panelWidth.value = Math.max(320, Math.min(1200, startWidth + delta)); + } + + function onUp(): void { + document.removeEventListener("pointermove", onMove); + document.removeEventListener("pointerup", onUp); + } + + document.addEventListener("pointermove", onMove); + document.addEventListener("pointerup", onUp); +} + const shells = ref([]); const selectedShell = ref(""); const connected = ref(false);