187 lines
7.9 KiB
Markdown
187 lines
7.9 KiB
Markdown
# 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
|