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;tosrc-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:
- Replace
pub mod ai;withpub mod pty; - Replace
usefor ai withuse pty::PtyService; - Replace
gemini: Mutex<Option<ai::GeminiClient>>withpub pty: PtyService - Replace
gemini: Mutex::new(None)withpty: PtyService::new() - 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):
-
geminiVisible→copilotVisible -
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).