docs: Local PTY Copilot implementation plan — 9 tasks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2d92ff806c
commit
a845d42661
780
docs/superpowers/plans/2026-03-24-local-pty-copilot.md
Normal file
780
docs/superpowers/plans/2026-03-24-local-pty-copilot.md
Normal file
@ -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<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**
|
||||||
|
|
||||||
|
```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<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:
|
||||||
|
|
||||||
|
```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<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**
|
||||||
|
|
||||||
|
```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
|
||||||
|
<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**
|
||||||
|
|
||||||
|
```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
|
||||||
|
<GeminiPanel v-if="geminiVisible" />
|
||||||
|
```
|
||||||
|
with:
|
||||||
|
```html
|
||||||
|
<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**
|
||||||
|
|
||||||
|
```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).
|
||||||
Loading…
Reference in New Issue
Block a user