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 @@
+
\ 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) =>
+ ``);
+ 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*/g, '');
+ h = h.replace(/\n\n/g, '');
+ h = `
${h}
`;
+ h = h.replace(/<\/p>/g, '');
+ h = h.replace(/\n/g, '
');
+ return h;
+}
+function esc(t) { return t.replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m])); }
+window.copyCode = (btn) => { const c = btn.closest('.code-block')?.querySelector('code')?.textContent || ''; navigator.clipboard.writeText(c).then(() => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 2000); }); };
+window.applyCode = (btn) => { const c = btn.closest('.code-block')?.querySelector('code')?.textContent || ''; vscode.postMessage({ type: 'applyEdit', content: c, filePath: '' }); };
+window.openUrl = (url) => { vscode.postMessage({ type: 'open_url', url }); };
+
+// ============================================================
+// Messages
+// ============================================================
+function addUserMessage(content) {
+ hideWelcome();
+ const el = document.createElement('div');
+ el.className = 'message message-user';
+ el.innerHTML = `
`;
+ $messages.appendChild(el);
+ S.messages.push({ role: 'user', content });
+ scrollBottom();
+}
+
+function createAssistantEl() {
+ hideWelcome();
+ const el = document.createElement('div');
+ el.className = 'message message-assistant';
+ el.innerHTML = ``;
+ $messages.appendChild(el);
+ scrollBottom();
+ return el;
+}
+
+function updateAssistant(el, text) {
+ const c = el?.querySelector('.message-content');
+ if (!c) return;
+ c.innerHTML = md(text) + '';
+ scrollBottom();
+}
+
+function finalizeAssistant(el, text) {
+ const c = el?.querySelector('.message-content');
+ if (!c) return;
+ c.innerHTML = md(text);
+ scrollBottom();
+}
+
+function addToolMessage(name, status) {
+ const el = document.createElement('div');
+ el.className = 'message message-tool';
+ el.innerHTML = ``;
+ $messages.appendChild(el);
+ scrollBottom();
+}
+
+function addErrorMessage(content) {
+ const el = document.createElement('div');
+ el.className = 'message message-error';
+ el.innerHTML = ``;
+ $messages.appendChild(el);
+ scrollBottom();
+}
+
+function addResultMessage(cost, duration, turns) {
+ const parts = [];
+ if (cost) parts.push(`Cost: $${cost.toFixed(4)}`);
+ if (duration) parts.push(`Time: ${(duration / 1000).toFixed(1)}s`);
+ if (turns) parts.push(`Turns: ${turns}`);
+ if (!parts.length) return;
+ const el = document.createElement('div');
+ el.className = 'message message-result';
+ el.innerHTML = `${parts.join(' · ')}
`;
+ $messages.appendChild(el);
+ scrollBottom();
+}
+
+// ============================================================
+// UI Helpers
+// ============================================================
+function hideWelcome() { if ($welcomeScreen) $welcomeScreen.style.display = 'none'; if ($messagesContainer) $messagesContainer.style.display = 'flex'; }
+function showWelcome() { if ($welcomeScreen) $welcomeScreen.style.display = 'flex'; if ($messagesContainer) $messagesContainer.style.display = 'none'; }
+function scrollBottom() { requestAnimationFrame(() => { if ($messagesContainer) $messagesContainer.scrollTop = $messagesContainer.scrollHeight; }); }
+function showStop() { $sendBtn.style.display = 'none'; $stopBtn.style.display = 'flex'; }
+function showSend() { $sendBtn.style.display = 'flex'; $stopBtn.style.display = 'none'; }
+function showToolProgress(name, elapsed) { if (!$toolProgress) return; $toolProgress.style.display = 'flex'; if ($toolProgressText) $toolProgressText.textContent = `${name || 'Working'} ${elapsed ? elapsed.toFixed(1) + 's' : '...'}`; }
+function hideToolProgress() { if ($toolProgress) $toolProgress.style.display = 'none'; }
+function autoResize() { if (!$userInput) return; $userInput.style.height = 'auto'; $userInput.style.height = Math.min($userInput.scrollHeight, 200) + 'px'; }
+
+function showPermissionBar(request) {
+ if (!$permissionBar) return;
+ $permissionBar.style.display = 'flex';
+ const text = $permissionBar.querySelector('.permission-text');
+ if (text) text.textContent = `${request?.tool_name || 'Tool'} wants to ${request?.input ? JSON.stringify(request.input).slice(0, 80) : 'execute'}`;
+ S.pendingPermission = request;
+}
+
+function hidePermissionBar() { if ($permissionBar) $permissionBar.style.display = 'none'; S.pendingPermission = null; }
+
+// ============================================================
+// Core
+// ============================================================
+function sendMessage() {
+ const content = $userInput?.value?.trim();
+ if (!content || S.isGenerating) return;
+ $userInput.value = '';
+ autoResize();
+ addUserMessage(content);
+ S.streamingText = '';
+ S.currentAssistantEl = createAssistantEl();
+ vscode.postMessage({ type: 'sendMessage', content, options: { includeFile: true } });
+}
+
+function stopGeneration() {
+ vscode.postMessage({ type: 'stopGeneration' });
+ S.isGenerating = false;
+ showSend();
+ hideToolProgress();
+ hidePermissionBar();
+ if (S.currentAssistantEl) { finalizeAssistant(S.currentAssistantEl, S.streamingText); S.currentAssistantEl = null; }
+}
+
+function newConversation() {
+ S.messages = [];
+ S.streamingText = '';
+ S.currentAssistantEl = null;
+ if ($messages) $messages.innerHTML = '';
+ showWelcome();
+ showSend();
+ hideToolProgress();
+ hidePermissionBar();
+ vscode.postMessage({ type: 'newConversation' });
+ vscode.postMessage({ type: 'generate_session_title' });
+}
+
+// ============================================================
+// Handlers
+// ============================================================
+function handleMsg(msg) {
+ switch (msg.type) {
+ case 'init_response': S.settings = msg; break;
+ case 'generationStarted': S.isGenerating = true; showStop(); hideToolProgress(); break;
+ case 'systemInit':
+ if (msg.mcp_servers) { S.mcpServers = msg.mcp_servers; updateMcpBadge(); }
+ if (msg.model) { S.currentModel = msg.model; if ($modelLabel) $modelLabel.textContent = msg.model; }
+ break;
+ case 'streamToken': handleStreamToken(msg.event); break;
+ case 'assistantMessage': handleAssistantMsg(msg.message); break;
+ case 'toolProgress': showToolProgress(msg.tool_name || msg.toolName, msg.elapsed_time_seconds || msg.elapsed); break;
+ case 'tool_permission_request':
+ showPermissionBar(msg.request || msg);
+ vscode.postMessage({ type: 'control_response', request_id: msg.request_id || msg.requestId, response: { approved: true } });
+ break;
+ case 'result':
+ hideToolProgress();
+ hidePermissionBar();
+ if (msg.costUsd || msg.durationMs || msg.numTurns) addResultMessage(msg.costUsd, msg.durationMs, msg.numTurns);
+ if (msg.isError && msg.result) {
+ addErrorMessage(msg.result);
+ if (S.currentAssistantEl) { S.currentAssistantEl.remove(); S.currentAssistantEl = null; }
+ } else if (msg.result && !S.streamingText) {
+ S.streamingText = msg.result;
+ if (S.currentAssistantEl) finalizeAssistant(S.currentAssistantEl, S.streamingText);
+ else { const el = createAssistantEl(); finalizeAssistant(el, S.streamingText); }
+ } else if (S.currentAssistantEl) { finalizeAssistant(S.currentAssistantEl, S.streamingText); S.currentAssistantEl = null; }
+ break;
+ case 'generationComplete':
+ S.isGenerating = false; showSend(); hideToolProgress(); hidePermissionBar();
+ if (S.currentAssistantEl) { finalizeAssistant(S.currentAssistantEl, S.streamingText); S.currentAssistantEl = null; }
+ vscode.postMessage({ type: 'generate_session_title' });
+ break;
+ case 'error':
+ S.isGenerating = false; showSend(); hideToolProgress(); hidePermissionBar();
+ addErrorMessage(msg.content || msg.message || 'Unknown error');
+ if (S.currentAssistantEl) { S.currentAssistantEl.remove(); S.currentAssistantEl = null; }
+ break;
+ case 'rawText':
+ S.streamingText += msg.text;
+ if (S.currentAssistantEl) updateAssistant(S.currentAssistantEl, S.streamingText);
+ break;
+ case 'newConversation':
+ S.messages = []; S.streamingText = ''; S.currentAssistantEl = null;
+ if ($messages) $messages.innerHTML = '';
+ showWelcome(); showSend(); hideToolProgress(); hidePermissionBar();
+ S.sessionId = msg.sessionId;
+ break;
+ case 'focusInput': $userInput?.focus(); break;
+ case 'blurInput': $userInput?.blur(); break;
+
+ // Model
+ case 'get_auth_status_response':
+ if (msg.model) { S.currentModel = msg.model; if ($modelLabel) $modelLabel.textContent = msg.model; }
+ break;
+ case 'set_model_response':
+ S.currentModel = msg.model;
+ if ($modelLabel) $modelLabel.textContent = msg.model || 'Default';
+ break;
+
+ // Permission mode
+ case 'set_permission_mode_response':
+ S.permissionMode = msg.mode;
+ if ($modeBtn) $modeBtn.textContent = msg.mode === 'default' ? 'Default' : msg.mode === 'plan' ? 'Plan' : msg.mode === 'acceptEdits' ? 'Accept Edits' : msg.mode;
+ break;
+
+ // Sessions
+ case 'list_sessions_response':
+ showSessionsPanel(msg.sessions || []);
+ break;
+ case 'get_session_response':
+ if (msg.messages?.length) {
+ if ($messages) $messages.innerHTML = '';
+ showWelcome(); showSend();
+ for (const m of msg.messages) {
+ if (m.role === 'user') addUserMessage(m.content);
+ else if (m.role === 'assistant') {
+ const el = createAssistantEl();
+ finalizeAssistant(el, m.content);
+ }
+ }
+ S.messages = msg.messages;
+ S.sessionId = msg.sessionId;
+ }
+ break;
+
+ // Selection
+ case 'selection_changed':
+ if (msg.fileName && $contextBadge && $contextFilename) {
+ $contextFilename.textContent = msg.fileName.split('/').pop();
+ $contextBadge.style.display = 'flex';
+ S.activeFile = msg;
+ }
+ break;
+
+ case 'insertMention':
+ if ($userInput && msg.mention) { $userInput.value += msg.mention + ' '; $userInput.focus(); autoResize(); }
+ break;
+
+ case 'getSettings':
+ S.settings = msg.settings || {};
+ if (S.settings.model && $modelLabel) $modelLabel.textContent = S.settings.model;
+ if (S.settings.permissionMode && $modeBtn) $modeBtn.textContent = S.settings.permissionMode;
+ break;
+
+ case 'generate_session_title_response':
+ document.title = msg.title ? `Hermes - ${msg.title}` : 'Hermes Agent';
+ break;
+
+ case 'applyEdit':
+ break;
+
+ case 'get_mcp_servers_response':
+ S.mcpServers = msg.servers || [];
+ updateMcpBadge();
+ break;
+
+ case 'check_git_status_response':
+ break;
+ }
+}
+
+function handleStreamToken(event) {
+ if (!event) return;
+ if (event.type === 'content_block_delta') {
+ const d = event.delta;
+ if (d?.type === 'text_delta' && d.text) {
+ S.streamingText += d.text;
+ if (S.currentAssistantEl) updateAssistant(S.currentAssistantEl, S.streamingText);
+ }
+ } else if (event.type === 'content_block_start') {
+ if (event.content_block?.type === 'tool_use') addToolMessage(event.content_block.name, 'running');
+ } else if (event.type === 'content_block_stop') {
+ hideToolProgress();
+ }
+}
+
+function handleAssistantMsg(message) {
+ if (!message?.content) return;
+ for (const block of message.content) {
+ if (block.type === 'tool_use') addToolMessage(block.name, 'complete');
+ }
+}
+
+// ============================================================
+// Sessions Panel (overlay)
+// ============================================================
+function showSessionsPanel(sessions) {
+ let panel = document.getElementById('sessions-panel');
+ if (panel) { panel.remove(); return; }
+ panel = document.createElement('div');
+ panel.id = 'sessions-panel';
+ panel.className = 'sessions-panel';
+ panel.innerHTML = `
+
+
+ ${sessions.length ? sessions.map(s => `
+
+
${esc(s.title || 'Untitled')}
+
${s.createdAt ? new Date(s.createdAt).toLocaleDateString() : ''}
+
+
+ `).join('') : '
No past conversations
'}
+
`;
+ document.body.appendChild(panel);
+ panel.querySelectorAll('.sp-item').forEach(el => {
+ el.addEventListener('click', (e) => {
+ if (e.target.classList.contains('sp-delete')) {
+ vscode.postMessage({ type: 'delete_session', sessionId: e.target.dataset.id });
+ e.target.closest('.sp-item')?.remove();
+ return;
+ }
+ vscode.postMessage({ type: 'get_session', sessionId: el.dataset.id });
+ panel.remove();
+ });
+ });
+}
+
+// ============================================================
+// MCP Server Management
+// ============================================================
+function updateMcpBadge() {
+ const connected = S.mcpServers.filter(s => s.status === 'connected').length;
+ const total = S.mcpServers.length;
+ const $badge = $('mcp-badge');
+ if ($badge) $badge.textContent = connected > 0 ? connected : '';
+}
+
+function showMcpPanel() {
+ let panel = document.getElementById('mcp-panel');
+ if (panel) { panel.remove(); return; }
+ panel = document.createElement('div');
+ panel.id = 'mcp-panel';
+ panel.className = 'mcp-panel';
+ panel.innerHTML = `
+
+
+ ${S.mcpServers.length ? S.mcpServers.map(s => `
+
+
+
+
${esc(s.name)}
+
${s.status}
+
+
+ ${s.status === 'connected'
+ ? ``
+ : ``}
+
+
+
+ `).join('') : '
No MCP servers configured
'}
+
`;
+ document.body.appendChild(panel);
+ panel.querySelectorAll('.mcp-enable').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ vscode.postMessage({ type: 'mcp_toggle', name: btn.dataset.name, enabled: true });
+ btn.closest('.mcp-item').querySelector('.mcp-status-dot').className = 'mcp-status-dot mcp-on';
+ btn.closest('.mcp-item').querySelector('.mcp-status-text').textContent = 'connected';
+ });
+ });
+ panel.querySelectorAll('.mcp-disable').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ vscode.postMessage({ type: 'mcp_toggle', name: btn.dataset.name, enabled: false });
+ btn.closest('.mcp-item').querySelector('.mcp-status-dot').className = 'mcp-status-dot mcp-off';
+ btn.closest('.mcp-item').querySelector('.mcp-status-text').textContent = 'disabled';
+ });
+ });
+ panel.querySelectorAll('.mcp-reconnect').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ vscode.postMessage({ type: 'reconnect_mcp_server', name: btn.dataset.name });
+ });
+ });
+ setTimeout(() => {
+ const closeHandler = (e) => { if (!panel.contains(e.target)) { panel.remove(); document.removeEventListener('click', closeHandler); } };
+ document.addEventListener('click', closeHandler);
+ }, 100);
+}
+
+// ============================================================
+// Model Picker
+// ============================================================
+function showModelPicker() {
+ const existing = document.getElementById('model-picker');
+ if (existing) { existing.remove(); return; }
+ const models = [
+ { label: 'Default', value: '' },
+ { label: 'Claude Opus 4.6', value: 'anthropic/claude-opus-4-6' },
+ { label: 'Claude Sonnet 4.6', value: 'anthropic/claude-sonnet-4-6' },
+ { label: 'GPT-4.1', value: 'openai/gpt-4.1' },
+ { label: 'Gemini 2.5 Pro', value: 'google/gemini-2.5-pro' },
+ { label: 'Qwen 3 235B', value: 'qwen/qwen3-235b-a22b' },
+ ];
+ const picker = document.createElement('div');
+ picker.id = 'model-picker';
+ picker.className = 'model-picker';
+ if ($modelBtn) {
+ const rect = $modelBtn.getBoundingClientRect();
+ picker.style.top = (rect.bottom + 4) + 'px';
+ picker.style.left = rect.left + 'px';
+ }
+ picker.innerHTML = `
+
+ ${models.map(m => `${m.label}
`).join('')}`;
+ document.body.appendChild(picker);
+ picker.querySelectorAll('.mp-item').forEach(el => {
+ el.addEventListener('click', (e) => {
+ e.stopPropagation();
+ S.currentModel = el.dataset.value;
+ if ($modelLabel) $modelLabel.textContent = el.textContent;
+ vscode.postMessage({ type: 'set_model', model: el.dataset.value });
+ picker.remove();
+ });
+ });
+ setTimeout(() => {
+ const closeHandler = (e) => { if (!picker.contains(e.target)) { picker.remove(); document.removeEventListener('click', closeHandler); } };
+ document.addEventListener('click', closeHandler);
+ }, 100);
+}
+
+// ============================================================
+// Permission Mode Cycle
+// ============================================================
+const MODES = ['default', 'plan', 'acceptEdits', 'bypassPermissions'];
+const MODE_LABELS = { default: 'Default', plan: 'Plan', acceptEdits: 'Accept Edits', bypassPermissions: 'Bypass' };
+function cyclePermissionMode() {
+ const idx = MODES.indexOf(S.permissionMode);
+ const next = MODES[(idx + 1) % MODES.length];
+ S.permissionMode = next;
+ if ($modeBtn) $modeBtn.textContent = MODE_LABELS[next];
+ vscode.postMessage({ type: 'set_permission_mode', mode: next });
+}
+
+// ============================================================
+// Events
+// ============================================================
+$sendBtn?.addEventListener('click', sendMessage);
+$stopBtn?.addEventListener('click', stopGeneration);
+$newChatBtn?.addEventListener('click', newConversation);
+$newChatBtnTop?.addEventListener('click', newConversation);
+$contextDismiss?.addEventListener('click', () => { if ($contextBadge) $contextBadge.style.display = 'none'; S.activeFile = null; });
+$modelBtn?.addEventListener('click', (e) => { e.stopPropagation(); showModelPicker(); });
+const $mcpBtn = $('mcp-btn');
+$mcpBtn?.addEventListener('click', (e) => { e.stopPropagation(); showMcpPanel(); });
+$sessionsBtn?.addEventListener('click', () => vscode.postMessage({ type: 'list_sessions' }));
+$settingsBtn?.addEventListener('click', () => vscode.postMessage({ type: 'open_config' }));
+$modeBtn?.addEventListener('click', cyclePermissionMode);
+$permAllow?.addEventListener('click', () => {
+ if (S.pendingPermission) vscode.postMessage({ type: 'control_response', request_id: S.pendingPermission.request_id, response: { approved: true } });
+ hidePermissionBar();
+});
+$permDeny?.addEventListener('click', () => {
+ if (S.pendingPermission) vscode.postMessage({ type: 'control_response', request_id: S.pendingPermission.request_id, response: { approved: false } });
+ hidePermissionBar();
+});
+
+$userInput?.addEventListener('input', autoResize);
+$userInput?.addEventListener('keydown', (e) => {
+ const useCtrlEnter = S.settings?.useCtrlEnterToSend;
+ if (e.key === 'Enter') {
+ if (useCtrlEnter) { if (e.metaKey || e.ctrlKey) { e.preventDefault(); sendMessage(); } }
+ else { if (!e.shiftKey && !e.metaKey && !e.ctrlKey) { e.preventDefault(); sendMessage(); } }
+ }
+ if (e.key === '@') {
+ vscode.postMessage({ type: 'insertAtMention' });
+ }
+ if (e.key === '/' && e.target.value === '') {
+ // Could show command palette
+ }
+});
+
+window.addEventListener('message', (event) => { if (event.data?.type) handleMsg(event.data); });
+
+// Init
+vscode.postMessage({ type: 'ready' });
+vscode.postMessage({ type: 'getSettings' });
+vscode.postMessage({ type: 'get_auth_status' });