diff --git a/package.json b/package.json index 59a6eb4a..3b2bd7be 100644 --- a/package.json +++ b/package.json @@ -1,427 +1,432 @@ -{ - "name": "acp-client", - "displayName": "ACP Client", - "description": "Agent Client Protocol client for VS Code — connect to GitHub Copilot, Claude Code, Gemini CLI, Qwen Code, Codex CLI, OpenCode, OpenClaw, Kiro CLI, Hermes Agent, and any ACP-compatible AI coding agent", - "version": "0.2.0", - "publisher": "formulahendry", - "license": "MIT", - "icon": "resources/icon.png", - "bugs": { - "url": "https://github.com/formulahendry/vscode-acp/issues", - "email": "formulahendry@gmail.com" - }, - "homepage": "https://github.com/formulahendry/vscode-acp/blob/main/README.md", - "repository": { - "type": "git", - "url": "https://github.com/formulahendry/vscode-acp.git" - }, - "engines": { - "vscode": "^1.85.0" - }, - "categories": [ - "AI", - "Chat" - ], - "keywords": [ - "acp", - "agent", - "ai", - "coding agent", - "copilot" - ], - "activationEvents": [], - "main": "./dist/extension.js", - "contributes": { - "viewsContainers": { - "activitybar": [ - { - "id": "acp-client", - "title": "ACP Client", - "icon": "resources/icon.svg" - } - ] - }, - "views": { - "acp-client": [ - { - "id": "acp-sessions", - "name": "Agents", - "type": "tree" - }, - { - "id": "acp-chat", - "name": "Chat", - "type": "webview" - } - ] - }, - "viewsWelcome": [ - { - "view": "acp-sessions", - "contents": "No agents configured.\n[Add Agent](command:acp.addAgent)\nConfigure agents in settings or browse the [Agent Registry](command:acp.browseRegistry)." - }, - { - "view": "acp-chat", - "contents": "Start chatting with an AI coding agent.\n[Connect to Agent](command:acp.connectAgent)\nSelect an agent from the list above to begin." - } - ], - "commands": [ - { - "command": "acp.connectAgent", - "title": "ACP: Connect to Agent", - "icon": "$(plug)" - }, - { - "command": "acp.newConversation", - "title": "ACP: New Conversation", - "icon": "$(add)" - }, - { - "command": "acp.sendPrompt", - "title": "ACP: Send Prompt" - }, - { - "command": "acp.cancelTurn", - "title": "ACP: Cancel Current Turn", - "icon": "$(debug-stop)" - }, - { - "command": "acp.disconnectAgent", - "title": "ACP: Disconnect Agent", - "icon": "$(debug-disconnect)" - }, - { - "command": "acp.openChat", - "title": "ACP: Open Chat Panel" - }, - { - "command": "acp.addAgent", - "title": "ACP: Add Agent Configuration", - "icon": "$(add)" - }, - { - "command": "acp.removeAgent", - "title": "ACP: Remove Agent" - }, - { - "command": "acp.showLog", - "title": "ACP: Show Log" - }, - { - "command": "acp.showTraffic", - "title": "ACP: Show Protocol Traffic" - }, - { - "command": "acp.browseRegistry", - "title": "ACP: Browse Agent Registry" - }, - { - "command": "acp.setMode", - "title": "ACP: Set Agent Mode" - }, - { - "command": "acp.setModel", - "title": "ACP: Set Agent Model" - }, - { - "command": "acp.attachFile", - "title": "ACP: Attach File to Prompt", - "icon": "$(attach)" - }, - { - "command": "acp.refreshAgents", - "title": "Refresh", - "icon": "$(refresh)" - }, - { - "command": "acp.refreshSessions", - "title": "ACP: Refresh Sessions" - }, - { - "command": "acp.openSession", - "title": "ACP: Open Session" - }, - { - "command": "acp.loadMoreSessions", - "title": "ACP: Load More Sessions" - }, - { - "command": "acp.copySessionId", - "title": "Copy Session ID" - }, - { - "command": "acp.forgetSession", - "title": "Forget Session" - }, - { - "command": "acp.restartAgent", - "title": "ACP: Restart Agent", - "icon": "$(debug-restart)" - } - ], - "menus": { - "commandPalette": [ - { - "command": "acp.openSession", - "when": "false" - }, - { - "command": "acp.loadMoreSessions", - "when": "false" - }, - { - "command": "acp.copySessionId", - "when": "false" - }, - { - "command": "acp.forgetSession", - "when": "false" - } - ], - "view/title": [ - { - "command": "acp.addAgent", - "when": "view == acp-sessions", - "group": "navigation@1" - }, - { - "command": "acp.refreshAgents", - "when": "view == acp-sessions", - "group": "navigation@2" - }, - { - "command": "acp.browseRegistry", - "when": "view == acp-sessions" - }, - { - "command": "acp.newConversation", - "when": "view == acp-chat", - "group": "navigation@1" - }, - { - "command": "acp.attachFile", - "when": "view == acp-chat", - "group": "navigation@2" - }, - { - "command": "acp.cancelTurn", - "when": "view == acp-chat && acp.turnInProgress", - "group": "navigation@3" - }, - { - "command": "acp.disconnectAgent", - "when": "view == acp-chat", - "group": "navigation@4" - }, - { - "command": "acp.showLog", - "when": "view == acp-chat" - }, - { - "command": "acp.showTraffic", - "when": "view == acp-chat" - }, - { - "command": "acp.restartAgent", - "when": "view == acp-chat" - } - ], - "view/item/context": [ - { - "command": "acp.connectAgent", - "when": "view == acp-sessions && viewItem == agent-disconnected", - "group": "inline" - }, - { - "command": "acp.disconnectAgent", - "when": "view == acp-sessions && viewItem == agent-connected", - "group": "inline" - }, - { - "command": "acp.removeAgent", - "when": "view == acp-sessions && viewItem =~ /^agent/" - }, - { - "command": "acp.restartAgent", - "when": "view == acp-sessions && viewItem == agent-connected" - }, - { - "command": "acp.refreshSessions", - "when": "view == acp-sessions && viewItem =~ /^agent/", - "group": "session@1" - }, - { - "command": "acp.copySessionId", - "when": "view == acp-sessions && viewItem =~ /^session/", - "group": "session@1" - }, - { - "command": "acp.forgetSession", - "when": "view == acp-sessions && viewItem == session-local", - "group": "session@2" - } - ] - }, - "keybindings": [ - { - "command": "acp.openChat", - "key": "ctrl+shift+a", - "mac": "cmd+shift+a" - }, - { - "command": "acp.cancelTurn", - "key": "escape", - "when": "acp.turnInProgress" - } - ], - "configuration": { - "title": "ACP Client", - "properties": { - "acp.agents": { - "type": "object", - "description": "Agent configurations. Each key is the agent name, value has command, args, and env.", - "default": { - "GitHub Copilot": { - "command": "npx", - "args": [ - "@github/copilot-language-server@latest", - "--acp" - ], - "env": {} - }, - "Claude Code": { - "command": "npx", - "args": [ - "@agentclientprotocol/claude-agent-acp@latest" - ], - "env": {} - }, - "Gemini CLI": { - "command": "npx", - "args": [ - "@google/gemini-cli@latest", - "--experimental-acp" - ], - "env": {} - }, - "Qwen Code": { - "command": "npx", - "args": [ - "@qwen-code/qwen-code@latest", - "--acp", - "--experimental-skills" - ], - "env": {} - }, - "Auggie CLI": { - "command": "npx", - "args": [ - "@augmentcode/auggie@latest", - "--acp" - ], - "env": { - "AUGMENT_DISABLE_AUTO_UPDATE": "1" - } - }, - "Qoder CLI": { - "command": "npx", - "args": [ - "@qoder-ai/qodercli@latest", - "--acp" - ], - "env": {} - }, - "Codex CLI": { - "command": "npx", - "args": [ - "@zed-industries/codex-acp@latest" - ], - "env": {} - }, - "OpenCode": { - "command": "npx", - "args": [ - "opencode-ai@latest", - "acp" - ], - "env": {} - }, - "OpenClaw": { - "command": "npx", - "args": [ - "openclaw", - "acp" - ], - "env": {} - }, - "Kiro CLI": { - "command": "kiro-cli", - "args": [ - "acp" - ], - "env": {} - }, - "Hermes Agent": { - "command": "hermes", - "args": [ - "acp" - ], - "env": {} - } - } - }, - "acp.autoApprovePermissions": { - "type": "string", - "enum": [ - "ask", - "allowAll" - ], - "default": "ask", - "description": "Automatically approve agent permission requests. 'ask' always prompts, 'allowAll' approves everything." - }, - "acp.defaultWorkingDirectory": { - "type": "string", - "default": "", - "description": "Default working directory for agent sessions. Leave empty to use the current workspace folder." - }, - "acp.logTraffic": { - "type": "boolean", - "default": true, - "description": "Log all ACP protocol traffic to the output channel." - } - } - } - }, - "scripts": { - "vscode:prepublish": "npm run package", - "compile": "webpack", - "watch": "webpack --watch", - "package": "webpack --mode production --devtool hidden-source-map", - "compile-tests": "tsc -p . --outDir out", - "watch-tests": "tsc -p . -w --outDir out", - "lint": "eslint src --max-warnings 0", - "pretest": "npm run compile-tests && npm run compile && npm run lint", - "test": "vscode-test" - }, - "dependencies": { - "@agentclientprotocol/sdk": "^0.21.1", - "@vscode/extension-telemetry": "^1.5.0", - "marked": "^15.0.0" - }, - "devDependencies": { - "@types/mocha": "^10.0.6", - "@types/node": "^20.0.0", - "@types/vscode": "^1.85.0", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "@typescript-eslint/parser": "^8.0.0", - "@vscode/test-cli": "^0.0.10", - "@vscode/test-electron": "^2.4.1", - "@vscode/vsce": "^3.7.1", - "eslint": "^9.0.0", - "ts-loader": "^9.5.0", - "typescript": "^5.5.0", - "typescript-eslint": "^8.0.0", - "webpack": "^5.95.0", - "webpack-cli": "^5.1.0" - } -} +{ + "name": "acp-client", + "displayName": "ACP Client", + "description": "Agent Client Protocol client for VS Code — connect to GitHub Copilot, Claude Code, Gemini CLI, Qwen Code, Codex CLI, OpenCode, OpenClaw, Kiro CLI, Hermes Agent, and any ACP-compatible AI coding agent", + "version": "0.2.0", + "publisher": "formulahendry", + "license": "MIT", + "icon": "resources/icon.png", + "bugs": { + "url": "https://github.com/formulahendry/vscode-acp/issues", + "email": "formulahendry@gmail.com" + }, + "homepage": "https://github.com/formulahendry/vscode-acp/blob/main/README.md", + "repository": { + "type": "git", + "url": "https://github.com/formulahendry/vscode-acp.git" + }, + "engines": { + "vscode": "^1.85.0" + }, + "categories": [ + "AI", + "Chat" + ], + "keywords": [ + "acp", + "agent", + "ai", + "coding agent", + "copilot" + ], + "activationEvents": [], + "main": "./dist/extension.js", + "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "acp-client", + "title": "ACP Client", + "icon": "resources/icon.svg" + } + ] + }, + "views": { + "acp-client": [ + { + "id": "acp-sessions", + "name": "Agents", + "type": "tree" + }, + { + "id": "acp-chat", + "name": "Chat", + "type": "webview" + } + ] + }, + "viewsWelcome": [ + { + "view": "acp-sessions", + "contents": "No agents configured.\n[Add Agent](command:acp.addAgent)\nConfigure agents in settings or browse the [Agent Registry](command:acp.browseRegistry)." + }, + { + "view": "acp-chat", + "contents": "Start chatting with an AI coding agent.\n[Connect to Agent](command:acp.connectAgent)\nSelect an agent from the list above to begin." + } + ], + "commands": [ + { + "command": "acp.connectAgent", + "title": "ACP: Connect to Agent", + "icon": "$(plug)" + }, + { + "command": "acp.newConversation", + "title": "ACP: New Conversation", + "icon": "$(add)" + }, + { + "command": "acp.sendPrompt", + "title": "ACP: Send Prompt" + }, + { + "command": "acp.cancelTurn", + "title": "ACP: Cancel Current Turn", + "icon": "$(debug-stop)" + }, + { + "command": "acp.disconnectAgent", + "title": "ACP: Disconnect Agent", + "icon": "$(debug-disconnect)" + }, + { + "command": "acp.openChat", + "title": "ACP: Open Chat Panel" + }, + { + "command": "acp.addAgent", + "title": "ACP: Add Agent Configuration", + "icon": "$(add)" + }, + { + "command": "acp.removeAgent", + "title": "ACP: Remove Agent" + }, + { + "command": "acp.showLog", + "title": "ACP: Show Log" + }, + { + "command": "acp.showTraffic", + "title": "ACP: Show Protocol Traffic" + }, + { + "command": "acp.browseRegistry", + "title": "ACP: Browse Agent Registry" + }, + { + "command": "acp.setMode", + "title": "ACP: Set Agent Mode" + }, + { + "command": "acp.setModel", + "title": "ACP: Set Agent Model" + }, + { + "command": "acp.attachFile", + "title": "ACP: Attach File to Prompt", + "icon": "$(attach)" + }, + { + "command": "acp.refreshAgents", + "title": "Refresh", + "icon": "$(refresh)" + }, + { + "command": "acp.refreshSessions", + "title": "ACP: Refresh Sessions" + }, + { + "command": "acp.openSession", + "title": "ACP: Open Session" + }, + { + "command": "acp.loadMoreSessions", + "title": "ACP: Load More Sessions" + }, + { + "command": "acp.copySessionId", + "title": "Copy Session ID" + }, + { + "command": "acp.forgetSession", + "title": "Forget Session" + }, + { + "command": "acp.restartAgent", + "title": "ACP: Restart Agent", + "icon": "$(debug-restart)" + } + ], + "menus": { + "commandPalette": [ + { + "command": "acp.openSession", + "when": "false" + }, + { + "command": "acp.loadMoreSessions", + "when": "false" + }, + { + "command": "acp.copySessionId", + "when": "false" + }, + { + "command": "acp.forgetSession", + "when": "false" + } + ], + "view/title": [ + { + "command": "acp.addAgent", + "when": "view == acp-sessions", + "group": "navigation@1" + }, + { + "command": "acp.refreshAgents", + "when": "view == acp-sessions", + "group": "navigation@2" + }, + { + "command": "acp.browseRegistry", + "when": "view == acp-sessions" + }, + { + "command": "acp.newConversation", + "when": "view == acp-chat", + "group": "navigation@1" + }, + { + "command": "acp.attachFile", + "when": "view == acp-chat", + "group": "navigation@2" + }, + { + "command": "acp.cancelTurn", + "when": "view == acp-chat && acp.turnInProgress", + "group": "navigation@3" + }, + { + "command": "acp.disconnectAgent", + "when": "view == acp-chat", + "group": "navigation@4" + }, + { + "command": "acp.showLog", + "when": "view == acp-chat" + }, + { + "command": "acp.showTraffic", + "when": "view == acp-chat" + }, + { + "command": "acp.restartAgent", + "when": "view == acp-chat" + } + ], + "view/item/context": [ + { + "command": "acp.connectAgent", + "when": "view == acp-sessions && viewItem == agent-disconnected", + "group": "inline" + }, + { + "command": "acp.disconnectAgent", + "when": "view == acp-sessions && viewItem == agent-connected", + "group": "inline" + }, + { + "command": "acp.removeAgent", + "when": "view == acp-sessions && viewItem =~ /^agent/" + }, + { + "command": "acp.restartAgent", + "when": "view == acp-sessions && viewItem == agent-connected" + }, + { + "command": "acp.refreshSessions", + "when": "view == acp-sessions && viewItem =~ /^agent/", + "group": "session@1" + }, + { + "command": "acp.copySessionId", + "when": "view == acp-sessions && viewItem =~ /^session/", + "group": "session@1" + }, + { + "command": "acp.forgetSession", + "when": "view == acp-sessions && viewItem == session-local", + "group": "session@2" + } + ] + }, + "keybindings": [ + { + "command": "acp.openChat", + "key": "ctrl+shift+a", + "mac": "cmd+shift+a" + }, + { + "command": "acp.cancelTurn", + "key": "escape", + "when": "acp.turnInProgress" + } + ], + "configuration": { + "title": "ACP Client", + "properties": { + "acp.agents": { + "type": "object", + "description": "Agent configurations. Each key is the agent name, value has command, args, and env.", + "default": { + "GitHub Copilot": { + "command": "npx", + "args": [ + "@github/copilot-language-server@latest", + "--acp" + ], + "env": {} + }, + "Claude Code": { + "command": "npx", + "args": [ + "@agentclientprotocol/claude-agent-acp@latest" + ], + "env": {} + }, + "Gemini CLI": { + "command": "npx", + "args": [ + "@google/gemini-cli@latest", + "--experimental-acp" + ], + "env": {} + }, + "Qwen Code": { + "command": "npx", + "args": [ + "@qwen-code/qwen-code@latest", + "--acp", + "--experimental-skills" + ], + "env": {} + }, + "Auggie CLI": { + "command": "npx", + "args": [ + "@augmentcode/auggie@latest", + "--acp" + ], + "env": { + "AUGMENT_DISABLE_AUTO_UPDATE": "1" + } + }, + "Qoder CLI": { + "command": "npx", + "args": [ + "@qoder-ai/qodercli@latest", + "--acp" + ], + "env": {} + }, + "Codex CLI": { + "command": "npx", + "args": [ + "@zed-industries/codex-acp@latest" + ], + "env": {} + }, + "OpenCode": { + "command": "npx", + "args": [ + "opencode-ai@latest", + "acp" + ], + "env": {} + }, + "OpenClaw": { + "command": "npx", + "args": [ + "openclaw", + "acp" + ], + "env": {} + }, + "Kiro CLI": { + "command": "kiro-cli", + "args": [ + "acp" + ], + "env": {} + }, + "Hermes Agent": { + "command": "hermes", + "args": [ + "acp" + ], + "env": {} + } + } + }, + "acp.autoApprovePermissions": { + "type": "string", + "enum": [ + "ask", + "allowAll" + ], + "default": "ask", + "description": "Automatically approve agent permission requests. 'ask' always prompts, 'allowAll' approves everything." + }, + "acp.defaultWorkingDirectory": { + "type": "string", + "default": "", + "description": "Default working directory for agent sessions. Leave empty to use the current workspace folder." + }, + "acp.chat.autoScroll": { + "type": "boolean", + "default": true, + "description": "When enabled, auto-scrolls the chat to follow new content. Pauses when you scroll up to read history, resumes when you scroll back to the bottom or send a new message." + }, + "acp.logTraffic": { + "type": "boolean", + "default": true, + "description": "Log all ACP protocol traffic to the output channel." + } + } + } + }, + "scripts": { + "vscode:prepublish": "npm run package", + "compile": "webpack", + "watch": "webpack --watch", + "package": "webpack --mode production --devtool hidden-source-map", + "compile-tests": "tsc -p . --outDir out", + "watch-tests": "tsc -p . -w --outDir out", + "lint": "eslint src --max-warnings 0", + "pretest": "npm run compile-tests && npm run compile && npm run lint", + "test": "vscode-test" + }, + "dependencies": { + "@agentclientprotocol/sdk": "^0.21.1", + "@vscode/extension-telemetry": "^1.5.0", + "marked": "^15.0.0" + }, + "devDependencies": { + "@types/mocha": "^10.0.6", + "@types/node": "^20.0.0", + "@types/vscode": "^1.85.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "@vscode/test-cli": "^0.0.10", + "@vscode/test-electron": "^2.4.1", + "@vscode/vsce": "^3.7.1", + "eslint": "^9.0.0", + "ts-loader": "^9.5.0", + "typescript": "^5.5.0", + "typescript-eslint": "^8.0.0", + "webpack": "^5.95.0", + "webpack-cli": "^5.1.0" + } +} diff --git a/src/ui/ChatWebviewProvider.ts b/src/ui/ChatWebviewProvider.ts index f9c58d27..9e44e380 100644 --- a/src/ui/ChatWebviewProvider.ts +++ b/src/ui/ChatWebviewProvider.ts @@ -1,2559 +1,2587 @@ -import * as vscode from 'vscode'; -import { marked } from 'marked'; -import { SessionManager } from '../core/SessionManager'; -import { SessionUpdateHandler, SessionUpdateListener } from '../handlers/SessionUpdateHandler'; -import type { SessionNotification } from '@agentclientprotocol/sdk'; -import { logError } from '../utils/Logger'; -import { sendEvent } from '../utils/TelemetryManager'; - -/** - * WebviewViewProvider for the ACP chat sidebar. - * Renders chat messages, tool calls, plans, and handles user input. - */ -export class ChatWebviewProvider implements vscode.WebviewViewProvider { - public static readonly viewType = 'acp-chat'; - - private view?: vscode.WebviewView; - private updateListener: SessionUpdateListener; - private _hasChatContent = false; - - constructor( - private readonly extensionUri: vscode.Uri, - private readonly sessionManager: SessionManager, - private readonly sessionUpdateHandler: SessionUpdateHandler, - ) { - // Configure marked for safe rendering - marked.setOptions({ - breaks: true, - gfm: true, - }); - - // Register as a session update listener - this.updateListener = (update: SessionNotification) => { - this.handleSessionUpdate(update); - }; - this.sessionUpdateHandler.addListener(this.updateListener); - } - - /** - * Render markdown text to HTML using marked. - */ - private renderMarkdown(text: string): string { - try { - return marked.parse(text) as string; - } catch { - return this.escapeHtml(text); - } - } - - private escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - } - - resolveWebviewView( - webviewView: vscode.WebviewView, - _context: vscode.WebviewViewResolveContext, - _token: vscode.CancellationToken, - ): void { - this.view = webviewView; - - webviewView.webview.options = { - enableScripts: true, - localResourceRoots: [this.extensionUri], - }; - - webviewView.webview.html = this.getHtmlContent(webviewView.webview); - - // Handle messages from the webview - webviewView.webview.onDidReceiveMessage(async (message) => { - switch (message.type) { - case 'sendPrompt': - this._hasChatContent = true; - await this.handleSendPrompt(message.text); - break; - case 'cancelTurn': - await this.handleCancelTurn(); - break; - case 'setMode': - await this.handleSetMode(message.modeId); - break; - case 'setModel': - await this.handleSetModel(message.modelId); - break; - case 'setConfigOption': - await this.handleSetConfigOption(message.configId, message.value); - break; - case 'executeCommand': - if (message.command) { - await vscode.commands.executeCommand(message.command); - } - break; - case 'ready': - // Webview loaded — send current session state - this.sendCurrentState(); - break; - case 'renderMarkdown': { - // Webview requests markdown rendering for history items - const items: Array<{index: number; text: string}> = message.items || []; - const rendered = items.map((item: {index: number; text: string}) => ({ - index: item.index, - html: this.renderMarkdown(item.text), - })); - this.postMessage({ type: 'markdownRendered', items: rendered }); - break; - } - } - }); - - webviewView.onDidDispose(() => { - this.view = undefined; - }); - } - - /** - * Forward session update to webview. - */ - private handleSessionUpdate(update: SessionNotification): void { - const updateData = update.update as any; - - // Persist session state BEFORE the active-session check. During session - // creation the agent can dispatch notifications (e.g. - // `available_commands_update`) before connectToAgent finishes setting - // `activeSessionId`. Without this, those updates would be dropped and - // the slash-command popup would never have commands to show. - if (updateData?.sessionUpdate === 'available_commands_update') { - this.sessionManager.applyAvailableCommands( - update.sessionId, - updateData.availableCommands || [], - ); - } - if (updateData?.sessionUpdate === 'config_option_update') { - this.sessionManager.applyConfigOptions( - update.sessionId, - updateData.configOptions || [], - ); - } - if (updateData?.sessionUpdate === 'session_info_update') { - this.sessionManager.applySessionInfoUpdate(update.sessionId, { - title: updateData.title, - updatedAt: updateData.updatedAt, - }); - } - - // Only forward to the webview if this is the active session — the - // webview only ever shows one session at a time. - const activeId = this.sessionManager.getActiveSessionId(); - if (update.sessionId !== activeId) { return; } - - this.postMessage({ - type: 'sessionUpdate', - update: update.update, - sessionId: update.sessionId, - }); - } - - /** - * Handle a prompt sent from the webview. - */ - private async handleSendPrompt(text: string): Promise { - const activeId = this.sessionManager.getActiveSessionId(); - if (!activeId) { - this.postMessage({ - type: 'error', - message: 'No active session. Create a session first.', - }); - return; - } - - sendEvent('chat/messageSent', { - agentName: this.sessionManager.getActiveAgentName() ?? '', - }, { - messageLength: text.length, - }); - - // Record the first prompt for the history store (used as a label - // fallback when no title is supplied by the agent). - this.sessionManager.recordFirstPrompt(activeId, text); - - // Tell webview we're processing - this.postMessage({ type: 'promptStart' }); - - try { - const response = await this.sessionManager.sendPrompt(activeId, text); - // Render the accumulated assistant text as markdown - // The webview will have sent us the raw text via promptEnd handling - this.postMessage({ - type: 'promptEnd', - stopReason: response.stopReason, - usage: (response as any).usage, - }); - this.sessionManager.touchHistory(activeId); - } catch (e: any) { - logError('Prompt failed', e); - this.postMessage({ - type: 'error', - message: e.message || 'Prompt failed', - }); - this.postMessage({ type: 'promptEnd', stopReason: 'error' }); - } - } - - /** - * Handle cancel request from webview. - */ - private async handleCancelTurn(): Promise { - const activeId = this.sessionManager.getActiveSessionId(); - if (activeId) { - try { - await this.sessionManager.cancelTurn(activeId); - } catch (e) { - logError('Cancel failed', e); - } - } - } - - /** - * Handle mode change from webview picker. - */ - private async handleSetMode(modeId: string): Promise { - const activeId = this.sessionManager.getActiveSessionId(); - if (!activeId || !modeId) { return; } - try { - await this.sessionManager.setMode(activeId, modeId); - } catch (e: any) { - logError('Failed to set mode', e); - this.postMessage({ type: 'error', message: `Failed to set mode: ${e.message}` }); - } - } - - /** - * Handle model change from webview picker. - */ - private async handleSetModel(modelId: string): Promise { - const activeId = this.sessionManager.getActiveSessionId(); - if (!activeId || !modelId) { return; } - try { - await this.sessionManager.setModel(activeId, modelId); - } catch (e: any) { - logError('Failed to set model', e); - this.postMessage({ type: 'error', message: `Failed to set model: ${e.message}` }); - } - } - - /** - * Handle generic config-option change from webview picker - * (ACP "Session Config Options"). The agent returns the full - * configOptions state which we re-broadcast so any cascading - * changes are reflected in the UI. - */ - private async handleSetConfigOption(configId: string, value: string): Promise { - const activeId = this.sessionManager.getActiveSessionId(); - if (!activeId || !configId) { return; } - try { - const options = await this.sessionManager.setConfigOption(activeId, configId, value); - this.postMessage({ type: 'configOptionsUpdate', configOptions: options }); - } catch (e: any) { - logError('Failed to set config option', e); - this.postMessage({ type: 'error', message: `Failed to set ${configId}: ${e.message}` }); - // Roll back optimistic update on the webview by replaying current state - const session = this.sessionManager.getSession(activeId); - this.postMessage({ - type: 'configOptionsUpdate', - configOptions: session?.configOptions ?? null, - }); - } - } - - /** - * Send current session state to the webview on load. - */ - private sendCurrentState(): void { - const activeId = this.sessionManager.getActiveSessionId(); - const session = activeId ? this.sessionManager.getSession(activeId) : null; - this.postMessage({ - type: 'state', - activeSessionId: activeId, - session: session ? { - sessionId: session.sessionId, - agentName: session.agentDisplayName, - title: session.title, - cwd: session.cwd, - modes: session.modes, - models: session.models, - configOptions: session.configOptions, - availableCommands: session.availableCommands, - } : null, - }); - } - - /** - * Post a message to the webview if it exists. - */ - private postMessage(message: any): void { - this.view?.webview.postMessage(message); - } - - /** - * Notify webview of a new active session. - */ - notifyActiveSessionChanged(): void { - this.sendCurrentState(); - } - - /** - * Notify webview of mode state changes. - */ - notifyModesUpdate(modes: any): void { - this.postMessage({ type: 'modesUpdate', modes }); - } - - /** - * Notify webview of model state changes. - */ - notifyModelsUpdate(models: any): void { - this.postMessage({ type: 'modelsUpdate', models }); - } - - /** - * Notify webview of session config-option state changes. - */ - notifyConfigOptionsUpdate(configOptions: any): void { - this.postMessage({ type: 'configOptionsUpdate', configOptions }); - } - - /** - * Notify webview that a `session/load` replay is starting. The webview - * wipes any previously-displayed history, disables input, and shows a - * loading overlay until {@link notifyLoadSessionEnd} fires. - */ - notifyLoadSessionStart(): void { - this.postMessage({ type: 'loadSessionStart' }); - } - - /** Notify webview that the active replay finished (success or failure). */ - notifyLoadSessionEnd(ok: boolean): void { - this.postMessage({ type: 'loadSessionEnd', ok }); - } - - /** Notify webview that session title / metadata changed. */ - notifySessionInfoUpdate(title: string | undefined | null): void { - this.postMessage({ type: 'sessionInfoUpdate', title: title ?? null }); - } - - /** - * Clear the chat history and reset to welcome state. - * Called when starting a new conversation. - */ - clearChat(): void { - this._hasChatContent = false; - this.postMessage({ type: 'clearChat' }); - } - - /** - * Whether the chat has any messages. - */ - get hasChatContent(): boolean { - return this._hasChatContent; - } - - /** - * Generate the HTML content for the webview. - */ - private getHtmlContent(webview: vscode.Webview): string { - const nonce = getNonce(); - - return /*html*/ ` - - - - - - ACP Chat - - - -
- -
-
-
-
- -
- -
-
-
🤖
-
ACP Chat
-
Connect to an AI coding agent to start chatting.
-
- - -
-
or press Ctrl+Shift+A anytime
-
-
- -
-
-
Commands
-
-
-
- -
- - - - -
-
- -
-
- -
-
- - - - - -
-
-
Loading conversation history…
-
- - - -`; - } - - /** - * Attach a file URI — notify the webview to include it in the next prompt. - */ - attachFile(uri: vscode.Uri): void { - if (this.view) { - this.view.webview.postMessage({ - type: 'file-attached', - path: uri.fsPath, - name: uri.fsPath.split(/[\\/]/).pop() || uri.fsPath, - }); - this.view.show?.(true); - } - } - - dispose(): void { - this.sessionUpdateHandler.removeListener(this.updateListener); - } -} - -function getNonce(): string { - let text = ''; - const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - for (let i = 0; i < 32; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)); - } - return text; -} +import * as vscode from 'vscode'; +import { marked } from 'marked'; +import { SessionManager } from '../core/SessionManager'; +import { SessionUpdateHandler, SessionUpdateListener } from '../handlers/SessionUpdateHandler'; +import type { SessionNotification } from '@agentclientprotocol/sdk'; +import { logError } from '../utils/Logger'; +import { sendEvent } from '../utils/TelemetryManager'; + +/** + * WebviewViewProvider for the ACP chat sidebar. + * Renders chat messages, tool calls, plans, and handles user input. + */ +export class ChatWebviewProvider implements vscode.WebviewViewProvider { + public static readonly viewType = 'acp-chat'; + + private view?: vscode.WebviewView; + private updateListener: SessionUpdateListener; + private _hasChatContent = false; + + constructor( + private readonly extensionUri: vscode.Uri, + private readonly sessionManager: SessionManager, + private readonly sessionUpdateHandler: SessionUpdateHandler, + ) { + // Configure marked for safe rendering + marked.setOptions({ + breaks: true, + gfm: true, + }); + + // Register as a session update listener + this.updateListener = (update: SessionNotification) => { + this.handleSessionUpdate(update); + }; + this.sessionUpdateHandler.addListener(this.updateListener); + } + + /** + * Render markdown text to HTML using marked. + */ + private renderMarkdown(text: string): string { + try { + return marked.parse(text) as string; + } catch { + return this.escapeHtml(text); + } + } + + private escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ): void { + this.view = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [this.extensionUri], + }; + + webviewView.webview.html = this.getHtmlContent(webviewView.webview); + + // Handle messages from the webview + webviewView.webview.onDidReceiveMessage(async (message) => { + switch (message.type) { + case 'sendPrompt': + this._hasChatContent = true; + await this.handleSendPrompt(message.text); + break; + case 'cancelTurn': + await this.handleCancelTurn(); + break; + case 'setMode': + await this.handleSetMode(message.modeId); + break; + case 'setModel': + await this.handleSetModel(message.modelId); + break; + case 'setConfigOption': + await this.handleSetConfigOption(message.configId, message.value); + break; + case 'executeCommand': + if (message.command) { + await vscode.commands.executeCommand(message.command); + } + break; + case 'ready': + // Webview loaded — send current session state + this.sendCurrentState(); + break; + case 'renderMarkdown': { + // Webview requests markdown rendering for history items + const items: Array<{index: number; text: string}> = message.items || []; + const rendered = items.map((item: {index: number; text: string}) => ({ + index: item.index, + html: this.renderMarkdown(item.text), + })); + this.postMessage({ type: 'markdownRendered', items: rendered }); + break; + } + } + }); + + webviewView.onDidDispose(() => { + this.view = undefined; + }); + } + + /** + * Forward session update to webview. + */ + private handleSessionUpdate(update: SessionNotification): void { + const updateData = update.update as any; + + // Persist session state BEFORE the active-session check. During session + // creation the agent can dispatch notifications (e.g. + // `available_commands_update`) before connectToAgent finishes setting + // `activeSessionId`. Without this, those updates would be dropped and + // the slash-command popup would never have commands to show. + if (updateData?.sessionUpdate === 'available_commands_update') { + this.sessionManager.applyAvailableCommands( + update.sessionId, + updateData.availableCommands || [], + ); + } + if (updateData?.sessionUpdate === 'config_option_update') { + this.sessionManager.applyConfigOptions( + update.sessionId, + updateData.configOptions || [], + ); + } + if (updateData?.sessionUpdate === 'session_info_update') { + this.sessionManager.applySessionInfoUpdate(update.sessionId, { + title: updateData.title, + updatedAt: updateData.updatedAt, + }); + } + + // Only forward to the webview if this is the active session — the + // webview only ever shows one session at a time. + const activeId = this.sessionManager.getActiveSessionId(); + if (update.sessionId !== activeId) { return; } + + this.postMessage({ + type: 'sessionUpdate', + update: update.update, + sessionId: update.sessionId, + }); + } + + /** + * Handle a prompt sent from the webview. + */ + private async handleSendPrompt(text: string): Promise { + const activeId = this.sessionManager.getActiveSessionId(); + if (!activeId) { + this.postMessage({ + type: 'error', + message: 'No active session. Create a session first.', + }); + return; + } + + sendEvent('chat/messageSent', { + agentName: this.sessionManager.getActiveAgentName() ?? '', + }, { + messageLength: text.length, + }); + + // Record the first prompt for the history store (used as a label + // fallback when no title is supplied by the agent). + this.sessionManager.recordFirstPrompt(activeId, text); + + // Tell webview we're processing + this.postMessage({ type: 'promptStart' }); + + try { + const response = await this.sessionManager.sendPrompt(activeId, text); + // Render the accumulated assistant text as markdown + // The webview will have sent us the raw text via promptEnd handling + this.postMessage({ + type: 'promptEnd', + stopReason: response.stopReason, + usage: (response as any).usage, + }); + this.sessionManager.touchHistory(activeId); + } catch (e: any) { + logError('Prompt failed', e); + this.postMessage({ + type: 'error', + message: e.message || 'Prompt failed', + }); + this.postMessage({ type: 'promptEnd', stopReason: 'error' }); + } + } + + /** + * Handle cancel request from webview. + */ + private async handleCancelTurn(): Promise { + const activeId = this.sessionManager.getActiveSessionId(); + if (activeId) { + try { + await this.sessionManager.cancelTurn(activeId); + } catch (e) { + logError('Cancel failed', e); + } + } + } + + /** + * Handle mode change from webview picker. + */ + private async handleSetMode(modeId: string): Promise { + const activeId = this.sessionManager.getActiveSessionId(); + if (!activeId || !modeId) { return; } + try { + await this.sessionManager.setMode(activeId, modeId); + } catch (e: any) { + logError('Failed to set mode', e); + this.postMessage({ type: 'error', message: `Failed to set mode: ${e.message}` }); + } + } + + /** + * Handle model change from webview picker. + */ + private async handleSetModel(modelId: string): Promise { + const activeId = this.sessionManager.getActiveSessionId(); + if (!activeId || !modelId) { return; } + try { + await this.sessionManager.setModel(activeId, modelId); + } catch (e: any) { + logError('Failed to set model', e); + this.postMessage({ type: 'error', message: `Failed to set model: ${e.message}` }); + } + } + + /** + * Handle generic config-option change from webview picker + * (ACP "Session Config Options"). The agent returns the full + * configOptions state which we re-broadcast so any cascading + * changes are reflected in the UI. + */ + private async handleSetConfigOption(configId: string, value: string): Promise { + const activeId = this.sessionManager.getActiveSessionId(); + if (!activeId || !configId) { return; } + try { + const options = await this.sessionManager.setConfigOption(activeId, configId, value); + this.postMessage({ type: 'configOptionsUpdate', configOptions: options }); + } catch (e: any) { + logError('Failed to set config option', e); + this.postMessage({ type: 'error', message: `Failed to set ${configId}: ${e.message}` }); + // Roll back optimistic update on the webview by replaying current state + const session = this.sessionManager.getSession(activeId); + this.postMessage({ + type: 'configOptionsUpdate', + configOptions: session?.configOptions ?? null, + }); + } + } + + /** + * Send current session state to the webview on load. + */ + private sendCurrentState(): void { + const activeId = this.sessionManager.getActiveSessionId(); + const session = activeId ? this.sessionManager.getSession(activeId) : null; + const config = vscode.workspace.getConfiguration('acp.chat'); + this.postMessage({ + type: 'state', + activeSessionId: activeId, + autoScroll: config.get('autoScroll', true), + session: session ? { + sessionId: session.sessionId, + agentName: session.agentDisplayName, + title: session.title, + cwd: session.cwd, + modes: session.modes, + models: session.models, + configOptions: session.configOptions, + availableCommands: session.availableCommands, + } : null, + }); + } + + /** + * Post a message to the webview if it exists. + */ + private postMessage(message: any): void { + this.view?.webview.postMessage(message); + } + + /** + * Notify webview of a new active session. + */ + notifyActiveSessionChanged(): void { + this.sendCurrentState(); + } + + /** + * Notify webview of mode state changes. + */ + notifyModesUpdate(modes: any): void { + this.postMessage({ type: 'modesUpdate', modes }); + } + + /** + * Notify webview of model state changes. + */ + notifyModelsUpdate(models: any): void { + this.postMessage({ type: 'modelsUpdate', models }); + } + + /** + * Notify webview of session config-option state changes. + */ + notifyConfigOptionsUpdate(configOptions: any): void { + this.postMessage({ type: 'configOptionsUpdate', configOptions }); + } + + /** + * Notify webview that a `session/load` replay is starting. The webview + * wipes any previously-displayed history, disables input, and shows a + * loading overlay until {@link notifyLoadSessionEnd} fires. + */ + notifyLoadSessionStart(): void { + this.postMessage({ type: 'loadSessionStart' }); + } + + /** Notify webview that the active replay finished (success or failure). */ + notifyLoadSessionEnd(ok: boolean): void { + this.postMessage({ type: 'loadSessionEnd', ok }); + } + + /** Notify webview that session title / metadata changed. */ + notifySessionInfoUpdate(title: string | undefined | null): void { + this.postMessage({ type: 'sessionInfoUpdate', title: title ?? null }); + } + + /** + * Clear the chat history and reset to welcome state. + * Called when starting a new conversation. + */ + clearChat(): void { + this._hasChatContent = false; + this.postMessage({ type: 'clearChat' }); + } + + /** + * Whether the chat has any messages. + */ + get hasChatContent(): boolean { + return this._hasChatContent; + } + + /** + * Generate the HTML content for the webview. + */ + private getHtmlContent(webview: vscode.Webview): string { + const nonce = getNonce(); + + return /*html*/ ` + + + + + + ACP Chat + + + +
+ +
+
+
+
+ +
+ +
+
+
🤖
+
ACP Chat
+
Connect to an AI coding agent to start chatting.
+
+ + +
+
or press Ctrl+Shift+A anytime
+
+
+ +
+
+
Commands
+
+
+
+ +
+ + + + +
+
+ +
+
+ +
+
+ + + + + +
+
+
Loading conversation history…
+
+ + + +`; + } + + /** + * Attach a file URI — notify the webview to include it in the next prompt. + */ + attachFile(uri: vscode.Uri): void { + if (this.view) { + this.view.webview.postMessage({ + type: 'file-attached', + path: uri.fsPath, + name: uri.fsPath.split(/[\\/]/).pop() || uri.fsPath, + }); + this.view.show?.(true); + } + } + + dispose(): void { + this.sessionUpdateHandler.removeListener(this.updateListener); + } +} + +function getNonce(): string { + let text = ''; + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +}