diff --git a/vscode-extension/extension.js b/vscode-extension/extension.js new file mode 100644 index 0000000000..b745cedfca --- /dev/null +++ b/vscode-extension/extension.js @@ -0,0 +1,1009 @@ +const vscode = require('vscode'); +const { spawn, execSync } = require('child_process'); +const { createInterface } = require('readline'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const crypto = require('crypto'); + +// ============================================================ +// Constants +// ============================================================ +const WEBVIEW_ID = 'hermes.chatView'; +const OUTPUT_CHANNEL_NAME = 'Hermes Agent'; +const SESSIONS_DIR = path.join(os.homedir(), '.hermes', 'vscode-sessions'); + +// ============================================================ +// Global State +// ============================================================ +let outputChannel; +let sidebarProvider; +let panelProvider; + +// ============================================================ +// Session Storage +// ============================================================ +class SessionStore { + constructor() { + this.sessionsDir = SESSIONS_DIR; + if (!fs.existsSync(this.sessionsDir)) { + fs.mkdirSync(this.sessionsDir, { recursive: true }); + } + } + + getSessionDir(sessionId) { + return path.join(this.sessionsDir, sessionId); + } + + listSessions() { + if (!fs.existsSync(this.sessionsDir)) return []; + return fs.readdirSync(this.sessionsDir) + .filter(d => fs.statSync(path.join(this.sessionsDir, d)).isDirectory()) + .map(id => { + const metaPath = path.join(this.sessionsDir, id, 'meta.json'); + try { + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + return { id, ...meta }; + } catch { + return { id, title: 'Untitled', createdAt: Date.now() }; + } + }) + .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); + } + + saveSessionMeta(sessionId, meta) { + const dir = this.getSessionDir(sessionId); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const metaPath = path.join(dir, 'meta.json'); + let existing = {}; + try { existing = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch {} + fs.writeFileSync(metaPath, JSON.stringify({ ...existing, ...meta }, null, 2)); + } + + saveMessages(sessionId, messages) { + const dir = this.getSessionDir(sessionId); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'messages.json'), JSON.stringify(messages, null, 2)); + } + + loadMessages(sessionId) { + try { + return JSON.parse(fs.readFileSync(path.join(this.getSessionDir(sessionId), 'messages.json'), 'utf8')); + } catch { return []; } + } + + deleteSession(sessionId) { + const dir = this.getSessionDir(sessionId); + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +} + +const sessionStore = new SessionStore(); + +// ============================================================ +// MCP Server Config (reads from ~/.hermes/config.yaml) +// ============================================================ +function getMcpServers() { + const configPath = path.join(os.homedir(), '.hermes', 'config.yaml'); + const servers = {}; + try { + const raw = fs.readFileSync(configPath, 'utf8'); + // Minimal YAML parser: extract mcp_servers section + const match = raw.match(/mcp_servers:\s*\n([\s\S]*?)(?=\n[a-z]|\n*$)/); + if (match) { + const section = match[1]; + // Parse simple key-value pairs like: + // server_name: + // command: ... + // args: [...] + let currentName = null; + let currentConfig = {}; + for (const line of section.split('\n')) { + const nameMatch = line.match(/^ (\w+):\s*$/); + if (nameMatch) { + if (currentName) servers[currentName] = currentConfig; + currentName = nameMatch[1]; + currentConfig = {}; + continue; + } + const cmdMatch = line.match(/^ command:\s*["']?(.+?)["']?\s*$/); + if (cmdMatch && currentName) currentConfig.command = cmdMatch[1]; + const argsMatch = line.match(/^ args:\s*\[(.+)\]\s*$/); + if (argsMatch && currentName) { + try { currentConfig.args = JSON.parse('[' + argsMatch[1] + ']'); } catch {} + } + } + if (currentName) servers[currentName] = currentConfig; + } + } catch {} + return servers; +} + +// ============================================================ +// Extension Activate / Deactivate +// ============================================================ +function activate(context) { + outputChannel = vscode.window.createOutputChannel(OUTPUT_CHANNEL_NAME); + outputChannel.appendLine('Hermes Agent extension activated'); + + checkHermesAvailable(); + + // Register diff document provider for showing edits + diffProvider = new DiffDocumentProvider(); + context.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider('hermes-diff', diffProvider) + ); + + sidebarProvider = new HermesChatProvider(context, outputChannel, 'sidebar'); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider(WEBVIEW_ID, sidebarProvider, { + webviewOptions: { retainContextWhenHidden: true }, + }) + ); + + // All commands + const commands = { + 'hermes.open': () => openPanel(context), + 'hermes.openInTab': () => openPanel(context), + 'hermes.openInSidebar': () => vscode.commands.executeCommand('workbench.view.extension.hermes-sidebar'), + 'hermes.newConversation': () => getActiveProvider()?.newConversation(), + 'hermes.focus': () => getActiveProvider()?.postMessageToWebview({ type: 'focusInput' }), + 'hermes.blur': () => getActiveProvider()?.postMessageToWebview({ type: 'blurInput' }), + 'hermes.stop': () => getActiveProvider()?.stopGeneration(), + 'hermes.showLogs': () => outputChannel.show(), + 'hermes.insertAtMention': () => getActiveProvider()?.handleInsertAtMention(), + 'hermes.logout': () => getActiveProvider()?.handleLogout(), + }; + + for (const [cmd, handler] of Object.entries(commands)) { + context.subscriptions.push(vscode.commands.registerCommand(cmd, handler)); + } + + // Track active editor for context + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor((editor) => { + getActiveProvider()?.sendSelectionChanged(editor); + }), + vscode.window.onDidChangeTextEditorSelection((event) => { + getActiveProvider()?.sendSelectionChanged(event.textEditor, event.selections[0]); + }) + ); +} + +function deactivate() { + outputChannel?.appendLine('Hermes Agent extension deactivated'); + sidebarProvider?.dispose(); + panelProvider?.dispose(); +} + +// ============================================================ +// Panel Management +// ============================================================ +function openPanel(context) { + if (panelProvider?.panel) { panelProvider.panel.reveal(); return; } + panelProvider = new HermesChatProvider(context, outputChannel, 'panel'); + const panel = vscode.window.createWebviewPanel('hermesChatPanel', 'Hermes Agent', vscode.ViewColumn.Beside, { + enableScripts: true, retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.file(path.join(context.extensionPath, 'webview')), + vscode.Uri.file(path.join(context.extensionPath, 'resources')), + ], + }); + panelProvider.setPanel(panel); + panel.iconPath = vscode.Uri.file(path.join(context.extensionPath, 'resources', 'hermes-logo.svg')); + panel.onDidDispose(() => { panelProvider = undefined; }); + context.subscriptions.push(panel); +} + +function getActiveProvider() { + return panelProvider?.panel ? panelProvider : sidebarProvider; +} + +// ============================================================ +// CLI Runner +// ============================================================ +class CliRunner { + constructor(outputChannel) { + this.outputChannel = outputChannel; + this.currentProcess = null; + } + + getConfig() { + const config = vscode.workspace.getConfiguration('hermes'); + return { + cliCommand: config.get('cliCommand', 'hermes'), + cwd: config.get('workingDirectory', '') || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd(), + includeActiveFile: config.get('includeActiveFile', true), + envVars: config.get('environmentVariables', []), + }; + } + + async *runQuery(prompt, options = {}) { + const config = this.getConfig(); + const env = { ...process.env }; + for (const { name, value } of config.envVars) { env[name] = value; } + + // Build hermes CLI command + const cmdParts = config.cliCommand.split(/\s+/); + const cmd = cmdParts[0]; + const baseArgs = cmdParts.slice(1); + const args = [...baseArgs, '-q', prompt, '--quiet']; + + if (options.model) args.push('--model', options.model); + if (options.resumeSessionId) args.push('--resume', options.resumeSessionId); + if (options.permissionMode === 'bypassPermissions') args.push('--yolo'); + if (options.maxTurns) args.push('--max-turns', String(options.maxTurns)); + if (options.toolsets) args.push('--toolsets', options.toolsets); + + this.outputChannel.appendLine(`[CLI] ${cmd} ${args.join(' ')}`); + + this.currentProcess = spawn(cmd, args, { cwd: config.cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }); + const rl = createInterface({ input: this.currentProcess.stdout }); + const stderrChunks = []; + this.currentProcess.stderr.on('data', (chunk) => { + stderrChunks.push(chunk.toString()); + this.outputChannel.appendLine(`[stderr] ${chunk.toString().trim()}`); + }); + + let accumulated = ''; + try { + for await (const line of rl) { + if (!line.trim()) continue; + // In --quiet mode, hermes outputs the response text directly to stdout + accumulated += line + '\n'; + yield { type: 'raw_text', text: line + '\n' }; + } + // Parse session_id from stderr (hermes writes "session_id: XXX" to stderr in --quiet mode) + const stderr = stderrChunks.join(''); + const sessionMatch = stderr.match(/session_id:\s*(\S+)/); + if (sessionMatch) { + this.lastSessionId = sessionMatch[1]; + } + yield { type: 'result', subtype: 'success', result: accumulated.trim() }; + } finally { + this.currentProcess = null; + } + } + + stop() { + if (this.currentProcess && !this.currentProcess.killed) { + this.currentProcess.kill('SIGTERM'); + setTimeout(() => { this.currentProcess?.kill?.('SIGKILL'); }, 3000); + this.currentProcess = null; + } + } +} + +// ============================================================ +// Chat Provider +// ============================================================ +class HermesChatProvider { + constructor(context, outputChannel, mode) { + this.context = context; + this.outputChannel = outputChannel; + this.mode = mode; + this.view = null; + this.panel = null; + this.cliRunner = new CliRunner(outputChannel); + this.conversationHistory = []; + this.isGenerating = false; + this.currentSessionId = crypto.randomUUID(); + this.permissionMode = 'default'; + this.currentModel = ''; + this.disposables = []; + } + + setPanel(panel) { + this.panel = panel; + this.initWebview(panel.webview); + panel.webview.onDidReceiveMessage((msg) => this.handleWebviewMessage(msg)); + panel.onDidDispose(() => this.dispose(), null, this.disposables); + } + + resolveWebviewView(webviewView) { + this.view = webviewView; + this.initWebview(webviewView.webview); + webviewView.webview.onDidReceiveMessage((msg) => this.handleWebviewMessage(msg)); + } + + initWebview(webview) { + webview.options = { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.file(path.join(this.context.extensionPath, 'webview')), + vscode.Uri.file(path.join(this.context.extensionPath, 'resources')), + ], + }; + webview.html = this.getHtml(webview); + } + + // ---------------------------------------------------------- + // FULL Message Handler + // ---------------------------------------------------------- + async handleWebviewMessage(msg) { + switch (msg.type) { + // === Core === + case 'ready': + this.postMessageToWebview({ type: 'init_response', version: '0.1.0' }); + this.sendAuthStatus(); + this.sendMcpServers(); + break; + case 'sendMessage': + await this.handleSendMessage(msg.content, msg.options); + break; + case 'stopGeneration': + this.stopGeneration(); + break; + case 'newConversation': + this.newConversation(); + break; + + // === Model === + case 'set_model': + this.currentModel = msg.model || ''; + this.postMessageToWebview({ type: 'set_model_response', model: this.currentModel }); + break; + case 'set_thinking_level': + this.postMessageToWebview({ type: 'set_thinking_level_response', level: msg.level }); + break; + + // === Permission === + case 'set_permission_mode': + this.permissionMode = msg.mode || 'default'; + this.postMessageToWebview({ type: 'set_permission_mode_response', mode: this.permissionMode }); + break; + case 'control_response': + break; + + // === Sessions === + case 'list_sessions': + this.postMessageToWebview({ type: 'list_sessions_response', sessions: sessionStore.listSessions() }); + break; + case 'get_session': + const msgs = sessionStore.loadMessages(msg.sessionId); + this.postMessageToWebview({ type: 'get_session_response', sessionId: msg.sessionId, messages: msgs }); + break; + case 'delete_session': + sessionStore.deleteSession(msg.sessionId); + this.postMessageToWebview({ type: 'delete_session_response', sessionId: msg.sessionId }); + break; + case 'rename_session': + sessionStore.saveSessionMeta(msg.sessionId, { title: msg.title }); + this.postMessageToWebview({ type: 'rename_session_response', sessionId: msg.sessionId }); + break; + case 'fork_conversation': + const forkedId = crypto.randomUUID(); + sessionStore.saveMessages(forkedId, this.conversationHistory); + sessionStore.saveSessionMeta(forkedId, { title: 'Forked', createdAt: Date.now() }); + this.postMessageToWebview({ type: 'fork_conversation_response', sessionId: forkedId }); + break; + + // === File Operations === + case 'open_file': + await this.handleOpenFile(msg.filePath, msg.line, msg.column); + break; + case 'open_diff': + await this.handleOpenDiff(msg.filePath, msg.original, msg.modified); + break; + case 'open_file_diffs': + if (msg.diffs) for (const d of msg.diffs) await this.handleOpenDiff(d.filePath, d.original, d.modified); + break; + case 'open_in_editor': + await this.handleOpenFile(msg.filePath); + break; + case 'list_files': + const files = await vscode.workspace.findFiles('**/*', '**/node_modules/**', 100); + this.postMessageToWebview({ type: 'list_files_response', files: files.map(f => vscode.workspace.asRelativePath(f)) }); + break; + case 'open_folder': + vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.file(msg.path)); + break; + case 'open_folder_in_new_window': + vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.file(msg.path), true); + break; + case 'open_url': + vscode.env.openExternal(vscode.Uri.parse(msg.url)); + break; + case 'open_config': + vscode.commands.executeCommand('workbench.action.openSettings', 'hermes'); + break; + case 'open_config_file': + const settingsPath = path.join(os.homedir(), '.hermes', 'config.yaml'); + try { + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(settingsPath)); + await vscode.window.showTextDocument(doc); + } catch { vscode.window.showErrorMessage('Settings file not found'); } + break; + + // === Edit Operations === + case 'applyEdit': + await this.handleApplyEdit(msg.filePath, msg.content); + break; + case 'acceptProposedDiff': + case 'rejectProposedDiff': + break; + + // === Git === + case 'check_git_status': + this.handleGitStatus(); + break; + case 'checkout_branch': + this.handleCheckoutBranch(msg.branch); + break; + case 'create_worktree': + this.handleCreateWorktree(msg.branch); + break; + + // === Terminal === + case 'open_terminal': + const term = vscode.window.createTerminal('Hermes'); + term.show(); + term.sendText(msg.command || ''); + this.postMessageToWebview({ type: 'open_terminal_response' }); + break; + case 'get_terminal_contents': + this.postMessageToWebview({ type: 'get_terminal_contents_response', content: '' }); + break; + case 'open_hermes_in_terminal': + vscode.commands.executeCommand('hermes.openInTerminal'); + break; + + // === Context === + case 'get_current_selection': + this.sendSelectionChanged(vscode.window.activeTextEditor); + break; + case 'get_context_usage': + this.postMessageToWebview({ type: 'get_context_usage_response', used: 0, total: 200000 }); + break; + + // === MCP === + case 'get_mcp_servers': + this.sendMcpServers(); + break; + case 'set_mcp_server_enabled': + this.postMessageToWebview({ type: 'set_mcp_server_enabled_response', name: msg.name }); + break; + case 'reconnect_mcp_server': + this.postMessageToWebview({ type: 'reconnect_mcp_server_response', name: msg.name }); + break; + case 'authenticate_mcp_server': + vscode.window.showInformationMessage(`MCP auth for ${msg.name}`); + break; + case 'mcp_toggle': + this.postMessageToWebview({ type: 'mcp_status', name: msg.name, enabled: msg.enabled }); + break; + + // === Auth === + case 'login': + this.handleLogin(); + break; + case 'logout': + this.handleLogout(); + break; + case 'get_auth_status': + this.sendAuthStatus(); + break; + + // === Settings === + case 'getSettings': + const cfg = vscode.workspace.getConfiguration('hermes'); + this.postMessageToWebview({ + type: 'getSettings', + settings: { + includeActiveFile: cfg.get('includeActiveFile', true), + autosave: cfg.get('autosave', true), + useCtrlEnterToSend: cfg.get('useCtrlEnterToSend', false), + permissionMode: this.permissionMode, + model: this.currentModel, + }, + }); + break; + case 'apply_settings': + this.postMessageToWebview({ type: 'apply_settings_response', success: true }); + break; + + // === Usage === + case 'request_usage_update': + this.postMessageToWebview({ type: 'request_usage_update_response', usage: {} }); + break; + + // === Misc === + case 'insertAtMention': + await this.handleInsertAtMention(); + break; + case 'show_notification': + if (msg.level === 'error') vscode.window.showErrorMessage(msg.message); + else if (msg.level === 'warning') vscode.window.showWarningMessage(msg.message); + else vscode.window.showInformationMessage(msg.message || msg.text || ''); + break; + case 'open_output_panel': + outputChannel.show(); + break; + case 'open_help': + vscode.env.openExternal(vscode.Uri.parse('https://github.com/NousResearch/hermes-agent')); + break; + case 'open_markdown_preview': + if (msg.filePath) { + const d = await vscode.workspace.openTextDocument(vscode.Uri.file(msg.filePath)); + await vscode.commands.executeCommand('markdown.showPreviewToSide', d.uri); + } + break; + case 'log_event': + this.outputChannel.appendLine(`[webview] ${msg.event || msg.message || ''}`); + break; + case 'dismiss_onboarding': + vscode.workspace.getConfiguration('hermes').update('hideOnboarding', true, true); + this.postMessageToWebview({ type: 'dismiss_onboarding_response' }); + break; + case 'generate_session_title': + const firstMsg = this.conversationHistory.find(m => m.role === 'user'); + const title = firstMsg?.content?.slice(0, 50) || 'New Chat'; + sessionStore.saveSessionMeta(this.currentSessionId, { title, createdAt: Date.now() }); + this.postMessageToWebview({ type: 'generate_session_title_response', title }); + break; + case 'exec': + try { + const result = execSync(msg.command, { cwd: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, timeout: 10000 }); + this.postMessageToWebview({ type: 'exec_response', stdout: result.toString(), exitCode: 0 }); + } catch (e) { + this.postMessageToWebview({ type: 'exec_response', stdout: e.stdout?.toString() || '', stderr: e.stderr?.toString() || e.message, exitCode: e.status || 1 }); + } + break; + } + } + + // ---------------------------------------------------------- + // Core: Send Message + // ---------------------------------------------------------- + async handleSendMessage(content, options = {}) { + if (this.isGenerating) return; + this.isGenerating = true; + this.postMessageToWebview({ type: 'generationStarted' }); + + // Use --resume for subsequent messages in the same session + const hasHistory = this.conversationHistory.length > 0; + const resumeId = hasHistory ? this.currentSessionId : undefined; + + let assistantText = ''; + try { + for await (const cliMsg of this.cliRunner.runQuery(content, { + model: options?.model || this.currentModel || undefined, + resumeSessionId: resumeId, + permissionMode: this.permissionMode !== 'default' ? this.permissionMode : undefined, + })) { + this.forwardCliMessage(cliMsg); + if (cliMsg.type === 'raw_text') { + assistantText += cliMsg.text; + } else if (cliMsg.type === 'result' && cliMsg.result && !assistantText) { + assistantText = cliMsg.result; + } + } + // Capture session ID from CLI if available + if (this.cliRunner.lastSessionId) { + this.currentSessionId = this.cliRunner.lastSessionId; + } + this.conversationHistory.push({ role: 'user', content }, { role: 'assistant', content: assistantText }); + sessionStore.saveMessages(this.currentSessionId, this.conversationHistory); + sessionStore.saveSessionMeta(this.currentSessionId, { updatedAt: Date.now() }); + } catch (err) { + this.outputChannel.appendLine(`Error: ${err.message}`); + this.postMessageToWebview({ type: 'error', content: err.message }); + } + + this.isGenerating = false; + this.postMessageToWebview({ type: 'generationComplete' }); + } + + forwardCliMessage(msg) { + const map = { + system: { type: 'systemInit' }, + stream_event: { type: 'streamToken' }, + assistant: { type: 'assistantMessage' }, + tool_progress: { type: 'toolProgress' }, + control_request: { type: 'tool_permission_request' }, + raw_text: { type: 'rawText' }, + }; + const mapping = map[msg.type]; + if (!mapping) { + if (msg.type === 'result') { + this.postMessageToWebview({ + type: 'result', subtype: msg.subtype, result: msg.result, + costUsd: msg.total_cost_usd, durationMs: msg.duration_ms, + numTurns: msg.num_turns, isError: msg.is_error, + }); + } + return; + } + this.postMessageToWebview({ ...msg, ...mapping }); + } + + // ---------------------------------------------------------- + // Session Management + // ---------------------------------------------------------- + newConversation() { + this.conversationHistory = []; + this.currentSessionId = crypto.randomUUID(); + sessionStore.saveSessionMeta(this.currentSessionId, { title: 'New Chat', createdAt: Date.now() }); + this.postMessageToWebview({ type: 'newConversation', sessionId: this.currentSessionId }); + } + + // ---------------------------------------------------------- + // File Operations + // ---------------------------------------------------------- + getActiveFileContext() { + const editor = vscode.window.activeTextEditor; + if (!editor) return null; + const doc = editor.document; + return { filePath: doc.fileName, language: doc.languageId, content: doc.getText(), selection: editor.selection.isEmpty ? null : doc.getText(editor.selection) }; + } + + sendSelectionChanged(editor, selection) { + if (!editor) return; + const sel = selection || editor.selection; + this.postMessageToWebview({ + type: 'selection_changed', + fileName: editor.document.fileName, + language: editor.document.languageId, + selection: sel && !sel.isEmpty ? editor.document.getText(sel) : null, + }); + } + + async handleOpenFile(filePath, line, column) { + try { + const doc = await vscode.workspace.openTextDocument(filePath); + const editor = await vscode.window.showTextDocument(doc, { preview: true }); + if (line) { + const pos = new vscode.Position(line - 1, (column || 1) - 1); + editor.selection = new vscode.Selection(pos, pos); + editor.revealRange(new vscode.Range(pos, pos)); + } + this.postMessageToWebview({ type: 'open_file_response', filePath }); + } catch (err) { + vscode.window.showErrorMessage(`Cannot open file: ${err.message}`); + } + } + + async handleOpenDiff(filePath, original, modified) { + try { + const name = path.basename(filePath || 'untitled'); + const originalUri = vscode.Uri.parse(`hermes-diff:///${name}.original`); + const modifiedUri = vscode.Uri.parse(`hermes-diff:///${name}.modified`); + if (diffProvider) { + diffProvider.setDocument(originalUri.toString(), original || ''); + diffProvider.setDocument(modifiedUri.toString(), modified || ''); + } + await vscode.commands.executeCommand('vscode.diff', originalUri, modifiedUri, `${name} (Hermes Diff)`); + this.postMessageToWebview({ type: 'open_diff_response', filePath }); + } catch (err) { + vscode.window.showErrorMessage(`Diff error: ${err.message}`); + } + } + + async handleApplyEdit(filePath, content) { + try { + const cfg = vscode.workspace.getConfiguration('hermes'); + if (cfg.get('autosave', true)) await vscode.workspace.saveAll(false); + + const uri = filePath ? vscode.Uri.file(filePath) : vscode.window.activeTextEditor?.document?.uri; + if (!uri) { vscode.window.showWarningMessage('No file to apply edit to'); return; } + + const edit = new vscode.WorkspaceEdit(); + try { + const doc = await vscode.workspace.openTextDocument(uri); + const fullRange = new vscode.Range(doc.lineAt(0).range.start, doc.lineAt(doc.lineCount - 1).range.end); + edit.replace(uri, fullRange, content); + } catch { + edit.createFile(uri, { ignoreIfExists: true }); + edit.insert(uri, new vscode.Position(0, 0), content); + } + await vscode.workspace.applyEdit(edit); + this.postMessageToWebview({ type: 'applyEdit', success: true, filePath: uri.fsPath }); + } catch (err) { + vscode.window.showErrorMessage(`Apply failed: ${err.message}`); + this.postMessageToWebview({ type: 'applyEdit', success: false, error: err.message }); + } + } + + // ---------------------------------------------------------- + // Git + // ---------------------------------------------------------- + async handleGitStatus() { + try { + const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const status = execSync('git status --porcelain', { cwd, timeout: 5000 }).toString(); + const branch = execSync('git branch --show-current', { cwd, timeout: 5000 }).toString().trim(); + this.postMessageToWebview({ type: 'check_git_status_response', status, branch, clean: !status.trim() }); + } catch (err) { + this.postMessageToWebview({ type: 'check_git_status_response', status: '', branch: '', clean: true, error: err.message }); + } + } + + async handleCheckoutBranch(branch) { + try { + const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + execSync(`git checkout ${branch}`, { cwd, timeout: 10000 }); + this.postMessageToWebview({ type: 'checkout_branch_response', branch, success: true }); + vscode.window.showInformationMessage(`Switched to branch ${branch}`); + } catch (err) { + this.postMessageToWebview({ type: 'checkout_branch_response', branch, success: false, error: err.message }); + } + } + + async handleCreateWorktree(branch) { + try { + const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const worktreePath = path.join(cwd, '..', `${path.basename(cwd)}-${branch}`); + execSync(`git worktree add "${worktreePath}" -b ${branch || 'new-branch'}`, { cwd, timeout: 10000 }); + this.postMessageToWebview({ type: 'create_worktree_response', path: worktreePath, success: true }); + const open = await vscode.window.showInformationMessage(`Worktree created at ${worktreePath}`, 'Open'); + if (open) vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.file(worktreePath), true); + } catch (err) { + this.postMessageToWebview({ type: 'create_worktree_response', success: false, error: err.message }); + } + } + + // ---------------------------------------------------------- + // Auth + // ---------------------------------------------------------- + sendAuthStatus() { + const hermesHome = path.join(os.homedir(), '.hermes'); + const envPath = path.join(hermesHome, '.env'); + const configPath = path.join(hermesHome, 'config.yaml'); + const hasEnv = fs.existsSync(envPath); + const hasConfig = fs.existsSync(configPath); + + this.postMessageToWebview({ + type: 'get_auth_status_response', + authenticated: hasEnv || hasConfig, + provider: 'multi', + model: this.currentModel || 'default', + }); + } + + async handleLogin() { + const key = await vscode.window.showInputBox({ + prompt: 'Enter your API key (saved to ~/.hermes/.env)', + password: true, + ignoreFocusOut: true, + }); + if (key) { + const hermesHome = path.join(os.homedir(), '.hermes'); + if (!fs.existsSync(hermesHome)) fs.mkdirSync(hermesHome, { recursive: true }); + const envPath = path.join(hermesHome, '.env'); + let envContent = ''; + if (fs.existsSync(envPath)) envContent = fs.readFileSync(envPath, 'utf8'); + if (!envContent.includes('OPENAI_API_KEY=')) { + envContent += `\nOPENAI_API_KEY=${key}`; + } else { + envContent = envContent.replace(/OPENAI_API_KEY=.*/, `OPENAI_API_KEY=${key}`); + } + fs.writeFileSync(envPath, envContent); + process.env.OPENAI_API_KEY = key; + this.postMessageToWebview({ type: 'login_response', success: true }); + vscode.window.showInformationMessage('API key saved to ~/.hermes/.env'); + } + } + + handleLogout() { + delete process.env.OPENAI_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + this.postMessageToWebview({ type: 'login_response', success: false, loggedOut: true }); + vscode.window.showInformationMessage('Logged out (cleared session keys)'); + } + + // ---------------------------------------------------------- + // MCP + // ---------------------------------------------------------- + sendMcpServers() { + const servers = getMcpServers(); + const serverList = Object.entries(servers).map(([name, config]) => ({ + name, command: config.command, enabled: true, + })); + this.postMessageToWebview({ type: 'get_mcp_servers_response', servers: serverList }); + } + + // ---------------------------------------------------------- + // @Mention + // ---------------------------------------------------------- + async handleInsertAtMention() { + const items = [ + { label: '$(file) File...', kind: vscode.QuickPickItemKind.Default, type: 'file' }, + { label: '$(symbol-method) Symbol...', kind: vscode.QuickPickItemKind.Default, type: 'symbol' }, + { label: '$(selection) Selection', kind: vscode.QuickPickItemKind.Default, type: 'selection' }, + { label: '$(git-branch) Git Branch', kind: vscode.QuickPickItemKind.Default, type: 'git' }, + ]; + const picked = await vscode.window.showQuickPick(items, { placeHolder: 'Insert reference' }); + if (!picked) return; + + if (picked.type === 'file') { + const files = await vscode.workspace.findFiles('**/*', '**/node_modules/**', 50); + const fileItems = files.map(f => ({ label: path.basename(f.fsPath), description: vscode.workspace.asRelativePath(f) })); + const fp = await vscode.window.showQuickPick(fileItems, { placeHolder: 'Select file' }); + if (fp) { + const fileContent = fs.readFileSync(files.find(f => vscode.workspace.asRelativePath(f) === fp.description)?.fsPath || '', 'utf8').slice(0, 5000); + this.postMessageToWebview({ type: 'insertMention', mention: `@${fp.description}`, content: fileContent }); + } + } else if (picked.type === 'selection') { + const sel = vscode.window.activeTextEditor?.selection; + if (sel && !sel.isEmpty) { + const text = vscode.window.activeTextEditor.document.getText(sel); + this.postMessageToWebview({ type: 'insertMention', mention: text }); + } + } else if (picked.type === 'git') { + try { + const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const branches = execSync('git branch -a --format=%(refname:short)', { cwd, timeout: 5000 }).toString().trim().split('\n'); + const bp = await vscode.window.showQuickPick(branches, { placeHolder: 'Select branch' }); + if (bp) this.postMessageToWebview({ type: 'insertMention', mention: `@branch:${bp}` }); + } catch {} + } + } + + // ---------------------------------------------------------- + // Stop / Post / Dispose + // ---------------------------------------------------------- + stopGeneration() { + this.cliRunner.stop(); + this.isGenerating = false; + this.postMessageToWebview({ type: 'generationComplete', stopped: true }); + } + + postMessageToWebview(msg) { + try { + this.panel?.webview?.postMessage(msg); + this.view?.webview?.postMessage(msg); + } catch {} + } + + dispose() { + this.cliRunner.stop(); + this.disposables.forEach(d => d.dispose()); + this.disposables = []; + this.view = null; + this.panel = null; + } + + // ---------------------------------------------------------- + // HTML + // ---------------------------------------------------------- + getHtml(webview) { + const nonce = getNonce(); + const cssUri = webview.asWebviewUri(vscode.Uri.file(path.join(this.context.extensionPath, 'webview', 'custom.css'))); + const jsUri = webview.asWebviewUri(vscode.Uri.file(path.join(this.context.extensionPath, 'webview', 'index.js'))); + const welcomeDark = webview.asWebviewUri(vscode.Uri.file(path.join(this.context.extensionPath, 'resources', 'welcome-art-dark.svg'))); + const welcomeLight = webview.asWebviewUri(vscode.Uri.file(path.join(this.context.extensionPath, 'resources', 'welcome-art-light.svg'))); + + return ` + + + + + + + + +
+
+ +
+ + + + +
+ +
+
+
+

Hermes Agent

+

Your self-improving AI coding partner. Ask questions, edit code, understand projects.

+
+
@ Mention files for context
+
/ Use slash commands
+
⌘Esc Focus / blur input
+
+
+
+ + + + + + + +
+ +
+ +
+ + +
+
+ +
+
+ + +`; + } +} + +// ============================================================ +// Diff Document Provider +// ============================================================ +class DiffDocumentProvider { + constructor() { + this.documents = new Map(); + } + + provideTextDocumentContent(uri) { + return this.documents.get(uri.toString()) || ''; + } + + setDocument(uriStr, content) { + this.documents.set(uriStr, content); + } +} + +let diffProvider; // Set during activate + +// ============================================================ +// Utils +// ============================================================ +function getNonce() { + const c = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let r = ''; + for (let i = 0; i < 32; i++) r += c.charAt(Math.floor(Math.random() * c.length)); + return r; +} + +async function checkHermesAvailable() { + return new Promise(resolve => { + const p = spawn('hermes', ['--version'], { stdio: 'pipe' }); + p.on('error', () => { + vscode.window.showWarningMessage('Hermes CLI not found. Install with: pip install hermes-agent'); + resolve(false); + }); + p.on('close', c => resolve(c === 0)); + }); +} + +module.exports = { activate, deactivate }; diff --git a/vscode-extension/hermes-vscode-0.1.0.vsix b/vscode-extension/hermes-vscode-0.1.0.vsix new file mode 100644 index 0000000000..0b9616ba72 Binary files /dev/null and b/vscode-extension/hermes-vscode-0.1.0.vsix differ diff --git a/vscode-extension/package.json b/vscode-extension/package.json new file mode 100644 index 0000000000..953b6798b8 --- /dev/null +++ b/vscode-extension/package.json @@ -0,0 +1,139 @@ +{ + "name": "hermes-vscode", + "displayName": "Hermes Agent", + "description": "Hermes AI Agent for VS Code: Self-improving AI coding assistant", + "version": "0.1.0", + "publisher": "hermes-agent", + "engines": { + "vscode": "^1.94.0" + }, + "categories": ["AI", "Chat"], + "keywords": ["hermes", "ai", "coding-assistant", "agent", "mcp"], + "activationEvents": ["onStartupFinished"], + "icon": "resources/hermes-logo.png", + "main": "./extension.js", + "contributes": { + "configuration": { + "title": "Hermes Agent", + "properties": { + "hermes.cliCommand": { + "type": "string", + "default": "hermes", + "description": "Command to invoke the Hermes CLI" + }, + "hermes.workingDirectory": { + "type": "string", + "default": "", + "description": "Working directory for CLI (defaults to workspace root)" + }, + "hermes.includeActiveFile": { + "type": "boolean", + "default": true, + "description": "Automatically include the active file as context" + }, + "hermes.preferredLocation": { + "type": "string", + "enum": ["sidebar", "panel"], + "enumDescriptions": ["Sidebar (Right)", "Panel (New Tab)"], + "default": "panel", + "description": "Where Hermes opens by default" + }, + "hermes.autosave": { + "type": "boolean", + "default": true, + "description": "Automatically save files before Hermes reads or writes them" + }, + "hermes.useCtrlEnterToSend": { + "type": "boolean", + "default": false, + "description": "Use Ctrl/Cmd+Enter to send prompts instead of just Enter" + }, + "hermes.environmentVariables": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Environment variable name" }, + "value": { "type": "string", "description": "Environment variable value" } + }, + "required": ["name", "value"] + }, + "default": [], + "description": "Environment variables to set when launching Hermes" + } + } + }, + "commands": [ + { + "command": "hermes.open", + "title": "Hermes: Open", + "icon": { "light": "resources/hermes-logo.svg", "dark": "resources/hermes-logo.svg" } + }, + { + "command": "hermes.openInTab", + "title": "Hermes: Open in New Tab", + "icon": { "light": "resources/hermes-logo.svg", "dark": "resources/hermes-logo.svg" } + }, + { + "command": "hermes.openInSidebar", + "title": "Hermes: Open in Side Bar", + "icon": { "light": "resources/hermes-logo.svg", "dark": "resources/hermes-logo.svg" } + }, + { + "command": "hermes.newConversation", + "title": "Hermes: New Conversation" + }, + { + "command": "hermes.focus", + "title": "Hermes: Focus input" + }, + { + "command": "hermes.stop", + "title": "Hermes: Stop generation" + } + ], + "keybindings": [ + { + "command": "hermes.focus", + "key": "cmd+escape", + "mac": "cmd+escape", + "win": "ctrl+escape", + "linux": "ctrl+escape", + "when": "editorTextFocus" + }, + { + "command": "hermes.openInTab", + "key": "cmd+shift+escape", + "mac": "cmd+shift+escape", + "win": "ctrl+shift+escape", + "linux": "ctrl+shift+escape" + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "hermes-sidebar", + "title": "Hermes Agent", + "icon": "resources/hermes-logo.svg" + } + ] + }, + "views": { + "hermes-sidebar": [ + { + "type": "webview", + "id": "hermes.chatView", + "name": "Hermes Agent" + } + ] + }, + "menus": { + "editor/title": [ + { + "command": "hermes.openInTab", + "group": "navigation" + } + ] + } + } +} diff --git a/vscode-extension/resources/hermes-logo.png b/vscode-extension/resources/hermes-logo.png new file mode 100644 index 0000000000..502cf956a2 Binary files /dev/null and b/vscode-extension/resources/hermes-logo.png differ diff --git a/vscode-extension/resources/hermes-logo.svg b/vscode-extension/resources/hermes-logo.svg new file mode 100644 index 0000000000..94001951ab --- /dev/null +++ b/vscode-extension/resources/hermes-logo.svg @@ -0,0 +1 @@ +Hermes \ No newline at end of file diff --git a/vscode-extension/resources/welcome-art-dark.svg b/vscode-extension/resources/welcome-art-dark.svg new file mode 100644 index 0000000000..7e4b63a1e6 --- /dev/null +++ b/vscode-extension/resources/welcome-art-dark.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vscode-extension/resources/welcome-art-light.svg b/vscode-extension/resources/welcome-art-light.svg new file mode 100644 index 0000000000..2965097298 --- /dev/null +++ b/vscode-extension/resources/welcome-art-light.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/vscode-extension/webview/custom.css b/vscode-extension/webview/custom.css new file mode 100644 index 0000000000..a8fec5f80c --- /dev/null +++ b/vscode-extension/webview/custom.css @@ -0,0 +1,1045 @@ +/* Hermes Agent - Custom Webview Styles */ +/* Complements the original index.css with layout for our specific HTML structure */ + +/* ============================================================ + Root & Layout + ============================================================ */ +html { + display: flex; + overscroll-behavior: none; + position: relative; + flex: 1; + height: 100%; +} + +body { + display: flex; + overscroll-behavior: none; + font-size: var(--vscode-chat-font-size, 13px); + font-family: var(--vscode-chat-font-family); + flex: 1; + max-width: 100%; + margin: 0; + padding: 0; +} + +#root { + display: flex; + flex-direction: column; + flex: 1; + max-width: 100%; + height: 100vh; + background: var(--vscode-sideBar-background, #1e1e1e); + color: var(--vscode-foreground, #cccccc); +} + +/* ============================================================ + CSS Variables (Hermes brand - blue) + ============================================================ */ +:root { + --hermes-blue: #4A90D9; + --hermes-blue-dark: #3A7BC8; + --hermes-ivory: #f0f4f8; + --hermes-slate: #0f1724; + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 12px; + --spacing-lg: 16px; + --spacing-xl: 20px; + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 12px; + --font-mono: var(--vscode-editor-font-family, 'Menlo', 'Monaco', 'Courier New', monospace); + --font-mono-size: var(--vscode-editor-font-size, 12px); +} + +/* ============================================================ + Welcome Screen + ============================================================ */ +.welcome-screen { + display: flex; + flex-direction: column; + flex: 1; + align-items: center; + justify-content: center; + padding: var(--spacing-xl); + text-align: center; +} + +.welcome-content { + max-width: 360px; +} + +.welcome-art { + width: 120px; + height: 120px; + margin: 0 auto var(--spacing-lg); +} + +.welcome-art img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.welcome-art-light { + display: none; +} + +.welcome-art-dark { + display: block; +} + +/* VS Code dark theme detection */ +@media (prefers-color-scheme: light) { + .welcome-art-dark { display: none; } + .welcome-art-light { display: block; } +} + +.welcome-title { + font-size: 1.5em; + font-weight: 600; + margin-bottom: var(--spacing-sm); + color: var(--hermes-blue); +} + +.welcome-subtitle { + font-size: 0.95em; + color: var(--vscode-descriptionForeground, #999); + margin-bottom: var(--spacing-xl); + line-height: 1.5; +} + +.welcome-hints { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.hint-item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + background: var(--vscode-editor-background, #252526); + border-radius: var(--radius-md); + font-size: 0.88em; + color: var(--vscode-descriptionForeground, #999); + text-align: left; +} + +.hint-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: var(--hermes-blue); + color: white; + border-radius: var(--radius-sm); + font-size: 0.75em; + font-weight: 600; + flex-shrink: 0; +} + +/* ============================================================ + Messages Container + ============================================================ */ +.messages-container { + display: flex; + flex-direction: column; + flex: 1; + overflow-y: auto; + padding: var(--spacing-md); + scroll-behavior: smooth; +} + +.messages-container::-webkit-scrollbar { + width: 6px; +} + +.messages-container::-webkit-scrollbar-track { + background: transparent; +} + +.messages-container::-webkit-scrollbar-thumb { + background: var(--vscode-scrollbarSlider-background, #555); + border-radius: 3px; +} + +.messages-container::-webkit-scrollbar-thumb:hover { + background: var(--vscode-scrollbarSlider-hoverBackground, #777); +} + +.messages { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + max-width: 100%; +} + +/* ============================================================ + Message Bubbles + ============================================================ */ +.message { + display: flex; + gap: var(--spacing-sm); + padding: var(--spacing-sm) 0; + animation: fadeIn 0.2s ease-out; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +.message-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 2px; +} + +.avatar-user { + background: var(--vscode-button-background, #0e639c); + color: var(--vscode-button-foreground, white); +} + +.avatar-assistant { + background: var(--hermes-blue); + color: white; +} + +.avatar-tool { + background: var(--vscode-textBlockQuote-background, #3c3c3c); + color: var(--vscode-descriptionForeground, #999); +} + +.avatar-error { + background: var(--vscode-inputValidation-errorBackground, #5a1d1d); + color: var(--vscode-errorForeground, #f48771); +} + +.message-body { + flex: 1; + min-width: 0; + overflow-wrap: break-word; +} + +.message-role { + font-size: 0.8em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; + color: var(--vscode-descriptionForeground, #999); +} + +.message-content { + font-size: var(--vscode-chat-font-size, 13px); + line-height: 1.6; + color: var(--vscode-foreground, #cccccc); +} + +.message-content p { + margin: 0 0 var(--spacing-sm) 0; +} + +.message-content p:last-child { + margin-bottom: 0; +} + +.message-content .chat-h { + margin: var(--spacing-sm) 0 var(--spacing-xs) 0; + font-weight: 600; + color: var(--vscode-foreground); +} + +.message-content .chat-h2 { font-size: 1.2em; } +.message-content .chat-h3 { font-size: 1.1em; } +.message-content .chat-h4 { font-size: 1em; } + +.message-content ul, .message-content ol { + margin: var(--spacing-xs) 0; + padding-left: var(--spacing-lg); +} + +.message-content li { + margin: 2px 0; +} + +.message-content .inline-code { + background: var(--vscode-textCodeBlock-background, #2d2d2d); + padding: 1px 5px; + border-radius: 3px; + font-family: var(--font-mono); + font-size: 0.9em; +} + +.message-content .chat-link { + color: var(--vscode-textLink-foreground, #3794ff); + text-decoration: none; +} + +.message-content .chat-link:hover { + text-decoration: underline; +} + +/* User message - subtle background */ +.message-user .message-content { + background: var(--vscode-input-background, #3c3c3c); + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-lg); + border-top-left-radius: var(--radius-sm); +} + +/* ============================================================ + Code Blocks + ============================================================ */ +.code-block { + margin: var(--spacing-sm) 0; + border-radius: var(--radius-md); + overflow: hidden; + border: 1px solid var(--vscode-panel-border, #3c3c3c); + background: var(--vscode-textCodeBlock-background, #1e1e1e); +} + +.code-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px var(--spacing-sm); + background: var(--vscode-editorGroupHeader-tabsBackground, #252526); + border-bottom: 1px solid var(--vscode-panel-border, #3c3c3c); +} + +.code-lang { + font-size: 0.75em; + font-family: var(--font-mono); + color: var(--vscode-descriptionForeground, #999); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.code-actions { + display: flex; + gap: 4px; +} + +.code-btn { + background: var(--vscode-button-background, #0e639c); + color: var(--vscode-button-foreground, white); + border: none; + border-radius: var(--radius-sm); + padding: 2px 8px; + font-size: 0.72em; + cursor: pointer; + font-family: inherit; + transition: background 0.15s; +} + +.code-btn:hover { + background: var(--vscode-button-hoverBackground, #1177bb); +} + +.code-block pre { + margin: 0; + padding: var(--spacing-sm); + overflow-x: auto; + font-family: var(--font-mono); + font-size: var(--font-mono-size); + line-height: 1.5; +} + +.code-block code { + font-family: inherit; + font-size: inherit; +} + +/* ============================================================ + Streaming Cursor + ============================================================ */ +.streaming-cursor { + display: inline-block; + width: 2px; + height: 1.1em; + background: var(--hermes-blue); + margin-left: 2px; + vertical-align: text-bottom; + animation: cursorBlink 0.8s ease-in-out infinite; +} + +@keyframes cursorBlink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* ============================================================ + Tool Messages + ============================================================ */ +.message-tool { + opacity: 0.8; +} + +.tool-header { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.tool-name { + font-family: var(--font-mono); + font-size: 0.85em; + font-weight: 500; + color: var(--vscode-foreground); +} + +.tool-status { + font-size: 0.75em; + color: var(--vscode-descriptionForeground); + padding: 1px 6px; + background: var(--vscode-textBlockQuote-background, #3c3c3c); + border-radius: 10px; +} + +/* ============================================================ + Tool Progress + ============================================================ */ +.tool-progress { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-xs) var(--spacing-md); + background: var(--vscode-editor-background, #252526); + border-top: 1px solid var(--vscode-panel-border, #3c3c3c); +} + +.tool-progress-bar { + flex: 1; + height: 2px; + background: var(--vscode-progressBar-background, #3c3c3c); + border-radius: 1px; + overflow: hidden; +} + +.tool-progress-fill { + height: 100%; + width: 40%; + background: var(--hermes-blue); + border-radius: 1px; + animation: progressSlide 1.5s ease-in-out infinite; +} + +@keyframes progressSlide { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(350%); } +} + +.tool-progress-text { + font-size: 0.78em; + color: var(--vscode-descriptionForeground, #999); + font-family: var(--font-mono); + white-space: nowrap; +} + +/* ============================================================ + Error Messages + ============================================================ */ +.message-error .message-content { + background: var(--vscode-inputValidation-errorBackground, #5a1d1d); + border: 1px solid var(--vscode-inputValidation-errorBorder, #f48771); + border-radius: var(--radius-md); + padding: var(--spacing-sm) var(--spacing-md); + color: var(--vscode-errorForeground, #f48771); +} + +/* ============================================================ + Result Meta + ============================================================ */ +.message-result { + padding: var(--spacing-xs) var(--spacing-sm); +} + +.result-meta { + font-size: 0.75em; + color: var(--vscode-descriptionForeground, #999); + text-align: center; + padding: 4px; +} + +/* ============================================================ + Input Area + ============================================================ */ +.input-area { + border-top: 1px solid var(--vscode-panel-border, #3c3c3c); + padding: var(--spacing-md); + background: var(--vscode-sideBar-background, #1e1e1e); +} + +.context-badge { + display: flex; + align-items: center; + gap: var(--spacing-xs); + padding: 2px var(--spacing-sm); + margin-bottom: var(--spacing-xs); + background: var(--vscode-badge-background, #4d4d4d); + color: var(--vscode-badge-foreground, white); + border-radius: 12px; + font-size: 0.78em; + width: fit-content; +} + +.context-badge .codicon { + font-size: 12px; +} + +.context-dismiss { + background: none; + border: none; + color: var(--vscode-badge-foreground, white); + cursor: pointer; + padding: 0 2px; + font-size: 0.9em; + opacity: 0.7; +} + +.context-dismiss:hover { + opacity: 1; +} + +.input-wrapper { + position: relative; + display: flex; + align-items: flex-end; + gap: var(--spacing-xs); +} + +#user-input { + flex: 1; + background: var(--vscode-input-background, #3c3c3c); + color: var(--vscode-input-foreground, #cccccc); + border: 1px solid var(--vscode-input-border, #3c3c3c); + border-radius: var(--radius-lg); + padding: 10px 44px 10px 14px; + resize: none; + font-family: var(--vscode-chat-font-family, inherit); + font-size: var(--vscode-chat-font-size, 13px); + line-height: 1.5; + outline: none; + min-height: 40px; + max-height: 200px; + transition: border-color 0.15s; +} + +#user-input:focus { + border-color: var(--vscode-focusBorder, #007fd4); +} + +#user-input::placeholder { + color: var(--vscode-input-placeholderForeground, #717171); +} + +.input-actions { + position: absolute; + right: 4px; + bottom: 4px; + display: flex; + gap: 4px; +} + +.icon-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--hermes-blue); + color: white; + border: none; + border-radius: 50%; + cursor: pointer; + transition: background 0.15s, transform 0.1s; +} + +.icon-btn:hover { + background: var(--hermes-blue-dark); + transform: scale(1.05); +} + +.icon-btn:active { + transform: scale(0.95); +} + +.stop-btn { + background: var(--vscode-errorForeground, #f48771); +} + +.stop-btn:hover { + background: #e55c5c; +} + +.input-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: var(--spacing-xs); + padding: 0 2px; +} + +.input-hint { + font-size: 0.72em; + color: var(--vscode-descriptionForeground, #717171); +} + +.text-btn { + background: none; + border: none; + color: var(--vscode-descriptionForeground, #999); + font-size: 0.78em; + cursor: pointer; + padding: 2px 6px; + border-radius: var(--radius-sm); + transition: color 0.15s, background 0.15s; +} + +.text-btn:hover { + color: var(--vscode-foreground); + background: var(--vscode-list-hoverBackground, #2a2d2e); +} + +/* ============================================================ + Scrollbar Styling + ============================================================ */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--vscode-scrollbarSlider-background, #555); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--vscode-scrollbarSlider-hoverBackground, #777); +} + +/* ============================================================ + Selection & Focus + ============================================================ */ +::selection { + background: var(--vscode-editor-selectionBackground, #264f78); +} + +:focus-visible { + outline: 1px solid var(--vscode-focusBorder, #007fd4); + outline-offset: -1px; +} + +/* ============================================================ + VS Code Theme Colors Override (Hermes brand) + ============================================================ */ +.vscode-dark #root { + background: var(--vscode-sideBar-background, #1e1e1e); +} + +.vscode-light #root { + background: var(--vscode-sideBar-background, #f3f3f3); +} + +.vscode-high-contrast #root { + background: var(--vscode-sideBar-background, #000000); +} + +/* ============================================================ + Toolbar (Top Bar) + ============================================================ */ +.toolbar { + display: flex; + align-items: center; + gap: 2px; + padding: 4px var(--spacing-sm); + background: var(--vscode-sideBarSectionHeader-background, #252526); + border-bottom: 1px solid var(--vscode-panel-border, #3c3c3c); + min-height: 32px; + flex-shrink: 0; +} + +.toolbar-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + background: none; + border: none; + color: var(--vscode-foreground, #cccccc); + font-size: 0.82em; + font-family: inherit; + cursor: pointer; + border-radius: var(--radius-sm); + transition: background 0.15s, color 0.15s; + white-space: nowrap; +} + +.toolbar-btn:hover { + background: var(--vscode-toolbar-hoverBackground, #2a2d2e); + color: var(--vscode-list-hoverForeground, #ffffff); +} + +.toolbar-btn:active { + background: var(--vscode-toolbar-activeBackground, #383a3b); +} + +.toolbar-btn svg { + opacity: 0.85; +} + +.toolbar-spacer { + flex: 1; +} + +/* ============================================================ + Permission Bar + ============================================================ */ +.permission-bar { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + background: var(--vscode-inputValidation-warningBackground, #352a05); + border-top: 1px solid var(--vscode-inputValidation-warningBorder, #cca700); + font-size: 0.85em; + color: var(--vscode-foreground); + flex-shrink: 0; +} + +.permission-text { + flex: 1; + font-size: 0.85em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.perm-btn { + border: none; + border-radius: var(--radius-sm); + padding: 3px 14px; + font-size: 0.82em; + font-family: inherit; + cursor: pointer; + transition: background 0.15s, transform 0.1s; + font-weight: 600; +} + +.perm-allow { + background: var(--vscode-button-background, #0e639c); + color: var(--vscode-button-foreground, white); +} + +.perm-allow:hover { + background: var(--vscode-button-hoverBackground, #1177bb); +} + +.perm-deny { + background: var(--vscode-button-secondaryBackground, #3a3d41); + color: var(--vscode-button-secondaryForeground, white); +} + +.perm-deny:hover { + background: var(--vscode-button-secondaryHoverBackground, #45494e); +} + +/* ============================================================ + Sessions Panel (Overlay) + ============================================================ */ +.sessions-panel { + position: fixed; + top: 0; + right: 0; + width: 280px; + height: 100vh; + background: var(--vscode-sideBar-background, #1e1e1e); + border-left: 1px solid var(--vscode-panel-border, #3c3c3c); + z-index: 1000; + display: flex; + flex-direction: column; + box-shadow: -4px 0 16px rgba(0, 0, 0, 0.3); + animation: slideInRight 0.15s ease-out; +} + +@keyframes slideInRight { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +.sp-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-md); + font-size: 0.92em; + font-weight: 600; + color: var(--vscode-foreground); + border-bottom: 1px solid var(--vscode-panel-border, #3c3c3c); +} + +.sp-close { + background: none; + border: none; + color: var(--vscode-descriptionForeground, #999); + font-size: 1.3em; + cursor: pointer; + padding: 0 4px; + line-height: 1; + transition: color 0.15s; +} + +.sp-close:hover { + color: var(--vscode-foreground); +} + +.sp-list { + flex: 1; + overflow-y: auto; + padding: var(--spacing-xs); +} + +.sp-item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.1s; + position: relative; +} + +.sp-item:hover { + background: var(--vscode-list-hoverBackground, #2a2d2e); +} + +.sp-title { + flex: 1; + font-size: 0.88em; + color: var(--vscode-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sp-date { + font-size: 0.72em; + color: var(--vscode-descriptionForeground, #999); + flex-shrink: 0; +} + +.sp-delete { + background: none; + border: none; + color: var(--vscode-descriptionForeground, #666); + font-size: 1.1em; + cursor: pointer; + padding: 0 4px; + opacity: 0; + transition: opacity 0.1s, color 0.15s; + flex-shrink: 0; +} + +.sp-item:hover .sp-delete { + opacity: 1; +} + +.sp-delete:hover { + color: var(--vscode-errorForeground, #f48771); +} + +.sp-empty { + padding: var(--spacing-xl); + text-align: center; + font-size: 0.88em; + color: var(--vscode-descriptionForeground, #999); +} + +/* ============================================================ + Model Picker (Dropdown) + ============================================================ */ +.model-picker { + position: fixed; + top: 36px; + left: var(--spacing-sm); + min-width: 200px; + background: var(--vscode-editorWidget-background, #252526); + border: 1px solid var(--vscode-panel-border, #3c3c3c); + border-radius: var(--radius-md); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + z-index: 999; + overflow: hidden; + animation: fadeIn 0.12s ease-out; +} + +.mp-header { + padding: var(--spacing-sm) var(--spacing-md); + font-size: 0.75em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--vscode-descriptionForeground, #999); + border-bottom: 1px solid var(--vscode-panel-border, #3c3c3c); +} + +.mp-item { + padding: var(--spacing-sm) var(--spacing-md); + font-size: 0.88em; + color: var(--vscode-foreground, #cccccc); + cursor: pointer; + transition: background 0.1s; +} + +.mp-item:hover { + background: var(--vscode-list-hoverBackground, #2a2d2e); +} + +.mp-item.active { + color: var(--hermes-blue); + font-weight: 600; +} + +.mp-item.active::before { + content: '\2713'; + margin-right: var(--spacing-sm); + font-size: 0.85em; +} + +/* ============================================================ + Input Footer (Right side) + ============================================================ */ +.input-footer-right { + display: flex; + align-items: center; + gap: 2px; +} + +/* ============================================================ + Context File Icon + ============================================================ */ +.context-file-icon { + font-size: 12px; + line-height: 1; +} + +/* ============================================================ + MCP Server Badge & Panel + ============================================================ */ +.mcp-badge { + font-size: 0.7em; + background: var(--hermes-blue); + color: white; + border-radius: 8px; + padding: 0 5px; + min-width: 14px; + text-align: center; + line-height: 1.4; +} + +.mcp-panel { + position: fixed; + top: 0; + right: 0; + width: 300px; + height: 100vh; + background: var(--vscode-sideBar-background, #1e1e1e); + border-left: 1px solid var(--vscode-panel-border, #3c3c3c); + z-index: 1000; + display: flex; + flex-direction: column; + box-shadow: -4px 0 16px rgba(0, 0, 0, 0.3); + animation: slideInRight 0.15s ease-out; +} + +.mcp-item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + border-bottom: 1px solid var(--vscode-panel-border, #3c3c3c); +} + +.mcp-item:last-child { + border-bottom: none; +} + +.mcp-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.mcp-on { + background: #4ec9b0; + box-shadow: 0 0 4px rgba(78, 201, 176, 0.5); +} + +.mcp-off { + background: var(--vscode-descriptionForeground, #666); +} + +.mcp-info { + flex: 1; + min-width: 0; +} + +.mcp-name { + font-size: 0.88em; + color: var(--vscode-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mcp-status-text { + font-size: 0.72em; + color: var(--vscode-descriptionForeground, #999); + text-transform: capitalize; +} + +.mcp-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.mcp-btn { + border: none; + border-radius: var(--radius-sm); + padding: 2px 8px; + font-size: 0.72em; + cursor: pointer; + font-family: inherit; + transition: background 0.15s; + background: var(--vscode-button-secondaryBackground, #3a3d41); + color: var(--vscode-button-secondaryForeground, white); +} + +.mcp-btn:hover { + background: var(--vscode-button-secondaryHoverBackground, #45494e); +} + +.mcp-enable { + background: var(--vscode-button-background, #0e639c); +} + +.mcp-enable:hover { + background: var(--vscode-button-hoverBackground, #1177bb); +} diff --git a/vscode-extension/webview/index.js b/vscode-extension/webview/index.js new file mode 100644 index 0000000000..4cc9e8a2c4 --- /dev/null +++ b/vscode-extension/webview/index.js @@ -0,0 +1,543 @@ +// @ts-check +// Hermes Agent - Webview Script (Full Feature Parity) +const vscode = acquireVsCodeApi(); + +const S = { + isGenerating: false, + streamingText: '', + currentAssistantEl: null, + messages: [], + settings: {}, + activeFile: null, + currentModel: 'Default', + permissionMode: 'default', + sessionId: null, + pendingPermission: null, + mcpServers: /** @type {Array<{name:string,status:string}>} */ ([]), +}; + +const $ = (id) => document.getElementById(id); +const $messages = $('messages'); +const $messagesContainer = $('messages-container'); +const $welcomeScreen = $('welcome-screen'); +const $userInput = $('user-input'); +const $sendBtn = $('send-btn'); +const $stopBtn = $('stop-btn'); +const $newChatBtn = $('new-chat-btn'); +const $newChatBtnTop = $('new-chat-btn-top'); +const $toolProgress = $('tool-progress'); +const $toolProgressText = $toolProgress?.querySelector('.tool-progress-text'); +const $contextBadge = $('context-badge'); +const $contextFilename = $('context-filename'); +const $contextDismiss = $('context-dismiss'); +const $modelBtn = $('model-btn'); +const $modelLabel = $('model-label'); +const $sessionsBtn = $('sessions-btn'); +const $settingsBtn = $('settings-btn'); +const $modeBtn = $('mode-btn'); +const $permissionBar = $('permission-bar'); +const $permAllow = $('perm-allow'); +const $permDeny = $('perm-deny'); + +// ============================================================ +// Markdown +// ============================================================ +function md(text) { + if (!text) return ''; + let h = esc(text); + h = h.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => + `
${lang || 'text'}
${code}
`); + h = h.replace(/`([^`]+)`/g, '$1'); + h = h.replace(/\*\*(.+?)\*\*/g, '$1'); + h = h.replace(/(?$1'); + h = h.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + h = h.replace(/^### (.+)$/gm, '

$1

'); + h = h.replace(/^## (.+)$/gm, '

$1

'); + h = h.replace(/^# (.+)$/gm, '

$1

'); + h = h.replace(/^- (.+)$/gm, '
  • $1
  • '); + h = h.replace(/(
  • [\s\S]*?<\/li>)/g, ''); + h = h.replace(/<\/ul>\s*