# Local PTY Copilot Panel — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the Gemini API stub with a local PTY terminal in the sidebar where users run CLI tools (claude, gemini, codex) directly. **Architecture:** New `PtyService` module mirrors `SshService` patterns — DashMap session registry, `portable-pty` for cross-platform PTY spawn, `spawn_blocking` output loop emitting Tauri events. Frontend reuses existing `useTerminal` composable with a new `backend` parameter. Gemini stub deleted entirely. **Tech Stack:** portable-pty (Rust PTY), xterm.js (existing), Tauri v2 events (existing) **Spec:** `docs/superpowers/specs/2026-03-24-local-pty-copilot-design.md` --- ### Task 1: Add portable-pty dependency **Files:** - Modify: `src-tauri/Cargo.toml` - [ ] **Step 1: Add portable-pty to Cargo.toml** Add under the existing dependencies: ```toml portable-pty = "0.8" ``` - [ ] **Step 2: Verify it resolves** Run: `cd src-tauri && cargo check` Expected: compiles with no errors - [ ] **Step 3: Commit** ```bash git add src-tauri/Cargo.toml src-tauri/Cargo.lock git commit -m "deps: add portable-pty for local PTY support" ``` --- ### Task 2: Create PtyService backend module **Files:** - Create: `src-tauri/src/pty/mod.rs` - [ ] **Step 1: Create the pty module with PtyService, PtySession, ShellInfo, list_shells** ```rust //! 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)] { // Check $SHELL first (user's default) 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 mut cmd = CommandBuilder::new(shell_path); // Inherit parent environment so PATH includes CLI tools // CommandBuilder inherits env by default — no action needed 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(()) } } ``` - [ ] **Step 2: Verify it compiles** Add `pub mod pty;` to `src-tauri/src/lib.rs` temporarily (just the module declaration, full AppState wiring comes in Task 4). Run: `cd src-tauri && cargo check` Expected: compiles (warnings about unused code are fine here) - [ ] **Step 3: Commit** ```bash git add src-tauri/src/pty/mod.rs src-tauri/src/lib.rs git commit -m "feat: PtyService — local PTY spawn, write, resize, disconnect" ``` --- ### Task 3: Create PTY Tauri commands **Files:** - Create: `src-tauri/src/commands/pty_commands.rs` - Modify: `src-tauri/src/commands/mod.rs` - [ ] **Step 1: Create pty_commands.rs** ```rust //! 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) } ``` - [ ] **Step 2: Add `pub mod pty_commands;` to `src-tauri/src/commands/mod.rs`** Replace the `ai_commands` line: ```rust pub mod vault; pub mod settings; pub mod connections; pub mod credentials; pub mod ssh_commands; pub mod sftp_commands; pub mod rdp_commands; pub mod theme_commands; pub mod pty_commands; ``` - [ ] **Step 3: Commit** ```bash git add src-tauri/src/commands/pty_commands.rs src-tauri/src/commands/mod.rs git commit -m "feat: PTY Tauri commands — spawn, write, resize, disconnect, list shells" ``` --- ### Task 4: Wire PtyService into AppState and delete Gemini stub **Files:** - Modify: `src-tauri/src/lib.rs` - Delete: `src-tauri/src/ai/mod.rs` - Delete: `src-tauri/src/commands/ai_commands.rs` - [ ] **Step 1: Update lib.rs** Full replacement of `lib.rs`: Changes: 1. Replace `pub mod ai;` with `pub mod pty;` 2. Replace `use` for ai with `use pty::PtyService;` 3. Replace `gemini: Mutex>` with `pub pty: PtyService` 4. Replace `gemini: Mutex::new(None)` with `pty: PtyService::new()` 5. Replace AI command registrations with PTY command registrations in `generate_handler!` The `generate_handler!` line 110 should change from: ``` commands::ai_commands::set_gemini_auth, commands::ai_commands::gemini_chat, commands::ai_commands::is_gemini_authenticated, ``` to: ``` 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, ``` - [ ] **Step 2: Delete Gemini files** ```bash rm src-tauri/src/ai/mod.rs rmdir src-tauri/src/ai rm src-tauri/src/commands/ai_commands.rs ``` - [ ] **Step 3: Verify build** Run: `cd src-tauri && cargo build` Expected: compiles with zero warnings - [ ] **Step 4: Run tests** Run: `cd src-tauri && cargo test` Expected: 82 tests pass (existing tests unaffected) - [ ] **Step 5: Commit** ```bash git add -A git commit -m "refactor: replace Gemini stub with PtyService in AppState" ``` --- ### Task 5: Parameterize useTerminal composable **Files:** - Modify: `src/composables/useTerminal.ts` - [ ] **Step 1: Add backend parameter** Change the function signature from: ```typescript export function useTerminal(sessionId: string): UseTerminalReturn { ``` to: ```typescript export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'): UseTerminalReturn { ``` - [ ] **Step 2: Derive command/event names from backend** Add at the top of the function body (after the addons, before the Terminal constructor): ```typescript 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}`; ``` - [ ] **Step 3: Set convertEol based on backend** In the Terminal constructor options, change: ```typescript convertEol: true, ``` to: ```typescript convertEol: backend === 'ssh', ``` - [ ] **Step 4: Replace hardcoded command names** Replace all `invoke("ssh_write"` with `invoke(writeCmd` (3 occurrences: onData handler, right-click paste handler). Replace `invoke("ssh_resize"` with `invoke(resizeCmd` (1 occurrence: onResize handler). Replace `` `ssh:data:${sessionId}` `` with `dataEvent` (1 occurrence: listen call in mount). Replace error log strings: `"SSH write error:"` → `"Write error:"`, `"SSH resize error:"` → `"Resize error:"`. - [ ] **Step 5: Verify existing SSH path still works** Run: `npx vue-tsc --noEmit` — should compile clean. Existing callers pass no second argument, so they default to `'ssh'`. - [ ] **Step 6: Commit** ```bash git add src/composables/useTerminal.ts git commit -m "refactor: parameterize useTerminal for ssh/pty backends" ``` --- ### Task 6: Create CopilotPanel.vue **Files:** - Create: `src/components/ai/CopilotPanel.vue` - [ ] **Step 1: Create the component** ```vue ``` - [ ] **Step 2: Verify TypeScript compiles** Run: `npx vue-tsc --noEmit` Expected: no errors - [ ] **Step 3: Commit** ```bash git add src/components/ai/CopilotPanel.vue git commit -m "feat: CopilotPanel — local PTY terminal in AI sidebar" ``` --- ### Task 7: Update MainLayout and delete GeminiPanel **Files:** - Modify: `src/layouts/MainLayout.vue` - Delete: `src/components/ai/GeminiPanel.vue` - [ ] **Step 1: Update MainLayout imports and template** In `MainLayout.vue`: Replace the import (line 205): ```typescript import GeminiPanel from "@/components/ai/GeminiPanel.vue"; ``` with: ```typescript import CopilotPanel from "@/components/ai/CopilotPanel.vue"; ``` Replace the template usage (line 168): ```html ``` with: ```html ``` Rename the ref and all references (line 219, 71, 73, 293): - `geminiVisible` → `copilotVisible` - Update the toolbar button title: `"Gemini XO (Ctrl+Shift+G)"` → `"AI Copilot (Ctrl+Shift+G)"` - [ ] **Step 2: Delete GeminiPanel.vue** ```bash rm src/components/ai/GeminiPanel.vue ``` - [ ] **Step 3: Verify frontend compiles** Run: `npx vue-tsc --noEmit` Expected: no errors - [ ] **Step 4: Commit** ```bash git add -A git commit -m "feat: swap GeminiPanel for CopilotPanel in MainLayout" ``` --- ### Task 8: Add PTY tests **Files:** - Modify: `src-tauri/src/pty/mod.rs` - [ ] **Step 1: Add test module to pty/mod.rs** Append to the bottom of `src-tauri/src/pty/mod.rs`: ```rust #[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 paths: Vec<&str> = shells.iter().map(|s| s.path.as_str()).collect(); let mut unique = paths.clone(); unique.sort(); unique.dedup(); assert_eq!(paths.len(), unique.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()); } } ``` - [ ] **Step 2: Run tests** Run: `cd src-tauri && cargo test` Expected: 87+ tests pass (82 existing + 5 new), zero warnings - [ ] **Step 3: Commit** ```bash git add src-tauri/src/pty/mod.rs git commit -m "test: PtyService unit tests — shell detection, error paths" ``` --- ### Task 9: Final build, verify, tag **Files:** None (verification only) - [ ] **Step 1: Full Rust build with zero warnings** Run: `cd src-tauri && cargo build` Expected: zero warnings, zero errors - [ ] **Step 2: Full test suite** Run: `cd src-tauri && cargo test` Expected: 87+ tests, all passing - [ ] **Step 3: Frontend type check** Run: `npx vue-tsc --noEmit` Expected: no errors - [ ] **Step 4: Push and tag** ```bash git push git tag v1.2.5 git push origin v1.2.5 ``` - [ ] **Step 5: Update CLAUDE.md test count** Update the test count in CLAUDE.md to reflect the new total. Commit and push (do NOT re-tag).