From a845d4266170586bc17934ef3f7c7ceeb6ab87a1 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Tue, 24 Mar 2026 20:38:26 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20Local=20PTY=20Copilot=20implementation?= =?UTF-8?q?=20plan=20=E2=80=94=209=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-24-local-pty-copilot.md | 780 ++++++++++++++++++ 1 file changed, 780 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-24-local-pty-copilot.md diff --git a/docs/superpowers/plans/2026-03-24-local-pty-copilot.md b/docs/superpowers/plans/2026-03-24-local-pty-copilot.md new file mode 100644 index 0000000..d5c5165 --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-local-pty-copilot.md @@ -0,0 +1,780 @@ +# 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).