wraith/docs/superpowers/specs/2026-03-24-local-pty-copilot-design.md
Vantz Stockwell 2d92ff806c docs: Local PTY Copilot Panel design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:34:52 -04:00

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:

  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 <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: true
  • backend === '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.rs
  • src-tauri/src/commands/ai_commands.rs
  • src/components/ai/GeminiPanel.vue
  • AppState.gemini field and Mutex<Option<ai::GeminiClient>> in lib.rs
  • AI command registrations from invoke handler
  • pub mod ai; from lib.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.jsoncore: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 platform
  • spawn() + write("echo hello\n") + read output contains "hello"
  • resize() doesn't error on active session
  • disconnect() removes session from registry
  • disconnect() 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 removalsreqwest, 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

  1. Commander opens Wraith, presses Ctrl+Shift+G
  2. Shell dropdown shows detected shells (bash on macOS, PowerShell + Git Bash on Windows)
  3. Selects shell, clicks Launch
  4. Full interactive terminal appears in sidebar
  5. Types claude (or gemini or codex) — CLI launches, works normally
  6. Resize sidebar → terminal reflows
  7. Close panel or kill session → PTY process terminates cleanly
  8. Shell exits → panel shows "Session ended — Relaunch?" prompt