promethios-overlay 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "promethios-overlay",
3
+ "version": "1.0.0",
4
+ "description": "Always-on-top floating chat overlay for Promethios — works across all apps on Windows, Mac, and Linux. Pill mode, watching toggle, ambient context bar, and hotkey support.",
5
+ "main": "src/main.js",
6
+ "scripts": {
7
+ "start": "electron .",
8
+ "dev": "electron . --dev",
9
+ "build:win": "electron-builder --win",
10
+ "build:mac": "electron-builder --mac",
11
+ "build:linux": "electron-builder --linux"
12
+ },
13
+ "keywords": [
14
+ "promethios",
15
+ "ai",
16
+ "overlay",
17
+ "electron",
18
+ "always-on-top",
19
+ "agent",
20
+ "ambient",
21
+ "floating"
22
+ ],
23
+ "author": "Promethios <hello@promethios.ai>",
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/wesheets/promethios.git",
28
+ "directory": "promethios-overlay"
29
+ },
30
+ "homepage": "https://promethios.ai/local-bridge",
31
+ "bugs": {
32
+ "url": "https://github.com/wesheets/promethios/issues"
33
+ },
34
+ "files": [
35
+ "src/",
36
+ "README.md"
37
+ ],
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "dependencies": {
42
+ "electron": "^29.0.0"
43
+ },
44
+ "devDependencies": {
45
+ "electron-builder": "^24.0.0"
46
+ },
47
+ "engines": {
48
+ "node": ">=18.0.0"
49
+ },
50
+ "build": {
51
+ "appId": "ai.promethios.overlay",
52
+ "productName": "Promethios",
53
+ "files": ["src/**/*"],
54
+ "win": {
55
+ "target": "nsis",
56
+ "icon": "assets/icon.ico"
57
+ },
58
+ "mac": {
59
+ "target": "dmg",
60
+ "icon": "assets/icon.icns"
61
+ },
62
+ "linux": {
63
+ "target": "AppImage",
64
+ "icon": "assets/icon.png"
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * launcher.js
3
+ *
4
+ * Called by the bridge CLI after successful authentication.
5
+ * Spawns the Electron overlay window as a detached child process.
6
+ *
7
+ * Usage (from bridge CLI):
8
+ * const { launchOverlay } = require('promethios-overlay/src/launcher');
9
+ * launchOverlay({ authToken, apiBase, threadId, dev });
10
+ */
11
+
12
+ const { spawn } = require('child_process');
13
+ const path = require('path');
14
+ const fs = require('fs');
15
+
16
+ /**
17
+ * Launch the Promethios overlay Electron window.
18
+ * Returns the child process (or null if Electron is not available).
19
+ */
20
+ function launchOverlay({ authToken, apiBase = 'https://api.promethios.ai', threadId = '', dev = false } = {}) {
21
+ // Find electron binary — try local node_modules first, then global
22
+ let electronBin = null;
23
+
24
+ const candidates = [
25
+ // Installed alongside this package
26
+ path.join(__dirname, '..', 'node_modules', '.bin', 'electron'),
27
+ path.join(__dirname, '..', 'node_modules', '.bin', 'electron.cmd'),
28
+ // Installed in the bridge CLI's node_modules
29
+ path.join(__dirname, '..', '..', 'promethios-bridge', 'node_modules', '.bin', 'electron'),
30
+ // Global electron
31
+ 'electron',
32
+ ];
33
+
34
+ for (const candidate of candidates) {
35
+ try {
36
+ if (candidate === 'electron') {
37
+ // Try to resolve globally
38
+ require.resolve('electron');
39
+ electronBin = candidate;
40
+ break;
41
+ }
42
+ if (fs.existsSync(candidate)) {
43
+ electronBin = candidate;
44
+ break;
45
+ }
46
+ } catch { /* not found */ }
47
+ }
48
+
49
+ if (!electronBin) {
50
+ if (dev) console.log('[overlay] Electron not found — overlay will not launch. Install with: npm install -g electron');
51
+ return null;
52
+ }
53
+
54
+ const mainScript = path.join(__dirname, 'main.js');
55
+
56
+ const args = [mainScript];
57
+ if (authToken) { args.push('--token', authToken); }
58
+ if (apiBase) { args.push('--api', apiBase); }
59
+ if (threadId) { args.push('--thread', threadId); }
60
+ if (dev) { args.push('--dev'); }
61
+
62
+ const child = spawn(electronBin, args, {
63
+ detached: true,
64
+ stdio: 'ignore',
65
+ env: { ...process.env, ELECTRON_NO_ATTACH_CONSOLE: '1' },
66
+ });
67
+
68
+ child.unref(); // Don't keep bridge CLI alive waiting for overlay
69
+
70
+ if (dev) console.log(`[overlay] Launched (pid ${child.pid})`);
71
+
72
+ return child;
73
+ }
74
+
75
+ module.exports = { launchOverlay };
package/src/main.js ADDED
@@ -0,0 +1,192 @@
1
+ /**
2
+ * promethios-overlay — Electron main process
3
+ *
4
+ * Creates an always-on-top floating chat window that works across all apps
5
+ * on Windows, Mac, and Linux. Launched by the bridge CLI after authentication.
6
+ *
7
+ * Launch args (passed from bridge CLI via child_process.spawn):
8
+ * --token <authToken> Bearer token for Promethios API
9
+ * --api <apiBase> API base URL (default: https://api.promethios.ai)
10
+ * --thread <threadId> Optional: open a specific thread on launch
11
+ * --dev Enable verbose logging
12
+ */
13
+
14
+ const { app, BrowserWindow, globalShortcut, ipcMain, screen, Tray, Menu, nativeImage } = require('electron');
15
+ const path = require('path');
16
+
17
+ // ── Parse launch args ────────────────────────────────────────────────────────
18
+ const args = process.argv.slice(2);
19
+ const getArg = (name) => {
20
+ const idx = args.indexOf(`--${name}`);
21
+ return idx !== -1 ? args[idx + 1] : null;
22
+ };
23
+ const AUTH_TOKEN = getArg('token') || '';
24
+ const API_BASE = getArg('api') || 'https://api.promethios.ai';
25
+ const THREAD_ID = getArg('thread') || '';
26
+ const DEV = args.includes('--dev');
27
+
28
+ // ── Window state ─────────────────────────────────────────────────────────────
29
+ let overlayWindow = null;
30
+ let tray = null;
31
+ let isExpanded = false;
32
+
33
+ // Pill dimensions (collapsed)
34
+ const PILL_W = 220;
35
+ const PILL_H = 52;
36
+
37
+ // Chat dimensions (expanded)
38
+ const CHAT_W = 380;
39
+ const CHAT_H = 560;
40
+
41
+ // ── App ready ────────────────────────────────────────────────────────────────
42
+ app.whenReady().then(() => {
43
+ createOverlayWindow();
44
+ registerHotkey();
45
+ createTray();
46
+ });
47
+
48
+ app.on('window-all-closed', () => {
49
+ // Keep app alive even if window is closed — user can restore via tray
50
+ if (process.platform !== 'darwin') {
51
+ // On Windows/Linux, keep running in tray
52
+ }
53
+ });
54
+
55
+ app.on('will-quit', () => {
56
+ globalShortcut.unregisterAll();
57
+ });
58
+
59
+ // ── Create the overlay window ────────────────────────────────────────────────
60
+ function createOverlayWindow() {
61
+ const { width: screenW, height: screenH } = screen.getPrimaryDisplay().workAreaSize;
62
+
63
+ overlayWindow = new BrowserWindow({
64
+ width: PILL_W,
65
+ height: PILL_H,
66
+ x: screenW - PILL_W - 24,
67
+ y: screenH - PILL_H - 24,
68
+ frame: false,
69
+ transparent: true,
70
+ alwaysOnTop: true,
71
+ skipTaskbar: true,
72
+ resizable: false,
73
+ movable: true,
74
+ hasShadow: true,
75
+ webPreferences: {
76
+ nodeIntegration: false,
77
+ contextIsolation: true,
78
+ preload: path.join(__dirname, 'preload.js'),
79
+ },
80
+ });
81
+
82
+ // Always on top — level 'floating' keeps it above most apps including full-screen
83
+ overlayWindow.setAlwaysOnTop(true, 'floating');
84
+ overlayWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
85
+
86
+ overlayWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
87
+
88
+ // Pass config to renderer once DOM is ready
89
+ overlayWindow.webContents.on('did-finish-load', () => {
90
+ overlayWindow.webContents.send('config', {
91
+ authToken: AUTH_TOKEN,
92
+ apiBase: API_BASE,
93
+ threadId: THREAD_ID,
94
+ dev: DEV,
95
+ });
96
+ });
97
+
98
+ if (DEV) {
99
+ overlayWindow.webContents.openDevTools({ mode: 'detach' });
100
+ }
101
+ }
102
+
103
+ // ── Toggle between pill and expanded chat ────────────────────────────────────
104
+ function toggleExpanded(expand) {
105
+ if (expand === undefined) expand = !isExpanded;
106
+ isExpanded = expand;
107
+
108
+ const { width: screenW, height: screenH } = screen.getPrimaryDisplay().workAreaSize;
109
+
110
+ if (isExpanded) {
111
+ const x = screenW - CHAT_W - 24;
112
+ const y = screenH - CHAT_H - 24;
113
+ overlayWindow.setBounds({ x, y, width: CHAT_W, height: CHAT_H }, true);
114
+ overlayWindow.webContents.send('expand', true);
115
+ } else {
116
+ const x = screenW - PILL_W - 24;
117
+ const y = screenH - PILL_H - 24;
118
+ overlayWindow.setBounds({ x, y, width: PILL_W, height: PILL_H }, true);
119
+ overlayWindow.webContents.send('expand', false);
120
+ }
121
+ }
122
+
123
+ // ── IPC from renderer ────────────────────────────────────────────────────────
124
+ ipcMain.on('toggle-expand', (_, expand) => toggleExpanded(expand));
125
+
126
+ ipcMain.on('set-watching', (_, watching) => {
127
+ // Relay to renderer (already handled there) — also log for debug
128
+ if (DEV) console.log('[overlay] watching:', watching);
129
+ });
130
+
131
+ ipcMain.on('close-overlay', () => {
132
+ overlayWindow.hide();
133
+ });
134
+
135
+ ipcMain.on('show-overlay', () => {
136
+ overlayWindow.show();
137
+ });
138
+
139
+ // ── Global hotkey: Ctrl+Shift+P (Win/Linux) or Cmd+Shift+P (Mac) ─────────────
140
+ function registerHotkey() {
141
+ const hotkey = process.platform === 'darwin' ? 'Command+Shift+P' : 'Control+Shift+P';
142
+ const registered = globalShortcut.register(hotkey, () => {
143
+ if (overlayWindow.isVisible()) {
144
+ toggleExpanded();
145
+ } else {
146
+ overlayWindow.show();
147
+ toggleExpanded(true);
148
+ }
149
+ });
150
+ if (DEV) console.log(`[overlay] Hotkey ${hotkey} registered:`, registered);
151
+ }
152
+
153
+ // ── System tray icon ─────────────────────────────────────────────────────────
154
+ function createTray() {
155
+ // Use a simple 16x16 template image (will be replaced with real icon in production)
156
+ const iconSize = process.platform === 'darwin' ? 16 : 32;
157
+ const trayIcon = nativeImage.createEmpty();
158
+ tray = new Tray(trayIcon);
159
+
160
+ const contextMenu = Menu.buildFromTemplate([
161
+ {
162
+ label: 'Show Promethios',
163
+ click: () => {
164
+ overlayWindow.show();
165
+ toggleExpanded(true);
166
+ },
167
+ },
168
+ {
169
+ label: 'Hide',
170
+ click: () => overlayWindow.hide(),
171
+ },
172
+ { type: 'separator' },
173
+ {
174
+ label: 'Quit',
175
+ click: () => {
176
+ app.quit();
177
+ },
178
+ },
179
+ ]);
180
+
181
+ tray.setToolTip('Promethios AI Overlay');
182
+ tray.setContextMenu(contextMenu);
183
+
184
+ tray.on('click', () => {
185
+ if (overlayWindow.isVisible()) {
186
+ toggleExpanded();
187
+ } else {
188
+ overlayWindow.show();
189
+ toggleExpanded(true);
190
+ }
191
+ });
192
+ }
package/src/preload.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * preload.js — Electron contextBridge
3
+ *
4
+ * Exposes a safe, limited API to the renderer process.
5
+ * No direct Node.js access in the renderer — all IPC goes through here.
6
+ */
7
+
8
+ const { contextBridge, ipcRenderer } = require('electron');
9
+
10
+ contextBridge.exposeInMainWorld('promethios', {
11
+ // Receive config from main process (authToken, apiBase, threadId)
12
+ onConfig: (cb) => ipcRenderer.on('config', (_, data) => cb(data)),
13
+
14
+ // Receive expand/collapse state changes from main process
15
+ onExpand: (cb) => ipcRenderer.on('expand', (_, expanded) => cb(expanded)),
16
+
17
+ // Tell main process to toggle expand/collapse
18
+ toggleExpand: (expand) => ipcRenderer.send('toggle-expand', expand),
19
+
20
+ // Tell main process about watching state change
21
+ setWatching: (watching) => ipcRenderer.send('set-watching', watching),
22
+
23
+ // Close the overlay window
24
+ close: () => ipcRenderer.send('close-overlay'),
25
+ });
@@ -0,0 +1,296 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Promethios</title>
7
+ <style>
8
+ /* ── Reset ─────────────────────────────────────────────────────────────── */
9
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
10
+ html, body { width: 100%; height: 100%; overflow: hidden; background: transparent; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
11
+
12
+ /* ── Pill (collapsed state) ─────────────────────────────────────────────── */
13
+ #pill {
14
+ position: fixed; bottom: 0; right: 0;
15
+ width: 220px; height: 52px;
16
+ background: rgba(15, 15, 20, 0.92);
17
+ backdrop-filter: blur(16px);
18
+ border: 1px solid rgba(139, 92, 246, 0.4);
19
+ border-radius: 26px;
20
+ display: flex; align-items: center; gap: 10px;
21
+ padding: 0 14px;
22
+ cursor: pointer;
23
+ -webkit-app-region: drag;
24
+ box-shadow: 0 4px 24px rgba(0,0,0,0.5), 0 0 0 1px rgba(139,92,246,0.15);
25
+ transition: border-color 0.2s, box-shadow 0.2s;
26
+ user-select: none;
27
+ }
28
+ #pill:hover {
29
+ border-color: rgba(139, 92, 246, 0.7);
30
+ box-shadow: 0 4px 32px rgba(139,92,246,0.25), 0 0 0 1px rgba(139,92,246,0.3);
31
+ }
32
+
33
+ /* Logo mark */
34
+ .pill-logo {
35
+ width: 28px; height: 28px; border-radius: 8px;
36
+ background: linear-gradient(135deg, #7c3aed, #a855f7);
37
+ display: flex; align-items: center; justify-content: center;
38
+ font-size: 14px; flex-shrink: 0;
39
+ -webkit-app-region: no-drag;
40
+ }
41
+
42
+ .pill-label {
43
+ flex: 1;
44
+ font-size: 13px; font-weight: 600;
45
+ color: #e2e8f0;
46
+ letter-spacing: 0.01em;
47
+ -webkit-app-region: no-drag;
48
+ }
49
+
50
+ /* Status dot — green=watching, yellow=paused */
51
+ #status-dot {
52
+ width: 8px; height: 8px; border-radius: 50%;
53
+ background: #22c55e;
54
+ flex-shrink: 0;
55
+ cursor: pointer;
56
+ -webkit-app-region: no-drag;
57
+ transition: background 0.3s;
58
+ position: relative;
59
+ }
60
+ #status-dot.paused { background: #eab308; }
61
+ #status-dot::after {
62
+ content: attr(data-tooltip);
63
+ position: absolute; bottom: 14px; right: 0;
64
+ background: rgba(15,15,20,0.95); color: #e2e8f0;
65
+ font-size: 11px; padding: 3px 8px; border-radius: 4px;
66
+ white-space: nowrap; pointer-events: none;
67
+ opacity: 0; transition: opacity 0.15s;
68
+ }
69
+ #status-dot:hover::after { opacity: 1; }
70
+
71
+ /* ── Chat window (expanded state) ───────────────────────────────────────── */
72
+ #chat {
73
+ display: none;
74
+ position: fixed; bottom: 0; right: 0;
75
+ width: 380px; height: 560px;
76
+ background: rgba(10, 10, 16, 0.96);
77
+ backdrop-filter: blur(20px);
78
+ border: 1px solid rgba(139, 92, 246, 0.35);
79
+ border-radius: 16px;
80
+ flex-direction: column;
81
+ overflow: hidden;
82
+ box-shadow: 0 8px 48px rgba(0,0,0,0.7), 0 0 0 1px rgba(139,92,246,0.1);
83
+ }
84
+ #chat.visible { display: flex; }
85
+
86
+ /* Header */
87
+ .chat-header {
88
+ display: flex; align-items: center; gap: 10px;
89
+ padding: 12px 14px;
90
+ background: rgba(139, 92, 246, 0.08);
91
+ border-bottom: 1px solid rgba(139, 92, 246, 0.2);
92
+ -webkit-app-region: drag;
93
+ flex-shrink: 0;
94
+ }
95
+ .chat-header .logo {
96
+ width: 26px; height: 26px; border-radius: 7px;
97
+ background: linear-gradient(135deg, #7c3aed, #a855f7);
98
+ display: flex; align-items: center; justify-content: center;
99
+ font-size: 13px; flex-shrink: 0;
100
+ -webkit-app-region: no-drag;
101
+ }
102
+ .chat-header .title {
103
+ flex: 1; font-size: 13px; font-weight: 600; color: #e2e8f0;
104
+ -webkit-app-region: no-drag;
105
+ }
106
+ .header-actions {
107
+ display: flex; align-items: center; gap: 8px;
108
+ -webkit-app-region: no-drag;
109
+ }
110
+ .header-btn {
111
+ width: 26px; height: 26px; border-radius: 6px;
112
+ background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
113
+ color: #94a3b8; font-size: 12px;
114
+ display: flex; align-items: center; justify-content: center;
115
+ cursor: pointer; transition: background 0.15s, color 0.15s;
116
+ }
117
+ .header-btn:hover { background: rgba(255,255,255,0.12); color: #e2e8f0; }
118
+
119
+ /* Watching toggle in header */
120
+ #watching-toggle {
121
+ display: flex; align-items: center; gap: 5px;
122
+ font-size: 11px; color: #94a3b8; cursor: pointer;
123
+ padding: 3px 8px; border-radius: 12px;
124
+ background: rgba(255,255,255,0.05);
125
+ border: 1px solid rgba(255,255,255,0.08);
126
+ transition: background 0.15s;
127
+ -webkit-app-region: no-drag;
128
+ }
129
+ #watching-toggle:hover { background: rgba(255,255,255,0.1); }
130
+ #watching-toggle .dot {
131
+ width: 6px; height: 6px; border-radius: 50%;
132
+ background: #22c55e; transition: background 0.3s;
133
+ }
134
+ #watching-toggle.paused .dot { background: #eab308; }
135
+ #watching-toggle.paused .watch-label::after { content: 'Paused'; }
136
+ #watching-toggle:not(.paused) .watch-label::after { content: 'Watching'; }
137
+
138
+ /* Ambient context bar */
139
+ #context-bar {
140
+ padding: 7px 14px;
141
+ background: rgba(139, 92, 246, 0.05);
142
+ border-bottom: 1px solid rgba(139, 92, 246, 0.12);
143
+ font-size: 11px; color: #7c6fa0;
144
+ display: flex; align-items: center; gap: 6px;
145
+ flex-shrink: 0; min-height: 30px;
146
+ }
147
+ #context-bar .ctx-icon { font-size: 12px; }
148
+ #context-bar .ctx-text {
149
+ flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
150
+ }
151
+ #context-bar.hidden { display: none; }
152
+
153
+ /* Messages area */
154
+ #messages {
155
+ flex: 1; overflow-y: auto; padding: 12px 14px;
156
+ display: flex; flex-direction: column; gap: 10px;
157
+ scroll-behavior: smooth;
158
+ }
159
+ #messages::-webkit-scrollbar { width: 4px; }
160
+ #messages::-webkit-scrollbar-track { background: transparent; }
161
+ #messages::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.3); border-radius: 2px; }
162
+
163
+ .msg { display: flex; flex-direction: column; gap: 2px; max-width: 88%; }
164
+ .msg.user { align-self: flex-end; align-items: flex-end; }
165
+ .msg.agent { align-self: flex-start; align-items: flex-start; }
166
+
167
+ .msg-bubble {
168
+ padding: 8px 12px; border-radius: 12px;
169
+ font-size: 13px; line-height: 1.5; color: #e2e8f0;
170
+ word-break: break-word;
171
+ }
172
+ .msg.user .msg-bubble {
173
+ background: linear-gradient(135deg, #7c3aed, #6d28d9);
174
+ border-bottom-right-radius: 4px;
175
+ }
176
+ .msg.agent .msg-bubble {
177
+ background: rgba(255,255,255,0.07);
178
+ border: 1px solid rgba(255,255,255,0.08);
179
+ border-bottom-left-radius: 4px;
180
+ }
181
+ .msg-time { font-size: 10px; color: #4b5563; }
182
+
183
+ /* Typing indicator */
184
+ #typing {
185
+ display: none; align-self: flex-start;
186
+ padding: 8px 14px; background: rgba(255,255,255,0.07);
187
+ border: 1px solid rgba(255,255,255,0.08);
188
+ border-radius: 12px; border-bottom-left-radius: 4px;
189
+ }
190
+ #typing.visible { display: flex; gap: 4px; align-items: center; }
191
+ .dot-pulse { width: 6px; height: 6px; border-radius: 50%; background: #7c3aed; animation: pulse 1.2s infinite; }
192
+ .dot-pulse:nth-child(2) { animation-delay: 0.2s; }
193
+ .dot-pulse:nth-child(3) { animation-delay: 0.4s; }
194
+ @keyframes pulse { 0%,80%,100% { opacity: 0.3; transform: scale(0.8); } 40% { opacity: 1; transform: scale(1); } }
195
+
196
+ /* Input area */
197
+ .chat-input-area {
198
+ padding: 10px 12px;
199
+ border-top: 1px solid rgba(255,255,255,0.07);
200
+ display: flex; align-items: flex-end; gap: 8px;
201
+ flex-shrink: 0;
202
+ -webkit-app-region: no-drag;
203
+ }
204
+ #msg-input {
205
+ flex: 1; background: rgba(255,255,255,0.06);
206
+ border: 1px solid rgba(255,255,255,0.1); border-radius: 10px;
207
+ color: #e2e8f0; font-size: 13px; padding: 8px 12px;
208
+ resize: none; outline: none; max-height: 100px;
209
+ font-family: inherit; line-height: 1.4;
210
+ transition: border-color 0.15s;
211
+ }
212
+ #msg-input::placeholder { color: #4b5563; }
213
+ #msg-input:focus { border-color: rgba(139,92,246,0.5); }
214
+
215
+ #send-btn {
216
+ width: 34px; height: 34px; border-radius: 9px;
217
+ background: linear-gradient(135deg, #7c3aed, #6d28d9);
218
+ border: none; cursor: pointer; color: white; font-size: 14px;
219
+ display: flex; align-items: center; justify-content: center;
220
+ transition: opacity 0.15s, transform 0.1s; flex-shrink: 0;
221
+ }
222
+ #send-btn:hover { opacity: 0.85; }
223
+ #send-btn:active { transform: scale(0.95); }
224
+ #send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
225
+
226
+ /* Empty state */
227
+ #empty-state {
228
+ flex: 1; display: flex; flex-direction: column;
229
+ align-items: center; justify-content: center; gap: 8px;
230
+ color: #4b5563; text-align: center; padding: 20px;
231
+ }
232
+ #empty-state .empty-icon { font-size: 32px; }
233
+ #empty-state .empty-title { font-size: 14px; font-weight: 600; color: #6b7280; }
234
+ #empty-state .empty-sub { font-size: 12px; line-height: 1.5; }
235
+ </style>
236
+ </head>
237
+ <body>
238
+
239
+ <!-- ── Pill (collapsed) ──────────────────────────────────────────────────── -->
240
+ <div id="pill">
241
+ <div class="pill-logo">⬡</div>
242
+ <span class="pill-label">Promethios</span>
243
+ <div id="status-dot" data-tooltip="Watching — click to pause"></div>
244
+ </div>
245
+
246
+ <!-- ── Chat window (expanded) ────────────────────────────────────────────── -->
247
+ <div id="chat">
248
+
249
+ <!-- Header -->
250
+ <div class="chat-header">
251
+ <div class="logo">⬡</div>
252
+ <span class="title">Promethios</span>
253
+ <div class="header-actions">
254
+ <!-- Watching toggle -->
255
+ <div id="watching-toggle" title="Toggle context watching">
256
+ <div class="dot"></div>
257
+ <span class="watch-label"></span>
258
+ </div>
259
+ <!-- Collapse to pill -->
260
+ <div class="header-btn" id="collapse-btn" title="Collapse">╌</div>
261
+ </div>
262
+ </div>
263
+
264
+ <!-- Ambient context bar -->
265
+ <div id="context-bar" class="hidden">
266
+ <span class="ctx-icon">⚡</span>
267
+ <span class="ctx-text" id="ctx-text">Watching your screen...</span>
268
+ </div>
269
+
270
+ <!-- Messages -->
271
+ <div id="messages">
272
+ <div id="empty-state">
273
+ <div class="empty-icon">🤖</div>
274
+ <div class="empty-title">Promethios is ready</div>
275
+ <div class="empty-sub">Ask anything — I can see what you're working on and help without you switching tabs.</div>
276
+ </div>
277
+ </div>
278
+
279
+ <!-- Typing indicator -->
280
+ <div id="typing">
281
+ <div class="dot-pulse"></div>
282
+ <div class="dot-pulse"></div>
283
+ <div class="dot-pulse"></div>
284
+ </div>
285
+
286
+ <!-- Input -->
287
+ <div class="chat-input-area">
288
+ <textarea id="msg-input" rows="1" placeholder="Ask anything..."></textarea>
289
+ <button id="send-btn">↑</button>
290
+ </div>
291
+
292
+ </div>
293
+
294
+ <script src="overlay.js"></script>
295
+ </body>
296
+ </html>
@@ -0,0 +1,311 @@
1
+ /**
2
+ * overlay.js — Renderer process logic
3
+ *
4
+ * Handles:
5
+ * - Config injection from main process (authToken, apiBase, threadId)
6
+ * - Pill ↔ chat expand/collapse
7
+ * - Watching toggle (green/yellow dot)
8
+ * - Ambient context display in the context bar
9
+ * - Chat message sending via Promethios API
10
+ * - Streaming response rendering
11
+ */
12
+
13
+ // ── State ────────────────────────────────────────────────────────────────────
14
+ let config = { authToken: '', apiBase: 'https://api.promethios.ai', threadId: '', dev: false };
15
+ let isExpanded = false;
16
+ let isWatching = true;
17
+ let messages = [];
18
+ let isStreaming = false;
19
+ let contextPollTimer = null;
20
+
21
+ // ── DOM refs ─────────────────────────────────────────────────────────────────
22
+ const pill = document.getElementById('pill');
23
+ const chat = document.getElementById('chat');
24
+ const statusDot = document.getElementById('status-dot');
25
+ const watchingToggle = document.getElementById('watching-toggle');
26
+ const collapseBtn = document.getElementById('collapse-btn');
27
+ const contextBar = document.getElementById('context-bar');
28
+ const ctxText = document.getElementById('ctx-text');
29
+ const messagesEl = document.getElementById('messages');
30
+ const emptyState = document.getElementById('empty-state');
31
+ const typingEl = document.getElementById('typing');
32
+ const msgInput = document.getElementById('msg-input');
33
+ const sendBtn = document.getElementById('send-btn');
34
+
35
+ // ── Receive config from main process ─────────────────────────────────────────
36
+ window.promethios.onConfig((data) => {
37
+ config = { ...config, ...data };
38
+ if (config.dev) console.log('[overlay] Config received:', config);
39
+ startContextPoll();
40
+ });
41
+
42
+ // ── Receive expand/collapse from main process ─────────────────────────────────
43
+ window.promethios.onExpand((expanded) => {
44
+ setExpanded(expanded);
45
+ });
46
+
47
+ // ── Pill click → expand ───────────────────────────────────────────────────────
48
+ pill.addEventListener('click', (e) => {
49
+ if (e.target === statusDot) return; // handled separately
50
+ window.promethios.toggleExpand(true);
51
+ });
52
+
53
+ // ── Status dot click → toggle watching ───────────────────────────────────────
54
+ statusDot.addEventListener('click', (e) => {
55
+ e.stopPropagation();
56
+ toggleWatching();
57
+ });
58
+
59
+ watchingToggle.addEventListener('click', () => {
60
+ toggleWatching();
61
+ });
62
+
63
+ // ── Collapse button ───────────────────────────────────────────────────────────
64
+ collapseBtn.addEventListener('click', () => {
65
+ window.promethios.toggleExpand(false);
66
+ });
67
+
68
+ // ── Send message ──────────────────────────────────────────────────────────────
69
+ sendBtn.addEventListener('click', sendMessage);
70
+ msgInput.addEventListener('keydown', (e) => {
71
+ if (e.key === 'Enter' && !e.shiftKey) {
72
+ e.preventDefault();
73
+ sendMessage();
74
+ }
75
+ });
76
+
77
+ // Auto-resize textarea
78
+ msgInput.addEventListener('input', () => {
79
+ msgInput.style.height = 'auto';
80
+ msgInput.style.height = Math.min(msgInput.scrollHeight, 100) + 'px';
81
+ });
82
+
83
+ // ── Expand / collapse ─────────────────────────────────────────────────────────
84
+ function setExpanded(expanded) {
85
+ isExpanded = expanded;
86
+ if (expanded) {
87
+ pill.style.display = 'none';
88
+ chat.classList.add('visible');
89
+ msgInput.focus();
90
+ } else {
91
+ pill.style.display = 'flex';
92
+ chat.classList.remove('visible');
93
+ }
94
+ }
95
+
96
+ // ── Watching toggle ───────────────────────────────────────────────────────────
97
+ function toggleWatching() {
98
+ isWatching = !isWatching;
99
+
100
+ // Update pill dot
101
+ statusDot.classList.toggle('paused', !isWatching);
102
+ statusDot.setAttribute('data-tooltip', isWatching
103
+ ? 'Watching — click to pause'
104
+ : 'Paused — click to resume watching');
105
+
106
+ // Update header toggle
107
+ watchingToggle.classList.toggle('paused', !isWatching);
108
+
109
+ // Hide/show context bar
110
+ if (!isWatching) {
111
+ contextBar.classList.add('hidden');
112
+ ctxText.textContent = '';
113
+ }
114
+
115
+ // Notify main process
116
+ window.promethios.setWatching(isWatching);
117
+
118
+ // Tell the API to pause/resume context capture
119
+ if (config.authToken) {
120
+ fetch(`${config.apiBase}/api/local-bridge/context/watching`, {
121
+ method: 'POST',
122
+ headers: { Authorization: `Bearer ${config.authToken}`, 'Content-Type': 'application/json' },
123
+ body: JSON.stringify({ watching: isWatching }),
124
+ }).catch(() => {});
125
+ }
126
+ }
127
+
128
+ // ── Context polling ───────────────────────────────────────────────────────────
129
+ function startContextPoll() {
130
+ if (contextPollTimer) clearInterval(contextPollTimer);
131
+ contextPollTimer = setInterval(fetchContext, 5000);
132
+ fetchContext(); // immediate first fetch
133
+ }
134
+
135
+ async function fetchContext() {
136
+ if (!isWatching || !config.authToken) return;
137
+ try {
138
+ const res = await fetch(`${config.apiBase}/api/local-bridge/context`, {
139
+ headers: { Authorization: `Bearer ${config.authToken}` },
140
+ });
141
+ if (!res.ok) return;
142
+ const ctx = await res.json();
143
+ updateContextBar(ctx);
144
+ } catch { /* ignore */ }
145
+ }
146
+
147
+ function updateContextBar(ctx) {
148
+ if (!ctx || (!ctx.active_app && !ctx.active_url)) {
149
+ contextBar.classList.add('hidden');
150
+ return;
151
+ }
152
+ contextBar.classList.remove('hidden');
153
+
154
+ let label = '';
155
+ if (ctx.active_url) {
156
+ try {
157
+ const url = new URL(ctx.active_url);
158
+ label = url.hostname.replace('www.', '');
159
+ if (ctx.active_window?.title) {
160
+ const title = ctx.active_window.title.replace(/ [-–|].*$/, '').trim();
161
+ if (title && title.length < 50) label = `${title} · ${label}`;
162
+ }
163
+ } catch {
164
+ label = ctx.active_url.slice(0, 60);
165
+ }
166
+ } else if (ctx.active_app) {
167
+ label = ctx.active_app;
168
+ if (ctx.active_window?.title) {
169
+ const title = ctx.active_window.title.replace(/ [-–|].*$/, '').trim();
170
+ if (title && title.length < 50 && title !== ctx.active_app) label = `${title} · ${ctx.active_app}`;
171
+ }
172
+ }
173
+
174
+ ctxText.textContent = label;
175
+ }
176
+
177
+ // ── Send message ──────────────────────────────────────────────────────────────
178
+ async function sendMessage() {
179
+ const text = msgInput.value.trim();
180
+ if (!text || isStreaming) return;
181
+ if (!config.authToken) {
182
+ appendMessage('agent', 'Not connected. Please restart the bridge with a valid token.');
183
+ return;
184
+ }
185
+
186
+ // Clear input
187
+ msgInput.value = '';
188
+ msgInput.style.height = 'auto';
189
+
190
+ // Add user message to UI
191
+ appendMessage('user', text);
192
+ hideEmptyState();
193
+
194
+ // Show typing indicator
195
+ setStreaming(true);
196
+
197
+ try {
198
+ // Use the Promethios chat/stream API
199
+ // If we have a threadId, send to that thread; otherwise create a new one
200
+ const endpoint = config.threadId
201
+ ? `${config.apiBase}/api/chat/stream`
202
+ : `${config.apiBase}/api/chat/stream`;
203
+
204
+ const body = {
205
+ message: text,
206
+ threadId: config.threadId || null,
207
+ source: 'overlay',
208
+ overlayMode: true,
209
+ };
210
+
211
+ const res = await fetch(endpoint, {
212
+ method: 'POST',
213
+ headers: {
214
+ Authorization: `Bearer ${config.authToken}`,
215
+ 'Content-Type': 'application/json',
216
+ Accept: 'text/event-stream',
217
+ },
218
+ body: JSON.stringify(body),
219
+ });
220
+
221
+ if (!res.ok) {
222
+ const err = await res.text();
223
+ appendMessage('agent', `Error: ${res.status} — ${err.slice(0, 200)}`);
224
+ setStreaming(false);
225
+ return;
226
+ }
227
+
228
+ // Stream the response
229
+ const reader = res.body.getReader();
230
+ const decoder = new TextDecoder();
231
+ let agentMsgEl = null;
232
+ let buffer = '';
233
+
234
+ while (true) {
235
+ const { done, value } = await reader.read();
236
+ if (done) break;
237
+
238
+ buffer += decoder.decode(value, { stream: true });
239
+ const lines = buffer.split('\n');
240
+ buffer = lines.pop(); // keep incomplete line
241
+
242
+ for (const line of lines) {
243
+ if (!line.startsWith('data: ')) continue;
244
+ const data = line.slice(6).trim();
245
+ if (data === '[DONE]') continue;
246
+ try {
247
+ const parsed = JSON.parse(data);
248
+ const delta = parsed.choices?.[0]?.delta?.content || parsed.delta?.text || parsed.text || '';
249
+ if (delta) {
250
+ if (!agentMsgEl) {
251
+ agentMsgEl = appendMessage('agent', '', true);
252
+ }
253
+ agentMsgEl.querySelector('.msg-bubble').textContent += delta;
254
+ scrollToBottom();
255
+ }
256
+ // Capture threadId if returned
257
+ if (parsed.threadId && !config.threadId) {
258
+ config.threadId = parsed.threadId;
259
+ }
260
+ } catch { /* non-JSON line, skip */ }
261
+ }
262
+ }
263
+
264
+ if (!agentMsgEl) {
265
+ appendMessage('agent', '(No response)');
266
+ }
267
+
268
+ } catch (err) {
269
+ appendMessage('agent', `Connection error: ${err.message}`);
270
+ }
271
+
272
+ setStreaming(false);
273
+ }
274
+
275
+ // ── UI helpers ────────────────────────────────────────────────────────────────
276
+ function appendMessage(role, text, returnEl = false) {
277
+ const now = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
278
+ const div = document.createElement('div');
279
+ div.className = `msg ${role}`;
280
+ div.innerHTML = `
281
+ <div class="msg-bubble">${escapeHtml(text)}</div>
282
+ <span class="msg-time">${now}</span>
283
+ `;
284
+ messagesEl.appendChild(div);
285
+ scrollToBottom();
286
+ if (returnEl) return div;
287
+ }
288
+
289
+ function hideEmptyState() {
290
+ if (emptyState) emptyState.style.display = 'none';
291
+ }
292
+
293
+ function setStreaming(streaming) {
294
+ isStreaming = streaming;
295
+ typingEl.classList.toggle('visible', streaming);
296
+ sendBtn.disabled = streaming;
297
+ scrollToBottom();
298
+ }
299
+
300
+ function scrollToBottom() {
301
+ messagesEl.scrollTop = messagesEl.scrollHeight;
302
+ }
303
+
304
+ function escapeHtml(str) {
305
+ return str
306
+ .replace(/&/g, '&amp;')
307
+ .replace(/</g, '&lt;')
308
+ .replace(/>/g, '&gt;')
309
+ .replace(/"/g, '&quot;')
310
+ .replace(/\n/g, '<br>');
311
+ }