wraith/docs/superpowers/plans/2026-03-24-local-pty-copilot.md
Vantz Stockwell a845d42661 docs: Local PTY Copilot implementation plan — 9 tasks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:38:26 -04:00

22 KiB

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:

portable-pty = "0.8"
  • Step 2: Verify it resolves

Run: cd src-tauri && cargo check Expected: compiles with no errors

  • Step 3: Commit
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

//! 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)]
        {
            // 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<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 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
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

//! 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)
}
  • Step 2: Add pub mod pty_commands; to src-tauri/src/commands/mod.rs

Replace the ai_commands line:

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
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<Option<ai::GeminiClient>> 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
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
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:

export function useTerminal(sessionId: string): UseTerminalReturn {

to:

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):

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:

convertEol: true,

to:

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

<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;

  // Use defaults until terminal is mounted and measured
  const cols = 80;
  const rows = 24;

  try {
    sessionId = await invoke<string>("spawn_local_shell", {
      shellPath: selectedShell.value,
      cols,
      rows,
    });
    connected.value = true;

    // Wait for DOM update so containerRef is available
    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>
  • Step 2: Verify TypeScript compiles

Run: npx vue-tsc --noEmit Expected: no errors

  • Step 3: Commit
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):

import GeminiPanel from "@/components/ai/GeminiPanel.vue";

with:

import CopilotPanel from "@/components/ai/CopilotPanel.vue";

Replace the template usage (line 168):

<GeminiPanel v-if="geminiVisible" />

with:

<CopilotPanel v-if="copilotVisible" />

Rename the ref and all references (line 219, 71, 73, 293):

  • geminiVisiblecopilotVisible

  • Update the toolbar button title: "Gemini XO (Ctrl+Shift+G)""AI Copilot (Ctrl+Shift+G)"

  • Step 2: Delete GeminiPanel.vue

rm src/components/ai/GeminiPanel.vue
  • Step 3: Verify frontend compiles

Run: npx vue-tsc --noEmit Expected: no errors

  • Step 4: Commit
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:

#[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
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
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).