diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index 72f72d5..29cfa72 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -1,6 +1,141 @@ -import { app, BrowserWindow } from 'electron'; +import { app, BrowserWindow, ipcMain } from 'electron'; import path from 'path'; +import * as pty from 'node-pty'; import { startPythonBackend, stopPythonBackend } from './process_manager'; +import * as settingsStore from './settings-store'; + +// Terminal PTY Storage +const ptyProcesses = new Map(); + +function setupTerminalIPC(mainWindow: BrowserWindow) { + ipcMain.on('terminal-spawn', (event, { id, shell, args, cwd, env }) => { + // Kill existing process if any + if (ptyProcesses.has(id)) { + ptyProcesses.get(id)?.kill(); + ptyProcesses.delete(id); + } + + // Resolve default shells and Windows extensions + let shellPath = shell; + if (!shellPath || shellPath === 'shell') { + shellPath = process.platform === 'win32' ? 'powershell.exe' : 'zsh'; + } + + // On Windows, commands like 'npx' need to be 'npx.cmd' + if (process.platform === 'win32' && !shellPath.endsWith('.exe') && !shellPath.endsWith('.cmd') && !shellPath.endsWith('.bat')) { + if (shellPath === 'npx' || shellPath === 'npm') { + shellPath = `${shellPath}.cmd`; + } + } + + console.log(`[PTY] Spawning: ${shellPath}`, args); + + // Merge custom env with process env + const spawnEnv = { ...process.env, ...(env || {}) }; + + try { + const ptyProcess = pty.spawn(shellPath, args || [], { + name: 'xterm-color', + cols: 80, + rows: 24, + cwd: cwd || process.cwd(), + env: spawnEnv as any, + useConpty: true // Better Windows support + }); + + ptyProcess.onData((data) => { + mainWindow.webContents.send(`terminal-data-${id}`, data); + }); + + ptyProcess.onExit(({ exitCode, signal }) => { + mainWindow.webContents.send(`terminal-exit-${id}`, { exitCode, signal }); + ptyProcesses.delete(id); + }); + + ptyProcesses.set(id, ptyProcess); + } catch (err) { + console.error(`[PTY] Failed to spawn ${shellPath}:`, err); + mainWindow.webContents.send(`terminal-data-${id}`, `\r\n\x1b[31m[ERROR] Failed to spawn ${shellPath}\x1b[0m\r\n\x1b[31m[ERROR] Error code: ${(err as any).code || 'unknown'}\x1b[0m\r\n`); + } + }); + + ipcMain.on('terminal-write', (event, { id, data }) => { + const ptyProcess = ptyProcesses.get(id); + if (ptyProcess) { + ptyProcess.write(data); + } + }); + + ipcMain.on('terminal-resize', (event, { id, cols, rows }) => { + const ptyProcess = ptyProcesses.get(id); + if (ptyProcess) { + ptyProcess.resize(cols, rows); + } + }); + + ipcMain.on('terminal-kill', (event, { id }) => { + const ptyProcess = ptyProcesses.get(id); + if (ptyProcess) { + ptyProcess.kill(); + ptyProcesses.delete(id); + } + }); + + // Settings IPC Handlers + ipcMain.handle('settings-save-key', async (_, { provider, key }) => { + return settingsStore.saveApiKey(provider, key); + }); + + ipcMain.handle('settings-get-key', async (_, { provider }) => { + return settingsStore.getApiKey(provider); + }); + + ipcMain.handle('settings-delete-key', async (_, { provider }) => { + return settingsStore.deleteApiKey(provider); + }); + + ipcMain.handle('settings-get-all', async () => { + return settingsStore.getAllSettings(); + }); + + ipcMain.handle('settings-get-providers', async () => { + return settingsStore.getEnabledProviders(); + }); + + ipcMain.handle('settings-has-key', async (_, { provider }) => { + return settingsStore.hasApiKey(provider); + }); + + // CLI Installation IPC Handlers + ipcMain.handle('cli-check-installed', async (_, { cli }) => { + const { exec } = require('child_process'); + const command = process.platform === 'win32' ? `where ${cli}` : `which ${cli}`; + + return new Promise((resolve) => { + exec(command, (error: any) => { + resolve(!error); + }); + }); + }); + + ipcMain.handle('cli-install', async (_, { installCommand }) => { + const { exec } = require('child_process'); + + console.log(`[CLI Install] Running: ${installCommand}`); + + return new Promise<{ success: boolean; output: string }>((resolve) => { + exec(installCommand, { shell: process.platform === 'win32' ? 'powershell.exe' : '/bin/bash' }, (error: any, stdout: string, stderr: string) => { + if (error) { + console.error(`[CLI Install] Error:`, error); + resolve({ success: false, output: stderr || error.message }); + } else { + console.log(`[CLI Install] Success:`, stdout); + resolve({ success: true, output: stdout }); + } + }); + }); + }); +} // Handle creating/removing shortcuts on Windows when installing/uninstalling. if (require('electron-squirrel-startup')) { @@ -9,30 +144,30 @@ if (require('electron-squirrel-startup')) { function createWindow() { const mainWindow = new BrowserWindow({ - width: 1280, - height: 800, + width: 1400, + height: 900, webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true, }, - titleBarStyle: 'hidden', // Auto-Claude style + titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', + trafficLightPosition: { x: 16, y: 16 }, title: 'Squadron', - backgroundColor: '#09090b', // Dark mode bg + backgroundColor: '#09090b', }); - // Load the index.html of the app. - // Load the index.html of the app. + setupTerminalIPC(mainWindow); + if (process.env.VITE_DEV_SERVER_URL) { mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL); - // mainWindow.webContents.openDevTools(); // Clean startup } else { mainWindow.loadFile(path.join(__dirname, '../dist/index.html')); } } app.whenReady().then(() => { - startPythonBackend(); // Start the Python server + startPythonBackend(); createWindow(); app.on('activate', function () { @@ -41,6 +176,12 @@ app.whenReady().then(() => { }); app.on('window-all-closed', function () { - stopPythonBackend(); // Ensure python is killed + // Kill all PTY processes on close + for (const [id, ptyProcess] of ptyProcesses) { + ptyProcess.kill(); + } + ptyProcesses.clear(); + + stopPythonBackend(); if (process.platform !== 'darwin') app.quit(); }); diff --git a/desktop/electron/preload.ts b/desktop/electron/preload.ts index 16f06b1..b50f779 100644 --- a/desktop/electron/preload.ts +++ b/desktop/electron/preload.ts @@ -1,9 +1,38 @@ import { contextBridge, ipcRenderer } from 'electron'; contextBridge.exposeInMainWorld('electronAPI', { - // Add IPC methods here sendMessage: (channel: string, data: any) => ipcRenderer.send(channel, data), onMessage: (channel: string, func: (...args: any[]) => void) => { - ipcRenderer.on(channel, (event, ...args) => func(...args)); - } + const subscription = (event: any, ...args: any[]) => func(...args); + ipcRenderer.on(channel, subscription); + return () => ipcRenderer.removeListener(channel, subscription); + }, + // Dedicated Terminal IPC + spawnTerminal: (id: string, shell: string, args: string[], cwd: string, env?: Record) => + ipcRenderer.send('terminal-spawn', { id, shell, args, cwd, env }), + writeTerminal: (id: string, data: string) => ipcRenderer.send('terminal-write', { id, data }), + resizeTerminal: (id: string, cols: number, rows: number) => ipcRenderer.send('terminal-resize', { id, cols, rows }), + killTerminal: (id: string) => ipcRenderer.send('terminal-kill', { id }), + onTerminalData: (id: string, callback: (data: string) => void) => { + const channel = `terminal-data-${id}`; + const subscription = (_: any, data: string) => callback(data); + ipcRenderer.on(channel, subscription); + return () => ipcRenderer.removeListener(channel, subscription); + }, + onTerminalExit: (id: string, callback: (code: number) => void) => { + const channel = `terminal-exit-${id}`; + const subscription = (_: any, { exitCode }: { exitCode: number }) => callback(exitCode); + ipcRenderer.on(channel, subscription); + return () => ipcRenderer.removeListener(channel, subscription); + }, + // Settings IPC + saveApiKey: (provider: string, key: string) => ipcRenderer.invoke('settings-save-key', { provider, key }), + getApiKey: (provider: string) => ipcRenderer.invoke('settings-get-key', { provider }), + deleteApiKey: (provider: string) => ipcRenderer.invoke('settings-delete-key', { provider }), + getSettings: () => ipcRenderer.invoke('settings-get-all'), + getEnabledProviders: () => ipcRenderer.invoke('settings-get-providers'), + hasApiKey: (provider: string) => ipcRenderer.invoke('settings-has-key', { provider }), + // CLI Installation IPC + checkCliInstalled: (cli: string) => ipcRenderer.invoke('cli-check-installed', { cli }), + installCli: (installCommand: string) => ipcRenderer.invoke('cli-install', { installCommand }), }); diff --git a/desktop/electron/settings-store.ts b/desktop/electron/settings-store.ts new file mode 100644 index 0000000..b9318c3 --- /dev/null +++ b/desktop/electron/settings-store.ts @@ -0,0 +1,132 @@ +import { safeStorage } from 'electron' +import * as fs from 'fs' +import * as path from 'path' +import { app } from 'electron' + +const SETTINGS_FILE = 'squadron-settings.json' + +interface Settings { + apiKeys: Record // encrypted keys + enabledProviders: string[] + defaultProvider: string + defaultModel: string +} + +const getSettingsPath = (): string => { + return path.join(app.getPath('userData'), SETTINGS_FILE) +} + +const loadSettings = (): Settings => { + try { + const settingsPath = getSettingsPath() + if (fs.existsSync(settingsPath)) { + const data = fs.readFileSync(settingsPath, 'utf-8') + return JSON.parse(data) + } + } catch (err) { + console.error('[Settings] Failed to load settings:', err) + } + return { + apiKeys: {}, + enabledProviders: ['shell'], + defaultProvider: 'shell', + defaultModel: 'default' + } +} + +const saveSettings = (settings: Settings): void => { + try { + const settingsPath = getSettingsPath() + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)) + } catch (err) { + console.error('[Settings] Failed to save settings:', err) + } +} + +// API Key Management (encrypted) +export const saveApiKey = (provider: string, key: string): boolean => { + try { + if (!safeStorage.isEncryptionAvailable()) { + console.warn('[Settings] Encryption not available, storing in plain text') + const settings = loadSettings() + settings.apiKeys[provider] = key + if (!settings.enabledProviders.includes(provider)) { + settings.enabledProviders.push(provider) + } + saveSettings(settings) + return true + } + + const encrypted = safeStorage.encryptString(key).toString('base64') + const settings = loadSettings() + settings.apiKeys[provider] = encrypted + if (!settings.enabledProviders.includes(provider)) { + settings.enabledProviders.push(provider) + } + saveSettings(settings) + return true + } catch (err) { + console.error('[Settings] Failed to save API key:', err) + return false + } +} + +export const getApiKey = (provider: string): string | null => { + try { + const settings = loadSettings() + const stored = settings.apiKeys[provider] + if (!stored) return null + + if (!safeStorage.isEncryptionAvailable()) { + return stored // plain text fallback + } + + const buffer = Buffer.from(stored, 'base64') + return safeStorage.decryptString(buffer) + } catch (err) { + console.error('[Settings] Failed to get API key:', err) + return null + } +} + +export const deleteApiKey = (provider: string): boolean => { + try { + const settings = loadSettings() + delete settings.apiKeys[provider] + settings.enabledProviders = settings.enabledProviders.filter(p => p !== provider) + saveSettings(settings) + return true + } catch (err) { + console.error('[Settings] Failed to delete API key:', err) + return false + } +} + +export const getEnabledProviders = (): string[] => { + const settings = loadSettings() + return settings.enabledProviders +} + +export const hasApiKey = (provider: string): boolean => { + const settings = loadSettings() + return !!settings.apiKeys[provider] +} + +export const getAllSettings = (): Omit & { hasKeys: Record } => { + const settings = loadSettings() + return { + enabledProviders: settings.enabledProviders, + defaultProvider: settings.defaultProvider, + defaultModel: settings.defaultModel, + hasKeys: Object.fromEntries( + Object.keys(settings.apiKeys).map(k => [k, true]) + ) + } +} + +export const setDefaultProvider = (provider: string, model: string): void => { + const settings = loadSettings() + settings.defaultProvider = provider + settings.defaultModel = model + saveSettings(settings) +} diff --git a/desktop/package-lock.json b/desktop/package-lock.json index c135ebb..c713885 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -11,14 +11,23 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.12", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.562.0", + "node-pty": "^1.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "tailwind-merge": "^3.4.0" + "tailwind-merge": "^3.4.0", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -1529,6 +1538,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -1949,6 +1996,591 @@ "node": ">=14" } }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.53", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", @@ -2746,7 +3378,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -2757,8 +3389,9 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3083,6 +3716,21 @@ "node": ">=10.0.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/7zip-bin": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", @@ -3342,6 +3990,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -4208,7 +4868,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -4353,6 +5013,12 @@ "license": "MIT", "optional": true }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -5459,6 +6125,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -6954,6 +7629,22 @@ "node": ">=10" } }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, + "node_modules/node-pty/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.27", "dev": true, @@ -7424,6 +8115,75 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/read-binary-file-arch": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", @@ -8381,6 +9141,49 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", @@ -8589,6 +9392,24 @@ "node": ">=8.0" } }, + "node_modules/xterm": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", + "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==", + "deprecated": "This package is now deprecated. Move to @xterm/xterm instead.", + "license": "MIT", + "peer": true + }, + "node_modules/xterm-addon-fit": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz", + "integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==", + "deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.", + "license": "MIT", + "peerDependencies": { + "xterm": "^5.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "dev": true, diff --git a/desktop/package.json b/desktop/package.json index b465113..12fa742 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -19,14 +19,23 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.12", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.562.0", + "node-pty": "^1.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "tailwind-merge": "^3.4.0" + "tailwind-merge": "^3.4.0", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 064075e..3b50f0c 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -1,128 +1,214 @@ import { useState, useEffect } from 'react' -import { LayoutDashboard, Terminal, Activity, Map, Lightbulb, FileClock, Settings, Plus, Github, GitBranch } from 'lucide-react' -import { cn } from '@/lib/utils' -import { getSystemStatus, type SystemStatus } from '@/lib/api' +import { + LayoutDashboard, + Terminal, + Activity, + Map, + Lightbulb, + FileClock, + Settings, + Plus, + Github, + GitBranch, +} from 'lucide-react' +import { getAgents, type Agent } from '@/lib/api' import { KanbanBoard } from '@/components/KanbanBoard' import { TaskWizard } from '@/components/TaskWizard' +import { AgentCard } from '@/components/AgentCard' +import { TerminalHub } from '@/components/TerminalHub' +import { SettingsPanel } from '@/components/SettingsPanel' + +// Shadcn Sidebar Imports +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupLabel, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, + SidebarTrigger, + SidebarRail, +} from "@/components/ui/sidebar" export default function App() { const [activeTab, setActiveTab] = useState('kanban') - const [systemStatus, setSystemStatus] = useState(null) + const [agents, setAgents] = useState([]) const [isWizardOpen, setIsWizardOpen] = useState(false) - const [kanbanKey, setKanbanKey] = useState(0) // Used to force refresh Kanban after creation + const [isSettingsOpen, setIsSettingsOpen] = useState(false) + const [kanbanKey, setKanbanKey] = useState(0) useEffect(() => { const fetchStatus = async () => { - const status = await getSystemStatus() - setSystemStatus(status) + const latestAgents = await getAgents() + setAgents(latestAgents) } fetchStatus() - const interval = setInterval(fetchStatus, 5000) // Poll every 5s + const interval = setInterval(fetchStatus, 5000) return () => clearInterval(interval) }, []) - const handleTaskCreated = () => { - setKanbanKey(prev => prev + 1) - } + const navItems = [ + { id: 'kanban', label: 'Operations', icon: LayoutDashboard }, + { id: 'terminals', label: 'Terminal Hub', icon: Terminal }, + { id: 'network', label: 'Agent Network', icon: Activity }, + { id: 'roadmap', label: 'Flight Plan', icon: Map }, + { id: 'prompts', label: 'Command Library', icon: Lightbulb }, + { id: 'history', label: 'Mission Logs', icon: FileClock }, + { id: 'settings', label: 'System Config', icon: Settings }, + ] return ( -
- {/* Sidebar */} - - - {/* Main Content */} -
-
-
-

