docs: Local PTY Copilot Panel design spec

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-24 20:34:52 -04:00
parent eda36c937b
commit 2d92ff806c

View File

@ -0,0 +1,186 @@
# 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`):
```rust
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`)
```rust
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
```rust
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":
```typescript
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.json``core: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 removals** — `reqwest`, `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