# 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> PtySession id: String writer: Mutex> // from master.take_writer() master: Mutex> // kept for resize() child: Mutex> // for kill/wait shell_path: String PtyService methods: spawn(shell_path, cols, rows, app_handle) -> Result write(session_id, data) -> Result<(), String> resize(session_id, cols, rows) -> Result<(), String> disconnect(session_id) -> Result<(), String> list_shells() -> Vec ``` 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` with `{ name, path }` pairs **Output loop** — spawned per session via `spawn_blocking` (not async — `portable-pty` reader is synchronous `std::io::Read`): ```rust 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`) ```rust spawn_local_shell(shell_path: String, cols: u32, rows: u32) -> Result 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, String> ``` All registered in `lib.rs` invoke handler. All added to `capabilities/default.json`. ### Backend — AppState Changes ```rust pub struct AppState { // ... existing fields ... pub pty: PtyService, // ADD // pub gemini: Mutex<...>, // DELETE } ``` ### Frontend — `src/components/ai/CopilotPanel.vue` Replaces `GeminiPanel.vue`. Structure: 1. **Header bar:** "AI Copilot" title + shell selector dropdown + spawn/kill buttons 2. **Terminal area:** xterm.js instance via `useTerminal` composable (adapted for PTY events) 3. **State:** shell list (from `list_available_shells`), active session ID, connected flag 4. **Close handling:** Listen for `pty:close:{session_id}` events to update `connected` state and show "Session ended — Relaunch?" UI. This differs from the SSH path where tab closure handles cleanup. Shell selector is a `