diff --git a/frontend/src/components/copilot/CopilotMessage.vue b/frontend/src/components/copilot/CopilotMessage.vue new file mode 100644 index 0000000..9fdf9bc --- /dev/null +++ b/frontend/src/components/copilot/CopilotMessage.vue @@ -0,0 +1,117 @@ + + + + + + + {{ message.content }} + + + + + + + + + + + + + + diff --git a/frontend/src/components/copilot/CopilotPanel.vue b/frontend/src/components/copilot/CopilotPanel.vue new file mode 100644 index 0000000..70c9fae --- /dev/null +++ b/frontend/src/components/copilot/CopilotPanel.vue @@ -0,0 +1,237 @@ + + + + + + + {{ ghostIcon }} + XO + {{ modelShort }} + + + + + {{ formatTokens(store.tokenUsage.input) }} / {{ formatTokens(store.tokenUsage.output) }} + + + + + + + + + + + + + + + + + + + {{ ghostIcon }} + + Connect to Claude to activate the XO AI copilot. + + + Connect to Claude + + + + + + + + {{ ghostIcon }} + XO standing by. + Send a message to begin. + + + + + + + + + + + + + + + + + + + + XO is responding... + + + + + + + + + diff --git a/frontend/src/components/copilot/CopilotSettings.vue b/frontend/src/components/copilot/CopilotSettings.vue new file mode 100644 index 0000000..24ec541 --- /dev/null +++ b/frontend/src/components/copilot/CopilotSettings.vue @@ -0,0 +1,137 @@ + + + + + + + + XO Settings + + + + + + + + + + + + + Connect to Claude + + + + Or enter API Key + + + Authenticate + + + + + + + Model + + claude-sonnet-4-5-20250514 + claude-opus-4-5-20250414 + claude-haiku-4-5-20251001 + + + + + + Token Usage + + + In: {{ formatTokens(store.tokenUsage.input) }} + + + Out: {{ formatTokens(store.tokenUsage.output) }} + + + + + + + + Clear History + + + Disconnect + + + + + + + + diff --git a/frontend/src/components/copilot/CopilotToolViz.vue b/frontend/src/components/copilot/CopilotToolViz.vue new file mode 100644 index 0000000..90a6662 --- /dev/null +++ b/frontend/src/components/copilot/CopilotToolViz.vue @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ icon }} {{ summary }} + + + + + + + + + + + Input + {{ formattedInput }} + + + Result + {{ formattedResult }} + + + + + + diff --git a/frontend/src/composables/useCopilot.ts b/frontend/src/composables/useCopilot.ts new file mode 100644 index 0000000..120d3f2 --- /dev/null +++ b/frontend/src/composables/useCopilot.ts @@ -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 { + 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, + ): Promise { + 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 { + 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 }; +} diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index f5e4f7e..837ad6c 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -31,6 +31,25 @@ + + + 👻 + + + + + + + + + + @@ -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("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); }); + + diff --git a/frontend/src/stores/copilot.store.ts b/frontend/src/stores/copilot.store.ts new file mode 100644 index 0000000..0272e7f --- /dev/null +++ b/frontend/src/stores/copilot.store.ts @@ -0,0 +1,182 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; + +export interface ToolCall { + id: string; + name: string; + input: Record; + 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(null); + const messages = ref([]); + const conversations = ref([]); + 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 { + // 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, + }; +});
+ Connect to Claude to activate the XO AI copilot. +
XO standing by.
Send a message to begin.
{{ formattedInput }}
{{ formattedResult }}