feat: AI copilot panel — chat UI with streaming, tool visualization, OAuth settings

Add the XO copilot right-side panel with:
- Pinia store (copilot.store.ts) managing panel state, messages, streaming, and token tracking
- useCopilot composable with mock streaming (30-100ms/word) and context-aware responses
- CopilotPanel: 320px collapsible panel with header, scrollable message list, and input area
- CopilotMessage: markdown rendering (bold, code, lists) with streaming cursor animation
- CopilotToolViz: color-coded tool call cards (green=terminal, yellow=SFTP, blue=RDP)
  with pending spinner, done checkmark, expandable input/result JSON
- CopilotSettings: model selector, token usage, clear history, connect/disconnect
- MainLayout integration: ghost icon toggle in toolbar, Ctrl+Shift+K shortcut,
  CSS slide transition, content area resizes (not overlay)

All Wails AIService bindings are TODOs with mock behavior. Pure Tailwind CSS,
no external markdown library.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-17 09:03:33 -04:00
parent 1793576030
commit be868e8172
7 changed files with 1028 additions and 0 deletions

View File

@ -0,0 +1,117 @@
<template>
<div
class="flex mb-3"
:class="message.role === 'user' ? 'justify-end' : 'justify-start'"
>
<div
class="max-w-[90%] rounded-lg px-3 py-2 text-sm bg-[#161b22] border-l-2"
:class="message.role === 'user' ? 'border-[#58a6ff]' : 'border-[#3fb950]'"
>
<!-- Rendered content -->
<div
v-if="message.role === 'assistant'"
class="copilot-md text-[#e0e0e0] leading-relaxed break-words"
v-html="renderedContent"
/>
<div v-else class="text-[#e0e0e0] leading-relaxed break-words whitespace-pre-wrap">
{{ message.content }}
</div>
<!-- Tool call visualizations (assistant only) -->
<CopilotToolViz
v-for="tc in message.toolCalls"
:key="tc.id"
:tool-call="tc"
/>
<!-- Streaming cursor -->
<span
v-if="isStreaming && message.role === 'assistant'"
class="inline-block w-2 h-4 bg-[#3fb950] align-middle animate-pulse ml-0.5"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { Message } from "@/stores/copilot.store";
import CopilotToolViz from "./CopilotToolViz.vue";
const props = defineProps<{
message: Message;
isStreaming?: boolean;
}>();
/**
* Simple regex-based markdown renderer.
* Handles: bold, inline code, code blocks, list items, newlines.
* No external library needed.
*/
const renderedContent = computed(() => {
let text = props.message.content;
// Escape HTML
text = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
// Code blocks (```...```)
text = text.replace(
/```(\w*)\n?([\s\S]*?)```/g,
'<pre class="bg-[#0d1117] rounded p-2 my-1.5 overflow-x-auto text-xs font-mono"><code>$2</code></pre>',
);
// Inline code (`...`)
text = text.replace(
/`([^`]+)`/g,
'<code class="bg-[#0d1117] text-[#79c0ff] px-1 py-0.5 rounded text-xs font-mono">$1</code>',
);
// Bold (**...**)
text = text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
// Italic (*...*)
text = text.replace(/\*(.+?)\*/g, "<em>$1</em>");
// List items (- item)
text = text.replace(/^- (.+)$/gm, '<li class="ml-3 list-disc">$1</li>');
// Wrap consecutive <li> in <ul>
text = text.replace(
/(<li[^>]*>.*?<\/li>\n?)+/g,
'<ul class="my-1">$&</ul>',
);
// Newlines to <br> (except inside <pre>)
text = text.replace(/\n(?!<\/?pre|<\/?ul|<\/?li)/g, "<br>");
return text;
});
</script>
<style scoped>
/* Markdown styling within copilot messages */
.copilot-md :deep(strong) {
color: #e0e0e0;
font-weight: 600;
}
.copilot-md :deep(em) {
color: #8b949e;
font-style: italic;
}
.copilot-md :deep(code) {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.copilot-md :deep(pre) {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.copilot-md :deep(ul) {
padding-left: 0.5rem;
}
</style>

View File

@ -0,0 +1,237 @@
<template>
<div
class="flex flex-col h-full bg-[#0d1117] border-l border-[#30363d] overflow-hidden"
:style="{ width: panelWidth + 'px' }"
>
<!-- Header -->
<div class="flex items-center justify-between px-3 py-2 bg-[#161b22] border-b border-[#30363d] shrink-0">
<div class="flex items-center gap-2">
<!-- Ghost icon -->
<span class="text-base" title="XO AI Copilot">{{ ghostIcon }}</span>
<span class="text-sm font-bold text-[#58a6ff]">XO</span>
<span class="text-[10px] text-[#484f58]">{{ modelShort }}</span>
</div>
<div class="flex items-center gap-2">
<!-- Token counter -->
<span class="text-[10px] text-[#484f58]">
{{ formatTokens(store.tokenUsage.input) }} / {{ formatTokens(store.tokenUsage.output) }}
</span>
<!-- Settings button -->
<button
class="text-[#8b949e] hover:text-[#e0e0e0] transition-colors cursor-pointer"
title="Settings"
@click="store.showSettings = true"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path fill-rule="evenodd" d="M7.429 1.525a6.593 6.593 0 011.142 0c.036.003.108.036.137.146l.289 1.105c.147.56.55.967.997 1.189.174.086.341.183.501.29.417.278.97.423 1.53.27l1.102-.303c.11-.03.175.016.195.046.219.31.41.641.573.989.014.031.022.11-.059.19l-.815.806c-.411.406-.562.957-.53 1.456a4.588 4.588 0 010 .582c-.032.499.119 1.05.53 1.456l.815.806c.08.08.073.159.059.19a6.494 6.494 0 01-.573.99c-.02.029-.086.074-.195.045l-1.103-.303c-.559-.153-1.112-.008-1.529.27-.16.107-.327.204-.5.29-.449.222-.851.628-.998 1.189l-.289 1.105c-.029.11-.101.143-.137.146a6.613 6.613 0 01-1.142 0c-.036-.003-.108-.037-.137-.146l-.289-1.105c-.147-.56-.55-.967-.997-1.189a4.502 4.502 0 01-.501-.29c-.417-.278-.97-.423-1.53-.27l-1.102.303c-.11.03-.175-.016-.195-.046a6.492 6.492 0 01-.573-.989c-.014-.031-.022-.11.059-.19l.815-.806c.411-.406.562-.957.53-1.456a4.587 4.587 0 010-.582c.032-.499-.119-1.05-.53-1.456l-.815-.806c-.08-.08-.073-.159-.059-.19a6.44 6.44 0 01.573-.99c.02-.029.086-.074.195-.045l1.103.303c.559.153 1.112.008 1.529-.27.16-.107.327-.204.5-.29.449-.222.851-.628.998-1.189l.289-1.105c.029-.11.101-.143.137-.146zM8 0c-.236 0-.47.01-.701.03-.743.065-1.29.615-1.458 1.261l-.29 1.106c-.017.066-.078.158-.211.224a5.994 5.994 0 00-.668.386c-.123.082-.233.117-.3.1L3.27 2.801c-.635-.175-1.322.017-1.768.681A7.963 7.963 0 00.767 4.905c-.324.627-.2 1.358.246 1.8l.816.806c.049.048.098.153.088.313a6.088 6.088 0 000 .352c.01.16-.04.265-.088.313l-.816.806c-.446.443-.57 1.173-.246 1.8a7.96 7.96 0 00.736 1.323c.446.664 1.133.856 1.768.681l1.103-.303c.066-.018.177.018.3.1.22.147.447.28.668.386.133.066.194.158.212.224l.289 1.106c.169.646.715 1.196 1.458 1.26a8.094 8.094 0 001.402 0c.743-.064 1.29-.614 1.458-1.26l.29-1.106c.017-.066.078-.158.211-.224a5.98 5.98 0 00.668-.386c.123-.082.233-.117.3-.1l1.102.302c.635.175 1.322-.017 1.768-.68a7.96 7.96 0 00.736-1.324c.324-.627.2-1.357-.246-1.8l-.816-.805c-.048-.048-.098-.153-.088-.313a6.15 6.15 0 000-.352c-.01-.16.04-.265.088-.313l.816-.806c.446-.443.57-1.173.246-1.8a7.963 7.963 0 00-.736-1.323c-.446-.664-1.133-.856-1.768-.681l-1.103.303c-.066.018-.176-.018-.3-.1a5.99 5.99 0 00-.667-.386c-.133-.066-.194-.158-.212-.224L10.16 1.29C9.99.645 9.444.095 8.701.031A8.094 8.094 0 008 0zm1.5 8a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM11 8a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<!-- Close button -->
<button
class="text-[#8b949e] hover:text-[#e0e0e0] transition-colors cursor-pointer"
title="Close panel (Ctrl+Shift+K)"
@click="store.togglePanel()"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z" />
</svg>
</button>
</div>
</div>
<!-- Unauthenticated state -->
<div
v-if="!store.isAuthenticated"
class="flex-1 flex flex-col items-center justify-center px-6"
>
<span class="text-4xl mb-4">{{ ghostIcon }}</span>
<p class="text-sm text-[#8b949e] text-center mb-4">
Connect to Claude to activate the XO AI copilot.
</p>
<button
class="px-4 py-2 text-sm font-medium text-white bg-[#1f6feb] hover:bg-[#388bfd] rounded transition-colors cursor-pointer"
@click="store.startLogin()"
>
Connect to Claude
</button>
</div>
<!-- Message list -->
<div
v-else
ref="messageListRef"
class="flex-1 overflow-y-auto px-3 py-3"
>
<!-- Empty state -->
<div
v-if="store.messages.length === 0"
class="flex flex-col items-center justify-center h-full text-center"
>
<span class="text-3xl mb-3 opacity-40">{{ ghostIcon }}</span>
<p class="text-sm text-[#8b949e]">XO standing by.</p>
<p class="text-xs text-[#484f58] mt-1">Send a message to begin.</p>
</div>
<!-- Messages -->
<CopilotMessage
v-for="msg in store.messages"
:key="msg.id"
:message="msg"
:is-streaming="store.isStreaming && msg.id === lastAssistantId"
/>
</div>
<!-- Input area (authenticated only) -->
<div
v-if="store.isAuthenticated"
class="shrink-0 border-t border-[#30363d] p-3 bg-[#161b22]"
>
<div class="flex gap-2">
<textarea
ref="inputRef"
v-model="inputText"
:disabled="store.isStreaming"
placeholder="Message the XO..."
rows="1"
class="flex-1 resize-none px-2.5 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] placeholder-[#484f58] outline-none focus:border-[#58a6ff] transition-colors disabled:opacity-50"
@keydown="handleInputKeydown"
@input="autoResize"
/>
<button
class="shrink-0 px-2.5 py-1.5 rounded transition-colors cursor-pointer"
:class="
canSend
? 'bg-[#1f6feb] hover:bg-[#388bfd] text-white'
: 'bg-[#21262d] text-[#484f58] cursor-not-allowed'
"
:disabled="!canSend"
title="Send message"
@click="handleSend"
>
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
<path d="M.989 8 .064 2.68a1.342 1.342 0 0 1 1.85-1.462l13.402 5.744a1.13 1.13 0 0 1 0 2.076L1.913 14.782a1.343 1.343 0 0 1-1.85-1.463L.99 8Zm.603-5.108L2.18 7.25h4.57a.75.75 0 0 1 0 1.5H2.18l-.588 4.358L14.233 8 1.592 2.892Z" />
</svg>
</button>
</div>
<!-- Streaming indicator -->
<div v-if="store.isStreaming" class="flex items-center gap-1.5 mt-1.5 text-[10px] text-[#3fb950]">
<span class="w-1.5 h-1.5 rounded-full bg-[#3fb950] animate-pulse" />
XO is responding...
</div>
</div>
<!-- Settings modal -->
<CopilotSettings v-if="store.showSettings" @close="store.showSettings = false" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue";
import { useCopilotStore } from "@/stores/copilot.store";
import { useCopilot } from "@/composables/useCopilot";
import CopilotMessage from "./CopilotMessage.vue";
import CopilotSettings from "./CopilotSettings.vue";
const store = useCopilotStore();
const { processMessage } = useCopilot();
const panelWidth = 320;
const inputText = ref("");
const inputRef = ref<HTMLTextAreaElement | null>(null);
const messageListRef = ref<HTMLDivElement | null>(null);
/** Short model name for header display. */
const modelShort = computed(() => {
const m = store.model;
if (m.includes("sonnet")) return "sonnet";
if (m.includes("opus")) return "opus";
if (m.includes("haiku")) return "haiku";
return m;
});
/** Token formatter. */
function formatTokens(n: number): string {
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
return String(n);
}
/** ID of the last assistant message (for streaming cursor). */
const lastAssistantId = computed(() => {
for (let i = store.messages.length - 1; i >= 0; i--) {
if (store.messages[i].role === "assistant") return store.messages[i].id;
}
return null;
});
/** Whether the send button should be active. */
const canSend = computed(() => inputText.value.trim().length > 0 && !store.isStreaming);
/** Send the message. */
async function handleSend(): Promise<void> {
const text = inputText.value.trim();
if (!text || store.isStreaming) return;
inputText.value = "";
resetTextareaHeight();
store.sendMessage(text);
await nextTick();
scrollToBottom();
await processMessage(text);
}
/** Handle keyboard shortcuts in the input area. */
function handleInputKeydown(event: KeyboardEvent): void {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSend();
}
}
/** Auto-resize textarea based on content. */
function autoResize(): void {
const el = inputRef.value;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 120) + "px";
}
/** Reset textarea height back to single line. */
function resetTextareaHeight(): void {
const el = inputRef.value;
if (!el) return;
el.style.height = "auto";
}
/** Scroll the message list to the bottom. */
function scrollToBottom(): void {
const el = messageListRef.value;
if (!el) return;
el.scrollTop = el.scrollHeight;
}
/** Auto-scroll when messages change or streaming content updates. */
watch(
() => store.messages.length,
async () => {
await nextTick();
scrollToBottom();
},
);
watch(
() => {
const msgs = store.messages;
if (msgs.length === 0) return "";
return msgs[msgs.length - 1].content;
},
async () => {
await nextTick();
scrollToBottom();
},
);
// The ghost icon used in multiple places
const ghostIcon = "\uD83D\uDC7B";
</script>

View File

@ -0,0 +1,137 @@
<template>
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click.self="emit('close')"
>
<!-- Modal -->
<div class="w-80 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[#30363d]">
<h3 class="text-sm font-semibold text-[#e0e0e0]">XO Settings</h3>
<button
class="text-[#8b949e] hover:text-[#e0e0e0] transition-colors cursor-pointer"
@click="emit('close')"
>
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z" />
</svg>
</button>
</div>
<!-- Body -->
<div class="px-4 py-4 space-y-4">
<!-- Auth section -->
<div v-if="!store.isAuthenticated">
<button
class="w-full px-3 py-2 text-sm font-medium text-white bg-[#1f6feb] hover:bg-[#388bfd] rounded transition-colors cursor-pointer"
@click="handleLogin"
>
Connect to Claude
</button>
<div class="mt-3">
<label class="block text-xs text-[#8b949e] mb-1">Or enter API Key</label>
<input
v-model="apiKey"
type="password"
placeholder="sk-ant-..."
class="w-full px-2.5 py-1.5 text-xs rounded bg-[#0d1117] border border-[#30363d] text-[#e0e0e0] placeholder-[#484f58] outline-none focus:border-[#58a6ff] transition-colors"
/>
<button
class="mt-2 w-full px-3 py-1.5 text-xs font-medium text-[#e0e0e0] bg-[#21262d] hover:bg-[#30363d] border border-[#30363d] rounded transition-colors cursor-pointer"
:disabled="!apiKey.trim()"
@click="handleApiKey"
>
Authenticate
</button>
</div>
</div>
<!-- Model selector -->
<div>
<label class="block text-xs text-[#8b949e] mb-1">Model</label>
<select
v-model="store.model"
class="w-full px-2.5 py-1.5 text-xs rounded bg-[#0d1117] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] transition-colors cursor-pointer appearance-none"
>
<option value="claude-sonnet-4-5-20250514">claude-sonnet-4-5-20250514</option>
<option value="claude-opus-4-5-20250414">claude-opus-4-5-20250414</option>
<option value="claude-haiku-4-5-20251001">claude-haiku-4-5-20251001</option>
</select>
</div>
<!-- Token usage -->
<div>
<label class="block text-xs text-[#8b949e] mb-1">Token Usage</label>
<div class="flex items-center gap-3 text-xs text-[#e0e0e0]">
<span>
<span class="text-[#8b949e]">In:</span> {{ formatTokens(store.tokenUsage.input) }}
</span>
<span>
<span class="text-[#8b949e]">Out:</span> {{ formatTokens(store.tokenUsage.output) }}
</span>
</div>
</div>
<!-- Actions -->
<div class="space-y-2 pt-2 border-t border-[#30363d]">
<button
class="w-full px-3 py-1.5 text-xs font-medium text-[#e0e0e0] bg-[#21262d] hover:bg-[#30363d] border border-[#30363d] rounded transition-colors cursor-pointer"
@click="handleClearHistory"
>
Clear History
</button>
<button
v-if="store.isAuthenticated"
class="w-full px-3 py-1.5 text-xs font-medium text-[#f85149] bg-[#21262d] hover:bg-[#30363d] border border-[#30363d] rounded transition-colors cursor-pointer"
@click="handleDisconnect"
>
Disconnect
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useCopilotStore } from "@/stores/copilot.store";
const store = useCopilotStore();
const apiKey = ref("");
const emit = defineEmits<{
(e: "close"): void;
}>();
function handleLogin(): void {
// TODO: Wails AIService.StartLogin()
store.startLogin();
emit("close");
}
function handleApiKey(): void {
if (!apiKey.value.trim()) return;
// TODO: Wails AIService.SetApiKey(apiKey)
store.isAuthenticated = true;
apiKey.value = "";
emit("close");
}
function handleClearHistory(): void {
store.clearHistory();
emit("close");
}
function handleDisconnect(): void {
store.disconnect();
emit("close");
}
function formatTokens(n: number): string {
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
return String(n);
}
</script>

View File

@ -0,0 +1,130 @@
<template>
<div
class="rounded border overflow-hidden my-2 text-xs"
:class="borderClass"
>
<!-- Tool call header clickable to expand -->
<button
class="w-full flex items-center gap-2 px-2.5 py-1.5 bg-[#21262d] hover:bg-[#282e36] transition-colors cursor-pointer text-left"
@click="expanded = !expanded"
>
<!-- Color bar -->
<span class="w-0.5 h-4 rounded-full shrink-0" :class="barColorClass" />
<!-- Status indicator -->
<span v-if="toolCall.status === 'pending'" class="shrink-0">
<svg class="w-3.5 h-3.5 animate-spin text-[#8b949e]" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" class="opacity-25" />
<path d="M4 12a8 8 0 018-8" stroke="currentColor" stroke-width="3" stroke-linecap="round" class="opacity-75" />
</svg>
</span>
<span v-else-if="toolCall.status === 'done'" class="shrink-0 text-[#3fb950]">
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z" />
</svg>
</span>
<span v-else class="shrink-0 text-[#f85149]">
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z" />
</svg>
</span>
<!-- Icon + summary -->
<span class="text-[#e0e0e0] truncate">{{ icon }} {{ summary }}</span>
<!-- Expand chevron -->
<svg
class="w-3 h-3 ml-auto shrink-0 text-[#8b949e] transition-transform"
:class="{ 'rotate-90': expanded }"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z" />
</svg>
</button>
<!-- Expanded details -->
<div v-if="expanded" class="border-t border-[#30363d] bg-[#161b22]">
<div class="px-2.5 py-2">
<div class="text-[10px] text-[#8b949e] mb-1 uppercase tracking-wide">Input</div>
<pre class="text-[11px] text-[#e0e0e0] whitespace-pre-wrap break-all font-mono bg-[#0d1117] rounded p-2">{{ formattedInput }}</pre>
</div>
<div v-if="toolCall.result !== undefined" class="px-2.5 pb-2">
<div class="text-[10px] text-[#8b949e] mb-1 uppercase tracking-wide">Result</div>
<pre class="text-[11px] text-[#e0e0e0] whitespace-pre-wrap break-all font-mono bg-[#0d1117] rounded p-2">{{ formattedResult }}</pre>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import type { ToolCall } from "@/stores/copilot.store";
const props = defineProps<{
toolCall: ToolCall;
}>();
const expanded = ref(false);
/** Border color class based on tool call status. */
const borderClass = computed(() => {
if (props.toolCall.status === "error") return "border-[#f85149]/30";
return "border-[#30363d]";
});
/** Left color bar based on tool type. */
const barColorClass = computed(() => {
const name = props.toolCall.name;
if (name.startsWith("terminal_") || name === "terminal_cwd") return "bg-[#3fb950]";
if (name.startsWith("sftp_")) return "bg-[#e3b341]";
if (name.startsWith("rdp_")) return "bg-[#1f6feb]";
return "bg-[#8b949e]";
});
/** Icon for the tool type. */
const icon = computed(() => {
const name = props.toolCall.name;
const icons: Record<string, string> = {
terminal_write: "\u27E9",
terminal_read: "\u25CE",
terminal_cwd: "\u27E9",
sftp_read: "\uD83D\uDCC4",
sftp_write: "\uD83D\uDCBE",
rdp_screenshot: "\uD83D\uDCF8",
rdp_click: "\uD83D\uDDB1",
rdp_type: "\u2328",
list_sessions: "\u2261",
connect_session: "\u2192",
disconnect_session: "\u2717",
};
return icons[name] ?? "\u2022";
});
/** One-line summary of the tool call. */
const summary = computed(() => {
const name = props.toolCall.name;
const input = props.toolCall.input;
if (name === "terminal_write") return `Typed \`${input.text}\` in session ${input.sessionId}`;
if (name === "terminal_read") return `Read ${input.lines ?? "?"} lines from session ${input.sessionId}`;
if (name === "terminal_cwd") return `Working directory in session ${input.sessionId}`;
if (name === "sftp_read") return `Read ${input.path}`;
if (name === "sftp_write") return `Wrote ${input.path}`;
if (name === "rdp_screenshot") return `Screenshot from session ${input.sessionId}`;
if (name === "rdp_click") return `Clicked at (${input.x}, ${input.y})`;
if (name === "rdp_type") return `Typed \`${input.text}\``;
if (name === "list_sessions") return "List active sessions";
if (name === "connect_session") return `Connect to session ${input.sessionId}`;
if (name === "disconnect_session") return `Disconnect session ${input.sessionId}`;
return name;
});
const formattedInput = computed(() => JSON.stringify(props.toolCall.input, null, 2));
const formattedResult = computed(() =>
props.toolCall.result !== undefined
? JSON.stringify(props.toolCall.result, null, 2)
: "",
);
</script>

View File

@ -0,0 +1,177 @@
import { useCopilotStore } from "@/stores/copilot.store";
import type { ToolCall } from "@/stores/copilot.store";
/**
* Composable providing mock Wails binding wrappers for the AI copilot.
*
* All functions simulate Claude-style behavior until real Wails bindings
* (AIService.*) are connected.
*/
export function useCopilot() {
const store = useCopilotStore();
/** Simulate word-by-word streaming output. */
async function mockStream(
text: string,
onDelta: (word: string) => void,
): Promise<void> {
const words = text.split(" ");
for (const word of words) {
await new Promise((r) => setTimeout(r, 30 + Math.random() * 70));
onDelta(word + " ");
}
}
/** Simulate a tool call execution with realistic delay. */
async function mockToolCall(
name: string,
_input: Record<string, unknown>,
): Promise<unknown> {
await new Promise((r) => setTimeout(r, 500 + Math.random() * 1000));
if (name === "list_sessions") return [];
if (name === "terminal_read") {
return {
lines: [
"$ systemctl status nginx",
"● nginx.service - A high performance web server",
" Loaded: loaded (/lib/systemd/system/nginx.service; enabled)",
" Active: active (running) since Mon 2026-03-17 08:30:12 UTC",
" Main PID: 1234 (nginx)",
" Tasks: 5 (limit: 4915)",
" Memory: 12.4M",
],
};
}
if (name === "terminal_write") return { status: "ok" };
if (name === "rdp_screenshot") {
return { thumbnail: "data:image/jpeg;base64,/9j/4AAQ..." };
}
if (name === "rdp_click") return { status: "ok" };
if (name === "rdp_type") return { status: "ok" };
if (name === "sftp_read") return { content: "# example file content" };
if (name === "sftp_write") return { bytesWritten: 1024 };
return { status: "ok" };
}
/**
* Process a user message route to the appropriate mock response.
*
* Routes:
* - "hello"/"hey" -> greeting
* - "ssh"/"server"/"check" -> terminal tool calls
* - "rdp"/"desktop"/"screen" -> RDP screenshot tool call
* - default -> list_sessions assessment
*/
async function processMessage(text: string): Promise<void> {
const lower = text.toLowerCase();
store.isStreaming = true;
const assistantMsg = store.createAssistantMessage();
try {
if (/\b(hello|hey|hi)\b/.test(lower)) {
await mockStream(
"XO online. I have access to your active sessions. What's the mission, Commander?",
(word) => store.appendStreamDelta(word),
);
} else if (/\b(ssh|server|check|nginx|status)\b/.test(lower)) {
await mockStream(
"On it. Let me check the server status.",
(word) => store.appendStreamDelta(word),
);
// Tool call 1: terminal_write
const writeCallId = `tc-${Date.now()}-1`;
const writeCall: ToolCall = {
id: writeCallId,
name: "terminal_write",
input: { sessionId: "s1", text: "systemctl status nginx" },
status: "pending",
};
store.addToolCall(writeCall);
const writeResult = await mockToolCall("terminal_write", writeCall.input);
store.completeToolCall(writeCallId, writeResult);
// Tool call 2: terminal_read
const readCallId = `tc-${Date.now()}-2`;
const readCall: ToolCall = {
id: readCallId,
name: "terminal_read",
input: { sessionId: "s1", lines: 20 },
status: "pending",
};
store.addToolCall(readCall);
const readResult = await mockToolCall("terminal_read", readCall.input);
store.completeToolCall(readCallId, readResult);
// Summary
store.appendStreamDelta("\n\n");
await mockStream(
"Nginx is **active and running**. The service has been up since 08:30 UTC today, using 12.4M of memory with 5 active tasks. Everything looks healthy.",
(word) => store.appendStreamDelta(word),
);
} else if (/\b(rdp|desktop|screen|screenshot)\b/.test(lower)) {
await mockStream(
"Taking a screenshot of the remote desktop.",
(word) => store.appendStreamDelta(word),
);
// Tool call: rdp_screenshot
const callId = `tc-${Date.now()}`;
const call: ToolCall = {
id: callId,
name: "rdp_screenshot",
input: { sessionId: "s2" },
status: "pending",
};
store.addToolCall(call);
const result = await mockToolCall("rdp_screenshot", call.input);
store.completeToolCall(callId, result);
store.appendStreamDelta("\n\n");
await mockStream(
"I can see the Windows desktop. The screen shows the default wallpaper with a few application shortcuts. No error dialogs or unusual activity detected.",
(word) => store.appendStreamDelta(word),
);
} else {
await mockStream(
"Understood. Let me assess the situation.",
(word) => store.appendStreamDelta(word),
);
// Tool call: list_sessions
const callId = `tc-${Date.now()}`;
const call: ToolCall = {
id: callId,
name: "list_sessions",
input: {},
status: "pending",
};
store.addToolCall(call);
const result = await mockToolCall("list_sessions", call.input);
store.completeToolCall(callId, result);
store.appendStreamDelta("\n\n");
await mockStream(
"I've reviewed your current sessions. No active connections detected at the moment. Would you like me to connect to a specific server or run a diagnostic?",
(word) => store.appendStreamDelta(word),
);
}
// Track mock output tokens
store.tokenUsage.output += Math.ceil(
(assistantMsg.content.length) / 4,
);
} finally {
store.isStreaming = false;
}
}
return { mockStream, mockToolCall, processMessage };
}

