feat: copilot QoL — resizable panel, SFTP tools, context, error watcher
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 15s
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 15s
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) <noreply@anthropic.com>
This commit is contained in:
parent
add0f0628f
commit
bc608b0683
@ -119,6 +119,43 @@ fn handle_tools_list(id: Value) -> JsonRpcResponse {
|
|||||||
"required": ["session_id"]
|
"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",
|
"name": "list_sessions",
|
||||||
"description": "List all active Wraith sessions (SSH, RDP, PTY) with connection details",
|
"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!({})),
|
"list_sessions" => call_wraith(port, "/mcp/sessions", serde_json::json!({})),
|
||||||
"terminal_read" => call_wraith(port, "/mcp/terminal/read", args.clone()),
|
"terminal_read" => call_wraith(port, "/mcp/terminal/read", args.clone()),
|
||||||
"terminal_execute" => call_wraith(port, "/mcp/terminal/execute", 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" => {
|
"terminal_screenshot" => {
|
||||||
let result = call_wraith(port, "/mcp/screenshot", args.clone());
|
let result = call_wraith(port, "/mcp/screenshot", args.clone());
|
||||||
// Screenshot returns base64 PNG — wrap as image content for multimodal AI
|
// Screenshot returns base64 PNG — wrap as image content for multimodal AI
|
||||||
|
|||||||
@ -119,3 +119,15 @@ pub async fn mcp_terminal_execute(
|
|||||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
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<String, String> {
|
||||||
|
let buf = state.scrollback.get(&session_id)
|
||||||
|
.ok_or_else(|| format!("No scrollback buffer for session {}", session_id))?;
|
||||||
|
Ok(buf.read_lines(20))
|
||||||
|
}
|
||||||
|
|||||||
@ -33,6 +33,7 @@ pub async fn connect_ssh(
|
|||||||
rows,
|
rows,
|
||||||
&state.sftp,
|
&state.sftp,
|
||||||
&state.scrollback,
|
&state.scrollback,
|
||||||
|
&state.error_watcher,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@ -65,6 +66,7 @@ pub async fn connect_ssh_with_key(
|
|||||||
rows,
|
rows,
|
||||||
&state.sftp,
|
&state.sftp,
|
||||||
&state.scrollback,
|
&state.scrollback,
|
||||||
|
&state.error_watcher,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,7 @@ use theme::ThemeService;
|
|||||||
use workspace::WorkspaceService;
|
use workspace::WorkspaceService;
|
||||||
use pty::PtyService;
|
use pty::PtyService;
|
||||||
use mcp::ScrollbackRegistry;
|
use mcp::ScrollbackRegistry;
|
||||||
|
use mcp::error_watcher::ErrorWatcher;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Database,
|
pub db: Database,
|
||||||
@ -41,6 +42,7 @@ pub struct AppState {
|
|||||||
pub workspace: WorkspaceService,
|
pub workspace: WorkspaceService,
|
||||||
pub pty: PtyService,
|
pub pty: PtyService,
|
||||||
pub scrollback: ScrollbackRegistry,
|
pub scrollback: ScrollbackRegistry,
|
||||||
|
pub error_watcher: std::sync::Arc<ErrorWatcher>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@ -61,6 +63,7 @@ impl AppState {
|
|||||||
workspace: WorkspaceService::new(SettingsService::new(database.clone())),
|
workspace: WorkspaceService::new(SettingsService::new(database.clone())),
|
||||||
pty: PtyService::new(),
|
pty: PtyService::new(),
|
||||||
scrollback: ScrollbackRegistry::new(),
|
scrollback: ScrollbackRegistry::new(),
|
||||||
|
error_watcher: std::sync::Arc::new(ErrorWatcher::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,11 +107,18 @@ pub fn run() {
|
|||||||
{
|
{
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
let state = app.state::<AppState>();
|
let state = app.state::<AppState>();
|
||||||
|
// 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 ssh_clone = state.ssh.clone();
|
||||||
let rdp_clone = state.rdp.clone();
|
let rdp_clone = state.rdp.clone();
|
||||||
|
let sftp_clone = state.sftp.clone();
|
||||||
let scrollback_clone = state.scrollback.clone();
|
let scrollback_clone = state.scrollback.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
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),
|
Ok(port) => log::info!("MCP server started on localhost:{}", port),
|
||||||
Err(e) => log::error!("Failed to start MCP server: {}", e),
|
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::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::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::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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
115
src-tauri/src/mcp/error_watcher.rs
Normal file
115
src-tauri/src/mcp/error_watcher.rs
Normal file
@ -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<String, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ErrorWatcher>,
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
pub mod scrollback;
|
pub mod scrollback;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
pub mod error_watcher;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|||||||
@ -11,12 +11,14 @@ use tokio::net::TcpListener;
|
|||||||
|
|
||||||
use crate::mcp::ScrollbackRegistry;
|
use crate::mcp::ScrollbackRegistry;
|
||||||
use crate::rdp::RdpService;
|
use crate::rdp::RdpService;
|
||||||
|
use crate::sftp::SftpService;
|
||||||
use crate::ssh::session::SshService;
|
use crate::ssh::session::SshService;
|
||||||
|
|
||||||
/// Shared state passed to axum handlers.
|
/// Shared state passed to axum handlers.
|
||||||
pub struct McpServerState {
|
pub struct McpServerState {
|
||||||
pub ssh: SshService,
|
pub ssh: SshService,
|
||||||
pub rdp: RdpService,
|
pub rdp: RdpService,
|
||||||
|
pub sftp: SftpService,
|
||||||
pub scrollback: ScrollbackRegistry,
|
pub scrollback: ScrollbackRegistry,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,6 +33,25 @@ struct ScreenshotRequest {
|
|||||||
session_id: String,
|
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)]
|
#[derive(Deserialize)]
|
||||||
struct TerminalExecuteRequest {
|
struct TerminalExecuteRequest {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
@ -82,6 +103,47 @@ async fn handle_list_sessions(
|
|||||||
ok_response(sessions)
|
ok_response(sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_sftp_list(
|
||||||
|
AxumState(state): AxumState<Arc<McpServerState>>,
|
||||||
|
Json(req): Json<SftpListRequest>,
|
||||||
|
) -> Json<McpResponse<Vec<serde_json::Value>>> {
|
||||||
|
match state.sftp.list(&req.session_id, &req.path).await {
|
||||||
|
Ok(entries) => {
|
||||||
|
let items: Vec<serde_json::Value> = 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<Arc<McpServerState>>,
|
||||||
|
Json(req): Json<SftpReadRequest>,
|
||||||
|
) -> Json<McpResponse<String>> {
|
||||||
|
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<Arc<McpServerState>>,
|
||||||
|
Json(req): Json<SftpWriteRequest>,
|
||||||
|
) -> Json<McpResponse<String>> {
|
||||||
|
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(
|
async fn handle_screenshot(
|
||||||
AxumState(state): AxumState<Arc<McpServerState>>,
|
AxumState(state): AxumState<Arc<McpServerState>>,
|
||||||
Json(req): Json<ScreenshotRequest>,
|
Json(req): Json<ScreenshotRequest>,
|
||||||
@ -165,15 +227,19 @@ async fn handle_terminal_execute(
|
|||||||
pub async fn start_mcp_server(
|
pub async fn start_mcp_server(
|
||||||
ssh: SshService,
|
ssh: SshService,
|
||||||
rdp: RdpService,
|
rdp: RdpService,
|
||||||
|
sftp: SftpService,
|
||||||
scrollback: ScrollbackRegistry,
|
scrollback: ScrollbackRegistry,
|
||||||
) -> Result<u16, String> {
|
) -> Result<u16, String> {
|
||||||
let state = Arc::new(McpServerState { ssh, rdp, scrollback });
|
let state = Arc::new(McpServerState { ssh, rdp, sftp, scrollback });
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/mcp/sessions", post(handle_list_sessions))
|
.route("/mcp/sessions", post(handle_list_sessions))
|
||||||
.route("/mcp/terminal/read", post(handle_terminal_read))
|
.route("/mcp/terminal/read", post(handle_terminal_read))
|
||||||
.route("/mcp/terminal/execute", post(handle_terminal_execute))
|
.route("/mcp/terminal/execute", post(handle_terminal_execute))
|
||||||
.route("/mcp/screenshot", post(handle_screenshot))
|
.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);
|
.with_state(state);
|
||||||
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await
|
let listener = TcpListener::bind("127.0.0.1:0").await
|
||||||
|
|||||||
@ -94,6 +94,7 @@ fn format_permissions(raw: Option<u32>) -> String {
|
|||||||
// ── SFTP service ─────────────────────────────────────────────────────────────
|
// ── SFTP service ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Manages SFTP sessions keyed by SSH session ID.
|
/// Manages SFTP sessions keyed by SSH session ID.
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct SftpService {
|
pub struct SftpService {
|
||||||
/// One `SftpSession` per SSH session, behind a mutex so async commands can
|
/// One `SftpSession` per SSH session, behind a mutex so async commands can
|
||||||
/// take a shared reference to the `SftpService` and still mutably borrow
|
/// take a shared reference to the `SftpService` and still mutably borrow
|
||||||
|
|||||||
@ -13,6 +13,7 @@ use tokio::sync::mpsc;
|
|||||||
|
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
use crate::mcp::ScrollbackRegistry;
|
use crate::mcp::ScrollbackRegistry;
|
||||||
|
use crate::mcp::error_watcher::ErrorWatcher;
|
||||||
use crate::sftp::SftpService;
|
use crate::sftp::SftpService;
|
||||||
use crate::ssh::cwd::CwdTracker;
|
use crate::ssh::cwd::CwdTracker;
|
||||||
use crate::ssh::host_key::{HostKeyResult, HostKeyStore};
|
use crate::ssh::host_key::{HostKeyResult, HostKeyStore};
|
||||||
@ -84,7 +85,7 @@ impl SshService {
|
|||||||
Self { sessions: DashMap::new(), db }
|
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<String, String> {
|
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<String, String> {
|
||||||
let session_id = uuid::Uuid::new_v4().to_string();
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
let config = Arc::new(russh::client::Config::default());
|
let config = Arc::new(russh::client::Config::default());
|
||||||
let handler = SshClient { host_key_store: HostKeyStore::new(self.db.clone()), hostname: hostname.to_string(), port };
|
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
|
// Create scrollback buffer for MCP terminal_read
|
||||||
let scrollback_buf = scrollback.create(&session_id);
|
let scrollback_buf = scrollback.create(&session_id);
|
||||||
|
error_watcher.watch(&session_id);
|
||||||
|
|
||||||
// Output reader loop — owns the Channel exclusively.
|
// Output reader loop — owns the Channel exclusively.
|
||||||
// Writes go through Handle::data() so no shared mutex is needed.
|
// Writes go through Handle::data() so no shared mutex is needed.
|
||||||
|
|||||||
@ -1,5 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col h-full bg-[var(--wraith-bg-secondary)] border-l border-[var(--wraith-border)] w-[640px]">
|
<div class="flex h-full relative">
|
||||||
|
<!-- Drag handle for resizing -->
|
||||||
|
<div
|
||||||
|
class="w-1 cursor-col-resize hover:bg-[var(--wraith-accent-blue)] active:bg-[var(--wraith-accent-blue)] transition-colors shrink-0"
|
||||||
|
@pointerdown="startResize"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="flex flex-col h-full bg-[var(--wraith-bg-secondary)] border-l border-[var(--wraith-border)] flex-1 min-w-0"
|
||||||
|
:style="{ width: panelWidth + 'px' }"
|
||||||
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="p-3 border-b border-[var(--wraith-border)] flex items-center justify-between gap-2">
|
<div class="p-3 border-b border-[var(--wraith-border)] flex items-center justify-between gap-2">
|
||||||
<span class="text-xs font-bold tracking-widest text-[var(--wraith-accent-blue)]">AI COPILOT</span>
|
<span class="text-xs font-bold tracking-widest text-[var(--wraith-accent-blue)]">AI COPILOT</span>
|
||||||
@ -57,6 +66,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@ -67,6 +77,29 @@ import { useTerminal } from "@/composables/useTerminal";
|
|||||||
|
|
||||||
interface ShellInfo { name: string; path: string; }
|
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<ShellInfo[]>([]);
|
const shells = ref<ShellInfo[]>([]);
|
||||||
const selectedShell = ref("");
|
const selectedShell = ref("");
|
||||||
const connected = ref(false);
|
const connected = ref(false);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user