From 7c2bef18490cf51bdffd43fe241c46376c1feef3 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 23:39:16 +0800 Subject: [PATCH 01/33] feat(mpp-vscode): init vscode extension project structure - Create project directory structure - Add package.json with VSCode extension configuration - Add tsconfig.json for TypeScript - Add README.md with TODO list and architecture overview Closes #31 --- mpp-vscode/README.md | 128 +++++++++++++++++++++++++++++++++++++ mpp-vscode/package.json | 133 +++++++++++++++++++++++++++++++++++++++ mpp-vscode/tsconfig.json | 21 +++++++ 3 files changed, 282 insertions(+) create mode 100644 mpp-vscode/README.md create mode 100644 mpp-vscode/package.json create mode 100644 mpp-vscode/tsconfig.json diff --git a/mpp-vscode/README.md b/mpp-vscode/README.md new file mode 100644 index 0000000000..38fe3e89ee --- /dev/null +++ b/mpp-vscode/README.md @@ -0,0 +1,128 @@ +# mpp-vscode + +基于 Kotlin Multiplatform (KMP) 的 VSCode 扩展,复用 mpp-core 的核心能力。 + +## 架构概述 + +``` +mpp-vscode/ +├── package.json # VSCode 扩展配置 +├── src/ +│ ├── extension.ts # 入口点 +│ ├── services/ +│ │ ├── ide-server.ts # MCP 协议服务器 +│ │ ├── diff-manager.ts # Diff 管理 +│ │ └── chat-service.ts # Chat 服务 +│ ├── providers/ +│ │ ├── chat-view.ts # Webview Provider +│ │ └── diff-content.ts # Diff Content Provider +│ ├── commands/ +│ │ └── index.ts # 命令注册 +│ └── bridge/ +│ └── mpp-core.ts # mpp-core 桥接层 +├── webview/ # Webview UI +│ ├── src/ +│ │ ├── App.tsx +│ │ └── components/ +│ └── package.json +└── tsconfig.json +``` + +## TODO List + +### Phase 1: 项目基础设施 ✅ +- [x] 创建项目目录结构 +- [ ] 创建 package.json (VSCode 扩展配置) +- [ ] 创建 tsconfig.json +- [ ] 配置 esbuild 打包 + +### Phase 2: 核心服务 +- [ ] 实现 mpp-core 桥接层 (`src/bridge/mpp-core.ts`) + - [ ] 导入 @autodev/mpp-core + - [ ] 封装 JsKoogLLMService + - [ ] 封装 JsCodingAgent + - [ ] 封装 JsToolRegistry + - [ ] 封装 JsCompletionManager +- [ ] 实现 extension.ts 入口 + - [ ] 扩展激活/停用 + - [ ] 服务初始化 + +### Phase 3: IDE 集成 +- [ ] 实现 IDE Server (MCP 协议) + - [ ] Express HTTP 服务器 + - [ ] MCP 工具注册 (openDiff, closeDiff) + - [ ] 会话管理 +- [ ] 实现 Diff Manager + - [ ] showDiff() - 显示差异 + - [ ] acceptDiff() - 接受更改 + - [ ] cancelDiff() - 取消更改 + - [ ] Diff Content Provider + +### Phase 4: Chat 界面 +- [ ] 实现 Chat Webview Provider + - [ ] Webview 创建和管理 + - [ ] 消息桥接 (VSCode ↔ Webview) +- [ ] 创建 Webview UI + - [ ] React 项目配置 + - [ ] 聊天消息组件 + - [ ] 输入框组件 + - [ ] 代码高亮组件 + +### Phase 5: 命令和功能 +- [ ] 注册 VSCode 命令 + - [ ] autodev.chat - 打开聊天 + - [ ] autodev.acceptDiff - 接受差异 + - [ ] autodev.cancelDiff - 取消差异 + - [ ] autodev.runAgent - 运行 Agent +- [ ] 快捷键绑定 +- [ ] 状态栏集成 + +### Phase 6: 高级功能 +- [ ] DevIns 语言支持 + - [ ] 语法高亮 + - [ ] 自动补全 +- [ ] 代码索引集成 +- [ ] 领域词典支持 + +## 参考项目 + +1. **autodev-vscode** - 早期 AutoDev VSCode 版本,全功能实现 +2. **gemini-cli/vscode-ide-companion** - Gemini 的轻量级 MCP 桥接器 +3. **mpp-ui** - 现有的 CLI 工具,展示如何使用 mpp-core + +## 开发指南 + +### 构建 mpp-core + +```bash +cd /Volumes/source/ai/autocrud +./gradlew :mpp-core:assembleJsPackage +``` + +### 安装依赖 + +```bash +cd mpp-vscode +npm install +``` + +### 开发模式 + +```bash +npm run watch +``` + +### 打包扩展 + +```bash +npm run package +``` + +## 技术栈 + +- **TypeScript** - 主要开发语言 +- **mpp-core (Kotlin/JS)** - 核心 LLM 和 Agent 能力 +- **React** - Webview UI +- **Express** - MCP 服务器 +- **esbuild** - 打包工具 + diff --git a/mpp-vscode/package.json b/mpp-vscode/package.json new file mode 100644 index 0000000000..525bcaca83 --- /dev/null +++ b/mpp-vscode/package.json @@ -0,0 +1,133 @@ +{ + "name": "autodev-vscode", + "displayName": "AutoDev", + "description": "AI-powered coding assistant based on Kotlin Multiplatform", + "version": "0.1.0", + "publisher": "phodal", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/phodal/auto-dev" + }, + "engines": { + "vscode": "^1.85.0" + }, + "categories": [ + "Programming Languages", + "Machine Learning", + "Other" + ], + "keywords": [ + "ai", + "coding assistant", + "llm", + "autodev" + ], + "activationEvents": [ + "onStartupFinished" + ], + "main": "./dist/extension.js", + "contributes": { + "commands": [ + { + "command": "autodev.chat", + "title": "AutoDev: Open Chat" + }, + { + "command": "autodev.acceptDiff", + "title": "AutoDev: Accept Diff" + }, + { + "command": "autodev.cancelDiff", + "title": "AutoDev: Cancel Diff" + }, + { + "command": "autodev.runAgent", + "title": "AutoDev: Run Coding Agent" + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "autodev-sidebar", + "title": "AutoDev", + "icon": "resources/icon.svg" + } + ] + }, + "views": { + "autodev-sidebar": [ + { + "type": "webview", + "id": "autodev.chatView", + "name": "Chat" + } + ] + }, + "configuration": { + "title": "AutoDev", + "properties": { + "autodev.provider": { + "type": "string", + "default": "openai", + "enum": ["openai", "anthropic", "google", "deepseek", "ollama", "openrouter"], + "description": "LLM provider" + }, + "autodev.model": { + "type": "string", + "default": "gpt-4", + "description": "Model name" + }, + "autodev.apiKey": { + "type": "string", + "default": "", + "description": "API key for the LLM provider" + }, + "autodev.baseUrl": { + "type": "string", + "default": "", + "description": "Custom base URL for the LLM provider" + }, + "autodev.ideServer.port": { + "type": "number", + "default": 23120, + "description": "Port for the IDE server (MCP protocol)" + } + } + }, + "keybindings": [ + { + "command": "autodev.chat", + "key": "ctrl+shift+a", + "mac": "cmd+shift+a" + } + ] + }, + "scripts": { + "vscode:prepublish": "npm run build", + "build": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node", + "watch": "npm run build -- --watch", + "package": "vsce package", + "lint": "eslint src --ext ts" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^20.10.0", + "@types/vscode": "^1.85.0", + "@typescript-eslint/eslint-plugin": "^6.13.0", + "@typescript-eslint/parser": "^6.13.0", + "esbuild": "^0.19.8", + "eslint": "^8.54.0", + "typescript": "^5.3.2", + "@vscode/vsce": "^2.22.0" + }, + "dependencies": { + "@autodev/mpp-core": "file:../mpp-core/build/packages/js", + "@modelcontextprotocol/sdk": "^1.0.0", + "cors": "^2.8.5", + "express": "^4.18.2", + "zod": "^3.22.4" + } +} + diff --git a/mpp-vscode/tsconfig.json b/mpp-vscode/tsconfig.json new file mode 100644 index 0000000000..7f060c3fc8 --- /dev/null +++ b/mpp-vscode/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "moduleResolution": "node" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "webview"] +} + From 0fd86e4f11b00377bc412e7ff344796dac9369c2 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 23:48:38 +0800 Subject: [PATCH 02/33] feat(mpp-vscode): implement core services and IDE integration Phase 2 - Core Services: - Add mpp-core bridge layer with LLMService, CodingAgent, ToolRegistry, CompletionManager, and DevInsCompiler wrappers - Implement extension.ts entry point with activation/deactivation Phase 3 - IDE Integration: - Implement IDE Server with Express HTTP endpoints - Add DiffManager for file diff operations - Support MCP protocol communication Phase 4 - Chat Interface: - Implement ChatViewProvider with webview - Add streaming LLM response support - Create embedded HTML chat UI Phase 5 - Commands: - Register autodev.chat, autodev.acceptDiff, autodev.cancelDiff, autodev.runAgent - Add keyboard shortcut (Cmd+Shift+A) Testing: - Add vitest configuration - Add unit tests for mpp-core bridge - Add unit tests for DiffManager - Add unit tests for IDEServer Refs #31 --- mpp-vscode/README.md | 93 ++-- mpp-vscode/package.json | 7 +- mpp-vscode/src/bridge/mpp-core.ts | 424 ++++++++++++++++++ mpp-vscode/src/extension.ts | 147 ++++++ mpp-vscode/src/providers/chat-view.ts | 263 +++++++++++ mpp-vscode/src/services/diff-manager.ts | 262 +++++++++++ mpp-vscode/src/services/ide-server.ts | 251 +++++++++++ mpp-vscode/src/utils/logger.ts | 27 ++ mpp-vscode/test/bridge/mpp-core.test.ts | 169 +++++++ mpp-vscode/test/mocks/vscode.ts | 80 ++++ mpp-vscode/test/services/diff-manager.test.ts | 159 +++++++ mpp-vscode/test/services/ide-server.test.ts | 125 ++++++ mpp-vscode/vitest.config.ts | 22 + 13 files changed, 1984 insertions(+), 45 deletions(-) create mode 100644 mpp-vscode/src/bridge/mpp-core.ts create mode 100644 mpp-vscode/src/extension.ts create mode 100644 mpp-vscode/src/providers/chat-view.ts create mode 100644 mpp-vscode/src/services/diff-manager.ts create mode 100644 mpp-vscode/src/services/ide-server.ts create mode 100644 mpp-vscode/src/utils/logger.ts create mode 100644 mpp-vscode/test/bridge/mpp-core.test.ts create mode 100644 mpp-vscode/test/mocks/vscode.ts create mode 100644 mpp-vscode/test/services/diff-manager.test.ts create mode 100644 mpp-vscode/test/services/ide-server.test.ts create mode 100644 mpp-vscode/vitest.config.ts diff --git a/mpp-vscode/README.md b/mpp-vscode/README.md index 38fe3e89ee..5ab4cb43f3 100644 --- a/mpp-vscode/README.md +++ b/mpp-vscode/README.md @@ -32,49 +32,55 @@ mpp-vscode/ ### Phase 1: 项目基础设施 ✅ - [x] 创建项目目录结构 -- [ ] 创建 package.json (VSCode 扩展配置) -- [ ] 创建 tsconfig.json -- [ ] 配置 esbuild 打包 - -### Phase 2: 核心服务 -- [ ] 实现 mpp-core 桥接层 (`src/bridge/mpp-core.ts`) - - [ ] 导入 @autodev/mpp-core - - [ ] 封装 JsKoogLLMService - - [ ] 封装 JsCodingAgent - - [ ] 封装 JsToolRegistry - - [ ] 封装 JsCompletionManager -- [ ] 实现 extension.ts 入口 - - [ ] 扩展激活/停用 - - [ ] 服务初始化 - -### Phase 3: IDE 集成 -- [ ] 实现 IDE Server (MCP 协议) - - [ ] Express HTTP 服务器 - - [ ] MCP 工具注册 (openDiff, closeDiff) - - [ ] 会话管理 -- [ ] 实现 Diff Manager - - [ ] showDiff() - 显示差异 - - [ ] acceptDiff() - 接受更改 - - [ ] cancelDiff() - 取消更改 - - [ ] Diff Content Provider - -### Phase 4: Chat 界面 -- [ ] 实现 Chat Webview Provider - - [ ] Webview 创建和管理 - - [ ] 消息桥接 (VSCode ↔ Webview) -- [ ] 创建 Webview UI - - [ ] React 项目配置 - - [ ] 聊天消息组件 - - [ ] 输入框组件 - - [ ] 代码高亮组件 - -### Phase 5: 命令和功能 -- [ ] 注册 VSCode 命令 - - [ ] autodev.chat - 打开聊天 - - [ ] autodev.acceptDiff - 接受差异 - - [ ] autodev.cancelDiff - 取消差异 - - [ ] autodev.runAgent - 运行 Agent -- [ ] 快捷键绑定 +- [x] 创建 package.json (VSCode 扩展配置) +- [x] 创建 tsconfig.json +- [x] 配置 esbuild 打包 +- [x] 配置 vitest 测试框架 + +### Phase 2: 核心服务 ✅ +- [x] 实现 mpp-core 桥接层 (`src/bridge/mpp-core.ts`) + - [x] 导入 @autodev/mpp-core + - [x] 封装 LLMService (JsKoogLLMService) + - [x] 封装 CodingAgent (JsCodingAgent) + - [x] 封装 ToolRegistry (JsToolRegistry) + - [x] 封装 CompletionManager (JsCompletionManager) + - [x] 封装 DevInsCompiler (JsDevInsCompiler) +- [x] 实现 extension.ts 入口 + - [x] 扩展激活/停用 + - [x] 服务初始化 +- [x] 添加单元测试 (`test/bridge/mpp-core.test.ts`) + +### Phase 3: IDE 集成 ✅ +- [x] 实现 IDE Server (MCP 协议) + - [x] Express HTTP 服务器 + - [x] 端点: /health, /context, /diff/open, /diff/close, /file/read, /file/write + - [x] 认证和 CORS 保护 + - [x] 端口文件写入 (~/.autodev/ide-server.json) +- [x] 实现 Diff Manager + - [x] showDiff() - 显示差异 + - [x] acceptDiff() - 接受更改 + - [x] cancelDiff() - 取消更改 + - [x] closeDiffByPath() - 按路径关闭 + - [x] DiffContentProvider +- [x] 添加单元测试 (`test/services/`) + +### Phase 4: Chat 界面 ✅ +- [x] 实现 Chat Webview Provider + - [x] Webview 创建和管理 + - [x] 消息桥接 (VSCode ↔ Webview) + - [x] LLM 服务集成 +- [x] 创建 Webview UI (内嵌 HTML) + - [x] 聊天消息组件 + - [x] 输入框组件 + - [x] 流式响应显示 + +### Phase 5: 命令和功能 ✅ +- [x] 注册 VSCode 命令 + - [x] autodev.chat - 打开聊天 + - [x] autodev.acceptDiff - 接受差异 + - [x] autodev.cancelDiff - 取消差异 + - [x] autodev.runAgent - 运行 Agent +- [x] 快捷键绑定 (Cmd+Shift+A) - [ ] 状态栏集成 ### Phase 6: 高级功能 @@ -83,6 +89,7 @@ mpp-vscode/ - [ ] 自动补全 - [ ] 代码索引集成 - [ ] 领域词典支持 +- [ ] React Webview UI (替换内嵌 HTML) ## 参考项目 diff --git a/mpp-vscode/package.json b/mpp-vscode/package.json index 525bcaca83..ebaaa5b6c1 100644 --- a/mpp-vscode/package.json +++ b/mpp-vscode/package.json @@ -108,7 +108,9 @@ "build": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node", "watch": "npm run build -- --watch", "package": "vsce package", - "lint": "eslint src --ext ts" + "lint": "eslint src --ext ts", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { "@types/cors": "^2.8.17", @@ -120,7 +122,8 @@ "esbuild": "^0.19.8", "eslint": "^8.54.0", "typescript": "^5.3.2", - "@vscode/vsce": "^2.22.0" + "@vscode/vsce": "^2.22.0", + "vitest": "^1.0.0" }, "dependencies": { "@autodev/mpp-core": "file:../mpp-core/build/packages/js", diff --git a/mpp-vscode/src/bridge/mpp-core.ts b/mpp-vscode/src/bridge/mpp-core.ts new file mode 100644 index 0000000000..e6603a0c60 --- /dev/null +++ b/mpp-vscode/src/bridge/mpp-core.ts @@ -0,0 +1,424 @@ +/** + * mpp-core Bridge - TypeScript wrapper for Kotlin/JS compiled mpp-core module + * + * This module provides a TypeScript-friendly interface to the mpp-core + * Kotlin Multiplatform library. + */ + +// @ts-ignore - Kotlin/JS generated module +import MppCore from '@autodev/mpp-core'; + +// Access the exported Kotlin/JS classes +const { + JsKoogLLMService, + JsModelConfig, + JsMessage, + JsModelRegistry, + JsCompletionManager, + JsDevInsCompiler, + JsToolRegistry, + JsCompressionConfig +} = MppCore.cc.unitmesh.llm; + +const { JsCodingAgent, JsAgentTask } = MppCore.cc.unitmesh.agent; + +// Provider type mapping +export const ProviderTypes: Record = { + 'openai': 'OPENAI', + 'anthropic': 'ANTHROPIC', + 'google': 'GOOGLE', + 'deepseek': 'DEEPSEEK', + 'ollama': 'OLLAMA', + 'openrouter': 'OPENROUTER', + 'custom-openai-base': 'CUSTOM_OPENAI_BASE' +}; + +/** + * Model configuration interface + */ +export interface ModelConfig { + provider: string; + model: string; + apiKey: string; + temperature?: number; + maxTokens?: number; + baseUrl?: string; +} + +/** + * Message interface for chat history + */ +export interface ChatMessage { + role: 'user' | 'assistant' | 'system'; + content: string; +} + +/** + * LLM Service wrapper - provides streaming and non-streaming LLM calls + */ +export class LLMService { + private koogService: any; + private chatHistory: ChatMessage[] = []; + + constructor(private config: ModelConfig) { + const providerName = ProviderTypes[config.provider.toLowerCase()] || config.provider.toUpperCase(); + + const modelConfig = new JsModelConfig( + providerName, + config.model, + config.apiKey, + config.temperature ?? 0.7, + config.maxTokens ?? 8192, + config.baseUrl ?? '' + ); + + this.koogService = new JsKoogLLMService(modelConfig); + } + + /** + * Stream a message and receive chunks via callback + */ + async streamMessage( + message: string, + onChunk: (chunk: string) => void + ): Promise { + this.chatHistory.push({ role: 'user', content: message }); + + const historyMessages = this.chatHistory.slice(0, -1).map(msg => + new JsMessage(msg.role, msg.content) + ); + + let fullResponse = ''; + + await this.koogService.streamPrompt( + message, + historyMessages, + (chunk: string) => { + fullResponse += chunk; + onChunk(chunk); + }, + (error: any) => { + throw new Error(`LLM Error: ${error.message || error}`); + }, + () => { /* complete */ } + ); + + this.chatHistory.push({ role: 'assistant', content: fullResponse }); + return fullResponse; + } + + /** + * Send a prompt and get complete response (non-streaming) + */ + async sendPrompt(prompt: string): Promise { + return await this.koogService.sendPrompt(prompt); + } + + /** + * Clear chat history + */ + clearHistory(): void { + this.chatHistory = []; + } + + /** + * Get chat history + */ + getHistory(): ChatMessage[] { + return [...this.chatHistory]; + } + + /** + * Get token info from last request + */ + getLastTokenInfo(): { totalTokens: number; inputTokens: number; outputTokens: number } { + const info = this.koogService.getLastTokenInfo(); + return { + totalTokens: info.totalTokens, + inputTokens: info.inputTokens, + outputTokens: info.outputTokens + }; + } +} + +/** + * Completion Manager - provides auto-completion for @agent, /command, $variable + */ +export class CompletionManager { + private manager: any; + + constructor() { + this.manager = new JsCompletionManager(); + } + + /** + * Initialize workspace for file path completion + */ + async initWorkspace(workspacePath: string): Promise { + return await this.manager.initWorkspace(workspacePath); + } + + /** + * Get completion suggestions + */ + getCompletions(text: string, cursorPosition: number): CompletionItem[] { + const items = this.manager.getCompletions(text, cursorPosition); + return Array.from(items).map((item: any) => ({ + text: item.text, + displayText: item.displayText, + description: item.description, + icon: item.icon, + triggerType: item.triggerType, + index: item.index + })); + } +} + +export interface CompletionItem { + text: string; + displayText: string; + description: string | null; + icon: string | null; + triggerType: string; + index: number; +} + +/** + * DevIns Compiler - compiles DevIns code (e.g., "/read-file:path") + */ +export class DevInsCompiler { + private compiler: any; + + constructor() { + this.compiler = new JsDevInsCompiler(); + } + + /** + * Compile DevIns source code + */ + async compile(source: string): Promise { + const result = await this.compiler.compile(source); + return { + success: result.success, + output: result.output, + errorMessage: result.errorMessage, + hasCommand: result.hasCommand + }; + } + + /** + * Compile and return just the output string + */ + async compileToString(source: string): Promise { + return await this.compiler.compileToString(source); + } +} + +export interface DevInsResult { + success: boolean; + output: string; + errorMessage: string | null; + hasCommand: boolean; +} + +/** + * Tool Registry - provides access to built-in tools + */ +export class ToolRegistry { + private registry: any; + + constructor(projectPath: string) { + this.registry = new JsToolRegistry(projectPath); + } + + /** + * Read a file + */ + async readFile(path: string, startLine?: number, endLine?: number): Promise { + const result = await this.registry.readFile(path, startLine, endLine); + return this.toToolResult(result); + } + + /** + * Write a file + */ + async writeFile(path: string, content: string, createDirectories = true): Promise { + const result = await this.registry.writeFile(path, content, createDirectories); + return this.toToolResult(result); + } + + /** + * Glob pattern matching + */ + async glob(pattern: string, path = '.', includeFileInfo = false): Promise { + const result = await this.registry.glob(pattern, path, includeFileInfo); + return this.toToolResult(result); + } + + /** + * Grep search + */ + async grep( + pattern: string, + path = '.', + options?: { include?: string; exclude?: string; recursive?: boolean; caseSensitive?: boolean } + ): Promise { + const result = await this.registry.grep( + pattern, + path, + options?.include, + options?.exclude, + options?.recursive ?? true, + options?.caseSensitive ?? true + ); + return this.toToolResult(result); + } + + /** + * Execute shell command + */ + async shell(command: string, workingDirectory?: string, timeoutMs = 30000): Promise { + const result = await this.registry.shell(command, workingDirectory, timeoutMs); + return this.toToolResult(result); + } + + /** + * Get available tools + */ + getAvailableTools(): string[] { + return Array.from(this.registry.getAvailableTools()); + } + + /** + * Format tool list for AI consumption + */ + formatToolListForAI(): string { + return this.registry.formatToolListForAI(); + } + + private toToolResult(result: any): ToolResult { + return { + success: result.success, + output: result.output, + errorMessage: result.errorMessage, + metadata: result.metadata + }; + } +} + +export interface ToolResult { + success: boolean; + output: string; + errorMessage: string | null; + metadata: Record; +} + +/** + * Coding Agent - AI-powered coding assistant + */ +export class CodingAgent { + private agent: any; + + constructor( + projectPath: string, + llmService: LLMService, + options?: { + maxIterations?: number; + mcpServers?: Record; + } + ) { + // Access internal koogService + const internalService = (llmService as any).koogService; + + this.agent = new JsCodingAgent( + projectPath, + internalService, + options?.maxIterations ?? 100, + null, // renderer + options?.mcpServers ?? null, + null // toolConfig + ); + } + + /** + * Execute a coding task + */ + async executeTask(requirement: string, projectPath: string): Promise { + const task = new JsAgentTask(requirement, projectPath); + const result = await this.agent.executeTask(task); + + return { + success: result.success, + message: result.message, + steps: Array.from(result.steps).map((step: any) => ({ + step: step.step, + action: step.action, + tool: step.tool, + params: step.params, + result: step.result, + success: step.success + })), + edits: Array.from(result.edits).map((edit: any) => ({ + file: edit.file, + operation: edit.operation, + content: edit.content + })) + }; + } + + /** + * Initialize workspace + */ + async initializeWorkspace(): Promise { + await this.agent.initializeWorkspace(); + } + + /** + * Get conversation history + */ + getConversationHistory(): ChatMessage[] { + const history = this.agent.getConversationHistory(); + return Array.from(history).map((msg: any) => ({ + role: msg.role as 'user' | 'assistant' | 'system', + content: msg.content + })); + } +} + +export interface AgentResult { + success: boolean; + message: string; + steps: AgentStep[]; + edits: AgentEdit[]; +} + +export interface AgentStep { + step: number; + action: string; + tool: string | null; + params: string | null; + result: string | null; + success: boolean; +} + +export interface AgentEdit { + file: string; + operation: string; + content: string | null; +} + +/** + * Get available models for a provider + */ +export function getAvailableModels(provider: string): string[] { + const providerName = ProviderTypes[provider.toLowerCase()] || provider.toUpperCase(); + return Array.from(JsModelRegistry.getAvailableModels(providerName)); +} + +/** + * Get all supported providers + */ +export function getAllProviders(): string[] { + return Array.from(JsModelRegistry.getAllProviders()); +} + diff --git a/mpp-vscode/src/extension.ts b/mpp-vscode/src/extension.ts new file mode 100644 index 0000000000..4506200ea9 --- /dev/null +++ b/mpp-vscode/src/extension.ts @@ -0,0 +1,147 @@ +/** + * AutoDev VSCode Extension - Entry Point + * + * Based on Kotlin Multiplatform (KMP) technology, reusing mpp-core capabilities. + */ + +import * as vscode from 'vscode'; +import { IDEServer } from './services/ide-server'; +import { DiffManager, DiffContentProvider } from './services/diff-manager'; +import { ChatViewProvider } from './providers/chat-view'; +import { createLogger } from './utils/logger'; + +export const DIFF_SCHEME = 'autodev-diff'; + +let ideServer: IDEServer | undefined; +let logger: vscode.OutputChannel; +let log: (message: string) => void = () => {}; + +/** + * Extension activation + */ +export async function activate(context: vscode.ExtensionContext) { + logger = vscode.window.createOutputChannel('AutoDev'); + log = createLogger(context, logger); + log('AutoDev extension activated'); + + // Initialize Diff Manager + const diffContentProvider = new DiffContentProvider(); + const diffManager = new DiffManager(log, diffContentProvider); + + // Register Diff Content Provider + context.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider(DIFF_SCHEME, diffContentProvider) + ); + + // Handle diff document close + context.subscriptions.push( + vscode.workspace.onDidCloseTextDocument((doc) => { + if (doc.uri.scheme === DIFF_SCHEME) { + diffManager.cancelDiff(doc.uri); + } + }) + ); + + // Register diff commands + context.subscriptions.push( + vscode.commands.registerCommand('autodev.acceptDiff', (uri?: vscode.Uri) => { + const docUri = uri ?? vscode.window.activeTextEditor?.document.uri; + if (docUri && docUri.scheme === DIFF_SCHEME) { + diffManager.acceptDiff(docUri); + } + }), + vscode.commands.registerCommand('autodev.cancelDiff', (uri?: vscode.Uri) => { + const docUri = uri ?? vscode.window.activeTextEditor?.document.uri; + if (docUri && docUri.scheme === DIFF_SCHEME) { + diffManager.cancelDiff(docUri); + } + }) + ); + + // Initialize Chat View Provider + const chatViewProvider = new ChatViewProvider(context, log); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider('autodev.chatView', chatViewProvider, { + webviewOptions: { retainContextWhenHidden: true } + }) + ); + + // Register chat command + context.subscriptions.push( + vscode.commands.registerCommand('autodev.chat', () => { + vscode.commands.executeCommand('autodev.chatView.focus'); + }) + ); + + // Register run agent command + context.subscriptions.push( + vscode.commands.registerCommand('autodev.runAgent', async () => { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + vscode.window.showWarningMessage('Please open a folder to run AutoDev Agent.'); + return; + } + + const input = await vscode.window.showInputBox({ + prompt: 'Enter your coding task', + placeHolder: 'e.g., Add a new API endpoint for user authentication' + }); + + if (input) { + chatViewProvider.sendMessage(input); + } + }) + ); + + // Initialize IDE Server (MCP protocol) + const config = vscode.workspace.getConfiguration('autodev'); + const serverPort = config.get('ideServer.port', 23120); + + ideServer = new IDEServer(log, diffManager, serverPort); + try { + await ideServer.start(context); + log(`IDE Server started on port ${serverPort}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log(`Failed to start IDE server: ${message}`); + } + + // Handle workspace folder changes + context.subscriptions.push( + vscode.workspace.onDidChangeWorkspaceFolders(() => { + ideServer?.syncEnvVars(); + }) + ); + + // Show welcome message on first install + const welcomeShownKey = 'autodev.welcomeShown'; + if (!context.globalState.get(welcomeShownKey)) { + vscode.window.showInformationMessage( + 'AutoDev extension installed successfully! Press Cmd+Shift+A to open chat.' + ); + context.globalState.update(welcomeShownKey, true); + } + + log('AutoDev extension initialization complete'); +} + +/** + * Extension deactivation + */ +export async function deactivate(): Promise { + log('AutoDev extension deactivating'); + + try { + if (ideServer) { + await ideServer.stop(); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log(`Failed to stop IDE server: ${message}`); + } finally { + if (logger) { + logger.dispose(); + } + } +} + diff --git a/mpp-vscode/src/providers/chat-view.ts b/mpp-vscode/src/providers/chat-view.ts new file mode 100644 index 0000000000..114f3572f6 --- /dev/null +++ b/mpp-vscode/src/providers/chat-view.ts @@ -0,0 +1,263 @@ +/** + * Chat View Provider - Webview for chat interface + */ + +import * as vscode from 'vscode'; +import { LLMService, ModelConfig } from '../bridge/mpp-core'; + +/** + * Chat View Provider for the sidebar webview + */ +export class ChatViewProvider implements vscode.WebviewViewProvider { + private webviewView: vscode.WebviewView | undefined; + private llmService: LLMService | undefined; + private messages: Array<{ role: string; content: string }> = []; + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly log: (message: string) => void + ) {} + + resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken + ): void { + this.webviewView = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [this.context.extensionUri] + }; + + webviewView.webview.html = this.getHtmlContent(); + + // Handle messages from webview + webviewView.webview.onDidReceiveMessage(async (message) => { + switch (message.type) { + case 'sendMessage': + await this.handleUserMessage(message.content); + break; + case 'clearHistory': + this.clearHistory(); + break; + } + }); + + // Initialize LLM service + this.initializeLLMService(); + } + + /** + * Send a message programmatically + */ + async sendMessage(content: string): Promise { + if (this.webviewView) { + // Show the chat view + this.webviewView.show(true); + } + await this.handleUserMessage(content); + } + + /** + * Post a message to the webview + */ + postMessage(message: any): void { + this.webviewView?.webview.postMessage(message); + } + + private initializeLLMService(): void { + const config = vscode.workspace.getConfiguration('autodev'); + + const modelConfig: ModelConfig = { + provider: config.get('provider', 'openai'), + model: config.get('model', 'gpt-4'), + apiKey: config.get('apiKey', ''), + baseUrl: config.get('baseUrl', '') + }; + + if (!modelConfig.apiKey) { + this.postMessage({ + type: 'error', + content: 'Please configure your API key in settings (autodev.apiKey)' + }); + return; + } + + try { + this.llmService = new LLMService(modelConfig); + this.log(`LLM Service initialized: ${modelConfig.provider}/${modelConfig.model}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.log(`Failed to initialize LLM service: ${message}`); + this.postMessage({ type: 'error', content: message }); + } + } + + private async handleUserMessage(content: string): Promise { + if (!this.llmService) { + this.initializeLLMService(); + if (!this.llmService) { + return; + } + } + + // Add user message + this.messages.push({ role: 'user', content }); + this.postMessage({ type: 'userMessage', content }); + + // Start streaming response + this.postMessage({ type: 'startResponse' }); + + try { + let fullResponse = ''; + + await this.llmService.streamMessage(content, (chunk) => { + fullResponse += chunk; + this.postMessage({ type: 'responseChunk', content: chunk }); + }); + + this.messages.push({ role: 'assistant', content: fullResponse }); + this.postMessage({ type: 'endResponse' }); + + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.log(`Error in chat: ${message}`); + this.postMessage({ type: 'error', content: message }); + } + } + + private clearHistory(): void { + this.messages = []; + this.llmService?.clearHistory(); + this.postMessage({ type: 'historyCleared' }); + } + + private getHtmlContent(): string { + return ` + + + + + AutoDev Chat + + + +
+
+ + +
+ + +`; + } +} + diff --git a/mpp-vscode/src/services/diff-manager.ts b/mpp-vscode/src/services/diff-manager.ts new file mode 100644 index 0000000000..4aa47bb595 --- /dev/null +++ b/mpp-vscode/src/services/diff-manager.ts @@ -0,0 +1,262 @@ +/** + * Diff Manager - Manages file diff views in VSCode + * + * Handles showing, accepting, and canceling file diffs. + */ + +import * as vscode from 'vscode'; +import * as path from 'path'; +import { DIFF_SCHEME } from '../extension'; + +/** + * Provides content for diff documents + */ +export class DiffContentProvider implements vscode.TextDocumentContentProvider { + private content = new Map(); + private onDidChangeEmitter = new vscode.EventEmitter(); + + get onDidChange(): vscode.Event { + return this.onDidChangeEmitter.event; + } + + provideTextDocumentContent(uri: vscode.Uri): string { + return this.content.get(uri.toString()) ?? ''; + } + + setContent(uri: vscode.Uri, content: string): void { + this.content.set(uri.toString(), content); + this.onDidChangeEmitter.fire(uri); + } + + deleteContent(uri: vscode.Uri): void { + this.content.delete(uri.toString()); + } + + getContent(uri: vscode.Uri): string | undefined { + return this.content.get(uri.toString()); + } +} + +/** + * Information about an open diff view + */ +interface DiffInfo { + originalFilePath: string; + newContent: string; + rightDocUri: vscode.Uri; +} + +/** + * Event types for diff changes + */ +export interface DiffEvent { + type: 'accepted' | 'closed'; + filePath: string; + content: string; +} + +/** + * Manages diff view lifecycle + */ +export class DiffManager { + private readonly onDidChangeEmitter = new vscode.EventEmitter(); + readonly onDidChange = this.onDidChangeEmitter.event; + + private diffDocuments = new Map(); + private readonly subscriptions: vscode.Disposable[] = []; + + constructor( + private readonly log: (message: string) => void, + private readonly diffContentProvider: DiffContentProvider + ) { + this.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor((editor) => { + this.onActiveEditorChange(editor); + }) + ); + this.onActiveEditorChange(vscode.window.activeTextEditor); + } + + dispose(): void { + for (const subscription of this.subscriptions) { + subscription.dispose(); + } + } + + /** + * Show a diff view for a file + */ + async showDiff(filePath: string, newContent: string): Promise { + const fileUri = vscode.Uri.file(filePath); + + const rightDocUri = vscode.Uri.from({ + scheme: DIFF_SCHEME, + path: filePath, + query: `rand=${Math.random()}` + }); + + this.diffContentProvider.setContent(rightDocUri, newContent); + this.diffDocuments.set(rightDocUri.toString(), { + originalFilePath: filePath, + newContent, + rightDocUri + }); + + const diffTitle = `${path.basename(filePath)} ↔ Modified`; + await vscode.commands.executeCommand('setContext', 'autodev.diff.isVisible', true); + + let leftDocUri: vscode.Uri; + try { + await vscode.workspace.fs.stat(fileUri); + leftDocUri = fileUri; + } catch { + // File doesn't exist, use untitled scheme + leftDocUri = vscode.Uri.from({ + scheme: 'untitled', + path: filePath + }); + } + + await vscode.commands.executeCommand( + 'vscode.diff', + leftDocUri, + rightDocUri, + diffTitle, + { preview: false, preserveFocus: true } + ); + + this.log(`Showing diff for: ${filePath}`); + } + + /** + * Accept changes in a diff view + */ + async acceptDiff(rightDocUri: vscode.Uri): Promise { + const diffInfo = this.diffDocuments.get(rightDocUri.toString()); + if (!diffInfo) { + return; + } + + const rightDoc = await vscode.workspace.openTextDocument(rightDocUri); + const modifiedContent = rightDoc.getText(); + + // Write the content to the original file + const fileUri = vscode.Uri.file(diffInfo.originalFilePath); + await vscode.workspace.fs.writeFile(fileUri, Buffer.from(modifiedContent, 'utf8')); + + await this.closeDiffEditor(rightDocUri); + + this.onDidChangeEmitter.fire({ + type: 'accepted', + filePath: diffInfo.originalFilePath, + content: modifiedContent + }); + + this.log(`Accepted diff for: ${diffInfo.originalFilePath}`); + } + + /** + * Cancel a diff view + */ + async cancelDiff(rightDocUri: vscode.Uri): Promise { + const diffInfo = this.diffDocuments.get(rightDocUri.toString()); + if (!diffInfo) { + await this.closeDiffEditor(rightDocUri); + return; + } + + const rightDoc = await vscode.workspace.openTextDocument(rightDocUri); + const modifiedContent = rightDoc.getText(); + await this.closeDiffEditor(rightDocUri); + + this.onDidChangeEmitter.fire({ + type: 'closed', + filePath: diffInfo.originalFilePath, + content: modifiedContent + }); + + this.log(`Cancelled diff for: ${diffInfo.originalFilePath}`); + } + + /** + * Close a diff by file path + */ + async closeDiffByPath(filePath: string): Promise { + let uriToClose: vscode.Uri | undefined; + + for (const [uriString, diffInfo] of this.diffDocuments.entries()) { + if (diffInfo.originalFilePath === filePath) { + uriToClose = vscode.Uri.parse(uriString); + break; + } + } + + if (uriToClose) { + const rightDoc = await vscode.workspace.openTextDocument(uriToClose); + const modifiedContent = rightDoc.getText(); + await this.closeDiffEditor(uriToClose); + + this.onDidChangeEmitter.fire({ + type: 'closed', + filePath, + content: modifiedContent + }); + + return modifiedContent; + } + + return undefined; + } + + /** + * Check if a diff is open for a file + */ + hasDiff(filePath: string): boolean { + for (const diffInfo of this.diffDocuments.values()) { + if (diffInfo.originalFilePath === filePath) { + return true; + } + } + return false; + } + + private async onActiveEditorChange(editor: vscode.TextEditor | undefined): Promise { + let isVisible = false; + + if (editor) { + isVisible = this.diffDocuments.has(editor.document.uri.toString()); + if (!isVisible) { + for (const document of this.diffDocuments.values()) { + if (document.originalFilePath === editor.document.uri.fsPath) { + isVisible = true; + break; + } + } + } + } + + await vscode.commands.executeCommand('setContext', 'autodev.diff.isVisible', isVisible); + } + + private async closeDiffEditor(rightDocUri: vscode.Uri): Promise { + const diffInfo = this.diffDocuments.get(rightDocUri.toString()); + await vscode.commands.executeCommand('setContext', 'autodev.diff.isVisible', false); + + if (diffInfo) { + this.diffDocuments.delete(rightDocUri.toString()); + this.diffContentProvider.deleteContent(rightDocUri); + } + + // Find and close the tab + for (const tabGroup of vscode.window.tabGroups.all) { + for (const tab of tabGroup.tabs) { + const input = tab.input as { modified?: vscode.Uri; original?: vscode.Uri }; + if (input && input.modified?.toString() === rightDocUri.toString()) { + await vscode.window.tabGroups.close(tab); + return; + } + } + } + } +} + diff --git a/mpp-vscode/src/services/ide-server.ts b/mpp-vscode/src/services/ide-server.ts new file mode 100644 index 0000000000..3fe0b9258d --- /dev/null +++ b/mpp-vscode/src/services/ide-server.ts @@ -0,0 +1,251 @@ +/** + * IDE Server - MCP protocol server for external tool communication + * + * Provides HTTP endpoints for diff operations and workspace context. + */ + +import * as vscode from 'vscode'; +import express, { Request, Response, NextFunction } from 'express'; +import cors from 'cors'; +import { randomUUID } from 'crypto'; +import { Server as HTTPServer } from 'http'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import { DiffManager } from './diff-manager'; + +const IDE_SERVER_PORT_ENV_VAR = 'AUTODEV_IDE_SERVER_PORT'; +const IDE_WORKSPACE_PATH_ENV_VAR = 'AUTODEV_IDE_WORKSPACE_PATH'; + +/** + * IDE Server for MCP protocol communication + */ +export class IDEServer { + private server: HTTPServer | undefined; + private context: vscode.ExtensionContext | undefined; + private portFile: string | undefined; + private authToken: string | undefined; + + constructor( + private readonly log: (message: string) => void, + private readonly diffManager: DiffManager, + private readonly port: number + ) {} + + /** + * Start the IDE server + */ + async start(context: vscode.ExtensionContext): Promise { + this.context = context; + this.authToken = randomUUID(); + + const app = express(); + app.use(express.json({ limit: '10mb' })); + + // CORS - only allow non-browser requests + app.use(cors({ + origin: (origin, callback) => { + if (!origin) { + return callback(null, true); + } + return callback(new Error('Request denied by CORS policy.'), false); + } + })); + + // Host validation + app.use((req: Request, res: Response, next: NextFunction) => { + const host = req.headers.host || ''; + const allowedHosts = [`localhost:${this.port}`, `127.0.0.1:${this.port}`]; + if (!allowedHosts.includes(host)) { + return res.status(403).json({ error: 'Invalid Host header' }); + } + next(); + }); + + // Auth validation + app.use((req: Request, res: Response, next: NextFunction) => { + const authHeader = req.headers.authorization; + if (!authHeader) { + this.log('Missing Authorization header'); + return res.status(401).send('Unauthorized'); + } + + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer' || parts[1] !== this.authToken) { + this.log('Invalid auth token'); + return res.status(401).send('Unauthorized'); + } + next(); + }); + + // Health check endpoint + app.get('/health', (_req: Request, res: Response) => { + res.json({ status: 'ok', version: '0.1.0' }); + }); + + // Get workspace context + app.get('/context', (_req: Request, res: Response) => { + const workspaceFolders = vscode.workspace.workspaceFolders; + const activeEditor = vscode.window.activeTextEditor; + + res.json({ + workspaceFolders: workspaceFolders?.map(f => ({ + name: f.name, + path: f.uri.fsPath + })) ?? [], + activeFile: activeEditor?.document.uri.fsPath ?? null, + selection: activeEditor?.selection ? { + start: { line: activeEditor.selection.start.line, character: activeEditor.selection.start.character }, + end: { line: activeEditor.selection.end.line, character: activeEditor.selection.end.character } + } : null + }); + }); + + // Open diff endpoint + app.post('/diff/open', async (req: Request, res: Response) => { + try { + const { filePath, content } = req.body; + if (!filePath || content === undefined) { + return res.status(400).json({ error: 'filePath and content are required' }); + } + + await this.diffManager.showDiff(filePath, content); + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.log(`Error opening diff: ${message}`); + res.status(500).json({ error: message }); + } + }); + + // Close diff endpoint + app.post('/diff/close', async (req: Request, res: Response) => { + try { + const { filePath } = req.body; + if (!filePath) { + return res.status(400).json({ error: 'filePath is required' }); + } + + const content = await this.diffManager.closeDiffByPath(filePath); + res.json({ success: true, content }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.log(`Error closing diff: ${message}`); + res.status(500).json({ error: message }); + } + }); + + // Read file endpoint + app.post('/file/read', async (req: Request, res: Response) => { + try { + const { filePath } = req.body; + if (!filePath) { + return res.status(400).json({ error: 'filePath is required' }); + } + + const uri = vscode.Uri.file(filePath); + const content = await vscode.workspace.fs.readFile(uri); + res.json({ success: true, content: Buffer.from(content).toString('utf8') }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + res.status(500).json({ error: message }); + } + }); + + // Write file endpoint + app.post('/file/write', async (req: Request, res: Response) => { + try { + const { filePath, content } = req.body; + if (!filePath || content === undefined) { + return res.status(400).json({ error: 'filePath and content are required' }); + } + + const uri = vscode.Uri.file(filePath); + await vscode.workspace.fs.writeFile(uri, Buffer.from(content, 'utf8')); + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + res.status(500).json({ error: message }); + } + }); + + // Start server + return new Promise((resolve, reject) => { + this.server = app.listen(this.port, '127.0.0.1', async () => { + this.log(`IDE Server listening on port ${this.port}`); + + // Write port file for external tools + await this.writePortFile(); + this.syncEnvVars(); + + resolve(); + }); + + this.server.on('error', (err) => { + this.log(`IDE Server error: ${err.message}`); + reject(err); + }); + }); + } + + /** + * Stop the IDE server + */ + async stop(): Promise { + if (this.server) { + return new Promise((resolve) => { + this.server!.close(() => { + this.log('IDE Server stopped'); + resolve(); + }); + }); + } + } + + /** + * Sync environment variables for terminals + */ + syncEnvVars(): void { + if (!this.context) return; + + const workspaceFolders = vscode.workspace.workspaceFolders; + const workspacePath = workspaceFolders && workspaceFolders.length > 0 + ? workspaceFolders.map(f => f.uri.fsPath).join(path.delimiter) + : ''; + + this.context.environmentVariableCollection.replace( + IDE_SERVER_PORT_ENV_VAR, + this.port.toString() + ); + this.context.environmentVariableCollection.replace( + IDE_WORKSPACE_PATH_ENV_VAR, + workspacePath + ); + } + + /** + * Write port file for external tools to discover the server + */ + private async writePortFile(): Promise { + const autodevDir = path.join(os.homedir(), '.autodev'); + this.portFile = path.join(autodevDir, 'ide-server.json'); + + const content = JSON.stringify({ + port: this.port, + authToken: this.authToken, + workspacePath: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '', + pid: process.pid + }); + + try { + await fs.mkdir(autodevDir, { recursive: true }); + await fs.writeFile(this.portFile, content); + await fs.chmod(this.portFile, 0o600); + this.log(`Port file written to: ${this.portFile}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.log(`Failed to write port file: ${message}`); + } + } +} + diff --git a/mpp-vscode/src/utils/logger.ts b/mpp-vscode/src/utils/logger.ts new file mode 100644 index 0000000000..08aae7dc19 --- /dev/null +++ b/mpp-vscode/src/utils/logger.ts @@ -0,0 +1,27 @@ +/** + * Logger utility for AutoDev extension + */ + +import * as vscode from 'vscode'; + +/** + * Create a logger function that writes to both output channel and console + */ +export function createLogger( + context: vscode.ExtensionContext, + outputChannel: vscode.OutputChannel +): (message: string) => void { + const isDevelopment = context.extensionMode === vscode.ExtensionMode.Development; + + return (message: string) => { + const timestamp = new Date().toISOString(); + const formattedMessage = `[${timestamp}] ${message}`; + + outputChannel.appendLine(formattedMessage); + + if (isDevelopment) { + console.log(`[AutoDev] ${formattedMessage}`); + } + }; +} + diff --git a/mpp-vscode/test/bridge/mpp-core.test.ts b/mpp-vscode/test/bridge/mpp-core.test.ts new file mode 100644 index 0000000000..7d760ce3e3 --- /dev/null +++ b/mpp-vscode/test/bridge/mpp-core.test.ts @@ -0,0 +1,169 @@ +/** + * Tests for mpp-core bridge + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ProviderTypes } from '../../src/bridge/mpp-core'; + +// Mock the mpp-core module +vi.mock('@autodev/mpp-core', () => ({ + default: { + cc: { + unitmesh: { + llm: { + JsKoogLLMService: vi.fn().mockImplementation(() => ({ + streamPrompt: vi.fn().mockResolvedValue(undefined), + sendPrompt: vi.fn().mockResolvedValue('test response'), + getLastTokenInfo: vi.fn().mockReturnValue({ totalTokens: 100, inputTokens: 50, outputTokens: 50 }) + })), + JsModelConfig: vi.fn(), + JsMessage: vi.fn(), + JsModelRegistry: { + getAvailableModels: vi.fn().mockReturnValue(['gpt-4', 'gpt-3.5-turbo']), + getAllProviders: vi.fn().mockReturnValue(['OPENAI', 'ANTHROPIC']) + }, + JsCompletionManager: vi.fn().mockImplementation(() => ({ + initWorkspace: vi.fn().mockResolvedValue(true), + getCompletions: vi.fn().mockReturnValue([]) + })), + JsDevInsCompiler: vi.fn().mockImplementation(() => ({ + compile: vi.fn().mockResolvedValue({ success: true, output: 'compiled', errorMessage: null, hasCommand: true }), + compileToString: vi.fn().mockResolvedValue('compiled output') + })), + JsToolRegistry: vi.fn().mockImplementation(() => ({ + readFile: vi.fn().mockResolvedValue({ success: true, output: 'file content', errorMessage: null, metadata: {} }), + writeFile: vi.fn().mockResolvedValue({ success: true, output: '', errorMessage: null, metadata: {} }), + glob: vi.fn().mockResolvedValue({ success: true, output: '["file1.ts", "file2.ts"]', errorMessage: null, metadata: {} }), + grep: vi.fn().mockResolvedValue({ success: true, output: 'match found', errorMessage: null, metadata: {} }), + shell: vi.fn().mockResolvedValue({ success: true, output: 'command output', errorMessage: null, metadata: {} }), + getAvailableTools: vi.fn().mockReturnValue(['read-file', 'write-file', 'glob', 'grep', 'shell']), + formatToolListForAI: vi.fn().mockReturnValue('Tool list for AI') + })), + JsCompressionConfig: vi.fn() + }, + agent: { + JsCodingAgent: vi.fn().mockImplementation(() => ({ + executeTask: vi.fn().mockResolvedValue({ + success: true, + message: 'Task completed', + steps: [], + edits: [] + }), + initializeWorkspace: vi.fn().mockResolvedValue(undefined), + getConversationHistory: vi.fn().mockReturnValue([]) + })), + JsAgentTask: vi.fn() + } + } + } + } +})); + +describe('ProviderTypes', () => { + it('should map provider names correctly', () => { + expect(ProviderTypes['openai']).toBe('OPENAI'); + expect(ProviderTypes['anthropic']).toBe('ANTHROPIC'); + expect(ProviderTypes['google']).toBe('GOOGLE'); + expect(ProviderTypes['deepseek']).toBe('DEEPSEEK'); + expect(ProviderTypes['ollama']).toBe('OLLAMA'); + expect(ProviderTypes['openrouter']).toBe('OPENROUTER'); + expect(ProviderTypes['custom-openai-base']).toBe('CUSTOM_OPENAI_BASE'); + }); +}); + +describe('LLMService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should be importable', async () => { + const { LLMService } = await import('../../src/bridge/mpp-core'); + expect(LLMService).toBeDefined(); + }); + + it('should create instance with config', async () => { + const { LLMService } = await import('../../src/bridge/mpp-core'); + const service = new LLMService({ + provider: 'openai', + model: 'gpt-4', + apiKey: 'test-key' + }); + expect(service).toBeDefined(); + }); + + it('should have empty history initially', async () => { + const { LLMService } = await import('../../src/bridge/mpp-core'); + const service = new LLMService({ + provider: 'openai', + model: 'gpt-4', + apiKey: 'test-key' + }); + expect(service.getHistory()).toEqual([]); + }); + + it('should clear history', async () => { + const { LLMService } = await import('../../src/bridge/mpp-core'); + const service = new LLMService({ + provider: 'openai', + model: 'gpt-4', + apiKey: 'test-key' + }); + service.clearHistory(); + expect(service.getHistory()).toEqual([]); + }); +}); + +describe('CompletionManager', () => { + it('should be importable', async () => { + const { CompletionManager } = await import('../../src/bridge/mpp-core'); + expect(CompletionManager).toBeDefined(); + }); + + it('should create instance', async () => { + const { CompletionManager } = await import('../../src/bridge/mpp-core'); + const manager = new CompletionManager(); + expect(manager).toBeDefined(); + }); +}); + +describe('DevInsCompiler', () => { + it('should be importable', async () => { + const { DevInsCompiler } = await import('../../src/bridge/mpp-core'); + expect(DevInsCompiler).toBeDefined(); + }); +}); + +describe('ToolRegistry', () => { + it('should be importable', async () => { + const { ToolRegistry } = await import('../../src/bridge/mpp-core'); + expect(ToolRegistry).toBeDefined(); + }); + + it('should create instance with project path', async () => { + const { ToolRegistry } = await import('../../src/bridge/mpp-core'); + const registry = new ToolRegistry('/test/project'); + expect(registry).toBeDefined(); + }); +}); + +describe('CodingAgent', () => { + it('should be importable', async () => { + const { CodingAgent } = await import('../../src/bridge/mpp-core'); + expect(CodingAgent).toBeDefined(); + }); +}); + +describe('Helper functions', () => { + it('should get available models', async () => { + const { getAvailableModels } = await import('../../src/bridge/mpp-core'); + const models = getAvailableModels('openai'); + expect(Array.isArray(models)).toBe(true); + }); + + it('should get all providers', async () => { + const { getAllProviders } = await import('../../src/bridge/mpp-core'); + const providers = getAllProviders(); + expect(Array.isArray(providers)).toBe(true); + }); +}); + diff --git a/mpp-vscode/test/mocks/vscode.ts b/mpp-vscode/test/mocks/vscode.ts new file mode 100644 index 0000000000..3048450563 --- /dev/null +++ b/mpp-vscode/test/mocks/vscode.ts @@ -0,0 +1,80 @@ +/** + * VSCode API Mock for testing + */ + +export const Uri = { + file: (path: string) => ({ scheme: 'file', fsPath: path, path, toString: () => `file://${path}` }), + from: (components: { scheme: string; path: string; query?: string }) => ({ + scheme: components.scheme, + path: components.path, + query: components.query, + fsPath: components.path, + toString: () => `${components.scheme}://${components.path}${components.query ? '?' + components.query : ''}` + }), + parse: (value: string) => { + const url = new URL(value); + return { scheme: url.protocol.replace(':', ''), path: url.pathname, fsPath: url.pathname, toString: () => value }; + } +}; + +export const EventEmitter = class { + private listeners: Function[] = []; + event = (listener: Function) => { + this.listeners.push(listener); + return { dispose: () => { this.listeners = this.listeners.filter(l => l !== listener); } }; + }; + fire = (data: any) => { this.listeners.forEach(l => l(data)); }; + dispose = () => { this.listeners = []; }; +}; + +export const window = { + createOutputChannel: (name: string) => ({ + appendLine: (message: string) => console.log(`[${name}] ${message}`), + dispose: () => {} + }), + showInformationMessage: async (message: string) => console.log(`[INFO] ${message}`), + showWarningMessage: async (message: string) => console.log(`[WARN] ${message}`), + showErrorMessage: async (message: string) => console.log(`[ERROR] ${message}`), + showInputBox: async () => undefined, + activeTextEditor: undefined, + onDidChangeActiveTextEditor: () => ({ dispose: () => {} }), + registerWebviewViewProvider: () => ({ dispose: () => {} }), + tabGroups: { all: [] } +}; + +export const workspace = { + workspaceFolders: undefined, + getConfiguration: (section: string) => ({ + get: (key: string, defaultValue?: T) => defaultValue + }), + fs: { + stat: async (uri: any) => ({}), + readFile: async (uri: any) => Buffer.from(''), + writeFile: async (uri: any, content: Uint8Array) => {} + }, + onDidCloseTextDocument: () => ({ dispose: () => {} }), + onDidChangeWorkspaceFolders: () => ({ dispose: () => {} }), + registerTextDocumentContentProvider: () => ({ dispose: () => {} }), + openTextDocument: async (uri: any) => ({ getText: () => '', uri }) +}; + +export const commands = { + registerCommand: (command: string, callback: Function) => ({ dispose: () => {} }), + executeCommand: async (command: string, ...args: any[]) => {} +}; + +export const ExtensionMode = { + Development: 1, + Test: 2, + Production: 3 +}; + +export default { + Uri, + EventEmitter, + window, + workspace, + commands, + ExtensionMode +}; + diff --git a/mpp-vscode/test/services/diff-manager.test.ts b/mpp-vscode/test/services/diff-manager.test.ts new file mode 100644 index 0000000000..688516a643 --- /dev/null +++ b/mpp-vscode/test/services/diff-manager.test.ts @@ -0,0 +1,159 @@ +/** + * Tests for DiffManager + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock vscode module +vi.mock('vscode', () => ({ + Uri: { + file: (path: string) => ({ scheme: 'file', fsPath: path, path, toString: () => `file://${path}` }), + from: (components: { scheme: string; path: string; query?: string }) => ({ + scheme: components.scheme, + path: components.path, + query: components.query, + fsPath: components.path, + toString: () => `${components.scheme}://${components.path}${components.query ? '?' + components.query : ''}` + }), + parse: (value: string) => { + const url = new URL(value); + return { scheme: url.protocol.replace(':', ''), path: url.pathname, fsPath: url.pathname, toString: () => value }; + } + }, + EventEmitter: class { + private listeners: Function[] = []; + event = (listener: Function) => { + this.listeners.push(listener); + return { dispose: () => { this.listeners = this.listeners.filter(l => l !== listener); } }; + }; + fire = (data: any) => { this.listeners.forEach(l => l(data)); }; + dispose = () => { this.listeners = []; }; + }, + window: { + onDidChangeActiveTextEditor: () => ({ dispose: () => {} }), + activeTextEditor: undefined, + tabGroups: { all: [] } + }, + workspace: { + fs: { + stat: vi.fn().mockResolvedValue({}), + writeFile: vi.fn().mockResolvedValue(undefined) + }, + openTextDocument: vi.fn().mockResolvedValue({ getText: () => 'test content', uri: {} }) + }, + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined) + } +})); + +// Mock extension module to avoid circular dependency +vi.mock('../../src/extension', () => ({ + DIFF_SCHEME: 'autodev-diff' +})); + +describe('DiffContentProvider', () => { + it('should be importable', async () => { + const { DiffContentProvider } = await import('../../src/services/diff-manager'); + expect(DiffContentProvider).toBeDefined(); + }); + + it('should create instance', async () => { + const { DiffContentProvider } = await import('../../src/services/diff-manager'); + const provider = new DiffContentProvider(); + expect(provider).toBeDefined(); + }); + + it('should set and get content', async () => { + const { DiffContentProvider } = await import('../../src/services/diff-manager'); + const vscode = await import('vscode'); + + const provider = new DiffContentProvider(); + const uri = vscode.Uri.from({ scheme: 'autodev-diff', path: '/test/file.ts' }); + + provider.setContent(uri, 'test content'); + expect(provider.getContent(uri)).toBe('test content'); + }); + + it('should delete content', async () => { + const { DiffContentProvider } = await import('../../src/services/diff-manager'); + const vscode = await import('vscode'); + + const provider = new DiffContentProvider(); + const uri = vscode.Uri.from({ scheme: 'autodev-diff', path: '/test/file.ts' }); + + provider.setContent(uri, 'test content'); + provider.deleteContent(uri); + expect(provider.getContent(uri)).toBeUndefined(); + }); + + it('should provide text document content', async () => { + const { DiffContentProvider } = await import('../../src/services/diff-manager'); + const vscode = await import('vscode'); + + const provider = new DiffContentProvider(); + const uri = vscode.Uri.from({ scheme: 'autodev-diff', path: '/test/file.ts' }); + + provider.setContent(uri, 'provided content'); + expect(provider.provideTextDocumentContent(uri)).toBe('provided content'); + }); + + it('should return empty string for unknown uri', async () => { + const { DiffContentProvider } = await import('../../src/services/diff-manager'); + const vscode = await import('vscode'); + + const provider = new DiffContentProvider(); + const uri = vscode.Uri.from({ scheme: 'autodev-diff', path: '/unknown/file.ts' }); + + expect(provider.provideTextDocumentContent(uri)).toBe(''); + }); +}); + +describe('DiffManager', () => { + let logMessages: string[] = []; + const mockLog = (message: string) => { logMessages.push(message); }; + + beforeEach(() => { + logMessages = []; + vi.clearAllMocks(); + }); + + it('should be importable', async () => { + const { DiffManager } = await import('../../src/services/diff-manager'); + expect(DiffManager).toBeDefined(); + }); + + it('should create instance', async () => { + const { DiffManager, DiffContentProvider } = await import('../../src/services/diff-manager'); + const contentProvider = new DiffContentProvider(); + const manager = new DiffManager(mockLog, contentProvider); + expect(manager).toBeDefined(); + }); + + it('should check if diff exists for file', async () => { + const { DiffManager, DiffContentProvider } = await import('../../src/services/diff-manager'); + const contentProvider = new DiffContentProvider(); + const manager = new DiffManager(mockLog, contentProvider); + + expect(manager.hasDiff('/test/file.ts')).toBe(false); + }); + + it('should emit events on diff changes', async () => { + const { DiffManager, DiffContentProvider } = await import('../../src/services/diff-manager'); + const contentProvider = new DiffContentProvider(); + const manager = new DiffManager(mockLog, contentProvider); + + const events: any[] = []; + manager.onDidChange((event) => events.push(event)); + + expect(events).toHaveLength(0); + }); + + it('should dispose subscriptions', async () => { + const { DiffManager, DiffContentProvider } = await import('../../src/services/diff-manager'); + const contentProvider = new DiffContentProvider(); + const manager = new DiffManager(mockLog, contentProvider); + + expect(() => manager.dispose()).not.toThrow(); + }); +}); + diff --git a/mpp-vscode/test/services/ide-server.test.ts b/mpp-vscode/test/services/ide-server.test.ts new file mode 100644 index 0000000000..996c500e58 --- /dev/null +++ b/mpp-vscode/test/services/ide-server.test.ts @@ -0,0 +1,125 @@ +/** + * Tests for IDEServer + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock vscode module +vi.mock('vscode', () => ({ + Uri: { + file: (path: string) => ({ scheme: 'file', fsPath: path, path, toString: () => `file://${path}` }) + }, + workspace: { + workspaceFolders: [{ name: 'test', uri: { fsPath: '/test/workspace' } }], + fs: { + readFile: vi.fn().mockResolvedValue(Buffer.from('file content')), + writeFile: vi.fn().mockResolvedValue(undefined) + } + }, + window: { + activeTextEditor: { + document: { uri: { fsPath: '/test/file.ts' } }, + selection: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } } + } + } +})); + +// Mock fs/promises +vi.mock('fs/promises', () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + chmod: vi.fn().mockResolvedValue(undefined) +})); + +// Mock DiffManager +const mockDiffManager = { + showDiff: vi.fn().mockResolvedValue(undefined), + closeDiffByPath: vi.fn().mockResolvedValue('modified content'), + onDidChange: { dispose: () => {} } +}; + +describe('IDEServer', () => { + let logMessages: string[] = []; + const mockLog = (message: string) => { logMessages.push(message); }; + + beforeEach(() => { + logMessages = []; + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should be importable', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + expect(IDEServer).toBeDefined(); + }); + + it('should create instance with port', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + const server = new IDEServer(mockLog, mockDiffManager as any, 23120); + expect(server).toBeDefined(); + }); + + it('should have start method', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + const server = new IDEServer(mockLog, mockDiffManager as any, 23120); + expect(typeof server.start).toBe('function'); + }); + + it('should have stop method', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + const server = new IDEServer(mockLog, mockDiffManager as any, 23120); + expect(typeof server.stop).toBe('function'); + }); + + it('should have syncEnvVars method', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + const server = new IDEServer(mockLog, mockDiffManager as any, 23120); + expect(typeof server.syncEnvVars).toBe('function'); + }); + + it('should stop gracefully when not started', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + const server = new IDEServer(mockLog, mockDiffManager as any, 23120); + await expect(server.stop()).resolves.toBeUndefined(); + }); +}); + +describe('IDEServer API Endpoints', () => { + // These tests would require starting the actual server + // For unit tests, we verify the structure and methods exist + + it('should define health endpoint handler', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + expect(IDEServer).toBeDefined(); + // The actual endpoint testing would be done in integration tests + }); + + it('should define context endpoint handler', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + expect(IDEServer).toBeDefined(); + }); + + it('should define diff/open endpoint handler', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + expect(IDEServer).toBeDefined(); + }); + + it('should define diff/close endpoint handler', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + expect(IDEServer).toBeDefined(); + }); + + it('should define file/read endpoint handler', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + expect(IDEServer).toBeDefined(); + }); + + it('should define file/write endpoint handler', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + expect(IDEServer).toBeDefined(); + }); +}); + diff --git a/mpp-vscode/vitest.config.ts b/mpp-vscode/vitest.config.ts new file mode 100644 index 0000000000..79dcc72024 --- /dev/null +++ b/mpp-vscode/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + exclude: ['node_modules', 'dist'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/**/*.d.ts'] + } + }, + resolve: { + alias: { + vscode: './test/mocks/vscode.ts' + } + } +}); + From 1a1fad2004d0d53911cb7530549a11af9291c15c Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 23:56:38 +0800 Subject: [PATCH 03/33] feat(mpp-vscode): add DevIns language support and status bar Phase 5 - Status Bar: - Add StatusBarManager with idle/thinking/streaming/error states - Animated status icons during LLM operations - Click to open chat command Phase 6 - DevIns Language Support: - Add TextMate grammar for syntax highlighting - Support commands (/), agents (@), variables ($) - Add language configuration (brackets, folding, etc.) - Implement DevInsCompletionProvider with built-in completions - Register completion triggers for /, @, $ characters Testing: - Add unit tests for StatusBarManager - Add unit tests for DevInsCompletionProvider - All 56 tests passing Refs #31 --- mpp-vscode/README.md | 10 +- mpp-vscode/package.json | 19 ++ mpp-vscode/resources/icon.svg | 7 + mpp-vscode/src/extension.ts | 11 ++ mpp-vscode/src/providers/devins-completion.ts | 156 ++++++++++++++++ mpp-vscode/src/services/status-bar.ts | 128 +++++++++++++ mpp-vscode/syntaxes/DevIns.tmLanguage.json | 173 ++++++++++++++++++ .../syntaxes/language-configuration.json | 43 +++++ .../test/providers/devins-completion.test.ts | 160 ++++++++++++++++ mpp-vscode/test/services/status-bar.test.ts | 99 ++++++++++ 10 files changed, 801 insertions(+), 5 deletions(-) create mode 100644 mpp-vscode/resources/icon.svg create mode 100644 mpp-vscode/src/providers/devins-completion.ts create mode 100644 mpp-vscode/src/services/status-bar.ts create mode 100644 mpp-vscode/syntaxes/DevIns.tmLanguage.json create mode 100644 mpp-vscode/syntaxes/language-configuration.json create mode 100644 mpp-vscode/test/providers/devins-completion.test.ts create mode 100644 mpp-vscode/test/services/status-bar.test.ts diff --git a/mpp-vscode/README.md b/mpp-vscode/README.md index 5ab4cb43f3..3062d528b2 100644 --- a/mpp-vscode/README.md +++ b/mpp-vscode/README.md @@ -81,12 +81,12 @@ mpp-vscode/ - [x] autodev.cancelDiff - 取消差异 - [x] autodev.runAgent - 运行 Agent - [x] 快捷键绑定 (Cmd+Shift+A) -- [ ] 状态栏集成 +- [x] 状态栏集成 -### Phase 6: 高级功能 -- [ ] DevIns 语言支持 - - [ ] 语法高亮 - - [ ] 自动补全 +### Phase 6: 高级功能 ✅ +- [x] DevIns 语言支持 + - [x] 语法高亮 (TextMate grammar) + - [x] 自动补全 (/, @, $ 触发) - [ ] 代码索引集成 - [ ] 领域词典支持 - [ ] React Webview UI (替换内嵌 HTML) diff --git a/mpp-vscode/package.json b/mpp-vscode/package.json index ebaaa5b6c1..7b360ed9ed 100644 --- a/mpp-vscode/package.json +++ b/mpp-vscode/package.json @@ -101,6 +101,25 @@ "key": "ctrl+shift+a", "mac": "cmd+shift+a" } + ], + "languages": [ + { + "id": "DevIns", + "aliases": ["devins", "devin"], + "extensions": [".devins", ".devin"], + "configuration": "syntaxes/language-configuration.json", + "icon": { + "light": "./resources/icon.svg", + "dark": "./resources/icon.svg" + } + } + ], + "grammars": [ + { + "language": "DevIns", + "scopeName": "source.devins", + "path": "syntaxes/DevIns.tmLanguage.json" + } ] }, "scripts": { diff --git a/mpp-vscode/resources/icon.svg b/mpp-vscode/resources/icon.svg new file mode 100644 index 0000000000..ef40202d61 --- /dev/null +++ b/mpp-vscode/resources/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/mpp-vscode/src/extension.ts b/mpp-vscode/src/extension.ts index 4506200ea9..ae5048345c 100644 --- a/mpp-vscode/src/extension.ts +++ b/mpp-vscode/src/extension.ts @@ -8,11 +8,14 @@ import * as vscode from 'vscode'; import { IDEServer } from './services/ide-server'; import { DiffManager, DiffContentProvider } from './services/diff-manager'; import { ChatViewProvider } from './providers/chat-view'; +import { StatusBarManager } from './services/status-bar'; +import { registerDevInsCompletionProvider } from './providers/devins-completion'; import { createLogger } from './utils/logger'; export const DIFF_SCHEME = 'autodev-diff'; let ideServer: IDEServer | undefined; +let statusBar: StatusBarManager | undefined; let logger: vscode.OutputChannel; let log: (message: string) => void = () => {}; @@ -24,6 +27,10 @@ export async function activate(context: vscode.ExtensionContext) { log = createLogger(context, logger); log('AutoDev extension activated'); + // Initialize Status Bar + statusBar = new StatusBarManager(); + context.subscriptions.push({ dispose: () => statusBar?.dispose() }); + // Initialize Diff Manager const diffContentProvider = new DiffContentProvider(); const diffManager = new DiffManager(log, diffContentProvider); @@ -113,6 +120,10 @@ export async function activate(context: vscode.ExtensionContext) { }) ); + // Register DevIns language completion provider + context.subscriptions.push(registerDevInsCompletionProvider(context)); + log('DevIns language support registered'); + // Show welcome message on first install const welcomeShownKey = 'autodev.welcomeShown'; if (!context.globalState.get(welcomeShownKey)) { diff --git a/mpp-vscode/src/providers/devins-completion.ts b/mpp-vscode/src/providers/devins-completion.ts new file mode 100644 index 0000000000..5a0063a449 --- /dev/null +++ b/mpp-vscode/src/providers/devins-completion.ts @@ -0,0 +1,156 @@ +/** + * DevIns Completion Provider - Auto-completion for DevIns language + */ + +import * as vscode from 'vscode'; +import { CompletionManager } from '../bridge/mpp-core'; + +/** + * Built-in DevIns commands + */ +const BUILTIN_COMMANDS = [ + { name: '/file', description: 'Read file content', args: ':path' }, + { name: '/write', description: 'Write content to file', args: ':path' }, + { name: '/run', description: 'Run shell command', args: ':command' }, + { name: '/patch', description: 'Apply patch to file', args: ':path' }, + { name: '/commit', description: 'Create git commit', args: ':message' }, + { name: '/symbol', description: 'Find symbol in codebase', args: ':name' }, + { name: '/rev', description: 'Review code changes', args: '' }, + { name: '/refactor', description: 'Refactor code', args: ':instruction' }, + { name: '/test', description: 'Generate tests', args: '' }, + { name: '/doc', description: 'Generate documentation', args: '' }, + { name: '/help', description: 'Show available commands', args: '' } +]; + +/** + * Built-in agents + */ +const BUILTIN_AGENTS = [ + { name: '@code', description: 'Code generation agent' }, + { name: '@test', description: 'Test generation agent' }, + { name: '@doc', description: 'Documentation agent' }, + { name: '@review', description: 'Code review agent' }, + { name: '@refactor', description: 'Refactoring agent' }, + { name: '@explain', description: 'Code explanation agent' } +]; + +/** + * Built-in variables + */ +const BUILTIN_VARIABLES = [ + { name: '$selection', description: 'Current editor selection' }, + { name: '$file', description: 'Current file path' }, + { name: '$fileName', description: 'Current file name' }, + { name: '$language', description: 'Current file language' }, + { name: '$workspace', description: 'Workspace root path' }, + { name: '$clipboard', description: 'Clipboard content' } +]; + +/** + * DevIns Completion Provider + */ +export class DevInsCompletionProvider implements vscode.CompletionItemProvider { + private completionManager: CompletionManager | undefined; + + constructor() { + try { + this.completionManager = new CompletionManager(); + } catch (e) { + // mpp-core not available, use built-in completions only + } + } + + async provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + _token: vscode.CancellationToken, + _context: vscode.CompletionContext + ): Promise { + const linePrefix = document.lineAt(position).text.substring(0, position.character); + const items: vscode.CompletionItem[] = []; + + // Command completion (starts with /) + if (linePrefix.endsWith('/') || /\/[a-zA-Z]*$/.test(linePrefix)) { + items.push(...this.getCommandCompletions(linePrefix)); + } + + // Agent completion (starts with @) + if (linePrefix.endsWith('@') || /@[a-zA-Z]*$/.test(linePrefix)) { + items.push(...this.getAgentCompletions(linePrefix)); + } + + // Variable completion (starts with $) + if (linePrefix.endsWith('$') || /\$[a-zA-Z]*$/.test(linePrefix)) { + items.push(...this.getVariableCompletions(linePrefix)); + } + + return items; + } + + private getCommandCompletions(linePrefix: string): vscode.CompletionItem[] { + const prefix = linePrefix.match(/\/([a-zA-Z]*)$/)?.[1] || ''; + + return BUILTIN_COMMANDS + .filter(cmd => cmd.name.substring(1).startsWith(prefix)) + .map(cmd => { + const item = new vscode.CompletionItem( + cmd.name, + vscode.CompletionItemKind.Function + ); + item.detail = cmd.description; + item.insertText = cmd.name.substring(1) + cmd.args; + item.documentation = new vscode.MarkdownString(`**${cmd.name}**\n\n${cmd.description}`); + return item; + }); + } + + private getAgentCompletions(linePrefix: string): vscode.CompletionItem[] { + const prefix = linePrefix.match(/@([a-zA-Z]*)$/)?.[1] || ''; + + return BUILTIN_AGENTS + .filter(agent => agent.name.substring(1).startsWith(prefix)) + .map(agent => { + const item = new vscode.CompletionItem( + agent.name, + vscode.CompletionItemKind.Class + ); + item.detail = agent.description; + item.insertText = agent.name.substring(1); + item.documentation = new vscode.MarkdownString(`**${agent.name}**\n\n${agent.description}`); + return item; + }); + } + + private getVariableCompletions(linePrefix: string): vscode.CompletionItem[] { + const prefix = linePrefix.match(/\$([a-zA-Z]*)$/)?.[1] || ''; + + return BUILTIN_VARIABLES + .filter(v => v.name.substring(1).startsWith(prefix)) + .map(v => { + const item = new vscode.CompletionItem( + v.name, + vscode.CompletionItemKind.Variable + ); + item.detail = v.description; + item.insertText = v.name.substring(1); + item.documentation = new vscode.MarkdownString(`**${v.name}**\n\n${v.description}`); + return item; + }); + } +} + +/** + * Register DevIns completion provider + */ +export function registerDevInsCompletionProvider( + context: vscode.ExtensionContext +): vscode.Disposable { + const provider = new DevInsCompletionProvider(); + + return vscode.languages.registerCompletionItemProvider( + { language: 'DevIns', scheme: 'file' }, + provider, + '/', '@', '$' + ); +} + diff --git a/mpp-vscode/src/services/status-bar.ts b/mpp-vscode/src/services/status-bar.ts new file mode 100644 index 0000000000..3496f4b049 --- /dev/null +++ b/mpp-vscode/src/services/status-bar.ts @@ -0,0 +1,128 @@ +/** + * Status Bar Service - Shows AutoDev status in VSCode status bar + */ + +import * as vscode from 'vscode'; + +export type StatusBarState = 'idle' | 'thinking' | 'streaming' | 'error'; + +/** + * Status Bar Manager for AutoDev + */ +export class StatusBarManager { + private statusBarItem: vscode.StatusBarItem; + private state: StatusBarState = 'idle'; + private animationInterval: NodeJS.Timeout | undefined; + private animationFrame = 0; + + constructor() { + this.statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 100 + ); + this.statusBarItem.command = 'autodev.chat'; + this.statusBarItem.tooltip = 'Click to open AutoDev Chat'; + this.updateDisplay(); + this.statusBarItem.show(); + } + + /** + * Set the status bar state + */ + setState(state: StatusBarState, message?: string): void { + this.state = state; + this.stopAnimation(); + + if (state === 'thinking' || state === 'streaming') { + this.startAnimation(); + } + + this.updateDisplay(message); + } + + /** + * Show a temporary message + */ + showMessage(message: string, timeout = 3000): void { + const previousState = this.state; + this.updateDisplay(message); + + setTimeout(() => { + if (this.state === previousState) { + this.updateDisplay(); + } + }, timeout); + } + + /** + * Dispose the status bar item + */ + dispose(): void { + this.stopAnimation(); + this.statusBarItem.dispose(); + } + + private updateDisplay(message?: string): void { + const icons: Record = { + idle: '$(sparkle)', + thinking: this.getThinkingIcon(), + streaming: this.getStreamingIcon(), + error: '$(error)' + }; + + const colors: Record = { + idle: undefined, + thinking: new vscode.ThemeColor('statusBarItem.warningForeground').toString(), + streaming: new vscode.ThemeColor('statusBarItem.prominentForeground').toString(), + error: new vscode.ThemeColor('statusBarItem.errorForeground').toString() + }; + + const icon = icons[this.state]; + const text = message || this.getDefaultText(); + + this.statusBarItem.text = `${icon} ${text}`; + this.statusBarItem.backgroundColor = this.state === 'error' + ? new vscode.ThemeColor('statusBarItem.errorBackground') + : undefined; + } + + private getDefaultText(): string { + switch (this.state) { + case 'idle': + return 'AutoDev'; + case 'thinking': + return 'Thinking...'; + case 'streaming': + return 'Generating...'; + case 'error': + return 'Error'; + } + } + + private getThinkingIcon(): string { + const frames = ['$(loading~spin)', '$(sync~spin)', '$(gear~spin)']; + return frames[this.animationFrame % frames.length]; + } + + private getStreamingIcon(): string { + const frames = ['$(pulse)', '$(radio-tower)', '$(broadcast)']; + return frames[this.animationFrame % frames.length]; + } + + private startAnimation(): void { + this.animationFrame = 0; + this.animationInterval = setInterval(() => { + this.animationFrame++; + this.updateDisplay(); + }, 500); + } + + private stopAnimation(): void { + if (this.animationInterval) { + clearInterval(this.animationInterval); + this.animationInterval = undefined; + } + this.animationFrame = 0; + } +} + diff --git a/mpp-vscode/syntaxes/DevIns.tmLanguage.json b/mpp-vscode/syntaxes/DevIns.tmLanguage.json new file mode 100644 index 0000000000..a0a2059266 --- /dev/null +++ b/mpp-vscode/syntaxes/DevIns.tmLanguage.json @@ -0,0 +1,173 @@ +{ + "name": "DevIns", + "scopeName": "source.devins", + "fileTypes": [".devin", ".devins"], + "folding": { + "markers": { + "start": "^```", + "end": "^```$" + } + }, + "patterns": [ + { "include": "#frontMatter" }, + { "include": "#command" }, + { "include": "#agent" }, + { "include": "#variable" }, + { "include": "#block" } + ], + "repository": { + "frontMatter": { + "begin": "\\A-{3}\\s*$", + "contentName": "meta.embedded.block.frontmatter", + "patterns": [{ "include": "source.yaml" }], + "end": "(^|\\G)-{3}|\\.{3}\\s*$" + }, + "command": { + "patterns": [ + { + "match": "^\\s*(/[a-zA-Z][a-zA-Z0-9_-]*)(:?)([^\\n]*)?", + "captures": { + "1": { "name": "keyword.control.command.devins" }, + "2": { "name": "punctuation.separator.devins" }, + "3": { "name": "string.unquoted.argument.devins" } + }, + "name": "meta.command.devins" + } + ] + }, + "agent": { + "patterns": [ + { + "match": "(@[a-zA-Z][a-zA-Z0-9_-]*)", + "captures": { + "1": { "name": "entity.name.tag.agent.devins" } + }, + "name": "meta.agent.devins" + } + ] + }, + "variable": { + "patterns": [ + { + "match": "(\\$[a-zA-Z][a-zA-Z0-9_]*)", + "captures": { + "1": { "name": "variable.other.devins" } + }, + "name": "meta.variable.devins" + } + ] + }, + "block": { + "patterns": [ + { "include": "#heading" }, + { "include": "#blockquote" }, + { "include": "#lists" }, + { "include": "#fenced_code_block" }, + { "include": "#paragraph" } + ] + }, + "heading": { + "match": "(?:^|\\G)[ ]{0,3}(#{1,6}\\s+(.*?)(\\s+#{1,6})?\\s*)$", + "name": "markup.heading.markdown", + "captures": { + "1": { + "patterns": [ + { + "match": "(#{1,6})\\s+(.*?)(?:\\s+(#+))?\\s*$", + "captures": { + "1": { "name": "punctuation.definition.heading.markdown" }, + "2": { "name": "entity.name.section.markdown" }, + "3": { "name": "punctuation.definition.heading.markdown" } + } + } + ] + } + } + }, + "blockquote": { + "begin": "(^|\\G)[ ]{0,3}(>) ?", + "captures": { + "2": { "name": "punctuation.definition.quote.begin.markdown" } + }, + "name": "markup.quote.markdown", + "patterns": [{ "include": "#block" }], + "while": "(^|\\G)\\s*(>) ?" + }, + "fenced_code_block": { + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*([^`\\s]*)?\\s*$", + "beginCaptures": { + "3": { "name": "punctuation.definition.markdown" }, + "4": { "name": "fenced_code.block.language.markdown" } + }, + "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", + "endCaptures": { + "3": { "name": "punctuation.definition.markdown" } + }, + "name": "markup.fenced_code.block.markdown", + "contentName": "meta.embedded.block" + }, + "lists": { + "patterns": [ + { + "begin": "(^|\\G)([ ]{0,3})([*+-])([ \\t])", + "beginCaptures": { + "3": { "name": "punctuation.definition.list.begin.markdown" } + }, + "name": "markup.list.unnumbered.markdown", + "patterns": [{ "include": "#block" }], + "while": "((^|\\G)([ ]{2,4}|\\t))|(^[ \\t]*$)" + }, + { + "begin": "(^|\\G)([ ]{0,3})([0-9]+[\\.\\)])([ \\t])", + "beginCaptures": { + "3": { "name": "punctuation.definition.list.begin.markdown" } + }, + "name": "markup.list.numbered.markdown", + "patterns": [{ "include": "#block" }], + "while": "((^|\\G)([ ]{2,4}|\\t))|(^[ \\t]*$)" + } + ] + }, + "paragraph": { + "begin": "(^|\\G)[ ]{0,3}(?=[^ \\t\\n])", + "name": "meta.paragraph.markdown", + "patterns": [{ "include": "#inline" }], + "while": "(^|\\G)((?=\\s*[-=]{3,}\\s*$)|[ ]{4,}(?=[^ \\t\\n]))" + }, + "inline": { + "patterns": [ + { "include": "#command" }, + { "include": "#agent" }, + { "include": "#variable" }, + { "include": "#bold" }, + { "include": "#italic" }, + { "include": "#raw" } + ] + }, + "bold": { + "match": "(\\*\\*|__)(.+?)(\\1)", + "captures": { + "1": { "name": "punctuation.definition.bold.markdown" }, + "2": { "name": "markup.bold.markdown" }, + "3": { "name": "punctuation.definition.bold.markdown" } + } + }, + "italic": { + "match": "(\\*|_)(.+?)(\\1)", + "captures": { + "1": { "name": "punctuation.definition.italic.markdown" }, + "2": { "name": "markup.italic.markdown" }, + "3": { "name": "punctuation.definition.italic.markdown" } + } + }, + "raw": { + "match": "(`+)((?:[^`]|(?!(?"] + }, + "brackets": [ + ["{", "}"], + ["[", "]"], + ["(", ")"] + ], + "colorizedBracketPairs": [], + "autoClosingPairs": [ + { "open": "{", "close": "}" }, + { "open": "[", "close": "]" }, + { "open": "(", "close": ")" }, + { "open": "<", "close": ">", "notIn": ["string"] }, + { "open": "`", "close": "`" }, + { "open": "\"", "close": "\"" }, + { "open": "'", "close": "'" } + ], + "surroundingPairs": [ + ["(", ")"], + ["[", "]"], + ["`", "`"], + ["_", "_"], + ["*", "*"], + ["{", "}"], + ["'", "'"], + ["\"", "\""], + ["<", ">"] + ], + "folding": { + "offSide": true, + "markers": { + "start": "^\\s*", + "end": "^\\s*" + } + }, + "wordPattern": { + "pattern": "(\\p{Alphabetic}|\\p{Number}|\\p{Nonspacing_Mark})(((\\p{Alphabetic}|\\p{Number}|\\p{Nonspacing_Mark})|[_])?(\\p{Alphabetic}|\\p{Number}|\\p{Nonspacing_Mark}))*", + "flags": "ug" + } +} + diff --git a/mpp-vscode/test/providers/devins-completion.test.ts b/mpp-vscode/test/providers/devins-completion.test.ts new file mode 100644 index 0000000000..7a56b7a003 --- /dev/null +++ b/mpp-vscode/test/providers/devins-completion.test.ts @@ -0,0 +1,160 @@ +/** + * Tests for DevIns Completion Provider + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock vscode module +vi.mock('vscode', () => ({ + CompletionItem: vi.fn().mockImplementation((label, kind) => ({ + label, + kind, + detail: '', + insertText: '', + documentation: null + })), + CompletionItemKind: { + Function: 3, + Class: 7, + Variable: 6 + }, + MarkdownString: vi.fn().mockImplementation((value) => ({ value })), + languages: { + registerCompletionItemProvider: vi.fn().mockReturnValue({ dispose: () => {} }) + } +})); + +// Mock mpp-core +vi.mock('../../src/bridge/mpp-core', () => ({ + CompletionManager: vi.fn().mockImplementation(() => ({ + initWorkspace: vi.fn().mockResolvedValue(true), + getCompletions: vi.fn().mockReturnValue([]) + })) +})); + +describe('DevInsCompletionProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should be importable', async () => { + const { DevInsCompletionProvider } = await import('../../src/providers/devins-completion'); + expect(DevInsCompletionProvider).toBeDefined(); + }); + + it('should create instance', async () => { + const { DevInsCompletionProvider } = await import('../../src/providers/devins-completion'); + const provider = new DevInsCompletionProvider(); + expect(provider).toBeDefined(); + }); + + it('should have provideCompletionItems method', async () => { + const { DevInsCompletionProvider } = await import('../../src/providers/devins-completion'); + const provider = new DevInsCompletionProvider(); + expect(typeof provider.provideCompletionItems).toBe('function'); + }); + + it('should provide command completions for /', async () => { + const { DevInsCompletionProvider } = await import('../../src/providers/devins-completion'); + const provider = new DevInsCompletionProvider(); + + const mockDocument = { + lineAt: vi.fn().mockReturnValue({ text: '/' }) + }; + const mockPosition = { character: 1 }; + const mockToken = { isCancellationRequested: false }; + const mockContext = {}; + + const items = await provider.provideCompletionItems( + mockDocument as any, + mockPosition as any, + mockToken as any, + mockContext as any + ); + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThan(0); + }); + + it('should provide agent completions for @', async () => { + const { DevInsCompletionProvider } = await import('../../src/providers/devins-completion'); + const provider = new DevInsCompletionProvider(); + + const mockDocument = { + lineAt: vi.fn().mockReturnValue({ text: '@' }) + }; + const mockPosition = { character: 1 }; + const mockToken = { isCancellationRequested: false }; + const mockContext = {}; + + const items = await provider.provideCompletionItems( + mockDocument as any, + mockPosition as any, + mockToken as any, + mockContext as any + ); + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThan(0); + }); + + it('should provide variable completions for $', async () => { + const { DevInsCompletionProvider } = await import('../../src/providers/devins-completion'); + const provider = new DevInsCompletionProvider(); + + const mockDocument = { + lineAt: vi.fn().mockReturnValue({ text: '$' }) + }; + const mockPosition = { character: 1 }; + const mockToken = { isCancellationRequested: false }; + const mockContext = {}; + + const items = await provider.provideCompletionItems( + mockDocument as any, + mockPosition as any, + mockToken as any, + mockContext as any + ); + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThan(0); + }); + + it('should return empty array for regular text', async () => { + const { DevInsCompletionProvider } = await import('../../src/providers/devins-completion'); + const provider = new DevInsCompletionProvider(); + + const mockDocument = { + lineAt: vi.fn().mockReturnValue({ text: 'hello world' }) + }; + const mockPosition = { character: 11 }; + const mockToken = { isCancellationRequested: false }; + const mockContext = {}; + + const items = await provider.provideCompletionItems( + mockDocument as any, + mockPosition as any, + mockToken as any, + mockContext as any + ); + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBe(0); + }); +}); + +describe('registerDevInsCompletionProvider', () => { + it('should be importable', async () => { + const { registerDevInsCompletionProvider } = await import('../../src/providers/devins-completion'); + expect(registerDevInsCompletionProvider).toBeDefined(); + }); + + it('should return disposable', async () => { + const { registerDevInsCompletionProvider } = await import('../../src/providers/devins-completion'); + const mockContext = { subscriptions: [] }; + const disposable = registerDevInsCompletionProvider(mockContext as any); + expect(disposable).toBeDefined(); + expect(typeof disposable.dispose).toBe('function'); + }); +}); + diff --git a/mpp-vscode/test/services/status-bar.test.ts b/mpp-vscode/test/services/status-bar.test.ts new file mode 100644 index 0000000000..b7d3160f14 --- /dev/null +++ b/mpp-vscode/test/services/status-bar.test.ts @@ -0,0 +1,99 @@ +/** + * Tests for StatusBarManager + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock vscode module +vi.mock('vscode', () => ({ + window: { + createStatusBarItem: vi.fn().mockReturnValue({ + text: '', + tooltip: '', + command: '', + backgroundColor: undefined, + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn() + }) + }, + StatusBarAlignment: { + Left: 1, + Right: 2 + }, + ThemeColor: vi.fn().mockImplementation((id: string) => ({ id, toString: () => id })) +})); + +describe('StatusBarManager', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should be importable', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + expect(StatusBarManager).toBeDefined(); + }); + + it('should create instance', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + const manager = new StatusBarManager(); + expect(manager).toBeDefined(); + }); + + it('should have setState method', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + const manager = new StatusBarManager(); + expect(typeof manager.setState).toBe('function'); + }); + + it('should have showMessage method', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + const manager = new StatusBarManager(); + expect(typeof manager.showMessage).toBe('function'); + }); + + it('should have dispose method', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + const manager = new StatusBarManager(); + expect(typeof manager.dispose).toBe('function'); + }); + + it('should set state to idle', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + const manager = new StatusBarManager(); + expect(() => manager.setState('idle')).not.toThrow(); + }); + + it('should set state to thinking', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + const manager = new StatusBarManager(); + expect(() => manager.setState('thinking')).not.toThrow(); + manager.dispose(); // Clean up animation interval + }); + + it('should set state to streaming', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + const manager = new StatusBarManager(); + expect(() => manager.setState('streaming')).not.toThrow(); + manager.dispose(); // Clean up animation interval + }); + + it('should set state to error', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + const manager = new StatusBarManager(); + expect(() => manager.setState('error')).not.toThrow(); + }); + + it('should show temporary message', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + const manager = new StatusBarManager(); + expect(() => manager.showMessage('Test message', 100)).not.toThrow(); + }); + + it('should dispose without error', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + const manager = new StatusBarManager(); + expect(() => manager.dispose()).not.toThrow(); + }); +}); + From 578308f8594681f50291e6239fa9ae28d0a72094 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Wed, 3 Dec 2025 07:28:42 +0800 Subject: [PATCH 04/33] feat(mpp-vscode): add React Webview UI for chat interface Phase 6 - React Webview UI: - Create webview React project with Vite build - Add MessageList component with Markdown rendering - Add ChatInput component with auto-resize textarea - Add useVSCode hook for extension communication - Integrate VSCode theme variables for consistent styling - Support streaming response with animated indicators - Add fallback inline HTML when React bundle not available - Update ChatViewProvider to load React bundle with CSP Components: - App.tsx - Main chat application - MessageList.tsx - Message display with Markdown support - ChatInput.tsx - Input with keyboard shortcuts - useVSCode.ts - VSCode API communication hook Testing: - Add ChatViewProvider tests - All 63 tests passing Refs #31 --- mpp-vscode/README.md | 6 +- mpp-vscode/package.json | 6 +- mpp-vscode/src/providers/chat-view.ts | 199 ++++++++---------- mpp-vscode/test/providers/chat-view.test.ts | 142 +++++++++++++ mpp-vscode/webview/index.html | 13 ++ mpp-vscode/webview/package.json | 25 +++ mpp-vscode/webview/src/App.css | 36 ++++ mpp-vscode/webview/src/App.tsx | 101 +++++++++ .../webview/src/components/ChatInput.css | 100 +++++++++ .../webview/src/components/ChatInput.tsx | 94 +++++++++ .../webview/src/components/MessageList.css | 141 +++++++++++++ .../webview/src/components/MessageList.tsx | 82 ++++++++ mpp-vscode/webview/src/hooks/useVSCode.ts | 93 ++++++++ mpp-vscode/webview/src/main.tsx | 11 + mpp-vscode/webview/src/styles/index.css | 141 +++++++++++++ mpp-vscode/webview/tsconfig.json | 21 ++ mpp-vscode/webview/vite.config.ts | 21 ++ 17 files changed, 1115 insertions(+), 117 deletions(-) create mode 100644 mpp-vscode/test/providers/chat-view.test.ts create mode 100644 mpp-vscode/webview/index.html create mode 100644 mpp-vscode/webview/package.json create mode 100644 mpp-vscode/webview/src/App.css create mode 100644 mpp-vscode/webview/src/App.tsx create mode 100644 mpp-vscode/webview/src/components/ChatInput.css create mode 100644 mpp-vscode/webview/src/components/ChatInput.tsx create mode 100644 mpp-vscode/webview/src/components/MessageList.css create mode 100644 mpp-vscode/webview/src/components/MessageList.tsx create mode 100644 mpp-vscode/webview/src/hooks/useVSCode.ts create mode 100644 mpp-vscode/webview/src/main.tsx create mode 100644 mpp-vscode/webview/src/styles/index.css create mode 100644 mpp-vscode/webview/tsconfig.json create mode 100644 mpp-vscode/webview/vite.config.ts diff --git a/mpp-vscode/README.md b/mpp-vscode/README.md index 3062d528b2..e7febba66f 100644 --- a/mpp-vscode/README.md +++ b/mpp-vscode/README.md @@ -87,9 +87,13 @@ mpp-vscode/ - [x] DevIns 语言支持 - [x] 语法高亮 (TextMate grammar) - [x] 自动补全 (/, @, $ 触发) +- [x] React Webview UI + - [x] React + Vite 构建 + - [x] Markdown 渲染 (react-markdown + remark-gfm) + - [x] VSCode 主题集成 + - [x] 流式响应动画 - [ ] 代码索引集成 - [ ] 领域词典支持 -- [ ] React Webview UI (替换内嵌 HTML) ## 参考项目 diff --git a/mpp-vscode/package.json b/mpp-vscode/package.json index 7b360ed9ed..e9d00e64be 100644 --- a/mpp-vscode/package.json +++ b/mpp-vscode/package.json @@ -124,8 +124,10 @@ }, "scripts": { "vscode:prepublish": "npm run build", - "build": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node", - "watch": "npm run build -- --watch", + "build": "npm run build:extension && npm run build:webview", + "build:extension": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node", + "build:webview": "cd webview && npm install && npm run build", + "watch": "npm run build:extension -- --watch", "package": "vsce package", "lint": "eslint src --ext ts", "test": "vitest run", diff --git a/mpp-vscode/src/providers/chat-view.ts b/mpp-vscode/src/providers/chat-view.ts index 114f3572f6..7d26971d5e 100644 --- a/mpp-vscode/src/providers/chat-view.ts +++ b/mpp-vscode/src/providers/chat-view.ts @@ -3,6 +3,7 @@ */ import * as vscode from 'vscode'; +import * as path from 'path'; import { LLMService, ModelConfig } from '../bridge/mpp-core'; /** @@ -27,10 +28,12 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { webviewView.webview.options = { enableScripts: true, - localResourceRoots: [this.context.extensionUri] + localResourceRoots: [ + vscode.Uri.joinPath(this.context.extensionUri, 'dist', 'webview') + ] }; - webviewView.webview.html = this.getHtmlContent(); + webviewView.webview.html = this.getHtmlContent(webviewView.webview); // Handle messages from webview webviewView.webview.onDidReceiveMessage(async (message) => { @@ -133,131 +136,99 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { this.postMessage({ type: 'historyCleared' }); } - private getHtmlContent(): string { + private getHtmlContent(webview: vscode.Webview): string { + // Check if React build exists + const webviewPath = vscode.Uri.joinPath(this.context.extensionUri, 'dist', 'webview'); + const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(webviewPath, 'assets', 'index.js')); + const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(webviewPath, 'assets', 'index.css')); + + // Use nonce for security + const nonce = this.getNonce(); + + // Try to use React build, fallback to inline HTML return ` + + AutoDev Chat - -
-
- - -
- + `; } + + private 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; + } } diff --git a/mpp-vscode/test/providers/chat-view.test.ts b/mpp-vscode/test/providers/chat-view.test.ts new file mode 100644 index 0000000000..331012c866 --- /dev/null +++ b/mpp-vscode/test/providers/chat-view.test.ts @@ -0,0 +1,142 @@ +/** + * Tests for ChatViewProvider + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock vscode module +vi.mock('vscode', () => ({ + Uri: { + joinPath: vi.fn().mockReturnValue({ fsPath: '/mock/path' }), + file: vi.fn().mockReturnValue({ fsPath: '/mock/path' }) + }, + window: { + createOutputChannel: vi.fn().mockReturnValue({ + appendLine: vi.fn(), + show: vi.fn() + }) + }, + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn().mockImplementation((key: string, defaultValue: unknown) => defaultValue) + }) + } +})); + +// Mock mpp-core +vi.mock('../../src/bridge/mpp-core', () => ({ + LLMService: vi.fn().mockImplementation(() => ({ + streamMessage: vi.fn().mockResolvedValue(undefined), + clearHistory: vi.fn() + })), + ModelConfig: {} +})); + +describe('ChatViewProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should be importable', async () => { + const { ChatViewProvider } = await import('../../src/providers/chat-view'); + expect(ChatViewProvider).toBeDefined(); + }); + + it('should create instance', async () => { + const { ChatViewProvider } = await import('../../src/providers/chat-view'); + const mockContext = { + extensionUri: { fsPath: '/mock/extension' }, + subscriptions: [] + }; + const mockLog = vi.fn(); + + const provider = new ChatViewProvider(mockContext as any, mockLog); + expect(provider).toBeDefined(); + }); + + it('should have resolveWebviewView method', async () => { + const { ChatViewProvider } = await import('../../src/providers/chat-view'); + const mockContext = { + extensionUri: { fsPath: '/mock/extension' }, + subscriptions: [] + }; + const mockLog = vi.fn(); + + const provider = new ChatViewProvider(mockContext as any, mockLog); + expect(typeof provider.resolveWebviewView).toBe('function'); + }); + + it('should have sendMessage method', async () => { + const { ChatViewProvider } = await import('../../src/providers/chat-view'); + const mockContext = { + extensionUri: { fsPath: '/mock/extension' }, + subscriptions: [] + }; + const mockLog = vi.fn(); + + const provider = new ChatViewProvider(mockContext as any, mockLog); + expect(typeof provider.sendMessage).toBe('function'); + }); + + it('should have postMessage method', async () => { + const { ChatViewProvider } = await import('../../src/providers/chat-view'); + const mockContext = { + extensionUri: { fsPath: '/mock/extension' }, + subscriptions: [] + }; + const mockLog = vi.fn(); + + const provider = new ChatViewProvider(mockContext as any, mockLog); + expect(typeof provider.postMessage).toBe('function'); + }); +}); + +describe('Webview HTML Generation', () => { + it('should generate HTML with React bundle references', async () => { + const { ChatViewProvider } = await import('../../src/providers/chat-view'); + const mockContext = { + extensionUri: { fsPath: '/mock/extension' }, + subscriptions: [] + }; + const mockLog = vi.fn(); + + const provider = new ChatViewProvider(mockContext as any, mockLog); + + // Access private method via any cast for testing + const getHtmlContent = (provider as any).getHtmlContent.bind(provider); + const mockWebview = { + asWebviewUri: vi.fn().mockReturnValue('mock-uri'), + cspSource: 'mock-csp' + }; + + const html = getHtmlContent(mockWebview); + + expect(html).toContain(''); + expect(html).toContain('
'); + expect(html).toContain('Content-Security-Policy'); + }); + + it('should include fallback inline HTML', async () => { + const { ChatViewProvider } = await import('../../src/providers/chat-view'); + const mockContext = { + extensionUri: { fsPath: '/mock/extension' }, + subscriptions: [] + }; + const mockLog = vi.fn(); + + const provider = new ChatViewProvider(mockContext as any, mockLog); + + const getHtmlContent = (provider as any).getHtmlContent.bind(provider); + const mockWebview = { + asWebviewUri: vi.fn().mockReturnValue('mock-uri'), + cspSource: 'mock-csp' + }; + + const html = getHtmlContent(mockWebview); + + // Should have fallback code + expect(html).toContain('hasChildNodes'); + expect(html).toContain('acquireVsCodeApi'); + }); +}); + diff --git a/mpp-vscode/webview/index.html b/mpp-vscode/webview/index.html new file mode 100644 index 0000000000..5ccd4cb03c --- /dev/null +++ b/mpp-vscode/webview/index.html @@ -0,0 +1,13 @@ + + + + + + AutoDev Chat + + +
+ + + + diff --git a/mpp-vscode/webview/package.json b/mpp-vscode/webview/package.json new file mode 100644 index 0000000000..3f8b9746e6 --- /dev/null +++ b/mpp-vscode/webview/package.json @@ -0,0 +1,25 @@ +{ + "name": "autodev-webview", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^9.0.1", + "remark-gfm": "^4.0.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "typescript": "^5.3.0", + "vite": "^5.0.0" + } +} + diff --git a/mpp-vscode/webview/src/App.css b/mpp-vscode/webview/src/App.css new file mode 100644 index 0000000000..997a26f822 --- /dev/null +++ b/mpp-vscode/webview/src/App.css @@ -0,0 +1,36 @@ +.app { + height: 100%; + display: flex; + flex-direction: column; + background: var(--background); +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-bottom: 1px solid var(--panel-border); + background: var(--background); +} + +.header-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 14px; +} + +.header-icon { + font-size: 16px; +} + +.dev-badge { + font-size: 10px; + padding: 2px 6px; + background: var(--selection-background); + border-radius: 4px; + opacity: 0.7; +} + diff --git a/mpp-vscode/webview/src/App.tsx b/mpp-vscode/webview/src/App.tsx new file mode 100644 index 0000000000..ad2d8be79b --- /dev/null +++ b/mpp-vscode/webview/src/App.tsx @@ -0,0 +1,101 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { MessageList, Message } from './components/MessageList'; +import { ChatInput } from './components/ChatInput'; +import { useVSCode, ExtensionMessage } from './hooks/useVSCode'; +import './App.css'; + +const App: React.FC = () => { + const [messages, setMessages] = useState([]); + const [isStreaming, setIsStreaming] = useState(false); + const { postMessage, onMessage, isVSCode } = useVSCode(); + + // Handle messages from extension + const handleExtensionMessage = useCallback((msg: ExtensionMessage) => { + switch (msg.type) { + case 'userMessage': + setMessages(prev => [...prev, { role: 'user', content: msg.content || '' }]); + break; + + case 'startResponse': + setIsStreaming(true); + setMessages(prev => [...prev, { role: 'assistant', content: '', isStreaming: true }]); + break; + + case 'responseChunk': + setMessages(prev => { + const updated = [...prev]; + const lastIdx = updated.length - 1; + if (lastIdx >= 0 && updated[lastIdx].role === 'assistant') { + updated[lastIdx] = { + ...updated[lastIdx], + content: updated[lastIdx].content + (msg.content || '') + }; + } + return updated; + }); + break; + + case 'endResponse': + setIsStreaming(false); + setMessages(prev => { + const updated = [...prev]; + const lastIdx = updated.length - 1; + if (lastIdx >= 0 && updated[lastIdx].role === 'assistant') { + updated[lastIdx] = { ...updated[lastIdx], isStreaming: false }; + } + return updated; + }); + break; + + case 'error': + setIsStreaming(false); + setMessages(prev => [...prev, { role: 'error', content: msg.content || 'An error occurred' }]); + break; + + case 'historyCleared': + setMessages([]); + break; + } + }, []); + + // Subscribe to extension messages + useEffect(() => { + return onMessage(handleExtensionMessage); + }, [onMessage, handleExtensionMessage]); + + // Send message to extension + const handleSend = useCallback((content: string) => { + postMessage({ type: 'sendMessage', content }); + }, [postMessage]); + + // Clear history + const handleClear = useCallback(() => { + postMessage({ type: 'clearHistory' }); + }, [postMessage]); + + return ( +
+
+
+ + AutoDev Chat +
+ {!isVSCode && ( + Dev Mode + )} +
+ + + + +
+ ); +}; + +export default App; + diff --git a/mpp-vscode/webview/src/components/ChatInput.css b/mpp-vscode/webview/src/components/ChatInput.css new file mode 100644 index 0000000000..d4750ee95f --- /dev/null +++ b/mpp-vscode/webview/src/components/ChatInput.css @@ -0,0 +1,100 @@ +.chat-input-container { + padding: 12px; + border-top: 1px solid var(--panel-border); + background: var(--background); +} + +.input-wrapper { + display: flex; + gap: 8px; + align-items: flex-end; +} + +.chat-textarea { + flex: 1; + padding: 10px 12px; + border: 1px solid var(--input-border); + background: var(--input-background); + color: var(--input-foreground); + border-radius: 6px; + resize: none; + font-family: inherit; + font-size: inherit; + line-height: 1.4; + min-height: 40px; + max-height: 150px; + outline: none; + transition: border-color 0.2s; +} + +.chat-textarea:focus { + border-color: var(--accent); +} + +.chat-textarea:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.chat-textarea::placeholder { + color: var(--foreground); + opacity: 0.5; +} + +.input-actions { + display: flex; + gap: 4px; +} + +.action-button { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; +} + +.send-button { + background: var(--button-background); + color: var(--button-foreground); +} + +.send-button:hover:not(:disabled) { + background: var(--button-hover); +} + +.send-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.clear-button { + background: transparent; + color: var(--foreground); + opacity: 0.6; +} + +.clear-button:hover:not(:disabled) { + opacity: 1; + background: var(--selection-background); +} + +.input-hint { + margin-top: 6px; + font-size: 11px; + color: var(--foreground); + opacity: 0.5; +} + +.input-hint kbd { + background: var(--selection-background); + padding: 2px 5px; + border-radius: 3px; + font-family: inherit; + font-size: 10px; +} + diff --git a/mpp-vscode/webview/src/components/ChatInput.tsx b/mpp-vscode/webview/src/components/ChatInput.tsx new file mode 100644 index 0000000000..1a4ec21771 --- /dev/null +++ b/mpp-vscode/webview/src/components/ChatInput.tsx @@ -0,0 +1,94 @@ +import React, { useState, useRef, useEffect, KeyboardEvent } from 'react'; +import './ChatInput.css'; + +interface ChatInputProps { + onSend: (message: string) => void; + onClear?: () => void; + disabled?: boolean; + placeholder?: string; +} + +export const ChatInput: React.FC = ({ + onSend, + onClear, + disabled = false, + placeholder = 'Ask AutoDev...' +}) => { + const [input, setInput] = useState(''); + const textareaRef = useRef(null); + + // Auto-resize textarea + useEffect(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.style.height = 'auto'; + textarea.style.height = `${Math.min(textarea.scrollHeight, 150)}px`; + } + }, [input]); + + // Focus on mount + useEffect(() => { + textareaRef.current?.focus(); + }, []); + + const handleSubmit = () => { + const trimmed = input.trim(); + if (trimmed && !disabled) { + onSend(trimmed); + setInput(''); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + + return ( +
+
+ + +
+ + +`; + } +} + diff --git a/mpp-vscode/src/services/diff-manager.ts b/mpp-vscode/src/services/diff-manager.ts new file mode 100644 index 0000000000..4aa47bb595 --- /dev/null +++ b/mpp-vscode/src/services/diff-manager.ts @@ -0,0 +1,262 @@ +/** + * Diff Manager - Manages file diff views in VSCode + * + * Handles showing, accepting, and canceling file diffs. + */ + +import * as vscode from 'vscode'; +import * as path from 'path'; +import { DIFF_SCHEME } from '../extension'; + +/** + * Provides content for diff documents + */ +export class DiffContentProvider implements vscode.TextDocumentContentProvider { + private content = new Map(); + private onDidChangeEmitter = new vscode.EventEmitter(); + + get onDidChange(): vscode.Event { + return this.onDidChangeEmitter.event; + } + + provideTextDocumentContent(uri: vscode.Uri): string { + return this.content.get(uri.toString()) ?? ''; + } + + setContent(uri: vscode.Uri, content: string): void { + this.content.set(uri.toString(), content); + this.onDidChangeEmitter.fire(uri); + } + + deleteContent(uri: vscode.Uri): void { + this.content.delete(uri.toString()); + } + + getContent(uri: vscode.Uri): string | undefined { + return this.content.get(uri.toString()); + } +} + +/** + * Information about an open diff view + */ +interface DiffInfo { + originalFilePath: string; + newContent: string; + rightDocUri: vscode.Uri; +} + +/** + * Event types for diff changes + */ +export interface DiffEvent { + type: 'accepted' | 'closed'; + filePath: string; + content: string; +} + +/** + * Manages diff view lifecycle + */ +export class DiffManager { + private readonly onDidChangeEmitter = new vscode.EventEmitter(); + readonly onDidChange = this.onDidChangeEmitter.event; + + private diffDocuments = new Map(); + private readonly subscriptions: vscode.Disposable[] = []; + + constructor( + private readonly log: (message: string) => void, + private readonly diffContentProvider: DiffContentProvider + ) { + this.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor((editor) => { + this.onActiveEditorChange(editor); + }) + ); + this.onActiveEditorChange(vscode.window.activeTextEditor); + } + + dispose(): void { + for (const subscription of this.subscriptions) { + subscription.dispose(); + } + } + + /** + * Show a diff view for a file + */ + async showDiff(filePath: string, newContent: string): Promise { + const fileUri = vscode.Uri.file(filePath); + + const rightDocUri = vscode.Uri.from({ + scheme: DIFF_SCHEME, + path: filePath, + query: `rand=${Math.random()}` + }); + + this.diffContentProvider.setContent(rightDocUri, newContent); + this.diffDocuments.set(rightDocUri.toString(), { + originalFilePath: filePath, + newContent, + rightDocUri + }); + + const diffTitle = `${path.basename(filePath)} ↔ Modified`; + await vscode.commands.executeCommand('setContext', 'autodev.diff.isVisible', true); + + let leftDocUri: vscode.Uri; + try { + await vscode.workspace.fs.stat(fileUri); + leftDocUri = fileUri; + } catch { + // File doesn't exist, use untitled scheme + leftDocUri = vscode.Uri.from({ + scheme: 'untitled', + path: filePath + }); + } + + await vscode.commands.executeCommand( + 'vscode.diff', + leftDocUri, + rightDocUri, + diffTitle, + { preview: false, preserveFocus: true } + ); + + this.log(`Showing diff for: ${filePath}`); + } + + /** + * Accept changes in a diff view + */ + async acceptDiff(rightDocUri: vscode.Uri): Promise { + const diffInfo = this.diffDocuments.get(rightDocUri.toString()); + if (!diffInfo) { + return; + } + + const rightDoc = await vscode.workspace.openTextDocument(rightDocUri); + const modifiedContent = rightDoc.getText(); + + // Write the content to the original file + const fileUri = vscode.Uri.file(diffInfo.originalFilePath); + await vscode.workspace.fs.writeFile(fileUri, Buffer.from(modifiedContent, 'utf8')); + + await this.closeDiffEditor(rightDocUri); + + this.onDidChangeEmitter.fire({ + type: 'accepted', + filePath: diffInfo.originalFilePath, + content: modifiedContent + }); + + this.log(`Accepted diff for: ${diffInfo.originalFilePath}`); + } + + /** + * Cancel a diff view + */ + async cancelDiff(rightDocUri: vscode.Uri): Promise { + const diffInfo = this.diffDocuments.get(rightDocUri.toString()); + if (!diffInfo) { + await this.closeDiffEditor(rightDocUri); + return; + } + + const rightDoc = await vscode.workspace.openTextDocument(rightDocUri); + const modifiedContent = rightDoc.getText(); + await this.closeDiffEditor(rightDocUri); + + this.onDidChangeEmitter.fire({ + type: 'closed', + filePath: diffInfo.originalFilePath, + content: modifiedContent + }); + + this.log(`Cancelled diff for: ${diffInfo.originalFilePath}`); + } + + /** + * Close a diff by file path + */ + async closeDiffByPath(filePath: string): Promise { + let uriToClose: vscode.Uri | undefined; + + for (const [uriString, diffInfo] of this.diffDocuments.entries()) { + if (diffInfo.originalFilePath === filePath) { + uriToClose = vscode.Uri.parse(uriString); + break; + } + } + + if (uriToClose) { + const rightDoc = await vscode.workspace.openTextDocument(uriToClose); + const modifiedContent = rightDoc.getText(); + await this.closeDiffEditor(uriToClose); + + this.onDidChangeEmitter.fire({ + type: 'closed', + filePath, + content: modifiedContent + }); + + return modifiedContent; + } + + return undefined; + } + + /** + * Check if a diff is open for a file + */ + hasDiff(filePath: string): boolean { + for (const diffInfo of this.diffDocuments.values()) { + if (diffInfo.originalFilePath === filePath) { + return true; + } + } + return false; + } + + private async onActiveEditorChange(editor: vscode.TextEditor | undefined): Promise { + let isVisible = false; + + if (editor) { + isVisible = this.diffDocuments.has(editor.document.uri.toString()); + if (!isVisible) { + for (const document of this.diffDocuments.values()) { + if (document.originalFilePath === editor.document.uri.fsPath) { + isVisible = true; + break; + } + } + } + } + + await vscode.commands.executeCommand('setContext', 'autodev.diff.isVisible', isVisible); + } + + private async closeDiffEditor(rightDocUri: vscode.Uri): Promise { + const diffInfo = this.diffDocuments.get(rightDocUri.toString()); + await vscode.commands.executeCommand('setContext', 'autodev.diff.isVisible', false); + + if (diffInfo) { + this.diffDocuments.delete(rightDocUri.toString()); + this.diffContentProvider.deleteContent(rightDocUri); + } + + // Find and close the tab + for (const tabGroup of vscode.window.tabGroups.all) { + for (const tab of tabGroup.tabs) { + const input = tab.input as { modified?: vscode.Uri; original?: vscode.Uri }; + if (input && input.modified?.toString() === rightDocUri.toString()) { + await vscode.window.tabGroups.close(tab); + return; + } + } + } + } +} + diff --git a/mpp-vscode/src/services/ide-server.ts b/mpp-vscode/src/services/ide-server.ts new file mode 100644 index 0000000000..3fe0b9258d --- /dev/null +++ b/mpp-vscode/src/services/ide-server.ts @@ -0,0 +1,251 @@ +/** + * IDE Server - MCP protocol server for external tool communication + * + * Provides HTTP endpoints for diff operations and workspace context. + */ + +import * as vscode from 'vscode'; +import express, { Request, Response, NextFunction } from 'express'; +import cors from 'cors'; +import { randomUUID } from 'crypto'; +import { Server as HTTPServer } from 'http'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import { DiffManager } from './diff-manager'; + +const IDE_SERVER_PORT_ENV_VAR = 'AUTODEV_IDE_SERVER_PORT'; +const IDE_WORKSPACE_PATH_ENV_VAR = 'AUTODEV_IDE_WORKSPACE_PATH'; + +/** + * IDE Server for MCP protocol communication + */ +export class IDEServer { + private server: HTTPServer | undefined; + private context: vscode.ExtensionContext | undefined; + private portFile: string | undefined; + private authToken: string | undefined; + + constructor( + private readonly log: (message: string) => void, + private readonly diffManager: DiffManager, + private readonly port: number + ) {} + + /** + * Start the IDE server + */ + async start(context: vscode.ExtensionContext): Promise { + this.context = context; + this.authToken = randomUUID(); + + const app = express(); + app.use(express.json({ limit: '10mb' })); + + // CORS - only allow non-browser requests + app.use(cors({ + origin: (origin, callback) => { + if (!origin) { + return callback(null, true); + } + return callback(new Error('Request denied by CORS policy.'), false); + } + })); + + // Host validation + app.use((req: Request, res: Response, next: NextFunction) => { + const host = req.headers.host || ''; + const allowedHosts = [`localhost:${this.port}`, `127.0.0.1:${this.port}`]; + if (!allowedHosts.includes(host)) { + return res.status(403).json({ error: 'Invalid Host header' }); + } + next(); + }); + + // Auth validation + app.use((req: Request, res: Response, next: NextFunction) => { + const authHeader = req.headers.authorization; + if (!authHeader) { + this.log('Missing Authorization header'); + return res.status(401).send('Unauthorized'); + } + + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer' || parts[1] !== this.authToken) { + this.log('Invalid auth token'); + return res.status(401).send('Unauthorized'); + } + next(); + }); + + // Health check endpoint + app.get('/health', (_req: Request, res: Response) => { + res.json({ status: 'ok', version: '0.1.0' }); + }); + + // Get workspace context + app.get('/context', (_req: Request, res: Response) => { + const workspaceFolders = vscode.workspace.workspaceFolders; + const activeEditor = vscode.window.activeTextEditor; + + res.json({ + workspaceFolders: workspaceFolders?.map(f => ({ + name: f.name, + path: f.uri.fsPath + })) ?? [], + activeFile: activeEditor?.document.uri.fsPath ?? null, + selection: activeEditor?.selection ? { + start: { line: activeEditor.selection.start.line, character: activeEditor.selection.start.character }, + end: { line: activeEditor.selection.end.line, character: activeEditor.selection.end.character } + } : null + }); + }); + + // Open diff endpoint + app.post('/diff/open', async (req: Request, res: Response) => { + try { + const { filePath, content } = req.body; + if (!filePath || content === undefined) { + return res.status(400).json({ error: 'filePath and content are required' }); + } + + await this.diffManager.showDiff(filePath, content); + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.log(`Error opening diff: ${message}`); + res.status(500).json({ error: message }); + } + }); + + // Close diff endpoint + app.post('/diff/close', async (req: Request, res: Response) => { + try { + const { filePath } = req.body; + if (!filePath) { + return res.status(400).json({ error: 'filePath is required' }); + } + + const content = await this.diffManager.closeDiffByPath(filePath); + res.json({ success: true, content }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.log(`Error closing diff: ${message}`); + res.status(500).json({ error: message }); + } + }); + + // Read file endpoint + app.post('/file/read', async (req: Request, res: Response) => { + try { + const { filePath } = req.body; + if (!filePath) { + return res.status(400).json({ error: 'filePath is required' }); + } + + const uri = vscode.Uri.file(filePath); + const content = await vscode.workspace.fs.readFile(uri); + res.json({ success: true, content: Buffer.from(content).toString('utf8') }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + res.status(500).json({ error: message }); + } + }); + + // Write file endpoint + app.post('/file/write', async (req: Request, res: Response) => { + try { + const { filePath, content } = req.body; + if (!filePath || content === undefined) { + return res.status(400).json({ error: 'filePath and content are required' }); + } + + const uri = vscode.Uri.file(filePath); + await vscode.workspace.fs.writeFile(uri, Buffer.from(content, 'utf8')); + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + res.status(500).json({ error: message }); + } + }); + + // Start server + return new Promise((resolve, reject) => { + this.server = app.listen(this.port, '127.0.0.1', async () => { + this.log(`IDE Server listening on port ${this.port}`); + + // Write port file for external tools + await this.writePortFile(); + this.syncEnvVars(); + + resolve(); + }); + + this.server.on('error', (err) => { + this.log(`IDE Server error: ${err.message}`); + reject(err); + }); + }); + } + + /** + * Stop the IDE server + */ + async stop(): Promise { + if (this.server) { + return new Promise((resolve) => { + this.server!.close(() => { + this.log('IDE Server stopped'); + resolve(); + }); + }); + } + } + + /** + * Sync environment variables for terminals + */ + syncEnvVars(): void { + if (!this.context) return; + + const workspaceFolders = vscode.workspace.workspaceFolders; + const workspacePath = workspaceFolders && workspaceFolders.length > 0 + ? workspaceFolders.map(f => f.uri.fsPath).join(path.delimiter) + : ''; + + this.context.environmentVariableCollection.replace( + IDE_SERVER_PORT_ENV_VAR, + this.port.toString() + ); + this.context.environmentVariableCollection.replace( + IDE_WORKSPACE_PATH_ENV_VAR, + workspacePath + ); + } + + /** + * Write port file for external tools to discover the server + */ + private async writePortFile(): Promise { + const autodevDir = path.join(os.homedir(), '.autodev'); + this.portFile = path.join(autodevDir, 'ide-server.json'); + + const content = JSON.stringify({ + port: this.port, + authToken: this.authToken, + workspacePath: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '', + pid: process.pid + }); + + try { + await fs.mkdir(autodevDir, { recursive: true }); + await fs.writeFile(this.portFile, content); + await fs.chmod(this.portFile, 0o600); + this.log(`Port file written to: ${this.portFile}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.log(`Failed to write port file: ${message}`); + } + } +} + diff --git a/mpp-vscode/src/utils/logger.ts b/mpp-vscode/src/utils/logger.ts new file mode 100644 index 0000000000..08aae7dc19 --- /dev/null +++ b/mpp-vscode/src/utils/logger.ts @@ -0,0 +1,27 @@ +/** + * Logger utility for AutoDev extension + */ + +import * as vscode from 'vscode'; + +/** + * Create a logger function that writes to both output channel and console + */ +export function createLogger( + context: vscode.ExtensionContext, + outputChannel: vscode.OutputChannel +): (message: string) => void { + const isDevelopment = context.extensionMode === vscode.ExtensionMode.Development; + + return (message: string) => { + const timestamp = new Date().toISOString(); + const formattedMessage = `[${timestamp}] ${message}`; + + outputChannel.appendLine(formattedMessage); + + if (isDevelopment) { + console.log(`[AutoDev] ${formattedMessage}`); + } + }; +} + diff --git a/mpp-vscode/test/bridge/mpp-core.test.ts b/mpp-vscode/test/bridge/mpp-core.test.ts new file mode 100644 index 0000000000..7d760ce3e3 --- /dev/null +++ b/mpp-vscode/test/bridge/mpp-core.test.ts @@ -0,0 +1,169 @@ +/** + * Tests for mpp-core bridge + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ProviderTypes } from '../../src/bridge/mpp-core'; + +// Mock the mpp-core module +vi.mock('@autodev/mpp-core', () => ({ + default: { + cc: { + unitmesh: { + llm: { + JsKoogLLMService: vi.fn().mockImplementation(() => ({ + streamPrompt: vi.fn().mockResolvedValue(undefined), + sendPrompt: vi.fn().mockResolvedValue('test response'), + getLastTokenInfo: vi.fn().mockReturnValue({ totalTokens: 100, inputTokens: 50, outputTokens: 50 }) + })), + JsModelConfig: vi.fn(), + JsMessage: vi.fn(), + JsModelRegistry: { + getAvailableModels: vi.fn().mockReturnValue(['gpt-4', 'gpt-3.5-turbo']), + getAllProviders: vi.fn().mockReturnValue(['OPENAI', 'ANTHROPIC']) + }, + JsCompletionManager: vi.fn().mockImplementation(() => ({ + initWorkspace: vi.fn().mockResolvedValue(true), + getCompletions: vi.fn().mockReturnValue([]) + })), + JsDevInsCompiler: vi.fn().mockImplementation(() => ({ + compile: vi.fn().mockResolvedValue({ success: true, output: 'compiled', errorMessage: null, hasCommand: true }), + compileToString: vi.fn().mockResolvedValue('compiled output') + })), + JsToolRegistry: vi.fn().mockImplementation(() => ({ + readFile: vi.fn().mockResolvedValue({ success: true, output: 'file content', errorMessage: null, metadata: {} }), + writeFile: vi.fn().mockResolvedValue({ success: true, output: '', errorMessage: null, metadata: {} }), + glob: vi.fn().mockResolvedValue({ success: true, output: '["file1.ts", "file2.ts"]', errorMessage: null, metadata: {} }), + grep: vi.fn().mockResolvedValue({ success: true, output: 'match found', errorMessage: null, metadata: {} }), + shell: vi.fn().mockResolvedValue({ success: true, output: 'command output', errorMessage: null, metadata: {} }), + getAvailableTools: vi.fn().mockReturnValue(['read-file', 'write-file', 'glob', 'grep', 'shell']), + formatToolListForAI: vi.fn().mockReturnValue('Tool list for AI') + })), + JsCompressionConfig: vi.fn() + }, + agent: { + JsCodingAgent: vi.fn().mockImplementation(() => ({ + executeTask: vi.fn().mockResolvedValue({ + success: true, + message: 'Task completed', + steps: [], + edits: [] + }), + initializeWorkspace: vi.fn().mockResolvedValue(undefined), + getConversationHistory: vi.fn().mockReturnValue([]) + })), + JsAgentTask: vi.fn() + } + } + } + } +})); + +describe('ProviderTypes', () => { + it('should map provider names correctly', () => { + expect(ProviderTypes['openai']).toBe('OPENAI'); + expect(ProviderTypes['anthropic']).toBe('ANTHROPIC'); + expect(ProviderTypes['google']).toBe('GOOGLE'); + expect(ProviderTypes['deepseek']).toBe('DEEPSEEK'); + expect(ProviderTypes['ollama']).toBe('OLLAMA'); + expect(ProviderTypes['openrouter']).toBe('OPENROUTER'); + expect(ProviderTypes['custom-openai-base']).toBe('CUSTOM_OPENAI_BASE'); + }); +}); + +describe('LLMService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should be importable', async () => { + const { LLMService } = await import('../../src/bridge/mpp-core'); + expect(LLMService).toBeDefined(); + }); + + it('should create instance with config', async () => { + const { LLMService } = await import('../../src/bridge/mpp-core'); + const service = new LLMService({ + provider: 'openai', + model: 'gpt-4', + apiKey: 'test-key' + }); + expect(service).toBeDefined(); + }); + + it('should have empty history initially', async () => { + const { LLMService } = await import('../../src/bridge/mpp-core'); + const service = new LLMService({ + provider: 'openai', + model: 'gpt-4', + apiKey: 'test-key' + }); + expect(service.getHistory()).toEqual([]); + }); + + it('should clear history', async () => { + const { LLMService } = await import('../../src/bridge/mpp-core'); + const service = new LLMService({ + provider: 'openai', + model: 'gpt-4', + apiKey: 'test-key' + }); + service.clearHistory(); + expect(service.getHistory()).toEqual([]); + }); +}); + +describe('CompletionManager', () => { + it('should be importable', async () => { + const { CompletionManager } = await import('../../src/bridge/mpp-core'); + expect(CompletionManager).toBeDefined(); + }); + + it('should create instance', async () => { + const { CompletionManager } = await import('../../src/bridge/mpp-core'); + const manager = new CompletionManager(); + expect(manager).toBeDefined(); + }); +}); + +describe('DevInsCompiler', () => { + it('should be importable', async () => { + const { DevInsCompiler } = await import('../../src/bridge/mpp-core'); + expect(DevInsCompiler).toBeDefined(); + }); +}); + +describe('ToolRegistry', () => { + it('should be importable', async () => { + const { ToolRegistry } = await import('../../src/bridge/mpp-core'); + expect(ToolRegistry).toBeDefined(); + }); + + it('should create instance with project path', async () => { + const { ToolRegistry } = await import('../../src/bridge/mpp-core'); + const registry = new ToolRegistry('/test/project'); + expect(registry).toBeDefined(); + }); +}); + +describe('CodingAgent', () => { + it('should be importable', async () => { + const { CodingAgent } = await import('../../src/bridge/mpp-core'); + expect(CodingAgent).toBeDefined(); + }); +}); + +describe('Helper functions', () => { + it('should get available models', async () => { + const { getAvailableModels } = await import('../../src/bridge/mpp-core'); + const models = getAvailableModels('openai'); + expect(Array.isArray(models)).toBe(true); + }); + + it('should get all providers', async () => { + const { getAllProviders } = await import('../../src/bridge/mpp-core'); + const providers = getAllProviders(); + expect(Array.isArray(providers)).toBe(true); + }); +}); + diff --git a/mpp-vscode/test/mocks/vscode.ts b/mpp-vscode/test/mocks/vscode.ts new file mode 100644 index 0000000000..3048450563 --- /dev/null +++ b/mpp-vscode/test/mocks/vscode.ts @@ -0,0 +1,80 @@ +/** + * VSCode API Mock for testing + */ + +export const Uri = { + file: (path: string) => ({ scheme: 'file', fsPath: path, path, toString: () => `file://${path}` }), + from: (components: { scheme: string; path: string; query?: string }) => ({ + scheme: components.scheme, + path: components.path, + query: components.query, + fsPath: components.path, + toString: () => `${components.scheme}://${components.path}${components.query ? '?' + components.query : ''}` + }), + parse: (value: string) => { + const url = new URL(value); + return { scheme: url.protocol.replace(':', ''), path: url.pathname, fsPath: url.pathname, toString: () => value }; + } +}; + +export const EventEmitter = class { + private listeners: Function[] = []; + event = (listener: Function) => { + this.listeners.push(listener); + return { dispose: () => { this.listeners = this.listeners.filter(l => l !== listener); } }; + }; + fire = (data: any) => { this.listeners.forEach(l => l(data)); }; + dispose = () => { this.listeners = []; }; +}; + +export const window = { + createOutputChannel: (name: string) => ({ + appendLine: (message: string) => console.log(`[${name}] ${message}`), + dispose: () => {} + }), + showInformationMessage: async (message: string) => console.log(`[INFO] ${message}`), + showWarningMessage: async (message: string) => console.log(`[WARN] ${message}`), + showErrorMessage: async (message: string) => console.log(`[ERROR] ${message}`), + showInputBox: async () => undefined, + activeTextEditor: undefined, + onDidChangeActiveTextEditor: () => ({ dispose: () => {} }), + registerWebviewViewProvider: () => ({ dispose: () => {} }), + tabGroups: { all: [] } +}; + +export const workspace = { + workspaceFolders: undefined, + getConfiguration: (section: string) => ({ + get: (key: string, defaultValue?: T) => defaultValue + }), + fs: { + stat: async (uri: any) => ({}), + readFile: async (uri: any) => Buffer.from(''), + writeFile: async (uri: any, content: Uint8Array) => {} + }, + onDidCloseTextDocument: () => ({ dispose: () => {} }), + onDidChangeWorkspaceFolders: () => ({ dispose: () => {} }), + registerTextDocumentContentProvider: () => ({ dispose: () => {} }), + openTextDocument: async (uri: any) => ({ getText: () => '', uri }) +}; + +export const commands = { + registerCommand: (command: string, callback: Function) => ({ dispose: () => {} }), + executeCommand: async (command: string, ...args: any[]) => {} +}; + +export const ExtensionMode = { + Development: 1, + Test: 2, + Production: 3 +}; + +export default { + Uri, + EventEmitter, + window, + workspace, + commands, + ExtensionMode +}; + diff --git a/mpp-vscode/test/services/diff-manager.test.ts b/mpp-vscode/test/services/diff-manager.test.ts new file mode 100644 index 0000000000..688516a643 --- /dev/null +++ b/mpp-vscode/test/services/diff-manager.test.ts @@ -0,0 +1,159 @@ +/** + * Tests for DiffManager + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock vscode module +vi.mock('vscode', () => ({ + Uri: { + file: (path: string) => ({ scheme: 'file', fsPath: path, path, toString: () => `file://${path}` }), + from: (components: { scheme: string; path: string; query?: string }) => ({ + scheme: components.scheme, + path: components.path, + query: components.query, + fsPath: components.path, + toString: () => `${components.scheme}://${components.path}${components.query ? '?' + components.query : ''}` + }), + parse: (value: string) => { + const url = new URL(value); + return { scheme: url.protocol.replace(':', ''), path: url.pathname, fsPath: url.pathname, toString: () => value }; + } + }, + EventEmitter: class { + private listeners: Function[] = []; + event = (listener: Function) => { + this.listeners.push(listener); + return { dispose: () => { this.listeners = this.listeners.filter(l => l !== listener); } }; + }; + fire = (data: any) => { this.listeners.forEach(l => l(data)); }; + dispose = () => { this.listeners = []; }; + }, + window: { + onDidChangeActiveTextEditor: () => ({ dispose: () => {} }), + activeTextEditor: undefined, + tabGroups: { all: [] } + }, + workspace: { + fs: { + stat: vi.fn().mockResolvedValue({}), + writeFile: vi.fn().mockResolvedValue(undefined) + }, + openTextDocument: vi.fn().mockResolvedValue({ getText: () => 'test content', uri: {} }) + }, + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined) + } +})); + +// Mock extension module to avoid circular dependency +vi.mock('../../src/extension', () => ({ + DIFF_SCHEME: 'autodev-diff' +})); + +describe('DiffContentProvider', () => { + it('should be importable', async () => { + const { DiffContentProvider } = await import('../../src/services/diff-manager'); + expect(DiffContentProvider).toBeDefined(); + }); + + it('should create instance', async () => { + const { DiffContentProvider } = await import('../../src/services/diff-manager'); + const provider = new DiffContentProvider(); + expect(provider).toBeDefined(); + }); + + it('should set and get content', async () => { + const { DiffContentProvider } = await import('../../src/services/diff-manager'); + const vscode = await import('vscode'); + + const provider = new DiffContentProvider(); + const uri = vscode.Uri.from({ scheme: 'autodev-diff', path: '/test/file.ts' }); + + provider.setContent(uri, 'test content'); + expect(provider.getContent(uri)).toBe('test content'); + }); + + it('should delete content', async () => { + const { DiffContentProvider } = await import('../../src/services/diff-manager'); + const vscode = await import('vscode'); + + const provider = new DiffContentProvider(); + const uri = vscode.Uri.from({ scheme: 'autodev-diff', path: '/test/file.ts' }); + + provider.setContent(uri, 'test content'); + provider.deleteContent(uri); + expect(provider.getContent(uri)).toBeUndefined(); + }); + + it('should provide text document content', async () => { + const { DiffContentProvider } = await import('../../src/services/diff-manager'); + const vscode = await import('vscode'); + + const provider = new DiffContentProvider(); + const uri = vscode.Uri.from({ scheme: 'autodev-diff', path: '/test/file.ts' }); + + provider.setContent(uri, 'provided content'); + expect(provider.provideTextDocumentContent(uri)).toBe('provided content'); + }); + + it('should return empty string for unknown uri', async () => { + const { DiffContentProvider } = await import('../../src/services/diff-manager'); + const vscode = await import('vscode'); + + const provider = new DiffContentProvider(); + const uri = vscode.Uri.from({ scheme: 'autodev-diff', path: '/unknown/file.ts' }); + + expect(provider.provideTextDocumentContent(uri)).toBe(''); + }); +}); + +describe('DiffManager', () => { + let logMessages: string[] = []; + const mockLog = (message: string) => { logMessages.push(message); }; + + beforeEach(() => { + logMessages = []; + vi.clearAllMocks(); + }); + + it('should be importable', async () => { + const { DiffManager } = await import('../../src/services/diff-manager'); + expect(DiffManager).toBeDefined(); + }); + + it('should create instance', async () => { + const { DiffManager, DiffContentProvider } = await import('../../src/services/diff-manager'); + const contentProvider = new DiffContentProvider(); + const manager = new DiffManager(mockLog, contentProvider); + expect(manager).toBeDefined(); + }); + + it('should check if diff exists for file', async () => { + const { DiffManager, DiffContentProvider } = await import('../../src/services/diff-manager'); + const contentProvider = new DiffContentProvider(); + const manager = new DiffManager(mockLog, contentProvider); + + expect(manager.hasDiff('/test/file.ts')).toBe(false); + }); + + it('should emit events on diff changes', async () => { + const { DiffManager, DiffContentProvider } = await import('../../src/services/diff-manager'); + const contentProvider = new DiffContentProvider(); + const manager = new DiffManager(mockLog, contentProvider); + + const events: any[] = []; + manager.onDidChange((event) => events.push(event)); + + expect(events).toHaveLength(0); + }); + + it('should dispose subscriptions', async () => { + const { DiffManager, DiffContentProvider } = await import('../../src/services/diff-manager'); + const contentProvider = new DiffContentProvider(); + const manager = new DiffManager(mockLog, contentProvider); + + expect(() => manager.dispose()).not.toThrow(); + }); +}); + diff --git a/mpp-vscode/test/services/ide-server.test.ts b/mpp-vscode/test/services/ide-server.test.ts new file mode 100644 index 0000000000..996c500e58 --- /dev/null +++ b/mpp-vscode/test/services/ide-server.test.ts @@ -0,0 +1,125 @@ +/** + * Tests for IDEServer + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock vscode module +vi.mock('vscode', () => ({ + Uri: { + file: (path: string) => ({ scheme: 'file', fsPath: path, path, toString: () => `file://${path}` }) + }, + workspace: { + workspaceFolders: [{ name: 'test', uri: { fsPath: '/test/workspace' } }], + fs: { + readFile: vi.fn().mockResolvedValue(Buffer.from('file content')), + writeFile: vi.fn().mockResolvedValue(undefined) + } + }, + window: { + activeTextEditor: { + document: { uri: { fsPath: '/test/file.ts' } }, + selection: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } } + } + } +})); + +// Mock fs/promises +vi.mock('fs/promises', () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + chmod: vi.fn().mockResolvedValue(undefined) +})); + +// Mock DiffManager +const mockDiffManager = { + showDiff: vi.fn().mockResolvedValue(undefined), + closeDiffByPath: vi.fn().mockResolvedValue('modified content'), + onDidChange: { dispose: () => {} } +}; + +describe('IDEServer', () => { + let logMessages: string[] = []; + const mockLog = (message: string) => { logMessages.push(message); }; + + beforeEach(() => { + logMessages = []; + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should be importable', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + expect(IDEServer).toBeDefined(); + }); + + it('should create instance with port', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + const server = new IDEServer(mockLog, mockDiffManager as any, 23120); + expect(server).toBeDefined(); + }); + + it('should have start method', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + const server = new IDEServer(mockLog, mockDiffManager as any, 23120); + expect(typeof server.start).toBe('function'); + }); + + it('should have stop method', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + const server = new IDEServer(mockLog, mockDiffManager as any, 23120); + expect(typeof server.stop).toBe('function'); + }); + + it('should have syncEnvVars method', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + const server = new IDEServer(mockLog, mockDiffManager as any, 23120); + expect(typeof server.syncEnvVars).toBe('function'); + }); + + it('should stop gracefully when not started', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + const server = new IDEServer(mockLog, mockDiffManager as any, 23120); + await expect(server.stop()).resolves.toBeUndefined(); + }); +}); + +describe('IDEServer API Endpoints', () => { + // These tests would require starting the actual server + // For unit tests, we verify the structure and methods exist + + it('should define health endpoint handler', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + expect(IDEServer).toBeDefined(); + // The actual endpoint testing would be done in integration tests + }); + + it('should define context endpoint handler', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + expect(IDEServer).toBeDefined(); + }); + + it('should define diff/open endpoint handler', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + expect(IDEServer).toBeDefined(); + }); + + it('should define diff/close endpoint handler', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + expect(IDEServer).toBeDefined(); + }); + + it('should define file/read endpoint handler', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + expect(IDEServer).toBeDefined(); + }); + + it('should define file/write endpoint handler', async () => { + const { IDEServer } = await import('../../src/services/ide-server'); + expect(IDEServer).toBeDefined(); + }); +}); + diff --git a/mpp-vscode/vitest.config.ts b/mpp-vscode/vitest.config.ts new file mode 100644 index 0000000000..79dcc72024 --- /dev/null +++ b/mpp-vscode/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + exclude: ['node_modules', 'dist'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/**/*.d.ts'] + } + }, + resolve: { + alias: { + vscode: './test/mocks/vscode.ts' + } + } +}); + From 7d6a9ce2f89d50e02e8eb45a41e0011bb4a4d9c1 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 23:56:38 +0800 Subject: [PATCH 14/33] feat(mpp-vscode): add DevIns language support and status bar Phase 5 - Status Bar: - Add StatusBarManager with idle/thinking/streaming/error states - Animated status icons during LLM operations - Click to open chat command Phase 6 - DevIns Language Support: - Add TextMate grammar for syntax highlighting - Support commands (/), agents (@), variables ($) - Add language configuration (brackets, folding, etc.) - Implement DevInsCompletionProvider with built-in completions - Register completion triggers for /, @, $ characters Testing: - Add unit tests for StatusBarManager - Add unit tests for DevInsCompletionProvider - All 56 tests passing Refs #31 --- mpp-vscode/README.md | 10 +- mpp-vscode/package.json | 19 ++ mpp-vscode/resources/icon.svg | 7 + mpp-vscode/src/extension.ts | 11 ++ mpp-vscode/src/providers/devins-completion.ts | 156 ++++++++++++++++ mpp-vscode/src/services/status-bar.ts | 128 +++++++++++++ mpp-vscode/syntaxes/DevIns.tmLanguage.json | 173 ++++++++++++++++++ .../syntaxes/language-configuration.json | 43 +++++ .../test/providers/devins-completion.test.ts | 160 ++++++++++++++++ mpp-vscode/test/services/status-bar.test.ts | 99 ++++++++++ 10 files changed, 801 insertions(+), 5 deletions(-) create mode 100644 mpp-vscode/resources/icon.svg create mode 100644 mpp-vscode/src/providers/devins-completion.ts create mode 100644 mpp-vscode/src/services/status-bar.ts create mode 100644 mpp-vscode/syntaxes/DevIns.tmLanguage.json create mode 100644 mpp-vscode/syntaxes/language-configuration.json create mode 100644 mpp-vscode/test/providers/devins-completion.test.ts create mode 100644 mpp-vscode/test/services/status-bar.test.ts diff --git a/mpp-vscode/README.md b/mpp-vscode/README.md index 5ab4cb43f3..3062d528b2 100644 --- a/mpp-vscode/README.md +++ b/mpp-vscode/README.md @@ -81,12 +81,12 @@ mpp-vscode/ - [x] autodev.cancelDiff - 取消差异 - [x] autodev.runAgent - 运行 Agent - [x] 快捷键绑定 (Cmd+Shift+A) -- [ ] 状态栏集成 +- [x] 状态栏集成 -### Phase 6: 高级功能 -- [ ] DevIns 语言支持 - - [ ] 语法高亮 - - [ ] 自动补全 +### Phase 6: 高级功能 ✅ +- [x] DevIns 语言支持 + - [x] 语法高亮 (TextMate grammar) + - [x] 自动补全 (/, @, $ 触发) - [ ] 代码索引集成 - [ ] 领域词典支持 - [ ] React Webview UI (替换内嵌 HTML) diff --git a/mpp-vscode/package.json b/mpp-vscode/package.json index ebaaa5b6c1..7b360ed9ed 100644 --- a/mpp-vscode/package.json +++ b/mpp-vscode/package.json @@ -101,6 +101,25 @@ "key": "ctrl+shift+a", "mac": "cmd+shift+a" } + ], + "languages": [ + { + "id": "DevIns", + "aliases": ["devins", "devin"], + "extensions": [".devins", ".devin"], + "configuration": "syntaxes/language-configuration.json", + "icon": { + "light": "./resources/icon.svg", + "dark": "./resources/icon.svg" + } + } + ], + "grammars": [ + { + "language": "DevIns", + "scopeName": "source.devins", + "path": "syntaxes/DevIns.tmLanguage.json" + } ] }, "scripts": { diff --git a/mpp-vscode/resources/icon.svg b/mpp-vscode/resources/icon.svg new file mode 100644 index 0000000000..ef40202d61 --- /dev/null +++ b/mpp-vscode/resources/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/mpp-vscode/src/extension.ts b/mpp-vscode/src/extension.ts index 4506200ea9..ae5048345c 100644 --- a/mpp-vscode/src/extension.ts +++ b/mpp-vscode/src/extension.ts @@ -8,11 +8,14 @@ import * as vscode from 'vscode'; import { IDEServer } from './services/ide-server'; import { DiffManager, DiffContentProvider } from './services/diff-manager'; import { ChatViewProvider } from './providers/chat-view'; +import { StatusBarManager } from './services/status-bar'; +import { registerDevInsCompletionProvider } from './providers/devins-completion'; import { createLogger } from './utils/logger'; export const DIFF_SCHEME = 'autodev-diff'; let ideServer: IDEServer | undefined; +let statusBar: StatusBarManager | undefined; let logger: vscode.OutputChannel; let log: (message: string) => void = () => {}; @@ -24,6 +27,10 @@ export async function activate(context: vscode.ExtensionContext) { log = createLogger(context, logger); log('AutoDev extension activated'); + // Initialize Status Bar + statusBar = new StatusBarManager(); + context.subscriptions.push({ dispose: () => statusBar?.dispose() }); + // Initialize Diff Manager const diffContentProvider = new DiffContentProvider(); const diffManager = new DiffManager(log, diffContentProvider); @@ -113,6 +120,10 @@ export async function activate(context: vscode.ExtensionContext) { }) ); + // Register DevIns language completion provider + context.subscriptions.push(registerDevInsCompletionProvider(context)); + log('DevIns language support registered'); + // Show welcome message on first install const welcomeShownKey = 'autodev.welcomeShown'; if (!context.globalState.get(welcomeShownKey)) { diff --git a/mpp-vscode/src/providers/devins-completion.ts b/mpp-vscode/src/providers/devins-completion.ts new file mode 100644 index 0000000000..5a0063a449 --- /dev/null +++ b/mpp-vscode/src/providers/devins-completion.ts @@ -0,0 +1,156 @@ +/** + * DevIns Completion Provider - Auto-completion for DevIns language + */ + +import * as vscode from 'vscode'; +import { CompletionManager } from '../bridge/mpp-core'; + +/** + * Built-in DevIns commands + */ +const BUILTIN_COMMANDS = [ + { name: '/file', description: 'Read file content', args: ':path' }, + { name: '/write', description: 'Write content to file', args: ':path' }, + { name: '/run', description: 'Run shell command', args: ':command' }, + { name: '/patch', description: 'Apply patch to file', args: ':path' }, + { name: '/commit', description: 'Create git commit', args: ':message' }, + { name: '/symbol', description: 'Find symbol in codebase', args: ':name' }, + { name: '/rev', description: 'Review code changes', args: '' }, + { name: '/refactor', description: 'Refactor code', args: ':instruction' }, + { name: '/test', description: 'Generate tests', args: '' }, + { name: '/doc', description: 'Generate documentation', args: '' }, + { name: '/help', description: 'Show available commands', args: '' } +]; + +/** + * Built-in agents + */ +const BUILTIN_AGENTS = [ + { name: '@code', description: 'Code generation agent' }, + { name: '@test', description: 'Test generation agent' }, + { name: '@doc', description: 'Documentation agent' }, + { name: '@review', description: 'Code review agent' }, + { name: '@refactor', description: 'Refactoring agent' }, + { name: '@explain', description: 'Code explanation agent' } +]; + +/** + * Built-in variables + */ +const BUILTIN_VARIABLES = [ + { name: '$selection', description: 'Current editor selection' }, + { name: '$file', description: 'Current file path' }, + { name: '$fileName', description: 'Current file name' }, + { name: '$language', description: 'Current file language' }, + { name: '$workspace', description: 'Workspace root path' }, + { name: '$clipboard', description: 'Clipboard content' } +]; + +/** + * DevIns Completion Provider + */ +export class DevInsCompletionProvider implements vscode.CompletionItemProvider { + private completionManager: CompletionManager | undefined; + + constructor() { + try { + this.completionManager = new CompletionManager(); + } catch (e) { + // mpp-core not available, use built-in completions only + } + } + + async provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + _token: vscode.CancellationToken, + _context: vscode.CompletionContext + ): Promise { + const linePrefix = document.lineAt(position).text.substring(0, position.character); + const items: vscode.CompletionItem[] = []; + + // Command completion (starts with /) + if (linePrefix.endsWith('/') || /\/[a-zA-Z]*$/.test(linePrefix)) { + items.push(...this.getCommandCompletions(linePrefix)); + } + + // Agent completion (starts with @) + if (linePrefix.endsWith('@') || /@[a-zA-Z]*$/.test(linePrefix)) { + items.push(...this.getAgentCompletions(linePrefix)); + } + + // Variable completion (starts with $) + if (linePrefix.endsWith('$') || /\$[a-zA-Z]*$/.test(linePrefix)) { + items.push(...this.getVariableCompletions(linePrefix)); + } + + return items; + } + + private getCommandCompletions(linePrefix: string): vscode.CompletionItem[] { + const prefix = linePrefix.match(/\/([a-zA-Z]*)$/)?.[1] || ''; + + return BUILTIN_COMMANDS + .filter(cmd => cmd.name.substring(1).startsWith(prefix)) + .map(cmd => { + const item = new vscode.CompletionItem( + cmd.name, + vscode.CompletionItemKind.Function + ); + item.detail = cmd.description; + item.insertText = cmd.name.substring(1) + cmd.args; + item.documentation = new vscode.MarkdownString(`**${cmd.name}**\n\n${cmd.description}`); + return item; + }); + } + + private getAgentCompletions(linePrefix: string): vscode.CompletionItem[] { + const prefix = linePrefix.match(/@([a-zA-Z]*)$/)?.[1] || ''; + + return BUILTIN_AGENTS + .filter(agent => agent.name.substring(1).startsWith(prefix)) + .map(agent => { + const item = new vscode.CompletionItem( + agent.name, + vscode.CompletionItemKind.Class + ); + item.detail = agent.description; + item.insertText = agent.name.substring(1); + item.documentation = new vscode.MarkdownString(`**${agent.name}**\n\n${agent.description}`); + return item; + }); + } + + private getVariableCompletions(linePrefix: string): vscode.CompletionItem[] { + const prefix = linePrefix.match(/\$([a-zA-Z]*)$/)?.[1] || ''; + + return BUILTIN_VARIABLES + .filter(v => v.name.substring(1).startsWith(prefix)) + .map(v => { + const item = new vscode.CompletionItem( + v.name, + vscode.CompletionItemKind.Variable + ); + item.detail = v.description; + item.insertText = v.name.substring(1); + item.documentation = new vscode.MarkdownString(`**${v.name}**\n\n${v.description}`); + return item; + }); + } +} + +/** + * Register DevIns completion provider + */ +export function registerDevInsCompletionProvider( + context: vscode.ExtensionContext +): vscode.Disposable { + const provider = new DevInsCompletionProvider(); + + return vscode.languages.registerCompletionItemProvider( + { language: 'DevIns', scheme: 'file' }, + provider, + '/', '@', '$' + ); +} + diff --git a/mpp-vscode/src/services/status-bar.ts b/mpp-vscode/src/services/status-bar.ts new file mode 100644 index 0000000000..3496f4b049 --- /dev/null +++ b/mpp-vscode/src/services/status-bar.ts @@ -0,0 +1,128 @@ +/** + * Status Bar Service - Shows AutoDev status in VSCode status bar + */ + +import * as vscode from 'vscode'; + +export type StatusBarState = 'idle' | 'thinking' | 'streaming' | 'error'; + +/** + * Status Bar Manager for AutoDev + */ +export class StatusBarManager { + private statusBarItem: vscode.StatusBarItem; + private state: StatusBarState = 'idle'; + private animationInterval: NodeJS.Timeout | undefined; + private animationFrame = 0; + + constructor() { + this.statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 100 + ); + this.statusBarItem.command = 'autodev.chat'; + this.statusBarItem.tooltip = 'Click to open AutoDev Chat'; + this.updateDisplay(); + this.statusBarItem.show(); + } + + /** + * Set the status bar state + */ + setState(state: StatusBarState, message?: string): void { + this.state = state; + this.stopAnimation(); + + if (state === 'thinking' || state === 'streaming') { + this.startAnimation(); + } + + this.updateDisplay(message); + } + + /** + * Show a temporary message + */ + showMessage(message: string, timeout = 3000): void { + const previousState = this.state; + this.updateDisplay(message); + + setTimeout(() => { + if (this.state === previousState) { + this.updateDisplay(); + } + }, timeout); + } + + /** + * Dispose the status bar item + */ + dispose(): void { + this.stopAnimation(); + this.statusBarItem.dispose(); + } + + private updateDisplay(message?: string): void { + const icons: Record = { + idle: '$(sparkle)', + thinking: this.getThinkingIcon(), + streaming: this.getStreamingIcon(), + error: '$(error)' + }; + + const colors: Record = { + idle: undefined, + thinking: new vscode.ThemeColor('statusBarItem.warningForeground').toString(), + streaming: new vscode.ThemeColor('statusBarItem.prominentForeground').toString(), + error: new vscode.ThemeColor('statusBarItem.errorForeground').toString() + }; + + const icon = icons[this.state]; + const text = message || this.getDefaultText(); + + this.statusBarItem.text = `${icon} ${text}`; + this.statusBarItem.backgroundColor = this.state === 'error' + ? new vscode.ThemeColor('statusBarItem.errorBackground') + : undefined; + } + + private getDefaultText(): string { + switch (this.state) { + case 'idle': + return 'AutoDev'; + case 'thinking': + return 'Thinking...'; + case 'streaming': + return 'Generating...'; + case 'error': + return 'Error'; + } + } + + private getThinkingIcon(): string { + const frames = ['$(loading~spin)', '$(sync~spin)', '$(gear~spin)']; + return frames[this.animationFrame % frames.length]; + } + + private getStreamingIcon(): string { + const frames = ['$(pulse)', '$(radio-tower)', '$(broadcast)']; + return frames[this.animationFrame % frames.length]; + } + + private startAnimation(): void { + this.animationFrame = 0; + this.animationInterval = setInterval(() => { + this.animationFrame++; + this.updateDisplay(); + }, 500); + } + + private stopAnimation(): void { + if (this.animationInterval) { + clearInterval(this.animationInterval); + this.animationInterval = undefined; + } + this.animationFrame = 0; + } +} + diff --git a/mpp-vscode/syntaxes/DevIns.tmLanguage.json b/mpp-vscode/syntaxes/DevIns.tmLanguage.json new file mode 100644 index 0000000000..a0a2059266 --- /dev/null +++ b/mpp-vscode/syntaxes/DevIns.tmLanguage.json @@ -0,0 +1,173 @@ +{ + "name": "DevIns", + "scopeName": "source.devins", + "fileTypes": [".devin", ".devins"], + "folding": { + "markers": { + "start": "^```", + "end": "^```$" + } + }, + "patterns": [ + { "include": "#frontMatter" }, + { "include": "#command" }, + { "include": "#agent" }, + { "include": "#variable" }, + { "include": "#block" } + ], + "repository": { + "frontMatter": { + "begin": "\\A-{3}\\s*$", + "contentName": "meta.embedded.block.frontmatter", + "patterns": [{ "include": "source.yaml" }], + "end": "(^|\\G)-{3}|\\.{3}\\s*$" + }, + "command": { + "patterns": [ + { + "match": "^\\s*(/[a-zA-Z][a-zA-Z0-9_-]*)(:?)([^\\n]*)?", + "captures": { + "1": { "name": "keyword.control.command.devins" }, + "2": { "name": "punctuation.separator.devins" }, + "3": { "name": "string.unquoted.argument.devins" } + }, + "name": "meta.command.devins" + } + ] + }, + "agent": { + "patterns": [ + { + "match": "(@[a-zA-Z][a-zA-Z0-9_-]*)", + "captures": { + "1": { "name": "entity.name.tag.agent.devins" } + }, + "name": "meta.agent.devins" + } + ] + }, + "variable": { + "patterns": [ + { + "match": "(\\$[a-zA-Z][a-zA-Z0-9_]*)", + "captures": { + "1": { "name": "variable.other.devins" } + }, + "name": "meta.variable.devins" + } + ] + }, + "block": { + "patterns": [ + { "include": "#heading" }, + { "include": "#blockquote" }, + { "include": "#lists" }, + { "include": "#fenced_code_block" }, + { "include": "#paragraph" } + ] + }, + "heading": { + "match": "(?:^|\\G)[ ]{0,3}(#{1,6}\\s+(.*?)(\\s+#{1,6})?\\s*)$", + "name": "markup.heading.markdown", + "captures": { + "1": { + "patterns": [ + { + "match": "(#{1,6})\\s+(.*?)(?:\\s+(#+))?\\s*$", + "captures": { + "1": { "name": "punctuation.definition.heading.markdown" }, + "2": { "name": "entity.name.section.markdown" }, + "3": { "name": "punctuation.definition.heading.markdown" } + } + } + ] + } + } + }, + "blockquote": { + "begin": "(^|\\G)[ ]{0,3}(>) ?", + "captures": { + "2": { "name": "punctuation.definition.quote.begin.markdown" } + }, + "name": "markup.quote.markdown", + "patterns": [{ "include": "#block" }], + "while": "(^|\\G)\\s*(>) ?" + }, + "fenced_code_block": { + "begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*([^`\\s]*)?\\s*$", + "beginCaptures": { + "3": { "name": "punctuation.definition.markdown" }, + "4": { "name": "fenced_code.block.language.markdown" } + }, + "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", + "endCaptures": { + "3": { "name": "punctuation.definition.markdown" } + }, + "name": "markup.fenced_code.block.markdown", + "contentName": "meta.embedded.block" + }, + "lists": { + "patterns": [ + { + "begin": "(^|\\G)([ ]{0,3})([*+-])([ \\t])", + "beginCaptures": { + "3": { "name": "punctuation.definition.list.begin.markdown" } + }, + "name": "markup.list.unnumbered.markdown", + "patterns": [{ "include": "#block" }], + "while": "((^|\\G)([ ]{2,4}|\\t))|(^[ \\t]*$)" + }, + { + "begin": "(^|\\G)([ ]{0,3})([0-9]+[\\.\\)])([ \\t])", + "beginCaptures": { + "3": { "name": "punctuation.definition.list.begin.markdown" } + }, + "name": "markup.list.numbered.markdown", + "patterns": [{ "include": "#block" }], + "while": "((^|\\G)([ ]{2,4}|\\t))|(^[ \\t]*$)" + } + ] + }, + "paragraph": { + "begin": "(^|\\G)[ ]{0,3}(?=[^ \\t\\n])", + "name": "meta.paragraph.markdown", + "patterns": [{ "include": "#inline" }], + "while": "(^|\\G)((?=\\s*[-=]{3,}\\s*$)|[ ]{4,}(?=[^ \\t\\n]))" + }, + "inline": { + "patterns": [ + { "include": "#command" }, + { "include": "#agent" }, + { "include": "#variable" }, + { "include": "#bold" }, + { "include": "#italic" }, + { "include": "#raw" } + ] + }, + "bold": { + "match": "(\\*\\*|__)(.+?)(\\1)", + "captures": { + "1": { "name": "punctuation.definition.bold.markdown" }, + "2": { "name": "markup.bold.markdown" }, + "3": { "name": "punctuation.definition.bold.markdown" } + } + }, + "italic": { + "match": "(\\*|_)(.+?)(\\1)", + "captures": { + "1": { "name": "punctuation.definition.italic.markdown" }, + "2": { "name": "markup.italic.markdown" }, + "3": { "name": "punctuation.definition.italic.markdown" } + } + }, + "raw": { + "match": "(`+)((?:[^`]|(?!(?"] + }, + "brackets": [ + ["{", "}"], + ["[", "]"], + ["(", ")"] + ], + "colorizedBracketPairs": [], + "autoClosingPairs": [ + { "open": "{", "close": "}" }, + { "open": "[", "close": "]" }, + { "open": "(", "close": ")" }, + { "open": "<", "close": ">", "notIn": ["string"] }, + { "open": "`", "close": "`" }, + { "open": "\"", "close": "\"" }, + { "open": "'", "close": "'" } + ], + "surroundingPairs": [ + ["(", ")"], + ["[", "]"], + ["`", "`"], + ["_", "_"], + ["*", "*"], + ["{", "}"], + ["'", "'"], + ["\"", "\""], + ["<", ">"] + ], + "folding": { + "offSide": true, + "markers": { + "start": "^\\s*", + "end": "^\\s*" + } + }, + "wordPattern": { + "pattern": "(\\p{Alphabetic}|\\p{Number}|\\p{Nonspacing_Mark})(((\\p{Alphabetic}|\\p{Number}|\\p{Nonspacing_Mark})|[_])?(\\p{Alphabetic}|\\p{Number}|\\p{Nonspacing_Mark}))*", + "flags": "ug" + } +} + diff --git a/mpp-vscode/test/providers/devins-completion.test.ts b/mpp-vscode/test/providers/devins-completion.test.ts new file mode 100644 index 0000000000..7a56b7a003 --- /dev/null +++ b/mpp-vscode/test/providers/devins-completion.test.ts @@ -0,0 +1,160 @@ +/** + * Tests for DevIns Completion Provider + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock vscode module +vi.mock('vscode', () => ({ + CompletionItem: vi.fn().mockImplementation((label, kind) => ({ + label, + kind, + detail: '', + insertText: '', + documentation: null + })), + CompletionItemKind: { + Function: 3, + Class: 7, + Variable: 6 + }, + MarkdownString: vi.fn().mockImplementation((value) => ({ value })), + languages: { + registerCompletionItemProvider: vi.fn().mockReturnValue({ dispose: () => {} }) + } +})); + +// Mock mpp-core +vi.mock('../../src/bridge/mpp-core', () => ({ + CompletionManager: vi.fn().mockImplementation(() => ({ + initWorkspace: vi.fn().mockResolvedValue(true), + getCompletions: vi.fn().mockReturnValue([]) + })) +})); + +describe('DevInsCompletionProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should be importable', async () => { + const { DevInsCompletionProvider } = await import('../../src/providers/devins-completion'); + expect(DevInsCompletionProvider).toBeDefined(); + }); + + it('should create instance', async () => { + const { DevInsCompletionProvider } = await import('../../src/providers/devins-completion'); + const provider = new DevInsCompletionProvider(); + expect(provider).toBeDefined(); + }); + + it('should have provideCompletionItems method', async () => { + const { DevInsCompletionProvider } = await import('../../src/providers/devins-completion'); + const provider = new DevInsCompletionProvider(); + expect(typeof provider.provideCompletionItems).toBe('function'); + }); + + it('should provide command completions for /', async () => { + const { DevInsCompletionProvider } = await import('../../src/providers/devins-completion'); + const provider = new DevInsCompletionProvider(); + + const mockDocument = { + lineAt: vi.fn().mockReturnValue({ text: '/' }) + }; + const mockPosition = { character: 1 }; + const mockToken = { isCancellationRequested: false }; + const mockContext = {}; + + const items = await provider.provideCompletionItems( + mockDocument as any, + mockPosition as any, + mockToken as any, + mockContext as any + ); + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThan(0); + }); + + it('should provide agent completions for @', async () => { + const { DevInsCompletionProvider } = await import('../../src/providers/devins-completion'); + const provider = new DevInsCompletionProvider(); + + const mockDocument = { + lineAt: vi.fn().mockReturnValue({ text: '@' }) + }; + const mockPosition = { character: 1 }; + const mockToken = { isCancellationRequested: false }; + const mockContext = {}; + + const items = await provider.provideCompletionItems( + mockDocument as any, + mockPosition as any, + mockToken as any, + mockContext as any + ); + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThan(0); + }); + + it('should provide variable completions for $', async () => { + const { DevInsCompletionProvider } = await import('../../src/providers/devins-completion'); + const provider = new DevInsCompletionProvider(); + + const mockDocument = { + lineAt: vi.fn().mockReturnValue({ text: '$' }) + }; + const mockPosition = { character: 1 }; + const mockToken = { isCancellationRequested: false }; + const mockContext = {}; + + const items = await provider.provideCompletionItems( + mockDocument as any, + mockPosition as any, + mockToken as any, + mockContext as any + ); + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThan(0); + }); + + it('should return empty array for regular text', async () => { + const { DevInsCompletionProvider } = await import('../../src/providers/devins-completion'); + const provider = new DevInsCompletionProvider(); + + const mockDocument = { + lineAt: vi.fn().mockReturnValue({ text: 'hello world' }) + }; + const mockPosition = { character: 11 }; + const mockToken = { isCancellationRequested: false }; + const mockContext = {}; + + const items = await provider.provideCompletionItems( + mockDocument as any, + mockPosition as any, + mockToken as any, + mockContext as any + ); + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBe(0); + }); +}); + +describe('registerDevInsCompletionProvider', () => { + it('should be importable', async () => { + const { registerDevInsCompletionProvider } = await import('../../src/providers/devins-completion'); + expect(registerDevInsCompletionProvider).toBeDefined(); + }); + + it('should return disposable', async () => { + const { registerDevInsCompletionProvider } = await import('../../src/providers/devins-completion'); + const mockContext = { subscriptions: [] }; + const disposable = registerDevInsCompletionProvider(mockContext as any); + expect(disposable).toBeDefined(); + expect(typeof disposable.dispose).toBe('function'); + }); +}); + diff --git a/mpp-vscode/test/services/status-bar.test.ts b/mpp-vscode/test/services/status-bar.test.ts new file mode 100644 index 0000000000..b7d3160f14 --- /dev/null +++ b/mpp-vscode/test/services/status-bar.test.ts @@ -0,0 +1,99 @@ +/** + * Tests for StatusBarManager + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock vscode module +vi.mock('vscode', () => ({ + window: { + createStatusBarItem: vi.fn().mockReturnValue({ + text: '', + tooltip: '', + command: '', + backgroundColor: undefined, + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn() + }) + }, + StatusBarAlignment: { + Left: 1, + Right: 2 + }, + ThemeColor: vi.fn().mockImplementation((id: string) => ({ id, toString: () => id })) +})); + +describe('StatusBarManager', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should be importable', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + expect(StatusBarManager).toBeDefined(); + }); + + it('should create instance', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + const manager = new StatusBarManager(); + expect(manager).toBeDefined(); + }); + + it('should have setState method', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + const manager = new StatusBarManager(); + expect(typeof manager.setState).toBe('function'); + }); + + it('should have showMessage method', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + const manager = new StatusBarManager(); + expect(typeof manager.showMessage).toBe('function'); + }); + + it('should have dispose method', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + const manager = new StatusBarManager(); + expect(typeof manager.dispose).toBe('function'); + }); + + it('should set state to idle', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + const manager = new StatusBarManager(); + expect(() => manager.setState('idle')).not.toThrow(); + }); + + it('should set state to thinking', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + const manager = new StatusBarManager(); + expect(() => manager.setState('thinking')).not.toThrow(); + manager.dispose(); // Clean up animation interval + }); + + it('should set state to streaming', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + const manager = new StatusBarManager(); + expect(() => manager.setState('streaming')).not.toThrow(); + manager.dispose(); // Clean up animation interval + }); + + it('should set state to error', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + const manager = new StatusBarManager(); + expect(() => manager.setState('error')).not.toThrow(); + }); + + it('should show temporary message', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + const manager = new StatusBarManager(); + expect(() => manager.showMessage('Test message', 100)).not.toThrow(); + }); + + it('should dispose without error', async () => { + const { StatusBarManager } = await import('../../src/services/status-bar'); + const manager = new StatusBarManager(); + expect(() => manager.dispose()).not.toThrow(); + }); +}); + From 6e78932f5549421b64c53cb9e6e32b44f881aad8 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Wed, 3 Dec 2025 07:28:42 +0800 Subject: [PATCH 15/33] feat(mpp-vscode): add React Webview UI for chat interface Phase 6 - React Webview UI: - Create webview React project with Vite build - Add MessageList component with Markdown rendering - Add ChatInput component with auto-resize textarea - Add useVSCode hook for extension communication - Integrate VSCode theme variables for consistent styling - Support streaming response with animated indicators - Add fallback inline HTML when React bundle not available - Update ChatViewProvider to load React bundle with CSP Components: - App.tsx - Main chat application - MessageList.tsx - Message display with Markdown support - ChatInput.tsx - Input with keyboard shortcuts - useVSCode.ts - VSCode API communication hook Testing: - Add ChatViewProvider tests - All 63 tests passing Refs #31 --- mpp-vscode/README.md | 6 +- mpp-vscode/package.json | 6 +- mpp-vscode/src/providers/chat-view.ts | 199 ++++++++---------- mpp-vscode/test/providers/chat-view.test.ts | 142 +++++++++++++ mpp-vscode/webview/index.html | 13 ++ mpp-vscode/webview/package.json | 25 +++ mpp-vscode/webview/src/App.css | 36 ++++ mpp-vscode/webview/src/App.tsx | 101 +++++++++ .../webview/src/components/ChatInput.css | 100 +++++++++ .../webview/src/components/ChatInput.tsx | 94 +++++++++ .../webview/src/components/MessageList.css | 141 +++++++++++++ .../webview/src/components/MessageList.tsx | 82 ++++++++ mpp-vscode/webview/src/hooks/useVSCode.ts | 93 ++++++++ mpp-vscode/webview/src/main.tsx | 11 + mpp-vscode/webview/src/styles/index.css | 141 +++++++++++++ mpp-vscode/webview/tsconfig.json | 21 ++ mpp-vscode/webview/vite.config.ts | 21 ++ 17 files changed, 1115 insertions(+), 117 deletions(-) create mode 100644 mpp-vscode/test/providers/chat-view.test.ts create mode 100644 mpp-vscode/webview/index.html create mode 100644 mpp-vscode/webview/package.json create mode 100644 mpp-vscode/webview/src/App.css create mode 100644 mpp-vscode/webview/src/App.tsx create mode 100644 mpp-vscode/webview/src/components/ChatInput.css create mode 100644 mpp-vscode/webview/src/components/ChatInput.tsx create mode 100644 mpp-vscode/webview/src/components/MessageList.css create mode 100644 mpp-vscode/webview/src/components/MessageList.tsx create mode 100644 mpp-vscode/webview/src/hooks/useVSCode.ts create mode 100644 mpp-vscode/webview/src/main.tsx create mode 100644 mpp-vscode/webview/src/styles/index.css create mode 100644 mpp-vscode/webview/tsconfig.json create mode 100644 mpp-vscode/webview/vite.config.ts diff --git a/mpp-vscode/README.md b/mpp-vscode/README.md index 3062d528b2..e7febba66f 100644 --- a/mpp-vscode/README.md +++ b/mpp-vscode/README.md @@ -87,9 +87,13 @@ mpp-vscode/ - [x] DevIns 语言支持 - [x] 语法高亮 (TextMate grammar) - [x] 自动补全 (/, @, $ 触发) +- [x] React Webview UI + - [x] React + Vite 构建 + - [x] Markdown 渲染 (react-markdown + remark-gfm) + - [x] VSCode 主题集成 + - [x] 流式响应动画 - [ ] 代码索引集成 - [ ] 领域词典支持 -- [ ] React Webview UI (替换内嵌 HTML) ## 参考项目 diff --git a/mpp-vscode/package.json b/mpp-vscode/package.json index 7b360ed9ed..e9d00e64be 100644 --- a/mpp-vscode/package.json +++ b/mpp-vscode/package.json @@ -124,8 +124,10 @@ }, "scripts": { "vscode:prepublish": "npm run build", - "build": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node", - "watch": "npm run build -- --watch", + "build": "npm run build:extension && npm run build:webview", + "build:extension": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node", + "build:webview": "cd webview && npm install && npm run build", + "watch": "npm run build:extension -- --watch", "package": "vsce package", "lint": "eslint src --ext ts", "test": "vitest run", diff --git a/mpp-vscode/src/providers/chat-view.ts b/mpp-vscode/src/providers/chat-view.ts index 114f3572f6..7d26971d5e 100644 --- a/mpp-vscode/src/providers/chat-view.ts +++ b/mpp-vscode/src/providers/chat-view.ts @@ -3,6 +3,7 @@ */ import * as vscode from 'vscode'; +import * as path from 'path'; import { LLMService, ModelConfig } from '../bridge/mpp-core'; /** @@ -27,10 +28,12 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { webviewView.webview.options = { enableScripts: true, - localResourceRoots: [this.context.extensionUri] + localResourceRoots: [ + vscode.Uri.joinPath(this.context.extensionUri, 'dist', 'webview') + ] }; - webviewView.webview.html = this.getHtmlContent(); + webviewView.webview.html = this.getHtmlContent(webviewView.webview); // Handle messages from webview webviewView.webview.onDidReceiveMessage(async (message) => { @@ -133,131 +136,99 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { this.postMessage({ type: 'historyCleared' }); } - private getHtmlContent(): string { + private getHtmlContent(webview: vscode.Webview): string { + // Check if React build exists + const webviewPath = vscode.Uri.joinPath(this.context.extensionUri, 'dist', 'webview'); + const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(webviewPath, 'assets', 'index.js')); + const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(webviewPath, 'assets', 'index.css')); + + // Use nonce for security + const nonce = this.getNonce(); + + // Try to use React build, fallback to inline HTML return ` + + AutoDev Chat - -
-
- - -
- + `; } + + private 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; + } } diff --git a/mpp-vscode/test/providers/chat-view.test.ts b/mpp-vscode/test/providers/chat-view.test.ts new file mode 100644 index 0000000000..331012c866 --- /dev/null +++ b/mpp-vscode/test/providers/chat-view.test.ts @@ -0,0 +1,142 @@ +/** + * Tests for ChatViewProvider + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock vscode module +vi.mock('vscode', () => ({ + Uri: { + joinPath: vi.fn().mockReturnValue({ fsPath: '/mock/path' }), + file: vi.fn().mockReturnValue({ fsPath: '/mock/path' }) + }, + window: { + createOutputChannel: vi.fn().mockReturnValue({ + appendLine: vi.fn(), + show: vi.fn() + }) + }, + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn().mockImplementation((key: string, defaultValue: unknown) => defaultValue) + }) + } +})); + +// Mock mpp-core +vi.mock('../../src/bridge/mpp-core', () => ({ + LLMService: vi.fn().mockImplementation(() => ({ + streamMessage: vi.fn().mockResolvedValue(undefined), + clearHistory: vi.fn() + })), + ModelConfig: {} +})); + +describe('ChatViewProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should be importable', async () => { + const { ChatViewProvider } = await import('../../src/providers/chat-view'); + expect(ChatViewProvider).toBeDefined(); + }); + + it('should create instance', async () => { + const { ChatViewProvider } = await import('../../src/providers/chat-view'); + const mockContext = { + extensionUri: { fsPath: '/mock/extension' }, + subscriptions: [] + }; + const mockLog = vi.fn(); + + const provider = new ChatViewProvider(mockContext as any, mockLog); + expect(provider).toBeDefined(); + }); + + it('should have resolveWebviewView method', async () => { + const { ChatViewProvider } = await import('../../src/providers/chat-view'); + const mockContext = { + extensionUri: { fsPath: '/mock/extension' }, + subscriptions: [] + }; + const mockLog = vi.fn(); + + const provider = new ChatViewProvider(mockContext as any, mockLog); + expect(typeof provider.resolveWebviewView).toBe('function'); + }); + + it('should have sendMessage method', async () => { + const { ChatViewProvider } = await import('../../src/providers/chat-view'); + const mockContext = { + extensionUri: { fsPath: '/mock/extension' }, + subscriptions: [] + }; + const mockLog = vi.fn(); + + const provider = new ChatViewProvider(mockContext as any, mockLog); + expect(typeof provider.sendMessage).toBe('function'); + }); + + it('should have postMessage method', async () => { + const { ChatViewProvider } = await import('../../src/providers/chat-view'); + const mockContext = { + extensionUri: { fsPath: '/mock/extension' }, + subscriptions: [] + }; + const mockLog = vi.fn(); + + const provider = new ChatViewProvider(mockContext as any, mockLog); + expect(typeof provider.postMessage).toBe('function'); + }); +}); + +describe('Webview HTML Generation', () => { + it('should generate HTML with React bundle references', async () => { + const { ChatViewProvider } = await import('../../src/providers/chat-view'); + const mockContext = { + extensionUri: { fsPath: '/mock/extension' }, + subscriptions: [] + }; + const mockLog = vi.fn(); + + const provider = new ChatViewProvider(mockContext as any, mockLog); + + // Access private method via any cast for testing + const getHtmlContent = (provider as any).getHtmlContent.bind(provider); + const mockWebview = { + asWebviewUri: vi.fn().mockReturnValue('mock-uri'), + cspSource: 'mock-csp' + }; + + const html = getHtmlContent(mockWebview); + + expect(html).toContain(''); + expect(html).toContain('
'); + expect(html).toContain('Content-Security-Policy'); + }); + + it('should include fallback inline HTML', async () => { + const { ChatViewProvider } = await import('../../src/providers/chat-view'); + const mockContext = { + extensionUri: { fsPath: '/mock/extension' }, + subscriptions: [] + }; + const mockLog = vi.fn(); + + const provider = new ChatViewProvider(mockContext as any, mockLog); + + const getHtmlContent = (provider as any).getHtmlContent.bind(provider); + const mockWebview = { + asWebviewUri: vi.fn().mockReturnValue('mock-uri'), + cspSource: 'mock-csp' + }; + + const html = getHtmlContent(mockWebview); + + // Should have fallback code + expect(html).toContain('hasChildNodes'); + expect(html).toContain('acquireVsCodeApi'); + }); +}); + diff --git a/mpp-vscode/webview/index.html b/mpp-vscode/webview/index.html new file mode 100644 index 0000000000..5ccd4cb03c --- /dev/null +++ b/mpp-vscode/webview/index.html @@ -0,0 +1,13 @@ + + + + + + AutoDev Chat + + +
+ + + + diff --git a/mpp-vscode/webview/package.json b/mpp-vscode/webview/package.json new file mode 100644 index 0000000000..3f8b9746e6 --- /dev/null +++ b/mpp-vscode/webview/package.json @@ -0,0 +1,25 @@ +{ + "name": "autodev-webview", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^9.0.1", + "remark-gfm": "^4.0.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "typescript": "^5.3.0", + "vite": "^5.0.0" + } +} + diff --git a/mpp-vscode/webview/src/App.css b/mpp-vscode/webview/src/App.css new file mode 100644 index 0000000000..997a26f822 --- /dev/null +++ b/mpp-vscode/webview/src/App.css @@ -0,0 +1,36 @@ +.app { + height: 100%; + display: flex; + flex-direction: column; + background: var(--background); +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-bottom: 1px solid var(--panel-border); + background: var(--background); +} + +.header-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 14px; +} + +.header-icon { + font-size: 16px; +} + +.dev-badge { + font-size: 10px; + padding: 2px 6px; + background: var(--selection-background); + border-radius: 4px; + opacity: 0.7; +} + diff --git a/mpp-vscode/webview/src/App.tsx b/mpp-vscode/webview/src/App.tsx new file mode 100644 index 0000000000..ad2d8be79b --- /dev/null +++ b/mpp-vscode/webview/src/App.tsx @@ -0,0 +1,101 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { MessageList, Message } from './components/MessageList'; +import { ChatInput } from './components/ChatInput'; +import { useVSCode, ExtensionMessage } from './hooks/useVSCode'; +import './App.css'; + +const App: React.FC = () => { + const [messages, setMessages] = useState([]); + const [isStreaming, setIsStreaming] = useState(false); + const { postMessage, onMessage, isVSCode } = useVSCode(); + + // Handle messages from extension + const handleExtensionMessage = useCallback((msg: ExtensionMessage) => { + switch (msg.type) { + case 'userMessage': + setMessages(prev => [...prev, { role: 'user', content: msg.content || '' }]); + break; + + case 'startResponse': + setIsStreaming(true); + setMessages(prev => [...prev, { role: 'assistant', content: '', isStreaming: true }]); + break; + + case 'responseChunk': + setMessages(prev => { + const updated = [...prev]; + const lastIdx = updated.length - 1; + if (lastIdx >= 0 && updated[lastIdx].role === 'assistant') { + updated[lastIdx] = { + ...updated[lastIdx], + content: updated[lastIdx].content + (msg.content || '') + }; + } + return updated; + }); + break; + + case 'endResponse': + setIsStreaming(false); + setMessages(prev => { + const updated = [...prev]; + const lastIdx = updated.length - 1; + if (lastIdx >= 0 && updated[lastIdx].role === 'assistant') { + updated[lastIdx] = { ...updated[lastIdx], isStreaming: false }; + } + return updated; + }); + break; + + case 'error': + setIsStreaming(false); + setMessages(prev => [...prev, { role: 'error', content: msg.content || 'An error occurred' }]); + break; + + case 'historyCleared': + setMessages([]); + break; + } + }, []); + + // Subscribe to extension messages + useEffect(() => { + return onMessage(handleExtensionMessage); + }, [onMessage, handleExtensionMessage]); + + // Send message to extension + const handleSend = useCallback((content: string) => { + postMessage({ type: 'sendMessage', content }); + }, [postMessage]); + + // Clear history + const handleClear = useCallback(() => { + postMessage({ type: 'clearHistory' }); + }, [postMessage]); + + return ( +
+
+
+ + AutoDev Chat +
+ {!isVSCode && ( + Dev Mode + )} +
+ + + + +
+ ); +}; + +export default App; + diff --git a/mpp-vscode/webview/src/components/ChatInput.css b/mpp-vscode/webview/src/components/ChatInput.css new file mode 100644 index 0000000000..d4750ee95f --- /dev/null +++ b/mpp-vscode/webview/src/components/ChatInput.css @@ -0,0 +1,100 @@ +.chat-input-container { + padding: 12px; + border-top: 1px solid var(--panel-border); + background: var(--background); +} + +.input-wrapper { + display: flex; + gap: 8px; + align-items: flex-end; +} + +.chat-textarea { + flex: 1; + padding: 10px 12px; + border: 1px solid var(--input-border); + background: var(--input-background); + color: var(--input-foreground); + border-radius: 6px; + resize: none; + font-family: inherit; + font-size: inherit; + line-height: 1.4; + min-height: 40px; + max-height: 150px; + outline: none; + transition: border-color 0.2s; +} + +.chat-textarea:focus { + border-color: var(--accent); +} + +.chat-textarea:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.chat-textarea::placeholder { + color: var(--foreground); + opacity: 0.5; +} + +.input-actions { + display: flex; + gap: 4px; +} + +.action-button { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; +} + +.send-button { + background: var(--button-background); + color: var(--button-foreground); +} + +.send-button:hover:not(:disabled) { + background: var(--button-hover); +} + +.send-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.clear-button { + background: transparent; + color: var(--foreground); + opacity: 0.6; +} + +.clear-button:hover:not(:disabled) { + opacity: 1; + background: var(--selection-background); +} + +.input-hint { + margin-top: 6px; + font-size: 11px; + color: var(--foreground); + opacity: 0.5; +} + +.input-hint kbd { + background: var(--selection-background); + padding: 2px 5px; + border-radius: 3px; + font-family: inherit; + font-size: 10px; +} + diff --git a/mpp-vscode/webview/src/components/ChatInput.tsx b/mpp-vscode/webview/src/components/ChatInput.tsx new file mode 100644 index 0000000000..1a4ec21771 --- /dev/null +++ b/mpp-vscode/webview/src/components/ChatInput.tsx @@ -0,0 +1,94 @@ +import React, { useState, useRef, useEffect, KeyboardEvent } from 'react'; +import './ChatInput.css'; + +interface ChatInputProps { + onSend: (message: string) => void; + onClear?: () => void; + disabled?: boolean; + placeholder?: string; +} + +export const ChatInput: React.FC = ({ + onSend, + onClear, + disabled = false, + placeholder = 'Ask AutoDev...' +}) => { + const [input, setInput] = useState(''); + const textareaRef = useRef(null); + + // Auto-resize textarea + useEffect(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.style.height = 'auto'; + textarea.style.height = `${Math.min(textarea.scrollHeight, 150)}px`; + } + }, [input]); + + // Focus on mount + useEffect(() => { + textareaRef.current?.focus(); + }, []); + + const handleSubmit = () => { + const trimmed = input.trim(); + if (trimmed && !disabled) { + onSend(trimmed); + setInput(''); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + + return ( +
+
+