docs: Local PTY Copilot Panel design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
eda36c937b
commit
2d92ff806c
186
docs/superpowers/specs/2026-03-24-local-pty-copilot-design.md
Normal file
186
docs/superpowers/specs/2026-03-24-local-pty-copilot-design.md
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user