Waiting for guidance…
+diff --git a/.gitignore b/.gitignore index e69de29..c997a6a 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,55 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ +*.egg +.eggs/ + +# Virtual environment +venv/ +.venv/ +env/ +ENV/ + +# Environment variables — NEVER commit secrets +.env +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store + +# Testing / coverage +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# Mypy +.mypy_cache/ + +# Logs +*.log +logs/ + +# Data / models +data/ +*.joblib + +# Node / Electron (frontend/overlay) +frontend/overlay/node_modules/ +frontend/overlay/dist/ +frontend/overlay/out/ +frontend/overlay/package-lock.json + +# Docker +*.dockerignore diff --git a/api/main.py b/api/main.py index 7fffd45..569ca33 100644 --- a/api/main.py +++ b/api/main.py @@ -3,9 +3,10 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from api.routes import status, mode +from api.routes import status, mode, plugins from api.routes import actions, context from api.websockets import guidance as ws_guidance +from api.websockets import router as ws_router from core.config import settings from core.errors import handle_exception # ✅ NEW @@ -32,6 +33,7 @@ async def startup_event(): logger.info("Execra API starting...") from api.websockets.router import broadcast_action_log from core.hybrid.action_logger import action_logger + action_logger.register_callback(broadcast_action_log) @@ -41,6 +43,7 @@ async def shutdown_event(): logger.info("Execra API shutting down...") from api.websockets.router import broadcast_action_log from core.hybrid.action_logger import action_logger + action_logger.unregister_callback(broadcast_action_log) @@ -50,10 +53,7 @@ def read_root(): try: return { "status": "success", - "data": { - "message": "Execra is running", - "version": "0.1.0" - } + "data": {"message": "Execra is running", "version": "0.1.0"}, } except Exception as e: return handle_exception(e) @@ -66,6 +66,7 @@ def read_root(): app.include_router(mode.router, prefix="/api/v1") app.include_router(actions.router, prefix="/api/v1") app.include_router(context.router, prefix="/api/v1") + app.include_router(plugins.router, prefix="/api/v1") except Exception as e: handle_exception(e) @@ -77,6 +78,7 @@ def read_root(): # WebSocket endpoints (no prefix — WS routes use the path as-is) app.include_router(ws_guidance.router) +app.include_router(ws_router.router) -# Alert suppression endpoints -app.include_router(suppression.router, prefix="/api/v1") \ No newline at end of file +# Alert suppression endpoints +app.include_router(suppression.router, prefix="/api/v1") diff --git a/core/config.py b/core/config.py index 9bcdb30..2f0d48f 100644 --- a/core/config.py +++ b/core/config.py @@ -4,10 +4,11 @@ """ import os -from typing import List, Optional from dataclasses import dataclass, field -from typing import Optional +from typing import List, Optional + from dotenv import load_dotenv + from core.utils.env_validator import assert_env # Load .env file @@ -59,6 +60,18 @@ class Settings: REDIS_URL: str = "redis://localhost:6379" REDIS_AUTH: Optional[str] = None + # WebSocket Configuration + WS_API_TOKEN: str = "" + WS_MAX_CONNECTIONS: int = 100 + WS_RATE_LIMIT_MESSAGES: int = 60 + WS_RATE_LIMIT_WINDOW_S: int = 60 + WS_HEARTBEAT_INTERVAL_S: int = 30 + + # Trust Score Weights + TRUST_SCORE_W1: float = 0.5 + TRUST_SCORE_W2: float = 0.3 + TRUST_SCORE_W3: float = 0.2 + # Trace Anomaly Detection (Isolation Forest) # Expected fraction of anomalous traces in training data. ANOMALY_CONTAMINATION: float = 0.1 @@ -118,6 +131,18 @@ def __post_init__(self): if val := os.getenv("REDIS_PASSWORD"): self.REDIS_AUTH = val + # WebSocket + if val := os.getenv("WS_API_TOKEN"): + self.WS_API_TOKEN = val + if val := os.getenv("WS_MAX_CONNECTIONS"): + self.WS_MAX_CONNECTIONS = int(val) + if val := os.getenv("WS_RATE_LIMIT_MESSAGES"): + self.WS_RATE_LIMIT_MESSAGES = int(val) + if val := os.getenv("WS_RATE_LIMIT_WINDOW_S"): + self.WS_RATE_LIMIT_WINDOW_S = int(val) + if val := os.getenv("WS_HEARTBEAT_INTERVAL_S"): + self.WS_HEARTBEAT_INTERVAL_S = int(val) + # Trust Score Weights if env_val := os.getenv("TRUST_SCORE_W1"): self.TRUST_SCORE_W1 = float(env_val) @@ -167,4 +192,4 @@ def validate_required(self) -> None: # Global settings instance - import this everywhere -settings = Settings() \ No newline at end of file +settings = Settings() diff --git a/core/intelligence/__pycache__/__init__.cpython-314.pyc b/core/intelligence/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 0cc4f4f..0000000 Binary files a/core/intelligence/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/core/intelligence/__pycache__/plugin_rule_engine.cpython-314.pyc b/core/intelligence/__pycache__/plugin_rule_engine.cpython-314.pyc deleted file mode 100644 index 27f546c..0000000 Binary files a/core/intelligence/__pycache__/plugin_rule_engine.cpython-314.pyc and /dev/null differ diff --git a/core/plugins/__pycache__/__init__.cpython-314.pyc b/core/plugins/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index bc21f6c..0000000 Binary files a/core/plugins/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/core/plugins/__pycache__/rule_loader.cpython-314.pyc b/core/plugins/__pycache__/rule_loader.cpython-314.pyc deleted file mode 100644 index e0a0acf..0000000 Binary files a/core/plugins/__pycache__/rule_loader.cpython-314.pyc and /dev/null differ diff --git a/frontend/overlay/.gitignore b/frontend/overlay/.gitignore new file mode 100644 index 0000000..11ae43e --- /dev/null +++ b/frontend/overlay/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +out/ +*.log +.DS_Store diff --git a/frontend/overlay/README.md b/frontend/overlay/README.md new file mode 100644 index 0000000..22fa7b7 --- /dev/null +++ b/frontend/overlay/README.md @@ -0,0 +1,92 @@ +# Execra Guidance Overlay + +An always-on-top, semi-transparent Electron.js desktop overlay that displays +Execra's real-time guidance over the user's screen while they work. + +--- + +## Prerequisites + +- **Node.js ≥ 18** — [nodejs.org](https://nodejs.org) +- **Execra backend running** — `uvicorn api.main:app --reload` (from project root) + +--- + +## Quick Start + +```bash +# 1. Navigate to this directory +cd frontend/overlay + +# 2. Install dependencies (only needed once) +npm install + +# 3. Launch the overlay +npm start + +# Dev mode (opens DevTools automatically) +npm run dev +``` + +--- + +## Files + +``` +frontend/overlay/ +├── main.js # Electron main process — BrowserWindow config & IPC +├── preload.js # contextBridge — exposes window.execra to renderer +├── package.json # npm config +├── .gitignore +└── renderer/ + ├── index.html # Overlay HTML shell + ├── app.js # UI logic — WebSocket events, rendering, controls + └── styles.css # Glassmorphism dark theme +``` + +--- + +## WebSocket Configuration + +By default the overlay connects to `ws://localhost:8000/ws/guidance` with no auth token +(matches the backend's default dev configuration where `WS_API_TOKEN` is empty). + +To change the URL or add a token, edit the top of `renderer/app.js`: + +```js +const WS_URL = 'ws://localhost:8000/ws/guidance'; +const WS_TOKEN = ''; // set to your WS_API_TOKEN value if configured +``` + +--- + +## UI Components + +| Component | Description | +|---|---| +| **Mode pill** | Shows PASSIVE / ACTIVE / MIXED — updates from each guidance payload | +| **Connection dot** | Green = connected, Red = disconnected, Orange = reconnecting | +| **Confidence bar** | Green ≥85%, Orange 65–84%, Red <65% | +| **Step counter** | "Step N of M" from guidance payload | +| **Source tags** | LLM · Rule Engine · Trace pill badges | +| **Instruction card** | Animated fade-in on each new instruction | +| **Reasoning** | Collapsible — shown when `reasoning` field is non-empty | +| **Error banner** | Red alert with severity icon on `error` messages | +| **Active Mode input** | Shown only in ACTIVE / MIXED mode; sends `{"prompt": "..."}` | +| **Minimize / Expand** | Collapses window to title bar only | + +--- + +## Reconnection + +The overlay reconnects automatically with **exponential back-off** (1 s → 2 s → 4 s … up to 30 s) +if the WebSocket drops unexpectedly. A normal close (code 1000) does not trigger reconnection. + +--- + +## Security + +- `contextIsolation: true` and `nodeIntegration: false` — renderer is fully sandboxed +- Only the `window.execra` API (defined in `preload.js` via `contextBridge`) is + accessible to renderer code — no direct Node.js or Electron API exposure +- Content Security Policy in `index.html` restricts resource origins diff --git a/frontend/overlay/main.js b/frontend/overlay/main.js new file mode 100644 index 0000000..e7b6f74 --- /dev/null +++ b/frontend/overlay/main.js @@ -0,0 +1,121 @@ +/** + * frontend/overlay/main.js + * + * Electron main process for the Execra guidance overlay. + * + * Creates a frameless, always-on-top, semi-transparent BrowserWindow that + * floats over the user's screen and displays real-time guidance from the + * Execra backend via WebSocket. + * + * Security: + * - contextIsolation: true — renderer cannot access Node APIs directly + * - nodeIntegration: false — renderer is sandboxed + * - preload script exposes only the safe window.execra API via contextBridge + * + * IPC channels: + * - 'overlay-minimize' — shrinks the window to collapsed state + * - 'overlay-restore' — restores the window to full height + * - 'overlay-close' — quits the application + */ + +const { app, BrowserWindow, ipcMain, screen } = require('electron'); +const path = require('path'); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const OVERLAY_WIDTH = 380; +const OVERLAY_HEIGHT = 520; +const OVERLAY_MIN_HEIGHT = 56; // collapsed (title bar only) +const MARGIN = 20; // distance from screen edge + +let mainWindow = null; + +// --------------------------------------------------------------------------- +// Window factory +// --------------------------------------------------------------------------- + +function createOverlayWindow() { + const { width: screenWidth, height: screenHeight } = + screen.getPrimaryDisplay().workAreaSize; + + mainWindow = new BrowserWindow({ + width: OVERLAY_WIDTH, + height: OVERLAY_HEIGHT, + x: screenWidth - OVERLAY_WIDTH - MARGIN, + y: screenHeight - OVERLAY_HEIGHT - MARGIN, + + // Appearance + frame: false, + transparent: true, + hasShadow: true, + vibrancy: 'dark', // macOS frosted-glass effect (ignored elsewhere) + visualEffectState: 'active', + + // Behaviour + alwaysOnTop: true, + skipTaskbar: false, + resizable: false, + minimizable: false, // custom minimize via IPC + fullscreenable: false, + movable: true, + + // Security + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + + mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); + + // Keep the overlay on top of other always-on-top windows too + mainWindow.setAlwaysOnTop(true, 'screen-saver'); + + // Dev tools in dev mode + if (process.argv.includes('--dev')) { + mainWindow.webContents.openDevTools({ mode: 'detach' }); + } + + mainWindow.on('closed', () => { mainWindow = null; }); +} + +// --------------------------------------------------------------------------- +// IPC handlers +// --------------------------------------------------------------------------- + +ipcMain.on('overlay-minimize', () => { + if (!mainWindow) return; + mainWindow.setSize(OVERLAY_WIDTH, OVERLAY_MIN_HEIGHT, true); +}); + +ipcMain.on('overlay-restore', () => { + if (!mainWindow) return; + mainWindow.setSize(OVERLAY_WIDTH, OVERLAY_HEIGHT, true); +}); + +ipcMain.on('overlay-close', () => { + app.quit(); +}); + +// --------------------------------------------------------------------------- +// App lifecycle +// --------------------------------------------------------------------------- + +app.whenReady().then(() => { + createOverlayWindow(); + + // macOS: re-create window when dock icon is clicked and no windows are open + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createOverlayWindow(); + }); +}); + +app.on('window-all-closed', () => { + // On macOS apps typically stay alive — quit unconditionally here + // since the overlay is meant to be explicitly closed. + app.quit(); +}); diff --git a/frontend/overlay/package.json b/frontend/overlay/package.json new file mode 100644 index 0000000..c703d66 --- /dev/null +++ b/frontend/overlay/package.json @@ -0,0 +1,21 @@ +{ + "name": "execra-overlay", + "version": "1.0.0", + "description": "Execra always-on-top guidance overlay — Electron.js frontend", + "main": "main.js", + "scripts": { + "start": "electron .", + "dev": "electron . --dev" + }, + "keywords": [ + "execra", + "electron", + "overlay", + "guidance" + ], + "author": "Execra Contributors", + "license": "MIT", + "devDependencies": { + "electron": "^42.2.0" + } +} diff --git a/frontend/overlay/preload.js b/frontend/overlay/preload.js new file mode 100644 index 0000000..d631773 --- /dev/null +++ b/frontend/overlay/preload.js @@ -0,0 +1,140 @@ +/** + * frontend/overlay/preload.js + * + * Runs in a privileged context (Node.js + Electron APIs available) but + * exposes ONLY a narrow, safe surface to the renderer via contextBridge. + * + * window.execra API exposed to renderer: + * + * connect(wsUrl, token?) — opens WebSocket; auto-reconnects on disconnect + * onMessage(callback) — register a handler for incoming WS messages + * sendPrompt(text) — send {"prompt": text} to the server (Active Mode) + * minimize() — collapse the overlay to title bar + * restore() — expand the overlay to full height + * closeOverlay() — quit the application + */ + +const { contextBridge, ipcRenderer } = require('electron'); + +// --------------------------------------------------------------------------- +// Internal WebSocket state — NOT exposed to renderer directly +// --------------------------------------------------------------------------- + +let _socket = null; +let _messageHandlers = []; +let _wsUrl = null; +let _wsToken = null; +let _reconnectTimer = null; +let _reconnectDelay = 1000; // ms — doubles on each failed attempt (max 30 s) +const MAX_RECONNECT_DELAY = 30000; + +/** + * Open a WebSocket connection to `url`. + * Reconnects automatically with exponential back-off on unexpected close. + */ +function _connect(url, token = '') { + _wsUrl = url; + _wsToken = token; + + const fullUrl = token ? `${url}?token=${encodeURIComponent(token)}` : url; + + if (_socket) { + _socket.onclose = null; // suppress reconnect from the old socket + _socket.close(); + } + + _socket = new WebSocket(fullUrl); + + _socket.onopen = () => { + _reconnectDelay = 1000; // reset back-off on successful connect + _dispatch({ type: 'connected' }); + }; + + _socket.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + _dispatch(data); + } catch { + _dispatch({ type: 'raw', data: event.data }); + } + }; + + _socket.onerror = () => { + _dispatch({ type: 'ws_error' }); + }; + + _socket.onclose = (event) => { + // 1000 = normal (user-initiated) — do NOT reconnect + if (event.code === 1000) { + _dispatch({ type: 'disconnected', clean: true }); + return; + } + + _dispatch({ type: 'disconnected', clean: false, code: event.code }); + + // Exponential back-off reconnect + clearTimeout(_reconnectTimer); + _reconnectTimer = setTimeout(() => { + _reconnectDelay = Math.min(_reconnectDelay * 2, MAX_RECONNECT_DELAY); + _connect(_wsUrl, _wsToken); + }, _reconnectDelay); + }; +} + +/** Dispatch a parsed message object to all registered handlers. */ +function _dispatch(message) { + _messageHandlers.forEach(cb => { + try { cb(message); } catch { /* renderer handler must not crash preload */ } + }); +} + +// --------------------------------------------------------------------------- +// contextBridge — the ONLY surface the renderer can touch +// --------------------------------------------------------------------------- + +contextBridge.exposeInMainWorld('execra', { + /** + * Connect to the Execra guidance WebSocket. + * @param {string} wsUrl e.g. "ws://localhost:8000/ws/guidance" + * @param {string} [token] optional auth token + */ + connect(wsUrl, token = '') { + _connect(wsUrl, token); + }, + + /** + * Register a callback for incoming WebSocket messages. + * The callback receives a parsed JS object. + * @param {function} callback + */ + onMessage(callback) { + if (typeof callback === 'function') { + _messageHandlers.push(callback); + } + }, + + /** + * Send a user prompt to the server (Active Mode). + * @param {string} text + */ + sendPrompt(text) { + if (_socket && _socket.readyState === WebSocket.OPEN) { + _socket.send(JSON.stringify({ prompt: text })); + } + }, + + /** Collapse the overlay window to title-bar height. */ + minimize() { + ipcRenderer.send('overlay-minimize'); + }, + + /** Restore the overlay window to full height. */ + restore() { + ipcRenderer.send('overlay-restore'); + }, + + /** Quit the overlay application. */ + closeOverlay() { + ipcRenderer.send('overlay-close'); + }, +}); diff --git a/frontend/overlay/renderer/app.js b/frontend/overlay/renderer/app.js new file mode 100644 index 0000000..ca07a98 --- /dev/null +++ b/frontend/overlay/renderer/app.js @@ -0,0 +1,350 @@ +/** + * frontend/overlay/renderer/app.js + * + * Renderer-side UI controller for the Execra guidance overlay. + * + * Responsibilities: + * - Connect to the Execra WebSocket guidance endpoint on page load + * - Handle all server event types: guidance, ping, error, connected, disconnected + * - Update every UI element in response to incoming GuidanceInstruction payloads + * - Manage minimize / expand / close lifecycle via window.execra IPC bridge + * - Handle Active Mode input and send prompts + * + * Compatible with both the current stub format {"guidance": "..."} and the + * full GuidanceInstruction schema: + * { instruction, confidence, source, step, total_steps, mode, reasoning, ... } + */ + +'use strict'; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +const WS_URL = 'ws://localhost:8000/ws/guidance'; +const WS_TOKEN = ''; // set if WS_API_TOKEN is configured in .env + +// --------------------------------------------------------------------------- +// DOM references +// --------------------------------------------------------------------------- + +const $ = id => document.getElementById(id); + +const elConnDot = $('conn-dot'); +const elModePill = $('mode-pill'); +const elBtnToggle = $('btn-toggle'); +const elBtnClose = $('btn-close'); +const elIconMinimize = $('icon-minimize'); +const elIconExpand = $('icon-expand'); +const elOverlayBody = $('overlay-body'); +const elErrorBanner = $('error-banner'); +const elErrorText = $('error-text'); +const elStepCounter = $('step-counter'); +const elSourceTags = $('source-tags'); +const elConfidenceFill = $('confidence-fill'); +const elConfidencePct = $('confidence-pct'); +const elConfidenceTrack = $('confidence-track'); +const elInstructionText = $('instruction-text'); +const elReasoningDetails = $('reasoning-details'); +const elReasoningText = $('reasoning-text'); +const elActiveWrap = $('active-input-wrap'); +const elActiveInput = $('active-input'); +const elBtnSend = $('btn-send'); +const elStatusText = $('status-text'); + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +let isMinimized = false; + +// --------------------------------------------------------------------------- +// WebSocket event handler +// --------------------------------------------------------------------------- + +/** + * Dispatch an incoming WS message to the appropriate UI handler. + * Handles both the stub format and the full GuidanceInstruction schema. + * + * @param {object} msg - Parsed JSON from the WebSocket + */ +function handleMessage(msg) { + // Internal connection lifecycle events (from preload.js) + if (msg.type === 'connected') { + setConnectionState('connected'); + return; + } + if (msg.type === 'disconnected') { + setConnectionState(msg.clean ? 'disconnected' : 'reconnecting'); + setStatus(msg.clean ? 'Disconnected' : 'Reconnecting…'); + return; + } + if (msg.type === 'ws_error') { + setConnectionState('reconnecting'); + setStatus('Connection error — retrying…'); + return; + } + if (msg.type === 'ping') { + // Heartbeat from server — nothing to render + return; + } + + // ── Error message from server ────────────────────────────────────────── + if (msg.error) { + showError(msg.error); + return; + } + + // ── Full GuidanceInstruction payload ────────────────────────────────── + if (msg.instruction) { + hideError(); + renderGuidance({ + instruction: msg.instruction, + confidence: msg.confidence ?? 1.0, + source: msg.source ?? [], + step: msg.step ?? 1, + total_steps: msg.total_steps ?? 1, + mode: msg.mode ?? 'passive', + reasoning: msg.reasoning ?? '', + }); + return; + } + + // ── Stub format: {"guidance": "..."} ────────────────────────────────── + if (msg.guidance) { + hideError(); + renderGuidance({ + instruction: msg.guidance, + confidence: 1.0, + source: [], + step: 1, + total_steps: 1, + mode: 'passive', + reasoning: '', + }); + } +} + +// --------------------------------------------------------------------------- +// UI renderers +// --------------------------------------------------------------------------- + +/** + * Render a full guidance payload into all UI components. + * @param {{ instruction, confidence, source, step, total_steps, mode, reasoning }} g + */ +function renderGuidance({ instruction, confidence, source, step, total_steps, mode, reasoning }) { + renderInstruction(instruction); + renderConfidence(confidence); + renderStepCounter(step, total_steps); + renderSourceTags(source); + renderMode(mode); + renderReasoning(reasoning); + setStatus(`Updated ${new Date().toLocaleTimeString()}`); +} + +/** + * Animate the instruction text with a fade-in on change. + * @param {string} text + */ +function renderInstruction(text) { + elInstructionText.classList.remove('fade-in'); + // Trigger reflow to restart animation + void elInstructionText.offsetWidth; + elInstructionText.textContent = text; + elInstructionText.classList.add('fade-in'); +} + +/** + * Update the confidence bar colour and width. + * Green ≥85%, Orange 65–84%, Red <65%. + * @param {number} score - 0.0 to 1.0 + */ +function renderConfidence(score) { + const pct = Math.round(score * 100); + elConfidenceFill.style.width = `${pct}%`; + + elConfidenceFill.classList.remove('high', 'medium', 'low'); + let cls, color; + if (pct >= 85) { + cls = 'high'; + color = '#22c55e'; + } else if (pct >= 65) { + cls = 'medium'; + color = '#f97316'; + } else { + cls = 'low'; + color = '#ef4444'; + } + elConfidenceFill.classList.add(cls); + + elConfidencePct.textContent = `${pct}%`; + elConfidencePct.style.color = color; + elConfidenceTrack.setAttribute('aria-valuenow', pct); +} + +/** + * Update the "Step N of M" counter. + * @param {number} step + * @param {number} total + */ +function renderStepCounter(step, total) { + elStepCounter.textContent = `Step ${step} of ${total}`; +} + +/** + * Render source tag pills (LLM, Rule Engine, Trace, or custom). + * @param {string[]} sources + */ +function renderSourceTags(sources) { + elSourceTags.innerHTML = ''; + if (!Array.isArray(sources) || sources.length === 0) return; + + sources.forEach(src => { + const pill = document.createElement('span'); + pill.className = 'source-tag'; + + const lc = src.toLowerCase(); + if (lc === 'llm') { + pill.classList.add('tag-llm'); + pill.textContent = 'LLM'; + } else if (lc.includes('rule')) { + pill.classList.add('tag-rule'); + pill.textContent = 'Rule Engine'; + } else if (lc.includes('trace') || lc.includes('execution')) { + pill.classList.add('tag-trace'); + pill.textContent = 'Trace'; + } else { + pill.classList.add('tag-other'); + pill.textContent = src; + } + + elSourceTags.appendChild(pill); + }); +} + +/** + * Update the mode pill and show/hide the Active Mode input. + * @param {'passive'|'active'|'mixed'|'safe'|'expert'} mode + */ +function renderMode(mode) { + // Normalise backend 'safe'→'passive', 'expert'→'active' + const normalised = mode === 'safe' ? 'passive' : mode === 'expert' ? 'active' : mode; + + elModePill.className = `mode-pill mode-${normalised}`; + elModePill.textContent = normalised.toUpperCase(); + + // Show Active Mode input only in active or mixed mode + const showInput = normalised === 'active' || normalised === 'mixed'; + elActiveWrap.style.display = showInput ? 'flex' : 'none'; +} + +/** + * Show reasoning section if text is non-empty. + * @param {string} text + */ +function renderReasoning(text) { + if (text && text.trim()) { + elReasoningText.textContent = text; + elReasoningDetails.style.display = 'block'; + } else { + elReasoningDetails.style.display = 'none'; + } +} + +// --------------------------------------------------------------------------- +// Error helpers +// --------------------------------------------------------------------------- + +function showError(message) { + elErrorText.textContent = message; + elErrorBanner.style.display = 'flex'; +} + +function hideError() { + elErrorBanner.style.display = 'none'; + elErrorText.textContent = ''; +} + +// --------------------------------------------------------------------------- +// Connection status helpers +// --------------------------------------------------------------------------- + +/** + * @param {'connected'|'disconnected'|'reconnecting'} state + */ +function setConnectionState(state) { + elConnDot.className = `conn-dot conn-${state}`; + elConnDot.title = { + connected: 'Connected to Execra', + disconnected: 'Disconnected', + reconnecting: 'Reconnecting…', + }[state] ?? state; +} + +function setStatus(text) { + elStatusText.textContent = text; +} + +// --------------------------------------------------------------------------- +// Minimize / Expand toggle +// --------------------------------------------------------------------------- + +function toggleMinimize() { + isMinimized = !isMinimized; + + if (isMinimized) { + elOverlayBody.style.display = 'none'; + elIconMinimize.style.display = 'none'; + elIconExpand.style.display = 'block'; + elBtnToggle.setAttribute('aria-label', 'Expand overlay'); + window.execra.minimize(); + } else { + elOverlayBody.style.display = ''; + elIconMinimize.style.display = 'block'; + elIconExpand.style.display = 'none'; + elBtnToggle.setAttribute('aria-label', 'Minimize overlay'); + window.execra.restore(); + } +} + +// --------------------------------------------------------------------------- +// Active Mode: send prompt on button click or Enter key +// --------------------------------------------------------------------------- + +function sendPrompt() { + const text = elActiveInput.value.trim(); + if (!text) return; + window.execra.sendPrompt(text); + elActiveInput.value = ''; + elActiveInput.focus(); +} + +elBtnSend.addEventListener('click', sendPrompt); + +elActiveInput.addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendPrompt(); + } +}); + +// --------------------------------------------------------------------------- +// Title bar controls +// --------------------------------------------------------------------------- + +elBtnToggle.addEventListener('click', toggleMinimize); + +elBtnClose.addEventListener('click', () => { + window.execra.closeOverlay(); +}); + +// --------------------------------------------------------------------------- +// Bootstrap: connect to WebSocket and register message handler +// --------------------------------------------------------------------------- + +setStatus('Connecting…'); +setConnectionState('reconnecting'); + +window.execra.onMessage(handleMessage); +window.execra.connect(WS_URL, WS_TOKEN); diff --git a/frontend/overlay/renderer/index.html b/frontend/overlay/renderer/index.html new file mode 100644 index 0000000..e8ab520 --- /dev/null +++ b/frontend/overlay/renderer/index.html @@ -0,0 +1,110 @@ + + +
+ + + +