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..39ac709e91 100644
--- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/CodingAgentTemplate.kt
+++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/CodingAgentTemplate.kt
@@ -36,15 +36,48 @@ All tools use the DevIns format with JSON parameters:
```
+# Planning and Task Management
+
+For complex multi-step tasks, use the `/plan` tool to create and track progress:
+
+## When to Use Planning
+- Tasks requiring multiple files to be created or modified
+- Tasks with dependencies between steps
+- Tasks that benefit from structured tracking
+
+## Plan Format
+```markdown
+1. Task Title
+ - [ ] Step 1 description
+ - [ ] Step 2 description
+
+2. Another Task
+ - [ ] Step description
+```
+
+## Plan Actions
+- `CREATE`: Create a new plan with markdown content
+- `COMPLETE_STEP`: Mark a step as done (taskIndex=1, stepIndex=1 for first step of first task)
+- `VIEW`: View current plan status
+
+Example:
+
+/plan
+```json
+{"action": "CREATE", "planMarkdown": "1. Setup\n - [ ] Create entity class\n - [ ] Create repository\n\n2. Implementation\n - [ ] Create service\n - [ ] Create controller"}
+```
+
+
# Task Completion Strategy
**IMPORTANT: Focus on completing the task efficiently.**
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
+2. **Plan if Complex**: For multi-step tasks, create a plan first using `/plan`
+3. **Gather Minimum Required Information**: Only collect information directly needed for the task
+4. **Execute the Task**: Make the necessary changes, marking steps complete as you go
+5. **Verify if Needed**: For code changes, compile/test to verify
+6. **Provide Summary**: Always end with a clear summary of what was done
**Avoid over-exploration**: Don't spend iterations exploring unrelated code. Stay focused on the task.
@@ -161,15 +194,48 @@ ${'$'}{toolList}
```
+# 计划和任务管理
+
+对于复杂的多步骤任务,使用 `/plan` 工具来创建和跟踪进度:
+
+## 何时使用计划
+- 需要创建或修改多个文件的任务
+- 步骤之间有依赖关系的任务
+- 需要结构化跟踪的任务
+
+## 计划格式
+```markdown
+1. 任务标题
+ - [ ] 步骤1描述
+ - [ ] 步骤2描述
+
+2. 另一个任务
+ - [ ] 步骤描述
+```
+
+## 计划操作
+- `CREATE`: 使用 markdown 内容创建新计划
+- `COMPLETE_STEP`: 标记步骤完成 (taskIndex=1, stepIndex=1 表示第一个任务的第一个步骤)
+- `VIEW`: 查看当前计划状态
+
+示例:
+
+/plan
+```json
+{"action": "CREATE", "planMarkdown": "1. 设置\n - [ ] 创建实体类\n - [ ] 创建仓库\n\n2. 实现\n - [ ] 创建服务\n - [ ] 创建控制器"}
+```
+
+
# 任务完成策略
**重要:专注于高效完成任务。**
1. **理解任务**:仔细阅读用户的请求
-2. **收集最少必要信息**:只收集任务直接需要的信息
-3. **执行任务**:进行必要的更改或提供答案
-4. **必要时验证**:对于代码更改,编译/测试以验证
-5. **提供总结**:始终以清晰的总结结束
+2. **复杂任务先计划**:对于多步骤任务,先使用 `/plan` 创建计划
+3. **收集最少必要信息**:只收集任务直接需要的信息
+4. **执行任务**:进行必要的更改,完成后标记步骤
+5. **必要时验证**:对于代码更改,编译/测试以验证
+6. **提供总结**:始终以清晰的总结结束
**避免过度探索**:不要花费迭代次数探索无关代码。保持专注于任务。
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..09378b624e 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
@@ -24,8 +24,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
)
@@ -212,7 +212,8 @@ class CodingAgentExecutor(
val executionContext = OrchestratorContext(
workingDirectory = projectPath,
- environment = emptyMap()
+ environment = emptyMap(),
+ timeout = asyncShellConfig.maxWaitTimeoutMs // Use max timeout for shell commands
)
var executionResult = toolOrchestrator.executeToolCall(
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..37c9c3879d 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
@@ -382,7 +382,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,26 +675,37 @@ 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
+ }
+
+ val planParams = cc.unitmesh.agent.tool.impl.PlanManagementParams(
+ action = action,
+ planMarkdown = planMarkdown,
+ taskIndex = taskIndex,
+ stepIndex = stepIndex
)
-
- val invocation = taskBoundaryTool.createInvocation(taskBoundaryParams)
+
+ val invocation = planTool.createInvocation(planParams)
return invocation.execute(context)
}
@@ -704,16 +715,16 @@ class ToolOrchestrator(
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 +749,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..3f5ff711ba
--- /dev/null
+++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/MarkdownPlanParser.kt
@@ -0,0 +1,129 @@
+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"
+ private val TASK_HEADER_PATTERN = Regex("^(\\d+)\\.\\s*(?:\\[([xX!*✓]?)\\]\\s*)?(.+?)(?:\\s*\\[([xX!*✓]?)\\])?$")
+
+ // Pattern for step items: "- [x] Step description"
+ 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..4628431920
--- /dev/null
+++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanStep.kt
@@ -0,0 +1,125 @@
+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 {
+ 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/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/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/tool/impl/PlanManagementTool.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/PlanManagementTool.kt
new file mode 100644
index 0000000000..5fd27ab0b5
--- /dev/null
+++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/PlanManagementTool.kt
@@ -0,0 +1,192 @@
+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.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 }
+
+@Serializable
+data class PlanManagementParams(
+ val action: String,
+ val planMarkdown: String = "",
+ val taskIndex: Int = 0,
+ val stepIndex: Int = 0
+)
+
+object PlanManagementSchema : DeclarativeToolSchema(
+ description = "Manage task plans for complex multi-step work.",
+ properties = mapOf(
+ "action" to string(description = "Action: CREATE, UPDATE, COMPLETE_STEP, FAIL_STEP, VIEW", required = true,
+ enum = listOf("CREATE", "UPDATE", "COMPLETE_STEP", "FAIL_STEP", "VIEW")),
+ "planMarkdown" to string(description = "Plan content in markdown format", required = false),
+ "taskIndex" to string(description = "1-based task index", required = false),
+ "stepIndex" to string(description = "1-based step index", required = false)
+ )
+) {
+ override fun getExampleUsage(toolName: String): String =
+ """/$toolName action="CREATE" planMarkdown="1. Setup\n - [ ] Init project""""
+}
+
+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)
+ 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()))
+ }
+
+ 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 {
+ if (params.taskIndex <= 0 || params.stepIndex <= 0) {
+ return ToolResult.Error("taskIndex and stepIndex must be positive", ToolErrorType.MISSING_REQUIRED_PARAMETER.code)
+ }
+ val currentPlan = planStateService.currentPlan.value
+ ?: return ToolResult.Error("No active plan", ToolErrorType.FILE_NOT_FOUND.code)
+ val taskIdx = params.taskIndex - 1
+ val stepIdx = params.stepIndex - 1
+ if (taskIdx >= currentPlan.tasks.size) {
+ return ToolResult.Error("Task index out of range", ToolErrorType.PARAMETER_OUT_OF_RANGE.code)
+ }
+ val task = currentPlan.tasks[taskIdx]
+ if (stepIdx >= task.steps.size) {
+ return ToolResult.Error("Step index out of range", ToolErrorType.PARAMETER_OUT_OF_RANGE.code)
+ }
+ val step = task.steps[stepIdx]
+ planStateService.updateStepStatus(task.id, step.id, status)
+ val updatedPlan = planStateService.currentPlan.value!!
+ val updatedStep = updatedPlan.tasks[taskIdx].steps[stepIdx]
+ val statusText = if (status == PlanTaskStatus.COMPLETED) "completed" else "failed"
+ return ToolResult.Success("Step $statusText: ${updatedStep.description}\n\n${updatedPlan.toMarkdown()}",
+ mapOf("task_id" to task.id, "step_index" to params.stepIndex.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)
+ - UPDATE: Update existing plan with new markdown
+ - 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
+
+ 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/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-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/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-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..8d28dcc352 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(
@@ -150,6 +156,11 @@ class ComposeRenderer : BaseRenderer() {
updateTaskFromToolCall(params)
}
+ // Handle plan management tool - update plan state
+ if (toolName == "plan") {
+ updatePlanFromToolCall(params)
+ }
+
// Extract file path for read/write operations
val filePath =
when (toolType) {
@@ -220,6 +231,54 @@ class ComposeRenderer : BaseRenderer() {
}
}
+ /**
+ * Update plan state from plan management tool call
+ */
+ private fun updatePlanFromToolCall(params: Map) {
+ val action = params["action"]?.uppercase() ?: return
+ val planMarkdown = params["planMarkdown"] ?: ""
+
+ when (action) {
+ "CREATE", "UPDATE" -> {
+ if (planMarkdown.isNotBlank()) {
+ _currentPlan = MarkdownPlanParser.parseToPlan(planMarkdown)
+ }
+ }
+ "COMPLETE_STEP" -> {
+ val taskIndex = params["taskIndex"]?.toIntOrNull() ?: return
+ val stepIndex = params["stepIndex"]?.toIntOrNull() ?: 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" -> {
+ val taskIndex = params["taskIndex"]?.toIntOrNull() ?: return
+ val stepIndex = params["stepIndex"]?.toIntOrNull() ?: 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,
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..cff04f512b 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
@@ -33,10 +33,16 @@ 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.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 +80,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 +101,43 @@ fun DevInEditorInput(
var mcpServers by remember { mutableStateOf