wraith/src/components/ai/CopilotPanel.vue
Vantz Stockwell 357491b4e8
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m58s
feat: debug logging macro + MCP tools inject button in copilot
Debug logging:
- wraith_log!() macro available in all modules, writes to wraith.log
- SSH connect/auth, PTY spawn, RDP connect all log with session IDs
- MCP startup panic now shows the actual error message

Copilot "Tools" button:
- Shows when a PTY session is active in the copilot panel
- Injects a formatted list of all 18 MCP tools into the chat
- Groups tools by category: session, terminal, SFTP, network, utilities
- Includes parameter signatures so the AI knows how to call them

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:12:23 -04:00

304 lines
10 KiB
Vue

<template>
<div class="flex h-full relative">
<!-- Drag handle for resizing -->
<div
class="w-1 cursor-col-resize hover:bg-[var(--wraith-accent-blue)] active:bg-[var(--wraith-accent-blue)] transition-colors shrink-0"
@pointerdown="startResize"
/>
<div
class="flex flex-col h-full bg-[var(--wraith-bg-secondary)] border-l border-[var(--wraith-border)] flex-1 min-w-0"
:style="{ width: panelWidth + 'px' }"
>
<!-- 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>
<button
v-if="connected"
class="px-2 py-0.5 text-[10px] rounded border border-[var(--wraith-border)] text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] cursor-pointer"
title="Inject available MCP tools into the chat"
@click="injectTools"
>
Tools
</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 with quick-launch presets -->
<div v-else class="flex-1 flex flex-col items-center justify-center gap-3 p-4">
<p class="text-xs text-[var(--wraith-text-muted)] text-center">
Select a shell and click Launch, or use a preset:
</p>
<div v-if="presets.length" class="flex flex-col gap-1.5 w-full max-w-[200px]">
<button
v-for="preset in presets"
:key="preset.name"
class="px-3 py-1.5 text-xs rounded bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] hover:border-[var(--wraith-accent-blue)] transition-colors cursor-pointer text-left"
@click="launchPreset(preset)"
>
{{ preset.name }}
</button>
</div>
<p class="text-[10px] text-[var(--wraith-text-muted)] text-center">
Configure presets in Settings → AI Copilot
</p>
</div>
</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; }
// Resizable panel
const panelWidth = ref(640);
function startResize(e: PointerEvent): void {
e.preventDefault();
const startX = e.clientX;
const startWidth = panelWidth.value;
function onMove(ev: PointerEvent): void {
// Dragging left increases width (panel is on the right side)
const delta = startX - ev.clientX;
panelWidth.value = Math.max(320, Math.min(1200, startWidth + delta));
}
function onUp(): void {
document.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerup", onUp);
}
document.addEventListener("pointermove", onMove);
document.addEventListener("pointerup", onUp);
}
interface LaunchPreset { name: string; shell: string; command: string; }
const presets = ref<LaunchPreset[]>([]);
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 loadPresets(): Promise<void> {
try {
const raw = await invoke<string | null>("get_setting", { key: "copilot_presets" });
if (raw) {
presets.value = JSON.parse(raw);
} else {
// Seed with sensible defaults
presets.value = [
{ name: "Claude Code", shell: "", command: "claude" },
{ name: "Gemini CLI", shell: "", command: "gemini" },
{ name: "Codex CLI", shell: "", command: "codex" },
];
}
} catch {
presets.value = [];
}
}
async function launchPreset(preset: LaunchPreset): Promise<void> {
const shell = preset.shell || selectedShell.value;
if (!shell) return;
selectedShell.value = shell;
await launch();
// Wait for the shell prompt before sending the command.
// Poll the scrollback for a prompt indicator (PS>, $, #, %, >)
if (sessionId && connected.value) {
const maxWait = 5000;
const start = Date.now();
const poll = setInterval(async () => {
if (Date.now() - start > maxWait) {
clearInterval(poll);
// Send anyway after timeout
invoke("pty_write", { sessionId, data: preset.command + "\r" }).catch(() => {});
return;
}
try {
const lines = await invoke<string>("mcp_terminal_read", { sessionId, lines: 3 });
const lastLine = lines.split("\n").pop()?.trim() || "";
// Detect common shell prompts
if (lastLine.endsWith("$") || lastLine.endsWith("#") || lastLine.endsWith("%") || lastLine.endsWith(">") || lastLine.endsWith("PS>")) {
clearInterval(poll);
invoke("pty_write", { sessionId, data: preset.command + "\r" }).catch(() => {});
}
} catch {
// Scrollback not ready yet, keep polling
}
}, 200);
}
}
async function launch(): Promise<void> {
if (!selectedShell.value) return;
sessionEnded.value = false;
try {
sessionId = await invoke<string>("spawn_local_shell", {
shellPath: selectedShell.value,
cols: 80,
rows: 24,
});
connected.value = true;
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 injectTools(): void {
if (!sessionId || !connected.value) return;
const toolsPrompt = [
"You have access to these Wraith MCP tools via the wraith-mcp-bridge:",
"",
"SESSION MANAGEMENT:",
" list_sessions List all active SSH/RDP/PTY sessions",
"",
"TERMINAL:",
" terminal_read(session_id, lines?) Read recent terminal output (ANSI stripped)",
" terminal_execute(session_id, command, timeout_ms?) Run a command and capture output",
" terminal_screenshot(session_id) Capture RDP session as PNG",
"",
"SFTP:",
" sftp_list(session_id, path) List remote directory",
" sftp_read(session_id, path) Read remote file",
" sftp_write(session_id, path, content) Write remote file",
"",
"NETWORK:",
" network_scan(session_id, subnet) Discover devices on subnet (ARP + ping sweep)",
" port_scan(session_id, target, ports?) Scan TCP ports",
" ping(session_id, target) Ping a host",
" traceroute(session_id, target) Traceroute to host",
" dns_lookup(session_id, domain, record_type?) DNS lookup",
" whois(session_id, target) Whois lookup",
" wake_on_lan(session_id, mac_address) Send WoL magic packet",
" bandwidth_test(session_id) Internet speed test",
"",
"UTILITIES (no session needed):",
" subnet_calc(cidr) Calculate subnet details",
" generate_ssh_key(key_type, comment?) Generate SSH key pair",
" generate_password(length?, uppercase?, lowercase?, digits?, symbols?) Generate password",
"",
].join("\n");
invoke("pty_write", { sessionId, data: toolsPrompt + "\r" }).catch(() => {});
}
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();
loadPresets();
});
onBeforeUnmount(() => {
if (connected.value) kill();
});
</script>