From 29e1e18bf4fdfec42cc6987bfbc0481a3eeba50d Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Wed, 3 Dec 2025 16:19:18 +0800 Subject: [PATCH 1/8] feat(mpp-ui): implement file context management with indexed search - Add SelectedFileItem, FileChip, TopToolbar, FileSearchPopup components - Add WorkspaceFileSearchProvider with pre-built file index for fast search - Add IndexingState enum for tracking indexing progress - Integrate file context into DevInEditorInput with buildAndSendMessage() - Add Prompt Enhancement button to BottomToolbar - Add AutoAwesome and History icons to AutoDevComposeIcons - Add FileContext to EditorCallbacks for file context submission Closes #35 --- .../unitmesh/devins/editor/EditorCallbacks.kt | 21 +- .../filesystem/DefaultFileSystem.jvm.kt | 26 +- .../devins/ui/compose/editor/BottomToolbar.kt | 24 ++ .../ui/compose/editor/DevInEditorInput.kt | 79 +++- .../ui/compose/editor/context/FileChip.kt | 174 ++++++++ .../compose/editor/context/FileSearchPopup.kt | 389 ++++++++++++++++++ .../editor/context/SelectedFileItem.kt | 76 ++++ .../ui/compose/editor/context/TopToolbar.kt | 223 ++++++++++ .../context/WorkspaceFileSearchProvider.kt | 202 +++++++++ .../ui/compose/icons/AutoDevComposeIcons.kt | 1 + 10 files changed, 1193 insertions(+), 22 deletions(-) create mode 100644 mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/context/FileChip.kt create mode 100644 mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/context/FileSearchPopup.kt create mode 100644 mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/context/SelectedFileItem.kt create mode 100644 mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/context/TopToolbar.kt create mode 100644 mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/editor/context/WorkspaceFileSearchProvider.kt 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/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-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>(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() } @@ -268,9 +312,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 +435,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() } @@ -448,6 +488,21 @@ 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 @@ -497,9 +552,7 @@ fun DevInEditorInput( keyboardActions = KeyboardActions( onSend = { if (textFieldValue.text.isNotBlank()) { - callbacks?.onSubmit(textFieldValue.text) - textFieldValue = TextFieldValue("") - showCompletion = false + buildAndSendMessage(textFieldValue.text) if (dismissKeyboardOnSend) { focusManager.clearFocus() } @@ -577,9 +630,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 +671,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/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 From 24c8bd217ed7959959a2daa656a0db2c03e29cf9 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Wed, 3 Dec 2025 16:54:09 +0800 Subject: [PATCH 2/8] feat(mpp-core): add plan management data models and parser for task management - Add TaskStatus enum (TODO, IN_PROGRESS, COMPLETED, FAILED, BLOCKED) and PlanPhase enum (PDCA cycle) - Add CodeFileLink data class for markdown file link extraction - Add PlanStep, PlanTask, AgentPlan data models with serialization support - Add MarkdownPlanParser using pure Kotlin regex (no IntelliJ dependencies) - Add PlanStateService with StateFlow and listener pattern for reactive updates - Add PlanUpdateListener interface for UI notifications - Add comprehensive unit tests for parser and state service - Fix DocQLReturnAllTest missing Pending branch in when expressions Part of #37 --- mpp-core/build.gradle.kts | 2 +- .../cc/unitmesh/agent/plan/AgentPlan.kt | 140 ++++++++++++++ .../cc/unitmesh/agent/plan/CodeFileLink.kt | 44 +++++ .../unitmesh/agent/plan/MarkdownPlanParser.kt | 129 +++++++++++++ .../unitmesh/agent/plan/PlanStateService.kt | 162 ++++++++++++++++ .../kotlin/cc/unitmesh/agent/plan/PlanStep.kt | 125 +++++++++++++ .../kotlin/cc/unitmesh/agent/plan/PlanTask.kt | 156 ++++++++++++++++ .../unitmesh/agent/plan/PlanUpdateListener.kt | 47 +++++ .../cc/unitmesh/agent/plan/TaskStatus.kt | 58 ++++++ .../agent/plan/MarkdownPlanParserTest.kt | 166 +++++++++++++++++ .../agent/plan/PlanStateServiceTest.kt | 173 ++++++++++++++++++ .../agent/tool/impl/DocQLReturnAllTest.kt | 2 + 12 files changed, 1203 insertions(+), 1 deletion(-) create mode 100644 mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/AgentPlan.kt create mode 100644 mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/CodeFileLink.kt create mode 100644 mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/MarkdownPlanParser.kt create mode 100644 mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanStateService.kt create mode 100644 mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanStep.kt create mode 100644 mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanTask.kt create mode 100644 mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanUpdateListener.kt create mode 100644 mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/TaskStatus.kt create mode 100644 mpp-core/src/commonTest/kotlin/cc/unitmesh/agent/plan/MarkdownPlanParserTest.kt create mode 100644 mpp-core/src/commonTest/kotlin/cc/unitmesh/agent/plan/PlanStateServiceTest.kt 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/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/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/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)}") From f60a05f8bcf3d96df2a437966d73e2293aac2fd7 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Wed, 3 Dec 2025 17:03:59 +0800 Subject: [PATCH 3/8] feat(mpp-core): add PlanManagementTool for AI agent task planning - Add PlanManagementTool with CREATE, UPDATE, COMPLETE_STEP, FAIL_STEP, VIEW actions - Add PlanManagementParams, PlanManagementSchema, PlanManagementInvocation - Add comprehensive unit tests for all tool actions - Tool integrates with PlanStateService for state management Part of #37 --- .../agent/tool/impl/PlanManagementTool.kt | 146 +++++++++++++++ .../agent/tool/impl/PlanManagementToolTest.kt | 170 ++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/PlanManagementTool.kt create mode 100644 mpp-core/src/commonTest/kotlin/cc/unitmesh/agent/tool/impl/PlanManagementToolTest.kt 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..b2ebe0c2c7 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/PlanManagementTool.kt @@ -0,0 +1,146 @@ +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)) + } +} + +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, UPDATE, COMPLETE_STEP, FAIL_STEP, VIEW + """.trimIndent() + + override val metadata: ToolMetadata = ToolMetadata( + displayName = "Plan Management", tuiEmoji = "plan_emoji", 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/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")) + } +} + From 47e7dd800724b3de2803bd5f3e21e282879b058f Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Wed, 3 Dec 2025 17:11:49 +0800 Subject: [PATCH 4/8] feat(mpp-core): register PlanManagementTool in BuiltinToolsProvider - Add PlanManagementTool to BuiltinToolsProvider.provide() - Add executePlanManagementTool method in ToolOrchestrator - Add tests for plan and task-boundary tool registration Part of #37 --- .../agent/orchestrator/ToolOrchestrator.kt | 31 +++++++++++++++++-- .../tool/registry/BuiltinToolsProvider.kt | 8 +++-- .../unitmesh/agent/tool/ToolRegistryTest.kt | 15 +++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) 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..0e14d81a23 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 @@ -383,6 +383,7 @@ class ToolOrchestrator( // 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 @@ -698,22 +699,46 @@ class ToolOrchestrator( return invocation.execute(context) } + private suspend fun executePlanManagementTool( + tool: Tool, + params: Map, + context: cc.unitmesh.agent.tool.ToolExecutionContext + ): ToolResult { + 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 ?: "" + val taskIndex = (params["taskIndex"] as? Number)?.toInt() ?: 0 + val stepIndex = (params["stepIndex"] as? Number)?.toInt() ?: 0 + + val planParams = cc.unitmesh.agent.tool.impl.PlanManagementParams( + action = action, + planMarkdown = planMarkdown, + taskIndex = taskIndex, + stepIndex = stepIndex + ) + + val invocation = planTool.createInvocation(planParams) + return invocation.execute(context) + } + 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) } 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..23adff04ad 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,6 +6,7 @@ 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 @@ -35,7 +36,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 +54,10 @@ class BuiltinToolsProvider : ToolProvider { } tools.add(WebFetchTool(dependencies.llmService)) - - // Task management tool + + // Task management tools tools.add(TaskBoundaryTool()) + tools.add(PlanManagementTool()) tools.add(DocQLTool()) return tools 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..ebbecc052e 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,19 @@ 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") + } + + @Test + fun testTaskBoundaryToolRegistered() { + val taskBoundaryTool = registry.getTool("task-boundary") + assertNotNull(taskBoundaryTool, "Task boundary tool should be registered") + assertEquals("task-boundary", taskBoundaryTool.name) + } } From 067802c7e281d39d63b4a5e5f89510416e8f220b Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Wed, 3 Dec 2025 17:21:08 +0800 Subject: [PATCH 5/8] feat(mpp-ui): add PlanPanel UI component and integrate with ComposeRenderer - Create PlanPanel composable with task and step display - Add expandable task cards with progress tracking - Implement status icons and colors for plan visualization - Integrate plan state tracking in ComposeRenderer - Handle plan tool calls to update UI state Part of #37 --- .../ui/compose/agent/ComposeRenderer.kt | 59 +++++ .../devins/ui/compose/agent/PlanPanel.kt | 232 ++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/PlanPanel.kt 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 + ) + } +} + From d572bba35d07a9ddd3029369a133861fbbad3787 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Wed, 3 Dec 2025 19:00:29 +0800 Subject: [PATCH 6/8] feat(mpp-core): add plan management guidance to system prompt and fix parameter parsing - Add Planning and Task Management section to CodingAgentTemplate (EN and ZH) - Document when to use planning, plan format, and plan actions - Update Task Completion Strategy to include planning step - Fix taskIndex/stepIndex parameter parsing in ToolOrchestrator to handle both Number and String types Part of #37 --- .../cc/unitmesh/agent/CodingAgentTemplate.kt | 82 +++++++++++++++++-- .../agent/orchestrator/ToolOrchestrator.kt | 14 +++- 2 files changed, 86 insertions(+), 10 deletions(-) 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/orchestrator/ToolOrchestrator.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolOrchestrator.kt index 0e14d81a23..ff5e6c5c57 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 @@ -709,8 +709,18 @@ class ToolOrchestrator( val action = params["action"] as? String ?: return ToolResult.Error("action parameter is required") val planMarkdown = params["planMarkdown"] as? String ?: "" - val taskIndex = (params["taskIndex"] as? Number)?.toInt() ?: 0 - val stepIndex = (params["stepIndex"] as? Number)?.toInt() ?: 0 + + // 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, From 2f9aa863481d3063a066d4b409ae956e3ceb9945 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Wed, 3 Dec 2025 19:06:25 +0800 Subject: [PATCH 7/8] refactor(mpp-core): remove TaskBoundaryTool in favor of PlanManagementTool - Remove TaskBoundaryTool.kt as PlanManagementTool provides superset functionality - Remove TaskBoundaryTool from BuiltinToolsProvider and ToolOrchestrator - Update ToolRegistryTest to remove task-boundary test - Update comments referencing task-boundary - Enhance PlanManagementTool with detailed KDoc documentation - Fix tuiEmoji to use proper emoji character Part of #37 --- .../agent/orchestrator/ToolOrchestrator.kt | 26 +-- .../unitmesh/agent/render/RendererModels.kt | 2 +- .../agent/tool/impl/PlanManagementTool.kt | 50 ++++- .../agent/tool/impl/TaskBoundaryTool.kt | 206 ------------------ .../tool/registry/BuiltinToolsProvider.kt | 2 - .../unitmesh/agent/tool/ToolRegistryTest.kt | 7 - 6 files changed, 50 insertions(+), 243 deletions(-) delete mode 100644 mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/TaskBoundaryTool.kt 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 ff5e6c5c57..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,6 @@ 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 -> { @@ -676,29 +675,6 @@ class ToolOrchestrator( return invocation.execute(context) } - private suspend fun executeTaskBoundaryTool( - 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 invocation = taskBoundaryTool.createInvocation(taskBoundaryParams) - return invocation.execute(context) - } - private suspend fun executePlanManagementTool( tool: Tool, params: Map, @@ -773,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/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 index b2ebe0c2c7..5fd27ab0b5 100644 --- 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 @@ -116,6 +116,44 @@ class PlanManagementInvocation( } } +/** + * 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() { @@ -124,11 +162,19 @@ class PlanManagementTool( 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, UPDATE, COMPLETE_STEP, FAIL_STEP, VIEW + + 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 = "plan_emoji", composeIcon = "plan", + displayName = "Plan Management", tuiEmoji = "📋", composeIcon = "plan", category = ToolCategory.Utility, schema = PlanManagementSchema ) 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 23adff04ad..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 @@ -9,7 +9,6 @@ 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 @@ -56,7 +55,6 @@ class BuiltinToolsProvider : ToolProvider { tools.add(WebFetchTool(dependencies.llmService)) // Task management tools - tools.add(TaskBoundaryTool()) tools.add(PlanManagementTool()) tools.add(DocQLTool()) 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 ebbecc052e..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 @@ -76,11 +76,4 @@ class ToolRegistryTest { assertEquals("plan", planTool.name) assertTrue(planTool.description.contains("plan"), "Should have plan-related description") } - - @Test - fun testTaskBoundaryToolRegistered() { - val taskBoundaryTool = registry.getTool("task-boundary") - assertNotNull(taskBoundaryTool, "Task boundary tool should be registered") - assertEquals("task-boundary", taskBoundaryTool.name) - } } From 495cb964869e94487150c8c4ec44b55c99de8eee Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Wed, 3 Dec 2025 19:29:13 +0800 Subject: [PATCH 8/8] fix(executor): reduce shell command max timeout to 2 minutes Lower the maximum wait time for shell command execution to 2 minutes and pass it as a timeout to the orchestrator context. --- .../cc/unitmesh/agent/executor/CodingAgentExecutor.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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(