wraith/src/composables/useTerminal.ts
Vantz Stockwell 512347af5f
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m6s
feat: Local PTY Copilot Panel — replace Gemini stub with real terminal
Ripped out the Gemini API stub (ai/mod.rs, ai_commands.rs, GeminiPanel.vue)
and replaced it with a local PTY terminal in the sidebar panel. Users select
a shell (bash/zsh/sh on Unix, PowerShell/CMD/Git Bash on Windows), launch it,
and run claude/gemini/codex or any CLI tool directly.

Backend:
- New PtyService module using portable-pty (cross-platform PTY)
- DashMap session registry (same pattern as SshService)
- spawn_blocking output loop (portable-pty reader is synchronous)
- 5 Tauri commands: list_available_shells, spawn_local_shell, pty_write,
  pty_resize, disconnect_pty

Frontend:
- Parameterized useTerminal composable: backend='ssh'|'pty'
- convertEol=false for PTY (PTY driver handles LF→CRLF)
- CopilotPanel.vue with shell selector, launch/kill, session ended prompt
- Ctrl+Shift+G toggle preserved

Tests: 87 total (5 new PTY tests), zero warnings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:43:18 -04:00

242 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { onBeforeUnmount } from "vue";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { SearchAddon } from "@xterm/addon-search";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import "@xterm/xterm/css/xterm.css";
/** MobaXTerm Classicinspired terminal theme colors. */
const defaultTheme = {
background: "#0d1117",
foreground: "#e0e0e0",
cursor: "#58a6ff",
cursorAccent: "#0d1117",
selectionBackground: "rgba(88, 166, 255, 0.3)",
selectionForeground: "#ffffff",
black: "#0d1117",
red: "#f85149",
green: "#3fb950",
yellow: "#e3b341",
blue: "#58a6ff",
magenta: "#bc8cff",
cyan: "#39c5cf",
white: "#e0e0e0",
brightBlack: "#484f58",
brightRed: "#ff7b72",
brightGreen: "#56d364",
brightYellow: "#e3b341",
brightBlue: "#79c0ff",
brightMagenta: "#d2a8ff",
brightCyan: "#56d4dd",
brightWhite: "#f0f6fc",
};
export interface UseTerminalReturn {
terminal: Terminal;
fitAddon: FitAddon;
searchAddon: SearchAddon;
mount: (container: HTMLElement) => void;
destroy: () => void;
write: (data: string) => void;
fit: () => void;
}
/**
* Composable that manages an xterm.js Terminal lifecycle.
*
* Wires bidirectional I/O:
* - User keystrokes → ssh_write (via Tauri invoke)
* - SSH stdout → xterm.js (via Tauri listen, base64 encoded)
* - Terminal resize → ssh_resize (via Tauri invoke)
*/
export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'): UseTerminalReturn {
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}`;
const fitAddon = new FitAddon();
const searchAddon = new SearchAddon();
const webLinksAddon = new WebLinksAddon();
const terminal = new Terminal({
theme: defaultTheme,
fontFamily: "'Cascadia Mono', 'Cascadia Code', Consolas, 'JetBrains Mono', 'Fira Code', Menlo, Monaco, 'Courier New', monospace",
fontSize: 14,
lineHeight: 1.2,
cursorBlink: true,
cursorStyle: "block",
scrollback: 10000,
allowProposedApi: true,
convertEol: backend === 'ssh',
rightClickSelectsWord: false,
});
terminal.loadAddon(fitAddon);
terminal.loadAddon(searchAddon);
terminal.loadAddon(webLinksAddon);
// Forward typed data to the backend
terminal.onData((data: string) => {
invoke(writeCmd, { sessionId, data }).catch((err: unknown) => {
console.error("Write error:", err);
});
});
// Forward resize events to the backend
terminal.onResize((size: { cols: number; rows: number }) => {
invoke(resizeCmd, { sessionId, cols: size.cols, rows: size.rows }).catch((err: unknown) => {
console.error("Resize error:", err);
});
});
// MobaXTerm-style clipboard: highlight to copy, right-click to paste
const selectionDisposable = terminal.onSelectionChange(() => {
const sel = terminal.getSelection();
if (sel) {
navigator.clipboard.writeText(sel).catch(() => {});
}
});
function handleRightClickPaste(e: MouseEvent): void {
e.preventDefault();
e.stopPropagation();
navigator.clipboard.readText().then((text) => {
if (text) {
invoke(writeCmd, { sessionId, data: text }).catch(() => {});
}
}).catch(() => {});
}
// Listen for SSH output events from the Rust backend (base64 encoded)
// Tauri listen() returns a Promise<UnlistenFn> — store both the promise and
// the resolved unlisten function so we can clean up properly.
let unlistenFn: UnlistenFn | null = null;
let unlistenPromise: Promise<UnlistenFn> | null = null;
let resizeObserver: ResizeObserver | null = null;
// Streaming TextDecoder persists across events so split multi-byte UTF-8
// sequences at chunk boundaries are decoded correctly (e.g. a 3-byte em-dash
// split across two Rust read() calls).
const utf8Decoder = new TextDecoder("utf-8", { fatal: false });
// Write batching — accumulate chunks and flush once per animation frame.
// Without this, every tiny SSH read (sometimes single characters) triggers
// a separate terminal.write(), producing a laggy typewriter effect.
let pendingData = "";
let rafId: number | null = null;
function flushPendingData(): void {
rafId = null;
if (pendingData) {
terminal.write(pendingData);
pendingData = "";
}
}
function queueWrite(data: string): void {
pendingData += data;
if (rafId === null) {
rafId = requestAnimationFrame(flushPendingData);
}
}
function mount(container: HTMLElement): void {
terminal.open(container);
// Wait for fonts to load before measuring cell dimensions.
// If the font (Cascadia Mono etc.) isn't loaded when fitAddon.fit()
// runs, canvas.measureText() uses a fallback font and gets wrong
// cell widths — producing tiny dashes and 200+ column terminals.
document.fonts.ready.then(() => {
fitAddon.fit();
});
// Right-click paste on the terminal's DOM element
terminal.element?.addEventListener("contextmenu", handleRightClickPaste);
// Subscribe to SSH output events for this session.
// Tauri v2 listen() callback receives { payload: T } — the base64 string
// is in event.payload (not event.data as in Wails).
unlistenPromise = listen<string>(dataEvent, (event) => {
const b64data = event.payload;
try {
// atob() returns Latin-1 — each byte becomes a char code 0x000xFF.
// Reconstruct raw bytes, then decode with the streaming TextDecoder
// which buffers incomplete multi-byte sequences between calls.
const binaryStr = atob(b64data);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
const decoded = utf8Decoder.decode(bytes, { stream: true });
if (decoded) {
queueWrite(decoded);
}
} catch {
// Fallback: write raw if not valid base64
queueWrite(b64data);
}
});
// Capture the resolved unlisten function for synchronous cleanup
unlistenPromise.then((fn) => {
unlistenFn = fn;
});
// Auto-fit when the container resizes
resizeObserver = new ResizeObserver(() => {
fitAddon.fit();
});
resizeObserver.observe(container);
}
function destroy(): void {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
// Flush any remaining buffered data before teardown
if (pendingData) {
terminal.write(pendingData);
pendingData = "";
}
terminal.element?.removeEventListener("contextmenu", handleRightClickPaste);
selectionDisposable.dispose();
// Clean up the Tauri event listener.
// If the promise already resolved, call unlisten directly.
// If it's still pending, wait for resolution then call it.
if (unlistenFn) {
unlistenFn();
unlistenFn = null;
} else if (unlistenPromise) {
unlistenPromise.then((fn) => fn());
}
unlistenPromise = null;
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
terminal.dispose();
}
function write(data: string): void {
terminal.write(data);
}
function fit(): void {
fitAddon.fit();
}
onBeforeUnmount(() => {
destroy();
});
return { terminal, fitAddon, searchAddon, mount, destroy, write, fit };
}