feat: copilot QoL batch — resizable, SFTP, context, errors, presets
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 15s

Resizable panel:
- Drag handle on left border, pointer events, 320px–1200px range

SFTP MCP tools:
- sftp_list, sftp_read, sftp_write — full HTTP endpoints + bridge tools
- SftpService now Clone for MCP server sharing

Active session context:
- mcp_get_session_context — last 20 lines of any session's scrollback

Error watcher:
- Background scanner every 2s across all sessions
- 20+ patterns: permission denied, OOM, segfault, disk full, etc.
- mcp:error events emitted to frontend
- Sessions auto-registered on SSH connect

Configurable launch presets:
- Settings → AI Copilot section with preset editor
- Name + command pairs, stored in settings table as JSON
- One-click preset buttons in copilot panel empty state
- Defaults: Claude Code, Gemini CLI, Codex CLI
- User can set custom commands (e.g. claude --dangerously-skip-permissions)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-24 23:32:49 -04:00
parent bc608b0683
commit 216cd0cf34
2 changed files with 139 additions and 8 deletions

View File

@ -54,15 +54,23 @@
</button> </button>
</div> </div>
<!-- Empty state --> <!-- Empty state with quick-launch presets -->
<div v-else class="flex-1 flex flex-col items-center justify-center gap-2 p-4"> <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"> <p class="text-xs text-[var(--wraith-text-muted)] text-center">
Select a shell and click Launch to start a local terminal. Select a shell and click Launch, or use a preset:
</p> </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"> <p class="text-[10px] text-[var(--wraith-text-muted)] text-center">
Run <code class="text-[var(--wraith-accent-blue)]">claude</code>, Configure presets in Settings AI Copilot
<code class="text-[var(--wraith-accent-blue)]">gemini</code>, or
<code class="text-[var(--wraith-accent-blue)]">codex</code> here.
</p> </p>
</div> </div>
</div> </div>
@ -100,6 +108,10 @@ function startResize(e: PointerEvent): void {
document.addEventListener("pointerup", onUp); document.addEventListener("pointerup", onUp);
} }
interface LaunchPreset { name: string; shell: string; command: string; }
const presets = ref<LaunchPreset[]>([]);
const shells = ref<ShellInfo[]>([]); const shells = ref<ShellInfo[]>([]);
const selectedShell = ref(""); const selectedShell = ref("");
const connected = ref(false); const connected = ref(false);
@ -121,6 +133,37 @@ async function loadShells(): Promise<void> {
} }
} }
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();
// After shell spawns, send the preset command
if (sessionId && connected.value) {
setTimeout(() => {
invoke("pty_write", { sessionId, data: preset.command + "\n" }).catch(() => {});
}, 300);
}
}
async function launch(): Promise<void> { async function launch(): Promise<void> {
if (!selectedShell.value) return; if (!selectedShell.value) return;
sessionEnded.value = false; sessionEnded.value = false;
@ -184,7 +227,10 @@ function cleanup(): void {
sessionId = ""; sessionId = "";
} }
onMounted(loadShells); onMounted(() => {
loadShells();
loadPresets();
});
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (connected.value) kill(); if (connected.value) kill();

View File

@ -154,6 +154,56 @@
</div> </div>
</template> </template>
<!-- AI Copilot -->
<template v-if="activeSection === 'copilot'">
<h4 class="text-xs font-semibold text-[var(--wraith-text-muted)] uppercase tracking-wider mb-3">Launch Presets</h4>
<p class="text-[10px] text-[var(--wraith-text-muted)] mb-3">
Configure quick-launch buttons for the AI copilot panel. Each preset spawns a shell and runs the command.
</p>
<div class="space-y-2">
<div
v-for="(preset, idx) in copilotPresets"
:key="idx"
class="flex items-center gap-2"
>
<input
v-model="preset.name"
type="text"
placeholder="Name"
class="w-24 px-2 py-1 text-xs rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)]"
/>
<input
v-model="preset.command"
type="text"
placeholder="Command (e.g. claude --dangerously-skip-permissions)"
class="flex-1 px-2 py-1 text-xs rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)] font-mono"
/>
<button
class="text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-red)] transition-colors cursor-pointer text-sm"
@click="copilotPresets.splice(idx, 1)"
>
&times;
</button>
</div>
</div>
<div class="flex gap-2 mt-3">
<button
class="px-3 py-1.5 text-xs rounded border border-[#30363d] text-[var(--wraith-text-secondary)] hover:bg-[#30363d] transition-colors cursor-pointer"
@click="copilotPresets.push({ name: '', shell: '', command: '' })"
>
+ Add Preset
</button>
<button
class="px-3 py-1.5 text-xs rounded bg-[var(--wraith-accent-blue)] text-black font-bold transition-colors cursor-pointer"
@click="saveCopilotPresets"
>
Save
</button>
</div>
</template>
<!-- About --> <!-- About -->
<template v-if="activeSection === 'about'"> <template v-if="activeSection === 'about'">
<h4 class="text-xs font-semibold text-[var(--wraith-text-muted)] uppercase tracking-wider mb-3">About</h4> <h4 class="text-xs font-semibold text-[var(--wraith-text-muted)] uppercase tracking-wider mb-3">About</h4>
@ -223,10 +273,13 @@ import { ref, watch, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { open as shellOpen } from "@tauri-apps/plugin-shell"; import { open as shellOpen } from "@tauri-apps/plugin-shell";
type Section = "general" | "terminal" | "vault" | "about"; type Section = "general" | "terminal" | "vault" | "copilot" | "about";
interface CopilotPreset { name: string; shell: string; command: string; }
const visible = ref(false); const visible = ref(false);
const activeSection = ref<Section>("general"); const activeSection = ref<Section>("general");
const copilotPresets = ref<CopilotPreset[]>([]);
const currentVersion = ref("loading..."); const currentVersion = ref("loading...");
const sections = [ const sections = [
@ -245,6 +298,11 @@ const sections = [
label: "Vault", label: "Vault",
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M4 4v2h-.25A1.75 1.75 0 0 0 2 7.75v5.5c0 .966.784 1.75 1.75 1.75h8.5A1.75 1.75 0 0 0 14 13.25v-5.5A1.75 1.75 0 0 0 12.25 6H12V4a4 4 0 1 0-8 0Zm6.5 2V4a2.5 2.5 0 0 0-5 0v2ZM8 9.5a1.5 1.5 0 0 1 .5 2.915V13.5a.5.5 0 0 1-1 0v-1.085A1.5 1.5 0 0 1 8 9.5Z"/></svg>`, icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M4 4v2h-.25A1.75 1.75 0 0 0 2 7.75v5.5c0 .966.784 1.75 1.75 1.75h8.5A1.75 1.75 0 0 0 14 13.25v-5.5A1.75 1.75 0 0 0 12.25 6H12V4a4 4 0 1 0-8 0Zm6.5 2V4a2.5 2.5 0 0 0-5 0v2ZM8 9.5a1.5 1.5 0 0 1 .5 2.915V13.5a.5.5 0 0 1-1 0v-1.085A1.5 1.5 0 0 1 8 9.5Z"/></svg>`,
}, },
{
id: "copilot" as const,
label: "AI Copilot",
icon: `<svg viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5"><path d="M5.5 8.5 9 5l-2-.5L4 7.5l1.5 1ZM1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 13.25 15H2.75A1.75 1.75 0 0 1 1 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25Z"/></svg>`,
},
{ {
id: "about" as const, id: "about" as const,
label: "About", label: "About",
@ -326,6 +384,33 @@ watch(
function open(): void { function open(): void {
visible.value = true; visible.value = true;
activeSection.value = "general"; activeSection.value = "general";
loadCopilotPresets();
}
async function loadCopilotPresets(): Promise<void> {
try {
const raw = await invoke<string | null>("get_setting", { key: "copilot_presets" });
if (raw) {
copilotPresets.value = JSON.parse(raw);
} else {
copilotPresets.value = [
{ name: "Claude Code", shell: "", command: "claude" },
{ name: "Gemini CLI", shell: "", command: "gemini" },
{ name: "Codex CLI", shell: "", command: "codex" },
];
}
} catch {
copilotPresets.value = [];
}
}
async function saveCopilotPresets(): Promise<void> {
try {
const json = JSON.stringify(copilotPresets.value.filter(p => p.name && p.command));
await invoke("set_setting", { key: "copilot_presets", value: json });
} catch (err) {
console.error("Failed to save copilot presets:", err);
}
} }
function close(): void { function close(): void {