{activeTab.charAt(0).toUpperCase() + activeTab.slice(1).replace('-', ' ')}

-

- - - {systemStatus?.status === 'online' ? 'System Online' : (systemStatus?.status || 'Connecting...')} - - {systemStatus && ( - <> - | - {systemStatus.agents_online} Agents Online - | - {systemStatus.missions_active} Active Missions - + + + + + + Main Interface + + + {navItems.map((item) => ( + + item.id === 'settings' ? setIsSettingsOpen(true) : setActiveTab(item.id)} + isActive={activeTab === item.id} + tooltip={item.label} + className={activeTab === item.id ? "bg-yellow-400 text-black font-bold" : "text-zinc-500 hover:text-white hover:bg-zinc-900"} + > + + {item.label} + + + ))} + + + + + + Deployment + + + + + + Repository + + + + + + Active Branch + + + + + + + + + +

+
+
+
+ Commander Michael + Session Active +
+ + + + + + + + +
+ {/* Subtle Background Glow */} +
+ +
+
+ +
+

+ {activeTab === 'kanban' ? 'Operation Dashboard' : 'Terminal Workbench'} +

+
+
+
+ Live System +
+
+
+
+ +
+ {activeTab === 'kanban' && ( +
+
+
+ {agents.map(agent => ( + + ))} +
+ +
+
+

Project Flight Path

+
+
+ +
+
+
)} -

-
-
- {activeTab === 'kanban' && } - {activeTab === 'terminals' &&
Agent Terminals placeholder - Coming soon in Issue 7
} + {activeTab === 'terminals' && ( +
+ +
+ )} +
+ + setIsWizardOpen(false)} - onTaskCreated={handleTaskCreated} + onTaskCreated={() => { + setKanbanKey(prev => prev + 1) + }} /> - - - ) -} -function SidebarItem({ icon, label, isActive, onClick }: { icon: any, label: string, isActive?: boolean, onClick: () => void }) { - return ( - + setIsSettingsOpen(false)} + /> + + ) } - diff --git a/desktop/src/components/ActivityFeed.tsx b/desktop/src/components/ActivityFeed.tsx new file mode 100644 index 0000000..98615dc --- /dev/null +++ b/desktop/src/components/ActivityFeed.tsx @@ -0,0 +1,97 @@ +import { useState, useEffect, useRef } from 'react' +import { Terminal, Lightbulb, Zap, CheckCircle2, AlertCircle, Clock } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface Event { + type: 'agent_start' | 'tool_call' | 'tool_result' | 'agent_thought' | 'agent_complete' | 'error' + agent: string + timestamp: string + data: any +} + +export function ActivityFeed() { + const [events, setEvents] = useState([]) + const scrollRef = useRef(null) + + useEffect(() => { + // Connect to SSE endpoint + const eventSource = new EventSource('http://127.0.0.1:8000/activity') + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data) + setEvents(prev => [...prev.slice(-49), data]) // Keep last 50 events + } catch (err) { + console.error('Failed to parse event:', err) + } + } + + return () => eventSource.close() + }, []) + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [events]) + + return ( +
+
+

+ + Neural Activity Stream +

+
+
+ Live +
+
+ +
+ {events.length === 0 ? ( +
+ + Monitoring events... +
+ ) : ( + events.map((event, i) => ( +
+
+ {event.type === 'agent_thought' && } + {event.type === 'tool_call' && } + {event.type === 'agent_complete' && } + {event.type === 'error' && } + {(event.type === 'agent_start' || event.type === 'tool_result') && } +
+ +
+
+ {event.agent} + + {new Date(event.timestamp).toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })} + +
+
+ {event.type === 'agent_thought' && thought: } + {event.type === 'tool_call' && executing {event.data.tool}()...} + {event.type === 'tool_result' && tool result captured.} + {event.data.thought || event.data.task || event.data.summary || event.data.error || (event.type === 'tool_result' ? 'Success' : '')} +
+
+
+ )) + )} +
+
+ ) +} diff --git a/desktop/src/components/AgentCard.tsx b/desktop/src/components/AgentCard.tsx new file mode 100644 index 0000000..3f3cc2d --- /dev/null +++ b/desktop/src/components/AgentCard.tsx @@ -0,0 +1,77 @@ +import { Terminal, BrainCircuit, Activity, Cpu } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { Agent } from '@/lib/api' + +interface AgentCardProps { + agent: Agent +} + +export function AgentCard({ agent }: AgentCardProps) { + const isThinking = agent.status === 'active' && !agent.current_tool + // const isExecuting = !!agent.current_tool // Reserved for future visual effects + + + return ( +
+
+
+
+ {agent.name === 'Marcus' && } + {agent.name === 'Caleb' && } + {agent.name === 'Sentinel' && } +
+
+

{agent.name}

+

{agent.role}

+
+
+ +
+ + {agent.status} +
+
+ +
+ {/* Thought / Status Area */} +
+ {agent.status === 'active' ? ( +
+
+ + {isThinking ? 'Thinking' : 'Executing'} +
+

+ "{agent.current_thought || 'Processing task...'}" +

+
+ ) : ( +
+ Idle Standby +
+ )} +
+ + {/* Current Tool Indicator */} + {agent.current_tool && ( +
+ + tool: + {agent.current_tool}() +
+ )} +
+ + {/* Decorative background pulse */} + {agent.status === 'active' && ( +
+ )} +
+ ) +} diff --git a/desktop/src/components/KanbanBoard.tsx b/desktop/src/components/KanbanBoard.tsx index e6153e7..bfa4e10 100644 --- a/desktop/src/components/KanbanBoard.tsx +++ b/desktop/src/components/KanbanBoard.tsx @@ -122,7 +122,7 @@ export function KanbanBoard() { } return ( -
+
{columns.map(col => ( - t.status === col.id)} - /> +
+ t.status === col.id)} + /> +
))} + {activeTask ? ( -
+
) : null} @@ -150,4 +152,5 @@ export function KanbanBoard() {
) + } diff --git a/desktop/src/components/SettingsPanel.tsx b/desktop/src/components/SettingsPanel.tsx new file mode 100644 index 0000000..f6f8ec3 --- /dev/null +++ b/desktop/src/components/SettingsPanel.tsx @@ -0,0 +1,173 @@ +import { useState, useEffect } from 'react' +import { X, Key, Check, AlertCircle, Eye, EyeOff, Trash2 } from 'lucide-react' +import { cn } from '@/lib/utils' +import { PROVIDERS } from '@/lib/providers' + +interface SettingsPanelProps { + isOpen: boolean + onClose: () => void +} + +export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) { + const [apiKeys, setApiKeys] = useState>({}) + const [hasKeys, setHasKeys] = useState>({}) + const [showKey, setShowKey] = useState>({}) + const [saving, setSaving] = useState(null) + const [saved, setSaved] = useState(null) + + const api = (window as any).electronAPI + + // Load existing key status on mount + useEffect(() => { + if (!isOpen) return + + const loadKeyStatus = async () => { + const status: Record = {} + for (const providerId of Object.keys(PROVIDERS)) { + if (providerId === 'shell') continue + try { + status[providerId] = await api.hasApiKey(providerId) + } catch { + status[providerId] = false + } + } + setHasKeys(status) + } + loadKeyStatus() + }, [isOpen]) + + const handleSaveKey = async (providerId: string) => { + const key = apiKeys[providerId] + if (!key?.trim()) return + + setSaving(providerId) + try { + await api.saveApiKey(providerId, key.trim()) + setHasKeys(prev => ({ ...prev, [providerId]: true })) + setApiKeys(prev => ({ ...prev, [providerId]: '' })) + setSaved(providerId) + setTimeout(() => setSaved(null), 2000) + } catch (err) { + console.error(`Failed to save API key for ${providerId}:`, err) + } finally { + setSaving(null) + } + } + + const handleDeleteKey = async (providerId: string) => { + try { + await api.deleteApiKey(providerId) + setHasKeys(prev => ({ ...prev, [providerId]: false })) + } catch (err) { + console.error(`Failed to delete API key for ${providerId}:`, err) + } + } + + if (!isOpen) return null + + const providerList = Object.values(PROVIDERS).filter(p => p.id !== 'shell') + + return ( +
+
+ {/* Header */} +
+

+ + API Configuration +

+ +
+ + {/* Content */} +
+

+ Configure API keys for AI providers. Keys are encrypted and stored securely on your device. +

+ + {providerList.map(provider => ( +
+
+ + {hasKeys[provider.id] && ( + + + Configured + + )} +
+ +
+
+ setApiKeys(prev => ({ ...prev, [provider.id]: e.target.value }))} + className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-sm text-zinc-100 placeholder:text-zinc-600 focus:outline-none focus:border-yellow-400/50 transition-colors pr-10" + /> + +
+ + + + {hasKeys[provider.id] && ( + + )} +
+ +

+ Environment variable: {provider.envKey} +

+
+ ))} + + {/* Security notice */} +
+ +
+ Security: API keys are encrypted using your operating system's secure storage (Windows DPAPI / macOS Keychain) and never leave your device. +
+
+
+ + {/* Footer */} +
+ +
+
+
+ ) +} diff --git a/desktop/src/components/TaskCard.tsx b/desktop/src/components/TaskCard.tsx index 4f4df53..4adfbd9 100644 --- a/desktop/src/components/TaskCard.tsx +++ b/desktop/src/components/TaskCard.tsx @@ -69,8 +69,17 @@ export function TaskCard({ task }: TaskCardProps) {
{task.status === 'in_progress' && ( -
-
+
+
+ Progress + {task.progress}% +
+
+
+
)} diff --git a/desktop/src/components/TerminalHub.tsx b/desktop/src/components/TerminalHub.tsx new file mode 100644 index 0000000..a0b95cf --- /dev/null +++ b/desktop/src/components/TerminalHub.tsx @@ -0,0 +1,379 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { Terminal as TerminalIcon, X, ChevronDown, Link2, Zap } from 'lucide-react' +import { cn } from '@/lib/utils' +import { XTermComponent } from './XTermComponent' +import { getTasks, type Task } from '@/lib/api' +import { PROVIDERS, getProviderById, getDefaultModel, type ProviderConfig } from '@/lib/providers' + +declare global { + interface Window { + electronAPI: { + spawnTerminal: (id: string, shell: string, args: string[], cwd: string, env?: Record) => void + writeTerminal: (id: string, data: string) => void + resizeTerminal: (id: string, cols: number, rows: number) => void + killTerminal: (id: string) => void + onTerminalData: (id: string, callback: (data: string) => void) => () => void + onTerminalExit: (id: string, callback: (code: number) => void) => () => void + getApiKey: (provider: string) => Promise + hasApiKey: (provider: string) => Promise + getEnabledProviders: () => Promise + } + } +} + +interface TerminalSession { + id: string + title: string + providerId: string + modelId: string + linkedTaskId?: string + isActive: boolean + needsRespawn: boolean +} + +// Pre-spawn 6 terminals on load +const DEFAULT_SESSIONS: TerminalSession[] = [ + { id: 'term-1', title: 'Terminal 1', providerId: 'shell', modelId: 'default', isActive: true, needsRespawn: false }, + { id: 'term-2', title: 'Terminal 2', providerId: 'shell', modelId: 'default', isActive: false, needsRespawn: false }, + { id: 'term-3', title: 'Terminal 3', providerId: 'shell', modelId: 'default', isActive: false, needsRespawn: false }, + { id: 'term-4', title: 'Terminal 4', providerId: 'shell', modelId: 'default', isActive: false, needsRespawn: false }, + { id: 'term-5', title: 'Terminal 5', providerId: 'shell', modelId: 'default', isActive: false, needsRespawn: false }, + { id: 'term-6', title: 'Terminal 6', providerId: 'shell', modelId: 'default', isActive: false, needsRespawn: false }, +] + +export function TerminalHub() { + const [sessions, setSessions] = useState(DEFAULT_SESSIONS) + const [tasks, setTasks] = useState([]) + const [enabledProviders, setEnabledProviders] = useState(['shell']) + const [openDropdown, setOpenDropdown] = useState(null) + const [openModelDropdown, setOpenModelDropdown] = useState(null) + const [openTaskDropdown, setOpenTaskDropdown] = useState(null) + const respawnTriggersRef = useRef>({}) + + // Fetch enabled providers on mount and periodically refresh + useEffect(() => { + const checkProviders = async () => { + try { + const providers = await window.electronAPI.getEnabledProviders() + setEnabledProviders(['shell', ...providers.filter(p => p !== 'shell')]) + } catch (err) { + console.error('Failed to get providers:', err) + } + } + checkProviders() + // Refresh every 2 seconds to pick up new API keys + const interval = setInterval(checkProviders, 2000) + return () => clearInterval(interval) + }, []) + + // Fetch tasks for linking + useEffect(() => { + const fetchTasks = async () => { + try { + const data = await getTasks() + setTasks(data) + } catch (err) { + console.error('Failed to fetch tasks:', err) + } + } + fetchTasks() + const interval = setInterval(fetchTasks, 10000) + return () => clearInterval(interval) + }, []) + + const closeSession = (id: string, e: React.MouseEvent) => { + e.stopPropagation() + window.electronAPI.killTerminal(id) + setSessions((prev: TerminalSession[]) => prev.filter(s => s.id !== id)) + setOpenDropdown(null) + setOpenModelDropdown(null) + setOpenTaskDropdown(null) + } + + const switchSession = (id: string) => { + setSessions((prev: TerminalSession[]) => prev.map(s => ({ + ...s, + isActive: s.id === id + }))) + } + + // Change provider and trigger respawn + const setProvider = useCallback(async (sessionId: string, providerId: string) => { + const provider = getProviderById(providerId) + if (!provider) return + + // Check if we have the API key for this provider + if (providerId !== 'shell') { + const hasKey = await window.electronAPI.hasApiKey(providerId) + if (!hasKey) { + console.warn(`No API key configured for ${providerId}`) + // Could show a toast/notification here + } + } + + const modelId = getDefaultModel(providerId) + + // Mark for respawn + respawnTriggersRef.current[sessionId] = (respawnTriggersRef.current[sessionId] || 0) + 1 + + setSessions((prev: TerminalSession[]) => prev.map(s => + s.id === sessionId ? { ...s, providerId, modelId, needsRespawn: true } : s + )) + setOpenDropdown(null) + }, []) + + // Change model and trigger respawn + const setModel = useCallback((sessionId: string, modelId: string) => { + respawnTriggersRef.current[sessionId] = (respawnTriggersRef.current[sessionId] || 0) + 1 + + setSessions((prev: TerminalSession[]) => prev.map(s => + s.id === sessionId ? { ...s, modelId, needsRespawn: true } : s + )) + setOpenModelDropdown(null) + }, []) + + // Link task to terminal + const linkTask = useCallback((sessionId: string, taskId: string | undefined) => { + setSessions((prev: TerminalSession[]) => prev.map(s => + s.id === sessionId ? { ...s, linkedTaskId: taskId } : s + )) + setOpenTaskDropdown(null) + }, []) + + // Inject task context into terminal + const injectTaskContext = useCallback((sessionId: string, task: Task) => { + const contextText = `\n# ═══════════════════════════════════════════════════════════════\n# TASK CONTEXT INJECTED\n# ═══════════════════════════════════════════════════════════════\n# Task: ${task.task}\n# Priority: ${task.priority}\n# Status: ${task.status}\n# ═══════════════════════════════════════════════════════════════\n\n` + window.electronAPI.writeTerminal(sessionId, contextText) + }, []) + + const getProviderInfo = (providerId: string): ProviderConfig => { + return getProviderById(providerId) || PROVIDERS.shell + } + + const getLinkedTask = (taskId?: string) => { + return tasks.find(t => t.id === taskId) + } + + // Get respawn trigger for XTermComponent key + const getRespawnTrigger = (id: string) => respawnTriggersRef.current[id] || 0 + + return ( +
+ {/* 6-Terminal Grid: 3 columns x 2 rows */} +
+ {sessions.map(s => { + const providerInfo = getProviderInfo(s.providerId) + const linkedTask = getLinkedTask(s.linkedTaskId) + const currentModel = providerInfo.models.find(m => m.id === s.modelId) || providerInfo.models[0] + + return ( +
switchSession(s.id)} + className={cn( + "flex flex-col min-h-0 bg-[#050506] transition-all cursor-pointer relative", + s.isActive && "ring-1 ring-yellow-500/30" + )} + > + {/* Terminal header */} +
+
+
+ + {/* Provider Toggle */} +
+ + + {openDropdown === s.id && ( +
+ {Object.values(PROVIDERS).filter(p => enabledProviders.includes(p.id) || p.id === 'shell').map(p => ( + + ))} +
+ )} +
+ + {/* Model Selector */} + {s.providerId !== 'shell' && ( +
+ + + {openModelDropdown === s.id && ( +
+ {providerInfo.models.map(m => ( + + ))} +
+ )} +
+ )} + + {/* Task Link Button */} +
+ + + {openTaskDropdown === s.id && ( +
+ + {tasks.map(t => ( + + ))} + {tasks.length === 0 && ( +
+ No tasks available +
+ )} +
+ )} +
+ + {/* Inject Context Button (when task linked) */} + {linkedTask && ( + + )} +
+ + closeSession(s.id, e)} + /> +
+ + {/* Linked task indicator */} + {linkedTask && ( +
+ 📋 {linkedTask.task.slice(0, 50)}{linkedTask.task.length > 50 ? '...' : ''} +
+ )} + + {/* Terminal content */} +
+ +
+
+ ) + })} + + {/* Empty state */} + {sessions.length === 0 && ( +
+ + NO TERMINALS +
+ )} +
+
+ ) +} diff --git a/desktop/src/components/TerminalView.tsx b/desktop/src/components/TerminalView.tsx new file mode 100644 index 0000000..37b9f38 --- /dev/null +++ b/desktop/src/components/TerminalView.tsx @@ -0,0 +1,196 @@ +import { useState, useEffect, useRef } from 'react' +import { Terminal as TerminalIcon, Shield, Search, Zap, Trash2, Cpu } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface LogEntry { + id: string + timestamp: string + agent: string + message: string + type: 'info' | 'warn' | 'error' | 'success' | 'cmd' +} + +export function TerminalView() { + const [logs, setLogs] = useState([]) + const [filter, setFilter] = useState('') + const [selectedAgent, setSelectedAgent] = useState(null) + const scrollRef = useRef(null) + + useEffect(() => { + const eventSource = new EventSource('http://127.0.0.1:8000/activity') + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data) + + // Convert activity events to terminal logs + let logType: LogEntry['type'] = 'info' + let messageValue = '' + + if (data.type === 'tool_call') { + logType = 'cmd' + messageValue = `> Executing tool: ${data.data.tool}(${JSON.stringify(data.data.arguments || {})})` + } else if (data.type === 'tool_result') { + logType = 'success' + messageValue = `✓ Tool result: ${typeof data.data.result === 'string' ? data.data.result.substring(0, 100) : 'Success'}` + } else if (data.type === 'agent_thought') { + logType = 'info' + messageValue = `🧠 Thought: ${data.data.thought}` + } else if (data.type === 'error') { + logType = 'error' + messageValue = `!! Error: ${data.data.error}` + } else { + return // Skip other event types for now + } + + const newEntry: LogEntry = { + id: Math.random().toString(36).substr(2, 9), + timestamp: data.timestamp, + agent: data.agent, + message: messageValue, + type: logType + } + + setLogs(prev => [...prev.slice(-199), newEntry]) + } catch (err) { + console.error('Failed to parse event:', err) + } + } + + return () => eventSource.close() + }, []) + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [logs]) + + const filteredLogs = logs.filter(log => { + const matchesAgent = selectedAgent ? log.agent === selectedAgent : true + const matchesSearch = log.message.toLowerCase().includes(filter.toLowerCase()) || + log.agent.toLowerCase().includes(filter.toLowerCase()) + return matchesAgent && matchesSearch + }) + + const agents = Array.from(new Set(logs.map(l => l.agent))) + + return ( +
+ {/* Terminal Header */} +
+
+
+
+
+
+
+
+

+ + Autonomous System Logs +

+
+ +
+
+ + setFilter(e.target.value)} + className="bg-zinc-950 border border-zinc-800 rounded-lg py-1 pl-8 pr-3 text-[10px] text-zinc-300 focus:outline-none focus:border-yellow-500/50 transition-colors w-48" + /> +
+ +
+
+ + {/* Agent Filter Tabs */} +
+ + {agents.map(agent => ( + + ))} +
+ + {/* Log Stream */} +
+ {filteredLogs.length === 0 ? ( +
+ + No Process Logs Found +
+ ) : ( + filteredLogs.map((log) => ( +
+ + [{new Date(log.timestamp).toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })}] + + + {log.agent.padEnd(8)} + + + {log.message} + +
+ )) + )} +
+ + {/* Terminal Footer */} +
+
+ + SSL Secured + + + SSE Live + +
+
+ {filteredLogs.length} Lines Displayed +
+
+
+ ) +} diff --git a/desktop/src/components/XTermComponent.tsx b/desktop/src/components/XTermComponent.tsx new file mode 100644 index 0000000..955d131 --- /dev/null +++ b/desktop/src/components/XTermComponent.tsx @@ -0,0 +1,170 @@ +import { useEffect, useRef } from 'react' +import { Terminal } from 'xterm' +import { FitAddon } from 'xterm-addon-fit' +import 'xterm/css/xterm.css' +import { getProviderById, PROVIDERS, type ProviderConfig } from '@/lib/providers' + +interface XTermComponentProps { + id: string + onData?: (data: string) => void + providerId?: string + modelId?: string + cwd?: string + isActive?: boolean +} + +export function XTermComponent({ id, providerId = 'shell', modelId, cwd, isActive }: XTermComponentProps) { + const termRef = useRef(null) + const terminalInstance = useRef(null) + const fitAddon = useRef(null) + + useEffect(() => { + if (!termRef.current) return + + // Initialize xterm + const term = new Terminal({ + cursorBlink: true, + fontSize: 10, + fontFamily: 'JetBrains Mono, Menlo, Monaco, Consolas, "Courier New", monospace', + fontWeight: 'normal', + lineHeight: 1.1, + letterSpacing: 0, + allowTransparency: true, + theme: { + background: 'transparent', + foreground: '#a1a1aa', + cursor: '#eab308', + selectionBackground: '#eab30833', + black: '#09090b', + red: '#ef4444', + green: '#22c55e', + yellow: '#eab308', + blue: '#3b82f6', + magenta: '#d946ef', + cyan: '#06b6d4', + white: '#e4e4e7', + } + }) + + const fit = new FitAddon() + term.loadAddon(fit) + term.open(termRef.current) + fit.fit() + + terminalInstance.current = term + fitAddon.current = fit + + // Get provider configuration + const provider = getProviderById(providerId) || PROVIDERS.shell + const api = (window as any).electronAPI + + // Check if CLI needs installation and auto-install if needed + const checkAndInstallCli = async (provider: ProviderConfig): Promise => { + // Skip for npx-based CLIs (they auto-download) + if (provider.cli === 'npx' || provider.cli === 'shell') { + return true + } + + // Check if CLI is installed + const isInstalled = await api.checkCliInstalled(provider.cli) + + if (!isInstalled && provider.installCommand) { + term.writeln(`\x1b[33m[Squadron] ${provider.name} CLI not found. Installing...\x1b[0m`) + term.writeln(`\x1b[90m$ ${provider.installCommand.windows || provider.installCommand.unix}\x1b[0m`) + + const command = globalThis?.process?.platform === 'win32' + ? provider.installCommand.windows + : provider.installCommand.unix + + const result = await api.installCli(command) + + if (result.success) { + term.writeln(`\x1b[32m[Squadron] ${provider.name} CLI installed successfully!\x1b[0m`) + term.writeln('') + return true + } else { + term.writeln(`\x1b[31m[Squadron] Failed to install ${provider.name} CLI:\x1b[0m`) + term.writeln(`\x1b[31m${result.output}\x1b[0m`) + term.writeln('') + term.writeln(`\x1b[33mPlease install manually and try again.\x1b[0m`) + return false + } + } + + return isInstalled + } + + // Build spawn configuration + const spawnTerminal = async () => { + let shell = provider.cli + let args = [...provider.args] + let env: Record = {} + + // For AI providers, check and auto-install CLI if needed + if (providerId !== 'shell') { + const cliReady = await checkAndInstallCli(provider) + if (!cliReady && provider.cli !== 'npx') { + return // Don't spawn if CLI couldn't be installed + } + } + + // For AI providers, get the API key and add to env + if (providerId !== 'shell' && provider.envKey) { + try { + const apiKey = await api.getApiKey(providerId) + if (apiKey) { + env[provider.envKey] = apiKey + } + } catch (err) { + console.error(`Failed to get API key for ${providerId}:`, err) + } + } + + // Spawn the terminal with the correct CLI and env + api.spawnTerminal(id, shell, args, cwd, env) + } + + spawnTerminal() + + const cleanupData = api.onTerminalData(id, (data: string) => { + term.write(data) + }) + + term.onData((data) => { + api.writeTerminal(id, data) + }) + + const handleResize = () => { + fit.fit() + api.resizeTerminal(id, term.cols, term.rows) + } + + window.addEventListener('resize', handleResize) + + // Initial resize + setTimeout(handleResize, 100) + + return () => { + cleanupData() + window.removeEventListener('resize', handleResize) + term.dispose() + } + }, [id, providerId, modelId, cwd]) + + useEffect(() => { + if (isActive && fitAddon.current) { + setTimeout(() => fitAddon.current?.fit(), 100) + } + }, [isActive]) + + return ( +
+