View File

@ -31,6 +31,25 @@
<path d="M11.5 7a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0zm-.82 4.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.75.75 0 1 1-1.06 1.06l-3.04-3.04z" />
</svg>
</button>
<!-- XO Copilot toggle -->
<button
class="relative hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
:class="copilotStore.isPanelOpen ? 'text-[var(--wraith-accent-blue)]' : ''"
title="Toggle XO Copilot (Ctrl+Shift+K)"
@click="copilotStore.togglePanel()"
>
<span class="text-sm">&#x1F47B;</span>
<!-- Streaming indicator dot -->
<span
v-if="copilotStore.isStreaming"
class="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-[var(--wraith-accent-blue)] animate-pulse"
/>
<!-- Subtle glow when open -->
<span
v-if="copilotStore.isPanelOpen"
class="absolute inset-0 rounded-full bg-[var(--wraith-accent-blue)] opacity-15 blur-sm"
/>
</button>
<button
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
title="Lock vault"
@ -107,6 +126,11 @@
<!-- Session area -->
<SessionContainer />
</div>
<!-- Copilot Panel (slides in from right) -->
<transition name="copilot-slide">
<CopilotPanel v-if="copilotStore.isPanelOpen" />
</transition>
</div>
<!-- Status bar -->
@ -128,6 +152,7 @@ import { ref, onMounted, onUnmounted } from "vue";
import { useAppStore } from "@/stores/app.store";
import { useConnectionStore } from "@/stores/connection.store";
import { useSessionStore } from "@/stores/session.store";
import { useCopilotStore } from "@/stores/copilot.store";
import SidebarToggle from "@/components/sidebar/SidebarToggle.vue";
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
import FileTree from "@/components/sftp/FileTree.vue";
@ -139,6 +164,7 @@ import EditorWindow from "@/components/editor/EditorWindow.vue";
import CommandPalette from "@/components/common/CommandPalette.vue";
import ThemePicker from "@/components/common/ThemePicker.vue";
import ImportDialog from "@/components/common/ImportDialog.vue";
import CopilotPanel from "@/components/copilot/CopilotPanel.vue";
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
import type { SidebarTab } from "@/components/sidebar/SidebarToggle.vue";
import type { FileEntry } from "@/composables/useSftp";
@ -146,6 +172,7 @@ import type { FileEntry } from "@/composables/useSftp";
const appStore = useAppStore();
const connectionStore = useConnectionStore();
const sessionStore = useSessionStore();
const copilotStore = useCopilotStore();
const sidebarWidth = ref(240);
const sidebarTab = ref<SidebarTab>("connections");
@ -247,6 +274,13 @@ function handleQuickConnect(): void {
/** Global keyboard shortcut handler. */
function handleKeydown(event: KeyboardEvent): void {
// Ctrl+Shift+K or Cmd+Shift+K toggle copilot panel
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "K") {
event.preventDefault();
copilotStore.togglePanel();
return;
}
// Ctrl+K or Cmd+K open command palette
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
event.preventDefault();
@ -262,3 +296,17 @@ onUnmounted(() => {
document.removeEventListener("keydown", handleKeydown);
});
</script>
<style scoped>
.copilot-slide-enter-active,
.copilot-slide-leave-active {
transition: width 0.3s ease, opacity 0.3s ease;
overflow: hidden;
}
.copilot-slide-enter-from,
.copilot-slide-leave-to {
width: 0px !important;
opacity: 0;
}
</style>

View File

@ -0,0 +1,182 @@
import { defineStore } from "pinia";
import { ref } from "vue";
export interface ToolCall {
id: string;
name: string;
input: Record<string, unknown>;
result?: unknown;
status: "pending" | "done" | "error";
}
export interface Message {
id: string;
role: "user" | "assistant";
content: string;
toolCalls?: ToolCall[];
timestamp: number;
}
export interface ConversationSummary {
id: string;
title: string;
model: string;
createdAt: string;
tokensIn: number;
tokensOut: number;
}
/**
* Copilot (XO) store.
* Manages the AI assistant panel state, messages, and streaming.
*
* All Wails AIService calls are TODOs with mock behavior for now.
*/
export const useCopilotStore = defineStore("copilot", () => {
const isPanelOpen = ref(false);
const isAuthenticated = ref(true); // default true for mock
const isStreaming = ref(false);
const activeConversationId = ref<string | null>(null);
const messages = ref<Message[]>([]);
const conversations = ref<ConversationSummary[]>([]);
const model = ref("claude-sonnet-4-5-20250514");
const tokenUsage = ref({ input: 0, output: 0 });
const showSettings = ref(false);
/** Toggle the copilot panel open/closed. */
function togglePanel(): void {
isPanelOpen.value = !isPanelOpen.value;
}
/** Start OAuth login flow. */
function startLogin(): void {
// TODO: Wails AIService.StartLogin()
isAuthenticated.value = true;
}
/** Start a new conversation. */
function newConversation(): void {
// TODO: Wails AIService.NewConversation()
activeConversationId.value = `conv-${Date.now()}`;
messages.value = [];
tokenUsage.value = { input: 0, output: 0 };
}
/** Send a user message and trigger a mock XO response. */
function sendMessage(text: string): void {
const userMsg: Message = {
id: `msg-${Date.now()}`,
role: "user",
content: text,
timestamp: Date.now(),
};
messages.value.push(userMsg);
// Track mock input tokens
tokenUsage.value.input += Math.ceil(text.length / 4);
if (!activeConversationId.value) {
activeConversationId.value = `conv-${Date.now()}`;
}
}
/** Append a streaming text delta to the latest assistant message. */
function appendStreamDelta(text: string): void {
const last = messages.value[messages.value.length - 1];
if (last && last.role === "assistant") {
last.content += text;
}
}
/** Create a new assistant message (for streaming start). */
function createAssistantMessage(): Message {
const msg: Message = {
id: `msg-${Date.now()}`,
role: "assistant",
content: "",
toolCalls: [],
timestamp: Date.now(),
};
messages.value.push(msg);
return msg;
}
/** Add a tool call to the latest assistant message. */
function addToolCall(call: ToolCall): void {
const last = messages.value[messages.value.length - 1];
if (last && last.role === "assistant") {
if (!last.toolCalls) last.toolCalls = [];
last.toolCalls.push(call);
}
}
/** Complete a pending tool call with a result. */
function completeToolCall(callId: string, result: unknown): void {
for (const msg of messages.value) {
if (!msg.toolCalls) continue;
const tc = msg.toolCalls.find((t) => t.id === callId);
if (tc) {
tc.result = result;
tc.status = "done";
break;
}
}
}
/** Mark a tool call as errored. */
function failToolCall(callId: string, error: unknown): void {
for (const msg of messages.value) {
if (!msg.toolCalls) continue;
const tc = msg.toolCalls.find((t) => t.id === callId);
if (tc) {
tc.result = error;
tc.status = "error";
break;
}
}
}
/** Load conversation list from backend. */
async function loadConversations(): Promise<void> {
// TODO: Wails AIService.ListConversations()
conversations.value = [];
}
/** Clear all messages in the current conversation. */
function clearHistory(): void {
messages.value = [];
activeConversationId.value = null;
tokenUsage.value = { input: 0, output: 0 };
}
/** Disconnect from Claude (reset auth state). */
function disconnect(): void {
// TODO: Wails AIService.Disconnect()
isAuthenticated.value = false;
clearHistory();
}
return {
isPanelOpen,
isAuthenticated,
isStreaming,
activeConversationId,
messages,
conversations,
model,
tokenUsage,
showSettings,
togglePanel,
startLogin,
newConversation,
sendMessage,
appendStreamDelta,
createAssistantMessage,
addToolCall,
completeToolCall,
failToolCall,
loadConversations,
clearHistory,
disconnect,
};
});