feat: Help menu + fix tab detach rendering
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m48s

Help menu (File → Help):
- Getting Started guide (connections, SFTP, copilot, tabs)
- Keyboard Shortcuts reference table
- MCP Integration page (setup command, all 18 tools documented,
  bridge path auto-populated, architecture explanation)
- About page with version and tech stack
- Opens as a tabbed popup window

Tab detach fixes:
- Added detached-*, editor-*, help-* to capabilities window list
  (detached windows had no event permissions — silent failure)
- SessionContainer filters out detached sessions (active=false)
  so the main window stops rendering the terminal when detached
- Terminal now only renders in the detached popup window

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-26 14:07:45 -04:00
parent f22f85ac00
commit 9c3afa39bd
6 changed files with 287 additions and 5 deletions

View File

@ -1,7 +1,7 @@
{ {
"identifier": "default", "identifier": "default",
"description": "Default capabilities for the main Wraith window", "description": "Default capabilities for the main Wraith window",
"windows": ["main", "tool-*"], "windows": ["main", "tool-*", "detached-*", "editor-*", "help-*"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:event:default", "core:event:default",

View File

@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for the main Wraith window","local":true,"windows":["main","tool-*"],"permissions":["core:default","core:event:default","core:window:default","core:window:allow-create","core:webview:default","core:webview:allow-create-webview-window","shell:allow-open","updater:default"]}} {"default":{"identifier":"default","description":"Default capabilities for the main Wraith window","local":true,"windows":["main","tool-*","detached-*","editor-*","help-*"],"permissions":["core:default","core:event:default","core:window:default","core:window:allow-create","core:webview:default","core:webview:allow-create-webview-window","shell:allow-open","updater:default"]}}

View File

@ -91,16 +91,17 @@ function setTerminalRef(sessionId: string, el: unknown): void {
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
// Only render sessions that are active (not detached to separate windows)
const sshSessions = computed(() => const sshSessions = computed(() =>
sessionStore.sessions.filter((s) => s.protocol === "ssh"), sessionStore.sessions.filter((s) => s.protocol === "ssh" && s.active),
); );
const localSessions = computed(() => const localSessions = computed(() =>
sessionStore.sessions.filter((s) => s.protocol === "local"), sessionStore.sessions.filter((s) => s.protocol === "local" && s.active),
); );
const rdpSessions = computed(() => const rdpSessions = computed(() =>
sessionStore.sessions.filter((s) => s.protocol === "rdp"), sessionStore.sessions.filter((s) => s.protocol === "rdp" && s.active),
); );
/** /**

View File

@ -0,0 +1,219 @@
<template>
<div class="flex flex-col h-full">
<!-- Tabs -->
<div class="flex items-center gap-1 px-4 py-2 bg-[#161b22] border-b border-[#30363d] shrink-0">
<button v-for="t in tabs" :key="t.id"
class="px-3 py-1 text-xs rounded cursor-pointer transition-colors"
:class="activeTab === t.id ? 'bg-[#58a6ff] text-black font-bold' : 'text-[#8b949e] hover:text-white'"
@click="activeTab = t.id"
>{{ t.label }}</button>
</div>
<div class="flex-1 overflow-auto p-6">
<!-- Getting Started -->
<div v-if="activeTab === 'guide'" class="prose-wraith">
<h2>Getting Started with Wraith</h2>
<p>Wraith is a native desktop SSH/SFTP/RDP client with an integrated AI copilot.</p>
<h3>Creating a Connection</h3>
<ol>
<li>Click <strong>File &rarr; New Connection</strong> or the <strong>+ Host</strong> button in the sidebar</li>
<li>Enter hostname, port, and protocol (SSH or RDP)</li>
<li>Optionally link a credential from the vault</li>
<li>Double-click the connection to connect</li>
</ol>
<h3>Quick Connect</h3>
<p>Type <code>user@host:port</code> in the Quick Connect bar and press Enter.</p>
<h3>AI Copilot</h3>
<p>Press <strong>Ctrl+Shift+G</strong> to open the AI copilot panel. Select a shell, click Launch, and run your AI CLI (Claude Code, Gemini, Codex).</p>
<p>Configure one-click launch presets in <strong>Settings &rarr; AI Copilot</strong>.</p>
<h3>Local Terminals</h3>
<p>Click the <strong>+</strong> button in the tab bar to open a local shell (PowerShell, CMD, Git Bash, WSL, bash, zsh).</p>
<h3>SFTP Browser</h3>
<p>Switch to the <strong>SFTP</strong> tab in the sidebar. It follows the active SSH session and tracks the current working directory.</p>
<p>Right-click files for Edit, Download, Rename, Delete.</p>
<h3>Tab Management</h3>
<ul>
<li><strong>Drag tabs</strong> to reorder</li>
<li><strong>Right-click tab</strong> &rarr; Detach to Window (pop out to separate window)</li>
<li>Close the detached window to reattach</li>
<li>Tabs pulse blue when there's new activity in the background</li>
</ul>
<h3>Remote Monitoring</h3>
<p>Every SSH session shows a monitoring bar at the bottom with CPU, RAM, disk, and network stats polled every 5 seconds. No agent needed.</p>
</div>
<!-- Keyboard Shortcuts -->
<div v-if="activeTab === 'shortcuts'" class="prose-wraith">
<h2>Keyboard Shortcuts</h2>
<table>
<thead><tr><th>Shortcut</th><th>Action</th></tr></thead>
<tbody>
<tr><td><kbd>Ctrl+K</kbd></td><td>Command Palette</td></tr>
<tr><td><kbd>Ctrl+Shift+G</kbd></td><td>Toggle AI Copilot</td></tr>
<tr><td><kbd>Ctrl+B</kbd></td><td>Toggle Sidebar</td></tr>
<tr><td><kbd>Ctrl+W</kbd></td><td>Close Active Tab</td></tr>
<tr><td><kbd>Ctrl+Tab</kbd></td><td>Next Tab</td></tr>
<tr><td><kbd>Ctrl+Shift+Tab</kbd></td><td>Previous Tab</td></tr>
<tr><td><kbd>Ctrl+1-9</kbd></td><td>Switch to Tab N</td></tr>
<tr><td><kbd>Ctrl+F</kbd></td><td>Find in Terminal</td></tr>
<tr><td><kbd>Ctrl+S</kbd></td><td>Save (in editor windows)</td></tr>
</tbody>
</table>
<h3>Terminal</h3>
<table>
<thead><tr><th>Action</th><th>How</th></tr></thead>
<tbody>
<tr><td>Copy</td><td>Select text (auto-copies)</td></tr>
<tr><td>Paste</td><td>Right-click</td></tr>
</tbody>
</table>
</div>
<!-- MCP Integration -->
<div v-if="activeTab === 'mcp'" class="prose-wraith">
<h2>MCP Integration (AI Tool Access)</h2>
<p>Wraith includes an MCP (Model Context Protocol) server that gives AI CLI tools programmatic access to your active sessions.</p>
<h3>Setup</h3>
<p>The MCP bridge binary is automatically downloaded to:</p>
<pre>{{ bridgePath }}</pre>
<p>Register with Claude Code:</p>
<pre>claude mcp add wraith -- "{{ bridgePath }}"</pre>
<h3>Available MCP Tools (18)</h3>
<h4>Session Management</h4>
<table>
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>list_sessions</code></td><td>List all active SSH/RDP/PTY sessions</td></tr>
</tbody>
</table>
<h4>Terminal</h4>
<table>
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>terminal_read</code></td><td>Read recent terminal output (ANSI stripped)</td></tr>
<tr><td><code>terminal_execute</code></td><td>Run a command and capture output</td></tr>
<tr><td><code>terminal_screenshot</code></td><td>Capture RDP frame as PNG</td></tr>
</tbody>
</table>
<h4>SFTP</h4>
<table>
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>sftp_list</code></td><td>List remote directory</td></tr>
<tr><td><code>sftp_read</code></td><td>Read remote file</td></tr>
<tr><td><code>sftp_write</code></td><td>Write remote file</td></tr>
</tbody>
</table>
<h4>Network</h4>
<table>
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>network_scan</code></td><td>ARP + ping sweep subnet discovery</td></tr>
<tr><td><code>port_scan</code></td><td>TCP port scan</td></tr>
<tr><td><code>ping</code></td><td>Ping a host</td></tr>
<tr><td><code>traceroute</code></td><td>Traceroute to host</td></tr>
<tr><td><code>dns_lookup</code></td><td>DNS query (A, MX, TXT, etc.)</td></tr>
<tr><td><code>whois</code></td><td>Whois lookup</td></tr>
<tr><td><code>wake_on_lan</code></td><td>Send WoL magic packet</td></tr>
<tr><td><code>bandwidth_test</code></td><td>Internet speed test</td></tr>
</tbody>
</table>
<h4>Utilities (no session needed)</h4>
<table>
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>subnet_calc</code></td><td>Subnet calculator</td></tr>
<tr><td><code>generate_ssh_key</code></td><td>Generate SSH key pair</td></tr>
<tr><td><code>generate_password</code></td><td>Generate secure password</td></tr>
</tbody>
</table>
<h3>How It Works</h3>
<ol>
<li>Wraith starts an HTTP server on <code>localhost</code> (random port)</li>
<li>Port written to <code>mcp-port</code> in data directory</li>
<li>Bridge binary reads the port and proxies JSON-RPC over stdio</li>
<li>AI CLI spawns the bridge as an MCP server</li>
</ol>
</div>
<!-- About -->
<div v-if="activeTab === 'about'" class="prose-wraith">
<h2>About Wraith</h2>
<p class="text-2xl font-bold tracking-widest text-[#58a6ff]">WRAITH</p>
<p>Exists everywhere, all at once.</p>
<table>
<tbody>
<tr><td>Version</td><td>{{ version }}</td></tr>
<tr><td>Runtime</td><td>Tauri v2 + Rust</td></tr>
<tr><td>Frontend</td><td>Vue 3 + TypeScript</td></tr>
<tr><td>Terminal</td><td>xterm.js 6</td></tr>
<tr><td>SSH</td><td>russh 0.48</td></tr>
<tr><td>RDP</td><td>ironrdp 0.14</td></tr>
<tr><td>License</td><td>Proprietary</td></tr>
<tr><td>Publisher</td><td>Vigilance Cyber / Vigilsynth</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
const tabs = [
{ id: "guide", label: "Getting Started" },
{ id: "shortcuts", label: "Shortcuts" },
{ id: "mcp", label: "MCP Integration" },
{ id: "about", label: "About" },
];
const activeTab = ref("guide");
const bridgePath = ref("loading...");
const version = ref("loading...");
onMounted(async () => {
// Read initial tab from URL
const params = new URLSearchParams(window.location.hash.split("?")[1] || "");
const page = params.get("page");
if (page && tabs.some(t => t.id === page)) activeTab.value = page;
try { version.value = await getVersion(); } catch { version.value = "unknown"; }
try { bridgePath.value = await invoke<string>("mcp_bridge_path"); } catch { bridgePath.value = "unknown"; }
});
</script>
<style scoped>
.prose-wraith h2 { font-size: 16px; font-weight: 700; color: #e0e0e0; margin-bottom: 12px; }
.prose-wraith h3 { font-size: 13px; font-weight: 600; color: #8b949e; margin-top: 20px; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; }
.prose-wraith h4 { font-size: 12px; font-weight: 600; color: #58a6ff; margin-top: 16px; margin-bottom: 6px; }
.prose-wraith p { font-size: 12px; color: #8b949e; margin-bottom: 8px; line-height: 1.6; }
.prose-wraith ol, .prose-wraith ul { font-size: 12px; color: #8b949e; margin-bottom: 8px; padding-left: 20px; }
.prose-wraith li { margin-bottom: 4px; line-height: 1.5; }
.prose-wraith code { background: #161b22; border: 1px solid #30363d; border-radius: 4px; padding: 1px 5px; font-size: 11px; color: #e0e0e0; }
.prose-wraith pre { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 10px 14px; font-size: 11px; color: #e0e0e0; overflow-x: auto; margin-bottom: 8px; font-family: 'Cascadia Mono', monospace; }
.prose-wraith kbd { background: #21262d; border: 1px solid #484f58; border-radius: 3px; padding: 1px 5px; font-size: 10px; color: #e0e0e0; }
.prose-wraith table { width: 100%; font-size: 12px; border-collapse: collapse; margin-bottom: 12px; }
.prose-wraith th { text-align: left; padding: 6px 10px; background: #161b22; color: #8b949e; font-weight: 500; border-bottom: 1px solid #30363d; }
.prose-wraith td { padding: 5px 10px; color: #e0e0e0; border-bottom: 1px solid #21262d; }
.prose-wraith strong { color: #e0e0e0; }
</style>

View File

@ -13,6 +13,7 @@
<FileEditor v-else-if="tool === 'editor'" :session-id="sessionId" /> <FileEditor v-else-if="tool === 'editor'" :session-id="sessionId" />
<SshKeyGen v-else-if="tool === 'ssh-keygen'" /> <SshKeyGen v-else-if="tool === 'ssh-keygen'" />
<PasswordGen v-else-if="tool === 'password-gen'" /> <PasswordGen v-else-if="tool === 'password-gen'" />
<HelpWindow v-else-if="tool === 'help'" />
<div v-else class="flex-1 flex items-center justify-center text-sm text-[#484f58]"> <div v-else class="flex-1 flex items-center justify-center text-sm text-[#484f58]">
Unknown tool: {{ tool }} Unknown tool: {{ tool }}
</div> </div>
@ -33,6 +34,7 @@ import DockerPanel from "./DockerPanel.vue";
import FileEditor from "./FileEditor.vue"; import FileEditor from "./FileEditor.vue";
import SshKeyGen from "./SshKeyGen.vue"; import SshKeyGen from "./SshKeyGen.vue";
import PasswordGen from "./PasswordGen.vue"; import PasswordGen from "./PasswordGen.vue";
import HelpWindow from "./HelpWindow.vue";
defineProps<{ defineProps<{
tool: string; tool: string;

View File

@ -141,6 +141,47 @@
</button> </button>
</div> </div>
</div> </div>
<!-- Help menu -->
<div class="relative">
<button
class="text-xs text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer px-2 py-1 rounded hover:bg-[var(--wraith-bg-tertiary)]"
@click="showHelpMenu = !showHelpMenu"
@blur="closeHelpMenuDeferred"
>
Help
</button>
<div
v-if="showHelpMenu"
class="absolute top-full left-0 mt-0.5 w-56 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden z-50 py-1"
>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleHelpAction('guide')"
>
<span class="flex-1">Getting Started</span>
</button>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleHelpAction('shortcuts')"
>
<span class="flex-1">Keyboard Shortcuts</span>
</button>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleHelpAction('mcp')"
>
<span class="flex-1">MCP Integration</span>
</button>
<div class="border-t border-[#30363d] my-1" />
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleHelpAction('about')"
>
<span class="flex-1">About Wraith</span>
</button>
</div>
</div>
</div> </div>
<!-- Quick Connect --> <!-- Quick Connect -->
@ -309,6 +350,7 @@ const sessionContainer = ref<InstanceType<typeof SessionContainer> | null>(null)
const showFileMenu = ref(false); const showFileMenu = ref(false);
const showToolsMenu = ref(false); const showToolsMenu = ref(false);
const showHelpMenu = ref(false);
function closeFileMenuDeferred(): void { function closeFileMenuDeferred(): void {
setTimeout(() => { showFileMenu.value = false; }, 150); setTimeout(() => { showFileMenu.value = false; }, 150);
@ -318,6 +360,24 @@ function closeToolsMenuDeferred(): void {
setTimeout(() => { showToolsMenu.value = false; }, 150); setTimeout(() => { showToolsMenu.value = false; }, 150);
} }
function closeHelpMenuDeferred(): void {
setTimeout(() => { showHelpMenu.value = false; }, 150);
}
async function handleHelpAction(page: string): Promise<void> {
showHelpMenu.value = false;
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
const label = `help-${page}-${Date.now()}`;
new WebviewWindow(label, {
title: `Wraith — Help`,
width: 750,
height: 600,
resizable: true,
center: true,
url: `index.html#/tool/help?page=${page}`,
});
}
async function handleToolAction(tool: string): Promise<void> { async function handleToolAction(tool: string): Promise<void> {
showToolsMenu.value = false; showToolsMenu.value = false;