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