All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m58s
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>
304 lines
10 KiB
Vue
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>
|