7.9 KiB
Local PTY Copilot Panel — Design Spec
Date: 2026-03-24 Status: Approved Author: Claude Opus 4.6 (XO)
Problem
The AI panel is a Gemini API stub (~130 lines backend, ~124 lines frontend) with no OAuth, no conversation history, no tool use. The Commander pays $200/mo Claude Max, $20/mo Gemini, $20/mo ChatGPT — all of which include CLI tool access (Claude Code, Gemini CLI, Codex CLI). These CLIs are designed for terminals. Wraith has a terminal. Ship a local PTY in the sidebar and let the user run whichever CLI they want.
Solution
Replace the Gemini stub with a local PTY terminal in the sidebar panel. Reuse the existing xterm.js infrastructure. User picks a shell (bash, sh, zsh, PowerShell, Git Bash), the panel spawns it locally, and they run claude, gemini, codex, or anything else.
Architecture
Backend — src-tauri/src/pty/mod.rs
New module following the same patterns as SshService:
PtyService
sessions: DashMap<String, Arc<PtySession>>
PtySession
id: String
writer: Mutex<Box<dyn Write + Send>> // from master.take_writer()
master: Mutex<Box<dyn MasterPty + Send>> // kept for resize()
child: Mutex<Box<dyn Child + Send + Sync>> // for kill/wait
shell_path: String
PtyService methods:
spawn(shell_path, cols, rows, app_handle) -> Result<String, String>
write(session_id, data) -> Result<(), String>
resize(session_id, cols, rows) -> Result<(), String>
disconnect(session_id) -> Result<(), String>
list_shells() -> Vec<ShellInfo>
Note: writer and master require Mutex wrappers because portable-pty trait objects are Send but not Sync, and the DashMap requires Sync on stored values.
PTY crate: portable-pty — cross-platform (Unix PTY, Windows ConPTY). MIT licensed. Part of the wezterm project.
Shell detection (list_shells):
- Unix: check existence of
/bin/bash,/bin/sh,/bin/zsh,$SHELL - Windows:
powershell.exe,cmd.exe, plus scan for Git Bash at common paths (C:\Program Files\Git\bin\bash.exe,C:\Program Files (x86)\Git\bin\bash.exe) - Return
Vec<ShellInfo>with{ name, path }pairs
Output loop — spawned per session via spawn_blocking (not async — portable-pty reader is synchronous std::io::Read):
tokio::task::spawn_blocking(move || {
let mut reader = BufReader::new(pty_reader);
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) => { app.emit("pty:close:{id}", ()); break; }
Ok(n) => { app.emit("pty:data:{id}", base64(&buf[..n])); }
Err(_) => break;
}
}
});
AppHandle::emit() is synchronous in Tauri v2, so it works from a blocking thread context without issues.
Environment: CommandBuilder inherits the parent process environment by default. This is required so that PATH includes the user's CLI tools (claude, gemini, codex). No env filtering should be applied.
Backend — Tauri Commands (src-tauri/src/commands/pty_commands.rs)
spawn_local_shell(shell_path: String, cols: u32, rows: u32) -> Result<String, String>
pty_write(session_id: String, data: String) -> Result<(), String>
pty_resize(session_id: String, cols: u32, rows: u32) -> Result<(), String>
disconnect_pty(session_id: String) -> Result<(), String>
list_available_shells() -> Result<Vec<ShellInfo>, String>
All registered in lib.rs invoke handler. All added to capabilities/default.json.
Backend — AppState Changes
pub struct AppState {
// ... existing fields ...
pub pty: PtyService, // ADD
// pub gemini: Mutex<...>, // DELETE
}
Frontend — src/components/ai/CopilotPanel.vue
Replaces GeminiPanel.vue. Structure:
- Header bar: "AI Copilot" title + shell selector dropdown + spawn/kill buttons
- Terminal area: xterm.js instance via
useTerminalcomposable (adapted for PTY events) - State: shell list (from
list_available_shells), active session ID, connected flag - Close handling: Listen for
pty:close:{session_id}events to updateconnectedstate and show "Session ended — Relaunch?" UI. This differs from the SSH path where tab closure handles cleanup.
Shell selector is a <select> dropdown populated on mount. "Launch" button calls spawn_local_shell. Terminal mounts when session starts.
Initial terminal size: On spawn, measure terminal dimensions via fitAddon.fit() before invoking spawn_local_shell. Pass the measured cols/rows. If the terminal is not yet mounted, use defaults (80x24) and immediately resize after mount.
Frontend — useTerminal Adaptation
Current useTerminal.ts hardcodes ssh_write, ssh_resize, and ssh:data: events.
Chosen approach: Parameterize the composable to accept a "backend type":
export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh')
backend === 'ssh'→invoke("ssh_write"),listen("ssh:data:{id}"),convertEol: truebackend === 'pty'→invoke("pty_write"),listen("pty:data:{id}"),convertEol: false
Same xterm.js instance, same resize observer, same clipboard, same base64 decode. Only the invoke target, event prefix, and EOL conversion change.
Important: The local PTY driver already translates LF to CRLF. The SSH path needs convertEol: true because raw SSH streams may send bare LF. Setting convertEol: true on the PTY path would produce double newlines.
Cleanup — Delete Gemini Stub
Remove entirely:
src-tauri/src/ai/mod.rssrc-tauri/src/commands/ai_commands.rssrc/components/ai/GeminiPanel.vueAppState.geminifield andMutex<Option<ai::GeminiClient>>inlib.rs- AI command registrations from invoke handler
pub mod ai;fromlib.rs
Keep reqwest in Cargo.toml — the RDP stack (ironrdp-tokio, sspi) depends on it transitively and may require the json feature flag our direct dependency enables.
Data Flow
User types in copilot panel
→ useTerminal.onData(data)
→ invoke("pty_write", { sessionId, data })
→ PtyService.write() → writer.write_all(data)
→ PTY stdin → shell process
Shell output → PTY stdout
→ output reader loop (spawn_blocking)
→ app.emit("pty:data:{id}", base64(bytes))
→ useTerminal listener → base64 decode → xterm.js.write()
Shell exits (user types "exit" or CLI tool quits)
→ reader returns Ok(0)
→ app.emit("pty:close:{id}", ())
→ CopilotPanel listens → updates connected state → shows relaunch UI
Tauri ACL
No changes needed to capabilities/default.json — core:default covers command invocation, core:event:default covers event listening. The PTY commands are registered via generate_handler!.
Testing
Rust tests:
list_shells()returns at least one shell on any platformspawn()+write("echo hello\n")+ read output contains "hello"resize()doesn't error on active sessiondisconnect()removes session from registrydisconnect()on nonexistent session returns error
Frontend: useTerminal composable already tested via SSH path. The backend parameter is a simple branch — no separate test needed.
Dependencies
Add:
portable-pty— cross-platform PTY (MIT license, part of wezterm project)
No removals — reqwest, md5, pem, and other existing deps serve SSH and RDP functionality.
Migration
No data migration needed. The Gemini stub stores nothing persistent — no DB tables, no settings, no vault entries. Clean delete.
Success Criteria
- Commander opens Wraith, presses Ctrl+Shift+G
- Shell dropdown shows detected shells (bash on macOS, PowerShell + Git Bash on Windows)
- Selects shell, clicks Launch
- Full interactive terminal appears in sidebar
- Types
claude(orgeminiorcodex) — CLI launches, works normally - Resize sidebar → terminal reflows
- Close panel or kill session → PTY process terminates cleanly
- Shell exits → panel shows "Session ended — Relaunch?" prompt