diff --git a/mpp-core/build.gradle.kts b/mpp-core/build.gradle.kts index 6328f5d266..c62878a9b9 100644 --- a/mpp-core/build.gradle.kts +++ b/mpp-core/build.gradle.kts @@ -135,7 +135,7 @@ kotlin { // Kotlin Logging for multiplatform logging implementation("io.github.oshai:kotlin-logging:7.0.13") - runtimeOnly("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1-0.6.x-compat") + implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1-0.6.x-compat") // Koog AI Framework - JVM only for now implementation("ai.koog:koog-agents:0.5.2") implementation("ai.koog:agents-mcp:0.5.2") diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/CodingAgentTemplate.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/CodingAgentTemplate.kt index d47d6a642b..cb12844fa7 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/CodingAgentTemplate.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/CodingAgentTemplate.kt @@ -36,17 +36,75 @@ All tools use the DevIns format with JSON parameters: ``` -# Task Completion Strategy +# Task Execution Strategy: Explore First, Then Plan -**IMPORTANT: Focus on completing the task efficiently.** +**CRITICAL: Always explore the codebase BEFORE creating a plan.** -1. **Understand the Task**: Read the user's request carefully -2. **Gather Minimum Required Information**: Only collect information directly needed for the task -3. **Execute the Task**: Make the necessary changes or provide the answer -4. **Verify if Needed**: For code changes, compile/test to verify -5. **Provide Summary**: Always end with a clear summary of what was done +## Phase 1: Exploration (REQUIRED before planning) +Before creating any plan, you MUST gather context: -**Avoid over-exploration**: Don't spend iterations exploring unrelated code. Stay focused on the task. +1. **Understand the request**: What exactly does the user want? +2. **Locate relevant files**: Use `/glob` to find files related to the task +3. **Read key files**: Use `/read-file` to understand existing code structure, patterns, and conventions +4. **Search for references**: Use `/grep` to find related code, usages, or patterns + +**Minimum exploration before planning:** +- For code modifications: Read the target file(s) and understand the structure +- For new features: Find similar existing implementations to follow patterns +- For bug fixes: Locate the bug and understand the context + +## Phase 2: Plan Creation (after exploration) +Only create a plan AFTER you have sufficient context: + +```markdown +1. Task Title + - [ ] Specific step with file path (e.g., "Add field to src/Entity.java") + - [ ] Another specific step + +2. Another Task + - [ ] Step with clear action +``` + +## Plan Actions +- `CREATE`: Create a new plan (only after exploration) +- `COMPLETE_STEP`: Mark a step done (taskIndex=1, stepIndex=1 for first step) +- `VIEW`: View current plan status + +## When to Use Planning +- Tasks requiring multiple files to be modified +- Complex features with dependencies between steps +- Skip planning for simple single-file edits + +## Plan Update Rules +- Mark ONE step at a time after completing actual work +- Do NOT batch multiple COMPLETE_STEP calls +- Update after work is done, not before + +Example workflow: +1. User: "Add validation to UserController" +2. Agent: Use /glob to find UserController +3. Agent: Use /read-file to read UserController +4. Agent: Create plan with specific steps based on what was learned +5. Agent: Execute each step, marking complete as done + + +/plan +```json +{"action": "CREATE", "planMarkdown": "1. Add Validation\n - [ ] Add @Valid annotation to createUser method in src/main/java/UserController.java\n - [ ] Create UserValidator class in src/main/java/validators/"} +``` + + +## Avoiding Common Mistakes + +**DON'T:** +- Create a plan immediately without reading any files +- Make assumptions about file locations or code structure +- Create vague steps like "implement feature" without specifics + +**DO:** +- Read relevant files first to understand the codebase +- Create specific steps with actual file paths +- Base your plan on what you learned during exploration # Information-Gathering Strategy @@ -95,10 +153,15 @@ When a tool fails: # IMPORTANT: One Tool Per Response -**Execute ONLY ONE tool per response.** +**Execute ONLY ONE tool per response. This is critical for proper execution.** - ✅ CORRECT: One block with ONE tool call -- ❌ WRONG: Multiple blocks +- ❌ WRONG: Multiple blocks or multiple tool calls + +**Special note for /plan tool:** +- Do NOT call multiple COMPLETE_STEP in one response +- Complete one step, wait for confirmation, then proceed to next step +- Each plan update requires a separate response cycle # Response Format @@ -161,17 +224,75 @@ ${'$'}{toolList} ``` -# 任务完成策略 +# 任务执行策略:先探索,后计划 + +**关键原则:在创建计划之前,必须先探索代码库。** + +## 第一阶段:探索(创建计划前必须完成) +在创建任何计划之前,你必须收集上下文: + +1. **理解请求**:用户到底想要什么? +2. **定位相关文件**:使用 `/glob` 查找与任务相关的文件 +3. **阅读关键文件**:使用 `/read-file` 了解现有代码结构、模式和约定 +4. **搜索引用**:使用 `/grep` 查找相关代码、用法或模式 + +**创建计划前的最少探索:** +- 对于代码修改:读取目标文件,理解其结构 +- 对于新功能:找到类似的现有实现以遵循模式 +- 对于 bug 修复:定位 bug 并理解上下文 + +## 第二阶段:创建计划(在探索之后) +只有在获得足够上下文后才创建计划: + +```markdown +1. 任务标题 + - [ ] 具体步骤带文件路径(如:"在 src/Entity.java 中添加字段") + - [ ] 另一个具体步骤 + +2. 另一个任务 + - [ ] 有明确操作的步骤 +``` + +## 计划操作 +- `CREATE`: 创建新计划(仅在探索之后) +- `COMPLETE_STEP`: 标记步骤完成 (taskIndex=1, stepIndex=1 表示第一个任务的第一个步骤) +- `VIEW`: 查看当前计划状态 + +## 何时使用计划 +- 需要修改多个文件的任务 +- 步骤之间有依赖关系的复杂功能 +- 简单的单文件编辑跳过计划 + +## 计划更新规则 +- 完成实际工作后一次只标记一个步骤 +- 不要在一次响应中批量调用 COMPLETE_STEP +- 工作完成后更新,而不是之前 + +示例工作流: +1. 用户:"给 UserController 添加验证" +2. Agent:使用 /glob 查找 UserController +3. Agent:使用 /read-file 读取 UserController +4. Agent:根据学到的内容创建具体步骤的计划 +5. Agent:执行每个步骤,完成后标记 + + +/plan +```json +{"action": "CREATE", "planMarkdown": "1. 添加验证\n - [ ] 在 src/main/java/UserController.java 的 createUser 方法添加 @Valid 注解\n - [ ] 在 src/main/java/validators/ 创建 UserValidator 类"} +``` + -**重要:专注于高效完成任务。** +## 避免常见错误 -1. **理解任务**:仔细阅读用户的请求 -2. **收集最少必要信息**:只收集任务直接需要的信息 -3. **执行任务**:进行必要的更改或提供答案 -4. **必要时验证**:对于代码更改,编译/测试以验证 -5. **提供总结**:始终以清晰的总结结束 +**不要:** +- 在没有读取任何文件的情况下立即创建计划 +- 对文件位置或代码结构做出假设 +- 创建模糊的步骤如"实现功能"而没有具体内容 -**避免过度探索**:不要花费迭代次数探索无关代码。保持专注于任务。 +**要:** +- 先读取相关文件以了解代码库 +- 创建带有实际文件路径的具体步骤 +- 基于探索阶段学到的内容制定计划 # 信息收集策略 diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/CodingAgentExecutor.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/CodingAgentExecutor.kt index 9c2ab59eb4..241f9cff68 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/CodingAgentExecutor.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/CodingAgentExecutor.kt @@ -3,12 +3,14 @@ package cc.unitmesh.agent.executor import cc.unitmesh.agent.* import cc.unitmesh.agent.conversation.ConversationManager import cc.unitmesh.agent.core.SubAgentManager +import cc.unitmesh.agent.logging.getLogger import cc.unitmesh.agent.tool.schema.ToolResultFormatter import cc.unitmesh.agent.orchestrator.ToolExecutionResult import cc.unitmesh.agent.orchestrator.ToolOrchestrator import cc.unitmesh.agent.render.CodingAgentRenderer import cc.unitmesh.agent.state.ToolCall import cc.unitmesh.agent.state.ToolExecutionState +import cc.unitmesh.agent.plan.PlanSummaryData import cc.unitmesh.agent.tool.ToolResult import cc.unitmesh.agent.tool.ToolType import cc.unitmesh.agent.tool.toToolType @@ -24,8 +26,8 @@ import cc.unitmesh.agent.orchestrator.ToolExecutionContext as OrchestratorContex data class AsyncShellConfig( /** Initial wait timeout in milliseconds before notifying AI that process is still running */ val initialWaitTimeoutMs: Long = 60_000L, // 1 minute - /** Maximum total wait time in milliseconds */ - val maxWaitTimeoutMs: Long = 300_000L, // 5 minutes + /** Maximum total wait time in milliseconds (2 minutes, similar to Cursor/Claude Code) */ + val maxWaitTimeoutMs: Long = 120_000L, // 2 minutes /** Interval for checking process status after initial timeout */ val checkIntervalMs: Long = 30_000L // 30 seconds ) @@ -38,7 +40,13 @@ class CodingAgentExecutor( maxIterations: Int = 100, private val subAgentManager: SubAgentManager? = null, enableLLMStreaming: Boolean = true, - private val asyncShellConfig: AsyncShellConfig = AsyncShellConfig() + private val asyncShellConfig: AsyncShellConfig = AsyncShellConfig(), + /** + * When true, only execute the first tool call per LLM response. + * This enforces the "one tool per response" rule even when LLM returns multiple tool calls. + * Default is true to prevent LLM from executing multiple tools in one iteration. + */ + private val singleToolPerIteration: Boolean = true ) : BaseAgentExecutor( projectPath = projectPath, llmService = llmService, @@ -47,6 +55,7 @@ class CodingAgentExecutor( maxIterations = maxIterations, enableLLMStreaming = enableLLMStreaming ) { + private val logger = getLogger("CodingAgentExecutor") private val steps = mutableListOf() private val edits = mutableListOf() @@ -92,12 +101,22 @@ class CodingAgentExecutor( break } - val toolCalls = toolCallParser.parseToolCalls(llmResponse.toString()) - if (toolCalls.isEmpty()) { + val allToolCalls = toolCallParser.parseToolCalls(llmResponse.toString()) + if (allToolCalls.isEmpty()) { renderer.renderTaskComplete() break } + // When singleToolPerIteration is enabled, only execute the first tool call + // This enforces the "one tool per response" rule even when LLM returns multiple tool calls + val toolCalls = if (singleToolPerIteration && allToolCalls.size > 1) { + logger.warn { "LLM returned ${allToolCalls.size} tool calls, but singleToolPerIteration is enabled. Only executing the first one: ${allToolCalls.first().toolName}" } + renderer.renderError("Warning: LLM returned ${allToolCalls.size} tool calls, only executing the first one") + listOf(allToolCalls.first()) + } else { + allToolCalls + } + val toolResults = executeToolCalls(toolCalls) val toolResultsText = ToolResultFormatter.formatMultipleToolResults(toolResults) conversationManager!!.addToolResults(toolResultsText) @@ -204,15 +223,15 @@ class CodingAgentExecutor( for (toolCall in toolsToExecute) { val toolName = toolCall.toolName val params = toolCall.params.mapValues { it.value as Any } - val paramsStr = params.entries.joinToString(" ") { (key, value) -> - "$key=\"$value\"" - } - renderer.renderToolCall(toolName, paramsStr) + // Use renderToolCallWithParams to pass parsed params directly + // This avoids string parsing issues with complex values like planMarkdown + renderer.renderToolCallWithParams(toolName, params) val executionContext = OrchestratorContext( workingDirectory = projectPath, - environment = emptyMap() + environment = emptyMap(), + timeout = asyncShellConfig.maxWaitTimeoutMs // Use max timeout for shell commands ) var executionResult = toolOrchestrator.executeToolCall( @@ -273,6 +292,11 @@ class CodingAgentExecutor( executionResult.metadata ) + // Render plan summary bar after plan tool execution + if (toolName == "plan" && executionResult.isSuccess) { + renderPlanSummaryIfAvailable() + } + val currentToolType = toolName.toToolType() if ((currentToolType == ToolType.WriteFile) && executionResult.isSuccess) { recordFileEdit(params) @@ -511,4 +535,14 @@ class CodingAgentExecutor( fun getConversationHistory(): List { return conversationManager?.getHistory() ?: emptyList() } + + /** + * Render plan summary bar if a plan is available + */ + private fun renderPlanSummaryIfAvailable() { + val planStateService = toolOrchestrator.getPlanStateService() ?: return + val currentPlan = planStateService.currentPlan.value ?: return + val summary = PlanSummaryData.from(currentPlan) + renderer.renderPlanSummary(summary) + } } diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolOrchestrator.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolOrchestrator.kt index f2dbe2d989..51d7e2a954 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolOrchestrator.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolOrchestrator.kt @@ -39,6 +39,15 @@ class ToolOrchestrator( ) { private val logger = getLogger("cc.unitmesh.agent.orchestrator.ToolOrchestrator") + /** + * Get the PlanStateService from the registered PlanManagementTool. + * Returns null if no plan tool is registered. + */ + fun getPlanStateService(): cc.unitmesh.agent.plan.PlanStateService? { + val planTool = registry.getTool("plan") as? cc.unitmesh.agent.tool.impl.PlanManagementTool + return planTool?.getPlanStateService() + } + // Coroutine scope for background tasks (async shell monitoring) private val backgroundScope = CoroutineScope(Dispatchers.Default) @@ -382,7 +391,7 @@ class ToolOrchestrator( else -> { // Handle special tools that need parameter conversion when (toolName.lowercase()) { - "task-boundary" -> executeTaskBoundaryTool(tool, params, basicContext) + "plan" -> executePlanManagementTool(tool, params, basicContext) "docql" -> executeDocQLTool(tool, params, basicContext) else -> { // For truly generic tools, use generic execution @@ -675,45 +684,104 @@ class ToolOrchestrator( return invocation.execute(context) } - private suspend fun executeTaskBoundaryTool( + private suspend fun executePlanManagementTool( tool: Tool, params: Map, context: cc.unitmesh.agent.tool.ToolExecutionContext ): ToolResult { - val taskBoundaryTool = tool as cc.unitmesh.agent.tool.impl.TaskBoundaryTool - - val taskName = params["taskName"] as? String - ?: return ToolResult.Error("taskName parameter is required") - val status = params["status"] as? String - ?: return ToolResult.Error("status parameter is required") - val summary = params["summary"] as? String ?: "" - - val taskBoundaryParams = cc.unitmesh.agent.tool.impl.TaskBoundaryParams( - taskName = taskName, - status = status, - summary = summary + val planTool = tool as cc.unitmesh.agent.tool.impl.PlanManagementTool + + val action = params["action"] as? String + ?: return ToolResult.Error("action parameter is required") + val planMarkdown = params["planMarkdown"] as? String ?: "" + + // Handle taskIndex and stepIndex - can be Number or String + val taskIndex = when (val v = params["taskIndex"]) { + is Number -> v.toInt() + is String -> v.toIntOrNull() ?: 0 + else -> 0 + } + val stepIndex = when (val v = params["stepIndex"]) { + is Number -> v.toInt() + is String -> v.toIntOrNull() ?: 0 + else -> 0 + } + + // Handle batch steps parameter + val steps = parseStepsParameter(params["steps"]) + + val planParams = cc.unitmesh.agent.tool.impl.PlanManagementParams( + action = action, + planMarkdown = planMarkdown, + taskIndex = taskIndex, + stepIndex = stepIndex, + steps = steps ) - - val invocation = taskBoundaryTool.createInvocation(taskBoundaryParams) + + val invocation = planTool.createInvocation(planParams) return invocation.execute(context) } + /** + * Parse the steps parameter which can be: + * - A List of Maps with taskIndex and stepIndex + * - A JSON string representing an array + * - null or empty + */ + private fun parseStepsParameter(stepsParam: Any?): List { + if (stepsParam == null) return emptyList() + + return when (stepsParam) { + is List<*> -> { + stepsParam.mapNotNull { item -> + when (item) { + is Map<*, *> -> { + val taskIdx = when (val t = item["taskIndex"]) { + is Number -> t.toInt() + is String -> t.toIntOrNull() ?: return@mapNotNull null + else -> return@mapNotNull null + } + val stepIdx = when (val s = item["stepIndex"]) { + is Number -> s.toInt() + is String -> s.toIntOrNull() ?: return@mapNotNull null + else -> return@mapNotNull null + } + cc.unitmesh.agent.tool.impl.StepRef(taskIdx, stepIdx) + } + else -> null + } + } + } + is String -> { + // Try to parse as JSON array + try { + val parsed = kotlinx.serialization.json.Json.decodeFromString>(stepsParam) + parsed + } catch (e: Exception) { + logger.debug { "Failed to parse steps parameter as JSON: ${e.message}" } + emptyList() + } + } + else -> emptyList() + } + } + private suspend fun executeDocQLTool( tool: Tool, params: Map, context: cc.unitmesh.agent.tool.ToolExecutionContext ): ToolResult { val docqlTool = tool as cc.unitmesh.agent.tool.impl.DocQLTool - + val query = params["query"] as? String ?: return ToolResult.Error("query parameter is required") val documentPath = params["documentPath"] as? String // Optional - + val docqlParams = cc.unitmesh.agent.tool.impl.DocQLParams( query = query, documentPath = documentPath ) - + val invocation = docqlTool.createInvocation(docqlParams) return invocation.execute(context) } @@ -738,7 +806,7 @@ class ToolOrchestrator( /** * Execute generic tool using ExecutableTool interface - * This handles new tools like task-boundary, ask-agent, etc. without needing specific implementations + * This handles new tools like ask-agent, etc. without needing specific implementations */ private suspend fun executeGenericTool( tool: Tool, diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/AgentPlan.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/AgentPlan.kt new file mode 100644 index 0000000000..2042b63eb9 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/AgentPlan.kt @@ -0,0 +1,140 @@ +package cc.unitmesh.agent.plan + +import kotlinx.datetime.Clock +import kotlinx.serialization.Serializable + +/** + * Represents a complete agent plan containing multiple tasks. + * + * An AgentPlan is the top-level container for organizing work + * into tasks and steps, with tracking for creation and update times. + */ +@Serializable +data class AgentPlan( + /** + * Unique identifier for this plan + */ + val id: String, + + /** + * Tasks in this plan + */ + val tasks: MutableList = mutableListOf(), + + /** + * Timestamp when this plan was created (epoch milliseconds) + */ + val createdAt: Long = Clock.System.now().toEpochMilliseconds(), + + /** + * Timestamp when this plan was last updated (epoch milliseconds) + */ + var updatedAt: Long = createdAt +) { + /** + * Overall status of the plan (derived from tasks) + */ + val status: TaskStatus + get() = when { + tasks.isEmpty() -> TaskStatus.TODO + tasks.all { it.status == TaskStatus.COMPLETED } -> TaskStatus.COMPLETED + tasks.any { it.status == TaskStatus.FAILED } -> TaskStatus.FAILED + tasks.any { it.status == TaskStatus.IN_PROGRESS } -> TaskStatus.IN_PROGRESS + tasks.any { it.status == TaskStatus.BLOCKED } -> TaskStatus.BLOCKED + else -> TaskStatus.TODO + } + + /** + * Overall progress percentage (0-100) + */ + val progressPercent: Int + get() { + val totalSteps = tasks.sumOf { it.totalStepCount } + if (totalSteps == 0) return 0 + val completedSteps = tasks.sumOf { it.completedStepCount } + return (completedSteps * 100) / totalSteps + } + + /** + * Total number of tasks + */ + val taskCount: Int + get() = tasks.size + + /** + * Number of completed tasks + */ + val completedTaskCount: Int + get() = tasks.count { it.isCompleted } + + /** + * Add a task to this plan + */ + fun addTask(task: PlanTask) { + tasks.add(task) + touch() + } + + /** + * Get a task by ID + */ + fun getTask(taskId: String): PlanTask? { + return tasks.find { it.id == taskId } + } + + /** + * Update a task's status + */ + fun updateTaskStatus(taskId: String, status: TaskStatus) { + getTask(taskId)?.updateStatus(status) + touch() + } + + /** + * Complete a step within a task + */ + fun completeStep(taskId: String, stepId: String) { + getTask(taskId)?.completeStep(stepId) + touch() + } + + /** + * Update the updatedAt timestamp + */ + private fun touch() { + updatedAt = Clock.System.now().toEpochMilliseconds() + } + + /** + * Convert to markdown format + */ + fun toMarkdown(): String { + val sb = StringBuilder() + tasks.forEachIndexed { index, task -> + sb.append(task.toMarkdown(index + 1)) + } + return sb.toString() + } + + companion object { + private var idCounter = 0L + + /** + * Create a new plan with generated ID + */ + fun create(tasks: List = emptyList()): AgentPlan { + return AgentPlan( + id = generateId(), + tasks = tasks.toMutableList() + ) + } + + /** + * Generate a unique plan ID + */ + fun generateId(): String { + return "plan_${++idCounter}_${Clock.System.now().toEpochMilliseconds()}" + } + } +} + diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/CodeFileLink.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/CodeFileLink.kt new file mode 100644 index 0000000000..01d74c0d35 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/CodeFileLink.kt @@ -0,0 +1,44 @@ +package cc.unitmesh.agent.plan + +import kotlinx.serialization.Serializable + +/** + * Represents a link to a code file in a plan step. + * Format in markdown: [DisplayText](filepath) + * + * Example: [Main.java](src/main/java/com/example/Main.java) + */ +@Serializable +data class CodeFileLink( + /** + * The display text shown in the link + */ + val displayText: String, + + /** + * The file path (relative or absolute) + */ + val filePath: String +) { + /** + * Convert to markdown link format + */ + fun toMarkdown(): String = "[$displayText]($filePath)" + + companion object { + private val LINK_PATTERN = Regex("\\[([^\\]]+)\\]\\(([^)]+)\\)") + + /** + * Extract all code file links from text + */ + fun extractFromText(text: String): List { + return LINK_PATTERN.findAll(text).map { match -> + CodeFileLink( + displayText = match.groupValues[1], + filePath = match.groupValues[2] + ) + }.toList() + } + } +} + diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/MarkdownPlanParser.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/MarkdownPlanParser.kt new file mode 100644 index 0000000000..d76808b2e5 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/MarkdownPlanParser.kt @@ -0,0 +1,131 @@ +package cc.unitmesh.agent.plan + +/** + * Parser for markdown-formatted plans. + * + * Supports the following format: + * ``` + * 1. Task Title + * - [✓] Completed step + * - [*] In-progress step + * - [ ] Todo step + * - [!] Failed step + * 2. Another Task + * - [ ] Step with [file link](path/to/file.kt) + * ``` + * + * This is a multiplatform implementation that doesn't depend on + * IntelliJ's markdown parser. + */ +object MarkdownPlanParser { + + // Pattern for task headers: "1. Task Title" or "1. [x] Task Title" + // Note: The closing bracket ] must be escaped as \\] for JavaScript compatibility + private val TASK_HEADER_PATTERN = Regex("^(\\d+)\\.\\s*(?:\\[([xX!*✓]?)\\]\\s*)?(.+?)(?:\\s*\\[([xX!*✓]?)\\])?$") + + // Pattern for step items: "- [x] Step description" + // Note: The closing bracket ] must be escaped as \\] for JavaScript compatibility + private val STEP_PATTERN = Regex("^\\s*[-*]\\s*\\[\\s*([xX!*✓]?)\\s*\\]\\s*(.*)") + + // Pattern for unordered list items without checkbox: "- Step description" + private val UNORDERED_ITEM_PATTERN = Regex("^\\s*[-*]\\s+(.+)") + + /** + * Parse markdown content into a list of PlanTasks. + */ + fun parse(content: String): List { + val lines = content.lines() + val tasks = mutableListOf() + var currentTask: PlanTask? = null + var stepIdCounter = 0 + + for (line in lines) { + val trimmedLine = line.trim() + if (trimmedLine.isEmpty()) continue + + // Try to match task header + val taskMatch = TASK_HEADER_PATTERN.find(trimmedLine) + if (taskMatch != null) { + // Save previous task if exists + currentTask?.let { + it.updateStatusFromSteps() + tasks.add(it) + } + + val title = taskMatch.groupValues[3].trim() + val startMarker = taskMatch.groupValues[2] + val endMarker = taskMatch.groupValues[4] + val marker = startMarker.ifEmpty { endMarker } + + currentTask = PlanTask( + id = PlanTask.generateId(), + title = title, + status = TaskStatus.fromMarker(marker) + ) + continue + } + + // Try to match step with checkbox + val stepMatch = STEP_PATTERN.find(line) + if (stepMatch != null && currentTask != null) { + val marker = stepMatch.groupValues[1] + val description = stepMatch.groupValues[2].trim() + val codeFileLinks = CodeFileLink.extractFromText(description) + + val step = PlanStep( + id = "step_${++stepIdCounter}", + description = description, + status = TaskStatus.fromMarker(marker), + codeFileLinks = codeFileLinks + ) + currentTask.addStep(step) + continue + } + + // Try to match unordered list item without checkbox + val unorderedMatch = UNORDERED_ITEM_PATTERN.find(line) + if (unorderedMatch != null && currentTask != null) { + val description = unorderedMatch.groupValues[1].trim() + // Skip if it looks like a checkbox item that didn't match + if (description.startsWith("[")) continue + + val codeFileLinks = CodeFileLink.extractFromText(description) + val step = PlanStep( + id = "step_${++stepIdCounter}", + description = description, + status = TaskStatus.TODO, + codeFileLinks = codeFileLinks + ) + currentTask.addStep(step) + } + } + + // Don't forget the last task + currentTask?.let { + it.updateStatusFromSteps() + tasks.add(it) + } + + return tasks + } + + /** + * Format a list of tasks back to markdown. + */ + fun formatToMarkdown(tasks: List): String { + val sb = StringBuilder() + tasks.forEachIndexed { index, task -> + sb.append(task.toMarkdown(index + 1)) + } + return sb.toString() + } + + /** + * Parse markdown content into an AgentPlan. + */ + fun parseToPlan(content: String): AgentPlan { + val tasks = parse(content) + return AgentPlan.create(tasks) + } +} + diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanStateService.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanStateService.kt new file mode 100644 index 0000000000..7c38668346 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanStateService.kt @@ -0,0 +1,162 @@ +package cc.unitmesh.agent.plan + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Service for managing plan state. + * + * Provides reactive state management using StateFlow and + * listener-based notifications for plan updates. + * + * This is the central point for all plan-related state management + * in the agent system. + */ +class PlanStateService { + + private val _currentPlan = MutableStateFlow(null) + + /** + * Observable state of the current plan. + * Use this for reactive UI updates. + */ + val currentPlan: StateFlow = _currentPlan.asStateFlow() + + private val listeners = mutableListOf() + + /** + * Get the current plan (non-reactive). + */ + fun getPlan(): AgentPlan? = _currentPlan.value + + /** + * Create a new plan from a list of tasks. + */ + fun createPlan(tasks: List): AgentPlan { + val plan = AgentPlan.create(tasks) + _currentPlan.value = plan + notifyPlanCreated(plan) + return plan + } + + /** + * Create a new plan from markdown content. + */ + fun createPlanFromMarkdown(markdown: String): AgentPlan { + val tasks = MarkdownPlanParser.parse(markdown) + return createPlan(tasks) + } + + /** + * Set the current plan directly. + */ + fun setPlan(plan: AgentPlan) { + _currentPlan.value = plan + notifyPlanCreated(plan) + } + + /** + * Update the current plan with new tasks. + */ + fun updatePlan(tasks: List) { + val plan = _currentPlan.value + if (plan != null) { + plan.tasks.clear() + plan.tasks.addAll(tasks) + notifyPlanUpdated(plan) + } else { + createPlan(tasks) + } + } + + /** + * Update the current plan from markdown content. + */ + fun updatePlanFromMarkdown(markdown: String) { + val tasks = MarkdownPlanParser.parse(markdown) + updatePlan(tasks) + } + + /** + * Add a task to the current plan. + */ + fun addTask(task: PlanTask) { + val plan = _currentPlan.value ?: createPlan(emptyList()) + plan.addTask(task) + notifyPlanUpdated(plan) + } + + /** + * Update a task's status. + */ + fun updateTaskStatus(taskId: String, status: TaskStatus) { + val plan = _currentPlan.value ?: return + val task = plan.getTask(taskId) ?: return + task.updateStatus(status) + notifyTaskUpdated(task) + } + + /** + * Complete a step within a task. + */ + fun completeStep(taskId: String, stepId: String) { + val plan = _currentPlan.value ?: return + plan.completeStep(taskId, stepId) + notifyStepCompleted(taskId, stepId) + } + + /** + * Update a step's status. + */ + fun updateStepStatus(taskId: String, stepId: String, status: TaskStatus) { + val plan = _currentPlan.value ?: return + val task = plan.getTask(taskId) ?: return + task.updateStepStatus(stepId, status) + notifyTaskUpdated(task) + } + + /** + * Clear the current plan. + */ + fun clearPlan() { + _currentPlan.value = null + notifyPlanCleared() + } + + /** + * Add a listener for plan updates. + */ + fun addListener(listener: PlanUpdateListener) { + listeners.add(listener) + } + + /** + * Remove a listener. + */ + fun removeListener(listener: PlanUpdateListener) { + listeners.remove(listener) + } + + // Notification methods + private fun notifyPlanCreated(plan: AgentPlan) { + listeners.forEach { it.onPlanCreated(plan) } + } + + private fun notifyPlanUpdated(plan: AgentPlan) { + listeners.forEach { it.onPlanUpdated(plan) } + } + + private fun notifyTaskUpdated(task: PlanTask) { + listeners.forEach { it.onTaskUpdated(task) } + } + + private fun notifyStepCompleted(taskId: String, stepId: String) { + listeners.forEach { it.onStepCompleted(taskId, stepId) } + } + + private fun notifyPlanCleared() { + listeners.forEach { it.onPlanCleared() } + } +} + diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanStep.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanStep.kt new file mode 100644 index 0000000000..e5184a8ead --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanStep.kt @@ -0,0 +1,126 @@ +package cc.unitmesh.agent.plan + +import cc.unitmesh.agent.plan.TaskStatus.Companion.toMarker +import kotlinx.serialization.Serializable + +/** + * Represents a single step within a plan task. + * + * A step is the smallest unit of work in a plan, with its own status + * and optional code file references. + * + * Markdown format: `- [status] description [FileName](filepath)` + */ +@Serializable +data class PlanStep( + /** + * Unique identifier for this step + */ + val id: String, + + /** + * Description of what this step accomplishes + */ + val description: String, + + /** + * Current status of this step + */ + var status: TaskStatus = TaskStatus.TODO, + + /** + * Code file links referenced in this step + */ + val codeFileLinks: List = emptyList() +) { + /** + * Whether this step is completed + */ + val isCompleted: Boolean + get() = status == TaskStatus.COMPLETED + + /** + * Update the status of this step + */ + fun updateStatus(newStatus: TaskStatus) { + status = newStatus + } + + /** + * Mark this step as completed + */ + fun complete() { + status = TaskStatus.COMPLETED + } + + /** + * Mark this step as failed + */ + fun fail() { + status = TaskStatus.FAILED + } + + /** + * Mark this step as in progress + */ + fun startProgress() { + status = TaskStatus.IN_PROGRESS + } + + /** + * Convert to markdown format + */ + fun toMarkdown(): String { + val marker = status.toMarker() + return "- [$marker] $description" + } + + companion object { + // Note: The closing bracket ] must be escaped as \\] for JavaScript compatibility + private val STEP_PATTERN = Regex("^\\s*-\\s*\\[\\s*([xX!*✓]?)\\s*\\]\\s*(.*)") + + /** + * Parse a step from markdown text + */ + fun fromMarkdown(text: String, id: String = generateId()): PlanStep? { + val match = STEP_PATTERN.find(text) ?: return null + val marker = match.groupValues[1] + val description = match.groupValues[2].trim() + val codeFileLinks = CodeFileLink.extractFromText(description) + + return PlanStep( + id = id, + description = description, + status = TaskStatus.fromMarker(marker), + codeFileLinks = codeFileLinks + ) + } + + /** + * Create a step from plain text (without status marker) + */ + fun fromText(text: String, id: String = generateId()): PlanStep { + val cleanText = text.trim().removePrefix("-").trim() + val codeFileLinks = CodeFileLink.extractFromText(cleanText) + + return PlanStep( + id = id, + description = cleanText, + status = TaskStatus.TODO, + codeFileLinks = codeFileLinks + ) + } + + private var idCounter = 0L + + private fun generateId(): String { + return "step_${++idCounter}_${currentTimeMillis()}" + } + + // Platform-agnostic time function + private fun currentTimeMillis(): Long { + return kotlinx.datetime.Clock.System.now().toEpochMilliseconds() + } + } +} + diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanSummaryData.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanSummaryData.kt new file mode 100644 index 0000000000..ceb73e454f --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanSummaryData.kt @@ -0,0 +1,98 @@ +package cc.unitmesh.agent.plan + +import kotlinx.serialization.Serializable + +/** + * Lightweight summary data for displaying plan status in UI. + * + * This is a simplified view of AgentPlan optimized for UI display, + * containing only the essential information needed for the summary bar. + */ +@Serializable +data class PlanSummaryData( + val planId: String, + val title: String, + val totalSteps: Int, + val completedSteps: Int, + val failedSteps: Int, + val progressPercent: Int, + val status: TaskStatus, + val currentStepDescription: String?, + val tasks: List +) { + companion object { + /** + * Create a PlanSummaryData from an AgentPlan + */ + fun from(plan: AgentPlan): PlanSummaryData { + val allSteps = plan.tasks.flatMap { it.steps } + val completedSteps = allSteps.count { it.status == TaskStatus.COMPLETED } + val failedSteps = allSteps.count { it.status == TaskStatus.FAILED } + + // Find current step (first in-progress or first todo) + val currentStep = allSteps.firstOrNull { it.status == TaskStatus.IN_PROGRESS } + ?: allSteps.firstOrNull { it.status == TaskStatus.TODO } + + // Title: use first task title or "Plan" + val title = plan.tasks.firstOrNull()?.title ?: "Plan" + + return PlanSummaryData( + planId = plan.id, + title = title, + totalSteps = allSteps.size, + completedSteps = completedSteps, + failedSteps = failedSteps, + progressPercent = plan.progressPercent, + status = plan.status, + currentStepDescription = currentStep?.description, + tasks = plan.tasks.map { TaskSummary.from(it) } + ) + } + } +} + +/** + * Summary of a single task within a plan + */ +@Serializable +data class TaskSummary( + val id: String, + val title: String, + val status: TaskStatus, + val completedSteps: Int, + val totalSteps: Int, + val steps: List +) { + companion object { + fun from(task: PlanTask): TaskSummary { + return TaskSummary( + id = task.id, + title = task.title, + status = task.status, + completedSteps = task.completedStepCount, + totalSteps = task.totalStepCount, + steps = task.steps.map { StepSummary.from(it) } + ) + } + } +} + +/** + * Summary of a single step within a task + */ +@Serializable +data class StepSummary( + val id: String, + val description: String, + val status: TaskStatus +) { + companion object { + fun from(step: PlanStep): StepSummary { + return StepSummary( + id = step.id, + description = step.description, + status = step.status + ) + } + } +} diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanTask.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanTask.kt new file mode 100644 index 0000000000..deeeabfc45 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanTask.kt @@ -0,0 +1,156 @@ +package cc.unitmesh.agent.plan + +import cc.unitmesh.agent.plan.TaskStatus.Companion.toMarker +import kotlinx.serialization.Serializable + +/** + * Represents a task in a plan, containing multiple steps. + * + * A task is a logical grouping of related steps that together + * accomplish a specific goal. + * + * Markdown format: + * ``` + * 1. Task Title + * - [✓] Step 1 + * - [*] Step 2 + * - [ ] Step 3 + * ``` + */ +@Serializable +data class PlanTask( + /** + * Unique identifier for this task + */ + val id: String, + + /** + * Title/name of this task + */ + val title: String, + + /** + * Steps within this task + */ + val steps: MutableList = mutableListOf(), + + /** + * Current status of this task (derived from steps or set manually) + */ + var status: TaskStatus = TaskStatus.TODO, + + /** + * Current phase of this task (PDCA cycle) + */ + var phase: PlanPhase = PlanPhase.PLAN +) { + /** + * Whether all steps in this task are completed + */ + val isCompleted: Boolean + get() = steps.isNotEmpty() && steps.all { it.isCompleted } + + /** + * Progress percentage (0-100) + */ + val progressPercent: Int + get() = if (steps.isEmpty()) 0 + else (steps.count { it.isCompleted } * 100) / steps.size + + /** + * Number of completed steps + */ + val completedStepCount: Int + get() = steps.count { it.isCompleted } + + /** + * Total number of steps + */ + val totalStepCount: Int + get() = steps.size + + /** + * Add a step to this task + */ + fun addStep(step: PlanStep) { + steps.add(step) + updateStatusFromSteps() + } + + /** + * Update a step's status by step ID + */ + fun updateStepStatus(stepId: String, newStatus: TaskStatus) { + steps.find { it.id == stepId }?.updateStatus(newStatus) + updateStatusFromSteps() + } + + /** + * Complete a step by ID + */ + fun completeStep(stepId: String) { + updateStepStatus(stepId, TaskStatus.COMPLETED) + } + + /** + * Update task status based on step statuses + */ + fun updateStatusFromSteps() { + if (steps.isEmpty()) return + + status = when { + steps.all { it.status == TaskStatus.COMPLETED } -> TaskStatus.COMPLETED + steps.any { it.status == TaskStatus.FAILED } -> TaskStatus.FAILED + steps.any { it.status == TaskStatus.IN_PROGRESS } -> TaskStatus.IN_PROGRESS + steps.any { it.status == TaskStatus.BLOCKED } -> TaskStatus.BLOCKED + else -> TaskStatus.TODO + } + } + + /** + * Manually update task status (also updates all steps if completing) + */ + fun updateStatus(newStatus: TaskStatus, updateSteps: Boolean = false) { + status = newStatus + if (updateSteps && newStatus == TaskStatus.COMPLETED) { + steps.forEach { it.complete() } + } + } + + /** + * Convert to markdown format + */ + fun toMarkdown(index: Int): String { + val sb = StringBuilder() + sb.appendLine("$index. $title") + steps.forEach { step -> + sb.appendLine(" ${step.toMarkdown()}") + } + return sb.toString() + } + + companion object { + private val TASK_HEADER_PATTERN = Regex("^(\\d+)\\.\\s*(?:\\[([xX!*✓]?)\\]\\s*)?(.+?)(?:\\s*\\[([xX!*✓]?)\\])?$") + + /** + * Parse task header from markdown + */ + fun parseHeader(text: String): Triple? { + val match = TASK_HEADER_PATTERN.find(text.trim()) ?: return null + val index = match.groupValues[1].toIntOrNull() ?: return null + val title = match.groupValues[3].trim() + val startMarker = match.groupValues[2] + val endMarker = match.groupValues[4] + val marker = startMarker.ifEmpty { endMarker } + + return Triple(index, title, TaskStatus.fromMarker(marker)) + } + + private var idCounter = 0L + + fun generateId(): String { + return "task_${++idCounter}_${kotlinx.datetime.Clock.System.now().toEpochMilliseconds()}" + } + } +} + diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanUpdateListener.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanUpdateListener.kt new file mode 100644 index 0000000000..c3bd371c20 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanUpdateListener.kt @@ -0,0 +1,47 @@ +package cc.unitmesh.agent.plan + +/** + * Listener interface for plan updates. + * + * Implement this interface to receive notifications when + * the plan state changes. + */ +interface PlanUpdateListener { + /** + * Called when a new plan is created or the entire plan is replaced. + */ + fun onPlanCreated(plan: AgentPlan) + + /** + * Called when the plan is updated (tasks added, removed, or modified). + */ + fun onPlanUpdated(plan: AgentPlan) + + /** + * Called when a specific task is updated. + */ + fun onTaskUpdated(task: PlanTask) + + /** + * Called when a specific step is completed. + */ + fun onStepCompleted(taskId: String, stepId: String) + + /** + * Called when the plan is cleared/reset. + */ + fun onPlanCleared() +} + +/** + * Default implementation of PlanUpdateListener with empty methods. + * Extend this class to only override the methods you need. + */ +open class DefaultPlanUpdateListener : PlanUpdateListener { + override fun onPlanCreated(plan: AgentPlan) {} + override fun onPlanUpdated(plan: AgentPlan) {} + override fun onTaskUpdated(task: PlanTask) {} + override fun onStepCompleted(taskId: String, stepId: String) {} + override fun onPlanCleared() {} +} + diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/TaskStatus.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/TaskStatus.kt new file mode 100644 index 0000000000..27c51b84fb --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/TaskStatus.kt @@ -0,0 +1,58 @@ +package cc.unitmesh.agent.plan + +import kotlinx.serialization.Serializable + +/** + * Task status enum for plan steps and tasks. + * Matches the markers used in markdown plan format: + * - [ ] TODO + * - [*] IN_PROGRESS + * - [✓] or [x] COMPLETED + * - [!] FAILED + * - BLOCKED (no standard marker, used programmatically) + */ +@Serializable +enum class TaskStatus { + TODO, + IN_PROGRESS, + COMPLETED, + FAILED, + BLOCKED; + + companion object { + /** + * Parse status from markdown marker character + */ + fun fromMarker(marker: String): TaskStatus = when (marker.trim().lowercase()) { + "x", "✓" -> COMPLETED + "!" -> FAILED + "*" -> IN_PROGRESS + "" , " " -> TODO + else -> TODO + } + + /** + * Get the markdown marker for this status + */ + fun TaskStatus.toMarker(): String = when (this) { + COMPLETED -> "✓" + FAILED -> "!" + IN_PROGRESS -> "*" + TODO -> " " + BLOCKED -> "B" + } + } +} + +/** + * Plan phase enum following PDCA cycle. + * Used to track the overall phase of a task. + */ +@Serializable +enum class PlanPhase { + PLAN, // Planning phase - analyzing and designing + DO, // Execution phase - implementing changes + CHECK, // Verification phase - testing and reviewing + ACT // Action phase - finalizing and deploying +} + diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt index 570595a1af..744e1cb98d 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt @@ -1,5 +1,6 @@ package cc.unitmesh.agent.render +import cc.unitmesh.agent.plan.PlanSummaryData import cc.unitmesh.agent.tool.ToolResult import cc.unitmesh.llm.compression.TokenInfo @@ -10,6 +11,22 @@ interface CodingAgentRenderer { fun renderLLMResponseEnd() fun renderToolCall(toolName: String, paramsStr: String) + + /** + * Render a tool call with parsed parameters. + * This is the preferred method as it avoids string parsing issues with complex values. + * + * @param toolName The name of the tool being called + * @param params The parsed parameters map + */ + fun renderToolCallWithParams(toolName: String, params: Map) { + // Default implementation: convert to string format for backward compatibility + val paramsStr = params.entries.joinToString(" ") { (key, value) -> + "$key=\"$value\"" + } + renderToolCall(toolName, paramsStr) + } + fun renderToolResult( toolName: String, success: Boolean, @@ -27,6 +44,21 @@ interface CodingAgentRenderer { fun updateTokenInfo(tokenInfo: TokenInfo) {} + /** + * Render a compact plan summary bar. + * Called when plan is created or updated to show progress in a compact format. + * + * Example display: + * ``` + * 📋 Plan: Create Tag System (3/5 steps, 60%) ████████░░░░░░░░ + * ``` + * + * @param summary The plan summary data containing progress information + */ + fun renderPlanSummary(summary: PlanSummaryData) { + // Default: no-op for renderers that don't support plan summary bar + } + fun renderUserConfirmationRequest(toolName: String, params: Map) /** diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt index 67d5ff4b72..f6180c9fa4 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt @@ -31,7 +31,7 @@ data class ToolCallDisplayInfo( ) /** - * Task information from task-boundary tool. + * Task information from plan management tool. */ data class TaskInfo( val taskName: String, diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererUtils.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererUtils.kt index 479f70bfac..41500be7d0 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererUtils.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererUtils.kt @@ -87,10 +87,38 @@ object RendererUtils { /** * Parse parameter string into a map. - * Handles both quoted and unquoted values. + * Handles both JSON format and key=value format. */ fun parseParamsString(paramsStr: String): Map { val params = mutableMapOf() + val trimmed = paramsStr.trim() + + // Try JSON format first (starts with { and ends with }) + if (trimmed.startsWith("{") && trimmed.endsWith("}")) { + try { + // Simple JSON parsing for flat objects with string values + val jsonContent = trimmed.substring(1, trimmed.length - 1) + // Match "key": "value" or "key": number patterns + val jsonRegex = Regex(""""(\w+)"\s*:\s*(?:"([^"\\]*(?:\\.[^"\\]*)*)"|(\d+(?:\.\d+)?)|(\w+))""") + jsonRegex.findAll(jsonContent).forEach { match -> + val key = match.groups[1]?.value + // Value can be: quoted string, number, or unquoted word (like true/false) + val value = match.groups[2]?.value + ?: match.groups[3]?.value + ?: match.groups[4]?.value + if (key != null && value != null) { + params[key] = value + } + } + if (params.isNotEmpty()) { + return params + } + } catch (_: Exception) { + // Fall through to key=value parsing + } + } + + // Fallback to key=value format val regex = Regex("""(\w+)="([^"]*)"|\s*(\w+)=([^\s]+)""") regex.findAll(paramsStr).forEach { match -> val key = match.groups[1]?.value ?: match.groups[3]?.value diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/PlanManagementTool.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/PlanManagementTool.kt new file mode 100644 index 0000000000..15839c44dc --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/PlanManagementTool.kt @@ -0,0 +1,253 @@ +package cc.unitmesh.agent.tool.impl + +import cc.unitmesh.agent.plan.AgentPlan +import cc.unitmesh.agent.plan.MarkdownPlanParser +import cc.unitmesh.agent.plan.PlanStateService +import cc.unitmesh.agent.plan.PlanSummaryData +import cc.unitmesh.agent.plan.TaskStatus as PlanTaskStatus +import cc.unitmesh.agent.tool.* +import cc.unitmesh.agent.tool.schema.DeclarativeToolSchema +import cc.unitmesh.agent.tool.schema.SchemaPropertyBuilder.string +import cc.unitmesh.agent.tool.schema.ToolCategory +import kotlinx.serialization.Serializable + +enum class PlanAction { CREATE, UPDATE, COMPLETE_STEP, FAIL_STEP, VIEW } + +/** + * Reference to a specific step within a task + */ +@Serializable +data class StepRef( + val taskIndex: Int, + val stepIndex: Int +) + +@Serializable +data class PlanManagementParams( + val action: String, + val planMarkdown: String = "", + val taskIndex: Int = 0, + val stepIndex: Int = 0, + /** + * For batch updates: list of steps to complete/fail in one call. + * When provided, taskIndex and stepIndex are ignored. + * Example: [{"taskIndex": 1, "stepIndex": 1}, {"taskIndex": 1, "stepIndex": 2}] + */ + val steps: List = emptyList() +) + +object PlanManagementSchema : DeclarativeToolSchema( + description = "Manage task plans for complex multi-step work.", + properties = mapOf( + "action" to string(description = "Action: CREATE (new plan), COMPLETE_STEP (mark steps done), FAIL_STEP (mark steps failed), VIEW (show plan). Use COMPLETE_STEP to update progress - do NOT resend the full plan.", required = true, + enum = listOf("CREATE", "COMPLETE_STEP", "FAIL_STEP", "VIEW")), + "planMarkdown" to string(description = "Plan content in markdown format (only for CREATE)", required = false), + "taskIndex" to string(description = "1-based task index (for single step update)", required = false), + "stepIndex" to string(description = "1-based step index (for single step update)", required = false), + "steps" to string(description = "Array of steps to update: [{\"taskIndex\": 1, \"stepIndex\": 1}, ...]. Use this for batch updates.", required = false) + ) +) { + override fun getExampleUsage(toolName: String): String = + """/$toolName action="COMPLETE_STEP" steps=[{"taskIndex": 1, "stepIndex": 1}, {"taskIndex": 1, "stepIndex": 2}]""" +} + +class PlanManagementInvocation( + params: PlanManagementParams, + tool: PlanManagementTool, + private val planStateService: PlanStateService +) : BaseToolInvocation(params, tool) { + + override fun getDescription(): String = "Plan Management: ${params.action}" + override fun getToolLocations(): List = emptyList() + + override suspend fun execute(context: ToolExecutionContext): ToolResult { + val action = try { + PlanAction.valueOf(params.action.uppercase()) + } catch (e: IllegalArgumentException) { + return ToolResult.Error("Invalid action: ${params.action}", ToolErrorType.PARAMETER_OUT_OF_RANGE.code) + } + return when (action) { + PlanAction.CREATE -> createPlan() + PlanAction.UPDATE -> updatePlan() + PlanAction.COMPLETE_STEP -> updateStepStatus(PlanTaskStatus.COMPLETED) + PlanAction.FAIL_STEP -> updateStepStatus(PlanTaskStatus.FAILED) + PlanAction.VIEW -> viewPlan() + } + } + + private fun createPlan(): ToolResult { + if (params.planMarkdown.isBlank()) { + return ToolResult.Error("planMarkdown is required for CREATE", ToolErrorType.MISSING_REQUIRED_PARAMETER.code) + } + val plan = planStateService.createPlanFromMarkdown(params.planMarkdown) + val summary = PlanSummaryData.from(plan) + + // Return concise summary with task titles + val taskList = plan.tasks.mapIndexed { idx, task -> + "${idx + 1}. ${task.title} (${task.steps.size} steps)" + }.joinToString("\n") + + return ToolResult.Success( + "Plan created with ${plan.taskCount} tasks, ${summary.totalSteps} steps.\n\n$taskList", + mapOf( + "plan_id" to plan.id, + "task_count" to plan.taskCount.toString(), + "total_steps" to summary.totalSteps.toString() + ) + ) + } + + private fun updatePlan(): ToolResult { + if (params.planMarkdown.isBlank()) { + return ToolResult.Error("planMarkdown is required for UPDATE", ToolErrorType.MISSING_REQUIRED_PARAMETER.code) + } + val tasks = MarkdownPlanParser.parse(params.planMarkdown) + if (planStateService.currentPlan.value == null) { + val plan = planStateService.createPlanFromMarkdown(params.planMarkdown) + return ToolResult.Success("Plan created with ${plan.taskCount} tasks.\n\n${plan.toMarkdown()}", + mapOf("plan_id" to plan.id, "task_count" to plan.taskCount.toString())) + } + planStateService.updatePlan(tasks) + val updatedPlan = planStateService.currentPlan.value!! + return ToolResult.Success("Plan updated with ${updatedPlan.taskCount} tasks.\n\n${updatedPlan.toMarkdown()}", + mapOf("plan_id" to updatedPlan.id, "task_count" to updatedPlan.taskCount.toString())) + } + + private fun updateStepStatus(status: PlanTaskStatus): ToolResult { + val currentPlan = planStateService.currentPlan.value + ?: return ToolResult.Error("No active plan", ToolErrorType.FILE_NOT_FOUND.code) + + // Determine which steps to update: batch (steps array) or single (taskIndex/stepIndex) + val stepsToUpdate = if (params.steps.isNotEmpty()) { + params.steps + } else if (params.taskIndex > 0 && params.stepIndex > 0) { + listOf(StepRef(params.taskIndex, params.stepIndex)) + } else { + return ToolResult.Error("Either 'steps' array or both 'taskIndex' and 'stepIndex' must be provided", + ToolErrorType.MISSING_REQUIRED_PARAMETER.code) + } + + val updatedSteps = mutableListOf() + val statusText = if (status == PlanTaskStatus.COMPLETED) "completed" else "failed" + + for (stepRef in stepsToUpdate) { + val taskIdx = stepRef.taskIndex - 1 + val stepIdx = stepRef.stepIndex - 1 + + if (taskIdx < 0 || taskIdx >= currentPlan.tasks.size) { + return ToolResult.Error("Task index ${stepRef.taskIndex} out of range", ToolErrorType.PARAMETER_OUT_OF_RANGE.code) + } + val task = currentPlan.tasks[taskIdx] + if (stepIdx < 0 || stepIdx >= task.steps.size) { + return ToolResult.Error("Step index ${stepRef.stepIndex} out of range for task ${stepRef.taskIndex}", ToolErrorType.PARAMETER_OUT_OF_RANGE.code) + } + val step = task.steps[stepIdx] + planStateService.updateStepStatus(task.id, step.id, status) + updatedSteps.add("[${stepRef.taskIndex}.${stepRef.stepIndex}] ${step.description}") + } + + val updatedPlan = planStateService.currentPlan.value!! + val summary = PlanSummaryData.from(updatedPlan) + + // Return concise summary instead of full markdown + val resultMessage = if (stepsToUpdate.size == 1) { + "Step $statusText: ${updatedSteps.first()}" + } else { + "${stepsToUpdate.size} steps $statusText:\n${updatedSteps.joinToString("\n") { " - $it" }}" + } + + return ToolResult.Success( + "$resultMessage\n\nProgress: ${summary.completedSteps}/${summary.totalSteps} (${summary.progressPercent}%)", + mapOf( + "steps_updated" to stepsToUpdate.size.toString(), + "progress" to "${summary.progressPercent}%", + "completed" to summary.completedSteps.toString(), + "total" to summary.totalSteps.toString(), + "status" to status.name + ) + ) + } + + private fun viewPlan(): ToolResult { + val currentPlan = planStateService.currentPlan.value + ?: return ToolResult.Success("No active plan.", mapOf("has_plan" to "false")) + return ToolResult.Success(currentPlan.toMarkdown(), mapOf( + "plan_id" to currentPlan.id, "task_count" to currentPlan.taskCount.toString(), + "progress" to "${currentPlan.progressPercent}%", "status" to currentPlan.status.name)) + } +} + +/** + * Plan Management Tool - for complex multi-step tasks + * + * ## Purpose + * Create and track structured plans with tasks and steps. This helps organize complex work + * and communicate progress to users through a visual plan UI. + * + * ## When to Use + * - Tasks requiring multiple files to be created or modified + * - Tasks with dependencies between steps + * - Complex refactoring or feature implementation + * - Any work that benefits from structured tracking (3+ steps) + * + * ## When NOT to Use + * - Simple one-step tasks (answering questions, quick refactors) + * - Single-file edits + * - Trivial operations + * + * ## Plan Format (Markdown) + * ``` + * 1. Task Title + * - [ ] Step 1 description + * - [ ] Step 2 description + * + * 2. Another Task + * - [ ] Step description + * ``` + * + * ## Example Flow + * ``` + * /plan action="CREATE" planMarkdown="1. Setup\n - [ ] Create entity\n - [ ] Create repository\n\n2. Implementation\n - [ ] Create service\n - [ ] Create controller" + * // ... create entity ... + * /plan action="COMPLETE_STEP" taskIndex=1 stepIndex=1 + * // ... create repository ... + * /plan action="COMPLETE_STEP" taskIndex=1 stepIndex=2 + * // ... continue ... + * ``` + */ +class PlanManagementTool( + private val planStateService: PlanStateService = PlanStateService() +) : BaseExecutableTool() { + + override val name: String = "plan" + override val description: String = """ + Manage task plans for complex multi-step work. Create structured plans with tasks and steps, + then track progress by marking steps as completed or failed. + + Actions: + - CREATE: Create a new plan from markdown (planMarkdown required) + - COMPLETE_STEP: Mark a step as completed (taskIndex and stepIndex required, 1-based) + - FAIL_STEP: Mark a step as failed + - VIEW: View current plan status + + IMPORTANT: Use COMPLETE_STEP to mark progress. Do NOT resend the full plan markdown to update progress. + Use for complex tasks (3+ steps). Skip for simple one-step tasks. + """.trimIndent() + + override val metadata: ToolMetadata = ToolMetadata( + displayName = "Plan Management", tuiEmoji = "📋", composeIcon = "plan", + category = ToolCategory.Utility, schema = PlanManagementSchema + ) + + override fun getParameterClass(): String = PlanManagementParams::class.simpleName ?: "PlanManagementParams" + + override fun createToolInvocation(params: PlanManagementParams): ToolInvocation { + if (params.action.isBlank()) throw ToolException("Action cannot be empty", ToolErrorType.MISSING_REQUIRED_PARAMETER) + try { PlanAction.valueOf(params.action.uppercase()) } + catch (e: IllegalArgumentException) { throw ToolException("Invalid action: ${params.action}", ToolErrorType.PARAMETER_OUT_OF_RANGE) } + return PlanManagementInvocation(params, this, planStateService) + } + + fun getPlanStateService(): PlanStateService = planStateService +} + diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/TaskBoundaryTool.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/TaskBoundaryTool.kt deleted file mode 100644 index 5f9a93a89b..0000000000 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/TaskBoundaryTool.kt +++ /dev/null @@ -1,206 +0,0 @@ -package cc.unitmesh.agent.tool.impl - -import cc.unitmesh.agent.tool.* -import cc.unitmesh.agent.tool.schema.DeclarativeToolSchema -import cc.unitmesh.agent.tool.schema.SchemaPropertyBuilder.string -import cc.unitmesh.agent.tool.schema.ToolCategory -import kotlinx.serialization.Serializable - -/** - * Task status enum matching Cursor's task boundary behavior - */ -enum class TaskStatus { - PLANNING, - WORKING, - COMPLETED, - BLOCKED, - CANCELLED -} - -/** - * Parameters for task boundary tool - */ -@Serializable -data class TaskBoundaryParams( - /** - * The name/title of the task - used as the header in UI - * Keep the same taskName to update an existing task, change it to create a new task block - */ - val taskName: String, - - /** - * Current status of the task (PLANNING, WORKING, COMPLETED, BLOCKED, CANCELLED) - */ - val status: String, - - /** - * Brief summary describing what this task accomplishes or what you're doing - */ - val summary: String = "" -) - -/** - * Schema for task boundary tool - */ -object TaskBoundarySchema : DeclarativeToolSchema( - description = "Communicate task progress through a structured UI. Use this to keep users informed of your work.", - properties = mapOf( - "taskName" to string( - description = "Task name/title - used as the header. Keep the same name to update an existing task, change it to create a new task block", - required = true, - maxLength = 100 - ), - "status" to string( - description = "Current task status", - required = true, - enum = listOf("PLANNING", "WORKING", "COMPLETED", "BLOCKED", "CANCELLED") - ), - "summary" to string( - description = "Brief summary of what this task does or current activity", - required = false, - maxLength = 500 - ) - ) -) { - override fun getExampleUsage(toolName: String): String { - return """/$toolName taskName="Planning Authentication" status="PLANNING" summary="Analyzing existing auth structure and planning OAuth2 implementation"""" - } -} - -/** - * Tool invocation for task boundary - */ -class TaskBoundaryInvocation( - params: TaskBoundaryParams, - tool: TaskBoundaryTool -) : BaseToolInvocation(params, tool) { - - override fun getDescription(): String { - return "Task: ${params.taskName} [${params.status}]" - } - - override fun getToolLocations(): List = emptyList() - - override suspend fun execute(context: ToolExecutionContext): ToolResult { - // Validate status - val status = try { - TaskStatus.valueOf(params.status.uppercase()) - } catch (e: IllegalArgumentException) { - return ToolResult.Error( - message = "Invalid status: ${params.status}. Must be one of: ${TaskStatus.values().joinToString(", ")}", - errorType = ToolErrorType.PARAMETER_OUT_OF_RANGE.code - ) - } - - // Create metadata for tracking - val metadata = mapOf( - "task_name" to params.taskName, - "status" to status.name, - "summary" to params.summary - ) - - // Format the output message - val output = buildString { - appendLine("📋 Task Update") - appendLine("Name: ${params.taskName}") - appendLine("Status: ${status.name}") - if (params.summary.isNotEmpty()) { - appendLine("Summary: ${params.summary}") - } - } - - return ToolResult.Success(output, metadata) - } -} - -/** - * Task Boundary Tool - inspired by Cursor's task management - * - * ## Purpose - * Communicate progress through a structured task UI. This helps users understand what you're working on - * and track your progress through complex multi-step tasks. - * - * ## UI Behavior - * - taskName = Header of the UI block - * - summary = Description of this task - * - status = Current activity (PLANNING, WORKING, COMPLETED, BLOCKED, CANCELLED) - * - * ## Usage Pattern - * - * **First call**: Set taskName using the mode and work area (e.g., "Planning Authentication"), - * set summary to briefly describe the goal, set status to what you're about to start doing. - * - * **Updates**: - * - Same taskName + updated summary/status = Updates accumulate in the same UI block - * - Different taskName = Starts a new UI block with a fresh summary for the new task - * - * ## When to Use - * - For complex tasks with multiple steps (3+ steps) - * - When you want to communicate progress during long-running operations - * - To signal major phase transitions (planning -> implementation -> testing) - * - * ## When NOT to Use - * - Simple one-step tasks (answering questions, quick refactors) - * - Single-file edits that don't affect many lines - * - Trivial operations - * - * ## Example Flow - * - * ``` - * /task-boundary taskName="Implementing User Authentication" status="PLANNING" summary="Analyzing existing code structure" - * // ... do some analysis ... - * /task-boundary taskName="Implementing User Authentication" status="WORKING" summary="Adding JWT token validation" - * // ... make changes ... - * /task-boundary taskName="Implementing User Authentication" status="COMPLETED" summary="Authentication implemented and tested" - * ``` - */ -class TaskBoundaryTool : BaseExecutableTool() { - - override val name: String = "task-boundary" - override val description: String = """ - Communicate task progress through a structured UI. Use this for complex multi-step tasks to keep users informed. - - - First call: Set taskName, initial status (usually PLANNING), and summary describing the goal - - Updates: Use same taskName to update an existing task, or change taskName to create a new task block - - Status options: PLANNING, WORKING, COMPLETED, BLOCKED, CANCELLED - - Skip for simple tasks (quick refactors, answering questions, single-file edits). - """.trimIndent() - - override val metadata: ToolMetadata = ToolMetadata( - displayName = "Task Boundary", - tuiEmoji = "📋", - composeIcon = "task", - category = ToolCategory.Utility, - schema = TaskBoundarySchema - ) - - override fun getParameterClass(): String = TaskBoundaryParams::class.simpleName ?: "TaskBoundaryParams" - - override fun createToolInvocation(params: TaskBoundaryParams): ToolInvocation { - // Validate parameters - validateParameters(params) - return TaskBoundaryInvocation(params, this) - } - - private fun validateParameters(params: TaskBoundaryParams) { - if (params.taskName.isBlank()) { - throw ToolException("Task name cannot be empty", ToolErrorType.MISSING_REQUIRED_PARAMETER) - } - - if (params.status.isBlank()) { - throw ToolException("Status cannot be empty", ToolErrorType.MISSING_REQUIRED_PARAMETER) - } - - // Validate status is a valid enum value - try { - TaskStatus.valueOf(params.status.uppercase()) - } catch (e: IllegalArgumentException) { - throw ToolException( - "Invalid status: ${params.status}. Must be one of: ${TaskStatus.values().joinToString(", ")}", - ToolErrorType.PARAMETER_OUT_OF_RANGE - ) - } - } -} - diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/registry/BuiltinToolsProvider.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/registry/BuiltinToolsProvider.kt index 57f5cc9004..a7fff2cf4b 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/registry/BuiltinToolsProvider.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/registry/BuiltinToolsProvider.kt @@ -6,9 +6,9 @@ import cc.unitmesh.agent.tool.impl.DocQLTool import cc.unitmesh.agent.tool.impl.EditFileTool import cc.unitmesh.agent.tool.impl.GlobTool import cc.unitmesh.agent.tool.impl.GrepTool +import cc.unitmesh.agent.tool.impl.PlanManagementTool import cc.unitmesh.agent.tool.impl.ReadFileTool import cc.unitmesh.agent.tool.impl.ShellTool -import cc.unitmesh.agent.tool.impl.TaskBoundaryTool import cc.unitmesh.agent.tool.impl.WebFetchTool import cc.unitmesh.agent.tool.impl.WriteFileTool import cc.unitmesh.agent.tool.impl.SmartEditTool @@ -35,7 +35,7 @@ class BuiltinToolsProvider : ToolProvider { // Search tools tools.add(GrepTool(dependencies.fileSystem)) - + // GlobTool with AnalysisAgent support for auto-summarization of large results val analysisAgent = dependencies.subAgentManager?.getSubAgent("analysis-agent") as? cc.unitmesh.agent.subagent.AnalysisAgent tools.add(GlobTool(dependencies.fileSystem, analysisAgent)) @@ -53,9 +53,9 @@ class BuiltinToolsProvider : ToolProvider { } tools.add(WebFetchTool(dependencies.llmService)) - - // Task management tool - tools.add(TaskBoundaryTool()) + + // Task management tools + tools.add(PlanManagementTool()) tools.add(DocQLTool()) return tools diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/schema/AgentToolFormatter.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/schema/AgentToolFormatter.kt index 395d8727d1..cab8cd5f64 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/schema/AgentToolFormatter.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/schema/AgentToolFormatter.kt @@ -44,16 +44,26 @@ object AgentToolFormatter { appendLine("## ${tool.name}") val toolType = tool.name.toToolType() - if (toolType != null) { + // Priority: 1. tool.metadata.schema (for tools like plan), 2. toolType.schema, 3. fallback + val schema = tool.metadata.schema + if (schema != null) { + // Use tool's own schema (e.g., PlanManagementSchema) + appendLine("**Parameters JSON Schema:**") + appendLine("```json") + val jsonSchema = schema.toJsonSchema() + val prettyJson = formatJsonSchema(jsonSchema) + appendLine(prettyJson) + appendLine("```") + } else if (toolType != null) { + // Fallback to ToolType schema for backward compatibility appendLine("**Parameters JSON Schema:**") appendLine("```json") - val jsonSchema = toolType.schema.toJsonSchema() - // Pretty print the JSON schema val prettyJson = formatJsonSchema(jsonSchema) appendLine(prettyJson) appendLine("```") } else { + // No schema available, use parameter class info val paramClass = tool.getParameterClass() when { paramClass.isBlank() || paramClass == "Unit" -> { diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/devins/editor/EditorCallbacks.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/devins/editor/EditorCallbacks.kt index 4d1b6c4f2a..13c781e468 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/devins/editor/EditorCallbacks.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/devins/editor/EditorCallbacks.kt @@ -1,8 +1,19 @@ package cc.unitmesh.devins.editor +/** + * Represents a file in the context. + * Used for passing file context information with submissions. + */ +data class FileContext( + val name: String, + val path: String, + val relativePath: String = name, + val isDirectory: Boolean = false +) + /** * 编辑器回调接口 - * + * * 定义了编辑器的各种回调方法,用于响应编辑器事件 * 所有方法都有默认空实现,子类只需要重写感兴趣的方法 */ @@ -11,6 +22,14 @@ interface EditorCallbacks { * 当用户提交内容时调用(例如按下 Cmd+Enter) */ fun onSubmit(text: String) {} + + /** + * 当用户提交内容时调用,包含文件上下文 + * 默认实现调用不带文件上下文的 onSubmit + */ + fun onSubmit(text: String, files: List) { + onSubmit(text) + } /** * 当文本内容变化时调用 diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTextParser.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/markdown/MarkdownTextParser.kt similarity index 92% rename from mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTextParser.kt rename to mpp-core/src/commonMain/kotlin/cc/unitmesh/markdown/MarkdownTextParser.kt index e1883c1801..5c49586661 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTextParser.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/markdown/MarkdownTextParser.kt @@ -1,5 +1,6 @@ -package cc.unitmesh.devins.idea.renderer.markdown +package cc.unitmesh.markdown +import org.intellij.markdown.MarkdownElementTypes import org.intellij.markdown.MarkdownTokenTypes import org.intellij.markdown.ast.ASTNode import org.intellij.markdown.ast.findChildOfType @@ -104,7 +105,7 @@ object MarkdownTextParser { * Extract link text from an inline link node. */ fun extractLinkText(node: ASTNode, content: String): String { - val linkText = node.findChildOfType(org.intellij.markdown.MarkdownElementTypes.LINK_TEXT) + val linkText = node.findChildOfType(MarkdownElementTypes.LINK_TEXT) return linkText?.children?.filter { it.type == MarkdownTokenTypes.TEXT } ?.joinToString("") { it.getTextInNode(content).toString() } ?: node.getTextInNode(content).toString() @@ -114,7 +115,7 @@ object MarkdownTextParser { * Extract link destination URL from an inline link node. */ fun extractLinkDestination(node: ASTNode, content: String): String { - val linkDest = node.findChildOfType(org.intellij.markdown.MarkdownElementTypes.LINK_DESTINATION) + val linkDest = node.findChildOfType(MarkdownElementTypes.LINK_DESTINATION) return linkDest?.getTextInNode(content)?.toString() ?: "" } @@ -122,7 +123,7 @@ object MarkdownTextParser { * Extract image alt text from an image node. */ fun extractImageAltText(node: ASTNode, content: String): String { - return node.findChildOfType(org.intellij.markdown.MarkdownElementTypes.LINK_TEXT) + return node.findChildOfType(MarkdownElementTypes.LINK_TEXT) ?.getTextInNode(content)?.toString()?.trim('[', ']') ?: "image" } @@ -132,5 +133,4 @@ object MarkdownTextParser { fun extractAutoLinkUrl(node: ASTNode, content: String): String { return node.getTextInNode(content).toString().trim('<', '>') } -} - +} \ No newline at end of file diff --git a/mpp-core/src/commonTest/kotlin/cc/unitmesh/agent/plan/MarkdownPlanParserTest.kt b/mpp-core/src/commonTest/kotlin/cc/unitmesh/agent/plan/MarkdownPlanParserTest.kt new file mode 100644 index 0000000000..77b819729a --- /dev/null +++ b/mpp-core/src/commonTest/kotlin/cc/unitmesh/agent/plan/MarkdownPlanParserTest.kt @@ -0,0 +1,166 @@ +package cc.unitmesh.agent.plan + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MarkdownPlanParserTest { + + @Test + fun `should parse simple plan with tasks only`() { + val markdown = """ + 1. Analyze existing code + 2. Implement feature + 3. Add tests + """.trimIndent() + + val tasks = MarkdownPlanParser.parse(markdown) + + assertEquals(3, tasks.size) + assertEquals("Analyze existing code", tasks[0].title) + assertEquals("Implement feature", tasks[1].title) + assertEquals("Add tests", tasks[2].title) + } + + @Test + fun `should parse plan with tasks and steps`() { + val markdown = """ + 1. Analyze existing code + - [ ] Review project structure + - [ ] Identify relevant files + 2. Implement feature + - [ ] Create new module + - [ ] Add tests + """.trimIndent() + + val tasks = MarkdownPlanParser.parse(markdown) + + assertEquals(2, tasks.size) + assertEquals(2, tasks[0].steps.size) + assertEquals(2, tasks[1].steps.size) + assertEquals("Review project structure", tasks[0].steps[0].description) + assertEquals("Create new module", tasks[1].steps[0].description) + } + + @Test + fun `should parse step status markers correctly`() { + val markdown = """ + 1. Task with various statuses + - [x] Completed step + - [*] In-progress step + - [ ] Todo step + - [!] Failed step + """.trimIndent() + + val tasks = MarkdownPlanParser.parse(markdown) + val steps = tasks[0].steps + + assertEquals(4, steps.size) + assertEquals(TaskStatus.COMPLETED, steps[0].status) + assertEquals(TaskStatus.IN_PROGRESS, steps[1].status) + assertEquals(TaskStatus.TODO, steps[2].status) + assertEquals(TaskStatus.FAILED, steps[3].status) + } + + @Test + fun `should parse checkmark symbol`() { + val markdown = """ + 1. Task with checkmark + - [x] Completed with x marker + """.trimIndent() + + val tasks = MarkdownPlanParser.parse(markdown) + + assertEquals(TaskStatus.COMPLETED, tasks[0].steps[0].status) + } + + @Test + fun `should extract code file links from steps`() { + val markdown = """ + 1. Modify files + - [ ] Update [Main.kt](src/main/kotlin/Main.kt) + - [ ] Fix [Config.kt](src/config/Config.kt) and [Utils.kt](src/utils/Utils.kt) + """.trimIndent() + + val tasks = MarkdownPlanParser.parse(markdown) + val steps = tasks[0].steps + + assertEquals(1, steps[0].codeFileLinks.size) + assertEquals("Main.kt", steps[0].codeFileLinks[0].displayText) + assertEquals("src/main/kotlin/Main.kt", steps[0].codeFileLinks[0].filePath) + + assertEquals(2, steps[1].codeFileLinks.size) + assertEquals("Config.kt", steps[1].codeFileLinks[0].displayText) + assertEquals("Utils.kt", steps[1].codeFileLinks[1].displayText) + } + + @Test + fun `should update task status from steps`() { + val markdown = """ + 1. Partially completed task + - [x] Done step + - [*] Working step + - [ ] Todo step + """.trimIndent() + + val tasks = MarkdownPlanParser.parse(markdown) + + assertEquals(TaskStatus.IN_PROGRESS, tasks[0].status) + } + + @Test + fun `should mark task as completed when all steps done`() { + val markdown = """ + 1. Completed task + - [x] Step 1 + - [x] Step 2 + """.trimIndent() + + val tasks = MarkdownPlanParser.parse(markdown) + + assertEquals(TaskStatus.COMPLETED, tasks[0].status) + assertTrue(tasks[0].isCompleted) + } + + @Test + fun `should format tasks back to markdown`() { + val tasks = listOf( + PlanTask( + id = "task1", + title = "First task", + steps = mutableListOf( + PlanStep("step1", "Do something", TaskStatus.COMPLETED), + PlanStep("step2", "Do another thing", TaskStatus.TODO) + ) + ) + ) + + val markdown = MarkdownPlanParser.formatToMarkdown(tasks) + + assertTrue(markdown.contains("1. First task")) + assertTrue(markdown.contains("Do something")) + assertTrue(markdown.contains("Do another thing")) + } + + @Test + fun `should handle empty content`() { + val tasks = MarkdownPlanParser.parse("") + assertTrue(tasks.isEmpty()) + } + + @Test + fun `should parse plan to AgentPlan`() { + val markdown = """ + 1. Task one + - [ ] Step one + 2. Task two + - [ ] Step two + """.trimIndent() + + val plan = MarkdownPlanParser.parseToPlan(markdown) + + assertEquals(2, plan.taskCount) + assertTrue(plan.id.startsWith("plan_")) + } +} + diff --git a/mpp-core/src/commonTest/kotlin/cc/unitmesh/agent/plan/PlanStateServiceTest.kt b/mpp-core/src/commonTest/kotlin/cc/unitmesh/agent/plan/PlanStateServiceTest.kt new file mode 100644 index 0000000000..cf34a21565 --- /dev/null +++ b/mpp-core/src/commonTest/kotlin/cc/unitmesh/agent/plan/PlanStateServiceTest.kt @@ -0,0 +1,173 @@ +package cc.unitmesh.agent.plan + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PlanStateServiceTest { + + @Test + fun `should create plan from tasks`() { + val service = PlanStateService() + val tasks = listOf( + PlanTask(id = "task1", title = "Task 1"), + PlanTask(id = "task2", title = "Task 2") + ) + + val plan = service.createPlan(tasks) + + assertNotNull(plan) + assertEquals(2, plan.taskCount) + assertEquals(plan, service.getPlan()) + } + + @Test + fun `should create plan from markdown`() { + val service = PlanStateService() + val markdown = """ + 1. First task + - [ ] Step one + - [ ] Step two + 2. Second task + - [ ] Step three + """.trimIndent() + + val plan = service.createPlanFromMarkdown(markdown) + + assertEquals(2, plan.taskCount) + assertEquals(2, plan.tasks[0].steps.size) + assertEquals(1, plan.tasks[1].steps.size) + } + + @Test + fun `should update task status`() { + val service = PlanStateService() + val task = PlanTask(id = "task1", title = "Task 1") + service.createPlan(listOf(task)) + + service.updateTaskStatus("task1", TaskStatus.IN_PROGRESS) + + assertEquals(TaskStatus.IN_PROGRESS, service.getPlan()?.getTask("task1")?.status) + } + + @Test + fun `should complete step`() { + val service = PlanStateService() + val step = PlanStep(id = "step1", description = "Step 1") + val task = PlanTask(id = "task1", title = "Task 1", steps = mutableListOf(step)) + service.createPlan(listOf(task)) + + service.completeStep("task1", "step1") + + val updatedStep = service.getPlan()?.getTask("task1")?.steps?.find { it.id == "step1" } + assertEquals(TaskStatus.COMPLETED, updatedStep?.status) + } + + @Test + fun `should notify listeners on plan created`() { + val service = PlanStateService() + var notifiedPlan: AgentPlan? = null + + service.addListener(object : DefaultPlanUpdateListener() { + override fun onPlanCreated(plan: AgentPlan) { + notifiedPlan = plan + } + }) + + val plan = service.createPlan(listOf(PlanTask(id = "task1", title = "Task 1"))) + + assertEquals(plan, notifiedPlan) + } + + @Test + fun `should notify listeners on task updated`() { + val service = PlanStateService() + var notifiedTask: PlanTask? = null + + service.addListener(object : DefaultPlanUpdateListener() { + override fun onTaskUpdated(task: PlanTask) { + notifiedTask = task + } + }) + + service.createPlan(listOf(PlanTask(id = "task1", title = "Task 1"))) + service.updateTaskStatus("task1", TaskStatus.COMPLETED) + + assertNotNull(notifiedTask) + assertEquals("task1", notifiedTask?.id) + assertEquals(TaskStatus.COMPLETED, notifiedTask?.status) + } + + @Test + fun `should notify listeners on step completed`() { + val service = PlanStateService() + var completedTaskId: String? = null + var completedStepId: String? = null + + service.addListener(object : DefaultPlanUpdateListener() { + override fun onStepCompleted(taskId: String, stepId: String) { + completedTaskId = taskId + completedStepId = stepId + } + }) + + val step = PlanStep(id = "step1", description = "Step 1") + val task = PlanTask(id = "task1", title = "Task 1", steps = mutableListOf(step)) + service.createPlan(listOf(task)) + service.completeStep("task1", "step1") + + assertEquals("task1", completedTaskId) + assertEquals("step1", completedStepId) + } + + @Test + fun `should clear plan`() { + val service = PlanStateService() + service.createPlan(listOf(PlanTask(id = "task1", title = "Task 1"))) + + service.clearPlan() + + assertNull(service.getPlan()) + } + + @Test + fun `should add task to existing plan`() { + val service = PlanStateService() + service.createPlan(listOf(PlanTask(id = "task1", title = "Task 1"))) + + service.addTask(PlanTask(id = "task2", title = "Task 2")) + + assertEquals(2, service.getPlan()?.taskCount) + } + + @Test + fun `should update step status`() { + val service = PlanStateService() + val step = PlanStep(id = "step1", description = "Step 1") + val task = PlanTask(id = "task1", title = "Task 1", steps = mutableListOf(step)) + service.createPlan(listOf(task)) + + service.updateStepStatus("task1", "step1", TaskStatus.IN_PROGRESS) + + val updatedStep = service.getPlan()?.getTask("task1")?.steps?.find { it.id == "step1" } + assertEquals(TaskStatus.IN_PROGRESS, updatedStep?.status) + } + + @Test + fun `should calculate progress correctly`() { + val service = PlanStateService() + val steps = mutableListOf( + PlanStep(id = "step1", description = "Step 1", status = TaskStatus.COMPLETED), + PlanStep(id = "step2", description = "Step 2", status = TaskStatus.COMPLETED), + PlanStep(id = "step3", description = "Step 3", status = TaskStatus.TODO), + PlanStep(id = "step4", description = "Step 4", status = TaskStatus.TODO) + ) + val task = PlanTask(id = "task1", title = "Task 1", steps = steps) + service.createPlan(listOf(task)) + + assertEquals(50, service.getPlan()?.progressPercent) + } +} + diff --git a/mpp-core/src/commonTest/kotlin/cc/unitmesh/agent/tool/ToolRegistryTest.kt b/mpp-core/src/commonTest/kotlin/cc/unitmesh/agent/tool/ToolRegistryTest.kt index 0739eea707..20c9275848 100644 --- a/mpp-core/src/commonTest/kotlin/cc/unitmesh/agent/tool/ToolRegistryTest.kt +++ b/mpp-core/src/commonTest/kotlin/cc/unitmesh/agent/tool/ToolRegistryTest.kt @@ -68,4 +68,12 @@ class ToolRegistryTest { assertTrue(toolInfo.description.isNotEmpty(), "Should have description") assertTrue(toolInfo.isDevIns, "Should be marked as DevIns tool") } + + @Test + fun testPlanManagementToolRegistered() { + val planTool = registry.getTool("plan") + assertNotNull(planTool, "Plan management tool should be registered") + assertEquals("plan", planTool.name) + assertTrue(planTool.description.contains("plan"), "Should have plan-related description") + } } diff --git a/mpp-core/src/commonTest/kotlin/cc/unitmesh/agent/tool/impl/PlanManagementToolTest.kt b/mpp-core/src/commonTest/kotlin/cc/unitmesh/agent/tool/impl/PlanManagementToolTest.kt new file mode 100644 index 0000000000..626245baf9 --- /dev/null +++ b/mpp-core/src/commonTest/kotlin/cc/unitmesh/agent/tool/impl/PlanManagementToolTest.kt @@ -0,0 +1,170 @@ +package cc.unitmesh.agent.tool.impl + +import cc.unitmesh.agent.plan.PlanStateService +import cc.unitmesh.agent.tool.ToolExecutionContext +import cc.unitmesh.agent.tool.ToolResult +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class PlanManagementToolTest { + + private fun createTool() = PlanManagementTool(PlanStateService()) + + @Test + fun `should create plan from markdown`() = runTest { + val tool = createTool() + val params = PlanManagementParams( + action = "CREATE", + planMarkdown = """ + 1. Setup project + - [ ] Create directory structure + - [ ] Initialize git + 2. Implement feature + - [ ] Write code + - [ ] Add tests + """.trimIndent() + ) + + val invocation = tool.createInvocation(params) + val result = invocation.execute(ToolExecutionContext()) + + assertIs(result) + assertTrue(result.content.contains("Plan created with 2 tasks")) + assertEquals("2", result.metadata["task_count"]) + } + + @Test + fun `should return error when planMarkdown is empty for CREATE`() = runTest { + val tool = createTool() + val params = PlanManagementParams(action = "CREATE", planMarkdown = "") + + val invocation = tool.createInvocation(params) + val result = invocation.execute(ToolExecutionContext()) + + assertIs(result) + assertTrue(result.message.contains("planMarkdown is required")) + } + + @Test + fun `should complete step successfully`() = runTest { + val tool = createTool() + + // First create a plan + val createParams = PlanManagementParams( + action = "CREATE", + planMarkdown = "1. Task\n - [ ] Step 1\n - [ ] Step 2" + ) + tool.createInvocation(createParams).execute(ToolExecutionContext()) + + // Then complete a step + val completeParams = PlanManagementParams( + action = "COMPLETE_STEP", + taskIndex = 1, + stepIndex = 1 + ) + val invocation = tool.createInvocation(completeParams) + val result = invocation.execute(ToolExecutionContext()) + + assertIs(result) + assertTrue(result.content.contains("Step completed")) + assertEquals("COMPLETED", result.metadata["status"]) + } + + @Test + fun `should fail step successfully`() = runTest { + val tool = createTool() + + // First create a plan + val createParams = PlanManagementParams( + action = "CREATE", + planMarkdown = "1. Task\n - [ ] Step 1" + ) + tool.createInvocation(createParams).execute(ToolExecutionContext()) + + // Then fail a step + val failParams = PlanManagementParams( + action = "FAIL_STEP", + taskIndex = 1, + stepIndex = 1 + ) + val invocation = tool.createInvocation(failParams) + val result = invocation.execute(ToolExecutionContext()) + + assertIs(result) + assertTrue(result.content.contains("Step failed")) + assertEquals("FAILED", result.metadata["status"]) + } + + @Test + fun `should return error when no plan exists for COMPLETE_STEP`() = runTest { + val tool = createTool() + val params = PlanManagementParams(action = "COMPLETE_STEP", taskIndex = 1, stepIndex = 1) + + val invocation = tool.createInvocation(params) + val result = invocation.execute(ToolExecutionContext()) + + assertIs(result) + assertTrue(result.message.contains("No active plan")) + } + + @Test + fun `should view current plan`() = runTest { + val tool = createTool() + + // First create a plan + val createParams = PlanManagementParams( + action = "CREATE", + planMarkdown = "1. Task\n - [ ] Step 1" + ) + tool.createInvocation(createParams).execute(ToolExecutionContext()) + + // Then view it + val viewParams = PlanManagementParams(action = "VIEW") + val invocation = tool.createInvocation(viewParams) + val result = invocation.execute(ToolExecutionContext()) + + assertIs(result) + assertTrue(result.content.contains("Task")) + assertEquals("1", result.metadata["task_count"]) + } + + @Test + fun `should return no active plan for VIEW when empty`() = runTest { + val tool = createTool() + val params = PlanManagementParams(action = "VIEW") + + val invocation = tool.createInvocation(params) + val result = invocation.execute(ToolExecutionContext()) + + assertIs(result) + assertTrue(result.content.contains("No active plan")) + assertEquals("false", result.metadata["has_plan"]) + } + + @Test + fun `should update existing plan`() = runTest { + val tool = createTool() + + // First create a plan + val createParams = PlanManagementParams( + action = "CREATE", + planMarkdown = "1. Task 1\n - [ ] Step 1" + ) + tool.createInvocation(createParams).execute(ToolExecutionContext()) + + // Then update it + val updateParams = PlanManagementParams( + action = "UPDATE", + planMarkdown = "1. Task 1\n - [x] Step 1\n2. Task 2\n - [ ] Step 2" + ) + val invocation = tool.createInvocation(updateParams) + val result = invocation.execute(ToolExecutionContext()) + + assertIs(result) + assertTrue(result.content.contains("Plan updated with 2 tasks")) + } +} + diff --git a/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/RendererExports.kt b/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/RendererExports.kt index 563c1ef389..3003b813a5 100644 --- a/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/RendererExports.kt +++ b/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/RendererExports.kt @@ -1,8 +1,39 @@ package cc.unitmesh.agent +import cc.unitmesh.agent.plan.PlanSummaryData import cc.unitmesh.agent.render.CodingAgentRenderer import kotlin.js.JsExport +/** + * JS-friendly plan summary data + */ +@JsExport +data class JsPlanSummaryData( + val planId: String, + val title: String, + val totalSteps: Int, + val completedSteps: Int, + val failedSteps: Int, + val progressPercent: Int, + val status: String, + val currentStepDescription: String? +) { + companion object { + fun from(summary: PlanSummaryData): JsPlanSummaryData { + return JsPlanSummaryData( + planId = summary.planId, + title = summary.title, + totalSteps = summary.totalSteps, + completedSteps = summary.completedSteps, + failedSteps = summary.failedSteps, + progressPercent = summary.progressPercent, + status = summary.status.name, + currentStepDescription = summary.currentStepDescription + ) + } + } +} + /** * JS-friendly renderer interface * Allows TypeScript to provide custom rendering implementations @@ -28,6 +59,9 @@ interface JsCodingAgentRenderer { // Error recovery methods fun renderRecoveryAdvice(recoveryAdvice: String) + + // Plan summary bar (optional - default no-op in BaseRenderer) + fun renderPlanSummary(summary: JsPlanSummaryData) {} } /** @@ -93,9 +127,11 @@ class JsRendererAdapter(private val jsRenderer: JsCodingAgentRenderer) : CodingA override fun renderUserConfirmationRequest(toolName: String, params: Map) { // For now, just use error rendering since JS renderer doesn't have this method yet - jsRenderer.renderError("🔐 Tool '$toolName' requires user confirmation: $params (Auto-approved)") + jsRenderer.renderError("Tool '$toolName' requires user confirmation: $params (Auto-approved)") } - + override fun renderPlanSummary(summary: PlanSummaryData) { + jsRenderer.renderPlanSummary(JsPlanSummaryData.from(summary)) + } } diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/devins/filesystem/DefaultFileSystem.jvm.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/devins/filesystem/DefaultFileSystem.jvm.kt index c46b70670e..1d8e194e3f 100644 --- a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/devins/filesystem/DefaultFileSystem.jvm.kt +++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/devins/filesystem/DefaultFileSystem.jvm.kt @@ -105,16 +105,18 @@ actual class DefaultFileSystem actual constructor(private val projectPath: Strin actual override fun searchFiles(pattern: String, maxDepth: Int, maxResults: Int): List { return try { + println("[DefaultFileSystem] searchFiles called: pattern=$pattern, projectPath=$projectPath") val projectRoot = Path.of(projectPath) if (!projectRoot.exists() || !projectRoot.isDirectory()) { + println("[DefaultFileSystem] Project root does not exist or is not a directory") return emptyList() } - + // Convert glob pattern to regex - handle ** and * differently // **/ should match zero or more directory levels (including root) // IMPORTANT: Use placeholders without * to avoid conflicts val regexPattern = pattern - .replace("**/", "___RECURSIVE___") // Protect **/ first + .replace("**/", "___RECURSIVE___") // Protect **/ first .replace("**", "___GLOBSTAR___") // Then protect ** .replace(".", "\\.") // Escape dots .replace("?", "___QUESTION___") // Protect ? before converting braces @@ -125,18 +127,22 @@ actual class DefaultFileSystem actual constructor(private val projectPath: Strin .replace("___RECURSIVE___", "(?:(?:.*/)|(?:))") // **/ matches zero or more directories .replace("___GLOBSTAR___", ".*") // ** without / matches anything .replace("___QUESTION___", ".") // Now replace ? with . - + + println("[DefaultFileSystem] Regex pattern: $regexPattern") val regex = regexPattern.toRegex(RegexOption.IGNORE_CASE) - + val results = mutableListOf() - + // 只保留最基本的排除目录(.git 必须排除,其他依赖 gitignore) // Add build to satisfy tests expecting no files under /build/; also pre-filter relative paths containing /build/ val criticalExcludeDirs = setOf(".git", "build") - + // Reload gitignore patterns before search + println("[DefaultFileSystem] Reloading gitignore...") gitIgnoreParser?.reload() - + + println("[DefaultFileSystem] Starting Files.walk...") + val startTime = System.currentTimeMillis() Files.walk(projectRoot, maxDepth).use { stream -> val iterator = stream .filter { path -> @@ -177,9 +183,13 @@ actual class DefaultFileSystem actual constructor(private val projectPath: Strin } } } - + + val elapsed = System.currentTimeMillis() - startTime + println("[DefaultFileSystem] Files.walk completed in ${elapsed}ms, found ${results.size} results") results } catch (e: Exception) { + println("[DefaultFileSystem] Error during search: ${e.message}") + e.printStackTrace() emptyList() } } diff --git a/mpp-core/src/jvmTest/kotlin/cc/unitmesh/agent/tool/impl/DocQLReturnAllTest.kt b/mpp-core/src/jvmTest/kotlin/cc/unitmesh/agent/tool/impl/DocQLReturnAllTest.kt index 869f4b04ec..fe69062f6d 100644 --- a/mpp-core/src/jvmTest/kotlin/cc/unitmesh/agent/tool/impl/DocQLReturnAllTest.kt +++ b/mpp-core/src/jvmTest/kotlin/cc/unitmesh/agent/tool/impl/DocQLReturnAllTest.kt @@ -126,6 +126,7 @@ class DocQLReturnAllTest { is ToolResult.Success -> resultAll.content is ToolResult.Error -> resultAll.message is ToolResult.AgentResult -> resultAll.content + is ToolResult.Pending -> resultAll.message } println("returnAll=true result: ${content.take(500)}") @@ -171,6 +172,7 @@ class DocQLReturnAllTest { is ToolResult.Success -> resultDefault.content is ToolResult.Error -> resultDefault.message is ToolResult.AgentResult -> resultDefault.content + is ToolResult.Pending -> resultDefault.message } println("returnAll=false result: ${content.take(500)}") diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt index 71c85f8f96..36440de2af 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt @@ -1,11 +1,15 @@ package cc.unitmesh.devins.idea.renderer +import cc.unitmesh.agent.plan.AgentPlan +import cc.unitmesh.agent.plan.MarkdownPlanParser +import cc.unitmesh.agent.plan.TaskStatus as PlanTaskStatus import cc.unitmesh.agent.render.BaseRenderer import cc.unitmesh.devins.llm.MessageRole import cc.unitmesh.agent.render.RendererUtils import cc.unitmesh.agent.render.TaskInfo import cc.unitmesh.agent.render.TaskStatus import cc.unitmesh.agent.render.TimelineItem +import cc.unitmesh.agent.render.ToolCallDisplayInfo import cc.unitmesh.agent.render.ToolCallInfo import cc.unitmesh.agent.tool.ToolType import cc.unitmesh.agent.tool.toToolType @@ -76,6 +80,10 @@ class JewelRenderer : BaseRenderer() { private val _tasks = MutableStateFlow>(emptyList()) val tasks: StateFlow> = _tasks.asStateFlow() + // Plan tracking (from plan management tool) + private val _currentPlan = MutableStateFlow(null) + val currentPlan: StateFlow = _currentPlan.asStateFlow() + // BaseRenderer implementation override fun renderIterationHeader(current: Int, max: Int) { @@ -138,6 +146,56 @@ class JewelRenderer : BaseRenderer() { updateTaskFromToolCall(params) } + // Handle plan management tool - update plan state + if (toolName == "plan") { + updatePlanFromToolCall(params) + // Skip rendering plan tool to timeline - it's shown in PlanSummaryBar + return + } + + renderToolCallInternal(toolName, toolInfo, params, paramsStr, toolType) + } + + /** + * Render a tool call with parsed parameters. + * This is the preferred method as it avoids string parsing issues with complex values. + */ + override fun renderToolCallWithParams(toolName: String, params: Map) { + // Convert params to string format for display + val paramsStr = params.entries.joinToString(" ") { (key, value) -> + "$key=\"$value\"" + } + val toolInfo = formatToolCallDisplay(toolName, paramsStr) + val toolType = toolName.toToolType() + + // Convert Map to Map for internal use + val stringParams = params.mapValues { it.value.toString() } + + // Handle task-boundary tool - update task list + if (toolName == "task-boundary") { + updateTaskFromToolCall(stringParams) + } + + // Handle plan management tool - update plan state with original params + if (toolName == "plan") { + updatePlanFromToolCallWithAnyParams(params) + // Skip rendering plan tool to timeline - it's shown in PlanSummaryBar + return + } + + renderToolCallInternal(toolName, toolInfo, stringParams, paramsStr, toolType) + } + + /** + * Internal method to render tool call UI elements + */ + private fun renderToolCallInternal( + toolName: String, + toolInfo: ToolCallDisplayInfo, + params: Map, + paramsStr: String, + toolType: ToolType? + ) { // Skip adding ToolCallItem for Shell - will be replaced by LiveTerminalItem // This prevents the "two bubbles" problem if (toolType == ToolType.Shell) { @@ -207,6 +265,82 @@ class JewelRenderer : BaseRenderer() { } } + /** + * Update plan state from plan management tool call (string params version) + */ + private fun updatePlanFromToolCall(params: Map) { + val action = params["action"]?.uppercase() ?: return + val planMarkdown = params["planMarkdown"] ?: "" + val taskIndex = params["taskIndex"]?.toIntOrNull() + val stepIndex = params["stepIndex"]?.toIntOrNull() + + updatePlanState(action, planMarkdown, taskIndex, stepIndex) + } + + /** + * Update plan state from plan management tool call with Any params. + * This is the preferred method as it handles complex values correctly. + */ + private fun updatePlanFromToolCallWithAnyParams(params: Map) { + val action = (params["action"] as? String)?.uppercase() ?: return + val planMarkdown = params["planMarkdown"] as? String ?: "" + val taskIndex = when (val v = params["taskIndex"]) { + is Number -> v.toInt() + is String -> v.toIntOrNull() + else -> null + } + val stepIndex = when (val v = params["stepIndex"]) { + is Number -> v.toInt() + is String -> v.toIntOrNull() + else -> null + } + + updatePlanState(action, planMarkdown, taskIndex, stepIndex) + } + + /** + * Internal method to update plan state + */ + private fun updatePlanState(action: String, planMarkdown: String, taskIndex: Int?, stepIndex: Int?) { + when (action) { + "CREATE", "UPDATE" -> { + if (planMarkdown.isNotBlank()) { + _currentPlan.value = MarkdownPlanParser.parseToPlan(planMarkdown) + } + } + "COMPLETE_STEP" -> { + if (taskIndex == null || stepIndex == null) return + _currentPlan.value?.let { plan -> + if (taskIndex in 1..plan.tasks.size) { + val task = plan.tasks[taskIndex - 1] + if (stepIndex in 1..task.steps.size) { + val step = task.steps[stepIndex - 1] + step.complete() + task.updateStatusFromSteps() + // Trigger recomposition by creating a new plan instance + _currentPlan.value = plan.copy(updatedAt = System.currentTimeMillis()) + } + } + } + } + "FAIL_STEP" -> { + if (taskIndex == null || stepIndex == null) return + _currentPlan.value?.let { plan -> + if (taskIndex in 1..plan.tasks.size) { + val task = plan.tasks[taskIndex - 1] + if (stepIndex in 1..task.steps.size) { + val step = task.steps[stepIndex - 1] + step.fail() + task.updateStatusFromSteps() + _currentPlan.value = plan.copy(updatedAt = System.currentTimeMillis()) + } + } + } + } + // VIEW action doesn't modify state + } + } + override fun renderToolResult( toolName: String, success: Boolean, diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownRenderer.kt index a8e7f4f545..cbaa9c5e74 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownRenderer.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.unitmesh.devins.idea.renderer.markdown.MarkdownInlineRenderer.appendMarkdownChildren import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import cc.unitmesh.markdown.MarkdownTextParser import org.intellij.markdown.MarkdownElementTypes import org.intellij.markdown.MarkdownTokenTypes import org.intellij.markdown.ast.ASTNode diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownInlineRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownInlineRenderer.kt index 91d879e657..b197687828 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownInlineRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownInlineRenderer.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.sp import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import cc.unitmesh.markdown.MarkdownTextParser import org.intellij.markdown.MarkdownElementTypes import org.intellij.markdown.MarkdownTokenTypes import org.intellij.markdown.ast.ASTNode diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTable.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTable.kt index 694816dbe7..08c66940bc 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTable.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTable.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.times +import cc.unitmesh.markdown.MarkdownTextParser import org.intellij.markdown.ast.ASTNode import org.intellij.markdown.flavours.gfm.GFMElementTypes import org.intellij.markdown.flavours.gfm.GFMTokenTypes diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt index ceaa0284f8..cf768ff086 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt @@ -51,6 +51,7 @@ fun IdeaAgentApp( val timeline by viewModel.renderer.timeline.collectAsState() val streamingOutput by viewModel.renderer.currentStreamingOutput.collectAsState() val isExecuting by viewModel.isExecuting.collectAsState() + val currentPlan by viewModel.renderer.currentPlan.collectAsState() val showConfigDialog by viewModel.showConfigDialog.collectAsState() val mcpPreloadingMessage by viewModel.mcpPreloadingMessage.collectAsState() val configWrapper by viewModel.configWrapper.collectAsState() @@ -172,6 +173,7 @@ fun IdeaAgentApp( onConfigSelect = { config -> viewModel.setActiveConfig(config.name) }, + currentPlan = currentPlan, onConfigureClick = { viewModel.setShowConfigDialog(true) } ) } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt index 6354f4077b..26ba409d7f 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt @@ -11,7 +11,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.awt.SwingPanel import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp +import cc.unitmesh.agent.plan.AgentPlan import cc.unitmesh.devins.idea.editor.* +import cc.unitmesh.devins.idea.toolwindow.plan.IdeaPlanSummaryBar import cc.unitmesh.llm.NamedModelConfig import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager @@ -76,7 +78,8 @@ fun IdeaDevInInputArea( availableConfigs: List = emptyList(), currentConfigName: String? = null, onConfigSelect: (NamedModelConfig) -> Unit = {}, - onConfigureClick: () -> Unit = {} + onConfigureClick: () -> Unit = {}, + currentPlan: AgentPlan? = null ) { var inputText by remember { mutableStateOf("") } var devInInput by remember { mutableStateOf(null) } @@ -102,6 +105,12 @@ fun IdeaDevInInputArea( shape = borderShape ) ) { + // Plan summary bar - shown above top toolbar when a plan is active + IdeaPlanSummaryBar( + plan = currentPlan, + modifier = Modifier.fillMaxWidth() + ) + // Top toolbar with file selection (no individual border) IdeaTopToolbar( project = project, diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/plan/IdeaPlanSummaryBar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/plan/IdeaPlanSummaryBar.kt new file mode 100644 index 0000000000..f897ed9b09 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/plan/IdeaPlanSummaryBar.kt @@ -0,0 +1,431 @@ +package cc.unitmesh.devins.idea.toolwindow.plan + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.agent.plan.AgentPlan +import cc.unitmesh.agent.plan.PlanStep +import cc.unitmesh.agent.plan.PlanTask +import cc.unitmesh.agent.plan.TaskStatus +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.IconButton +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.icons.AllIconsKeys + +/** + * Plan Summary Bar for IntelliJ IDEA using Jewel components. + * + * Displays a collapsible summary of the current plan above the input box. + * Uses Jewel theming and components for native IntelliJ look and feel. + */ +@Composable +fun IdeaPlanSummaryBar( + plan: AgentPlan?, + modifier: Modifier = Modifier, + onViewDetails: (() -> Unit)? = null, + onDismiss: (() -> Unit)? = null +) { + // Don't render if no plan + if (plan == null || plan.tasks.isEmpty()) { + return + } + + var isExpanded by remember { mutableStateOf(false) } + + val backgroundColor = when (plan.status) { + TaskStatus.FAILED -> AutoDevColors.Red.c900.copy(alpha = 0.2f) + TaskStatus.COMPLETED -> AutoDevColors.Green.c900.copy(alpha = 0.2f) + else -> JewelTheme.globalColors.panelBackground + } + + Column( + modifier = modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp)) + .clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp)) + ) { + // Collapsed header + IdeaPlanSummaryHeader( + plan = plan, + isExpanded = isExpanded, + onExpandToggle = { isExpanded = !isExpanded }, + onDismiss = onDismiss + ) + + // Expanded content + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(), + exit = shrinkVertically() + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(JewelTheme.globalColors.borders.normal) + ) + IdeaPlanExpandedContent(plan = plan) + } + } + } +} + +@Composable +private fun IdeaPlanSummaryHeader( + plan: AgentPlan, + isExpanded: Boolean, + onExpandToggle: () -> Unit, + onDismiss: (() -> Unit)? +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onExpandToggle() } + .padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Left side: icon, title, progress + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Status icon + IdeaPlanStatusIcon(status = plan.status) + + // Expand arrow + Icon( + key = if (isExpanded) AllIconsKeys.General.ArrowDown else AllIconsKeys.General.ArrowRight, + contentDescription = if (isExpanded) "Collapse" else "Expand", + modifier = Modifier.size(12.dp), + tint = AutoDevColors.Neutral.c400 + ) + + // Title + Text( + text = plan.tasks.firstOrNull()?.title ?: "Plan", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + + // Progress indicator + IdeaPlanProgressBadge(plan = plan) + } + + // Right side: current step and dismiss + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Current step description (compact) + val currentStep = findCurrentStep(plan) + if (currentStep != null && !isExpanded) { + Text( + text = currentStep, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = AutoDevColors.Neutral.c400 + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 150.dp) + ) + } + + // Dismiss button + if (onDismiss != null) { + IconButton( + onClick = onDismiss, + modifier = Modifier.size(20.dp) + ) { + Icon( + key = AllIconsKeys.Actions.Close, + contentDescription = "Dismiss", + modifier = Modifier.size(12.dp), + tint = AutoDevColors.Neutral.c400 + ) + } + } + } + } +} + +@Composable +private fun IdeaPlanStatusIcon(status: TaskStatus) { + val infiniteTransition = rememberInfiniteTransition() + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(2000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ) + ) + + when (status) { + TaskStatus.COMPLETED -> Icon( + key = AllIconsKeys.Actions.Checked, + contentDescription = "Completed", + modifier = Modifier.size(16.dp), + tint = AutoDevColors.Green.c400 + ) + TaskStatus.FAILED -> Icon( + key = AllIconsKeys.General.Error, + contentDescription = "Failed", + modifier = Modifier.size(16.dp), + tint = AutoDevColors.Red.c400 + ) + TaskStatus.IN_PROGRESS -> Icon( + key = AllIconsKeys.Actions.Refresh, + contentDescription = "In Progress", + modifier = Modifier.size(16.dp).rotate(rotation), + tint = AutoDevColors.Blue.c400 + ) + TaskStatus.BLOCKED -> Icon( + key = AllIconsKeys.General.Warning, + contentDescription = "Blocked", + modifier = Modifier.size(16.dp), + tint = AutoDevColors.Amber.c400 + ) + else -> Icon( + key = AllIconsKeys.General.TodoDefault, + contentDescription = "Plan", + modifier = Modifier.size(16.dp), + tint = AutoDevColors.Neutral.c400 + ) + } +} + +@Composable +private fun IdeaPlanProgressBadge(plan: AgentPlan) { + val totalSteps = plan.tasks.sumOf { it.totalStepCount } + val completedSteps = plan.tasks.sumOf { it.completedStepCount } + val progress = plan.progressPercent + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Progress bar (simple Box-based implementation) + Box( + modifier = Modifier + .width(60.dp) + .height(4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(AutoDevColors.Neutral.c700) + ) { + val progressColor = when (plan.status) { + TaskStatus.COMPLETED -> AutoDevColors.Green.c400 + TaskStatus.FAILED -> AutoDevColors.Red.c400 + else -> AutoDevColors.Blue.c400 + } + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(fraction = progress / 100f) + .background(progressColor) + ) + } + + // Progress text + Text( + text = "$completedSteps/$totalSteps", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = AutoDevColors.Neutral.c400 + ) + ) + } +} + +@Composable +private fun IdeaPlanExpandedContent(plan: AgentPlan) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + items(plan.tasks, key = { it.id }) { task -> + IdeaTaskSummaryItem(task = task) + } + } +} + +@Composable +private fun IdeaTaskSummaryItem(task: PlanTask) { + var isExpanded by remember { mutableStateOf(true) } + + Column( + modifier = Modifier + .fillMaxWidth() + .background( + JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f), + RoundedCornerShape(4.dp) + ) + .padding(8.dp) + ) { + // Task header + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { isExpanded = !isExpanded }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.weight(1f) + ) { + IdeaStepStatusIcon(status = task.status, size = 14) + Text( + text = task.title, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + fontWeight = FontWeight.Medium + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Text( + text = "${task.completedStepCount}/${task.totalStepCount}", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = AutoDevColors.Neutral.c400 + ) + ) + } + + // Steps (if expanded) + AnimatedVisibility(visible = isExpanded && task.steps.isNotEmpty()) { + Column( + modifier = Modifier.padding(start = 20.dp, top = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + task.steps.forEach { step -> + IdeaStepItem(step = step) + } + } + } + } +} + +@Composable +private fun IdeaStepItem(step: PlanStep) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + IdeaStepStatusIcon(status = step.status, size = 12) + Text( + text = step.description, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = when (step.status) { + TaskStatus.COMPLETED -> AutoDevColors.Neutral.c500 + TaskStatus.FAILED -> AutoDevColors.Red.c400 + else -> AutoDevColors.Neutral.c200 + } + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun IdeaStepStatusIcon(status: TaskStatus, size: Int = 14) { + val infiniteTransition = rememberInfiniteTransition() + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(2000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ) + ) + + when (status) { + TaskStatus.COMPLETED -> Icon( + key = AllIconsKeys.Actions.Checked, + contentDescription = "Completed", + modifier = Modifier.size(size.dp), + tint = AutoDevColors.Green.c400 + ) + TaskStatus.FAILED -> Icon( + key = AllIconsKeys.Actions.Close, + contentDescription = "Failed", + modifier = Modifier.size(size.dp), + tint = AutoDevColors.Red.c400 + ) + TaskStatus.IN_PROGRESS -> Icon( + key = AllIconsKeys.Actions.Refresh, + contentDescription = "In Progress", + modifier = Modifier.size(size.dp).rotate(rotation), + tint = AutoDevColors.Blue.c400 + ) + else -> Box( + modifier = Modifier + .size((size - 4).dp) + .background(AutoDevColors.Neutral.c600.copy(alpha = 0.3f), CircleShape) + ) + } +} + +/** + * Find the current step description (first in-progress or first todo) + */ +private fun findCurrentStep(plan: AgentPlan): String? { + for (task in plan.tasks) { + for (step in task.steps) { + if (step.status == TaskStatus.IN_PROGRESS) { + return step.description + } + } + } + for (task in plan.tasks) { + for (step in task.steps) { + if (step.status == TaskStatus.TODO) { + return step.description + } + } + } + return null +} + diff --git a/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTextParserTest.kt b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTextParserTest.kt index b64bc3c6c6..37a6999584 100644 --- a/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTextParserTest.kt +++ b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTextParserTest.kt @@ -1,5 +1,6 @@ package cc.unitmesh.devins.idea.renderer.markdown +import cc.unitmesh.markdown.MarkdownTextParser import org.intellij.markdown.MarkdownElementTypes import org.intellij.markdown.ast.ASTNode import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor diff --git a/mpp-ios/build-ios-app.sh b/mpp-ios/build-ios-app.sh index 005543c54d..233e204a12 100755 --- a/mpp-ios/build-ios-app.sh +++ b/mpp-ios/build-ios-app.sh @@ -341,14 +341,42 @@ elif [ "$ACTION" = "build" ] || [ "$ACTION" = "run" ]; then if [ "$ACTION" = "run" ]; then if [ "$TARGET_TYPE" = "device" ]; then - # 真机运行 - if command -v ios-deploy &> /dev/null; then - echo -e "${BLUE}🚀 使用 ios-deploy 安装并运行到设备...${NC}" - ios-deploy --debug --bundle "$APP_PATH" --id "$DEVICE_ID" + # 真机运行 - 使用 devicectl 安装和启动 + echo -e "${BLUE}🚀 安装应用到真机...${NC}" + + # 获取连接的设备 ID (使用 devicectl) + # UUID 格式: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX + DEVICE_UUID=$(xcrun devicectl list devices 2>&1 | grep "connected" | grep -oE "[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}" | head -n 1) + + if [ -z "$DEVICE_UUID" ]; then + echo -e "${RED}❌ 未找到已连接的设备${NC}" + echo -e "${YELLOW}请确保设备已连接并信任此电脑${NC}" + exit 1 + fi + + echo -e "${GREEN}✓ 找到设备: ${DEVICE_UUID}${NC}" + + # 使用 devicectl 安装应用 + echo -e "${YELLOW} 安装应用...${NC}" + xcrun devicectl device install app --device "$DEVICE_UUID" "$APP_PATH" + + if [ $? -ne 0 ]; then + echo -e "${RED}❌ 应用安装失败${NC}" + exit 1 + fi + + echo -e "${GREEN}✅ 应用安装成功!${NC}" + + # 使用 devicectl 启动应用 + BUNDLE_ID="cc.unitmesh.AutoDevApp" + echo -e "${YELLOW} 启动应用...${NC}" + xcrun devicectl device process launch --device "$DEVICE_UUID" "$BUNDLE_ID" + + if [ $? -ne 0 ]; then + echo -e "${YELLOW}⚠️ 应用启动失败,可能需要在设备上信任开发者证书${NC}" + echo -e "${CYAN}请在 iPhone 上: 设置 -> 通用 -> VPN与设备管理 -> 信任开发者${NC}" else - echo -e "${YELLOW}⚠️ 未找到 ios-deploy 工具,无法自动安装到真机${NC}" - echo -e "请使用以下命令安装: ${CYAN}brew install ios-deploy${NC}" - echo -e "或者打开 Xcode 手动运行: ${CYAN}open AutoDevApp.xcworkspace${NC}" + echo -e "${GREEN}✅ 应用已启动!${NC}" fi else # 运行到模拟器 diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt index 94cb80f57e..363e083e5d 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt @@ -1,6 +1,8 @@ package cc.unitmesh.devins.ui.compose.agent import androidx.compose.runtime.* +import cc.unitmesh.agent.plan.AgentPlan +import cc.unitmesh.agent.plan.MarkdownPlanParser import cc.unitmesh.agent.render.BaseRenderer import cc.unitmesh.agent.render.RendererUtils import cc.unitmesh.agent.render.TaskInfo @@ -75,6 +77,10 @@ class ComposeRenderer : BaseRenderer() { private val _tasks = mutableStateListOf() val tasks: List = _tasks + // Plan tracking from plan management tool + private var _currentPlan by mutableStateOf(null) + val currentPlan: AgentPlan? get() = _currentPlan + // BaseRenderer implementation override fun renderIterationHeader( @@ -117,8 +123,17 @@ class ComposeRenderer : BaseRenderer() { override fun renderLLMResponseEnd() { super.renderLLMResponseEnd() - // Add the completed reasoning as a message to timeline + // Save content and token info before clearing val finalContent = _currentStreamingOutput.trim() + val tokenInfo = _lastMessageTokenInfo + + // IMPORTANT: Clear streaming output FIRST to avoid showing both + // StreamingMessageItem and MessageItem simultaneously (double progress bar issue) + _currentStreamingOutput = "" + _isProcessing = false + _lastMessageTokenInfo = null + + // Then add the completed message to timeline if (finalContent.isNotEmpty()) { _timeline.add( TimelineItem.MessageItem( @@ -127,14 +142,10 @@ class ComposeRenderer : BaseRenderer() { role = MessageRole.ASSISTANT, content = finalContent ), - tokenInfo = _lastMessageTokenInfo + tokenInfo = tokenInfo ) ) } - - _currentStreamingOutput = "" - _isProcessing = false - _lastMessageTokenInfo = null // Reset after use } override fun renderToolCall( @@ -150,6 +161,57 @@ class ComposeRenderer : BaseRenderer() { updateTaskFromToolCall(params) } + // Handle plan management tool - update plan state + if (toolName == "plan") { + updatePlanFromToolCall(params) + // Skip rendering plan tool to timeline - it's shown in PlanSummaryBar + return + } + + renderToolCallInternal(toolName, toolInfo, params, paramsStr, toolType) + } + + /** + * Render a tool call with parsed parameters. + * This is the preferred method as it avoids string parsing issues with complex values. + */ + override fun renderToolCallWithParams(toolName: String, params: Map) { + // Convert params to string format for display + val paramsStr = params.entries.joinToString(" ") { (key, value) -> + "$key=\"$value\"" + } + val toolInfo = formatToolCallDisplay(toolName, paramsStr) + val toolType = toolName.toToolType() + + // Convert Map to Map for internal use + val stringParams = params.mapValues { it.value.toString() } + + // Handle task-boundary tool - update task list + if (toolName == "task-boundary") { + updateTaskFromToolCall(stringParams) + } + + // Handle plan management tool - update plan state with original params + if (toolName == "plan") { + updatePlanFromToolCallWithAnyParams(params) + } + + // Skip rendering plan tool to timeline - it's shown in PlanSummaryBar + if (toolName != "plan") { + renderToolCallInternal(toolName, toolInfo, stringParams, paramsStr, toolType) + } + } + + /** + * Internal method to render tool call UI elements + */ + private fun renderToolCallInternal( + toolName: String, + toolInfo: ToolCallInfo, + params: Map, + paramsStr: String, + toolType: ToolType? + ) { // Extract file path for read/write operations val filePath = when (toolType) { @@ -220,6 +282,82 @@ class ComposeRenderer : BaseRenderer() { } } + /** + * Update plan state from plan management tool call (string params version) + */ + private fun updatePlanFromToolCall(params: Map) { + val action = params["action"]?.uppercase() ?: return + val planMarkdown = params["planMarkdown"] ?: "" + val taskIndex = params["taskIndex"]?.toIntOrNull() + val stepIndex = params["stepIndex"]?.toIntOrNull() + + updatePlanState(action, planMarkdown, taskIndex, stepIndex) + } + + /** + * Update plan state from plan management tool call with Any params. + * This is the preferred method as it handles complex values correctly. + */ + private fun updatePlanFromToolCallWithAnyParams(params: Map) { + val action = (params["action"] as? String)?.uppercase() ?: return + val planMarkdown = params["planMarkdown"] as? String ?: "" + val taskIndex = when (val v = params["taskIndex"]) { + is Number -> v.toInt() + is String -> v.toIntOrNull() + else -> null + } + val stepIndex = when (val v = params["stepIndex"]) { + is Number -> v.toInt() + is String -> v.toIntOrNull() + else -> null + } + + updatePlanState(action, planMarkdown, taskIndex, stepIndex) + } + + /** + * Internal method to update plan state + */ + private fun updatePlanState(action: String, planMarkdown: String, taskIndex: Int?, stepIndex: Int?) { + when (action) { + "CREATE", "UPDATE" -> { + if (planMarkdown.isNotBlank()) { + _currentPlan = MarkdownPlanParser.parseToPlan(planMarkdown) + } + } + "COMPLETE_STEP" -> { + if (taskIndex == null || stepIndex == null) return + _currentPlan?.let { plan -> + if (taskIndex in 1..plan.tasks.size) { + val task = plan.tasks[taskIndex - 1] + if (stepIndex in 1..task.steps.size) { + val step = task.steps[stepIndex - 1] + step.complete() + task.updateStatusFromSteps() + // Trigger recomposition by creating a new plan instance + _currentPlan = plan.copy(updatedAt = Clock.System.now().toEpochMilliseconds()) + } + } + } + } + "FAIL_STEP" -> { + if (taskIndex == null || stepIndex == null) return + _currentPlan?.let { plan -> + if (taskIndex in 1..plan.tasks.size) { + val task = plan.tasks[taskIndex - 1] + if (stepIndex in 1..task.steps.size) { + val step = task.steps[stepIndex - 1] + step.fail() + task.updateStatusFromSteps() + _currentPlan = plan.copy(updatedAt = Clock.System.now().toEpochMilliseconds()) + } + } + } + } + // VIEW action doesn't modify state + } + } + override fun renderToolResult( toolName: String, success: Boolean, @@ -243,6 +381,10 @@ class ComposeRenderer : BaseRenderer() { // Extract command from the last tool call if available val command = _currentToolCall?.details?.removePrefix("Executing: ") ?: "unknown" + // IMPORTANT: Clear currentToolCall FIRST to avoid showing both + // CurrentToolCallItem and the result item simultaneously (double progress bar issue) + _currentToolCall = null + // For Live sessions, we show both the terminal widget and the result summary // Don't remove anything, just add a result item after the live terminal if (isLiveSession) { @@ -272,6 +414,10 @@ class ComposeRenderer : BaseRenderer() { ) } } else { + // IMPORTANT: Clear currentToolCall FIRST to avoid showing both + // CurrentToolCallItem and the result item simultaneously (double progress bar issue) + _currentToolCall = null + // Update the last ToolCallItem with result information val lastItem = _timeline.lastOrNull() if (lastItem is ToolCallItem && lastItem.success == null) { @@ -317,8 +463,6 @@ class ComposeRenderer : BaseRenderer() { ) } } - - _currentToolCall = null } override fun renderTaskComplete() { diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/PlanPanel.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/PlanPanel.kt new file mode 100644 index 0000000000..019cbba260 --- /dev/null +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/PlanPanel.kt @@ -0,0 +1,232 @@ +package cc.unitmesh.devins.ui.compose.agent + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Assignment +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Badge +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import cc.unitmesh.agent.plan.AgentPlan +import cc.unitmesh.agent.plan.PlanStep +import cc.unitmesh.agent.plan.PlanTask +import cc.unitmesh.agent.plan.TaskStatus + +val TaskStatus.planColor: Color + get() = when (this) { + TaskStatus.TODO -> Color(0xFF9E9E9E) + TaskStatus.IN_PROGRESS -> Color(0xFF2196F3) + TaskStatus.COMPLETED -> Color(0xFF4CAF50) + TaskStatus.FAILED -> Color(0xFFF44336) + TaskStatus.BLOCKED -> Color(0xFFFF9800) + } + +@Composable +fun TaskStatus.planIcon(): Unit = when (this) { + TaskStatus.TODO -> Icon(Icons.Default.RadioButtonUnchecked, null, tint = planColor) + TaskStatus.IN_PROGRESS -> Icon(Icons.Default.Refresh, null, tint = planColor) + TaskStatus.COMPLETED -> Icon(Icons.Default.CheckCircle, null, tint = planColor) + TaskStatus.FAILED -> Icon(Icons.Default.Error, null, tint = planColor) + TaskStatus.BLOCKED -> Icon(Icons.Default.Warning, null, tint = planColor) +} + +@Composable +fun PlanPanel( + plan: AgentPlan?, + modifier: Modifier = Modifier, + onClose: () -> Unit = {}, + onStepClick: ((taskId: String, stepId: String) -> Unit)? = null +) { + Card( + modifier = modifier, + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) { + PlanPanelHeader(plan = plan, onClose = onClose) + HorizontalDivider() + if (plan == null || plan.tasks.isEmpty()) { + EmptyPlanContent() + } else { + PlanContent(plan = plan, onStepClick = onStepClick) + } + } + } +} + +@Composable +private fun PlanPanelHeader(plan: AgentPlan?, onClose: () -> Unit) { + Surface(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Default.Assignment, contentDescription = "Plan", tint = MaterialTheme.colorScheme.primary) + Text("Plan", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + if (plan != null) { + Badge(containerColor = MaterialTheme.colorScheme.primaryContainer) { Text("${plan.progressPercent}%") } + } + } + IconButton(onClick = onClose, modifier = Modifier.size(24.dp)) { + Icon(Icons.Default.Close, contentDescription = "Close", modifier = Modifier.size(18.dp)) + } + } + } +} + +@Composable +private fun EmptyPlanContent() { + Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { + Icon(Icons.Default.Assignment, null, Modifier.size(48.dp), MaterialTheme.colorScheme.outline) + Text("No active plan", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +private fun PlanContent(plan: AgentPlan, onStepClick: ((taskId: String, stepId: String) -> Unit)?) { + LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + items(plan.tasks, key = { it.id }) { task -> PlanTaskCard(task = task, onStepClick = onStepClick) } + } +} + +@Composable +private fun PlanTaskCard(task: PlanTask, onStepClick: ((taskId: String, stepId: String) -> Unit)?) { + var expanded by remember { mutableStateOf(true) } + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = task.status.planColor.copy(alpha = 0.08f)) + ) { + Column(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth().clickable { expanded = !expanded }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f) + ) { + Box(modifier = Modifier.size(20.dp)) { task.status.planIcon() } + Text( + task.title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "${task.completedStepCount}/${task.totalStepCount}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Icon( + if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + if (expanded) "Collapse" else "Expand", + Modifier.size(20.dp), + MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + AnimatedVisibility(visible = expanded && task.steps.isNotEmpty()) { + Column( + modifier = Modifier.fillMaxWidth().padding(start = 28.dp, top = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + task.steps.forEach { step -> + PlanStepItem(step = step, onClick = { onStepClick?.invoke(task.id, step.id) }) + } + } + } + } + } +} + +@Composable +private fun PlanStepItem(step: PlanStep, onClick: (() -> Unit)?) { + val infiniteTransition = rememberInfiniteTransition() + val angle by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable(animation = tween(2000, easing = LinearEasing), repeatMode = RepeatMode.Restart) + ) + Row( + modifier = Modifier.fillMaxWidth() + .then(if (onClick != null) Modifier.clickable { onClick() } else Modifier) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box(modifier = Modifier.size(16.dp), contentAlignment = Alignment.Center) { + when (step.status) { + TaskStatus.IN_PROGRESS -> Icon(Icons.Default.Refresh, null, Modifier.size(14.dp).rotate(angle), step.status.planColor) + TaskStatus.COMPLETED -> Icon(Icons.Default.Check, null, Modifier.size(14.dp), step.status.planColor) + TaskStatus.FAILED -> Icon(Icons.Default.Close, null, Modifier.size(14.dp), step.status.planColor) + else -> Box(modifier = Modifier.size(10.dp).background(step.status.planColor.copy(alpha = 0.3f), CircleShape)) + } + } + Text( + step.description, + style = MaterialTheme.typography.bodySmall, + color = if (step.status == TaskStatus.COMPLETED) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, + textDecoration = if (step.status == TaskStatus.COMPLETED) TextDecoration.LineThrough else TextDecoration.None, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } +} + diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/BottomToolbar.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/BottomToolbar.kt index 2d74686e19..ec17beaca7 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/BottomToolbar.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/BottomToolbar.kt @@ -25,6 +25,8 @@ fun BottomToolbar( isExecuting: Boolean = false, onStopClick: () -> Unit = {}, onAtClick: () -> Unit = {}, + onEnhanceClick: () -> Unit = {}, + isEnhancing: Boolean = false, onSettingsClick: () -> Unit = {}, workspacePath: String? = null, totalTokenInfo: cc.unitmesh.llm.compression.TokenInfo? = null, @@ -149,6 +151,28 @@ fun BottomToolbar( ) } + // Prompt Enhancement button (Ctrl+P) + IconButton( + onClick = onEnhanceClick, + enabled = !isEnhancing, + modifier = Modifier.size(36.dp) + ) { + if (isEnhancing) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + } else { + Icon( + imageVector = AutoDevComposeIcons.AutoAwesome, + contentDescription = "Enhance Prompt (Ctrl+P)", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + } + } + IconButton( onClick = onSettingsClick, modifier = Modifier.size(36.dp) diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/DevInEditorInput.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/DevInEditorInput.kt index b693d16301..e3bc46f211 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/DevInEditorInput.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/DevInEditorInput.kt @@ -12,13 +12,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.* +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.TextFieldValue @@ -33,10 +30,17 @@ import cc.unitmesh.devins.completion.CompletionItem import cc.unitmesh.devins.completion.CompletionManager import cc.unitmesh.devins.completion.CompletionTriggerType import cc.unitmesh.devins.editor.EditorCallbacks +import cc.unitmesh.devins.editor.FileContext import cc.unitmesh.devins.ui.compose.config.ToolConfigDialog import cc.unitmesh.devins.ui.compose.editor.changes.FileChangeSummary import cc.unitmesh.devins.ui.compose.editor.completion.CompletionPopup +import cc.unitmesh.devins.ui.compose.editor.plan.PlanSummaryBar import cc.unitmesh.devins.ui.compose.editor.completion.CompletionTrigger +import cc.unitmesh.devins.ui.compose.editor.context.FileSearchPopup +import cc.unitmesh.devins.ui.compose.editor.context.FileSearchProvider +import cc.unitmesh.devins.ui.compose.editor.context.SelectedFileItem +import cc.unitmesh.devins.ui.compose.editor.context.TopToolbar +import cc.unitmesh.devins.ui.compose.editor.context.WorkspaceFileSearchProvider import cc.unitmesh.devins.ui.compose.editor.highlighting.DevInSyntaxHighlighter import cc.unitmesh.devins.ui.config.ConfigManager import cc.unitmesh.devins.ui.compose.sketch.getUtf8FontFamily @@ -74,7 +78,8 @@ fun DevInEditorInput( modifier: Modifier = Modifier, onModelConfigChange: (ModelConfig) -> Unit = {}, dismissKeyboardOnSend: Boolean = true, - renderer: cc.unitmesh.devins.ui.compose.agent.ComposeRenderer? = null + renderer: cc.unitmesh.devins.ui.compose.agent.ComposeRenderer? = null, + fileSearchProvider: FileSearchProvider? = null ) { var textFieldValue by remember { mutableStateOf(TextFieldValue(initialText)) } var highlightedText by remember { mutableStateOf(initialText) } @@ -94,6 +99,43 @@ fun DevInEditorInput( var mcpServers by remember { mutableStateOf>(emptyMap()) } val mcpClientManager = remember { McpClientManager() } + // File context state (for TopToolbar) + var selectedFiles by remember { mutableStateOf>(emptyList()) } + var autoAddCurrentFile by remember { mutableStateOf(true) } + + // File search provider - use WorkspaceFileSearchProvider as default if not provided + val effectiveSearchProvider = remember { fileSearchProvider ?: WorkspaceFileSearchProvider() } + + // Helper function to convert SelectedFileItem to FileContext + fun getFileContexts(): List = selectedFiles.map { file -> + FileContext( + name = file.name, + path = file.path, + relativePath = file.relativePath, + isDirectory = file.isDirectory + ) + } + + /** + * Build and send message with file references (like IDEA's buildAndSendMessage). + * Appends DevIns commands for selected files to the message. + */ + fun buildAndSendMessage(text: String) { + if (text.isBlank()) return + + // Generate DevIns commands for selected files + val filesText = selectedFiles.joinToString("\n") { it.toDevInsCommand() } + val fullText = if (filesText.isNotEmpty()) "$text\n$filesText" else text + + // Send with file contexts + callbacks?.onSubmit(fullText, getFileContexts()) + + // Clear input and files + textFieldValue = TextFieldValue("") + selectedFiles = emptyList() + showCompletion = false + } + val highlighter = remember { DevInSyntaxHighlighter() } val manager = completionManager ?: remember { CompletionManager() } val focusRequester = remember { FocusRequester() } @@ -109,36 +151,27 @@ fun DevInEditorInput( val inputLineHeight = if (isAndroid && isCompactMode) 24.sp else 22.sp val maxLines = if (isAndroid && isCompactMode) 5 else 8 - // iOS: Use flexible sizing (wrapContent + maxHeight) to avoid keyboard constraint conflicts + // iOS: Use smaller, fixed height to avoid keyboard issues // Android/Desktop: Use minHeight for touch targets + maxHeight for bounds - val minHeight = if (Platform.isIOS) { - null // iOS uses natural content height to avoid keyboard conflicts - } else if (isCompactMode) { - if (isAndroid) 52.dp else 56.dp - } else { - 80.dp + val minHeight = when { + Platform.isIOS -> 44.dp // iOS: standard touch target height + isCompactMode && isAndroid -> 52.dp + isCompactMode -> 56.dp + else -> 80.dp } - val maxHeight = if (isCompactMode) { - when { - Platform.isIOS -> 160.dp // iOS: generous max height, flexible min - isAndroid -> 120.dp - else -> 96.dp - } - } else { - when { - Platform.isIOS -> 200.dp // iOS: more room in non-compact mode - else -> 160.dp - } + val maxHeight = when { + Platform.isIOS && isCompactMode -> 80.dp // iOS compact: smaller max + Platform.isIOS -> 100.dp // iOS: reduced max height + isCompactMode && isAndroid -> 120.dp + isCompactMode -> 96.dp + else -> 160.dp } - val padding = if (isCompactMode) { - when { - Platform.isIOS -> 14.dp // iOS: slightly more padding for comfort - else -> 12.dp - } - } else { - 20.dp + val padding = when { + Platform.isIOS -> 10.dp // iOS: smaller padding + isCompactMode -> 12.dp + else -> 20.dp } // Initialize MCP client manager with config @@ -268,9 +301,7 @@ fun DevInEditorInput( ) { scope.launch { delay(100) // Small delay to ensure UI updates - callbacks?.onSubmit(trimmedText) - textFieldValue = TextFieldValue("") - showCompletion = false + buildAndSendMessage(trimmedText) } return } @@ -393,9 +424,7 @@ fun DevInEditorInput( // 桌面端:Enter 发送消息(但不在移动端拦截) !isAndroid && !Platform.isIOS && event.key == Key.Enter && !event.isShiftPressed -> { if (textFieldValue.text.isNotBlank()) { - callbacks?.onSubmit(textFieldValue.text) - textFieldValue = TextFieldValue("") - showCompletion = false + buildAndSendMessage(textFieldValue.text) if (dismissKeyboardOnSend) { focusManager.clearFocus() } @@ -427,6 +456,12 @@ fun DevInEditorInput( ), verticalArrangement = Arrangement.spacedBy(4.dp) ) { + // Plan Summary Bar - shown above file changes when a plan is active + PlanSummaryBar( + plan = renderer?.currentPlan, + modifier = Modifier.fillMaxWidth() + ) + // File Change Summary - shown above the editor FileChangeSummary() @@ -448,22 +483,26 @@ fun DevInEditorInput( Column( modifier = Modifier.fillMaxWidth() ) { + // Top toolbar with file context management (desktop only) + if (!isMobile) { + TopToolbar( + selectedFiles = selectedFiles, + onAddFile = { file -> selectedFiles = selectedFiles + file }, + onRemoveFile = { file -> + selectedFiles = selectedFiles.filter { it.path != file.path } + }, + onClearFiles = { selectedFiles = emptyList() }, + autoAddCurrentFile = autoAddCurrentFile, + onToggleAutoAdd = { autoAddCurrentFile = !autoAddCurrentFile }, + searchProvider = effectiveSearchProvider + ) + } + Box( modifier = Modifier .fillMaxWidth() - .then( - // iOS: Use wrapContentHeight + maxHeight only (no minHeight) - // to avoid conflicts with keyboard constraints - if (Platform.isIOS) { - Modifier - .wrapContentHeight() - .heightIn(max = maxHeight) - } else { - // Android/Desktop: Use traditional min/max constraints - Modifier.heightIn(min = minHeight!!, max = maxHeight) - } - ) + .heightIn(min = minHeight, max = maxHeight) .padding(padding) ) { BasicTextField( @@ -483,29 +522,16 @@ fun DevInEditorInput( .onPreviewKeyEvent { handleKeyEvent(it) }, textStyle = TextStyle( - fontFamily = getUtf8FontFamily(), + fontFamily = FontFamily.Monospace, fontSize = inputFontSize, - color = MaterialTheme.colorScheme.onSurface, + // 使用透明颜色,避免与高亮文本重叠产生重影 + color = Color.Transparent, lineHeight = inputLineHeight ), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), maxLines = maxLines, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = if (isMobile) ImeAction.Send else ImeAction.Default - ), - keyboardActions = KeyboardActions( - onSend = { - if (textFieldValue.text.isNotBlank()) { - callbacks?.onSubmit(textFieldValue.text) - textFieldValue = TextFieldValue("") - showCompletion = false - if (dismissKeyboardOnSend) { - focusManager.clearFocus() - } - } - } - ), + // 移除 KeyboardOptions 和 KeyboardActions,使用系统默认行为 + // 避免在某些平台上导致键盘弹出异常 decorationBox = { innerTextField -> Box( modifier = @@ -541,15 +567,8 @@ fun DevInEditorInput( ) } - // 实际的输入框(透明) - Box( - modifier = - Modifier - .fillMaxWidth() - .wrapContentHeight() - ) { - innerTextField() - } + // 实际的输入框(透明文本,只保留光标和选择) + innerTextField() } } ) @@ -577,9 +596,7 @@ fun DevInEditorInput( BottomToolbar( onSendClick = { if (textFieldValue.text.isNotBlank()) { - callbacks?.onSubmit(textFieldValue.text) - textFieldValue = TextFieldValue("") - showCompletion = false + buildAndSendMessage(textFieldValue.text) // Force dismiss keyboard on mobile if (isMobile) { focusManager.clearFocus() @@ -620,6 +637,8 @@ fun DevInEditorInput( } } }, + onEnhanceClick = { enhanceCurrentInput() }, + isEnhancing = isEnhancing, onSettingsClick = { showToolConfig = true }, diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/context/FileChip.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/context/FileChip.kt new file mode 100644 index 0000000000..0ca49415a1 --- /dev/null +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/context/FileChip.kt @@ -0,0 +1,174 @@ +package cc.unitmesh.devins.ui.compose.editor.context + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.devins.ui.compose.icons.AutoDevComposeIcons + +/** + * FileChip Component + * + * Displays a selected file as a removable chip. + * Similar to IdeaTopToolbar's FileChip - shows remove button only on hover. + */ +@Composable +fun FileChip( + file: SelectedFileItem, + onRemove: () -> Unit, + showPath: Boolean = false, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + + Surface( + modifier = modifier + .clip(RoundedCornerShape(4.dp)) + .hoverable(interactionSource = interactionSource) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + shape = RoundedCornerShape(4.dp) + ), + shape = RoundedCornerShape(4.dp), + color = if (isHovered) { + MaterialTheme.colorScheme.surfaceVariant + } else { + MaterialTheme.colorScheme.surface.copy(alpha = 0.8f) + }, + contentColor = MaterialTheme.colorScheme.onSurface + ) { + Row( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + // File/Folder icon + Icon( + imageVector = if (file.isDirectory) AutoDevComposeIcons.Folder else AutoDevComposeIcons.InsertDriveFile, + contentDescription = if (file.isDirectory) "Folder" else "File", + modifier = Modifier.size(14.dp), + tint = if (file.isDirectory) MaterialTheme.colorScheme.primary else LocalContentColor.current + ) + + // File name + Text( + text = file.name, + style = MaterialTheme.typography.labelSmall, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + // Optional path (truncated) + if (showPath && file.truncatedPath.isNotEmpty()) { + Text( + text = file.truncatedPath, + style = MaterialTheme.typography.labelSmall, + fontSize = 10.sp, + color = LocalContentColor.current.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 100.dp) + ) + } + + // Remove button - only show on hover (like IDEA version) + if (isHovered) { + Icon( + imageVector = AutoDevComposeIcons.Close, + contentDescription = "Remove from context", + modifier = Modifier + .size(14.dp) + .clickable(onClick = onRemove), + tint = LocalContentColor.current.copy(alpha = 0.6f) + ) + } + } + } +} + +/** + * Expanded FileChip for vertical list view. + * Similar to IdeaTopToolbar's FileChipExpanded - shows full path and hover effect. + */ +@Composable +fun FileChipExpanded( + file: SelectedFileItem, + onRemove: () -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .hoverable(interactionSource = interactionSource) + .background( + if (isHovered) MaterialTheme.colorScheme.surfaceVariant + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // File/Folder icon + Icon( + imageVector = if (file.isDirectory) AutoDevComposeIcons.Folder else AutoDevComposeIcons.InsertDriveFile, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + + // File info + Column(modifier = Modifier.weight(1f)) { + Text( + text = file.name, + style = MaterialTheme.typography.bodySmall, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = file.path, + style = MaterialTheme.typography.labelSmall, + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + // Remove button - always visible in expanded mode but with hover effect + Icon( + imageVector = AutoDevComposeIcons.Close, + contentDescription = "Remove from context", + modifier = Modifier + .size(16.dp) + .clickable(onClick = onRemove), + tint = if (isHovered) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + } + ) + } +} + diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/context/FileSearchPopup.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/context/FileSearchPopup.kt new file mode 100644 index 0000000000..a029e09ac7 --- /dev/null +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/context/FileSearchPopup.kt @@ -0,0 +1,389 @@ +package cc.unitmesh.devins.ui.compose.editor.context + +import androidx.compose.foundation.background +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.* +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.devins.ui.compose.icons.AutoDevComposeIcons +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** File search provider interface for platform-specific implementations */ +interface FileSearchProvider { + suspend fun searchFiles(query: String): List + suspend fun getRecentFiles(): List +} + +/** Default file search provider that returns empty results */ +object DefaultFileSearchProvider : FileSearchProvider { + override suspend fun searchFiles(query: String): List = emptyList() + override suspend fun getRecentFiles(): List = emptyList() +} + +/** + * FileSearchPopup - A dropdown menu for searching and selecting files to add to context. + * Similar to IDEA's IdeaFileSearchPopup using DropdownMenu. + * + * @param expanded Whether the dropdown is expanded + * @param onDismiss Called when the dropdown should be dismissed + * @param onSelectFile Called when a file is selected + * @param selectedFiles Currently selected files (to filter from results) + * @param searchProvider Provider for file search functionality + */ +@Composable +fun FileSearchPopup( + expanded: Boolean, + onDismiss: () -> Unit, + onSelectFile: (SelectedFileItem) -> Unit, + selectedFiles: List, + searchProvider: FileSearchProvider = DefaultFileSearchProvider, + modifier: Modifier = Modifier +) { + var searchQuery by remember { mutableStateOf("") } + var searchResults by remember { mutableStateOf>(emptyList()) } + var recentFiles by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + val scope = rememberCoroutineScope() + + // Observe workspace changes + val currentWorkspace by cc.unitmesh.devins.workspace.WorkspaceManager.workspaceFlow.collectAsState() + + // Observe indexing state if using WorkspaceFileSearchProvider + val indexingState = if (searchProvider is WorkspaceFileSearchProvider) { + searchProvider.indexingState.collectAsState().value + } else { + IndexingState.READY + } + + // Build index when popup opens + LaunchedEffect(expanded, currentWorkspace) { + if (expanded && currentWorkspace != null && searchProvider is WorkspaceFileSearchProvider) { + searchProvider.buildIndex() + } + } + + // Load recent files when popup opens + LaunchedEffect(expanded, indexingState) { + if (expanded && indexingState == IndexingState.READY) { + searchQuery = "" + searchResults = emptyList() + isLoading = false + recentFiles = searchProvider.getRecentFiles() + delay(100) + try { focusRequester.requestFocus() } catch (_: Exception) {} + } + } + + // Debounced search function + fun performSearch(query: String) { + if (query.length < 2 || currentWorkspace == null || indexingState != IndexingState.READY) { + searchResults = emptyList() + isLoading = false + return + } + + isLoading = true + scope.launch { + delay(150) // Debounce + try { + searchResults = searchProvider.searchFiles(query) + } catch (e: Exception) { + searchResults = emptyList() + } finally { + isLoading = false + } + } + } + + // Filter out already selected files + val displayItems = remember(searchQuery, searchResults, recentFiles, selectedFiles) { + val items = if (searchQuery.length >= 2) searchResults else recentFiles + items.filter { item -> selectedFiles.none { it.path == item.path } } + } + + // Separate files and folders + val files = displayItems.filter { !it.isDirectory } + val folders = displayItems.filter { it.isDirectory } + + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss, + modifier = modifier.widthIn(min = 300.dp, max = 400.dp), + offset = DpOffset(0.dp, 4.dp) + ) { + // Search field at top + SearchField( + value = searchQuery, + onValueChange = { + searchQuery = it + performSearch(it) + }, + focusRequester = focusRequester, + onDismiss = onDismiss + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + // Content based on state + when { + currentWorkspace == null -> { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "No workspace opened", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + } + indexingState == IndexingState.INDEXING -> { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Indexing files...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + } + indexingState == IndexingState.ERROR -> { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Failed to index files", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f) + ) + } + } + isLoading -> { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + } + displayItems.isEmpty() -> { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = if (searchQuery.length >= 2) "No files found" else "Type to search...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + } + searchQuery.length < 2 -> { + // Show recent files + SectionHeader(icon = AutoDevComposeIcons.History, title = "Recent Files") + displayItems.take(8).forEach { item -> + FileMenuItem( + item = item, + showHistoryIcon = true, + onClick = { onSelectFile(item); onDismiss() } + ) + } + } + else -> { + // Show search results grouped + if (files.isNotEmpty()) { + SectionHeader(title = "Files (${files.size})") + files.take(8).forEach { file -> + FileMenuItem(item = file, onClick = { onSelectFile(file); onDismiss() }) + } + if (files.size > 8) { + MoreItemsHint(count = files.size - 8) + } + } + if (folders.isNotEmpty()) { + if (files.isNotEmpty()) { + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + } + SectionHeader(title = "Folders (${folders.size})") + folders.take(5).forEach { folder -> + FileMenuItem(item = folder, onClick = { onSelectFile(folder); onDismiss() }) + } + } + } + } + } +} + +/** Search field for the dropdown menu */ +@Composable +private fun SearchField( + value: String, + onValueChange: (String) -> Unit, + focusRequester: FocusRequester, + onDismiss: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = AutoDevComposeIcons.Search, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester) + .onKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.Escape) { + onDismiss() + true + } else false + }, + textStyle = TextStyle( + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + singleLine = true, + decorationBox = { innerTextField -> + Box { + if (value.isEmpty()) { + Text( + "Search files and folders...", + style = TextStyle(fontSize = 13.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + innerTextField() + } + } + ) + } +} + +/** Section header for dropdown menu */ +@Composable +private fun SectionHeader( + title: String, + icon: androidx.compose.ui.graphics.vector.ImageVector? = null +) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + Text( + text = title, + style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } +} + +/** File menu item for dropdown */ +@Composable +private fun FileMenuItem( + item: SelectedFileItem, + showHistoryIcon: Boolean = false, + onClick: () -> Unit +) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + + DropdownMenuItem( + text = { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = item.name, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (item.truncatedPath.isNotEmpty()) { + Text( + text = item.truncatedPath, + style = MaterialTheme.typography.bodySmall, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + }, + onClick = onClick, + leadingIcon = { + Icon( + imageVector = when { + showHistoryIcon -> AutoDevComposeIcons.History + item.isDirectory -> AutoDevComposeIcons.Folder + else -> AutoDevComposeIcons.InsertDriveFile + }, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + }, + modifier = Modifier + .hoverable(interactionSource = interactionSource) + .background( + if (isHovered) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.surface + ) + ) +} + +/** Hint showing more items available */ +@Composable +private fun MoreItemsHint(count: Int) { + Text( + text = "... and $count more", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) +} diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/context/SelectedFileItem.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/context/SelectedFileItem.kt new file mode 100644 index 0000000000..f8bebd7eed --- /dev/null +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/context/SelectedFileItem.kt @@ -0,0 +1,76 @@ +package cc.unitmesh.devins.ui.compose.editor.context + +/** + * Represents a selected file in the file context. + * Used by TopToolbar and FileChip components. + * + * Similar to IDEA's SelectedFileItem but platform-agnostic. + */ +data class SelectedFileItem( + val name: String, + val path: String, + val relativePath: String = name, + val isDirectory: Boolean = false, + val isRecentFile: Boolean = false +) { + /** + * Generate the DevIns command for this file/folder. + * Uses /dir: for directories and /file: for files. + */ + fun toDevInsCommand(): String { + return if (isDirectory) "/dir:$path" else "/file:$path" + } + + /** + * Truncated path for display, e.g., "...cc/unitmesh/devins/idea/editor" + * Shows the parent directory path without the file name, truncated if too long. + */ + val truncatedPath: String + get() { + val parentPath = relativePath.substringBeforeLast("/", "") + if (parentPath.isEmpty()) return "" + if (parentPath.length <= 40) return parentPath + + val parts = parentPath.split("/") + if (parts.size <= 2) return "...$parentPath" + + val keepParts = parts.takeLast(4) + return "...${keepParts.joinToString("/")}" + } + + companion object { + /** + * Create a SelectedFileItem from a file path. + * Extracts the file name from the path. + */ + fun fromPath(path: String, isDirectory: Boolean = false, isRecent: Boolean = false): SelectedFileItem { + val name = path.substringAfterLast('/').ifEmpty { + path.substringAfterLast('\\') + }.ifEmpty { path } + return SelectedFileItem( + name = name, + path = path, + relativePath = path, + isDirectory = isDirectory, + isRecentFile = isRecent + ) + } + } +} + +/** + * Truncate path for display, showing last 3-4 parts. + */ +fun truncatePath(path: String, maxLength: Int = 30): String { + val parentPath = path.substringBeforeLast('/') + if (parentPath.isEmpty() || parentPath == path) return "" + + if (parentPath.length <= maxLength) return parentPath + + val parts = parentPath.split('/') + if (parts.size <= 2) return "...$parentPath" + + val keepParts = parts.takeLast(3) + return ".../${keepParts.joinToString("/")}" +} + diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/context/TopToolbar.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/context/TopToolbar.kt new file mode 100644 index 0000000000..5a188cf612 --- /dev/null +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/context/TopToolbar.kt @@ -0,0 +1,223 @@ +package cc.unitmesh.devins.ui.compose.editor.context + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.devins.ui.compose.icons.AutoDevComposeIcons + +/** + * TopToolbar Component + * + * Displays file context management toolbar with add file button and selected files. + * Similar to TopToolbar.tsx from mpp-vscode and IdeaTopToolbar.kt from mpp-idea. + */ +@Composable +fun TopToolbar( + selectedFiles: List, + onAddFile: (SelectedFileItem) -> Unit, + onRemoveFile: (SelectedFileItem) -> Unit, + onClearFiles: () -> Unit, + autoAddCurrentFile: Boolean = true, + onToggleAutoAdd: () -> Unit = {}, + searchProvider: FileSearchProvider = DefaultFileSearchProvider, + modifier: Modifier = Modifier +) { + var isExpanded by remember { mutableStateOf(false) } + var showFileSearch by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() + + Column(modifier = modifier.fillMaxWidth()) { + // Main toolbar row + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Add file button with dropdown + Box { + if (selectedFiles.isEmpty()) { + // Full button when no files selected + TextButton( + onClick = { showFileSearch = true }, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + modifier = Modifier.height(28.dp) + ) { + Icon( + imageVector = AutoDevComposeIcons.Add, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Add context", + style = MaterialTheme.typography.labelSmall + ) + } + } else { + // Icon-only button when files are selected + IconButton( + onClick = { showFileSearch = true }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = AutoDevComposeIcons.Add, + contentDescription = "Add file to context", + modifier = Modifier.size(16.dp) + ) + } + } + + // File search dropdown menu + FileSearchPopup( + expanded = showFileSearch, + onDismiss = { showFileSearch = false }, + onSelectFile = { file -> + onAddFile(file) + showFileSearch = false + }, + selectedFiles = selectedFiles, + searchProvider = searchProvider + ) + } + + // File chips (horizontal scroll when collapsed) + if (selectedFiles.isNotEmpty() && !isExpanded) { + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(scrollState), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + selectedFiles.take(5).forEach { file -> + FileChip( + file = file, + onRemove = { onRemoveFile(file) } + ) + } + if (selectedFiles.size > 5) { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Text( + text = "+${selectedFiles.size - 5} more", + style = MaterialTheme.typography.labelSmall, + fontSize = 10.sp, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp) + ) + } + } + } + } else if (selectedFiles.isEmpty()) { + Spacer(modifier = Modifier.weight(1f)) + } + + // Context indicator (auto-add toggle) + ContextIndicator( + isActive = autoAddCurrentFile, + onClick = onToggleAutoAdd + ) + + // Expand/Collapse button (only when multiple files) + if (selectedFiles.size > 1) { + IconButton( + onClick = { isExpanded = !isExpanded }, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = if (isExpanded) AutoDevComposeIcons.ExpandLess else AutoDevComposeIcons.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand", + modifier = Modifier.size(16.dp) + ) + } + } + + // Clear all button + if (selectedFiles.isNotEmpty()) { + IconButton( + onClick = onClearFiles, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = AutoDevComposeIcons.Clear, + contentDescription = "Clear all files", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // Expanded file list + AnimatedVisibility( + visible = isExpanded && selectedFiles.isNotEmpty(), + enter = expandVertically(), + exit = shrinkVertically() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + selectedFiles.forEach { file -> + FileChipExpanded( + file = file, + onRemove = { onRemoveFile(file) } + ) + } + } + } + } +} + +/** + * Context indicator showing auto-add current file status. + */ +@Composable +private fun ContextIndicator( + isActive: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + IconButton( + onClick = onClick, + modifier = modifier.size(24.dp) + ) { + Box { + Icon( + imageVector = AutoDevComposeIcons.InsertDriveFile, + contentDescription = if (isActive) "Auto-add current file: ON" else "Auto-add current file: OFF", + modifier = Modifier.size(14.dp), + tint = if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + } + ) + // Active indicator dot + if (isActive) { + Surface( + modifier = Modifier + .size(6.dp) + .align(Alignment.BottomEnd), + shape = RoundedCornerShape(3.dp), + color = MaterialTheme.colorScheme.primary + ) {} + } + } + } +} + diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/context/WorkspaceFileSearchProvider.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/context/WorkspaceFileSearchProvider.kt new file mode 100644 index 0000000000..5f6b3d3ad3 --- /dev/null +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/context/WorkspaceFileSearchProvider.kt @@ -0,0 +1,202 @@ +package cc.unitmesh.devins.ui.compose.editor.context + +import cc.unitmesh.agent.logging.AutoDevLogger +import cc.unitmesh.devins.workspace.Workspace +import cc.unitmesh.devins.workspace.WorkspaceManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext + +private const val TAG = "FileSearch" + +/** + * Indexing state for file search + */ +enum class IndexingState { + NOT_STARTED, + INDEXING, + READY, + ERROR +} + +/** + * File search provider that uses WorkspaceManager's file system with pre-built index. + * Builds an in-memory index of all files for fast searching. + */ +class WorkspaceFileSearchProvider( + private val recentFilesProvider: RecentFilesProvider? = null +) : FileSearchProvider { + + private val _indexingState = MutableStateFlow(IndexingState.NOT_STARTED) + val indexingState: StateFlow = _indexingState + + private var fileIndex: List = emptyList() + private var indexedWorkspacePath: String? = null + + data class IndexedFile( + val name: String, + val relativePath: String, + val isDirectory: Boolean + ) + + /** + * Build the file index for the current workspace. + * Should be called when workspace is opened. + */ + suspend fun buildIndex() = withContext(Dispatchers.Default) { + val workspace = WorkspaceManager.currentWorkspace + if (workspace == null) { + AutoDevLogger.warn(TAG) { "buildIndex: No workspace available" } + return@withContext + } + + val rootPath = workspace.rootPath + if (rootPath == null) { + AutoDevLogger.warn(TAG) { "buildIndex: No root path available" } + return@withContext + } + + AutoDevLogger.info(TAG) { "buildIndex: workspace=$rootPath, currentState=${_indexingState.value}" } + + // Skip if already indexed for this workspace + if (indexedWorkspacePath == rootPath && fileIndex.isNotEmpty()) { + AutoDevLogger.info(TAG) { "buildIndex: Already indexed ${fileIndex.size} files" } + _indexingState.value = IndexingState.READY + return@withContext + } + + _indexingState.value = IndexingState.INDEXING + AutoDevLogger.info(TAG) { "buildIndex: Starting indexing..." } + + try { + val files = mutableListOf() + indexFilesRecursively(workspace, "", files, maxDepth = 6) + fileIndex = files + indexedWorkspacePath = rootPath + _indexingState.value = IndexingState.READY + AutoDevLogger.info(TAG) { "Index built: ${files.size} files" } + } catch (e: Exception) { + AutoDevLogger.error(TAG) { "Index error: ${e.message}" } + e.printStackTrace() + _indexingState.value = IndexingState.ERROR + } + } + + private fun indexFilesRecursively( + workspace: Workspace, + currentPath: String, + files: MutableList, + maxDepth: Int, + currentDepth: Int = 0 + ) { + if (currentDepth >= maxDepth || files.size >= 5000) return + + val fileSystem = workspace.fileSystem + val pathToList = if (currentPath.isEmpty()) "." else currentPath + + try { + val entries = fileSystem.listFiles(pathToList, null) + if (currentDepth == 0) { + AutoDevLogger.info(TAG) { "indexFilesRecursively: root entries=${entries.size}" } + } + for (entry in entries) { + val name = entry.substringAfterLast('/') + + // Skip hidden files and common ignored directories + if (name.startsWith(".") || name in IGNORED_DIRS) continue + + val isDir = fileSystem.isDirectory(fileSystem.resolvePath(entry)) + files.add(IndexedFile(name, entry, isDir)) + + if (isDir && files.size < 5000) { + indexFilesRecursively(workspace, entry, files, maxDepth, currentDepth + 1) + } + } + } catch (e: Exception) { + AutoDevLogger.error(TAG) { "indexFilesRecursively error at '$pathToList': ${e.message}" } + } + } + + override suspend fun searchFiles(query: String): List = withContext(Dispatchers.Default) { + val workspace = WorkspaceManager.currentWorkspace + if (workspace == null) { + AutoDevLogger.warn(TAG) { "searchFiles: No workspace available" } + return@withContext emptyList() + } + val fileSystem = workspace.fileSystem + + // Build index if not ready + if (_indexingState.value != IndexingState.READY) { + AutoDevLogger.info(TAG) { "searchFiles: Index not ready, building..." } + buildIndex() + } + + val lowerQuery = query.lowercase() + AutoDevLogger.info(TAG) { "searchFiles: query='$query', indexSize=${fileIndex.size}" } + + val results = fileIndex + .filter { it.name.lowercase().contains(lowerQuery) } + .take(50) + .map { indexed -> + SelectedFileItem( + name = indexed.name, + path = fileSystem.resolvePath(indexed.relativePath), + relativePath = indexed.relativePath, + isDirectory = indexed.isDirectory + ) + } + .sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() })) + + AutoDevLogger.info(TAG) { "searchFiles: found ${results.size} results" } + results + } + + override suspend fun getRecentFiles(): List = withContext(Dispatchers.Default) { + recentFilesProvider?.getRecentFiles() ?: getDefaultRecentFiles() + } + + private fun getDefaultRecentFiles(): List { + val workspace = WorkspaceManager.currentWorkspace ?: return emptyList() + val fileSystem = workspace.fileSystem + + return try { + val rootFiles = fileSystem.listFiles(".", null).take(10) + rootFiles.mapNotNull { path -> + val name = path.substringAfterLast('/') + if (name.startsWith(".")) return@mapNotNull null + val isDir = fileSystem.isDirectory(fileSystem.resolvePath(path)) + SelectedFileItem(name = name, path = fileSystem.resolvePath(path), relativePath = path, isDirectory = isDir) + } + } catch (e: Exception) { + emptyList() + } + } + + companion object { + private val IGNORED_DIRS = setOf( + "node_modules", "build", "dist", "target", "out", + "__pycache__", ".gradle", ".idea", ".vscode" + ) + } +} + +/** Interface for platform-specific recent files tracking */ +interface RecentFilesProvider { + suspend fun getRecentFiles(): List + fun addRecentFile(file: SelectedFileItem) +} + +/** In-memory recent files provider */ +class InMemoryRecentFilesProvider(private val maxSize: Int = 20) : RecentFilesProvider { + private val recentFiles = mutableListOf() + + override suspend fun getRecentFiles(): List = recentFiles.toList() + + override fun addRecentFile(file: SelectedFileItem) { + recentFiles.removeAll { it.path == file.path } + recentFiles.add(0, file) + if (recentFiles.size > maxSize) recentFiles.removeAt(recentFiles.lastIndex) + } +} + diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/plan/PlanSummaryBar.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/plan/PlanSummaryBar.kt new file mode 100644 index 0000000000..369be9b3ab --- /dev/null +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/plan/PlanSummaryBar.kt @@ -0,0 +1,416 @@ +package cc.unitmesh.devins.ui.compose.editor.plan + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.agent.plan.AgentPlan +import cc.unitmesh.agent.plan.PlanStep +import cc.unitmesh.agent.plan.PlanTask +import cc.unitmesh.agent.plan.TaskStatus + +/** + * Plan Summary Bar Component + * + * Displays a collapsible summary of the current plan above the input box. + * Shows progress, current step, and allows expanding to see full plan details. + * + * Similar to FileChangeSummary component pattern. + */ +@Composable +fun PlanSummaryBar( + plan: AgentPlan?, + modifier: Modifier = Modifier, + onViewDetails: (() -> Unit)? = null, + onDismiss: (() -> Unit)? = null +) { + // Don't render if no plan + if (plan == null || plan.tasks.isEmpty()) { + return + } + + var isExpanded by remember { mutableStateOf(false) } + + Surface( + modifier = modifier, + shape = RoundedCornerShape(topEnd = 4.dp, topStart = 4.dp, bottomEnd = 0.dp, bottomStart = 0.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + color = when (plan.status) { + TaskStatus.FAILED -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) + TaskStatus.COMPLETED -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + }, + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + Column(modifier = Modifier.fillMaxWidth()) { + // Expanded content - placed ABOVE header so it expands upward + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(expandFrom = Alignment.Bottom), + exit = shrinkVertically(shrinkTowards = Alignment.Bottom) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + PlanExpandedContent(plan = plan) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } + + // Collapsed header - always at bottom + PlanSummaryHeader( + plan = plan, + isExpanded = isExpanded, + onExpandToggle = { isExpanded = !isExpanded }, + onDismiss = onDismiss + ) + } + } +} + +@Composable +private fun PlanSummaryHeader( + plan: AgentPlan, + isExpanded: Boolean, + onExpandToggle: () -> Unit, + onDismiss: (() -> Unit)? +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onExpandToggle() } + .padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Left side: icon, title, progress + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Status icon + PlanStatusIcon(status = plan.status) + + // Expand arrow + Icon( + imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.Default.KeyboardArrowRight, + contentDescription = if (isExpanded) "Collapse" else "Expand", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Title + Text( + text = plan.tasks.firstOrNull()?.title ?: "Plan", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + + // Progress indicator + PlanProgressBadge(plan = plan) + } + + // Right side: current step and dismiss + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Current step description (compact) + val currentStep = findCurrentStep(plan) + if (currentStep != null && !isExpanded) { + Text( + text = currentStep, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 150.dp) + ) + } + + // Dismiss button + if (onDismiss != null) { + IconButton( + onClick = onDismiss, + modifier = Modifier.size(20.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Dismiss", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Composable +private fun PlanStatusIcon(status: TaskStatus) { + val infiniteTransition = rememberInfiniteTransition() + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(2000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ) + ) + + when (status) { + TaskStatus.COMPLETED -> Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Completed", + modifier = Modifier.size(18.dp), + tint = Color(0xFF4CAF50) + ) + TaskStatus.FAILED -> Icon( + imageVector = Icons.Default.Error, + contentDescription = "Failed", + modifier = Modifier.size(18.dp), + tint = Color(0xFFF44336) + ) + TaskStatus.IN_PROGRESS -> Icon( + imageVector = Icons.Default.Sync, + contentDescription = "In Progress", + modifier = Modifier.size(18.dp).rotate(rotation), + tint = Color(0xFF2196F3) + ) + TaskStatus.BLOCKED -> Icon( + imageVector = Icons.Default.Warning, + contentDescription = "Blocked", + modifier = Modifier.size(18.dp), + tint = Color(0xFFFF9800) + ) + else -> Icon( + imageVector = Icons.Default.Assignment, + contentDescription = "Plan", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary + ) + } +} + +@Composable +private fun PlanProgressBadge(plan: AgentPlan) { + val totalSteps = plan.tasks.sumOf { it.totalStepCount } + val completedSteps = plan.tasks.sumOf { it.completedStepCount } + val progress = plan.progressPercent + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Progress bar + LinearProgressIndicator( + progress = { progress / 100f }, + modifier = Modifier + .width(60.dp) + .height(4.dp) + .clip(RoundedCornerShape(2.dp)), + color = when (plan.status) { + TaskStatus.COMPLETED -> Color(0xFF4CAF50) + TaskStatus.FAILED -> Color(0xFFF44336) + else -> Color(0xFF2196F3) + }, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + + // Progress text + Text( + text = "$completedSteps/$totalSteps", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp + ) + } +} + +@Composable +private fun PlanExpandedContent(plan: AgentPlan) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + items(plan.tasks, key = { it.id }) { task -> + TaskSummaryItem(task = task) + } + } +} + +@Composable +private fun TaskSummaryItem(task: PlanTask) { + var isExpanded by remember { mutableStateOf(true) } + + Column( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + RoundedCornerShape(4.dp) + ) + .padding(8.dp) + ) { + // Task header + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { isExpanded = !isExpanded }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.weight(1f) + ) { + StepStatusIcon(status = task.status, size = 14) + Text( + text = task.title, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Text( + text = "${task.completedStepCount}/${task.totalStepCount}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + } + + // Steps (if expanded) + AnimatedVisibility(visible = isExpanded && task.steps.isNotEmpty()) { + Column( + modifier = Modifier.padding(start = 20.dp, top = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + task.steps.forEach { step -> + StepItem(step = step) + } + } + } + } +} + +@Composable +private fun StepItem(step: PlanStep) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + StepStatusIcon(status = step.status, size = 12) + Text( + text = step.description, + style = MaterialTheme.typography.labelSmall, + color = when (step.status) { + TaskStatus.COMPLETED -> MaterialTheme.colorScheme.onSurfaceVariant + TaskStatus.FAILED -> Color(0xFFF44336) + else -> MaterialTheme.colorScheme.onSurface + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun StepStatusIcon(status: TaskStatus, size: Int = 14) { + val infiniteTransition = rememberInfiniteTransition() + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(2000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ) + ) + + when (status) { + TaskStatus.COMPLETED -> Icon( + imageVector = Icons.Default.Check, + contentDescription = "Completed", + modifier = Modifier.size(size.dp), + tint = Color(0xFF4CAF50) + ) + TaskStatus.FAILED -> Icon( + imageVector = Icons.Default.Close, + contentDescription = "Failed", + modifier = Modifier.size(size.dp), + tint = Color(0xFFF44336) + ) + TaskStatus.IN_PROGRESS -> Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "In Progress", + modifier = Modifier.size(size.dp).rotate(rotation), + tint = Color(0xFF2196F3) + ) + else -> Box( + modifier = Modifier + .size((size - 4).dp) + .background(Color(0xFF9E9E9E).copy(alpha = 0.3f), CircleShape) + ) + } +} + +/** + * Find the current step description (first in-progress or first todo) + */ +private fun findCurrentStep(plan: AgentPlan): String? { + for (task in plan.tasks) { + for (step in task.steps) { + if (step.status == TaskStatus.IN_PROGRESS) { + return step.description + } + } + } + for (task in plan.tasks) { + for (step in task.steps) { + if (step.status == TaskStatus.TODO) { + return step.description + } + } + } + return null +} + diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/icons/AutoDevComposeIcons.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/icons/AutoDevComposeIcons.kt index 82c7b385cd..36faa9d8b5 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/icons/AutoDevComposeIcons.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/icons/AutoDevComposeIcons.kt @@ -59,6 +59,7 @@ object AutoDevComposeIcons { val Chat: ImageVector get() = Icons.Default.Chat val SmartToy: ImageVector get() = Icons.Default.SmartToy val AlternateEmail: ImageVector get() = Icons.Default.AlternateEmail + val AutoAwesome: ImageVector get() = Icons.Default.AutoAwesome // Theme & Display val LightMode: ImageVector get() = Icons.Default.LightMode diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/SketchRenderer.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/SketchRenderer.kt index 1aa1ebc950..3bbfe952f3 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/SketchRenderer.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/SketchRenderer.kt @@ -140,13 +140,6 @@ object SketchRenderer : BaseContentRenderer() { } } } - - if (!isComplete && content.isNotEmpty()) { - Spacer(modifier = Modifier.height(8.dp)) - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth() - ) - } } } } diff --git a/mpp-ui/src/jsMain/typescript/agents/render/BaseRenderer.ts b/mpp-ui/src/jsMain/typescript/agents/render/BaseRenderer.ts index cd243a1bca..ed3a1dc9b3 100644 --- a/mpp-ui/src/jsMain/typescript/agents/render/BaseRenderer.ts +++ b/mpp-ui/src/jsMain/typescript/agents/render/BaseRenderer.ts @@ -11,6 +11,7 @@ import {cc} from "autodev-mpp-core/autodev-mpp-core"; import JsCodingAgentRenderer = cc.unitmesh.agent.JsCodingAgentRenderer; +import JsPlanSummaryData = cc.unitmesh.agent.JsPlanSummaryData; export abstract class BaseRenderer implements JsCodingAgentRenderer { // Required by Kotlin JS export interface @@ -134,6 +135,16 @@ export abstract class BaseRenderer implements JsCodingAgentRenderer { // no-op by default } + /** + * Render a compact plan summary bar. + * Default implementation - subclasses can override for custom rendering. + * + * @param summary The plan summary data + */ + renderPlanSummary(summary: JsPlanSummaryData): void { + // Default: no-op, subclasses can override + } + /** * Common implementation for LLM response start */ diff --git a/mpp-ui/src/jsMain/typescript/agents/render/CliRenderer.ts b/mpp-ui/src/jsMain/typescript/agents/render/CliRenderer.ts index 1001217234..57e5c82059 100644 --- a/mpp-ui/src/jsMain/typescript/agents/render/CliRenderer.ts +++ b/mpp-ui/src/jsMain/typescript/agents/render/CliRenderer.ts @@ -11,6 +11,8 @@ import chalk from 'chalk'; import hljs from 'highlight.js'; import { semanticChalk, dividers } from '../../design-system/theme-helpers.js'; import { BaseRenderer } from './BaseRenderer.js'; +import { cc } from 'autodev-mpp-core/autodev-mpp-core'; +import JsPlanSummaryData = cc.unitmesh.agent.JsPlanSummaryData; /** * CliRenderer extends BaseRenderer and implements the unified JsCodingAgentRenderer interface @@ -40,6 +42,55 @@ export class CliRenderer extends BaseRenderer { // The reference format shows tools directly without iteration numbers } + /** + * Render a compact plan summary bar + * Example: 📋 Plan: Create Tag System (3/5 steps, 60%) ████████░░░░░░░░ + */ + renderPlanSummary(summary: JsPlanSummaryData): void { + const { title, completedSteps, totalSteps, progressPercent, status, currentStepDescription } = summary; + + // Build progress bar (16 chars wide) + const barWidth = 16; + const filledWidth = Math.round((progressPercent / 100) * barWidth); + const emptyWidth = barWidth - filledWidth; + const progressBar = '█'.repeat(filledWidth) + '░'.repeat(emptyWidth); + + // Status indicator + let statusIcon = '📋'; + let statusColor = semanticChalk.info; + if (status === 'COMPLETED') { + statusIcon = '✅'; + statusColor = semanticChalk.success; + } else if (status === 'FAILED') { + statusIcon = '❌'; + statusColor = semanticChalk.error; + } else if (status === 'IN_PROGRESS') { + statusIcon = '🔄'; + } + + // Truncate title if too long + const maxTitleLen = 30; + const displayTitle = title.length > maxTitleLen ? title.substring(0, maxTitleLen - 3) + '...' : title; + + // Main summary line + console.log( + statusIcon + ' ' + + chalk.bold('Plan: ') + + chalk.white(displayTitle) + ' ' + + semanticChalk.muted(`(${completedSteps}/${totalSteps} steps, ${progressPercent}%) `) + + statusColor(progressBar) + ); + + // Show current step if available + if (currentStepDescription && status !== 'COMPLETED') { + const maxStepLen = 50; + const displayStep = currentStepDescription.length > maxStepLen + ? currentStepDescription.substring(0, maxStepLen - 3) + '...' + : currentStepDescription; + console.log(' ⎿ ' + semanticChalk.muted('Next: ' + displayStep)); + } + } + renderLLMResponseStart(): void { this.baseLLMResponseStart(); // Use BaseRenderer helper process.stdout.write(semanticChalk.muted('💭 ')); @@ -157,25 +208,67 @@ export class CliRenderer extends BaseRenderer { // Fallback to the old regex-based parsing docqlParams = params; } - + const query = docqlParams.query || params.query || paramsStr; const docPath = docqlParams.documentPath || params.documentPath; const maxResults = docqlParams.maxResults || params.maxResults; const reranker = docqlParams.rerankerType || params.rerankerType; - + // Build details string - truncate long queries for display const displayQuery = query.length > 80 ? query.substring(0, 77) + '...' : query; let details = `Query: "${displayQuery}"`; if (docPath) details += ` | Doc: ${docPath}`; if (maxResults) details += ` | Max: ${maxResults}`; if (reranker) details += ` | Reranker: ${reranker}`; - + return { name: 'DocQL', description: 'document query', details }; } + case 'plan': { + const action = params.action || 'unknown'; + const actionLower = action.toLowerCase(); + if (actionLower === 'create') { + return { + name: 'Plan', + description: 'creating plan', + details: 'Creating new task plan...' + }; + } else if (actionLower === 'complete_step') { + const steps = params.steps; + if (steps) { + return { + name: 'Plan', + description: 'updating progress', + details: `Completing multiple steps...` + }; + } + return { + name: 'Plan', + description: 'updating progress', + details: `Completing step ${params.taskIndex || '?'}.${params.stepIndex || '?'}` + }; + } else if (actionLower === 'fail_step') { + return { + name: 'Plan', + description: 'marking failed', + details: `Step ${params.taskIndex || '?'}.${params.stepIndex || '?'} failed` + }; + } else if (actionLower === 'view') { + return { + name: 'Plan', + description: 'viewing plan', + details: 'Viewing current plan status' + }; + } + return { + name: 'Plan', + description: action, + details: paramsStr + }; + } default: return { name: toolName, diff --git a/mpp-vscode/README.md b/mpp-vscode/README.md new file mode 100644 index 0000000000..e7febba66f --- /dev/null +++ b/mpp-vscode/README.md @@ -0,0 +1,139 @@ +# 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] 创建项目目录结构 +- [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) +- [x] 状态栏集成 + +### Phase 6: 高级功能 ✅ +- [x] DevIns 语言支持 + - [x] 语法高亮 (TextMate grammar) + - [x] 自动补全 (/, @, $ 触发) +- [x] React Webview UI + - [x] React + Vite 构建 + - [x] Markdown 渲染 (react-markdown + remark-gfm) + - [x] VSCode 主题集成 + - [x] 流式响应动画 +- [ ] 代码索引集成 +- [ ] 领域词典支持 + +## 参考项目 + +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..b44643e994 --- /dev/null +++ b/mpp-vscode/package.json @@ -0,0 +1,170 @@ +{ + "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-sketch" + }, + "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" + } + ], + "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": { + "vscode:prepublish": "npm run build", + "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", + "test:watch": "vitest" + }, + "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", + "@vscode/vsce": "^2.22.0", + "esbuild": "^0.19.8", + "eslint": "^8.54.0", + "typescript": "^5.3.2", + "vitest": "^1.0.0" + }, + "dependencies": { + "@autodev/mpp-core": "file:../mpp-core/build/packages/js", + "@modelcontextprotocol/sdk": "^1.0.0", + "cors": "^2.8.5", + "express": "^4.18.2", + "yaml": "^2.8.2", + "zod": "^3.22.4" + } +} 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/bridge/mpp-core.ts b/mpp-vscode/src/bridge/mpp-core.ts new file mode 100644 index 0000000000..1b79cd9ea6 --- /dev/null +++ b/mpp-vscode/src/bridge/mpp-core.ts @@ -0,0 +1,582 @@ +/** + * 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 +} = 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; +} + +/** + * VSCode Renderer - Forwards agent events to webview + * Implements the JsCodingAgentRenderer interface + */ +export class VSCodeRenderer { + constructor(private chatProvider: { postMessage: (msg: any) => void }) {} + + renderIterationHeader(current: number, max: number): void { + this.chatProvider.postMessage({ + type: 'iterationUpdate', + data: { current, max } + }); + } + + renderLLMResponseStart(): void { + this.chatProvider.postMessage({ type: 'startResponse' }); + } + + renderLLMResponseChunk(chunk: string): void { + this.chatProvider.postMessage({ type: 'responseChunk', content: chunk }); + } + + renderLLMResponseEnd(): void { + this.chatProvider.postMessage({ type: 'endResponse' }); + } + + renderToolCall(toolName: string, paramsStr: string): void { + this.chatProvider.postMessage({ + type: 'toolCall', + data: { + toolName, + params: paramsStr, + description: `Calling ${toolName}`, + success: null + } + }); + } + + renderToolResult(toolName: string, success: boolean, output: string | null, fullOutput: string | null): void { + this.chatProvider.postMessage({ + type: 'toolResult', + data: { + toolName, + success, + output, + fullOutput, + summary: success ? 'Completed' : 'Failed' + } + }); + } + + renderTaskComplete(): void { + this.chatProvider.postMessage({ + type: 'taskComplete', + data: { success: true, message: 'Task completed' } + }); + } + + renderFinalResult(success: boolean, message: string, iterations: number): void { + this.chatProvider.postMessage({ + type: 'taskComplete', + data: { success, message: `${message} (${iterations} iterations)` } + }); + } + + renderError(message: string): void { + this.chatProvider.postMessage({ type: 'error', content: message }); + } + + renderRepeatWarning(toolName: string, count: number): void { + this.chatProvider.postMessage({ + type: 'error', + content: `Warning: ${toolName} called ${count} times - consider different approach` + }); + } + + renderRecoveryAdvice(advice: string): void { + this.chatProvider.postMessage({ + type: 'responseChunk', + content: `\n\n💡 **Suggestion**: ${advice}\n` + }); + } + + renderUserConfirmationRequest(toolName: string, params: Record): void { + // Auto-approve for now + this.chatProvider.postMessage({ + type: 'toolCall', + data: { + toolName, + params: JSON.stringify(params), + description: `Tool '${toolName}' needs approval (auto-approved)`, + success: null + } + }); + } + + forceStop(): void { + this.chatProvider.postMessage({ + type: 'taskComplete', + data: { success: false, message: 'Stopped by user' } + }); + } +} + +/** + * Coding Agent - AI-powered coding assistant + * Wraps mpp-core's JsCodingAgent + */ +export class CodingAgent { + private agent: any; + private renderer: VSCodeRenderer; + + constructor( + config: ModelConfig, + toolRegistry: ToolRegistry, + renderer: VSCodeRenderer, + projectPath: string, + options?: { + maxIterations?: number; + mcpServers?: Record; + } + ) { + this.renderer = renderer; + + // Create model config + 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 ?? '' + ); + + // Create LLM service + const llmService = new JsKoogLLMService(modelConfig); + + // Create agent with renderer + this.agent = new JsCodingAgent( + projectPath, + llmService, + options?.maxIterations ?? 100, + this.createKotlinRenderer(), + options?.mcpServers ?? null, + null // toolConfig + ); + } + + /** + * Create a Kotlin-compatible renderer object + */ + private createKotlinRenderer(): any { + const renderer = this.renderer; + return { + renderIterationHeader: (c: number, m: number) => renderer.renderIterationHeader(c, m), + renderLLMResponseStart: () => renderer.renderLLMResponseStart(), + renderLLMResponseChunk: (chunk: string) => renderer.renderLLMResponseChunk(chunk), + renderLLMResponseEnd: () => renderer.renderLLMResponseEnd(), + renderToolCall: (name: string, params: string) => renderer.renderToolCall(name, params), + renderToolResult: (name: string, success: boolean, output: string | null, full: string | null) => + renderer.renderToolResult(name, success, output, full), + renderTaskComplete: () => renderer.renderTaskComplete(), + renderFinalResult: (success: boolean, msg: string, iters: number) => + renderer.renderFinalResult(success, msg, iters), + renderError: (msg: string) => renderer.renderError(msg), + renderRepeatWarning: (name: string, count: number) => renderer.renderRepeatWarning(name, count), + renderRecoveryAdvice: (advice: string) => renderer.renderRecoveryAdvice(advice), + renderUserConfirmationRequest: (name: string, params: any) => + renderer.renderUserConfirmationRequest(name, params), + forceStop: () => renderer.forceStop() + }; + } + + /** + * Execute a DevIns command or natural language task + */ + async execute(input: string): Promise { + try { + await this.agent.execute(input); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.renderer.renderError(message); + } + } + + /** + * 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 + })) + }; + } + + /** + * Clear conversation history + */ + clearHistory(): void { + this.agent.clearHistory?.(); + } + + /** + * 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..1ec3be2c86 --- /dev/null +++ b/mpp-vscode/src/extension.ts @@ -0,0 +1,160 @@ +/** + * 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 { 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 = () => {}; + +/** + * Extension activation + */ +export async function activate(context: vscode.ExtensionContext) { + logger = vscode.window.createOutputChannel('AutoDev'); + 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); + + // 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(); + }) + ); + + // 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)) { + const isMac = process.platform === 'darwin'; + const keyCombo = isMac ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'; + vscode.window.showInformationMessage( + `AutoDev extension installed successfully! Press ${keyCombo} 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..0ed099ccc2 --- /dev/null +++ b/mpp-vscode/src/providers/chat-view.ts @@ -0,0 +1,968 @@ +/** + * Chat View Provider - Webview for chat interface + * + * Uses mpp-core's JsCodingAgent for agent-based interactions + * Configuration is loaded from ~/.autodev/config.yaml (same as CLI and Desktop) + * + * Architecture mirrors: + * - mpp-ui/src/jsMain/typescript/modes/AgentMode.ts + * - mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt + */ + +import * as vscode from 'vscode'; +import { ConfigManager, AutoDevConfigWrapper, LLMConfig } from '../services/config-manager'; + +// @ts-ignore - Kotlin/JS generated module +import MppCore from '@autodev/mpp-core'; + +// Access Kotlin/JS exports - same pattern as AgentMode.ts +const KotlinCC = MppCore.cc.unitmesh; + +/** + * Chat View Provider for the sidebar webview + */ +export class ChatViewProvider implements vscode.WebviewViewProvider { + private webviewView: vscode.WebviewView | undefined; + private codingAgent: any = null; + private llmService: any = null; + private configWrapper: AutoDevConfigWrapper | null = null; + private completionManager: any = null; + private isExecuting = false; + private messages: Array<{ role: string; content: string }> = []; + private editorChangeDisposable: vscode.Disposable | undefined; + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly log: (message: string) => void + ) { + // Initialize completion manager + try { + if (KotlinCC?.llm?.JsCompletionManager) { + this.completionManager = new KotlinCC.llm.JsCompletionManager(); + this.log('CompletionManager initialized'); + } + } catch (error) { + this.log(`Failed to initialize CompletionManager: ${error}`); + } + + // Listen to active editor changes + this.editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor((editor) => { + if (editor && this.webviewView) { + this.sendActiveFileUpdate(editor.document); + } + }); + } + + /** + * Send active file update to webview + */ + private sendActiveFileUpdate(document: vscode.TextDocument): void { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) return; + + const relativePath = vscode.workspace.asRelativePath(document.uri, false); + const fileName = document.fileName.split('/').pop() || document.fileName.split('\\').pop() || ''; + const isDirectory = false; + + // Skip binary files and non-file schemes + if (document.uri.scheme !== 'file') return; + const binaryExtensions = ['jar', 'class', 'exe', 'dll', 'so', 'dylib', 'png', 'jpg', 'jpeg', 'gif', 'ico', 'pdf', 'zip', 'tar', 'gz', 'rar', '7z']; + const ext = fileName.split('.').pop()?.toLowerCase() || ''; + if (binaryExtensions.includes(ext)) return; + + this.postMessage({ + type: 'activeFileChanged', + data: { + path: relativePath, + name: fileName, + isDirectory + } + }); + } + + /** + * Send current active file to webview + */ + private sendCurrentActiveFile(): void { + const editor = vscode.window.activeTextEditor; + if (editor) { + this.sendActiveFileUpdate(editor.document); + } + } + + async resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken + ): Promise { + this.webviewView = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.context.extensionUri, 'dist', 'webview') + ] + }; + + webviewView.webview.html = this.getHtmlContent(webviewView.webview); + + // Handle messages from webview + webviewView.webview.onDidReceiveMessage(async (message) => { + this.log(`Received message from webview: ${JSON.stringify(message)}`); + switch (message.type) { + case 'sendMessage': + await this.handleUserMessage(message.content); + break; + case 'clearHistory': + this.clearHistory(); + break; + case 'action': + await this.handleAction(message.action, message.data); + break; + case 'openConfig': + await this.openConfigFile(); + break; + case 'stopExecution': + this.stopExecution(); + break; + case 'selectConfig': + await this.selectConfig(message.data?.configName as string); + break; + case 'searchFiles': + await this.handleSearchFiles(message.data?.query as string); + break; + case 'getRecentFiles': + await this.handleGetRecentFiles(); + break; + case 'readFileContent': + await this.handleReadFileContent(message.data?.path as string); + break; + case 'requestConfig': + // Webview is ready and requesting config + this.sendConfigUpdate(); + // Also send current active file + this.sendCurrentActiveFile(); + break; + case 'getActiveFile': + // Get current active file + this.sendCurrentActiveFile(); + break; + case 'getCompletions': + // Get completion suggestions from mpp-core + await this.handleGetCompletions( + message.data?.text as string, + message.data?.cursorPosition as number + ); + break; + case 'applyCompletion': + // Apply a completion item + await this.handleApplyCompletion( + message.data?.text as string, + message.data?.cursorPosition as number, + message.data?.completionIndex as number + ); + break; + } + }); + + // Initialize agent from config file + await this.initializeFromConfig(); + } + + /** + * Stop current execution + */ + private stopExecution(): void { + if (this.isExecuting) { + this.isExecuting = false; + this.postMessage({ type: 'taskComplete', data: { success: false, message: 'Stopped by user' } }); + this.log('Execution stopped by user'); + } + } + + /** + * Select a different config + */ + private async selectConfig(configName: string): Promise { + if (!this.configWrapper || !configName) return; + + const configs = this.configWrapper.getAllConfigs(); + const selectedConfig = configs.find(c => c.name === configName); + + if (selectedConfig) { + // Recreate LLM service with new config + this.llmService = this.createLLMService(selectedConfig); + this.codingAgent = null; // Reset agent to use new LLM service + + this.log(`Switched to config: ${configName}`); + + // Send updated config state to webview + this.sendConfigUpdate(configName); + + this.postMessage({ + type: 'responseChunk', + content: `✨ Switched to: \`${selectedConfig.name}\` (${selectedConfig.provider}/${selectedConfig.model})` + }); + } + } + + /** + * Send a message programmatically + */ + async sendMessage(content: string): Promise { + if (this.webviewView) { + this.webviewView.show(true); + } + await this.handleUserMessage(content); + } + + /** + * Post a message to the webview + */ + postMessage(message: any): void { + this.webviewView?.webview.postMessage(message); + } + + /** + * Send config update to webview + */ + private sendConfigUpdate(currentConfigName?: string): void { + if (!this.configWrapper) return; + + const configs = this.configWrapper.getAllConfigs(); + const availableConfigs = configs.map(c => ({ + name: c.name, + provider: c.provider, + model: c.model + })); + + this.postMessage({ + type: 'configUpdate', + data: { + availableConfigs, + currentConfigName: currentConfigName || this.configWrapper.getActiveConfig()?.name || null + } + }); + } + + /** + * Initialize from ~/.autodev/config.yaml + * Mirrors IdeaAgentViewModel.loadConfiguration() + */ + private async initializeFromConfig(): Promise { + try { + this.configWrapper = await ConfigManager.load(); + const activeConfig = this.configWrapper.getActiveConfig(); + + // Send config state to webview + this.sendConfigUpdate(); + + if (!activeConfig || !this.configWrapper.isValid()) { + this.log('No valid configuration found in ~/.autodev/config.yaml'); + // Show welcome message with config instructions + this.postMessage({ + type: 'responseChunk', + content: this.getWelcomeMessage() + }); + return; + } + + // Create LLM service + this.llmService = this.createLLMService(activeConfig); + this.log(`LLM Service initialized: ${activeConfig.provider}/${activeConfig.model}`); + + // Show ready message + this.postMessage({ + type: 'responseChunk', + content: `✨ **AutoDev Ready**\n\nUsing: \`${activeConfig.name}\` (${activeConfig.provider}/${activeConfig.model})\n\nType a message or use DevIns commands like \`/file:path\` or \`@code\`.` + }); + + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.log(`Failed to load configuration: ${message}`); + } + } + + /** + * Create LLM service from config + * Same pattern as AgentMode.ts + */ + private createLLMService(config: LLMConfig): any { + const modelConfig = new KotlinCC.llm.JsModelConfig( + config.provider, // Use lowercase provider name like AgentMode.ts + config.model, + config.apiKey || '', + config.temperature ?? 0.7, + config.maxTokens ?? 8192, + config.baseUrl || '' + ); + return new KotlinCC.llm.JsKoogLLMService(modelConfig); + } + + /** + * Initialize CodingAgent (lazy initialization) + * Same pattern as AgentMode.ts + */ + private initializeCodingAgent(): any { + if (this.codingAgent) { + return this.codingAgent; + } + + if (!this.llmService) { + throw new Error('LLM service not configured. Please configure ~/.autodev/config.yaml'); + } + + const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd(); + const mcpServers = this.configWrapper?.getEnabledMcpServers() || {}; + + // Create renderer that forwards events to webview + const renderer = this.createRenderer(); + + // Create CodingAgent - same constructor as AgentMode.ts + this.codingAgent = new KotlinCC.agent.JsCodingAgent( + workspacePath, + this.llmService, + 10, // maxIterations + renderer, + Object.keys(mcpServers).length > 0 ? mcpServers : null, + null // toolConfig + ); + + this.log(`CodingAgent initialized for workspace: ${workspacePath}`); + return this.codingAgent; + } + + /** + * Create renderer that forwards events to webview + * Mirrors TuiRenderer from mpp-ui + * Must implement JsCodingAgentRenderer interface including __doNotUseOrImplementIt + */ + private createRenderer(): any { + const self = this; + return { + // Required by Kotlin JS export interface + __doNotUseOrImplementIt: {}, + + renderIterationHeader: (current: number, max: number) => { + self.postMessage({ type: 'iterationUpdate', data: { current, max } }); + }, + renderLLMResponseStart: () => { + self.postMessage({ type: 'startResponse' }); + }, + renderLLMResponseChunk: (chunk: string) => { + self.postMessage({ type: 'responseChunk', content: chunk }); + }, + renderLLMResponseEnd: () => { + self.postMessage({ type: 'endResponse' }); + }, + renderToolCall: (toolName: string, params: string) => { + self.postMessage({ + type: 'toolCall', + data: { toolName, params, description: `Calling ${toolName}` } + }); + }, + renderToolResult: (toolName: string, success: boolean, output: string | null, fullOutput: string | null) => { + self.postMessage({ + type: 'toolResult', + data: { toolName, success, output, fullOutput } + }); + }, + renderTaskComplete: () => { + self.postMessage({ type: 'taskComplete', data: { success: true, message: 'Task completed' } }); + }, + renderFinalResult: (success: boolean, message: string, iterations: number) => { + self.postMessage({ + type: 'taskComplete', + data: { success, message: `${message} (${iterations} iterations)` } + }); + }, + renderError: (message: string) => { + self.postMessage({ type: 'error', content: message }); + }, + renderRepeatWarning: (toolName: string, count: number) => { + self.postMessage({ + type: 'error', + content: `Warning: ${toolName} called ${count} times` + }); + }, + renderRecoveryAdvice: (advice: string) => { + self.postMessage({ type: 'responseChunk', content: `\n💡 ${advice}\n` }); + }, + renderUserConfirmationRequest: () => {}, + addLiveTerminal: () => {}, + forceStop: () => { + self.postMessage({ type: 'taskComplete', data: { success: false, message: 'Stopped' } }); + } + }; + } + + /** + * Handle user message + * Mirrors IdeaAgentViewModel.executeTask() + */ + private async handleUserMessage(content: string): Promise { + if (this.isExecuting) { + this.postMessage({ type: 'error', content: 'Already executing a task. Please wait.' }); + return; + } + + const trimmedContent = content?.trim(); + if (!trimmedContent) return; + + // Add user message to timeline + this.messages.push({ role: 'user', content: trimmedContent }); + this.postMessage({ type: 'userMessage', content: trimmedContent }); + + // Check if LLM is configured + if (!this.llmService) { + this.postMessage({ + type: 'responseChunk', + content: this.getConfigRequiredMessage() + }); + return; + } + + this.isExecuting = true; + + try { + const agent = this.initializeCodingAgent(); + const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd(); + const task = new KotlinCC.agent.JsAgentTask(trimmedContent, workspacePath); + const result = await agent.executeTask(task); + + if (result && result.message) { + this.messages.push({ role: 'assistant', content: result.message }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.log(`Error in chat: ${message}`); + this.postMessage({ type: 'error', content: message }); + } finally { + this.isExecuting = false; + } + } + + private getWelcomeMessage(): string { + return `# Welcome to AutoDev! 🚀 + +No configuration found. Please create \`~/.autodev/config.yaml\`: + +\`\`\`yaml +active: default +configs: + - name: default + provider: openai # or anthropic, deepseek, ollama, etc. + apiKey: your-api-key + model: gpt-4 +\`\`\` + +**Supported Providers:** +- \`openai\` - OpenAI (GPT-4, GPT-3.5) +- \`anthropic\` - Anthropic (Claude) +- \`deepseek\` - DeepSeek +- \`ollama\` - Ollama (local models) +- \`openrouter\` - OpenRouter + +Click **Open Config** below to create the file.`; + } + + private getConfigRequiredMessage(): string { + return `⚠️ **Configuration Required** + +Please configure your LLM provider in \`~/.autodev/config.yaml\`. + +Click **Open Config** to edit the configuration file.`; + } + + /** + * Open config file in editor + */ + private async openConfigFile(): Promise { + const configPath = ConfigManager.getConfigPath(); + const uri = vscode.Uri.file(configPath); + + try { + await vscode.workspace.fs.stat(uri); + } catch { + // File doesn't exist, create it with template + const template = `# AutoDev Configuration +# See: https://github.com/phodal/auto-dev + +active: default +configs: + - name: default + provider: openai + apiKey: your-api-key-here + model: gpt-4 + # baseUrl: https://api.openai.com/v1 # Optional + # temperature: 0.7 + # maxTokens: 8192 + +# MCP Servers (optional) +# mcpServers: +# filesystem: +# command: npx +# args: ["-y", "@anthropic/mcp-server-filesystem"] +`; + await vscode.workspace.fs.writeFile(uri, Buffer.from(template, 'utf-8')); + } + + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + } + + private async handleAction(action: string, data: any): Promise { + this.log(`Action: ${action}, data: ${JSON.stringify(data)}`); + + switch (action) { + case 'insert': + // Insert code at cursor + const editor = vscode.window.activeTextEditor; + if (editor && data.code) { + await editor.edit(editBuilder => { + editBuilder.insert(editor.selection.active, data.code); + }); + } + break; + + case 'apply': + // Apply code changes (show diff) + if (data.code) { + // TODO: Show diff view + vscode.window.showInformationMessage('Apply code: ' + data.code.substring(0, 50) + '...'); + } + break; + + case 'accept-diff': + // Accept diff changes + // TODO: Apply diff + break; + + case 'reject-diff': + // Reject diff changes + break; + + case 'run-command': + // Run terminal command + if (data.command) { + const terminal = vscode.window.createTerminal('AutoDev'); + terminal.show(); + terminal.sendText(data.command); + } + break; + + case 'view-diff': + // Open diff view + break; + + case 'rerun-tool': + // Rerun a tool + break; + + case 'optimizePrompt': + // Optimize prompt using LLM + await this.handlePromptOptimize(data?.prompt as string); + break; + + case 'openMcpConfig': + // Open MCP configuration + await this.openMcpConfig(); + break; + } + } + + /** + * Open MCP configuration file + */ + private async openMcpConfig(): Promise { + try { + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + const mcpConfigPath = `${homeDir}/.autodev/mcp.json`; + + // Check if file exists, create if not + const fs = await import('fs').then(m => m.promises); + try { + await fs.access(mcpConfigPath); + } catch { + // Create default MCP config + const defaultConfig = { + mcpServers: {} + }; + await fs.mkdir(`${homeDir}/.autodev`, { recursive: true }); + await fs.writeFile(mcpConfigPath, JSON.stringify(defaultConfig, null, 2)); + } + + // Open the file in VSCode + const uri = vscode.Uri.file(mcpConfigPath); + await vscode.window.showTextDocument(uri); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.log(`Failed to open MCP config: ${message}`); + vscode.window.showErrorMessage(`Failed to open MCP config: ${message}`); + } + } + + /** + * Handle prompt optimization request + * Uses LLM to enhance the user's prompt + */ + private async handlePromptOptimize(prompt: string): Promise { + if (!prompt || !this.llmService) { + this.postMessage({ type: 'promptOptimizeFailed', data: { error: 'No prompt or LLM service' } }); + return; + } + + try { + const systemPrompt = `You are a prompt optimization assistant. Your task is to enhance the user's prompt to be more clear, specific, and effective for an AI coding assistant. + +Rules: +1. Keep the original intent and meaning +2. Add clarity and specificity where needed +3. Structure the prompt for better understanding +4. Keep it concise - don't make it unnecessarily long +5. Return ONLY the optimized prompt, no explanations + +User's original prompt:`; + + const response = await this.llmService.chat([ + { role: 'system', content: systemPrompt }, + { role: 'user', content: prompt } + ]); + + if (response) { + this.postMessage({ type: 'promptOptimized', data: { optimizedPrompt: response.trim() } }); + } else { + this.postMessage({ type: 'promptOptimizeFailed', data: { error: 'Empty response' } }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.log(`Prompt optimization failed: ${message}`); + this.postMessage({ type: 'promptOptimizeFailed', data: { error: message } }); + } + } + + /** + * Handle file search request from webview + * Searches for files matching the query in the workspace + */ + private async handleSearchFiles(query: string): Promise { + if (!query || query.length < 2) { + this.postMessage({ type: 'searchFilesResult', data: { files: [], folders: [] } }); + return; + } + + try { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + this.postMessage({ type: 'searchFilesResult', data: { files: [], folders: [] } }); + return; + } + + const lowerQuery = query.toLowerCase(); + + // Search for files matching the query + const files = await vscode.workspace.findFiles( + `**/*${query}*`, + '**/node_modules/**', + 50 + ); + + const fileResults: Array<{ name: string; path: string; relativePath: string; isDirectory: boolean }> = []; + const folderPaths = new Set(); + + for (const file of files) { + const relativePath = vscode.workspace.asRelativePath(file, false); + const name = file.path.split('/').pop() || ''; + + // Skip binary files + const ext = name.split('.').pop()?.toLowerCase() || ''; + const binaryExts = ['jar', 'class', 'exe', 'dll', 'so', 'dylib', 'png', 'jpg', 'jpeg', 'gif', 'ico', 'pdf', 'zip', 'tar', 'gz']; + if (binaryExts.includes(ext)) continue; + + fileResults.push({ + name, + path: relativePath, // Use relative path for consistency with activeFileChanged + relativePath, + isDirectory: false + }); + + // Collect parent folders that match the query + const parts = relativePath.split('/'); + let currentPath = ''; + for (let i = 0; i < parts.length - 1; i++) { + currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; + if (parts[i].toLowerCase().includes(lowerQuery)) { + folderPaths.add(currentPath); + } + } + } + + const folderResults = Array.from(folderPaths).slice(0, 10).map(p => ({ + name: p.split('/').pop() || p, + path: p, // Use relative path for consistency + relativePath: p, + isDirectory: true + })); + + this.postMessage({ + type: 'searchFilesResult', + data: { + files: fileResults.slice(0, 30), + folders: folderResults + } + }); + } catch (error) { + this.log(`Error searching files: ${error}`); + this.postMessage({ type: 'searchFilesResult', data: { files: [], folders: [] } }); + } + } + + /** + * Handle get recent files request from webview + * Returns recently opened files in the workspace + */ + private async handleGetRecentFiles(): Promise { + try { + // Get recently opened text documents + const recentFiles: Array<{ name: string; path: string; relativePath: string; isDirectory: boolean }> = []; + + // Get visible text editors first (most recently used) + for (const editor of vscode.window.visibleTextEditors) { + const doc = editor.document; + if (doc.uri.scheme === 'file') { + const relativePath = vscode.workspace.asRelativePath(doc.uri, false); + recentFiles.push({ + name: doc.fileName.split('/').pop() || '', + path: relativePath, // Use relative path for consistency + relativePath, + isDirectory: false + }); + } + } + + // Add other open documents + for (const doc of vscode.workspace.textDocuments) { + const relativePath = vscode.workspace.asRelativePath(doc.uri, false); + if (doc.uri.scheme === 'file' && !recentFiles.some(f => f.path === relativePath)) { + recentFiles.push({ + name: doc.fileName.split('/').pop() || '', + path: relativePath, // Use relative path for consistency + relativePath, + isDirectory: false + }); + } + } + + this.postMessage({ + type: 'recentFilesResult', + data: { files: recentFiles.slice(0, 20) } + }); + } catch (error) { + this.log(`Error getting recent files: ${error}`); + this.postMessage({ type: 'recentFilesResult', data: { files: [] } }); + } + } + + /** + * Handle read file content request from webview + * filePath can be either relative or absolute path + */ + private async handleReadFileContent(filePath: string): Promise { + if (!filePath) { + this.postMessage({ type: 'fileContentResult', data: { content: null, error: 'No path provided' } }); + return; + } + + try { + // Convert relative path to absolute if needed + let uri: vscode.Uri; + if (filePath.startsWith('/') || filePath.match(/^[a-zA-Z]:\\/)) { + // Already absolute path + uri = vscode.Uri.file(filePath); + } else { + // Relative path - resolve against workspace + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + this.postMessage({ type: 'fileContentResult', data: { content: null, error: 'No workspace folder' } }); + return; + } + uri = vscode.Uri.joinPath(workspaceFolder.uri, filePath); + } + + const content = await vscode.workspace.fs.readFile(uri); + const text = new TextDecoder().decode(content); + + this.postMessage({ + type: 'fileContentResult', + data: { path: filePath, content: text } + }); + } catch (error) { + this.log(`Error reading file: ${error}`); + this.postMessage({ + type: 'fileContentResult', + data: { path: filePath, content: null, error: String(error) } + }); + } + } + + /** + * Handle get completions request from webview + * Uses mpp-core's CompletionManager + */ + private async handleGetCompletions(text: string, cursorPosition: number): Promise { + if (!this.completionManager) { + this.postMessage({ type: 'completionsResult', data: { items: [] } }); + return; + } + + try { + const items = this.completionManager.getCompletions(text, cursorPosition); + const itemsArray = Array.from(items || []).map((item: any, index: number) => ({ + text: item.text, + displayText: item.displayText, + description: item.description, + icon: item.icon, + triggerType: item.triggerType, + index + })); + + this.postMessage({ type: 'completionsResult', data: { items: itemsArray } }); + } catch (error) { + this.log(`Error getting completions: ${error}`); + this.postMessage({ type: 'completionsResult', data: { items: [] } }); + } + } + + /** + * Handle apply completion request from webview + * Uses mpp-core's CompletionManager insert handler + */ + private async handleApplyCompletion( + text: string, + cursorPosition: number, + completionIndex: number + ): Promise { + if (!this.completionManager) { + this.postMessage({ type: 'completionApplied', data: null }); + return; + } + + try { + const result = this.completionManager.applyCompletion(text, cursorPosition, completionIndex); + if (result) { + this.postMessage({ + type: 'completionApplied', + data: { + newText: result.newText, + newCursorPosition: result.newCursorPosition, + shouldTriggerNextCompletion: result.shouldTriggerNextCompletion + } + }); + } else { + this.postMessage({ type: 'completionApplied', data: null }); + } + } catch (error) { + this.log(`Error applying completion: ${error}`); + this.postMessage({ type: 'completionApplied', data: null }); + } + } + + private clearHistory(): void { + this.messages = []; + this.codingAgent?.clearHistory(); + this.postMessage({ type: 'historyCleared' }); + } + + 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')); + + // Try to use React build, fallback to inline HTML + return ` + + + + + + + AutoDev Chat + + + +
+ + + +`; + } +} + 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/config-manager.ts b/mpp-vscode/src/services/config-manager.ts new file mode 100644 index 0000000000..53cfff2dcf --- /dev/null +++ b/mpp-vscode/src/services/config-manager.ts @@ -0,0 +1,177 @@ +/** + * ConfigManager - Loads configuration from ~/.autodev/config.yaml + * + * Mirrors mpp-ui's ConfigManager.ts for consistency across platforms. + * Uses Node.js fs for file operations in VSCode extension context. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as yaml from 'yaml'; + +export type LLMProvider = 'openai' | 'anthropic' | 'google' | 'deepseek' | 'ollama' | 'openrouter' | 'glm' | 'qwen' | 'kimi' | 'custom-openai-base'; + +/** + * Single LLM configuration + */ +export interface LLMConfig { + name: string; + provider: LLMProvider; + apiKey: string; + model: string; + baseUrl?: string; + temperature?: number; + maxTokens?: number; +} + +/** + * MCP Server configuration + */ +export interface McpServerConfig { + command?: string; + url?: string; + args?: string[]; + disabled?: boolean; + autoApprove?: string[]; + env?: Record; + timeout?: number; + trust?: boolean; + headers?: Record; + cwd?: string; +} + +/** + * Root configuration file structure + */ +export interface ConfigFile { + active: string; + configs: LLMConfig[]; + mcpServers?: Record; +} + +/** + * Configuration wrapper with validation + */ +export class AutoDevConfigWrapper { + constructor(private configFile: ConfigFile) {} + + getConfigFile(): ConfigFile { + return this.configFile; + } + + getActiveConfig(): LLMConfig | null { + if (!this.configFile.active || this.configFile.configs.length === 0) { + return null; + } + const config = this.configFile.configs.find(c => c.name === this.configFile.active); + return config || this.configFile.configs[0]; + } + + getAllConfigs(): LLMConfig[] { + return this.configFile.configs; + } + + getActiveName(): string { + return this.configFile.active; + } + + getMcpServers(): Record { + return this.configFile.mcpServers || {}; + } + + getEnabledMcpServers(): Record { + const mcpServers = this.getMcpServers(); + return Object.fromEntries( + Object.entries(mcpServers).filter(([_, server]) => !server.disabled) + ); + } + + isValid(): boolean { + const active = this.getActiveConfig(); + if (!active) return false; + + // Ollama doesn't require API key + if (active.provider === 'ollama') { + return !!active.model; + } + + // Custom OpenAI-compatible providers require baseUrl, apiKey, and model + if (active.provider === 'custom-openai-base') { + return !!active.baseUrl && !!active.apiKey && !!active.model; + } + + return !!active.provider && !!active.apiKey && !!active.model; + } +} + +/** + * ConfigManager - Manages loading configuration from ~/.autodev/config.yaml + */ +export class ConfigManager { + private static CONFIG_DIR = path.join(os.homedir(), '.autodev'); + private static CONFIG_FILE = path.join(ConfigManager.CONFIG_DIR, 'config.yaml'); + + /** + * Load configuration from file + */ + static async load(): Promise { + try { + // Ensure directory exists + if (!fs.existsSync(this.CONFIG_DIR)) { + fs.mkdirSync(this.CONFIG_DIR, { recursive: true }); + } + + if (!fs.existsSync(this.CONFIG_FILE)) { + return this.createEmpty(); + } + + const content = fs.readFileSync(this.CONFIG_FILE, 'utf-8'); + const parsed = yaml.parse(content); + + // Check if it's the new format (has 'configs' array) + if (parsed && Array.isArray(parsed.configs)) { + return new AutoDevConfigWrapper(parsed as ConfigFile); + } + + // Legacy format - convert to new format + if (parsed && parsed.provider) { + const migrated: ConfigFile = { + active: 'default', + configs: [{ + name: 'default', + provider: parsed.provider, + apiKey: parsed.apiKey, + model: parsed.model, + baseUrl: parsed.baseUrl, + temperature: parsed.temperature, + maxTokens: parsed.maxTokens, + }] + }; + return new AutoDevConfigWrapper(migrated); + } + + return this.createEmpty(); + } catch (error) { + return this.createEmpty(); + } + } + + /** + * Get configuration file path + */ + static getConfigPath(): string { + return this.CONFIG_FILE; + } + + /** + * Create empty configuration + */ + private static createEmpty(): AutoDevConfigWrapper { + return new AutoDevConfigWrapper({ + active: '', + configs: [] + }); + } +} + 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/services/status-bar.ts b/mpp-vscode/src/services/status-bar.ts new file mode 100644 index 0000000000..d3cf53d476 --- /dev/null +++ b/mpp-vscode/src/services/status-bar.ts @@ -0,0 +1,121 @@ +/** + * 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 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/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/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/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/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/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/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/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(); + }); +}); + 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"] +} + 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' + } + } +}); + 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..2041c33924 --- /dev/null +++ b/mpp-vscode/webview/src/App.css @@ -0,0 +1,135 @@ +.app { + height: 100%; + display: flex; + flex-direction: column; + background: var(--vscode-editor-background); +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-bottom: 1px solid var(--vscode-panel-border); + background: var(--vscode-editorWidget-background); + flex-shrink: 0; +} + +.header-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 14px; + color: var(--vscode-foreground); +} + +.header-icon { + font-size: 16px; +} + +.header-status { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--vscode-descriptionForeground); +} + +.status-dot { + width: 6px; + height: 6px; + background-color: var(--vscode-charts-yellow, #dcdcaa); + border-radius: 50%; + animation: pulse 1.4s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 80%, 100% { opacity: 0.4; transform: scale(1); } + 40% { opacity: 1; transform: scale(1.2); } +} + +.iteration-badge { + font-size: 10px; + padding: 2px 6px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + border-radius: 10px; +} + +.dev-badge { + font-size: 10px; + padding: 2px 6px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + border-radius: 4px; + opacity: 0.7; +} + +.header-config-btn { + background: transparent; + border: none; + cursor: pointer; + font-size: 14px; + padding: 4px 8px; + border-radius: 4px; + opacity: 0.7; + transition: opacity 0.2s, background 0.2s; +} + +.header-config-btn:hover { + opacity: 1; + background: var(--vscode-toolbar-hoverBackground); +} + +.app-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +.config-prompt { + display: flex; + justify-content: center; + padding: 16px; +} + +.config-btn { + padding: 10px 20px; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: background 0.2s; +} + +.config-btn:hover { + background: var(--vscode-button-hoverBackground); +} + +/* Loading indicator */ +.loading-indicator { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 20px; + color: var(--vscode-descriptionForeground); + font-size: 13px; +} + +.loading-spinner { + width: 16px; + height: 16px; + border: 2px solid var(--vscode-progressBar-background, #0e639c); + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/mpp-vscode/webview/src/App.tsx b/mpp-vscode/webview/src/App.tsx new file mode 100644 index 0000000000..be8cf68a3d --- /dev/null +++ b/mpp-vscode/webview/src/App.tsx @@ -0,0 +1,443 @@ +/** + * AutoDev VSCode Webview App + * + * Main application component using Timeline-based architecture + * Mirrors mpp-ui's ComposeRenderer and AgentChatInterface + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { Timeline } from './components/Timeline'; +import { ChatInput } from './components/ChatInput'; +import { ModelConfig } from './components/ModelSelector'; +import { SelectedFile } from './components/FileChip'; +import { CompletionItem } from './components/CompletionPopup'; +import { useVSCode, ExtensionMessage } from './hooks/useVSCode'; +import type { AgentState, ToolCallInfo, TerminalOutput, ToolCallTimelineItem } from './types/timeline'; +import './App.css'; + +interface ConfigState { + availableConfigs: ModelConfig[]; + currentConfigName: string | null; +} + +interface CompletionResult { + newText: string; + newCursorPosition: number; + shouldTriggerNextCompletion: boolean; +} + +const App: React.FC = () => { + // Agent state - mirrors ComposeRenderer's state + const [agentState, setAgentState] = useState({ + timeline: [], + currentStreamingContent: '', + isProcessing: false, + currentIteration: 0, + maxIterations: 10, + tasks: [], + }); + + // Config state - mirrors IdeaAgentViewModel's config management + const [configState, setConfigState] = useState({ + availableConfigs: [], + currentConfigName: null + }); + + // Token usage state + const [totalTokens, setTotalTokens] = useState(null); + + // Active file state (for auto-add current file feature) + const [activeFile, setActiveFile] = useState(null); + + // Completion state - from mpp-core + const [completionItems, setCompletionItems] = useState([]); + const [completionResult, setCompletionResult] = useState(null); + + const { postMessage, onMessage, isVSCode } = useVSCode(); + + // Handle messages from extension + const handleExtensionMessage = useCallback((msg: ExtensionMessage) => { + switch (msg.type) { + // User message is already added in handleSend for immediate feedback + // So we skip adding it again here to avoid duplicates + case 'userMessage': + break; + + // LLM response start + case 'startResponse': + setAgentState(prev => ({ + ...prev, + isProcessing: true, + currentStreamingContent: '' + })); + break; + + // LLM response chunk (streaming) + case 'responseChunk': + setAgentState(prev => ({ + ...prev, + currentStreamingContent: prev.currentStreamingContent + (msg.content || '') + })); + break; + + // LLM response end + case 'endResponse': + setAgentState(prev => ({ + ...prev, + isProcessing: false, + timeline: [...prev.timeline, { + type: 'message', + timestamp: Date.now(), + message: { role: 'assistant', content: prev.currentStreamingContent } + }], + currentStreamingContent: '' + })); + break; + + // Tool call + case 'toolCall': + if (msg.data) { + const toolCall: ToolCallInfo = { + toolName: (msg.data.toolName as string) || 'unknown', + description: (msg.data.description as string) || '', + params: (msg.data.params as string) || '', + success: msg.data.success as boolean | null | undefined + }; + setAgentState(prev => ({ + ...prev, + timeline: [...prev.timeline, { + type: 'tool_call' as const, + timestamp: Date.now(), + toolCall + }] + })); + } + break; + + // Tool result - update the last tool call + case 'toolResult': + if (msg.data) { + setAgentState(prev => { + const timeline = [...prev.timeline]; + // Find the last tool call and update it + for (let i = timeline.length - 1; i >= 0; i--) { + if (timeline[i].type === 'tool_call') { + const item = timeline[i] as ToolCallTimelineItem; + item.toolCall = { + ...item.toolCall, + success: msg.data?.success as boolean | undefined, + output: msg.data?.output as string | undefined, + summary: msg.data?.summary as string | undefined + }; + break; + } + } + return { ...prev, timeline }; + }); + } + break; + + // Terminal output + case 'terminalOutput': + if (msg.data) { + const terminal: TerminalOutput = { + command: (msg.data.command as string) || '', + output: (msg.data.output as string) || '', + exitCode: (msg.data.exitCode as number) || 0, + executionTimeMs: (msg.data.executionTimeMs as number) || 0 + }; + setAgentState(prev => ({ + ...prev, + timeline: [...prev.timeline, { + type: 'terminal_output' as const, + timestamp: Date.now(), + terminal + }] + })); + } + break; + + // Task complete + case 'taskComplete': + setAgentState(prev => ({ + ...prev, + isProcessing: false, + timeline: [...prev.timeline, { + type: 'task_complete' as const, + timestamp: Date.now(), + success: Boolean(msg.data?.success ?? true), + message: String(msg.data?.message || 'Task completed') + }] + })); + break; + + // Error + case 'error': + setAgentState(prev => ({ + ...prev, + isProcessing: false, + currentStreamingContent: '', + timeline: [...prev.timeline, { + type: 'error', + timestamp: Date.now(), + message: msg.content || 'An error occurred' + }], + errorMessage: msg.content + })); + break; + + // Clear history + case 'historyCleared': + setAgentState({ + timeline: [], + currentStreamingContent: '', + isProcessing: false, + currentIteration: 0, + maxIterations: 10, + tasks: [], + }); + break; + + // Iteration update + case 'iterationUpdate': + setAgentState(prev => ({ + ...prev, + currentIteration: Number(msg.data?.current) || prev.currentIteration, + maxIterations: Number(msg.data?.max) || prev.maxIterations + })); + break; + + // Config update from extension + case 'configUpdate': + if (msg.data) { + setConfigState({ + availableConfigs: (msg.data.availableConfigs as ModelConfig[]) || [], + currentConfigName: (msg.data.currentConfigName as string) || null + }); + } + break; + + // Token usage update + case 'tokenUpdate': + if (msg.data?.totalTokens != null) { + setTotalTokens(msg.data.totalTokens as number); + } + break; + + // Active file changed (for auto-add current file) + case 'activeFileChanged': + if (msg.data) { + setActiveFile({ + path: msg.data.path as string, + name: msg.data.name as string, + relativePath: msg.data.path as string, + isDirectory: msg.data.isDirectory as boolean || false + }); + } + break; + + // Completion results from mpp-core + case 'completionsResult': + if (msg.data?.items) { + setCompletionItems(msg.data.items as CompletionItem[]); + } + break; + + // Completion applied result + case 'completionApplied': + if (msg.data) { + setCompletionResult({ + newText: msg.data.newText as string, + newCursorPosition: msg.data.newCursorPosition as number, + shouldTriggerNextCompletion: msg.data.shouldTriggerNextCompletion as boolean + }); + } + break; + } + }, []); + + // Subscribe to extension messages + useEffect(() => { + return onMessage(handleExtensionMessage); + }, [onMessage, handleExtensionMessage]); + + // Request config on mount + useEffect(() => { + postMessage({ type: 'requestConfig' }); + }, [postMessage]); + + // Send message to extension + const handleSend = useCallback((content: string, files?: SelectedFile[]) => { + // Build message with file context (DevIns format) + let fullContent = content; + if (files && files.length > 0) { + const fileCommands = files.map(f => + f.isDirectory ? `/dir:${f.relativePath}` : `/file:${f.relativePath}` + ).join('\n'); + fullContent = `${fileCommands}\n\n${content}`; + } + + // Immediately show user message in timeline for feedback + setAgentState(prev => ({ + ...prev, + isProcessing: true, + timeline: [...prev.timeline, { + type: 'message', + timestamp: Date.now(), + message: { role: 'user', content: fullContent } + }] + })); + + // Send to extension + postMessage({ type: 'sendMessage', content: fullContent }); + }, [postMessage]); + + // Clear history + const handleClear = useCallback(() => { + postMessage({ type: 'clearHistory' }); + }, [postMessage]); + + // Handle actions from sketch renderers + const handleAction = useCallback((action: string, data: any) => { + postMessage({ type: 'action', action, data }); + }, [postMessage]); + + // Handle open config + const handleOpenConfig = useCallback(() => { + postMessage({ type: 'openConfig' }); + }, [postMessage]); + + // Handle stop execution + const handleStop = useCallback(() => { + postMessage({ type: 'stopExecution' }); + }, [postMessage]); + + // Handle config selection + const handleConfigSelect = useCallback((config: ModelConfig) => { + postMessage({ type: 'selectConfig', data: { configName: config.name } }); + }, [postMessage]); + + // Handle prompt optimization + const handlePromptOptimize = useCallback(async (prompt: string): Promise => { + return new Promise((resolve) => { + // Send optimization request to extension + postMessage({ type: 'action', action: 'optimizePrompt', data: { prompt } }); + + // Listen for response + const handler = (event: MessageEvent) => { + const msg = event.data; + if (msg.type === 'promptOptimized' && msg.data?.optimizedPrompt) { + window.removeEventListener('message', handler); + resolve(msg.data.optimizedPrompt as string); + } else if (msg.type === 'promptOptimizeFailed') { + window.removeEventListener('message', handler); + resolve(prompt); // Return original on failure + } + }; + window.addEventListener('message', handler); + + // Timeout after 30 seconds + setTimeout(() => { + window.removeEventListener('message', handler); + resolve(prompt); + }, 30000); + }); + }, [postMessage]); + + // Handle MCP config click + const handleMcpConfigClick = useCallback(() => { + postMessage({ type: 'action', action: 'openMcpConfig' }); + }, [postMessage]); + + // Handle get completions from mpp-core + const handleGetCompletions = useCallback((text: string, cursorPosition: number) => { + postMessage({ type: 'getCompletions', data: { text, cursorPosition } }); + }, [postMessage]); + + // Handle apply completion from mpp-core + const handleApplyCompletion = useCallback((text: string, cursorPosition: number, completionIndex: number) => { + postMessage({ type: 'applyCompletion', data: { text, cursorPosition, completionIndex } }); + }, [postMessage]); + + // Check if we need to show config prompt + const needsConfig = agentState.timeline.length === 0 && + agentState.currentStreamingContent.includes('No configuration found') || + agentState.currentStreamingContent.includes('Configuration Required'); + + return ( +
+
+
+ + AutoDev +
+ {agentState.isProcessing && ( +
+ + Processing... + {agentState.currentIteration > 0 && ( + + {agentState.currentIteration}/{agentState.maxIterations} + + )} +
+ )} + + {!isVSCode && ( + Dev Mode + )} +
+ +
+ + + {/* Show loading indicator when processing */} + {agentState.isProcessing && !agentState.currentStreamingContent && ( +
+
+ Processing... +
+ )} + + {/* Show Open Config button when config is needed */} + {needsConfig && ( +
+ +
+ )} +
+ + +
+ ); +}; + +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..50a4ff0dcf --- /dev/null +++ b/mpp-vscode/webview/src/components/ChatInput.css @@ -0,0 +1,205 @@ +.chat-input-container { + position: relative; + border: 1px solid var(--panel-border); + border-radius: 8px; + background: var(--background); + margin: 8px; +} + +/* Bottom Toolbar */ +.input-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 8px; + border-top: 1px solid var(--panel-border); + background: var(--selection-background); +} + +.toolbar-left { + display: flex; + align-items: center; + gap: 8px; +} + +.toolbar-right { + display: flex; + align-items: center; + gap: 4px; +} + +.toolbar-button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: transparent; + border: none; + border-radius: 4px; + color: var(--foreground); + opacity: 0.6; + cursor: pointer; + transition: all 0.15s; +} + +.toolbar-button:hover:not(:disabled) { + opacity: 1; + background: var(--selection-background); +} + +.toolbar-button:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +/* Input Wrapper */ +.input-wrapper { + display: flex; + gap: 8px; + align-items: flex-end; + padding: 8px; +} + +.input-with-completion { + position: relative; + flex: 1; + min-width: 0; +} + +.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 { + height: 32px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 12px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all 0.15s; +} + +.action-button span { + display: inline-block; +} + +.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; +} + +.stop-button { + background: var(--vscode-inputValidation-errorBackground, #5a1d1d); + color: var(--vscode-inputValidation-errorForeground, #f48771); + border: 1px solid var(--vscode-inputValidation-errorBorder, #be1100); +} + +.stop-button:hover { + background: var(--vscode-inputValidation-errorBackground, #6a2d2d); +} + +.clear-button { + background: transparent; + color: var(--foreground); + opacity: 0.6; +} + +.clear-button:hover:not(:disabled) { + opacity: 1; + background: var(--selection-background); +} + +.input-hint { + font-size: 10px; + color: var(--foreground); + opacity: 0.4; + white-space: nowrap; +} + +.input-hint kbd { + background: var(--background); + padding: 1px 4px; + border-radius: 2px; + font-family: inherit; + font-size: 9px; +} + +/* Token indicator */ +.token-indicator { + font-size: 11px; + color: var(--foreground); + opacity: 0.6; + padding: 2px 6px; + background: var(--background); + border-radius: 4px; +} + +/* Enhance button */ +.enhance-button { + display: flex; + align-items: center; + gap: 4px; +} + +.enhance-button.enhancing { + color: var(--vscode-textLink-foreground, #3794ff); + animation: pulse 1.5s ease-in-out infinite; +} + +.enhance-button .enhancing-text { + font-size: 10px; +} + +@keyframes pulse { + 0%, 100% { opacity: 0.6; } + 50% { opacity: 1; } +} diff --git a/mpp-vscode/webview/src/components/ChatInput.tsx b/mpp-vscode/webview/src/components/ChatInput.tsx new file mode 100644 index 0000000000..0661106be3 --- /dev/null +++ b/mpp-vscode/webview/src/components/ChatInput.tsx @@ -0,0 +1,294 @@ +import React, { useState, useRef, useEffect, KeyboardEvent, useCallback } from 'react'; +import { ModelSelector, ModelConfig } from './ModelSelector'; +import { TopToolbar } from './TopToolbar'; +import { SelectedFile } from './FileChip'; +import { DevInInput } from './DevInInput'; +import { CompletionPopup, CompletionItem } from './CompletionPopup'; +import { PlanSummaryBar, PlanData } from './plan'; +import './ChatInput.css'; + +interface ChatInputProps { + onSend: (message: string, files?: SelectedFile[]) => void; + onClear?: () => void; + onStop?: () => void; + onConfigSelect?: (config: ModelConfig) => void; + onConfigureClick?: () => void; + onMcpConfigClick?: () => void; + onPromptOptimize?: (prompt: string) => Promise; + onGetCompletions?: (text: string, cursorPosition: number) => void; + onApplyCompletion?: (text: string, cursorPosition: number, completionIndex: number) => void; + completionItems?: CompletionItem[]; + completionResult?: { newText: string; newCursorPosition: number; shouldTriggerNextCompletion: boolean } | null; + disabled?: boolean; + isExecuting?: boolean; + placeholder?: string; + availableConfigs?: ModelConfig[]; + currentConfigName?: string | null; + totalTokens?: number | null; + activeFile?: SelectedFile | null; + currentPlan?: PlanData | null; +} + +export const ChatInput: React.FC = ({ + onSend, + onClear, + onStop, + onConfigSelect, + onConfigureClick, + onMcpConfigClick, + onPromptOptimize, + onGetCompletions, + onApplyCompletion, + completionItems: externalCompletionItems, + completionResult, + disabled = false, + isExecuting = false, + placeholder = 'Ask AutoDev...', + availableConfigs = [], + currentConfigName = null, + totalTokens = null, + activeFile = null, + currentPlan = null +}) => { + const [input, setInput] = useState(''); + const [selectedFiles, setSelectedFiles] = useState([]); + const [isEnhancing, setIsEnhancing] = useState(false); + const [completionOpen, setCompletionOpen] = useState(false); + const [selectedCompletionIndex, setSelectedCompletionIndex] = useState(0); + const [autoAddCurrentFile, setAutoAddCurrentFile] = useState(true); + const inputRef = useRef(null); + const cursorPositionRef = useRef(0); + + // Use external completion items if provided + const completionItems = externalCompletionItems || []; + + // Auto-add active file when it changes + useEffect(() => { + if (autoAddCurrentFile && activeFile) { + setSelectedFiles(prev => { + if (prev.some(f => f.path === activeFile.path)) return prev; + return [...prev, activeFile]; + }); + } + }, [activeFile, autoAddCurrentFile]); + + // Handle completion result from mpp-core + useEffect(() => { + if (completionResult) { + setInput(completionResult.newText); + cursorPositionRef.current = completionResult.newCursorPosition; + if (completionResult.shouldTriggerNextCompletion && onGetCompletions) { + // Trigger next completion + onGetCompletions(completionResult.newText, completionResult.newCursorPosition); + } else { + setCompletionOpen(false); + } + } + }, [completionResult, onGetCompletions]); + + // Update completion items when external items change + useEffect(() => { + if (externalCompletionItems && externalCompletionItems.length > 0) { + setCompletionOpen(true); + setSelectedCompletionIndex(0); + } else if (externalCompletionItems && externalCompletionItems.length === 0) { + setCompletionOpen(false); + } + }, [externalCompletionItems]); + + const handleAddFile = useCallback((file: SelectedFile) => { + setSelectedFiles(prev => { + if (prev.some(f => f.path === file.path)) return prev; + return [...prev, file]; + }); + }, []); + + const handleRemoveFile = useCallback((file: SelectedFile) => { + setSelectedFiles(prev => prev.filter(f => f.path !== file.path)); + }, []); + + const handleClearFiles = useCallback(() => { + setSelectedFiles([]); + }, []); + + // Handle completion trigger - request completions from mpp-core + const handleTriggerCompletion = useCallback((trigger: '/' | '@' | '$', position: number) => { + cursorPositionRef.current = position; + if (onGetCompletions) { + // Use mpp-core for completions + onGetCompletions(input.substring(0, position) + trigger, position + 1); + } + }, [input, onGetCompletions]); + + // Handle completion selection + const handleSelectCompletion = useCallback((item: CompletionItem, _index: number) => { + if (onApplyCompletion && item.index !== undefined) { + // Use mpp-core to apply completion + onApplyCompletion(input, cursorPositionRef.current, item.index); + } else { + // Fallback: simple text replacement + setInput(prev => prev + (item.insertText || item.text)); + } + setCompletionOpen(false); + }, [input, onApplyCompletion]); + + const handleSubmit = () => { + const trimmed = input.trim(); + if (trimmed && !disabled) { + onSend(trimmed, selectedFiles.length > 0 ? selectedFiles : undefined); + setInput(''); + // Keep files in context for follow-up questions + } + }; + + const handlePromptOptimize = useCallback(async () => { + if (!onPromptOptimize || !input.trim() || isEnhancing || isExecuting) return; + + setIsEnhancing(true); + try { + const enhanced = await onPromptOptimize(input); + if (enhanced) { + setInput(enhanced); + } + } catch (error) { + console.error('Failed to optimize prompt:', error); + } finally { + setIsEnhancing(false); + } + }, [input, onPromptOptimize, isEnhancing, isExecuting]); + + const handleKeyDown = (e: KeyboardEvent) => { + // Don't submit if completion popup is open + if (completionOpen) return; + + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + + return ( +
+ {/* Plan Summary Bar - shown above file toolbar when a plan is active */} + + + {/* File Context Toolbar */} + setAutoAddCurrentFile(prev => !prev)} + /> + + {/* Input Area with DevIn highlighting */} +
+
+ + + {/* Completion Popup - positioned relative to input */} + setCompletionOpen(false)} + onNavigate={setSelectedCompletionIndex} + /> +
+
+ {isExecuting ? ( + + ) : ( + + )} +
+
+ + {/* Bottom Toolbar - Model Selector, Token Info, and Actions */} +
+
+ {})} + onConfigureClick={onConfigureClick || (() => {})} + /> + {/* Token usage indicator */} + {totalTokens != null && totalTokens > 0 && ( + + {totalTokens}t + + )} +
+
+ {/* MCP Config button */} + {onMcpConfigClick && ( + + )} + {/* Prompt optimization button */} + {onPromptOptimize && ( + + )} + + Enter send · Shift+Enter newline + + {onClear && ( + + )} +
+
+
+ ); +}; + diff --git a/mpp-vscode/webview/src/components/CompletionPopup.css b/mpp-vscode/webview/src/components/CompletionPopup.css new file mode 100644 index 0000000000..f82dc6a89f --- /dev/null +++ b/mpp-vscode/webview/src/components/CompletionPopup.css @@ -0,0 +1,77 @@ +/* CompletionPopup Component Styles */ + +.completion-popup { + position: absolute; + bottom: calc(100% + 4px); + left: 0; + background: var(--background); + border: 1px solid var(--panel-border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + min-width: 200px; + max-width: 350px; + max-height: 180px; + overflow-y: auto; + z-index: 1000; +} + +.completion-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + cursor: pointer; + transition: background 0.1s; +} + +.completion-item:hover, +.completion-item.selected { + background: var(--list-hover-background); +} + +.completion-item.selected { + background: var(--selection-background); +} + +.completion-icon { + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + border-radius: 3px; + flex-shrink: 0; +} + +.completion-item.command .completion-icon { + background: rgba(220, 220, 170, 0.2); + color: var(--vscode-symbolIcon-functionForeground, #dcdcaa); +} + +.completion-item.agent .completion-icon { + background: rgba(78, 201, 176, 0.2); + color: var(--vscode-symbolIcon-classForeground, #4ec9b0); +} + +.completion-item.variable .completion-icon { + background: rgba(156, 220, 254, 0.2); + color: var(--vscode-symbolIcon-variableForeground, #9cdcfe); +} + +.completion-label { + font-size: 13px; + font-weight: 500; + flex-shrink: 0; +} + +.completion-detail { + font-size: 11px; + color: var(--foreground); + opacity: 0.5; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + diff --git a/mpp-vscode/webview/src/components/CompletionPopup.tsx b/mpp-vscode/webview/src/components/CompletionPopup.tsx new file mode 100644 index 0000000000..cafcf1e294 --- /dev/null +++ b/mpp-vscode/webview/src/components/CompletionPopup.tsx @@ -0,0 +1,118 @@ +/** + * CompletionPopup Component + * + * Shows auto-completion suggestions for DevIn commands, agents, and variables. + * Uses mpp-core's CompletionManager for completion items. + */ + +import React, { useEffect, useCallback } from 'react'; +import './CompletionPopup.css'; + +// CompletionItem from mpp-core +export interface CompletionItem { + text: string; + displayText: string; + description: string | null; + icon: string | null; + triggerType?: string; + index?: number; + // Legacy fields for backward compatibility + label?: string; + detail?: string; + insertText?: string; + kind?: 'command' | 'agent' | 'variable'; +} + +interface CompletionPopupProps { + isOpen: boolean; + items: CompletionItem[]; + selectedIndex?: number; + onSelect: (item: CompletionItem, index: number) => void; + onClose: () => void; + onNavigate?: (index: number) => void; + position?: { top: number; left: number }; +} + +// Helper to get icon for completion item +const getItemIcon = (item: CompletionItem): string => { + if (item.icon) return item.icon; + if (item.triggerType === 'COMMAND') return '/'; + if (item.triggerType === 'AGENT') return '@'; + if (item.triggerType === 'VARIABLE') return '$'; + if (item.kind === 'command') return '/'; + if (item.kind === 'agent') return '@'; + if (item.kind === 'variable') return '$'; + return ''; +}; + +// Helper to get display text +const getDisplayText = (item: CompletionItem): string => { + return item.displayText || item.label || item.text; +}; + +// Helper to get description +const getDescription = (item: CompletionItem): string | null => { + return item.description || item.detail || null; +}; + +export const CompletionPopup: React.FC = ({ + isOpen, items, selectedIndex = 0, onSelect, onClose, onNavigate, position +}) => { + // Handle keyboard navigation + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (!isOpen) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + if (onNavigate) { + onNavigate(Math.min(selectedIndex + 1, items.length - 1)); + } + break; + case 'ArrowUp': + e.preventDefault(); + if (onNavigate) { + onNavigate(Math.max(selectedIndex - 1, 0)); + } + break; + case 'Enter': + case 'Tab': + e.preventDefault(); + if (items[selectedIndex]) { + onSelect(items[selectedIndex], selectedIndex); + } + break; + case 'Escape': + e.preventDefault(); + onClose(); + break; + } + }, [isOpen, items, selectedIndex, onSelect, onClose, onNavigate]); + + useEffect(() => { + if (isOpen) { + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + } + }, [isOpen, handleKeyDown]); + + if (!isOpen || items.length === 0) return null; + + return ( +
+ {items.map((item, index) => ( +
onSelect(item, index)} + onMouseEnter={() => onNavigate?.(index)} + > + {getItemIcon(item)} + {getDisplayText(item)} + {getDescription(item) && {getDescription(item)}} +
+ ))} +
+ ); +}; + diff --git a/mpp-vscode/webview/src/components/DevInInput.css b/mpp-vscode/webview/src/components/DevInInput.css new file mode 100644 index 0000000000..288a674ee7 --- /dev/null +++ b/mpp-vscode/webview/src/components/DevInInput.css @@ -0,0 +1,78 @@ +/* DevInInput Component Styles */ + +.devin-input-container { + position: relative; + flex: 1; + min-height: 36px; +} + +/* Highlight overlay - positioned behind textarea */ +.devin-highlight-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + padding: 8px 12px; + font-family: inherit; + font-size: inherit; + line-height: 1.4; + white-space: pre-wrap; + word-wrap: break-word; + overflow: hidden; + pointer-events: none; + color: transparent; + background: transparent; +} + +/* Textarea - transparent text, visible caret */ +.devin-textarea { + position: relative; + width: 100%; + padding: 8px 12px; + border: none; + background: transparent; + color: var(--foreground); + resize: none; + font-family: inherit; + font-size: inherit; + line-height: 1.4; + min-height: 36px; + max-height: 150px; + outline: none; + /* Make text visible but also show highlighting */ + caret-color: var(--foreground); +} + +.devin-textarea::placeholder { + color: var(--foreground); + opacity: 0.5; +} + +.devin-textarea:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Syntax highlighting colors */ +.devin-command { + color: var(--vscode-symbolIcon-functionForeground, #dcdcaa); + background: rgba(220, 220, 170, 0.1); + border-radius: 2px; + padding: 0 2px; +} + +.devin-agent { + color: var(--vscode-symbolIcon-classForeground, #4ec9b0); + background: rgba(78, 201, 176, 0.1); + border-radius: 2px; + padding: 0 2px; +} + +.devin-variable { + color: var(--vscode-symbolIcon-variableForeground, #9cdcfe); + background: rgba(156, 220, 254, 0.1); + border-radius: 2px; + padding: 0 2px; +} + diff --git a/mpp-vscode/webview/src/components/DevInInput.tsx b/mpp-vscode/webview/src/components/DevInInput.tsx new file mode 100644 index 0000000000..0ea2fdd0d3 --- /dev/null +++ b/mpp-vscode/webview/src/components/DevInInput.tsx @@ -0,0 +1,121 @@ +/** + * DevInInput Component + * + * A textarea with DevIn syntax highlighting overlay. + * Highlights commands (/file:, /dir:, etc.), agents (@), and variables ($). + */ + +import React, { useRef, useEffect, KeyboardEvent, useCallback } from 'react'; +import './DevInInput.css'; + +interface DevInInputProps { + value: string; + onChange: (value: string) => void; + onKeyDown?: (e: KeyboardEvent) => void; + onTriggerCompletion?: (trigger: '/' | '@' | '$', position: number) => void; + placeholder?: string; + disabled?: boolean; +} + +// DevIn syntax patterns +const PATTERNS = { + command: /\/[a-zA-Z_][a-zA-Z0-9_]*(?::[^\s\n]*)?/g, + agent: /@[a-zA-Z_][a-zA-Z0-9_]*/g, + variable: /\$[a-zA-Z_][a-zA-Z0-9_]*/g, +}; + +export const DevInInput: React.FC = ({ + value, onChange, onKeyDown, onTriggerCompletion, placeholder, disabled +}) => { + const textareaRef = useRef(null); + const highlightRef = useRef(null); + + // Sync scroll between textarea and highlight overlay + const syncScroll = useCallback(() => { + if (textareaRef.current && highlightRef.current) { + highlightRef.current.scrollTop = textareaRef.current.scrollTop; + highlightRef.current.scrollLeft = textareaRef.current.scrollLeft; + } + }, []); + + // Auto-resize textarea + useEffect(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.style.height = 'auto'; + textarea.style.height = `${Math.min(textarea.scrollHeight, 150)}px`; + } + }, [value]); + + // Handle input change and detect completion triggers + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + const cursorPos = e.target.selectionStart; + onChange(newValue); + + // Check for completion triggers + if (onTriggerCompletion && cursorPos > 0) { + const charBefore = newValue[cursorPos - 1]; + const charBeforeThat = cursorPos > 1 ? newValue[cursorPos - 2] : ' '; + + // Trigger completion if typing /, @, or $ after whitespace or at start + if ((charBefore === '/' || charBefore === '@' || charBefore === '$') && + (charBeforeThat === ' ' || charBeforeThat === '\n' || cursorPos === 1)) { + onTriggerCompletion(charBefore as '/' | '@' | '$', cursorPos); + } + } + }; + + // Generate highlighted HTML + const getHighlightedHtml = () => { + let html = escapeHtml(value); + + // Apply highlighting in order (commands, agents, variables) + html = html.replace(PATTERNS.command, '$&'); + html = html.replace(PATTERNS.agent, '$&'); + html = html.replace(PATTERNS.variable, '$&'); + + // Preserve line breaks + html = html.replace(/\n/g, '
'); + + // Add trailing space to match textarea behavior + if (html.endsWith('
') || html === '') { + html += ' '; + } + + return html; + }; + + return ( +
+