feat: Local PTY Copilot Panel — replace Gemini stub with real terminal
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m6s

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) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-24 20:43:18 -04:00
parent a845d42661
commit 512347af5f
16 changed files with 691 additions and 227 deletions

63
AGENTS.md Normal file
View File

@ -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.

View File

@ -61,7 +61,7 @@ npm install # Install frontend deps
npm run dev # Vite dev server only npm run dev # Vite dev server only
cargo tauri dev # Full app (Rust + frontend) cargo tauri dev # Full app (Rust + frontend)
cargo tauri build # Production build 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 cd src-tauri && cargo build # Build Rust only
``` ```

149
src-tauri/Cargo.lock generated
View File

@ -1416,6 +1416,12 @@ dependencies = [
"tendril 0.5.0", "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]] [[package]]
name = "dpi" name = "dpi"
version = "0.1.2" version = "0.1.2"
@ -1729,10 +1735,21 @@ version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
dependencies = [ dependencies = [
"memoffset", "memoffset 0.9.1",
"rustc_version", "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]] [[package]]
name = "filetime" name = "filetime"
version = "0.2.27" version = "0.2.27"
@ -2870,6 +2887,15 @@ dependencies = [
"hybrid-array", "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]] [[package]]
name = "ipconfig" name = "ipconfig"
version = "0.3.2" version = "0.3.2"
@ -3510,6 +3536,15 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memoffset"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "memoffset" name = "memoffset"
version = "0.9.1" version = "0.9.1"
@ -3649,6 +3684,20 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" 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]] [[package]]
name = "nodrop" name = "nodrop"
version = "0.1.14" version = "0.1.14"
@ -4728,6 +4777,27 @@ dependencies = [
"portable-atomic", "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]] [[package]]
name = "portpicker" name = "portpicker"
version = "0.1.1" version = "0.1.1"
@ -5986,6 +6056,48 @@ dependencies = [
"serde", "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]] [[package]]
name = "serialize-to-javascript" name = "serialize-to-javascript"
version = "0.1.2" version = "0.1.2"
@ -6092,6 +6204,22 @@ dependencies = [
"windows-sys 0.60.2", "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]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@ -6947,6 +7075,15 @@ dependencies = [
"utf-8", "utf-8",
] ]
[[package]]
name = "termios"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@ -8482,6 +8619,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "winreg" name = "winreg"
version = "0.50.0" version = "0.50.0"
@ -8635,6 +8781,7 @@ dependencies = [
"md5", "md5",
"pem", "pem",
"pkcs8 0.10.2", "pkcs8 0.10.2",
"portable-pty",
"rand 0.9.2", "rand 0.9.2",
"reqwest 0.12.28", "reqwest 0.12.28",
"rusqlite", "rusqlite",

View File

@ -45,6 +45,9 @@ pem = "3"
pkcs8 = { version = "0.10", features = ["pem"] } pkcs8 = { version = "0.10", features = ["pem"] }
sec1 = { version = "0.7", features = ["pem"] } sec1 = { version = "0.7", features = ["pem"] }
# Local PTY for AI copilot panel
portable-pty = "0.8"
# RDP (IronRDP) # RDP (IronRDP)
ironrdp = { version = "0.14", features = ["connector", "session", "graphics", "input"] } ironrdp = { version = "0.14", features = ["connector", "session", "graphics", "input"] }
ironrdp-tokio = { version = "0.8", features = ["reqwest-rustls-ring"] } ironrdp-tokio = { version = "0.8", features = ["reqwest-rustls-ring"] }

View File

@ -1 +1 @@
{} {"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"]}}

View File

@ -1,18 +1,39 @@
!include "MUI2.nsh" !include "MUI2.nsh"
!include "nsDialogs.nsh"
!include "LogicLib.nsh"
!include "WinMessages.nsh"
Name "Wraith" Name "Wraith"
OutFile "OUTFILE_PLACEHOLDER" OutFile "OUTFILE_PLACEHOLDER"
InstallDir "$PROGRAMFILES64\Wraith" InstallDir "$PROGRAMFILES64\Wraith"
RequestExecutionLevel admin RequestExecutionLevel admin
Var DesktopShortcut
!insertmacro MUI_PAGE_DIRECTORY !insertmacro MUI_PAGE_DIRECTORY
Page custom OptionsPage OptionsPageLeave
!insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_LANGUAGE "English" !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" Section "Install"
SetOutPath "$INSTDIR" SetOutPath "$INSTDIR"
File "Wraith.exe" File "Wraith.exe"
File "wraith.ico" File "wraith.ico"
CreateDirectory "$SMPROGRAMS\Wraith" CreateDirectory "$SMPROGRAMS\Wraith"
CreateShortcut "$SMPROGRAMS\Wraith\Wraith.lnk" "$INSTDIR\Wraith.exe" "" "$INSTDIR\wraith.ico" 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" WriteUninstaller "$INSTDIR\uninstall.exe"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Wraith" "DisplayName" "Wraith" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Wraith" "DisplayName" "Wraith"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Wraith" "UninstallString" "$INSTDIR\uninstall.exe" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Wraith" "UninstallString" "$INSTDIR\uninstall.exe"

View File

@ -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<String>,
expiry: Option<u64>,
},
}
#[derive(Clone)]
pub struct GeminiClient {
auth: Arc<TokioMutex<AuthMethod>>,
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<String> {
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())
}
}

View File

@ -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<String>, 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<String, String> {
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()
}

View File

@ -6,4 +6,4 @@ pub mod ssh_commands;
pub mod sftp_commands; pub mod sftp_commands;
pub mod rdp_commands; pub mod rdp_commands;
pub mod theme_commands; pub mod theme_commands;
pub mod ai_commands; pub mod pty_commands;

View File

@ -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<ShellInfo> {
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<String, String> {
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)
}

View File

@ -8,7 +8,7 @@ pub mod sftp;
pub mod rdp; pub mod rdp;
pub mod theme; pub mod theme;
pub mod workspace; pub mod workspace;
pub mod ai; pub mod pty;
pub mod commands; pub mod commands;
use std::path::PathBuf; use std::path::PathBuf;
@ -24,6 +24,7 @@ use ssh::session::SshService;
use rdp::RdpService; use rdp::RdpService;
use theme::ThemeService; use theme::ThemeService;
use workspace::WorkspaceService; use workspace::WorkspaceService;
use pty::PtyService;
pub struct AppState { pub struct AppState {
pub db: Database, pub db: Database,
@ -36,7 +37,7 @@ pub struct AppState {
pub rdp: RdpService, pub rdp: RdpService,
pub theme: ThemeService, pub theme: ThemeService,
pub workspace: WorkspaceService, pub workspace: WorkspaceService,
pub gemini: Mutex<Option<ai::GeminiClient>>, pub pty: PtyService,
} }
impl AppState { impl AppState {
@ -55,7 +56,7 @@ impl AppState {
rdp: RdpService::new(), rdp: RdpService::new(),
theme: ThemeService::new(database.clone()), theme: ThemeService::new(database.clone()),
workspace: WorkspaceService::new(SettingsService::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::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::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::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!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

217
src-tauri/src/pty/mod.rs Normal file
View File

@ -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<Box<dyn Write + Send>>,
master: Mutex<Box<dyn MasterPty + Send>>,
child: Mutex<Box<dyn Child + Send + Sync>>,
}
pub struct PtyService {
sessions: DashMap<String, Arc<PtySession>>,
}
impl PtyService {
pub fn new() -> Self {
Self { sessions: DashMap::new() }
}
/// Detect available shells on the system.
pub fn list_shells(&self) -> Vec<ShellInfo> {
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<String, String> {
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());
}
}

View File

@ -0,0 +1,159 @@
<template>
<div class="flex flex-col h-full bg-[var(--wraith-bg-secondary)] border-l border-[var(--wraith-border)] w-80">
<!-- Header -->
<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>
<div class="flex items-center gap-1.5">
<select
v-model="selectedShell"
class="bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded px-1.5 py-0.5 text-[10px] text-[var(--wraith-text-secondary)] outline-none"
:disabled="connected"
>
<option v-for="shell in shells" :key="shell.path" :value="shell.path">
{{ shell.name }}
</option>
</select>
<button
v-if="!connected"
class="px-2 py-0.5 text-[10px] font-bold rounded bg-[var(--wraith-accent-blue)] text-black cursor-pointer"
:disabled="!selectedShell"
@click="launch"
>
Launch
</button>
<button
v-else
class="px-2 py-0.5 text-[10px] font-bold rounded bg-[var(--wraith-accent-red,#f85149)] text-white cursor-pointer"
@click="kill"
>
Kill
</button>
</div>
</div>
<!-- Terminal area -->
<div v-if="connected" ref="containerRef" class="flex-1 min-h-0" />
<!-- Session ended prompt -->
<div v-else-if="sessionEnded" class="flex-1 flex flex-col items-center justify-center gap-3 p-4">
<p class="text-xs text-[var(--wraith-text-muted)]">Session ended</p>
<button
class="px-3 py-1.5 text-xs rounded bg-[var(--wraith-accent-blue)] text-black font-bold cursor-pointer"
@click="launch"
>
Relaunch
</button>
</div>
<!-- Empty state -->
<div v-else class="flex-1 flex flex-col items-center justify-center gap-2 p-4">
<p class="text-xs text-[var(--wraith-text-muted)] text-center">
Select a shell and click Launch to start a local terminal.
</p>
<p class="text-[10px] text-[var(--wraith-text-muted)] text-center">
Run <code class="text-[var(--wraith-accent-blue)]">claude</code>,
<code class="text-[var(--wraith-accent-blue)]">gemini</code>, or
<code class="text-[var(--wraith-accent-blue)]">codex</code> here.
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, onMounted, onBeforeUnmount } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { useTerminal } from "@/composables/useTerminal";
interface ShellInfo { name: string; path: string; }
const shells = ref<ShellInfo[]>([]);
const selectedShell = ref("");
const connected = ref(false);
const sessionEnded = ref(false);
const containerRef = ref<HTMLElement | null>(null);
let sessionId = "";
let terminalInstance: ReturnType<typeof useTerminal> | null = null;
let closeUnlisten: UnlistenFn | null = null;
async function loadShells(): Promise<void> {
try {
shells.value = await invoke<ShellInfo[]>("list_available_shells");
if (shells.value.length > 0 && !selectedShell.value) {
selectedShell.value = shells.value[0].path;
}
} catch (err) {
console.error("Failed to list shells:", err);
}
}
async function launch(): Promise<void> {
if (!selectedShell.value) return;
sessionEnded.value = false;
try {
sessionId = await invoke<string>("spawn_local_shell", {
shellPath: selectedShell.value,
cols: 80,
rows: 24,
});
connected.value = true;
await nextTick();
if (containerRef.value) {
terminalInstance = useTerminal(sessionId, "pty");
terminalInstance.mount(containerRef.value);
// Fit after mount to get real dimensions, then resize the PTY
setTimeout(() => {
if (terminalInstance) {
terminalInstance.fit();
const term = terminalInstance.terminal;
invoke("pty_resize", {
sessionId,
cols: term.cols,
rows: term.rows,
}).catch(() => {});
}
}, 50);
}
// Listen for shell exit
closeUnlisten = await listen(`pty:close:${sessionId}`, () => {
cleanup();
sessionEnded.value = true;
});
} catch (err) {
console.error("Failed to spawn shell:", err);
connected.value = false;
}
}
function kill(): void {
if (sessionId) {
invoke("disconnect_pty", { sessionId }).catch(() => {});
}
cleanup();
}
function cleanup(): void {
if (terminalInstance) {
terminalInstance.destroy();
terminalInstance = null;
}
if (closeUnlisten) {
closeUnlisten();
closeUnlisten = null;
}
connected.value = false;
sessionId = "";
}
onMounted(loadShells);
onBeforeUnmount(() => {
if (connected.value) kill();
});
</script>

View File

@ -1,124 +0,0 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
const messages = ref<{role: string, content: string}[]>([]);
const input = ref("");
const isAuthenticated = ref(false);
const loading = ref(false);
const authType = ref<"ApiKey" | "ServiceAccount" | "GoogleAccount">("ApiKey");
const apiKey = ref("");
const saJson = ref("");
async function checkAuth() {
isAuthenticated.value = await invoke("is_gemini_authenticated");
}
async function handleAuth() {
let authValue: any = null;
if (authType.value === "ApiKey") authValue = { type: "ApiKey", value: apiKey.value };
else if (authType.value === "ServiceAccount") authValue = { type: "ServiceAccount", value: saJson.value };
if (!authValue) return;
try {
await invoke("set_gemini_auth", { auth: authValue });
isAuthenticated.value = true;
} catch (e) {
alert(e);
}
}
async function handleSend() {
if (!input.value.trim() || loading.value) return;
const userMsg = input.value;
messages.value.push({ role: "user", content: userMsg });
input.value = "";
loading.value = true;
try {
const response = await invoke<string>("gemini_chat", { message: userMsg });
messages.value.push({ role: "model", content: response });
} catch (e) {
messages.value.push({ role: "error", content: String(e) });
} finally {
loading.value = false;
}
}
onMounted(checkAuth);
</script>
<template>
<div class="flex flex-col h-full bg-[var(--wraith-bg-secondary)] border-l border-[var(--wraith-border)] w-80">
<div class="p-3 border-b border-[var(--wraith-border)] flex items-center justify-between">
<span class="text-xs font-bold tracking-widest text-[var(--wraith-accent-blue)]">GEMINI XO</span>
<button v-if="isAuthenticated" @click="isAuthenticated = false" class="text-[10px] text-[var(--wraith-text-muted)] hover:text-white">REAUTH</button>
</div>
<div v-if="!isAuthenticated" class="p-4 space-y-4">
<div class="space-y-2">
<label class="text-[10px] uppercase text-[var(--wraith-text-secondary)]">Auth Method</label>
<select v-model="authType" class="w-full bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded px-2 py-1 text-xs outline-none">
<option value="ApiKey">API Key</option>
<option value="ServiceAccount">Service Account</option>
<option value="GoogleAccount">Google Account (OAuth)</option>
</select>
</div>
<div v-if="authType === 'ApiKey'" class="space-y-2">
<label class="text-[10px] uppercase text-[var(--wraith-text-secondary)]">API Key</label>
<input v-model="apiKey" type="password" class="w-full bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded px-2 py-1 text-xs outline-none" />
</div>
<div v-if="authType === 'ServiceAccount'" class="space-y-2">
<label class="text-[10px] uppercase text-[var(--wraith-text-secondary)]">JSON Config</label>
<textarea v-model="saJson" rows="5" class="w-full bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded px-2 py-1 text-xs outline-none resize-none"></textarea>
</div>
<div v-if="authType === 'GoogleAccount'" class="p-4 bg-[var(--wraith-bg-tertiary)] rounded-lg text-center border border-dashed border-[var(--wraith-border)]">
<p class="text-[10px] text-[var(--wraith-text-muted)] mb-3">OAuth2 PKCE flow not yet active in this stub</p>
<button disabled class="w-full py-2 bg-white text-black font-bold rounded text-xs opacity-50">SIGN IN WITH GOOGLE</button>
</div>
<button v-if="authType !== 'GoogleAccount'" @click="handleAuth" class="w-full py-2 bg-[var(--wraith-accent-blue)] text-black font-bold rounded text-xs">ACTIVATE XO</button>
</div>
<template v-else>
<div class="flex-1 overflow-y-auto p-3 space-y-4">
<div v-if="messages.length === 0" class="text-center text-[10px] text-[var(--wraith-text-muted)] pt-8">
<p class="mb-1">Gemini XO ready.</p>
<p>Ask me anything about your sessions.</p>
</div>
<div v-for="(msg, i) in messages" :key="i"
:class="['text-xs rounded-lg px-3 py-2 max-w-[90%]',
msg.role === 'user' ? 'bg-[var(--wraith-accent-blue)]/15 text-[var(--wraith-text-primary)] ml-auto' : '',
msg.role === 'model' ? 'bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-primary)]' : '',
msg.role === 'error' ? 'bg-red-500/15 text-red-400' : '']">
{{ msg.content }}
</div>
<div v-if="loading" class="text-xs text-[var(--wraith-text-muted)] animate-pulse">Thinking...</div>
</div>
<div class="p-3 border-t border-[var(--wraith-border)]">
<div class="flex gap-2">
<input
v-model="input"
type="text"
placeholder="Ask the XO..."
class="flex-1 bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded px-2 py-1.5 text-xs outline-none focus:border-[var(--wraith-accent-blue)]"
@keyup.enter="handleSend"
:disabled="loading"
/>
<button
@click="handleSend"
:disabled="loading || !input.trim()"
class="px-3 py-1.5 bg-[var(--wraith-accent-blue)] text-black font-bold rounded text-xs disabled:opacity-40"
>
&gt;
</button>
</div>
</div>
</template>
</div>
</template>

View File

@ -51,7 +51,11 @@ export interface UseTerminalReturn {
* - SSH stdout xterm.js (via Tauri listen, base64 encoded) * - SSH stdout xterm.js (via Tauri listen, base64 encoded)
* - Terminal resize ssh_resize (via Tauri invoke) * - 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 fitAddon = new FitAddon();
const searchAddon = new SearchAddon(); const searchAddon = new SearchAddon();
const webLinksAddon = new WebLinksAddon(); const webLinksAddon = new WebLinksAddon();
@ -65,7 +69,7 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
cursorStyle: "block", cursorStyle: "block",
scrollback: 10000, scrollback: 10000,
allowProposedApi: true, allowProposedApi: true,
convertEol: true, convertEol: backend === 'ssh',
rightClickSelectsWord: false, rightClickSelectsWord: false,
}); });
@ -73,17 +77,17 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
terminal.loadAddon(searchAddon); terminal.loadAddon(searchAddon);
terminal.loadAddon(webLinksAddon); terminal.loadAddon(webLinksAddon);
// Forward typed data to the SSH backend // Forward typed data to the backend
terminal.onData((data: string) => { terminal.onData((data: string) => {
invoke("ssh_write", { sessionId, data }).catch((err: unknown) => { invoke(writeCmd, { sessionId, data }).catch((err: unknown) => {
console.error("SSH write error:", err); 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 }) => { terminal.onResize((size: { cols: number; rows: number }) => {
invoke("ssh_resize", { sessionId, cols: size.cols, rows: size.rows }).catch((err: unknown) => { invoke(resizeCmd, { sessionId, cols: size.cols, rows: size.rows }).catch((err: unknown) => {
console.error("SSH resize error:", err); console.error("Resize error:", err);
}); });
}); });
@ -100,7 +104,7 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
e.stopPropagation(); e.stopPropagation();
navigator.clipboard.readText().then((text) => { navigator.clipboard.readText().then((text) => {
if (text) { if (text) {
invoke("ssh_write", { sessionId, data: text }).catch(() => {}); invoke(writeCmd, { sessionId, data: text }).catch(() => {});
} }
}).catch(() => {}); }).catch(() => {});
} }
@ -156,7 +160,7 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
// Subscribe to SSH output events for this session. // Subscribe to SSH output events for this session.
// Tauri v2 listen() callback receives { payload: T } — the base64 string // Tauri v2 listen() callback receives { payload: T } — the base64 string
// is in event.payload (not event.data as in Wails). // is in event.payload (not event.data as in Wails).
unlistenPromise = listen<string>(`ssh:data:${sessionId}`, (event) => { unlistenPromise = listen<string>(dataEvent, (event) => {
const b64data = event.payload; const b64data = event.payload;
try { try {

View File

@ -68,9 +68,9 @@
<button <button
class="hover:text-[var(--wraith-accent-blue)] transition-colors cursor-pointer" class="hover:text-[var(--wraith-accent-blue)] transition-colors cursor-pointer"
:class="{ 'text-[var(--wraith-accent-blue)]': geminiVisible }" :class="{ 'text-[var(--wraith-accent-blue)]': copilotVisible }"
title="Gemini XO (Ctrl+Shift+G)" title="AI Copilot (Ctrl+Shift+G)"
@click="geminiVisible = !geminiVisible" @click="copilotVisible = !copilotVisible"
> >
AI AI
</button> </button>
@ -164,8 +164,8 @@
<SessionContainer ref="sessionContainer" /> <SessionContainer ref="sessionContainer" />
</div> </div>
<!-- Gemini AI Panel --> <!-- AI Copilot Panel -->
<GeminiPanel v-if="geminiVisible" /> <CopilotPanel v-if="copilotVisible" />
</div> </div>
<!-- Status bar --> <!-- Status bar -->
@ -202,7 +202,7 @@ import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.
import FileTree from "@/components/sftp/FileTree.vue"; import FileTree from "@/components/sftp/FileTree.vue";
import TransferProgress from "@/components/sftp/TransferProgress.vue"; import TransferProgress from "@/components/sftp/TransferProgress.vue";
import EditorWindow from "@/components/editor/EditorWindow.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 { FileEntry } from "@/composables/useSftp";
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue"; import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
@ -216,7 +216,7 @@ const activeSessionId = computed(() => sessionStore.activeSessionId);
const sidebarWidth = ref(240); const sidebarWidth = ref(240);
const sidebarVisible = ref(true); const sidebarVisible = ref(true);
const sidebarTab = ref<SidebarTab>("connections"); const sidebarTab = ref<SidebarTab>("connections");
const geminiVisible = ref(false); const copilotVisible = ref(false);
const quickConnectInput = ref(""); const quickConnectInput = ref("");
const commandPalette = ref<InstanceType<typeof CommandPalette> | null>(null); const commandPalette = ref<InstanceType<typeof CommandPalette> | 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 === "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 >= "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.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; } if (ctrl && event.key === "f") { const active = sessionStore.activeSession; if (active?.protocol === "ssh") { event.preventDefault(); sessionContainer.value?.openActiveSearch(); } return; }
} }