From 512347af5fc9df361dafe9b6102cfbb36a0b8b75 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Tue, 24 Mar 2026 20:43:18 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Local=20PTY=20Copilot=20Panel=20?= =?UTF-8?q?=E2=80=94=20replace=20Gemini=20stub=20with=20real=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ripped out the Gemini API stub (ai/mod.rs, ai_commands.rs, GeminiPanel.vue) and replaced it with a local PTY terminal in the sidebar panel. Users select a shell (bash/zsh/sh on Unix, PowerShell/CMD/Git Bash on Windows), launch it, and run claude/gemini/codex or any CLI tool directly. Backend: - New PtyService module using portable-pty (cross-platform PTY) - DashMap session registry (same pattern as SshService) - spawn_blocking output loop (portable-pty reader is synchronous) - 5 Tauri commands: list_available_shells, spawn_local_shell, pty_write, pty_resize, disconnect_pty Frontend: - Parameterized useTerminal composable: backend='ssh'|'pty' - convertEol=false for PTY (PTY driver handles LF→CRLF) - CopilotPanel.vue with shell selector, launch/kill, session ended prompt - Ctrl+Shift+G toggle preserved Tests: 87 total (5 new PTY tests), zero warnings Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 63 +++++++ CLAUDE.md | 2 +- src-tauri/Cargo.lock | 149 +++++++++++++++- src-tauri/Cargo.toml | 3 + src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/installer.nsi.template | 23 ++- src-tauri/src/ai/mod.rs | 51 ------ src-tauri/src/commands/ai_commands.rs | 25 --- src-tauri/src/commands/mod.rs | 2 +- src-tauri/src/commands/pty_commands.rs | 49 ++++++ src-tauri/src/lib.rs | 9 +- src-tauri/src/pty/mod.rs | 217 ++++++++++++++++++++++++ src/components/ai/CopilotPanel.vue | 159 +++++++++++++++++ src/components/ai/GeminiPanel.vue | 124 -------------- src/composables/useTerminal.ts | 24 +-- src/layouts/MainLayout.vue | 16 +- 16 files changed, 691 insertions(+), 227 deletions(-) create mode 100644 AGENTS.md delete mode 100644 src-tauri/src/ai/mod.rs delete mode 100644 src-tauri/src/commands/ai_commands.rs create mode 100644 src-tauri/src/commands/pty_commands.rs create mode 100644 src-tauri/src/pty/mod.rs create mode 100644 src/components/ai/CopilotPanel.vue delete mode 100644 src/components/ai/GeminiPanel.vue diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..24545a6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,63 @@ +# AGENTS.md — Wraith Desktop v2 + +## Agent Roster + +Three tiers. Use the right tool for the job. + +### Architect (Opus) + +**Role:** Strategy, COAs, root cause analysis, architectural decisions. +**When to use:** Design questions, complex debugging, trade-off analysis, cross-module planning. +**How to use:** Plans, not code. The Architect reasons about the problem and presents options. The Commander decides. Then Specialists execute. + +### Specialist (Sonnet) + +**Role:** Full-stack execution. Writes code, fixes bugs, builds features. +**When to use:** Implementation tasks with clear requirements. Feature builds, bug fixes, refactoring, test writing. +**How to use:** `subagent_type: general-purpose, model: sonnet`. Give precise briefs with file paths, expected behavior, and acceptance criteria. + +### Scout (Sonnet/Haiku) + +**Role:** Recon, context mapping, read-only exploration. +**When to use:** Before any implementation. Understanding code structure, finding patterns, mapping dependencies. +**How to use:** `subagent_type: Explore, model: sonnet` for thorough exploration. Haiku for quick file lookups. Scouts NEVER modify files. + +## Dispatch Rules + +- **Simple bug fix (1-2 files):** Do it yourself. Don't burn an agent on a one-liner. +- **Feature build (3+ files):** Dispatch a Specialist with a complete brief. +- **Unknown territory:** Scout first, then Specialist. +- **Architecture decision:** Architect agent OR present COAs to the Commander directly. +- **Mechanical bulk work (renaming, formatting, repetitive edits):** Sonnet Specialist. Don't waste Opus on mechanical tasks. +- **Security-critical code (vault, crypto, auth):** Opus Architect reviews. Sonnet Specialist implements. Both touch the code. + +## Cross-Project Context + +Wraith is part of the Vigilsynth portfolio alongside: + +- **Vigilance HQ** (`../vigilance-hq`) — MSP operations platform. Vue 3 + Express.js. 1,216+ commits. Production. +- **Vigilance Command** (`../vigilance-command-v2`) — Security OS. NestJS + Rust agent. 16 modules. Active development. +- **Vigilance Complete** (`../vigilance-complete`) — The merge. HQ + Command unified. + +The Commander manages multiple AI XOs across all repos simultaneously. Context from one repo may inform work in another. When the Commander references HQ patterns, Command architecture, or Vigilsynth strategy, that's cross-project context — use it. + +## Gemini CLI + +Gemini CLI is available in the Commander's environment. Gemini specializes in: +- Architecture and protocol design +- Library/crate research and evaluation +- Deep code audits against specifications +- Optimization identification + +The Commander may direct Gemini to work on Wraith alongside or instead of Claude. Both AIs follow the same CLAUDE.md doctrine. The Commander routes tasks to whichever AI is best suited. + +## The Go Reference + +The Go version of Wraith lives at `../wraith`. It is the reference implementation: +- SSH terminal with xterm.js worked +- SFTP sidebar with CWD following worked +- Connection manager with groups and search worked +- Credential vault with Argon2id encryption worked +- Multi-tab sessions worked + +When building features, Scouts should read the Go version first. Specialists should match or exceed Go's capabilities. Don't reinvent what was already solved — port it better. diff --git a/CLAUDE.md b/CLAUDE.md index a0034df..1c54281 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ npm install # Install frontend deps npm run dev # Vite dev server only cargo tauri dev # Full app (Rust + frontend) cargo tauri build # Production build -cd src-tauri && cargo test # Run Rust tests (82 tests) +cd src-tauri && cargo test # Run Rust tests (87 tests) cd src-tauri && cargo build # Build Rust only ``` diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 07aa977..b37a5de 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1416,6 +1416,12 @@ dependencies = [ "tendril 0.5.0", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -1729,10 +1735,21 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" dependencies = [ - "memoffset", + "memoffset 0.9.1", "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.27" @@ -2870,6 +2887,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + [[package]] name = "ipconfig" version = "0.3.2" @@ -3510,6 +3536,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -3649,6 +3684,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", + "pin-utils", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -4728,6 +4777,27 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "portpicker" version = "0.1.1" @@ -5986,6 +6056,48 @@ dependencies = [ "serde", ] +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -6092,6 +6204,22 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -6947,6 +7075,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -8482,6 +8619,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.50.0" @@ -8635,6 +8781,7 @@ dependencies = [ "md5", "pem", "pkcs8 0.10.2", + "portable-pty", "rand 0.9.2", "reqwest 0.12.28", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e4cffc1..b56a310 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -45,6 +45,9 @@ pem = "3" pkcs8 = { version = "0.10", features = ["pem"] } sec1 = { version = "0.7", features = ["pem"] } +# Local PTY for AI copilot panel +portable-pty = "0.8" + # RDP (IronRDP) ironrdp = { version = "0.14", features = ["connector", "session", "graphics", "input"] } ironrdp-tokio = { version = "0.8", features = ["reqwest-rustls-ring"] } diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 9e26dfe..3956473 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capabilities for the main Wraith window","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:window:default","shell:allow-open"]}} \ No newline at end of file diff --git a/src-tauri/installer.nsi.template b/src-tauri/installer.nsi.template index d47a776..9068c4d 100644 --- a/src-tauri/installer.nsi.template +++ b/src-tauri/installer.nsi.template @@ -1,18 +1,39 @@ !include "MUI2.nsh" +!include "nsDialogs.nsh" +!include "LogicLib.nsh" +!include "WinMessages.nsh" Name "Wraith" OutFile "OUTFILE_PLACEHOLDER" InstallDir "$PROGRAMFILES64\Wraith" RequestExecutionLevel admin +Var DesktopShortcut !insertmacro MUI_PAGE_DIRECTORY +Page custom OptionsPage OptionsPageLeave !insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_LANGUAGE "English" + +Function OptionsPage + nsDialogs::Create 1018 + Pop $0 + ${NSD_CreateCheckbox} 0 0 100% 12u "Create Desktop Shortcut" + Pop $1 + ; Unchecked by default — no ${NSD_Check} + nsDialogs::Show +FunctionEnd + +Function OptionsPageLeave + ${NSD_GetState} $1 $DesktopShortcut +FunctionEnd + Section "Install" SetOutPath "$INSTDIR" File "Wraith.exe" File "wraith.ico" CreateDirectory "$SMPROGRAMS\Wraith" CreateShortcut "$SMPROGRAMS\Wraith\Wraith.lnk" "$INSTDIR\Wraith.exe" "" "$INSTDIR\wraith.ico" - CreateShortcut "$DESKTOP\Wraith.lnk" "$INSTDIR\Wraith.exe" "" "$INSTDIR\wraith.ico" + ${If} $DesktopShortcut == ${BST_CHECKED} + CreateShortcut "$DESKTOP\Wraith.lnk" "$INSTDIR\Wraith.exe" "" "$INSTDIR\wraith.ico" + ${EndIf} WriteUninstaller "$INSTDIR\uninstall.exe" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Wraith" "DisplayName" "Wraith" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Wraith" "UninstallString" "$INSTDIR\uninstall.exe" diff --git a/src-tauri/src/ai/mod.rs b/src-tauri/src/ai/mod.rs deleted file mode 100644 index 369bff7..0000000 --- a/src-tauri/src/ai/mod.rs +++ /dev/null @@ -1,51 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use tokio::sync::Mutex as TokioMutex; - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(tag = "type", content = "value")] -pub enum AuthMethod { - ApiKey(String), - ServiceAccount(String), - GoogleAccount { - access_token: String, - refresh_token: Option, - expiry: Option, - }, -} - -#[derive(Clone)] -pub struct GeminiClient { - auth: Arc>, - model: String, -} - -impl GeminiClient { - pub fn new(auth: AuthMethod, model: String) -> Self { - Self { - auth: Arc::new(TokioMutex::new(auth)), - model, - } - } - - pub async fn chat(&self, message: &str) -> anyhow::Result { - let auth = self.auth.lock().await; - let client = reqwest::Client::new(); - let url = format!("https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent", self.model); - let payload = serde_json::json!({ - "contents": [{"parts": [{"text": message}]}] - }); - let mut request = client.post(url); - match &*auth { - AuthMethod::ApiKey(key) => { request = request.query(&[("key", key)]); } - AuthMethod::GoogleAccount { access_token, .. } => { request = request.bearer_auth(access_token); } - AuthMethod::ServiceAccount(_) => { return Err(anyhow::anyhow!("Service Account auth not yet fully implemented")); } - } - let resp = request.json(&payload).send().await?.error_for_status()?; - let json: serde_json::Value = resp.json().await?; - let text = json["candidates"][0]["content"]["parts"][0]["text"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("Failed to parse Gemini response"))?; - Ok(text.to_string()) - } -} diff --git a/src-tauri/src/commands/ai_commands.rs b/src-tauri/src/commands/ai_commands.rs deleted file mode 100644 index 3083a0d..0000000 --- a/src-tauri/src/commands/ai_commands.rs +++ /dev/null @@ -1,25 +0,0 @@ -use tauri::State; -use crate::AppState; -use crate::ai::{AuthMethod, GeminiClient}; - -#[tauri::command] -pub async fn set_gemini_auth(auth: AuthMethod, model: Option, state: State<'_, AppState>) -> Result<(), String> { - let client = GeminiClient::new(auth, model.unwrap_or_else(|| "gemini-2.0-flash".to_string())); - *state.gemini.lock().unwrap() = Some(client); - Ok(()) -} - -#[tauri::command] -pub async fn gemini_chat(message: String, state: State<'_, AppState>) -> Result { - let client = { - let client_opt = state.gemini.lock().unwrap(); - client_opt.as_ref().cloned().ok_or("Gemini not authenticated")? - }; - - client.chat(&message).await.map_err(|e| e.to_string()) -} - -#[tauri::command] -pub fn is_gemini_authenticated(state: State<'_, AppState>) -> bool { - state.gemini.lock().unwrap().is_some() -} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 7b3f05b..bd94329 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,4 +6,4 @@ pub mod ssh_commands; pub mod sftp_commands; pub mod rdp_commands; pub mod theme_commands; -pub mod ai_commands; +pub mod pty_commands; diff --git a/src-tauri/src/commands/pty_commands.rs b/src-tauri/src/commands/pty_commands.rs new file mode 100644 index 0000000..c8290c2 --- /dev/null +++ b/src-tauri/src/commands/pty_commands.rs @@ -0,0 +1,49 @@ +//! Tauri commands for local PTY session management. + +use tauri::{AppHandle, State}; + +use crate::pty::ShellInfo; +use crate::AppState; + +#[tauri::command] +pub fn list_available_shells(state: State<'_, AppState>) -> Vec { + state.pty.list_shells() +} + +#[tauri::command] +pub fn spawn_local_shell( + shell_path: String, + cols: u32, + rows: u32, + app_handle: AppHandle, + state: State<'_, AppState>, +) -> Result { + state.pty.spawn(&shell_path, cols as u16, rows as u16, app_handle) +} + +#[tauri::command] +pub fn pty_write( + session_id: String, + data: String, + state: State<'_, AppState>, +) -> Result<(), String> { + state.pty.write(&session_id, data.as_bytes()) +} + +#[tauri::command] +pub fn pty_resize( + session_id: String, + cols: u32, + rows: u32, + state: State<'_, AppState>, +) -> Result<(), String> { + state.pty.resize(&session_id, cols as u16, rows as u16) +} + +#[tauri::command] +pub fn disconnect_pty( + session_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + state.pty.disconnect(&session_id) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2d45f7c..49a4cbd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,7 +8,7 @@ pub mod sftp; pub mod rdp; pub mod theme; pub mod workspace; -pub mod ai; +pub mod pty; pub mod commands; use std::path::PathBuf; @@ -24,6 +24,7 @@ use ssh::session::SshService; use rdp::RdpService; use theme::ThemeService; use workspace::WorkspaceService; +use pty::PtyService; pub struct AppState { pub db: Database, @@ -36,7 +37,7 @@ pub struct AppState { pub rdp: RdpService, pub theme: ThemeService, pub workspace: WorkspaceService, - pub gemini: Mutex>, + pub pty: PtyService, } impl AppState { @@ -55,7 +56,7 @@ impl AppState { rdp: RdpService::new(), theme: ThemeService::new(database.clone()), workspace: WorkspaceService::new(SettingsService::new(database.clone())), - gemini: Mutex::new(None), + pty: PtyService::new(), }) } @@ -107,7 +108,7 @@ pub fn run() { commands::sftp_commands::sftp_list, commands::sftp_commands::sftp_read_file, commands::sftp_commands::sftp_write_file, commands::sftp_commands::sftp_mkdir, commands::sftp_commands::sftp_delete, commands::sftp_commands::sftp_rename, 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::ai_commands::set_gemini_auth, commands::ai_commands::gemini_chat, commands::ai_commands::is_gemini_authenticated, + 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, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/pty/mod.rs b/src-tauri/src/pty/mod.rs new file mode 100644 index 0000000..5ca6a61 --- /dev/null +++ b/src-tauri/src/pty/mod.rs @@ -0,0 +1,217 @@ +//! Local PTY service — spawns shells for the AI copilot panel. + +use std::io::{Read, Write}; +use std::sync::{Arc, Mutex}; + +use base64::Engine; +use dashmap::DashMap; +use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; +use serde::Serialize; +use tauri::{AppHandle, Emitter}; + +#[derive(Debug, Serialize, Clone)] +pub struct ShellInfo { + pub name: String, + pub path: String, +} + +pub struct PtySession { + pub id: String, + pub shell_path: String, + writer: Mutex>, + master: Mutex>, + child: Mutex>, +} + +pub struct PtyService { + sessions: DashMap>, +} + +impl PtyService { + pub fn new() -> Self { + Self { sessions: DashMap::new() } + } + + /// Detect available shells on the system. + pub fn list_shells(&self) -> Vec { + let mut shells = Vec::new(); + + #[cfg(unix)] + { + if let Ok(user_shell) = std::env::var("SHELL") { + if std::path::Path::new(&user_shell).exists() { + let name = std::path::Path::new(&user_shell) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("shell") + .to_string(); + shells.push(ShellInfo { name, path: user_shell }); + } + } + for (name, path) in [("bash", "/bin/bash"), ("zsh", "/bin/zsh"), ("sh", "/bin/sh")] { + if std::path::Path::new(path).exists() && !shells.iter().any(|s| s.path == path) { + shells.push(ShellInfo { name: name.to_string(), path: path.to_string() }); + } + } + } + + #[cfg(windows)] + { + shells.push(ShellInfo { name: "PowerShell".to_string(), path: "powershell.exe".to_string() }); + shells.push(ShellInfo { name: "CMD".to_string(), path: "cmd.exe".to_string() }); + for git_bash in [ + r"C:\Program Files\Git\bin\bash.exe", + r"C:\Program Files (x86)\Git\bin\bash.exe", + ] { + if std::path::Path::new(git_bash).exists() { + shells.push(ShellInfo { name: "Git Bash".to_string(), path: git_bash.to_string() }); + break; + } + } + } + + shells + } + + /// Spawn a local shell and start reading its output. + pub fn spawn( + &self, + shell_path: &str, + cols: u16, + rows: u16, + app_handle: AppHandle, + ) -> Result { + let session_id = uuid::Uuid::new_v4().to_string(); + let pty_system = native_pty_system(); + + let pair = pty_system + .openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }) + .map_err(|e| format!("Failed to open PTY: {}", e))?; + + let cmd = CommandBuilder::new(shell_path); + + let child = pair.slave + .spawn_command(cmd) + .map_err(|e| format!("Failed to spawn shell '{}': {}", shell_path, e))?; + + let reader = pair.master + .try_clone_reader() + .map_err(|e| format!("Failed to clone PTY reader: {}", e))?; + + let writer = pair.master + .take_writer() + .map_err(|e| format!("Failed to take PTY writer: {}", e))?; + + let session = Arc::new(PtySession { + id: session_id.clone(), + shell_path: shell_path.to_string(), + writer: Mutex::new(writer), + master: Mutex::new(pair.master), + child: Mutex::new(child), + }); + + self.sessions.insert(session_id.clone(), session); + + // Output reader loop — runs in a blocking thread because + // portable-pty's reader is synchronous (std::io::Read). + let sid = session_id.clone(); + let app = app_handle; + tokio::task::spawn_blocking(move || { + let mut reader = std::io::BufReader::new(reader); + let mut buf = [0u8; 4096]; + loop { + match reader.read(&mut buf) { + Ok(0) => { + let _ = app.emit(&format!("pty:close:{}", sid), ()); + break; + } + Ok(n) => { + let encoded = base64::engine::general_purpose::STANDARD.encode(&buf[..n]); + let _ = app.emit(&format!("pty:data:{}", sid), encoded); + } + Err(_) => { + let _ = app.emit(&format!("pty:close:{}", sid), ()); + break; + } + } + } + }); + + Ok(session_id) + } + + /// Write data to a PTY session's stdin. + pub fn write(&self, session_id: &str, data: &[u8]) -> Result<(), String> { + let session = self.sessions.get(session_id) + .ok_or_else(|| format!("PTY session {} not found", session_id))?; + let mut writer = session.writer.lock() + .map_err(|e| format!("Failed to lock PTY writer: {}", e))?; + writer.write_all(data) + .map_err(|e| format!("Failed to write to PTY {}: {}", session_id, e)) + } + + /// Resize a PTY session. + pub fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> { + let session = self.sessions.get(session_id) + .ok_or_else(|| format!("PTY session {} not found", session_id))?; + let master = session.master.lock() + .map_err(|e| format!("Failed to lock PTY master: {}", e))?; + master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }) + .map_err(|e| format!("Failed to resize PTY {}: {}", session_id, e)) + } + + /// Kill and remove a PTY session. + pub fn disconnect(&self, session_id: &str) -> Result<(), String> { + let (_, session) = self.sessions.remove(session_id) + .ok_or_else(|| format!("PTY session {} not found", session_id))?; + if let Ok(mut child) = session.child.lock() { + let _ = child.kill(); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_shells_returns_at_least_one() { + let svc = PtyService::new(); + let shells = svc.list_shells(); + assert!(!shells.is_empty(), "should find at least one shell"); + for shell in &shells { + assert!(!shell.name.is_empty()); + assert!(!shell.path.is_empty()); + } + } + + #[test] + fn list_shells_no_duplicates() { + let svc = PtyService::new(); + let shells = svc.list_shells(); + let mut paths: Vec<&str> = shells.iter().map(|s| s.path.as_str()).collect(); + let original_len = paths.len(); + paths.sort(); + paths.dedup(); + assert_eq!(original_len, paths.len(), "shell list should not contain duplicates"); + } + + #[test] + fn disconnect_nonexistent_session_errors() { + let svc = PtyService::new(); + assert!(svc.disconnect("nonexistent").is_err()); + } + + #[test] + fn write_nonexistent_session_errors() { + let svc = PtyService::new(); + assert!(svc.write("nonexistent", b"hello").is_err()); + } + + #[test] + fn resize_nonexistent_session_errors() { + let svc = PtyService::new(); + assert!(svc.resize("nonexistent", 80, 24).is_err()); + } +} diff --git a/src/components/ai/CopilotPanel.vue b/src/components/ai/CopilotPanel.vue new file mode 100644 index 0000000..566a9da --- /dev/null +++ b/src/components/ai/CopilotPanel.vue @@ -0,0 +1,159 @@ + + + diff --git a/src/components/ai/GeminiPanel.vue b/src/components/ai/GeminiPanel.vue deleted file mode 100644 index 85befd6..0000000 --- a/src/components/ai/GeminiPanel.vue +++ /dev/null @@ -1,124 +0,0 @@ - - - \ No newline at end of file diff --git a/src/composables/useTerminal.ts b/src/composables/useTerminal.ts index 69d1068..2068c50 100644 --- a/src/composables/useTerminal.ts +++ b/src/composables/useTerminal.ts @@ -51,7 +51,11 @@ export interface UseTerminalReturn { * - SSH stdout → xterm.js (via Tauri listen, base64 encoded) * - Terminal resize → ssh_resize (via Tauri invoke) */ -export function useTerminal(sessionId: string): UseTerminalReturn { +export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'): UseTerminalReturn { + const writeCmd = backend === 'ssh' ? 'ssh_write' : 'pty_write'; + const resizeCmd = backend === 'ssh' ? 'ssh_resize' : 'pty_resize'; + const dataEvent = backend === 'ssh' ? `ssh:data:${sessionId}` : `pty:data:${sessionId}`; + const fitAddon = new FitAddon(); const searchAddon = new SearchAddon(); const webLinksAddon = new WebLinksAddon(); @@ -65,7 +69,7 @@ export function useTerminal(sessionId: string): UseTerminalReturn { cursorStyle: "block", scrollback: 10000, allowProposedApi: true, - convertEol: true, + convertEol: backend === 'ssh', rightClickSelectsWord: false, }); @@ -73,17 +77,17 @@ export function useTerminal(sessionId: string): UseTerminalReturn { terminal.loadAddon(searchAddon); terminal.loadAddon(webLinksAddon); - // Forward typed data to the SSH backend + // Forward typed data to the backend terminal.onData((data: string) => { - invoke("ssh_write", { sessionId, data }).catch((err: unknown) => { - console.error("SSH write error:", err); + invoke(writeCmd, { sessionId, data }).catch((err: unknown) => { + console.error("Write error:", err); }); }); - // Forward resize events to the SSH backend + // Forward resize events to the backend terminal.onResize((size: { cols: number; rows: number }) => { - invoke("ssh_resize", { sessionId, cols: size.cols, rows: size.rows }).catch((err: unknown) => { - console.error("SSH resize error:", err); + invoke(resizeCmd, { sessionId, cols: size.cols, rows: size.rows }).catch((err: unknown) => { + console.error("Resize error:", err); }); }); @@ -100,7 +104,7 @@ export function useTerminal(sessionId: string): UseTerminalReturn { e.stopPropagation(); navigator.clipboard.readText().then((text) => { if (text) { - invoke("ssh_write", { sessionId, data: text }).catch(() => {}); + invoke(writeCmd, { sessionId, data: text }).catch(() => {}); } }).catch(() => {}); } @@ -156,7 +160,7 @@ export function useTerminal(sessionId: string): UseTerminalReturn { // Subscribe to SSH output events for this session. // Tauri v2 listen() callback receives { payload: T } — the base64 string // is in event.payload (not event.data as in Wails). - unlistenPromise = listen(`ssh:data:${sessionId}`, (event) => { + unlistenPromise = listen(dataEvent, (event) => { const b64data = event.payload; try { diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 8e6f1ba..0d2b244 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -68,9 +68,9 @@ @@ -164,8 +164,8 @@ - - + + @@ -202,7 +202,7 @@ import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog. import FileTree from "@/components/sftp/FileTree.vue"; import TransferProgress from "@/components/sftp/TransferProgress.vue"; import EditorWindow from "@/components/editor/EditorWindow.vue"; -import GeminiPanel from "@/components/ai/GeminiPanel.vue"; +import CopilotPanel from "@/components/ai/CopilotPanel.vue"; import type { FileEntry } from "@/composables/useSftp"; import type { ThemeDefinition } from "@/components/common/ThemePicker.vue"; @@ -216,7 +216,7 @@ const activeSessionId = computed(() => sessionStore.activeSessionId); const sidebarWidth = ref(240); const sidebarVisible = ref(true); const sidebarTab = ref("connections"); -const geminiVisible = ref(false); +const copilotVisible = ref(false); const quickConnectInput = ref(""); const commandPalette = ref | null>(null); @@ -290,7 +290,7 @@ function handleKeydown(event: KeyboardEvent): void { if (ctrl && event.key === "Tab" && event.shiftKey) { event.preventDefault(); const sessions = sessionStore.sessions; if (sessions.length < 2) return; const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId); const prev = sessions[(idx - 1 + sessions.length) % sessions.length]; sessionStore.activateSession(prev.id); return; } if (ctrl && event.key >= "1" && event.key <= "9") { const tabIndex = parseInt(event.key, 10) - 1; const sessions = sessionStore.sessions; if (tabIndex < sessions.length) { event.preventDefault(); sessionStore.activateSession(sessions[tabIndex].id); } return; } if (ctrl && event.key === "b") { event.preventDefault(); sidebarVisible.value = !sidebarVisible.value; return; } - if (ctrl && event.shiftKey && event.key.toLowerCase() === "g") { event.preventDefault(); geminiVisible.value = !geminiVisible.value; return; } + if (ctrl && event.shiftKey && event.key.toLowerCase() === "g") { event.preventDefault(); copilotVisible.value = !copilotVisible.value; return; } if (ctrl && event.key === "f") { const active = sessionStore.activeSession; if (active?.protocol === "ssh") { event.preventDefault(); sessionContainer.value?.openActiveSearch(); } return; } }