From eb00356c88a55d1b83cbe8601a29504769c0bce8 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 00:00:18 +0800 Subject: [PATCH 01/37] chore(plugin): update plugin name, vendor email, and description Renamed plugin to "AutoDev Next", updated vendor email, and revised description for clarity. --- mpp-idea/src/main/resources/META-INF/plugin.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mpp-idea/src/main/resources/META-INF/plugin.xml b/mpp-idea/src/main/resources/META-INF/plugin.xml index f3c963d3f7..6101d71f1c 100644 --- a/mpp-idea/src/main/resources/META-INF/plugin.xml +++ b/mpp-idea/src/main/resources/META-INF/plugin.xml @@ -1,11 +1,11 @@ cc.unitmesh.devins.idea - AutoDev Compose UI - UnitMesh + AutoDev Next + UnitMesh From 20397b11a27032e8e56c9640715ba567e6800bc5 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 00:11:42 +0800 Subject: [PATCH 02/37] feat(ui): add platform-specific UTF-8 font support Introduce getUtf8FontFamily() for proper CJK/UTF-8 rendering on WASM using Noto Sans SC, while other platforms use system defaults. Also exclude heavy dependencies and large font assets from the IntelliJ plugin build to reduce size. --- mpp-idea/build.gradle.kts | 41 +++++++++++++++++++ mpp-ui/build.gradle.kts | 3 +- .../ui/compose/sketch/CodeFont.android.kt | 8 ++++ .../ui/compose/agent/AgentMessageList.kt | 7 +--- .../ui/compose/editor/DevInEditorInput.kt | 6 +-- .../devins/ui/compose/sketch/CodeFont.kt | 9 ++++ .../devins/ui/compose/sketch/CodeFont.ios.kt | 8 ++++ .../devins/ui/compose/sketch/CodeFont.js.kt | 8 ++++ .../devins/ui/compose/sketch/CodeFont.jvm.kt | 8 ++++ .../ui/compose/sketch/CodeFont.wasmJs.kt | 15 ++++++- 10 files changed, 102 insertions(+), 11 deletions(-) diff --git a/mpp-idea/build.gradle.kts b/mpp-idea/build.gradle.kts index 323b6c2987..5cf4bf53e4 100644 --- a/mpp-idea/build.gradle.kts +++ b/mpp-idea/build.gradle.kts @@ -37,6 +37,31 @@ repositories { } } +// Global exclusions for heavy dependencies not needed in IntelliJ plugin +// These exclusions apply to ALL configurations including transitive dependencies from includeBuild projects +configurations.all { + exclude(group = "aws.sdk.kotlin") // AWS SDK (~30MB) - from ai.koog:prompt-executor-bedrock-client + exclude(group = "aws.smithy.kotlin") // AWS Smithy runtime + exclude(group = "org.apache.tika") // Apache Tika (~100MB) - document parsing + exclude(group = "org.apache.poi") // Apache POI - Office document parsing (from Tika) + exclude(group = "org.apache.pdfbox") // PDFBox (~10MB) - PDF parsing + exclude(group = "net.sourceforge.plantuml") // PlantUML (~20MB) - from dev.snipme:highlights + exclude(group = "org.jsoup") // Jsoup HTML parser + exclude(group = "ai.koog", module = "prompt-executor-bedrock-client") // Bedrock executor + // Redis/Lettuce - not needed in IDEA plugin (~5MB) + exclude(group = "io.lettuce") + exclude(group = "io.projectreactor") + // RxJava - not needed (~2.6MB) + exclude(group = "io.reactivex.rxjava3") + // RSyntaxTextArea - IDEA has its own editor (~1.3MB) + exclude(group = "com.fifesoft") + // Netty - not needed for IDEA plugin (~3MB) + exclude(group = "io.netty") + // pty4j/jediterm - IDEA has its own terminal (~3MB) + exclude(group = "org.jetbrains.pty4j") + exclude(group = "org.jetbrains.jediterm") +} + dependencies { // Depend on mpp-ui and mpp-core JVM targets for shared UI components and ConfigManager // For KMP projects, we need to depend on the JVM target specifically @@ -98,6 +123,7 @@ dependencies { exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-serialization-core-jvm") exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-io-core") exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-io-core-jvm") + // Note: Heavy dependencies (AWS, Tika, POI, PDFBox, PlantUML, Jsoup) are excluded globally above } // Use platform-provided kotlinx libraries to avoid classloader conflicts @@ -192,6 +218,21 @@ tasks { useJUnitPlatform() } + // Exclude large font files from mpp-ui that are not needed in IDEA plugin + // These fonts are for Desktop/WASM apps, IDEA has its own fonts + named("prepareSandbox") { + // Exclude font files from the plugin distribution + // NotoSansSC-Regular.ttf (~18MB), NotoColorEmoji.ttf (~11MB x2), FiraCode fonts (~1MB) + exclude("**/fonts/**") + exclude("**/composeResources/**/font/**") + exclude("**/*.ttf") + exclude("**/*.otf") + // Also exclude icon files meant for desktop app + exclude("**/icon.icns") + exclude("**/icon.ico") + exclude("**/icon-512.png") + } + // Task to verify no conflicting dependencies are included register("verifyNoDuplicateDependencies") { group = "verification" diff --git a/mpp-ui/build.gradle.kts b/mpp-ui/build.gradle.kts index c5f321e74c..eeb55f8719 100644 --- a/mpp-ui/build.gradle.kts +++ b/mpp-ui/build.gradle.kts @@ -696,7 +696,8 @@ tasks.register("downloadWasmFonts") { group = "build" description = "Download fonts for WASM UTF-8 support (not committed to Git)" - fontDir.set(file("src/commonMain/composeResources/font")) + // Fonts are only needed for WASM platform, so download to wasmJsMain + fontDir.set(file("src/wasmJsMain/composeResources/font")) useCJKFont.set(project.findProperty("useCJKFont")?.toString()?.toBoolean() ?: true) } diff --git a/mpp-ui/src/androidMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.android.kt b/mpp-ui/src/androidMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.android.kt index d2756605ce..102f83de33 100644 --- a/mpp-ui/src/androidMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.android.kt +++ b/mpp-ui/src/androidMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.android.kt @@ -1,5 +1,6 @@ package cc.unitmesh.devins.ui.compose.sketch +import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -30,3 +31,10 @@ actual fun getFiraCodeFontFamily(): FontFamily { FontFamily.Monospace } } + +/** + * Android implementation - use default font family + * Android has good system font support for UTF-8 + */ +@Composable +actual fun getUtf8FontFamily(): FontFamily = FontFamily.Default diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentMessageList.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentMessageList.kt index 0ba49a12ab..69dcd043e9 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentMessageList.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentMessageList.kt @@ -17,17 +17,14 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import autodev_intellij.mpp_ui.generated.resources.NotoSansSC_Regular -import autodev_intellij.mpp_ui.generated.resources.Res -import cc.unitmesh.agent.Platform import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.devins.llm.Message import cc.unitmesh.devins.llm.MessageRole import cc.unitmesh.devins.ui.compose.icons.AutoDevComposeIcons import cc.unitmesh.devins.ui.compose.sketch.SketchRenderer +import cc.unitmesh.devins.ui.compose.sketch.getUtf8FontFamily import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.Font @Composable fun AgentMessageList( @@ -278,7 +275,7 @@ fun MessageItem( } else { Text( text = message.content, - fontFamily = if (Platform.isWasm) FontFamily(Font(Res.font.NotoSansSC_Regular)) else FontFamily.Monospace, + fontFamily = getUtf8FontFamily(), style = MaterialTheme.typography.bodyMedium ) } 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 3a45add3ce..b693d16301 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 @@ -25,8 +25,6 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import autodev_intellij.mpp_ui.generated.resources.NotoSansSC_Regular -import autodev_intellij.mpp_ui.generated.resources.Res import cc.unitmesh.agent.Platform import cc.unitmesh.agent.mcp.McpClientManager import cc.unitmesh.agent.mcp.McpConfig @@ -41,13 +39,13 @@ 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.highlighting.DevInSyntaxHighlighter import cc.unitmesh.devins.ui.config.ConfigManager +import cc.unitmesh.devins.ui.compose.sketch.getUtf8FontFamily import cc.unitmesh.devins.workspace.WorkspaceManager import cc.unitmesh.llm.KoogLLMService import cc.unitmesh.llm.ModelConfig import cc.unitmesh.llm.PromptEnhancer import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.Font /** * DevIn 编辑器输入组件 @@ -485,7 +483,7 @@ fun DevInEditorInput( .onPreviewKeyEvent { handleKeyEvent(it) }, textStyle = TextStyle( - fontFamily = if (Platform.isWasm) FontFamily(Font(Res.font.NotoSansSC_Regular)) else FontFamily.Monospace, + fontFamily = getUtf8FontFamily(), fontSize = inputFontSize, color = MaterialTheme.colorScheme.onSurface, lineHeight = inputLineHeight diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.kt index 7053a1fa0c..7999a37b04 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.kt @@ -1,5 +1,6 @@ package cc.unitmesh.devins.ui.compose.sketch +import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.FontFamily /** @@ -12,3 +13,11 @@ expect fun getFiraCodeFontFamily(): FontFamily * Get default monospace font family as fallback */ fun getDefaultMonospaceFontFamily(): FontFamily = FontFamily.Monospace + +/** + * Get UTF-8 font family for text display + * On WASM, returns Noto Sans SC for CJK support + * On other platforms, returns default font family + */ +@Composable +expect fun getUtf8FontFamily(): FontFamily diff --git a/mpp-ui/src/iosMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.ios.kt b/mpp-ui/src/iosMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.ios.kt index 61a74f917d..a9323e9558 100644 --- a/mpp-ui/src/iosMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.ios.kt +++ b/mpp-ui/src/iosMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.ios.kt @@ -1,5 +1,6 @@ package cc.unitmesh.devins.ui.compose.sketch +import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.FontFamily /** @@ -12,3 +13,10 @@ actual fun getFiraCodeFontFamily(): FontFamily { return FontFamily.Monospace } +/** + * iOS implementation - use default font family + * iOS has good system font support for UTF-8 + */ +@Composable +actual fun getUtf8FontFamily(): FontFamily = FontFamily.Default + diff --git a/mpp-ui/src/jsMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.js.kt b/mpp-ui/src/jsMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.js.kt index 1cca68b762..d18464c1ec 100644 --- a/mpp-ui/src/jsMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.js.kt +++ b/mpp-ui/src/jsMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.js.kt @@ -1,5 +1,6 @@ package cc.unitmesh.devins.ui.compose.sketch +import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.FontFamily /** @@ -14,3 +15,10 @@ actual fun getFiraCodeFontFamily(): FontFamily { // The browser will use the system's monospace font return FontFamily.Monospace } + +/** + * JS implementation - use default font family + * Browser has good system font support for UTF-8 + */ +@Composable +actual fun getUtf8FontFamily(): FontFamily = FontFamily.Default diff --git a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.jvm.kt b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.jvm.kt index 168039403d..5edd109a24 100644 --- a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.jvm.kt +++ b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.jvm.kt @@ -1,5 +1,6 @@ package cc.unitmesh.devins.ui.compose.sketch +import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.platform.Font @@ -29,3 +30,10 @@ actual fun getFiraCodeFontFamily(): FontFamily { FontFamily.Monospace } } + +/** + * JVM implementation - use default font family + * JVM has good system font support for UTF-8 + */ +@Composable +actual fun getUtf8FontFamily(): FontFamily = FontFamily.Default diff --git a/mpp-ui/src/wasmJsMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.wasmJs.kt b/mpp-ui/src/wasmJsMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.wasmJs.kt index d6ea550974..c6ffa413ce 100644 --- a/mpp-ui/src/wasmJsMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.wasmJs.kt +++ b/mpp-ui/src/wasmJsMain/kotlin/cc/unitmesh/devins/ui/compose/sketch/CodeFont.wasmJs.kt @@ -1,14 +1,27 @@ package cc.unitmesh.devins.ui.compose.sketch +import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.FontFamily +import autodev_intellij.mpp_ui.generated.resources.NotoSansSC_Regular +import autodev_intellij.mpp_ui.generated.resources.Res +import org.jetbrains.compose.resources.Font /** * WASM JS implementation of FiraCode font loading * Falls back to default monospace - * + * * Note: This is non-composable to match the expect declaration in commonMain */ actual fun getFiraCodeFontFamily(): FontFamily { // WASM uses default monospace for code return FontFamily.Monospace } + +/** + * WASM implementation - use Noto Sans SC for CJK support + * This font is only bundled in WASM builds (~18MB) + */ +@Composable +actual fun getUtf8FontFamily(): FontFamily { + return FontFamily(Font(Res.font.NotoSansSC_Regular)) +} From 9f9b0096ba5ce844fdc3ec133b9647b65c6ded0f Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 08:04:10 +0800 Subject: [PATCH 03/37] feat: add pluggable DevInsCompilerService for switchable compiler core - Add DevInsCompilerService interface in mpp-core with global instance management - Add DefaultDevInsCompilerService using mpp-core's AST-based compiler - Add IdeaDevInsCompilerService in mpp-idea using devins-lang's PSI-based compiler - Modify KoogLLMService to accept optional compiler service injection - Update IdeaAgentViewModel to inject IDEA compiler for full IDE feature support - Add unit tests for DevInsCompilerService This enables mpp-idea to use the full-featured PSI-based DevInsCompiler with IDE capabilities (Symbol resolution, Refactor, Database, etc.) while CLI/Desktop continues using the cross-platform AST-based compiler. --- .../kotlin/cc/unitmesh/agent/CodingAgent.kt | 2 +- .../service/DefaultDevInsCompilerService.kt | 70 +++++++++ .../compiler/service/DevInsCompilerService.kt | 101 +++++++++++++ .../kotlin/cc/unitmesh/llm/KoogLLMService.kt | 36 +++-- .../service/DevInsCompilerServiceTest.kt | 120 ++++++++++++++++ .../compiler/IdeaDevInsCompilerService.kt | 133 ++++++++++++++++++ .../idea/toolwindow/IdeaAgentViewModel.kt | 13 +- 7 files changed, 459 insertions(+), 16 deletions(-) create mode 100644 mpp-core/src/commonMain/kotlin/cc/unitmesh/devins/compiler/service/DefaultDevInsCompilerService.kt create mode 100644 mpp-core/src/commonMain/kotlin/cc/unitmesh/devins/compiler/service/DevInsCompilerService.kt create mode 100644 mpp-core/src/jvmTest/kotlin/cc/unitmesh/devins/compiler/service/DevInsCompilerServiceTest.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/compiler/IdeaDevInsCompilerService.kt diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/CodingAgent.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/CodingAgent.kt index 1aebfd227e..fc796c37ea 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/CodingAgent.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/CodingAgent.kt @@ -86,7 +86,7 @@ class CodingAgent( private val toolOrchestrator = ToolOrchestrator(toolRegistry, policyEngine, renderer, mcpConfigService = mcpToolConfigService) private val errorRecoveryAgent = ErrorRecoveryAgent(projectPath, llmService) - private val analysisAgent = AnalysisAgent(llmService, contentThreshold = 5000) + private val analysisAgent = AnalysisAgent(llmService, contentThreshold = 15000) private val mcpToolsInitializer = McpToolsInitializer() // 执行器 diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/devins/compiler/service/DefaultDevInsCompilerService.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/devins/compiler/service/DefaultDevInsCompilerService.kt new file mode 100644 index 0000000000..2b01a0d8c4 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/devins/compiler/service/DefaultDevInsCompilerService.kt @@ -0,0 +1,70 @@ +package cc.unitmesh.devins.compiler.service + +import cc.unitmesh.devins.compiler.DevInsCompiler +import cc.unitmesh.devins.compiler.context.CompilerContext +import cc.unitmesh.devins.compiler.result.DevInsCompiledResult +import cc.unitmesh.devins.compiler.variable.VariableScope +import cc.unitmesh.devins.compiler.variable.VariableType +import cc.unitmesh.devins.filesystem.ProjectFileSystem + +/** + * 默认的 DevIns 编译器服务实现 + * + * 使用 mpp-core 的 DevInsCompiler,基于自定义 AST 解析器。 + * 适用于 CLI、Desktop、WASM 等跨平台环境。 + * + * 特点: + * - 跨平台支持(JS, WASM, Desktop JVM, Android, iOS) + * - 基于自定义 DevInsParser 解析 + * - 命令输出为占位符格式(如 {{FILE_CONTENT:path}}) + * - 不支持 IDE 特定功能(Symbol 解析、重构等) + */ +class DefaultDevInsCompilerService : DevInsCompilerService { + + override suspend fun compile(source: String, fileSystem: ProjectFileSystem): DevInsCompiledResult { + val context = CompilerContext().apply { + this.fileSystem = fileSystem + } + val compiler = DevInsCompiler(context) + return compiler.compileFromSource(source) + } + + override suspend fun compile( + source: String, + fileSystem: ProjectFileSystem, + variables: Map + ): DevInsCompiledResult { + val context = CompilerContext().apply { + this.fileSystem = fileSystem + } + + // 添加自定义变量 + variables.forEach { (name, value) -> + context.variableTable.addVariable( + name = name, + varType = inferVariableType(value), + value = value, + scope = VariableScope.USER_DEFINED + ) + } + + val compiler = DevInsCompiler(context) + return compiler.compileFromSource(source) + } + + override fun supportsIdeFeatures(): Boolean = false + + override fun getName(): String = "DefaultDevInsCompilerService (mpp-core)" + + private fun inferVariableType(value: Any): VariableType { + return when (value) { + is String -> VariableType.STRING + is Int, is Long, is Double, is Float -> VariableType.NUMBER + is Boolean -> VariableType.BOOLEAN + is List<*> -> VariableType.ARRAY + is Map<*, *> -> VariableType.OBJECT + else -> VariableType.UNKNOWN + } + } +} + diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/devins/compiler/service/DevInsCompilerService.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/devins/compiler/service/DevInsCompilerService.kt new file mode 100644 index 0000000000..1feaf0d270 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/devins/compiler/service/DevInsCompilerService.kt @@ -0,0 +1,101 @@ +package cc.unitmesh.devins.compiler.service + +import cc.unitmesh.devins.compiler.result.DevInsCompiledResult +import cc.unitmesh.devins.filesystem.ProjectFileSystem +import kotlin.concurrent.Volatile + +/** + * DevIns 编译器服务接口 + * + * 提供可切换的编译器核心,支持: + * - mpp-core 默认实现(跨平台,基于自定义 AST) + * - IDEA 专用实现(基于 PSI,支持 IDE 功能如 Symbol 解析、重构等) + * + * 使用方式: + * ```kotlin + * // 在 mpp-idea 中使用 IDEA 编译器 + * val compilerService = IdeaDevInsCompilerService(project) + * val llmService = KoogLLMService(config, compilerService = compilerService) + * + * // 在 CLI/Desktop 中使用默认编译器 + * val llmService = KoogLLMService(config) + * // compilerService 默认为 DefaultDevInsCompilerService + * ``` + */ +interface DevInsCompilerService { + + /** + * 编译 DevIns 源代码 + * + * @param source DevIns 源代码字符串 + * @param fileSystem 项目文件系统,用于解析文件路径 + * @return 编译结果 + */ + suspend fun compile(source: String, fileSystem: ProjectFileSystem): DevInsCompiledResult + + /** + * 编译 DevIns 源代码,带有自定义变量 + * + * @param source DevIns 源代码字符串 + * @param fileSystem 项目文件系统 + * @param variables 自定义变量映射 + * @return 编译结果 + */ + suspend fun compile( + source: String, + fileSystem: ProjectFileSystem, + variables: Map + ): DevInsCompiledResult + + /** + * 检查编译器是否支持 IDE 功能 + * + * IDE 功能包括: + * - Symbol 解析 (/symbol 命令) + * - 代码重构 (/refactor 命令) + * - 数据库操作 (/database 命令) + * - 代码结构分析 (/structure 命令) + * - 符号使用查找 (/usage 命令) + * + * @return true 如果支持 IDE 功能 + */ + fun supportsIdeFeatures(): Boolean = false + + /** + * 获取编译器名称,用于日志和调试 + */ + fun getName(): String + + companion object { + /** + * 全局编译器服务实例 + * 可以在应用启动时设置为 IDEA 专用实现 + */ + @Volatile + private var instance: DevInsCompilerService? = null + + /** + * 获取当前编译器服务实例 + * 如果未设置,返回默认实现 + */ + fun getInstance(): DevInsCompilerService { + return instance ?: DefaultDevInsCompilerService() + } + + /** + * 设置全局编译器服务实例 + * 应在应用启动时调用 + */ + fun setInstance(service: DevInsCompilerService) { + instance = service + } + + /** + * 重置为默认实现 + */ + fun reset() { + instance = null + } + } +} + diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/llm/KoogLLMService.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/llm/KoogLLMService.kt index b9cb556e58..536e7e7008 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/llm/KoogLLMService.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/llm/KoogLLMService.kt @@ -7,8 +7,7 @@ import ai.koog.prompt.llm.LLModel import ai.koog.prompt.params.LLMParams import ai.koog.prompt.streaming.StreamFrame import cc.unitmesh.agent.logging.getLogger -import cc.unitmesh.devins.compiler.DevInsCompilerFacade -import cc.unitmesh.devins.compiler.context.CompilerContext +import cc.unitmesh.devins.compiler.service.DevInsCompilerService import cc.unitmesh.devins.filesystem.EmptyFileSystem import cc.unitmesh.devins.filesystem.ProjectFileSystem import cc.unitmesh.devins.llm.Message @@ -17,29 +16,40 @@ import cc.unitmesh.llm.compression.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.onCompletion -import kotlinx.serialization.json.Json import kotlinx.datetime.Clock +/** + * LLM 服务 + * + * @param config 模型配置 + * @param compressionConfig 压缩配置 + * @param compilerService 可选的编译器服务,用于编译 DevIns 命令 + * 如果不提供,将使用 DevInsCompilerService.getInstance() + */ class KoogLLMService( private val config: ModelConfig, - private val compressionConfig: CompressionConfig = CompressionConfig() + private val compressionConfig: CompressionConfig = CompressionConfig(), + private val compilerService: DevInsCompilerService? = null ) { private val logger = getLogger("KoogLLMService") private val executor: SingleLLMPromptExecutor by lazy { ExecutorFactory.create(config) } - + private val model: LLModel by lazy { ModelRegistry.createModel(config.provider, config.modelName) ?: ModelRegistry.createGenericModel(config.provider, config.modelName) } - + private val compressionService: ChatCompressionService by lazy { ChatCompressionService(executor, model, compressionConfig) } - + + // 获取实际使用的编译器服务 + private val actualCompilerService: DevInsCompilerService + get() = compilerService ?: DevInsCompilerService.getInstance() + // Token 追踪 private var lastTokenInfo: TokenInfo = TokenInfo() private var messagesSinceLastCompression = 0 @@ -125,16 +135,14 @@ class KoogLLMService( } private suspend fun compilePrompt(userPrompt: String, fileSystem: ProjectFileSystem): String { - val context = CompilerContext().apply { - this.fileSystem = fileSystem - } - - val compiledResult = DevInsCompilerFacade.compile(userPrompt, context) + val compiledResult = actualCompilerService.compile(userPrompt, fileSystem) if (compiledResult.hasError) { - logger.warn { "⚠️ [KoogLLMService] 编译错误: ${compiledResult.errorMessage}" } + logger.warn { "⚠️ [KoogLLMService] 编译错误 (${actualCompilerService.getName()}): ${compiledResult.errorMessage}" } } + logger.debug { "📝 [KoogLLMService] 使用编译器: ${actualCompilerService.getName()}, IDE功能: ${actualCompilerService.supportsIdeFeatures()}" } + return compiledResult.output } diff --git a/mpp-core/src/jvmTest/kotlin/cc/unitmesh/devins/compiler/service/DevInsCompilerServiceTest.kt b/mpp-core/src/jvmTest/kotlin/cc/unitmesh/devins/compiler/service/DevInsCompilerServiceTest.kt new file mode 100644 index 0000000000..78b18048ea --- /dev/null +++ b/mpp-core/src/jvmTest/kotlin/cc/unitmesh/devins/compiler/service/DevInsCompilerServiceTest.kt @@ -0,0 +1,120 @@ +package cc.unitmesh.devins.compiler.service + +import cc.unitmesh.devins.filesystem.EmptyFileSystem +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class DevInsCompilerServiceTest { + + @AfterTest + fun tearDown() { + DevInsCompilerService.reset() + } + + @Test + fun `getInstance returns DefaultDevInsCompilerService when not set`() { + val service = DevInsCompilerService.getInstance() + assertNotNull(service) + assertEquals("DefaultDevInsCompilerService (mpp-core)", service.getName()) + assertFalse(service.supportsIdeFeatures()) + } + + @Test + fun `setInstance allows custom implementation`() { + val customService = object : DevInsCompilerService { + override suspend fun compile(source: String, fileSystem: cc.unitmesh.devins.filesystem.ProjectFileSystem) = + cc.unitmesh.devins.compiler.result.DevInsCompiledResult(output = "custom: $source") + + override suspend fun compile( + source: String, + fileSystem: cc.unitmesh.devins.filesystem.ProjectFileSystem, + variables: Map + ) = compile(source, fileSystem) + + override fun supportsIdeFeatures() = true + override fun getName() = "CustomCompilerService" + } + + DevInsCompilerService.setInstance(customService) + + val service = DevInsCompilerService.getInstance() + assertEquals("CustomCompilerService", service.getName()) + assertTrue(service.supportsIdeFeatures()) + } + + @Test + fun `reset restores default implementation`() { + val customService = object : DevInsCompilerService { + override suspend fun compile(source: String, fileSystem: cc.unitmesh.devins.filesystem.ProjectFileSystem) = + cc.unitmesh.devins.compiler.result.DevInsCompiledResult(output = "custom") + + override suspend fun compile( + source: String, + fileSystem: cc.unitmesh.devins.filesystem.ProjectFileSystem, + variables: Map + ) = compile(source, fileSystem) + + override fun supportsIdeFeatures() = true + override fun getName() = "CustomCompilerService" + } + + DevInsCompilerService.setInstance(customService) + assertEquals("CustomCompilerService", DevInsCompilerService.getInstance().getName()) + + DevInsCompilerService.reset() + assertEquals("DefaultDevInsCompilerService (mpp-core)", DevInsCompilerService.getInstance().getName()) + } +} + +class DefaultDevInsCompilerServiceTest { + + @Test + fun `compile returns output for simple text`() = runTest { + val service = DefaultDevInsCompilerService() + val result = service.compile("Hello World", EmptyFileSystem()) + + assertEquals("Hello World", result.output) + assertFalse(result.hasError) + } + + @Test + fun `compile handles DevIns commands`() = runTest { + val service = DefaultDevInsCompilerService() + // /file command should produce placeholder in mpp-core implementation + val result = service.compile("/file:test.kt", EmptyFileSystem()) + + // The mpp-core compiler outputs placeholders for commands + assertNotNull(result.output) + } + + @Test + fun `supportsIdeFeatures returns false`() { + val service = DefaultDevInsCompilerService() + assertFalse(service.supportsIdeFeatures()) + } + + @Test + fun `getName returns correct name`() { + val service = DefaultDevInsCompilerService() + assertEquals("DefaultDevInsCompilerService (mpp-core)", service.getName()) + } + + @Test + fun `compile with variables works`() = runTest { + val service = DefaultDevInsCompilerService() + val variables = mapOf( + "name" to "test", + "count" to 42, + "enabled" to true + ) + + val result = service.compile("Hello \$name", EmptyFileSystem(), variables) + assertNotNull(result.output) + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/compiler/IdeaDevInsCompilerService.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/compiler/IdeaDevInsCompilerService.kt new file mode 100644 index 0000000000..1ef74a1034 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/compiler/IdeaDevInsCompilerService.kt @@ -0,0 +1,133 @@ +package cc.unitmesh.devins.idea.compiler + +import cc.unitmesh.devins.compiler.result.DevInsCompiledResult +import cc.unitmesh.devins.compiler.service.DevInsCompilerService +import cc.unitmesh.devins.filesystem.ProjectFileSystem +import cc.unitmesh.devti.language.compiler.DevInsCompiler +import cc.unitmesh.devti.language.psi.DevInFile +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiWhiteSpace +import com.intellij.psi.util.PsiUtilBase + +/** + * IDEA 专用的 DevIns 编译器服务 + * + * 使用 devins-lang 模块的 DevInsCompiler,基于 IntelliJ PSI 解析。 + * 支持完整的 IDE 功能: + * - Symbol 解析 (/symbol 命令) + * - 代码重构 (/refactor 命令) + * - 数据库操作 (/database 命令) + * - 代码结构分析 (/structure 命令) + * - 符号使用查找 (/usage 命令) + * - 文件操作 (/file, /write, /edit_file 命令) + * - 进程管理 (/launch_process, /kill_process 等) + * + * @param project IntelliJ Project 实例 + * @param editor 可选的编辑器实例,用于获取当前光标位置 + */ +class IdeaDevInsCompilerService( + private val project: Project, + private val editor: Editor? = null +) : DevInsCompilerService { + + override suspend fun compile(source: String, fileSystem: ProjectFileSystem): DevInsCompiledResult { + return compileInternal(source) + } + + override suspend fun compile( + source: String, + fileSystem: ProjectFileSystem, + variables: Map + ): DevInsCompiledResult { + // TODO: 支持自定义变量注入到 VariableTable + return compileInternal(source) + } + + override fun supportsIdeFeatures(): Boolean = true + + override fun getName(): String = "IdeaDevInsCompilerService (devins-lang PSI)" + + private suspend fun compileInternal(source: String): DevInsCompiledResult { + // 从字符串创建 DevInFile + val devInFile = DevInFile.fromString(project, source) + + // 获取当前编辑器和光标位置的元素 + val currentEditor = editor ?: FileEditorManager.getInstance(project).selectedTextEditor + val element = currentEditor?.let { getElementAtCaret(it) } + + // 创建并执行编译器 + val compiler = DevInsCompiler(project, devInFile, currentEditor, element) + val ideaResult = compiler.compile() + + // 转换为 mpp-core 的 DevInsCompiledResult + return convertToMppResult(ideaResult) + } + + private fun getElementAtCaret(editor: Editor): PsiElement? { + return runReadAction { + val offset = editor.caretModel.currentCaret.offset + val psiFile = PsiUtilBase.getPsiFileInEditor(editor, project) ?: return@runReadAction null + + var element = psiFile.findElementAt(offset) ?: return@runReadAction null + if (element is PsiWhiteSpace) { + element = element.parent + } + element + } + } + + /** + * 将 devins-lang 的编译结果转换为 mpp-core 的格式 + */ + private fun convertToMppResult( + ideaResult: cc.unitmesh.devti.language.compiler.DevInsCompiledResult + ): DevInsCompiledResult { + return DevInsCompiledResult( + input = ideaResult.input, + output = ideaResult.output, + isLocalCommand = ideaResult.isLocalCommand, + hasError = ideaResult.hasError, + errorMessage = null, // IDEA 版本没有 errorMessage 字段 + executeAgent = ideaResult.executeAgent?.let { agent -> + cc.unitmesh.devins.compiler.result.CustomAgentConfig( + name = agent.name, + type = agent.state.name, + parameters = emptyMap() + ) + }, + nextJob = null, // DevInFile 不能直接转换,需要时再处理 + config = ideaResult.config?.let { hobbitHole -> + cc.unitmesh.devins.compiler.result.FrontMatterConfig( + name = hobbitHole.name, + description = hobbitHole.description, + variables = emptyMap(), + lifecycle = emptyMap(), + functions = emptyList(), + agents = emptyList() + ) + } + ) + } + + companion object { + /** + * 创建 IDEA 编译器服务实例 + */ + fun create(project: Project, editor: Editor? = null): IdeaDevInsCompilerService { + return IdeaDevInsCompilerService(project, editor) + } + + /** + * 注册为全局编译器服务 + * 应在 IDEA 插件启动时调用 + */ + fun registerAsGlobal(project: Project, editor: Editor? = null) { + DevInsCompilerService.setInstance(IdeaDevInsCompilerService(project, editor)) + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt index b94a41189a..c0b17eda14 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt @@ -9,6 +9,8 @@ import cc.unitmesh.agent.config.PreloadingStatus import cc.unitmesh.agent.config.ToolConfigFile import cc.unitmesh.agent.tool.ToolType import cc.unitmesh.agent.tool.schema.ToolCategory +import cc.unitmesh.devins.compiler.service.DevInsCompilerService +import cc.unitmesh.devins.idea.compiler.IdeaDevInsCompilerService import cc.unitmesh.devins.idea.renderer.JewelRenderer import cc.unitmesh.devins.ui.config.AutoDevConfigWrapper import cc.unitmesh.devins.ui.config.ConfigManager @@ -65,6 +67,11 @@ class IdeaAgentViewModel( // LLM Service (created from config) private var llmService: KoogLLMService? = null + // IDEA DevIns Compiler Service (uses PSI-based compiler with full IDE features) + private val ideaCompilerService: DevInsCompilerService by lazy { + IdeaDevInsCompilerService.create(project) + } + // CodingAgent instance private var codingAgent: CodingAgent? = null private var agentInitialized = false @@ -121,8 +128,12 @@ class IdeaAgentViewModel( _currentModelConfig.value = modelConfig // Create LLM service if config is valid + // Inject IDEA compiler service for full IDE feature support if (modelConfig != null && modelConfig.isValid()) { - llmService = KoogLLMService.create(modelConfig) + llmService = KoogLLMService( + config = modelConfig, + compilerService = ideaCompilerService + ) // Start MCP preloading after LLM service is created startMcpPreloading() } From dba998efe59df1e3c83994278b97333061713409 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 09:21:03 +0800 Subject: [PATCH 04/37] feat(mpp-idea): add IdeaDiffActions for diff/patch operations - Create IdeaDiffActions.kt to encapsulate diff business logic - Reuse core module's PatchProcessor, DiffRepair, showSingleDiff() - Add action buttons to IdeaDiffRenderer (Accept/Reject/View Diff/Repair) - Update IdeaSketchRenderer to pass project parameter - Fix IdeaAnalysisComponents to use named parameters Related to #25 --- .../idea/renderer/sketch/IdeaDiffRenderer.kt | 213 +++++++++++++++++- .../renderer/sketch/IdeaSketchRenderer.kt | 14 +- .../sketch/actions/IdeaDiffActions.kt | 147 ++++++++++++ .../codereview/IdeaAnalysisComponents.kt | 21 +- 4 files changed, 379 insertions(+), 16 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/actions/IdeaDiffActions.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDiffRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDiffRenderer.kt index ec7af491ad..28637493be 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDiffRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDiffRenderer.kt @@ -1,10 +1,14 @@ package cc.unitmesh.devins.idea.renderer.sketch import androidx.compose.foundation.background +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.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -14,31 +18,104 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.unitmesh.agent.diff.DiffLineType import cc.unitmesh.agent.diff.DiffParser +import cc.unitmesh.devins.idea.renderer.sketch.actions.IdeaDiffActions +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import com.intellij.openapi.project.Project import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Icon import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.Tooltip +import org.jetbrains.jewel.ui.icons.AllIconsKeys /** * Diff renderer for IntelliJ IDEA with Jewel styling. - * Renders unified diff format with syntax highlighting. + * Renders unified diff format with syntax highlighting and action buttons. + * + * Related GitHub Issue: https://github.com/phodal/auto-dev/issues/25 */ @Composable fun IdeaDiffRenderer( diffContent: String, + project: Project? = null, modifier: Modifier = Modifier ) { val fileDiffs = remember(diffContent) { DiffParser.parse(diffContent) } + var isRepairing by remember { mutableStateOf(false) } + var patchApplied by remember { mutableStateOf(false) } Column(modifier = modifier) { - if (fileDiffs.isEmpty()) { - Text( - text = "Unable to parse diff content", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 11.sp, - color = AutoDevColors.Red.c400 - ), - modifier = Modifier.padding(8.dp) + // Toolbar with action buttons + if (project != null && fileDiffs.isNotEmpty()) { + DiffToolbar( + diffContent = diffContent, + project = project, + isRepairing = isRepairing, + patchApplied = patchApplied, + onAccept = { + val success = IdeaDiffActions.acceptPatch(project, diffContent) + if (success) patchApplied = true + }, + onReject = { + IdeaDiffActions.rejectPatch(project) + patchApplied = false + }, + onViewDiff = { + IdeaDiffActions.viewDiff(project, diffContent) { + val success = IdeaDiffActions.acceptPatch(project, diffContent) + if (success) patchApplied = true + } + }, + onRepair = { + isRepairing = true + IdeaDiffActions.repairPatch(project, diffContent) { + isRepairing = false + } + } ) + } + + if (fileDiffs.isEmpty()) { + // Show error with repair option + Row( + modifier = Modifier.padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Unable to parse diff content", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = AutoDevColors.Red.c400 + ) + ) + if (project != null && !isRepairing) { + DiffActionButton( + tooltip = "Repair with AI", + onClick = { + isRepairing = true + IdeaDiffActions.repairPatch(project, diffContent) { + isRepairing = false + } + } + ) { + Icon( + key = AllIconsKeys.Actions.IntentionBulb, + contentDescription = "Repair", + modifier = Modifier.size(14.dp) + ) + } + } + if (isRepairing) { + Text( + text = "Repairing...", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = AutoDevColors.Blue.c400 + ) + ) + } + } return@Column } @@ -122,3 +199,117 @@ fun IdeaDiffRenderer( } } +@Composable +private fun DiffToolbar( + diffContent: String, + project: Project, + isRepairing: Boolean, + patchApplied: Boolean, + onAccept: () -> Unit, + onReject: () -> Unit, + onViewDiff: () -> Unit, + onRepair: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(JewelTheme.globalColors.panelBackground) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + // Accept button + DiffActionButton( + tooltip = "Accept and apply patch", + onClick = onAccept, + enabled = !patchApplied + ) { + Icon( + key = AllIconsKeys.Actions.Commit, + contentDescription = "Accept", + modifier = Modifier.size(14.dp), + tint = if (patchApplied) AutoDevColors.Neutral.c500 else AutoDevColors.Green.c400 + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + // Reject/Undo button + DiffActionButton( + tooltip = "Reject/Undo patch", + onClick = onReject + ) { + Icon( + key = AllIconsKeys.Actions.Rollback, + contentDescription = "Reject", + modifier = Modifier.size(14.dp) + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + // View Diff button + DiffActionButton( + tooltip = "View diff in dialog", + onClick = onViewDiff + ) { + Icon( + key = AllIconsKeys.Actions.ListChanges, + contentDescription = "View Diff", + modifier = Modifier.size(14.dp) + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + // Repair button + DiffActionButton( + tooltip = "Repair patch with AI", + onClick = onRepair, + enabled = !isRepairing + ) { + if (isRepairing) { + Icon( + imageVector = IdeaComposeIcons.Refresh, + contentDescription = "Repairing...", + modifier = Modifier.size(14.dp), + tint = AutoDevColors.Blue.c400 + ) + } else { + Icon( + key = AllIconsKeys.Actions.IntentionBulb, + contentDescription = "Repair", + modifier = Modifier.size(14.dp) + ) + } + } + } +} + +@Composable +private fun DiffActionButton( + tooltip: String, + onClick: () -> Unit, + enabled: Boolean = true, + content: @Composable () -> Unit +) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + + Tooltip(tooltip = { Text(tooltip) }) { + Box( + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(4.dp)) + .hoverable(interactionSource = interactionSource) + .background( + if (isHovered && enabled) JewelTheme.globalColors.borders.normal.copy(alpha = 0.3f) + else Color.Transparent + ) + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier), + contentAlignment = Alignment.Center + ) { + content() + } + } +} diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt index a30043e1f4..7645d9754d 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt @@ -9,6 +9,7 @@ import cc.unitmesh.devins.idea.renderer.MermaidDiagramView import cc.unitmesh.devins.idea.renderer.markdown.JewelMarkdownRenderer import cc.unitmesh.devins.parser.CodeFence import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project import org.jetbrains.jewel.ui.component.CircularProgressIndicator /** @@ -18,22 +19,31 @@ import org.jetbrains.jewel.ui.component.CircularProgressIndicator * Handles various content block types: * - Markdown/Text -> JewelMarkdown * - Code -> IdeaCodeBlockRenderer - * - Diff -> IdeaDiffRenderer + * - Diff -> IdeaDiffRenderer (with action buttons when project is provided) * - Thinking -> IdeaThinkingBlockRenderer * - Walkthrough -> IdeaWalkthroughBlockRenderer * - Mermaid -> MermaidDiagramView * - DevIn -> IdeaDevInBlockRenderer + * + * Related GitHub Issue: https://github.com/phodal/auto-dev/issues/25 */ object IdeaSketchRenderer { /** * Render LLM response content with full sketch support. + * + * @param content The content to render + * @param isComplete Whether the content is complete (not streaming) + * @param parentDisposable Parent disposable for resource cleanup + * @param project Optional project for action buttons (Accept/Reject/View Diff) + * @param modifier Compose modifier */ @Composable fun RenderResponse( content: String, isComplete: Boolean = false, parentDisposable: Disposable, + project: Project? = null, modifier: Modifier = Modifier ) { Column(modifier = modifier) { @@ -58,6 +68,7 @@ object IdeaSketchRenderer { if (fence.text.isNotBlank()) { IdeaDiffRenderer( diffContent = fence.text, + project = project, modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(8.dp)) @@ -130,4 +141,3 @@ object IdeaSketchRenderer { } } } - diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/actions/IdeaDiffActions.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/actions/IdeaDiffActions.kt new file mode 100644 index 0000000000..74429cd2e4 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/actions/IdeaDiffActions.kt @@ -0,0 +1,147 @@ +package cc.unitmesh.devins.idea.renderer.sketch.actions + +import cc.unitmesh.devti.AutoDevNotifications +import cc.unitmesh.devti.sketch.ui.patch.DiffRepair +import cc.unitmesh.devti.sketch.ui.patch.showSingleDiff +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.command.CommandProcessor +import com.intellij.openapi.command.UndoConfirmationPolicy +import com.intellij.openapi.command.undo.UndoManager +import com.intellij.openapi.diff.impl.patch.PatchReader +import com.intellij.openapi.diff.impl.patch.TextFilePatch +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.changes.patch.AbstractFilePatchInProgress +import com.intellij.openapi.vcs.changes.patch.ApplyPatchDefaultExecutor +import com.intellij.openapi.vcs.changes.patch.MatchPatchPaths +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiDocumentManager +import com.intellij.util.containers.MultiMap + +/** + * Business logic actions for Diff/Patch operations in mpp-idea. + * Reuses core module's PatchProcessor, DiffRepair, and showSingleDiff logic. + * + * Related GitHub Issue: https://github.com/phodal/auto-dev/issues/25 + */ +object IdeaDiffActions { + + /** + * Parse patch content and return file patches + */ + fun parsePatches(patchContent: String): List { + return try { + val reader = PatchReader(patchContent) + reader.parseAllPatches() + reader.textPatches + } catch (e: Exception) { + emptyList() + } + } + + /** + * Accept and apply patch to files + * @return true if patch was applied successfully + */ + fun acceptPatch(project: Project, patchContent: String): Boolean { + val filePatches = parsePatches(patchContent) + if (filePatches.isEmpty()) { + AutoDevNotifications.error(project, "No valid patches found") + return false + } + + PsiDocumentManager.getInstance(project).commitAllDocuments() + val commandProcessor = CommandProcessor.getInstance() + val shelfExecutor = ApplyPatchDefaultExecutor(project) + + var success = false + commandProcessor.executeCommand(project, { + commandProcessor.markCurrentCommandAsGlobal(project) + + val matchedPatches = MatchPatchPaths(project).execute(filePatches, true) + val patchGroups = MultiMap>() + for (patchInProgress in matchedPatches) { + patchGroups.putValue(patchInProgress.base, patchInProgress) + } + + val pathsFromGroups = ApplyPatchDefaultExecutor.pathsFromGroups(patchGroups) + val reader = PatchReader(patchContent) + reader.parseAllPatches() + val additionalInfo = reader.getAdditionalInfo(pathsFromGroups) + + shelfExecutor.apply(filePatches, patchGroups, null, "AutoDev.diff", additionalInfo) + success = true + }, "ApplyPatch", null, UndoConfirmationPolicy.REQUEST_CONFIRMATION, false) + + return success + } + + /** + * Reject/Undo the last patch application + * @return true if undo was performed + */ + fun rejectPatch(project: Project): Boolean { + val undoManager = UndoManager.getInstance(project) + val fileEditor = FileEditorManager.getInstance(project).selectedEditor ?: return false + + if (undoManager.isUndoAvailable(fileEditor)) { + undoManager.undo(fileEditor) + return true + } + return false + } + + /** + * Show diff preview dialog + * @param onAccept callback when user clicks Accept in the dialog + */ + fun viewDiff(project: Project, patchContent: String, onAccept: (() -> Unit)? = null) { + showSingleDiff(project, patchContent, onAccept) + } + + /** + * Repair a failed patch using AI + * @param onRepaired callback with the repaired code + */ + fun repairPatch( + project: Project, + patchContent: String, + onRepaired: ((String) -> Unit)? = null + ) { + val editor = FileEditorManager.getInstance(project).selectedTextEditor + if (editor == null) { + AutoDevNotifications.error(project, "No editor available for repair") + return + } + + ApplicationManager.getApplication().invokeLater { + DiffRepair.applyDiffRepairSuggestion( + project, + editor, + editor.document.text, + patchContent + ) { repairedCode -> + onRepaired?.invoke(repairedCode) + } + } + } + + /** + * Repair patch synchronously (for background processing) + */ + fun repairPatchSync( + project: Project, + originalCode: String, + patchContent: String, + onComplete: (String) -> Unit + ) { + DiffRepair.applyDiffRepairSuggestionSync(project, originalCode, patchContent, onComplete) + } + + /** + * Check if patches are valid + */ + fun hasValidPatches(patchContent: String): Boolean { + return parsePatches(patchContent).isNotEmpty() + } +} diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaAnalysisComponents.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaAnalysisComponents.kt index 2f073d58d7..dec58603f4 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaAnalysisComponents.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaAnalysisComponents.kt @@ -147,7 +147,12 @@ private fun IdeaLintIssueRow(issue: LintIssue, modifiedRanges: List IdeaSketchRenderer.RenderResponse(fixOutput, !isGenerating, parentDisposable, Modifier.fillMaxWidth()) + fixOutput.isNotEmpty() -> IdeaSketchRenderer.RenderResponse( + content = fixOutput, + isComplete = !isGenerating, + parentDisposable = parentDisposable, + modifier = Modifier.fillMaxWidth() + ) isGenerating -> Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() } else -> Text("No fixes generated yet.", style = JewelTheme.defaultTextStyle.copy(color = JewelTheme.globalColors.text.info, fontSize = 12.sp)) } From 48c66bbd3e832475c62c72da67663e528650875f Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 09:25:45 +0800 Subject: [PATCH 05/37] feat(mpp-idea): add IdeaCodeActions for code operations - Create IdeaCodeActions.kt with copy/insert/save methods - Update IdeaCodeBlockRenderer.kt with toolbar (Copy/Insert/Save buttons) - Pass project parameter to IdeaCodeBlockRenderer for action buttons Phase 2 of #25 --- .../renderer/sketch/IdeaCodeBlockRenderer.kt | 138 ++++++++++++++++-- .../renderer/sketch/IdeaSketchRenderer.kt | 1 + .../sketch/actions/IdeaCodeActions.kt | 136 +++++++++++++++++ 3 files changed, 262 insertions(+), 13 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/actions/IdeaCodeActions.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaCodeBlockRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaCodeBlockRenderer.kt index d670ae0855..c69c85126f 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaCodeBlockRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaCodeBlockRenderer.kt @@ -1,16 +1,27 @@ package cc.unitmesh.devins.idea.renderer.sketch 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.runtime.Composable +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import cc.unitmesh.devins.idea.renderer.sketch.actions.IdeaCodeActions import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import com.intellij.openapi.project.Project import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.IconButton import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.Tooltip +import org.jetbrains.jewel.ui.icons.AllIconsKeys /** * Code block renderer for IntelliJ IDEA with Jewel styling. @@ -19,14 +30,50 @@ import org.jetbrains.jewel.ui.component.Text fun IdeaCodeBlockRenderer( code: String, language: String, + project: Project? = null, modifier: Modifier = Modifier ) { Column( modifier = modifier .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f)) - .padding(8.dp) ) { - // Language header + // Toolbar with language label and actions + CodeBlockToolbar( + code = code, + language = language, + project = project + ) + + // Code content + Text( + text = code, + style = JewelTheme.defaultTextStyle.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ), + modifier = Modifier.fillMaxWidth().padding(8.dp) + ) + } +} + +@Composable +private fun CodeBlockToolbar( + code: String, + language: String, + project: Project? +) { + var copied by remember { mutableStateOf(false) } + var inserted by remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(JewelTheme.globalColors.panelBackground) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Language label if (language.isNotBlank()) { Text( text = language, @@ -36,18 +83,83 @@ fun IdeaCodeBlockRenderer( color = AutoDevColors.Blue.c400 ) ) - Spacer(modifier = Modifier.height(4.dp)) + } else { + Spacer(modifier = Modifier.width(1.dp)) } - // Code content - Text( - text = code, - style = JewelTheme.defaultTextStyle.copy( - fontFamily = FontFamily.Monospace, - fontSize = 11.sp - ), - modifier = Modifier.fillMaxWidth() - ) + // Action buttons + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Copy button + CodeActionButton( + tooltip = if (copied) "Copied!" else "Copy to Clipboard", + iconKey = if (copied) AllIconsKeys.Actions.Checked else AllIconsKeys.Actions.Copy, + onClick = { + if (IdeaCodeActions.copyToClipboard(code)) { + copied = true + } + } + ) + + // Insert at cursor button (only if project is available) + if (project != null) { + val canInsert = remember(project) { IdeaCodeActions.canInsertAtCursor(project) } + CodeActionButton( + tooltip = if (inserted) "Inserted!" else "Insert at Cursor", + iconKey = if (inserted) AllIconsKeys.Actions.Checked else AllIconsKeys.Actions.MoveDown, + enabled = canInsert, + onClick = { + if (IdeaCodeActions.insertAtCursor(project, code)) { + inserted = true + } + } + ) + + // Save to file button + CodeActionButton( + tooltip = "Save to File", + iconKey = AllIconsKeys.Actions.MenuSaveall, + onClick = { + val fileName = IdeaCodeActions.getSuggestedFileName(language) + IdeaCodeActions.saveToFile(project, code, fileName) + } + ) + } + } + } +} + +@Composable +private fun CodeActionButton( + tooltip: String, + iconKey: org.jetbrains.jewel.ui.icon.IconKey, + enabled: Boolean = true, + onClick: () -> Unit +) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + + Tooltip(tooltip = { Text(tooltip) }) { + IconButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier + .size(24.dp) + .hoverable(interactionSource) + .background( + if (isHovered && enabled) AutoDevColors.Neutral.c700.copy(alpha = 0.3f) + else Color.Transparent + ) + ) { + Icon( + key = iconKey, + contentDescription = tooltip, + modifier = Modifier.size(16.dp), + tint = if (enabled) AutoDevColors.Neutral.c300 else AutoDevColors.Neutral.c600 + ) + } } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt index 7645d9754d..b1a3dcbac5 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt @@ -126,6 +126,7 @@ object IdeaSketchRenderer { IdeaCodeBlockRenderer( code = fence.text, language = fence.languageId, + project = project, modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(8.dp)) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/actions/IdeaCodeActions.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/actions/IdeaCodeActions.kt new file mode 100644 index 0000000000..8e7b2b6bbd --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/actions/IdeaCodeActions.kt @@ -0,0 +1,136 @@ +package cc.unitmesh.devins.idea.renderer.sketch.actions + +import cc.unitmesh.devti.AutoDevNotifications +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.fileChooser.FileChooserFactory +import com.intellij.openapi.fileChooser.FileSaverDescriptor +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.codeStyle.CodeStyleManager +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection +import java.io.IOException + +/** + * Business logic actions for Code operations in mpp-idea. + * Reuses core module's AutoDevCopyToClipboardAction, AutoDevInsertCodeAction, AutoDevSaveFileAction logic. + * + * Related GitHub Issue: https://github.com/phodal/auto-dev/issues/25 + */ +object IdeaCodeActions { + + /** + * Copy code to clipboard + */ + fun copyToClipboard(code: String): Boolean { + return try { + val selection = StringSelection(code) + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(selection, null) + true + } catch (e: Exception) { + false + } + } + + /** + * Insert code at cursor position in the currently selected editor + * @return true if insertion was successful + */ + fun insertAtCursor(project: Project, code: String): Boolean { + val textEditor = FileEditorManager.getInstance(project).selectedTextEditor ?: return false + val document = textEditor.document + + if (!document.isWritable) return false + + val psiFile = PsiDocumentManager.getInstance(project).getPsiFile(document) + val currentSelection = textEditor.selectionModel + + return try { + WriteCommandAction.writeCommandAction(project).compute { + val offset: Int + + if (currentSelection.hasSelection()) { + offset = currentSelection.selectionStart + document.replaceString(currentSelection.selectionStart, currentSelection.selectionEnd, code) + } else { + offset = textEditor.caretModel.offset + document.insertString(offset, code) + } + + PsiDocumentManager.getInstance(project).commitDocument(document) + if (psiFile != null) { + CodeStyleManager.getInstance(project).reformatText(psiFile, offset, offset + code.length) + } + true + } + } catch (e: Exception) { + false + } + } + + /** + * Check if there's a writable editor available for insertion + */ + fun canInsertAtCursor(project: Project): Boolean { + val textEditor = FileEditorManager.getInstance(project).selectedTextEditor ?: return false + return textEditor.document.isWritable + } + + /** + * Save code to a new file using file chooser dialog + */ + fun saveToFile(project: Project, code: String, suggestedFileName: String = "code.txt") { + val descriptor = FileSaverDescriptor("Save Code", "Save code to a file") + val dialog = FileChooserFactory.getInstance().createSaveFileDialog(descriptor, project) + val dir = project.baseDir + val virtualFileWrapper = dialog.save(dir, suggestedFileName) ?: return + + try { + ApplicationManager.getApplication().runWriteAction { + val file = virtualFileWrapper.file + file.writeText(code) + LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file) + AutoDevNotifications.notify(project, "File saved successfully to: ${file.absolutePath}") + } + } catch (ex: IOException) { + AutoDevNotifications.error(project, "Failed to save file: ${ex.message}") + } + } + + /** + * Get suggested file name based on language + */ + fun getSuggestedFileName(language: String): String { + val extension = when (language.lowercase()) { + "kotlin" -> "kt" + "java" -> "java" + "python" -> "py" + "javascript", "js" -> "js" + "typescript", "ts" -> "ts" + "rust" -> "rs" + "go" -> "go" + "c" -> "c" + "cpp", "c++" -> "cpp" + "csharp", "c#" -> "cs" + "ruby" -> "rb" + "php" -> "php" + "swift" -> "swift" + "scala" -> "scala" + "html" -> "html" + "css" -> "css" + "json" -> "json" + "yaml", "yml" -> "yaml" + "xml" -> "xml" + "sql" -> "sql" + "shell", "bash", "sh" -> "sh" + "markdown", "md" -> "md" + else -> "txt" + } + return "code.$extension" + } +} + From ca4ae8f169e33bd3a5c93129a0f659f41ead3666 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 09:30:37 +0800 Subject: [PATCH 06/37] feat(mpp-idea): add IdeaPlanActions and IdeaPlanRenderer - Create IdeaPlanActions.kt with parsePlan/copyToClipboard/pinToToolWindow methods - Create IdeaPlanRenderer.kt with plan sections, steps, status indicators - Add plan language support to IdeaSketchRenderer - Reuses core module's MarkdownPlanParser, AgentStateService Phase 3 of #25 --- .../idea/renderer/sketch/IdeaPlanRenderer.kt | 166 ++++++++++++++++++ .../renderer/sketch/IdeaSketchRenderer.kt | 12 ++ .../sketch/actions/IdeaPlanActions.kt | 108 ++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaPlanRenderer.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/actions/IdeaPlanActions.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaPlanRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaPlanRenderer.kt new file mode 100644 index 0000000000..06570c0549 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaPlanRenderer.kt @@ -0,0 +1,166 @@ +package cc.unitmesh.devins.idea.renderer.sketch + +import androidx.compose.foundation.background +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.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.devins.idea.renderer.sketch.actions.IdeaPlanActions +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import cc.unitmesh.devti.observer.plan.AgentTaskEntry +import cc.unitmesh.devti.observer.plan.TaskStatus +import com.intellij.openapi.project.Project +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.IconButton +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.Tooltip +import org.jetbrains.jewel.ui.icons.AllIconsKeys + +/** + * Plan renderer for IntelliJ IDEA with Jewel styling. + */ +@Composable +fun IdeaPlanRenderer( + planContent: String, + project: Project? = null, + isComplete: Boolean = false, + modifier: Modifier = Modifier +) { + val planItems = remember(planContent) { IdeaPlanActions.parsePlan(planContent) } + var isCompressed by remember { mutableStateOf(false) } + var copied by remember { mutableStateOf(false) } + + Column( + modifier = modifier + .background(JewelTheme.globalColors.panelBackground) + .clip(RoundedCornerShape(4.dp)) + ) { + PlanToolbar(planContent, project, isCompressed, copied, + onToggleCompress = { isCompressed = !isCompressed }, + onCopy = { if (IdeaPlanActions.copyToClipboard(planContent)) copied = true }, + onPin = { project?.let { IdeaPlanActions.pinToToolWindow(it, planContent) } } + ) + + if (!isCompressed) { + Column(modifier = Modifier.padding(8.dp)) { + planItems.forEachIndexed { index, entry -> + PlanSection(index, entry, Modifier.fillMaxWidth()) + if (index < planItems.lastIndex) Spacer(modifier = Modifier.height(8.dp)) + } + } + } else { + CompressedPlanView(planItems) + } + } +} + +@Composable +private fun PlanToolbar( + planContent: String, project: Project?, isCompressed: Boolean, copied: Boolean, + onToggleCompress: () -> Unit, onCopy: () -> Unit, onPin: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth().background(JewelTheme.globalColors.panelBackground) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Plan", style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp, fontWeight = FontWeight.Bold)) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + PlanActionButton(if (isCompressed) "Expand" else "Compress", + if (isCompressed) AllIconsKeys.Actions.Expandall else AllIconsKeys.Actions.Collapseall, onToggleCompress) + PlanActionButton(if (copied) "Copied!" else "Copy Plan", + if (copied) AllIconsKeys.Actions.Checked else AllIconsKeys.Actions.Copy, onCopy) + if (project != null) PlanActionButton("Pin to Planner", AllIconsKeys.Actions.PinTab, onPin) + } + } +} + +@Composable +private fun PlanActionButton(tooltip: String, iconKey: org.jetbrains.jewel.ui.icon.IconKey, onClick: () -> Unit) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + Tooltip(tooltip = { Text(tooltip) }) { + IconButton(onClick = onClick, modifier = Modifier.size(24.dp).hoverable(interactionSource) + .background(if (isHovered) AutoDevColors.Neutral.c700.copy(alpha = 0.3f) else Color.Transparent)) { + Icon(iconKey, tooltip, Modifier.size(16.dp), tint = AutoDevColors.Neutral.c300) + } + } +} + +@Composable +private fun CompressedPlanView(planItems: List) { + val completedCount = planItems.count { it.status == TaskStatus.COMPLETED } + Row(Modifier.fillMaxWidth().padding(8.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { + Text("${planItems.size} sections", style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp)) + Text("$completedCount/${planItems.size} completed", style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, color = if (completedCount == planItems.size) AutoDevColors.Green.c400 else AutoDevColors.Neutral.c400)) + } +} + +@Composable +private fun PlanSection(index: Int, entry: AgentTaskEntry, modifier: Modifier = Modifier) { + var isExpanded by remember { mutableStateOf(true) } + Column(modifier.background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f), RoundedCornerShape(4.dp)).padding(8.dp)) { + Row(Modifier.fillMaxWidth().clickable { isExpanded = !isExpanded }, Arrangement.SpaceBetween, Alignment.CenterVertically) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(if (isExpanded) AllIconsKeys.General.ArrowDown else AllIconsKeys.General.ArrowRight, + if (isExpanded) "Collapse" else "Expand", Modifier.size(12.dp), tint = AutoDevColors.Neutral.c400) + StatusIcon(entry.status) + Text("${index + 1}. ${entry.title}", style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp, fontWeight = FontWeight.Bold), maxLines = 1) + } + StatusLabel(entry.status) + } + if (isExpanded && entry.steps.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Column(Modifier.padding(start = 20.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + entry.steps.forEach { step -> PlanStep(step) } + } + } + } +} + +@Composable +private fun PlanStep(step: cc.unitmesh.devti.observer.plan.AgentPlanStep) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(if (step.completed) AllIconsKeys.Actions.Checked else AllIconsKeys.Nodes.EmptyNode, + if (step.completed) "Completed" else "Pending", Modifier.size(14.dp), + tint = if (step.completed) AutoDevColors.Green.c400 else AutoDevColors.Neutral.c500) + Text(step.step, style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp, + color = if (step.completed) AutoDevColors.Neutral.c400 else AutoDevColors.Neutral.c200)) + } +} + +@Composable +private fun StatusIcon(status: TaskStatus) { + val (iconKey, tint) = when (status) { + TaskStatus.COMPLETED -> AllIconsKeys.Actions.Checked to AutoDevColors.Green.c400 + TaskStatus.FAILED -> AllIconsKeys.General.Error to AutoDevColors.Red.c400 + TaskStatus.IN_PROGRESS -> AllIconsKeys.Actions.Execute to AutoDevColors.Blue.c400 + TaskStatus.TODO -> AllIconsKeys.General.TodoDefault to AutoDevColors.Neutral.c500 + } + Icon(iconKey, status.name, Modifier.size(14.dp), tint = tint) +} + +@Composable +private fun StatusLabel(status: TaskStatus) { + val (text, color) = when (status) { + TaskStatus.COMPLETED -> "Done" to AutoDevColors.Green.c400 + TaskStatus.FAILED -> "Failed" to AutoDevColors.Red.c400 + TaskStatus.IN_PROGRESS -> "Running" to AutoDevColors.Blue.c400 + TaskStatus.TODO -> "Todo" to AutoDevColors.Neutral.c500 + } + Text(text, style = JewelTheme.defaultTextStyle.copy(fontSize = 10.sp, fontWeight = FontWeight.Bold, color = color)) +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt index b1a3dcbac5..ad4969efa1 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt @@ -75,6 +75,18 @@ object IdeaSketchRenderer { } } + "plan" -> { + if (fence.text.isNotBlank()) { + IdeaPlanRenderer( + planContent = fence.text, + project = project, + isComplete = blockIsComplete, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + "thinking" -> { if (fence.text.isNotBlank()) { IdeaThinkingBlockRenderer( diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/actions/IdeaPlanActions.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/actions/IdeaPlanActions.kt new file mode 100644 index 0000000000..f197b0fa5f --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/actions/IdeaPlanActions.kt @@ -0,0 +1,108 @@ +package cc.unitmesh.devins.idea.renderer.sketch.actions + +import cc.unitmesh.devti.gui.AutoDevPlannerToolWindowFactory +import cc.unitmesh.devti.gui.planner.AutoDevPlannerToolWindow +import cc.unitmesh.devti.observer.agent.AgentStateService +import cc.unitmesh.devti.observer.plan.AgentTaskEntry +import cc.unitmesh.devti.observer.plan.MarkdownPlanParser +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindowManager +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection + +/** + * Business logic actions for Plan operations in mpp-idea. + * Reuses core module's PlanToolbarFactory, MarkdownPlanParser, AgentStateService logic. + * + * Related GitHub Issue: https://github.com/phodal/auto-dev/issues/25 + */ +object IdeaPlanActions { + + /** + * Parse plan content to AgentTaskEntry list + */ + fun parsePlan(content: String): List { + return MarkdownPlanParser.parse(content) + } + + /** + * Format plan entries back to markdown + */ + fun formatPlanToMarkdown(entries: List): String { + return MarkdownPlanParser.formatPlanToMarkdown(entries.toMutableList()) + } + + /** + * Copy plan to clipboard + */ + fun copyPlanToClipboard(project: Project): Boolean { + return try { + val agentStateService = project.getService(AgentStateService::class.java) + val currentPlan = agentStateService.getPlan() + val planString = formatPlanToMarkdown(currentPlan) + + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + val selection = StringSelection(planString) + clipboard.setContents(selection, null) + true + } catch (e: Exception) { + false + } + } + + /** + * Copy specific plan content to clipboard + */ + fun copyToClipboard(content: String): Boolean { + return try { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + val selection = StringSelection(content) + clipboard.setContents(selection, null) + true + } catch (e: Exception) { + false + } + } + + /** + * Pin plan to the Planner tool window + */ + fun pinToToolWindow(project: Project, planContent: String? = null) { + val toolWindowManager = ToolWindowManager.getInstance(project) + val toolWindow = toolWindowManager.getToolWindow(AutoDevPlannerToolWindowFactory.PlANNER_ID) + ?: return + + val codingPanel = toolWindow.contentManager.component.components + ?.filterIsInstance() + ?.firstOrNull() + + toolWindow.activate { + val content = if (planContent != null) { + planContent + } else { + val agentStateService = project.getService(AgentStateService::class.java) + val currentPlan = agentStateService.getPlan() + formatPlanToMarkdown(currentPlan) + } + + codingPanel?.switchToPlanView(content) + } + } + + /** + * Save plan to AgentStateService + */ + fun savePlanToService(project: Project, entries: List) { + val agentStateService = project.getService(AgentStateService::class.java) + agentStateService.updatePlan(entries.toMutableList()) + } + + /** + * Get current plan from AgentStateService + */ + fun getCurrentPlan(project: Project): List { + val agentStateService = project.getService(AgentStateService::class.java) + return agentStateService.getPlan() + } +} + From 75c5ec61e611e95e9c958ab6e74e6439f062a618 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 09:34:24 +0800 Subject: [PATCH 07/37] feat(mpp-idea): add IdeaTerminalActions and IdeaTerminalRenderer - Create IdeaTerminalActions.kt with checkDangerousCommand/executeCommand methods - Create IdeaTerminalRenderer.kt with command display, execute button, output panel - Add bash/shell/sh/zsh language support to IdeaSketchRenderer - Reuses core module's ShellSafetyCheck, ProcessExecutor Phase 4 of #25 --- .../renderer/sketch/IdeaSketchRenderer.kt | 12 ++ .../renderer/sketch/IdeaTerminalRenderer.kt | 170 ++++++++++++++++++ .../sketch/actions/IdeaTerminalActions.kt | 150 ++++++++++++++++ 3 files changed, 332 insertions(+) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaTerminalRenderer.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/actions/IdeaTerminalActions.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt index ad4969efa1..272b10da5e 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt @@ -98,6 +98,18 @@ object IdeaSketchRenderer { } } + "bash", "shell", "sh", "zsh" -> { + if (fence.text.isNotBlank()) { + IdeaTerminalRenderer( + command = fence.text, + project = project, + isComplete = blockIsComplete, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + "walkthrough" -> { if (fence.text.isNotBlank()) { IdeaWalkthroughBlockRenderer( diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaTerminalRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaTerminalRenderer.kt new file mode 100644 index 0000000000..0204b690bc --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaTerminalRenderer.kt @@ -0,0 +1,170 @@ +package cc.unitmesh.devins.idea.renderer.sketch + +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.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.devins.idea.renderer.sketch.actions.ExecutionResult +import cc.unitmesh.devins.idea.renderer.sketch.actions.IdeaTerminalActions +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import cc.unitmesh.devti.util.AutoDevCoroutineScope +import com.intellij.openapi.project.Project +import kotlinx.coroutines.launch +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.* +import org.jetbrains.jewel.ui.icons.AllIconsKeys + +/** + * Terminal renderer for IntelliJ IDEA with Jewel styling. + */ +@Composable +fun IdeaTerminalRenderer( + command: String, + project: Project? = null, + isComplete: Boolean = false, + modifier: Modifier = Modifier +) { + var executionState by remember { mutableStateOf(TerminalState.IDLE) } + var executionResult by remember { mutableStateOf(null) } + var showOutput by remember { mutableStateOf(false) } + var copied by remember { mutableStateOf(false) } + + // Check if command is dangerous + val (isDangerous, dangerReason) = remember(command) { + IdeaTerminalActions.checkDangerousCommand(command) + } + + Column( + modifier = modifier + .background(JewelTheme.globalColors.panelBackground) + .clip(RoundedCornerShape(4.dp)) + ) { + // Toolbar + TerminalToolbar( + command = command, + project = project, + executionState = executionState, + isDangerous = isDangerous, + copied = copied, + onExecute = { + if (project != null && !isDangerous) { + executionState = TerminalState.RUNNING + AutoDevCoroutineScope.scope(project).launch { + val result = IdeaTerminalActions.executeCommand(project, command) + executionResult = result + executionState = if (result.isSuccess) TerminalState.SUCCESS else TerminalState.FAILED + showOutput = true + } + } + }, + onCopy = { + if (IdeaTerminalActions.copyToClipboard(command)) copied = true + }, + onToggleOutput = { showOutput = !showOutput } + ) + + // Command display + CommandDisplay(command = command, isDangerous = isDangerous, dangerReason = dangerReason) + + // Output (if available) + if (showOutput && executionResult != null) { + OutputDisplay(result = executionResult!!) + } + } +} + +private enum class TerminalState { IDLE, RUNNING, SUCCESS, FAILED } + +@Composable +private fun TerminalToolbar( + command: String, project: Project?, executionState: TerminalState, isDangerous: Boolean, + copied: Boolean, onExecute: () -> Unit, onCopy: () -> Unit, onToggleOutput: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth().background(JewelTheme.globalColors.panelBackground) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(AllIconsKeys.Debugger.Console, "Terminal", Modifier.size(14.dp), tint = AutoDevColors.Neutral.c400) + Text("Terminal", style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp, fontWeight = FontWeight.Bold)) + // Status indicator + when (executionState) { + TerminalState.RUNNING -> CircularProgressIndicator(Modifier.size(14.dp)) + TerminalState.SUCCESS -> Icon(AllIconsKeys.Actions.Checked, "Success", Modifier.size(14.dp), tint = AutoDevColors.Green.c400) + TerminalState.FAILED -> Icon(AllIconsKeys.General.Error, "Failed", Modifier.size(14.dp), tint = AutoDevColors.Red.c400) + else -> {} + } + } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + if (project != null && !isDangerous) { + TerminalActionButton( + if (executionState == TerminalState.RUNNING) "Running..." else "Execute", + if (executionState == TerminalState.RUNNING) AllIconsKeys.Actions.Suspend else AllIconsKeys.Actions.Execute, + enabled = executionState != TerminalState.RUNNING, onClick = onExecute + ) + } + TerminalActionButton(if (copied) "Copied!" else "Copy", + if (copied) AllIconsKeys.Actions.Checked else AllIconsKeys.Actions.Copy, onClick = onCopy) + TerminalActionButton("Toggle Output", AllIconsKeys.Actions.PreviewDetails, onClick = onToggleOutput) + } + } +} + +@Composable +private fun TerminalActionButton(tooltip: String, iconKey: org.jetbrains.jewel.ui.icon.IconKey, + enabled: Boolean = true, onClick: () -> Unit) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + Tooltip(tooltip = { Text(tooltip) }) { + IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(24.dp).hoverable(interactionSource) + .background(if (isHovered && enabled) AutoDevColors.Neutral.c700.copy(alpha = 0.3f) else Color.Transparent)) { + Icon(iconKey, tooltip, Modifier.size(16.dp), + tint = if (enabled) AutoDevColors.Neutral.c300 else AutoDevColors.Neutral.c600) + } + } +} + +@Composable +private fun CommandDisplay(command: String, isDangerous: Boolean, dangerReason: String) { + Column(Modifier.fillMaxWidth().padding(8.dp)) { + if (isDangerous) { + Row(Modifier.fillMaxWidth().background(AutoDevColors.Red.c900.copy(alpha = 0.3f), RoundedCornerShape(4.dp)) + .padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(AllIconsKeys.General.Warning, "Warning", Modifier.size(16.dp), tint = AutoDevColors.Red.c400) + Text("Dangerous command blocked: $dangerReason", + style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp, color = AutoDevColors.Red.c300)) + } + Spacer(Modifier.height(8.dp)) + } + Text(command, style = JewelTheme.defaultTextStyle.copy(fontFamily = FontFamily.Monospace, fontSize = 11.sp), + modifier = Modifier.fillMaxWidth().background(AutoDevColors.Neutral.c900, RoundedCornerShape(4.dp)).padding(8.dp)) + } +} + +@Composable +private fun OutputDisplay(result: ExecutionResult) { + Column(Modifier.fillMaxWidth().padding(horizontal = 8.dp).padding(bottom = 8.dp)) { + Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { + Text("Output (Exit: ${result.exitCode})", style = JewelTheme.defaultTextStyle.copy(fontSize = 10.sp, + color = if (result.isSuccess) AutoDevColors.Green.c400 else AutoDevColors.Red.c400)) + } + Spacer(Modifier.height(4.dp)) + Text(result.displayOutput.ifBlank { "(no output)" }, + style = JewelTheme.defaultTextStyle.copy(fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = AutoDevColors.Neutral.c300), + modifier = Modifier.fillMaxWidth().background(AutoDevColors.Neutral.c800, RoundedCornerShape(4.dp)).padding(8.dp)) + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/actions/IdeaTerminalActions.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/actions/IdeaTerminalActions.kt new file mode 100644 index 0000000000..837db15306 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/actions/IdeaTerminalActions.kt @@ -0,0 +1,150 @@ +package cc.unitmesh.devins.idea.renderer.sketch.actions + +import cc.unitmesh.devti.AutoDevNotifications +import cc.unitmesh.devti.sketch.run.ProcessExecutor +import cc.unitmesh.devti.sketch.run.ShellSafetyCheck +import com.intellij.openapi.project.Project +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.asCoroutineDispatcher +import org.jetbrains.ide.PooledThreadExecutor +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection + +/** + * Business logic actions for Terminal operations in mpp-idea. + * Reuses core module's ShellSafetyCheck, ProcessExecutor logic. + * + * Related GitHub Issue: https://github.com/phodal/auto-dev/issues/25 + */ +object IdeaTerminalActions { + + /** + * Check if a command is dangerous + * @return Pair of (isDangerous, reason) + */ + fun checkDangerousCommand(command: String): Pair { + return try { + ShellSafetyCheck.checkDangerousCommand(command) + } catch (e: Exception) { + Pair(true, "Error checking command safety: ${e.message}") + } + } + + /** + * Execute a shell command and return the result + */ + suspend fun executeCommand( + project: Project, + command: String, + dispatcher: CoroutineDispatcher = PooledThreadExecutor.INSTANCE.asCoroutineDispatcher() + ): ExecutionResult { + val (isDangerous, reason) = checkDangerousCommand(command) + if (isDangerous) { + return ExecutionResult( + exitCode = -1, + output = "", + error = "Command blocked for safety: $reason", + isDangerous = true, + dangerReason = reason + ) + } + + return try { + val executor = project.getService(ProcessExecutor::class.java) + val result = executor.executeCode(command, dispatcher) + ExecutionResult( + exitCode = result.exitCode, + output = result.stdOutput, + error = result.errOutput, + isDangerous = false, + dangerReason = "" + ) + } catch (e: Exception) { + ExecutionResult( + exitCode = -1, + output = "", + error = "Execution error: ${e.message}", + isDangerous = false, + dangerReason = "" + ) + } + } + + /** + * Execute command synchronously (blocking) + */ + fun executeCommandSync(project: Project, command: String): ExecutionResult { + val (isDangerous, reason) = checkDangerousCommand(command) + if (isDangerous) { + return ExecutionResult( + exitCode = -1, + output = "", + error = "Command blocked for safety: $reason", + isDangerous = true, + dangerReason = reason + ) + } + + return try { + val executor = project.getService(ProcessExecutor::class.java) + val result = executor.executeCode(command) + ExecutionResult( + exitCode = result.exitCode, + output = result.stdOutput, + error = result.errOutput, + isDangerous = false, + dangerReason = "" + ) + } catch (e: Exception) { + ExecutionResult( + exitCode = -1, + output = "", + error = "Execution error: ${e.message}", + isDangerous = false, + dangerReason = "" + ) + } + } + + /** + * Copy command or output to clipboard + */ + fun copyToClipboard(text: String): Boolean { + return try { + val selection = StringSelection(text) + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(selection, null) + true + } catch (e: Exception) { + false + } + } + + /** + * Notify user about execution result + */ + fun notifyResult(project: Project, result: ExecutionResult) { + if (result.exitCode == 0) { + AutoDevNotifications.notify(project, "Command executed successfully") + } else if (result.isDangerous) { + AutoDevNotifications.warn(project, "Command blocked: ${result.dangerReason}") + } else { + AutoDevNotifications.error(project, "Command failed with exit code ${result.exitCode}") + } + } +} + +/** + * Result of command execution + */ +data class ExecutionResult( + val exitCode: Int, + val output: String, + val error: String, + val isDangerous: Boolean, + val dangerReason: String +) { + val isSuccess: Boolean get() = exitCode == 0 && !isDangerous + val displayOutput: String get() = if (output.isNotBlank()) output else error +} + From 2490afa7e15a77c0a967099a63680582bfe4efdc Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 09:38:13 +0800 Subject: [PATCH 08/37] feat(mpp-idea): add IdeaDiagramActions and enhanced IdeaMermaidRenderer - Create IdeaDiagramActions.kt with copySourceToClipboard/saveDiagramToFile/validate methods - Create IdeaMermaidRenderer.kt with toolbar (copy/show code), validation warnings - Update IdeaSketchRenderer to use enhanced IdeaMermaidRenderer - Note: PlantUML excluded from mpp-idea due to size (~20MB) Phase 5 of #25 --- .../renderer/sketch/IdeaMermaidRenderer.kt | 167 ++++++++++++++++++ .../renderer/sketch/IdeaSketchRenderer.kt | 5 +- .../sketch/actions/IdeaDiagramActions.kt | 166 +++++++++++++++++ 3 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaMermaidRenderer.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/actions/IdeaDiagramActions.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaMermaidRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaMermaidRenderer.kt new file mode 100644 index 0000000000..264ab7189b --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaMermaidRenderer.kt @@ -0,0 +1,167 @@ +package cc.unitmesh.devins.idea.renderer.sketch + +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.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.SwingPanel +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.devins.idea.renderer.MermaidRenderer +import cc.unitmesh.devins.idea.renderer.sketch.actions.IdeaDiagramActions +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.ui.jcef.JBCefApp +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.* +import org.jetbrains.jewel.ui.icons.AllIconsKeys + +/** + * Enhanced Mermaid renderer with toolbar actions. + */ +@Composable +fun IdeaMermaidRenderer( + mermaidCode: String, + project: Project? = null, + isDarkTheme: Boolean = true, + parentDisposable: Disposable, + modifier: Modifier = Modifier +) { + if (!JBCefApp.isSupported()) { + JcefNotAvailableMessage(modifier) + return + } + + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + var copied by remember { mutableStateOf(false) } + var showCode by remember { mutableStateOf(false) } + + val (isValid, validationError) = remember(mermaidCode) { + IdeaDiagramActions.validateMermaidSyntax(mermaidCode) + } + + val renderer = remember { + MermaidRenderer(parentDisposable) { success, message -> + isLoading = false + if (!success) errorMessage = message + } + } + + LaunchedEffect(mermaidCode, isDarkTheme) { + isLoading = true + errorMessage = null + renderer.renderMermaid(mermaidCode, isDarkTheme) + } + + Column(modifier = modifier.background(JewelTheme.globalColors.panelBackground).clip(RoundedCornerShape(4.dp))) { + // Toolbar + MermaidToolbar( + project = project, + mermaidCode = mermaidCode, + copied = copied, + showCode = showCode, + onCopy = { if (IdeaDiagramActions.copySourceToClipboard(mermaidCode)) copied = true }, + onToggleCode = { showCode = !showCode } + ) + + // Validation warning + if (!isValid) { + ValidationWarning(validationError) + } + + // Code view (collapsible) + if (showCode) { + CodePreview(mermaidCode) + } + + // Diagram view + Box(modifier = Modifier.fillMaxWidth().heightIn(min = 200.dp)) { + SwingPanel(factory = { renderer.component }, modifier = Modifier.fillMaxWidth().heightIn(min = 200.dp)) + if (isLoading) LoadingIndicator() + errorMessage?.let { ErrorMessage(it) } + } + } +} + +@Composable +private fun MermaidToolbar( + project: Project?, mermaidCode: String, copied: Boolean, showCode: Boolean, + onCopy: () -> Unit, onToggleCode: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth().background(JewelTheme.globalColors.panelBackground) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(AllIconsKeys.FileTypes.Diagram, "Mermaid", Modifier.size(14.dp), tint = AutoDevColors.Cyan.c400) + Text("Mermaid", style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp, fontWeight = FontWeight.Bold)) + } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + DiagramActionButton(if (showCode) "Hide Code" else "Show Code", + if (showCode) AllIconsKeys.Actions.Collapseall else AllIconsKeys.Actions.Expandall, onToggleCode) + DiagramActionButton(if (copied) "Copied!" else "Copy", + if (copied) AllIconsKeys.Actions.Checked else AllIconsKeys.Actions.Copy, onCopy) + } + } +} + +@Composable +private fun DiagramActionButton(tooltip: String, iconKey: org.jetbrains.jewel.ui.icon.IconKey, onClick: () -> Unit) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + Tooltip(tooltip = { Text(tooltip) }) { + IconButton(onClick = onClick, modifier = Modifier.size(24.dp).hoverable(interactionSource) + .background(if (isHovered) AutoDevColors.Neutral.c700.copy(alpha = 0.3f) else Color.Transparent)) { + Icon(iconKey, tooltip, Modifier.size(16.dp), tint = AutoDevColors.Neutral.c300) + } + } +} + +@Composable +private fun ValidationWarning(message: String) { + Row(Modifier.fillMaxWidth().background(AutoDevColors.Amber.c900.copy(alpha = 0.3f)).padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(AllIconsKeys.General.Warning, "Warning", Modifier.size(14.dp), tint = AutoDevColors.Amber.c400) + Text(message, style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp, color = AutoDevColors.Amber.c300)) + } +} + +@Composable +private fun CodePreview(code: String) { + Text(code, style = JewelTheme.defaultTextStyle.copy(fontSize = 10.sp, color = AutoDevColors.Neutral.c400), + modifier = Modifier.fillMaxWidth().background(AutoDevColors.Neutral.c900).padding(8.dp)) +} + +@Composable +private fun LoadingIndicator() { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } +} + +@Composable +private fun ErrorMessage(error: String) { + Box(Modifier.fillMaxWidth().background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.9f)).padding(16.dp), + contentAlignment = Alignment.Center) { + Text("Error: $error", style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp, color = AutoDevColors.Red.c500)) + } +} + +@Composable +private fun JcefNotAvailableMessage(modifier: Modifier) { + Box(modifier.fillMaxWidth().heightIn(min = 100.dp).background(JewelTheme.globalColors.panelBackground), + contentAlignment = Alignment.Center) { + Text("JCEF not available", style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp, color = AutoDevColors.Amber.c500)) + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt index 272b10da5e..d800f56605 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import cc.unitmesh.devins.idea.renderer.MermaidDiagramView + import cc.unitmesh.devins.idea.renderer.markdown.JewelMarkdownRenderer import cc.unitmesh.devins.parser.CodeFence import com.intellij.openapi.Disposable @@ -124,8 +124,9 @@ object IdeaSketchRenderer { "mermaid", "mmd" -> { if (fence.text.isNotBlank() && blockIsComplete) { - MermaidDiagramView( + IdeaMermaidRenderer( mermaidCode = fence.text, + project = project, isDarkTheme = true, // TODO: detect theme parentDisposable = parentDisposable, modifier = Modifier.fillMaxWidth() diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/actions/IdeaDiagramActions.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/actions/IdeaDiagramActions.kt new file mode 100644 index 0000000000..2e5ae93688 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/actions/IdeaDiagramActions.kt @@ -0,0 +1,166 @@ +package cc.unitmesh.devins.idea.renderer.sketch.actions + +import com.intellij.openapi.fileChooser.FileChooserFactory +import com.intellij.openapi.fileChooser.FileSaverDescriptor +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFileWrapper +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection +import java.io.File + +/** + * Business logic actions for Diagram operations (Mermaid, PlantUML, Graphviz) in mpp-idea. + * + * Related GitHub Issue: https://github.com/phodal/auto-dev/issues/25 + */ +object IdeaDiagramActions { + + /** + * Copy diagram source code to clipboard + */ + fun copySourceToClipboard(source: String): Boolean { + return try { + val selection = StringSelection(source) + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(selection, null) + true + } catch (e: Exception) { + false + } + } + + /** + * Save diagram to file (PNG, SVG, etc.) + */ + fun saveDiagramToFile( + project: Project?, + bytes: ByteArray, + format: String, + defaultFileName: String = "diagram" + ): Boolean { + if (project == null) return false + + return try { + val descriptor = FileSaverDescriptor( + "Save Diagram", + "Save diagram as $format file", + format + ) + + val dialog = FileChooserFactory.getInstance().createSaveFileDialog(descriptor, project) + val wrapper: VirtualFileWrapper? = dialog.save("$defaultFileName.$format") + + if (wrapper != null) { + val file = wrapper.file + file.writeBytes(bytes) + true + } else { + false + } + } catch (e: Exception) { + false + } + } + + /** + * Save diagram bytes to a specific file path + */ + fun saveDiagramToPath(bytes: ByteArray, filePath: String): Boolean { + return try { + File(filePath).writeBytes(bytes) + true + } catch (e: Exception) { + false + } + } + + /** + * Validate Mermaid diagram syntax (basic check) + */ + fun validateMermaidSyntax(code: String): Pair { + val trimmed = code.trim() + if (trimmed.isEmpty()) { + return Pair(false, "Empty diagram code") + } + + val validStarts = listOf( + "graph", "flowchart", "sequenceDiagram", "classDiagram", + "stateDiagram", "erDiagram", "journey", "gantt", "pie", + "gitGraph", "mindmap", "timeline", "quadrantChart", + "requirementDiagram", "C4Context", "sankey" + ) + + val hasValidStart = validStarts.any { + trimmed.startsWith(it, ignoreCase = true) + } + + return if (hasValidStart) { + Pair(true, "") + } else { + Pair(false, "Unknown diagram type. Expected: ${validStarts.joinToString(", ")}") + } + } + + /** + * Validate PlantUML diagram syntax (basic check) + */ + fun validatePlantUmlSyntax(code: String): Pair { + val trimmed = code.trim() + if (trimmed.isEmpty()) { + return Pair(false, "Empty diagram code") + } + + val hasStart = trimmed.contains("@startuml", ignoreCase = true) || + trimmed.contains("@startmindmap", ignoreCase = true) || + trimmed.contains("@startgantt", ignoreCase = true) || + trimmed.contains("@startwbs", ignoreCase = true) || + trimmed.contains("@startjson", ignoreCase = true) || + trimmed.contains("@startyaml", ignoreCase = true) + + return if (hasStart) { + Pair(true, "") + } else { + Pair(false, "Missing @startuml or similar directive") + } + } + + /** + * Validate Graphviz DOT syntax (basic check) + */ + fun validateDotSyntax(code: String): Pair { + val trimmed = code.trim() + if (trimmed.isEmpty()) { + return Pair(false, "Empty diagram code") + } + + val hasValidStart = trimmed.startsWith("digraph", ignoreCase = true) || + trimmed.startsWith("graph", ignoreCase = true) || + trimmed.startsWith("strict", ignoreCase = true) + + return if (hasValidStart) { + Pair(true, "") + } else { + Pair(false, "Expected 'digraph', 'graph', or 'strict' keyword") + } + } + + /** + * Get diagram type from language identifier + */ + fun getDiagramType(language: String): DiagramType { + return when (language.lowercase()) { + "mermaid", "mmd" -> DiagramType.MERMAID + "plantuml", "puml", "uml" -> DiagramType.PLANTUML + "dot", "graphviz", "gv" -> DiagramType.GRAPHVIZ + else -> DiagramType.UNKNOWN + } + } +} + +enum class DiagramType { + MERMAID, + PLANTUML, + GRAPHVIZ, + UNKNOWN +} + From 42281578d8bb8eb7dd45a1d1a482bb676e9460f6 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 13:32:00 +0800 Subject: [PATCH 09/37] feat(core): add async live terminal support for shell tools Introduce async shell execution with live terminal sessions. Shell commands now return a Pending result immediately, and session completion is monitored in the background. Renderers are updated to handle live terminal status and await session results. --- .../agent/orchestrator/ToolExecutionResult.kt | 38 +++- .../agent/orchestrator/ToolOrchestrator.kt | 177 ++++++++++++++---- .../agent/render/CodingAgentRenderer.kt | 36 ++++ .../kotlin/cc/unitmesh/agent/tool/Tool.kt | 23 +++ .../kotlin/cc/unitmesh/llm/JsExports.kt | 6 + .../devins/idea/renderer/JewelRenderer.kt | 116 ++++++++++++ .../renderer/sketch/IdeaSketchRenderer.kt | 18 +- .../ui/compose/agent/ComposeRenderer.kt | 97 ++++++++++ 8 files changed, 460 insertions(+), 51 deletions(-) diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolExecutionResult.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolExecutionResult.kt index c79d90ae3d..f1069c7ee0 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolExecutionResult.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolExecutionResult.kt @@ -28,8 +28,15 @@ data class ToolExecutionResult( is ToolResult.Success -> true is ToolResult.AgentResult -> result.success is ToolResult.Error -> false + is ToolResult.Pending -> false // Pending is not yet successful } - + + /** + * Check if the execution is pending (async) + */ + val isPending: Boolean + get() = result is ToolResult.Pending + /** * Get the result content */ @@ -38,6 +45,7 @@ data class ToolExecutionResult( is ToolResult.Success -> result.content is ToolResult.AgentResult -> result.content is ToolResult.Error -> result.message + is ToolResult.Pending -> result.message } /** @@ -98,5 +106,33 @@ data class ToolExecutionResult( metadata = metadata ) } + + /** + * Create a pending result for async tool execution (e.g., Shell with PTY) + */ + fun pending( + executionId: String, + toolName: String, + sessionId: String, + command: String, + startTime: Long, + metadata: Map = emptyMap() + ): ToolExecutionResult { + return ToolExecutionResult( + executionId = executionId, + toolName = toolName, + result = ToolResult.Pending( + sessionId = sessionId, + toolName = toolName, + command = command, + message = "Executing: $command" + ), + startTime = startTime, + endTime = startTime, // Not yet completed + retryCount = 0, + state = ToolExecutionState.Executing(executionId, startTime), + metadata = metadata + mapOf("sessionId" to sessionId, "isAsync" to "true") + ) + } } } 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 32b71dd67e..90afb89b60 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 @@ -15,20 +15,32 @@ import cc.unitmesh.agent.tool.shell.LiveShellExecutor import cc.unitmesh.agent.tool.shell.LiveShellSession import cc.unitmesh.agent.tool.shell.ShellExecutionConfig import cc.unitmesh.agent.tool.shell.ShellExecutor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.yield import kotlinx.datetime.Clock /** * Tool orchestrator responsible for managing tool execution workflow * Handles permission checking, state management, and execution coordination + * + * @param asyncShellExecution If true, shell commands will execute asynchronously and return + * a Pending result immediately. The UI can display live terminal output + * and the result will be updated when the command completes. + * If false (default), shell commands will block until completion. */ class ToolOrchestrator( private val registry: ToolRegistry, private val policyEngine: PolicyEngine, private val renderer: CodingAgentRenderer, - private val mcpConfigService: McpToolConfigService? = null + private val mcpConfigService: McpToolConfigService? = null, + private val asyncShellExecution: Boolean = true ) { private val logger = getLogger("ToolOrchestrator") + + // Coroutine scope for background tasks (async shell monitoring) + private val backgroundScope = CoroutineScope(Dispatchers.Default) /** * Execute a single tool call with full orchestration @@ -116,53 +128,91 @@ class ToolOrchestrator( } } - // Execute the tool (如果已经启动了 PTY,这里需要等待完成) + // Execute the tool val result = if (liveSession != null) { - // 对于 Live PTY,等待完成并从 session 获取输出 val shellExecutor = getShellExecutor(registry.getTool(toolName) as cc.unitmesh.agent.tool.impl.ShellTool) - - // 等待 PTY 进程完成 - val exitCode = try { - if (shellExecutor is LiveShellExecutor) { - shellExecutor.waitForSession(liveSession, context.timeout) - } else { - throw ToolException("Executor does not support live sessions", ToolErrorType.NOT_SUPPORTED) - } - } catch (e: ToolException) { - return ToolExecutionResult.failure( - context.executionId, toolName, "Command execution error: ${e.message}", - startTime, Clock.System.now().toEpochMilliseconds() + + if (asyncShellExecution) { + // Async mode: Return Pending immediately and monitor in background + val command = liveSession.command + val sessionId = liveSession.sessionId + + // Start background monitoring for session completion + startSessionMonitoring( + session = liveSession, + shellExecutor = shellExecutor as LiveShellExecutor, + startTime = startTime, + timeoutMs = context.timeout ) - } catch (e: Exception) { - return ToolExecutionResult.failure( - context.executionId, toolName, "Command execution error: ${e.message}", - startTime, Clock.System.now().toEpochMilliseconds() + + // Return Pending result immediately + logger.debug { "Returning Pending result for async shell execution: $sessionId" } + ToolResult.Pending( + sessionId = sessionId, + toolName = toolName, + command = command, + message = "Executing: $command", + metadata = mapOf( + "workingDirectory" to (liveSession.workingDirectory ?: ""), + "isAsync" to "true" + ) ) - } - - // 从 session 获取输出 - val stdout = liveSession.getStdout() - val metadata = mapOf( - "exit_code" to exitCode.toString(), - "execution_time_ms" to (Clock.System.now().toEpochMilliseconds() - startTime).toString(), - "shell" to (shellExecutor.getDefaultShell() ?: "unknown"), - "stdout" to stdout, - "stderr" to "" - ) - - if (exitCode == 0) { - ToolResult.Success(stdout, metadata) } else { - ToolResult.Error("Command failed with exit code: $exitCode", metadata = metadata) + // Sync mode: Wait for completion (original behavior) + val exitCode = try { + if (shellExecutor is LiveShellExecutor) { + shellExecutor.waitForSession(liveSession, context.timeout) + } else { + throw ToolException("Executor does not support live sessions", ToolErrorType.NOT_SUPPORTED) + } + } catch (e: ToolException) { + return ToolExecutionResult.failure( + context.executionId, toolName, "Command execution error: ${e.message}", + startTime, Clock.System.now().toEpochMilliseconds() + ) + } catch (e: Exception) { + return ToolExecutionResult.failure( + context.executionId, toolName, "Command execution error: ${e.message}", + startTime, Clock.System.now().toEpochMilliseconds() + ) + } + + // Get output from session + val stdout = liveSession.getStdout() + val metadata = mapOf( + "exit_code" to exitCode.toString(), + "execution_time_ms" to (Clock.System.now().toEpochMilliseconds() - startTime).toString(), + "shell" to (shellExecutor.getDefaultShell() ?: "unknown"), + "stdout" to stdout, + "stderr" to "" + ) + + if (exitCode == 0) { + ToolResult.Success(stdout, metadata) + } else { + ToolResult.Error("Command failed with exit code: $exitCode", metadata = metadata) + } } } else { - // 普通执行 + // Normal execution for non-shell tools executeToolInternal(toolName, params, context) } val endTime = Clock.System.now().toEpochMilliseconds() - - // Update final state + + // Handle Pending result specially (async shell execution) + if (result is ToolResult.Pending) { + return ToolExecutionResult.pending( + executionId = context.executionId, + toolName = toolName, + sessionId = result.sessionId, + command = result.command, + startTime = startTime, + metadata = result.metadata + ) + } + + // Update final state for completed results val finalState = if (isSuccessResult(result)) { ToolExecutionState.Success(toolCall.id, result, endTime - startTime) } else { @@ -176,7 +226,7 @@ class ToolOrchestrator( } else { metadata } - + return ToolExecutionResult( executionId = context.executionId, toolName = toolName, @@ -205,6 +255,53 @@ class ToolOrchestrator( return tool.getExecutor() } + /** + * Start background monitoring for an async shell session. + * When the session completes, updates the renderer with the final status. + */ + private fun startSessionMonitoring( + session: LiveShellSession, + shellExecutor: LiveShellExecutor, + startTime: Long, + timeoutMs: Long + ) { + backgroundScope.launch { + try { + logger.debug { "Starting background monitoring for session: ${session.sessionId}" } + + // Wait for the session to complete + val exitCode = shellExecutor.waitForSession(session, timeoutMs) + val endTime = Clock.System.now().toEpochMilliseconds() + val executionTimeMs = endTime - startTime + + logger.debug { "Session ${session.sessionId} completed with exit code: $exitCode" } + + // Get output from session + val output = session.getStdout() + + // Update renderer with final status + renderer.updateLiveTerminalStatus( + sessionId = session.sessionId, + exitCode = exitCode, + executionTimeMs = executionTimeMs, + output = output + ) + + logger.debug { "Updated renderer with session completion: ${session.sessionId}" } + } catch (e: Exception) { + logger.error(e) { "Error monitoring session ${session.sessionId}: ${e.message}" } + + // Update renderer with error status + renderer.updateLiveTerminalStatus( + sessionId = session.sessionId, + exitCode = -1, + executionTimeMs = Clock.System.now().toEpochMilliseconds() - startTime, + output = "Error: ${e.message}" + ) + } + } + } + private suspend fun executeToolInternal( toolName: String, params: Map, @@ -571,6 +668,7 @@ class ToolOrchestrator( is ToolResult.Success -> true is ToolResult.AgentResult -> result.success is ToolResult.Error -> false + is ToolResult.Pending -> false // Pending is not yet successful } } @@ -578,7 +676,8 @@ class ToolOrchestrator( return when (result) { is ToolResult.Error -> result.message is ToolResult.AgentResult -> if (!result.success) result.content else "" - else -> "Unknown error" + is ToolResult.Pending -> "" // Pending has no error yet + is ToolResult.Success -> "" } } diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt index c933bc0fdc..bc495e9c43 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt @@ -1,5 +1,6 @@ package cc.unitmesh.agent.render +import cc.unitmesh.agent.tool.ToolResult import cc.unitmesh.llm.compression.TokenInfo interface CodingAgentRenderer { @@ -28,6 +29,10 @@ interface CodingAgentRenderer { fun renderUserConfirmationRequest(toolName: String, params: Map) + /** + * Add a live terminal session to the timeline. + * Called when a Shell tool starts execution with PTY support. + */ fun addLiveTerminal( sessionId: String, command: String, @@ -36,4 +41,35 @@ interface CodingAgentRenderer { ) { // Default: no-op for renderers that don't support live terminals } + + /** + * Update the status of a live terminal session. + * Called when the shell command completes (either success or failure). + * + * @param sessionId The session ID of the live terminal + * @param exitCode The exit code of the command (0 = success) + * @param executionTimeMs The total execution time in milliseconds + * @param output The captured output (optional, may be null if output is streamed via PTY) + */ + fun updateLiveTerminalStatus( + sessionId: String, + exitCode: Int, + executionTimeMs: Long, + output: String? = null + ) { + // Default: no-op for renderers that don't support live terminals + } + + /** + * Await the result of an async session. + * Used when the Agent needs to wait for a shell command to complete before proceeding. + * + * @param sessionId The session ID to wait for + * @param timeoutMs Maximum time to wait in milliseconds + * @return The final ToolResult (Success or Error) + */ + suspend fun awaitSessionResult(sessionId: String, timeoutMs: Long): ToolResult { + // Default: return error for renderers that don't support async sessions + return ToolResult.Error("Async session not supported by this renderer") + } } diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/Tool.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/Tool.kt index a97a76846a..5851041df6 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/Tool.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/Tool.kt @@ -77,25 +77,48 @@ sealed class ToolResult { val metadata: Map = emptyMap() ) : ToolResult() + /** + * Pending 结果 - 表示异步执行中的工具调用 + * 用于 Shell 等需要实时输出的工具,UI 可以通过 sessionId 跟踪执行状态 + * + * @param sessionId 会话 ID,用于跟踪和更新执行状态 + * @param toolName 工具名称 + * @param command 执行的命令(用于显示) + * @param message 状态消息 + * @param metadata 额外的元数据 + */ + @Serializable + data class Pending( + val sessionId: String, + val toolName: String, + val command: String = "", + val message: String = "Executing...", + val metadata: Map = emptyMap() + ) : ToolResult() + fun isSuccess(): Boolean = this is Success || (this is AgentResult && this.success) fun isError(): Boolean = this is Error || (this is AgentResult && !this.success) + fun isPending(): Boolean = this is Pending fun getOutput(): String = when (this) { is Success -> content is AgentResult -> content is Error -> "" + is Pending -> message } fun getError(): String = when (this) { is Success -> "" is AgentResult -> if (!success) content else "" is Error -> message + is Pending -> "" } fun extractMetadata(): Map = when (this) { is Success -> metadata is AgentResult -> metadata is Error -> metadata + is Pending -> metadata } } diff --git a/mpp-core/src/jsMain/kotlin/cc/unitmesh/llm/JsExports.kt b/mpp-core/src/jsMain/kotlin/cc/unitmesh/llm/JsExports.kt index 4d8fa4b165..143712083f 100644 --- a/mpp-core/src/jsMain/kotlin/cc/unitmesh/llm/JsExports.kt +++ b/mpp-core/src/jsMain/kotlin/cc/unitmesh/llm/JsExports.kt @@ -797,6 +797,12 @@ private fun cc.unitmesh.agent.tool.ToolResult.toJsToolResult(): JsToolResult { errorMessage = if (!this.success) "Agent execution failed" else null, metadata = this.metadata ) + is cc.unitmesh.agent.tool.ToolResult.Pending -> JsToolResult( + success = false, // Not yet completed + output = this.message, + errorMessage = null, + metadata = this.metadata + mapOf("sessionId" to this.sessionId, "isPending" to "true") + ) } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt index 1c601da634..fa0c720cba 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt @@ -404,5 +404,121 @@ class JewelRenderer : BaseRenderer() { private fun parseParamsString(paramsStr: String) = RendererUtils.parseParamsString(paramsStr) + + // ========== Live Terminal Support ========== + + // Channel map for awaiting session results + private val sessionResultChannels = mutableMapOf>() + + /** + * Adds a live terminal session to the timeline. + * This is called when a Shell tool is executed with PTY support. + */ + override fun addLiveTerminal( + sessionId: String, + command: String, + workingDirectory: String?, + ptyHandle: Any? + ) { + addTimelineItem( + TimelineItem.LiveTerminalItem( + sessionId = sessionId, + command = command, + workingDirectory = workingDirectory, + ptyHandle = ptyHandle + ) + ) + } + + /** + * Update the status of a live terminal session when it completes. + * This is called from the background monitoring coroutine in ToolOrchestrator. + */ + override fun updateLiveTerminalStatus( + sessionId: String, + exitCode: Int, + executionTimeMs: Long, + output: String? + ) { + // Find and update the LiveTerminalItem in the timeline + _timeline.update { currentTimeline -> + currentTimeline.map { item -> + if (item is TimelineItem.LiveTerminalItem && item.sessionId == sessionId) { + item.copy(exitCode = exitCode, executionTimeMs = executionTimeMs) + } else { + item + } + } + } + + // Also notify any waiting coroutines via the session result channel + sessionResultChannels[sessionId]?.let { channel -> + val result = if (exitCode == 0) { + cc.unitmesh.agent.tool.ToolResult.Success( + content = output ?: "", + metadata = mapOf( + "exit_code" to exitCode.toString(), + "execution_time_ms" to executionTimeMs.toString() + ) + ) + } else { + cc.unitmesh.agent.tool.ToolResult.Error( + message = "Command failed with exit code: $exitCode", + metadata = mapOf( + "exit_code" to exitCode.toString(), + "execution_time_ms" to executionTimeMs.toString(), + "output" to (output ?: "") + ) + ) + } + channel.trySend(result) + sessionResultChannels.remove(sessionId) + } + } + + /** + * Await the result of an async shell session. + * Used when the Agent needs to wait for a shell command to complete before proceeding. + */ + override suspend fun awaitSessionResult(sessionId: String, timeoutMs: Long): cc.unitmesh.agent.tool.ToolResult { + // Check if the session is already completed + val existingItem = _timeline.value.find { + it is TimelineItem.LiveTerminalItem && it.sessionId == sessionId + } as? TimelineItem.LiveTerminalItem + + if (existingItem?.exitCode != null) { + // Session already completed + return if (existingItem.exitCode == 0) { + cc.unitmesh.agent.tool.ToolResult.Success( + content = "", + metadata = mapOf( + "exit_code" to existingItem.exitCode.toString(), + "execution_time_ms" to (existingItem.executionTimeMs ?: 0L).toString() + ) + ) + } else { + cc.unitmesh.agent.tool.ToolResult.Error( + message = "Command failed with exit code: ${existingItem.exitCode}", + metadata = mapOf( + "exit_code" to existingItem.exitCode.toString(), + "execution_time_ms" to (existingItem.executionTimeMs ?: 0L).toString() + ) + ) + } + } + + // Create a channel to wait for the result + val channel = kotlinx.coroutines.channels.Channel(1) + sessionResultChannels[sessionId] = channel + + return try { + kotlinx.coroutines.withTimeout(timeoutMs) { + channel.receive() + } + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + sessionResultChannels.remove(sessionId) + cc.unitmesh.agent.tool.ToolResult.Error("Session timed out after ${timeoutMs}ms") + } + } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt index d800f56605..b70d85f07b 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt @@ -24,14 +24,14 @@ import org.jetbrains.jewel.ui.component.CircularProgressIndicator * - Walkthrough -> IdeaWalkthroughBlockRenderer * - Mermaid -> MermaidDiagramView * - DevIn -> IdeaDevInBlockRenderer - * + * * Related GitHub Issue: https://github.com/phodal/auto-dev/issues/25 */ object IdeaSketchRenderer { /** * Render LLM response content with full sketch support. - * + * * @param content The content to render * @param isComplete Whether the content is complete (not streaming) * @param parentDisposable Parent disposable for resource cleanup @@ -147,15 +147,11 @@ object IdeaSketchRenderer { } else -> { - if (fence.text.isNotBlank()) { - IdeaCodeBlockRenderer( - code = fence.text, - language = fence.languageId, - project = project, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(8.dp)) - } + JewelMarkdownRenderer( + fence.text, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) } } } 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 d7da6fd1ce..dda0be42fc 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 @@ -443,6 +443,103 @@ class ComposeRenderer : BaseRenderer() { ) } + /** + * Update the status of a live terminal session when it completes. + * This is called from the background monitoring coroutine in ToolOrchestrator. + */ + override fun updateLiveTerminalStatus( + sessionId: String, + exitCode: Int, + executionTimeMs: Long, + output: String? + ) { + // Find and update the LiveTerminalItem in the timeline + val index = _timeline.indexOfFirst { + it is TimelineItem.LiveTerminalItem && it.sessionId == sessionId + } + + if (index >= 0) { + val existingItem = _timeline[index] as TimelineItem.LiveTerminalItem + // Replace with updated item containing exit code and execution time + _timeline[index] = existingItem.copy( + exitCode = exitCode, + executionTimeMs = executionTimeMs + ) + } + + // Also notify any waiting coroutines via the session result channel + sessionResultChannels[sessionId]?.let { channel -> + val result = if (exitCode == 0) { + cc.unitmesh.agent.tool.ToolResult.Success( + content = output ?: "", + metadata = mapOf( + "exit_code" to exitCode.toString(), + "execution_time_ms" to executionTimeMs.toString() + ) + ) + } else { + cc.unitmesh.agent.tool.ToolResult.Error( + message = "Command failed with exit code: $exitCode", + metadata = mapOf( + "exit_code" to exitCode.toString(), + "execution_time_ms" to executionTimeMs.toString(), + "output" to (output ?: "") + ) + ) + } + channel.trySend(result) + sessionResultChannels.remove(sessionId) + } + } + + // Channel map for awaiting session results + private val sessionResultChannels = mutableMapOf>() + + /** + * Await the result of an async shell session. + * Used when the Agent needs to wait for a shell command to complete before proceeding. + */ + override suspend fun awaitSessionResult(sessionId: String, timeoutMs: Long): cc.unitmesh.agent.tool.ToolResult { + // Check if the session is already completed + val existingItem = _timeline.find { + it is TimelineItem.LiveTerminalItem && it.sessionId == sessionId + } as? TimelineItem.LiveTerminalItem + + if (existingItem?.exitCode != null) { + // Session already completed + return if (existingItem.exitCode == 0) { + cc.unitmesh.agent.tool.ToolResult.Success( + content = "", + metadata = mapOf( + "exit_code" to existingItem.exitCode.toString(), + "execution_time_ms" to (existingItem.executionTimeMs ?: 0L).toString() + ) + ) + } else { + cc.unitmesh.agent.tool.ToolResult.Error( + message = "Command failed with exit code: ${existingItem.exitCode}", + metadata = mapOf( + "exit_code" to existingItem.exitCode.toString(), + "execution_time_ms" to (existingItem.executionTimeMs ?: 0L).toString() + ) + ) + } + } + + // Create a channel to wait for the result + val channel = kotlinx.coroutines.channels.Channel(1) + sessionResultChannels[sessionId] = channel + + return try { + kotlinx.coroutines.withTimeout(timeoutMs) { + channel.receive() + } + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + sessionResultChannels.remove(sessionId) + cc.unitmesh.agent.tool.ToolResult.Error("Session timed out after ${timeoutMs}ms") + } + } + fun forceStop() { // If there's streaming output, save it as a message first val currentOutput = _currentStreamingOutput.trim() From 7763bb8e0a255fa1dece2864bd462dc2aa557095 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 14:08:17 +0800 Subject: [PATCH 10/37] feat(shell): add live terminal output streaming in IDEA Enable real-time shell command output in the IDEA timeline using live process monitoring. Adds a new UI bubble for live terminal sessions and updates shell execution to support async streaming and status reporting. --- .../agent/executor/CodingAgentExecutor.kt | 133 ++++++++- .../agent/tool/schema/ToolResultFormatter.kt | 34 ++- .../tool/shell/DefaultShellExecutor.jvm.kt | 82 +++++- .../timeline/IdeaLiveTerminalBubble.kt | 272 ++++++++++++++++++ .../timeline/IdeaTimelineContent.kt | 11 +- .../devins/idea/renderer/JewelRenderer.kt | 14 +- 6 files changed, 522 insertions(+), 24 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt 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 e74fc0ac5d..899921a151 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 @@ -18,6 +18,18 @@ import kotlinx.coroutines.coroutineScope import kotlinx.datetime.Clock import cc.unitmesh.agent.orchestrator.ToolExecutionContext as OrchestratorContext +/** + * Configuration for async shell execution timeout behavior + */ +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 + /** Interval for checking process status after initial timeout */ + val checkIntervalMs: Long = 30_000L // 30 seconds +) + class CodingAgentExecutor( projectPath: String, llmService: KoogLLMService, @@ -25,7 +37,8 @@ class CodingAgentExecutor( renderer: CodingAgentRenderer, maxIterations: Int = 100, private val subAgentManager: SubAgentManager? = null, - enableLLMStreaming: Boolean = true + enableLLMStreaming: Boolean = true, + private val asyncShellConfig: AsyncShellConfig = AsyncShellConfig() ) : BaseAgentExecutor( projectPath = projectPath, llmService = llmService, @@ -202,12 +215,17 @@ class CodingAgentExecutor( environment = emptyMap() ) - val executionResult = toolOrchestrator.executeToolCall( + var executionResult = toolOrchestrator.executeToolCall( toolName, params, executionContext ) + // Handle Pending result (async shell execution) + if (executionResult.isPending) { + executionResult = handlePendingResult(executionResult, toolName, params) + } + results.add(Triple(toolName, params, executionResult)) val stepResult = AgentStep( @@ -240,7 +258,8 @@ class CodingAgentExecutor( } } is ToolResult.AgentResult -> if (!result.success) result.content else stepResult.result - else -> stepResult.result + is ToolResult.Pending -> stepResult.result // Should not happen after handlePendingResult + is ToolResult.Success -> stepResult.result } val contentHandlerResult = checkForLongContent(toolName, fullOutput ?: "", executionResult) @@ -260,7 +279,7 @@ class CodingAgentExecutor( } // 错误恢复处理 - if (!executionResult.isSuccess) { + if (!executionResult.isSuccess && !executionResult.isPending) { val command = if (toolName == "shell") params["command"] as? String else null val errorMessage = executionResult.content ?: "Unknown error" @@ -271,6 +290,112 @@ class CodingAgentExecutor( results } + /** + * Handle a Pending result from async shell execution. + * Waits for the session to complete with timeout handling. + * If the process takes longer than initialWaitTimeoutMs, returns a special result + * indicating the process is still running (similar to Augment's behavior). + */ + private suspend fun handlePendingResult( + pendingResult: ToolExecutionResult, + toolName: String, + params: Map + ): ToolExecutionResult { + val pending = pendingResult.result as? ToolResult.Pending + ?: return pendingResult + + val sessionId = pending.sessionId + val command = pending.command + val startTime = pendingResult.startTime + + // First, try to wait for the initial timeout + val initialResult = renderer.awaitSessionResult(sessionId, asyncShellConfig.initialWaitTimeoutMs) + + return when (initialResult) { + is ToolResult.Success -> { + // Process completed within initial timeout + val endTime = Clock.System.now().toEpochMilliseconds() + ToolExecutionResult.success( + executionId = pendingResult.executionId, + toolName = toolName, + content = initialResult.content, + startTime = startTime, + endTime = endTime, + metadata = initialResult.metadata + mapOf("sessionId" to sessionId) + ) + } + is ToolResult.Error -> { + // Process failed + val endTime = Clock.System.now().toEpochMilliseconds() + ToolExecutionResult.failure( + executionId = pendingResult.executionId, + toolName = toolName, + error = initialResult.message, + startTime = startTime, + endTime = endTime, + metadata = initialResult.metadata + mapOf("sessionId" to sessionId) + ) + } + is ToolResult.Pending -> { + // Process is still running after initial timeout + // Return a special result to inform the AI + val elapsedSeconds = (Clock.System.now().toEpochMilliseconds() - startTime) / 1000 + val stillRunningMessage = buildString { + appendLine("⏳ Process is still running after ${elapsedSeconds}s") + appendLine("Command: $command") + appendLine("Session ID: $sessionId") + appendLine() + appendLine("The process is executing in the background. You can:") + appendLine("1. Continue with other tasks while waiting") + appendLine("2. Check the terminal output in the UI for real-time progress") + appendLine("3. The result will be available when the process completes") + } + + // Return as a "success" with the still-running message + // This allows the agent to continue and make decisions + val endTime = Clock.System.now().toEpochMilliseconds() + ToolExecutionResult( + executionId = pendingResult.executionId, + toolName = toolName, + result = ToolResult.Success( + content = stillRunningMessage, + metadata = mapOf( + "status" to "still_running", + "sessionId" to sessionId, + "command" to command, + "elapsedSeconds" to elapsedSeconds.toString() + ) + ), + startTime = startTime, + endTime = endTime, + state = ToolExecutionState.Executing(pendingResult.executionId, startTime), + metadata = mapOf( + "sessionId" to sessionId, + "isAsync" to "true", + "stillRunning" to "true" + ) + ) + } + is ToolResult.AgentResult -> { + // Unexpected, but handle it + val endTime = Clock.System.now().toEpochMilliseconds() + ToolExecutionResult( + executionId = pendingResult.executionId, + toolName = toolName, + result = initialResult, + startTime = startTime, + endTime = endTime, + state = if (initialResult.success) { + ToolExecutionState.Success(pendingResult.executionId, initialResult, endTime - startTime) + } else { + ToolExecutionState.Failed(pendingResult.executionId, initialResult.content, endTime - startTime) + }, + metadata = mapOf("sessionId" to sessionId) + ) + } + } + } + private fun recordFileEdit(params: Map) { val path = params["path"] as? String val content = params["content"] as? String diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/schema/ToolResultFormatter.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/schema/ToolResultFormatter.kt index 25bd56acd0..6c75f97e23 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/schema/ToolResultFormatter.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/schema/ToolResultFormatter.kt @@ -1,6 +1,7 @@ package cc.unitmesh.agent.tool.schema import cc.unitmesh.agent.orchestrator.ToolExecutionResult +import cc.unitmesh.agent.tool.ToolResult /** * 工具执行结果格式化器 @@ -26,15 +27,32 @@ object ToolResultFormatter { } } - // 格式化结果 - sb.append("Result: ${if (result.isSuccess) "SUCCESS" else "FAILED"}\n") - sb.append("Output:\n") - sb.append(result.content) + // 格式化结果状态 + val statusText = when { + result.isPending -> "PENDING" + result.isSuccess -> "SUCCESS" + else -> "FAILED" + } + sb.append("Result: $statusText\n") + + // 处理 Pending 状态的特殊格式化 + if (result.isPending) { + val pending = result.result as? ToolResult.Pending + if (pending != null) { + sb.append("Status: Process is executing asynchronously\n") + sb.append("Session ID: ${pending.sessionId}\n") + sb.append("Command: ${pending.command}\n") + sb.append("Message: ${pending.message}\n") + } + } else { + sb.append("Output:\n") + sb.append(result.content) - if (!result.isSuccess) { - val errorMsg = result.errorMessage - if (!errorMsg.isNullOrEmpty()) { - sb.append("\nError: $errorMsg") + if (!result.isSuccess) { + val errorMsg = result.errorMessage + if (!errorMsg.isNullOrEmpty()) { + sb.append("\nError: $errorMsg") + } } } diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/DefaultShellExecutor.jvm.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/DefaultShellExecutor.jvm.kt index db230244b3..693e49e679 100644 --- a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/DefaultShellExecutor.jvm.kt +++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/DefaultShellExecutor.jvm.kt @@ -8,9 +8,10 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import java.io.File import java.io.IOException +import java.util.UUID import java.util.concurrent.TimeUnit -actual class DefaultShellExecutor : ShellExecutor { +actual class DefaultShellExecutor : ShellExecutor, LiveShellExecutor { private val ptyExecutor: PtyShellExecutor? by lazy { try { val executor = PtyShellExecutor() @@ -343,4 +344,83 @@ actual class DefaultShellExecutor : ShellExecutor { commandLower.contains(dangerous) } } + + // ==================== LiveShellExecutor Implementation ==================== + + /** + * Check if live shell execution is supported. + * Returns true if PTY is available, or falls back to ProcessBuilder-based live execution. + */ + override fun supportsLiveExecution(): Boolean { + // Always support live execution - use PTY if available, otherwise ProcessBuilder + return true + } + + /** + * Start a shell command with live output streaming. + * Uses PTY if available, otherwise falls back to ProcessBuilder. + */ + override suspend fun startLiveExecution( + command: String, + config: ShellExecutionConfig + ): LiveShellSession = withContext(Dispatchers.IO) { + // Try PTY first if available + if (ptyExecutor != null) { + return@withContext ptyExecutor!!.startLiveExecution(command, config) + } + + // Fallback to ProcessBuilder-based live execution + if (!validateCommand(command)) { + throw ToolException("Command not allowed: $command", ToolErrorType.PERMISSION_DENIED) + } + + val sessionId = UUID.randomUUID().toString() + val processCommand = prepareCommand(command, config.shell) + + val processBuilder = ProcessBuilder(processCommand).apply { + config.workingDirectory?.let { workDir -> + directory(File(workDir)) + } + if (config.environment.isNotEmpty()) { + environment().putAll(config.environment) + } + augmentEnvironmentPath(environment(), config.environment) + redirectErrorStream(false) + } + + val process = processBuilder.start() + + LiveShellSession( + sessionId = sessionId, + command = command, + workingDirectory = config.workingDirectory, + ptyHandle = process, // Pass the Process object as ptyHandle + isLiveSupported = true + ) + } + + /** + * Wait for a live session to complete and return the exit code. + */ + override suspend fun waitForSession( + session: LiveShellSession, + timeoutMs: Long + ): Int = withContext(Dispatchers.IO) { + // Try PTY executor first if available + if (ptyExecutor != null && session.ptyHandle is com.pty4j.PtyProcess) { + return@withContext ptyExecutor!!.waitForSession(session, timeoutMs) + } + + // Handle ProcessBuilder-based session + val process = session.ptyHandle as? Process + ?: throw ToolException("Invalid session handle", ToolErrorType.INTERNAL_ERROR) + + val completed = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS) + if (!completed) { + process.destroyForcibly() + throw ToolException("Command timed out after ${timeoutMs}ms", ToolErrorType.TIMEOUT) + } + + process.exitValue() + } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt new file mode 100644 index 0000000000..6bee2e83b4 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt @@ -0,0 +1,272 @@ +package cc.unitmesh.devins.idea.components.timeline + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.agent.render.TimelineItem +import cc.unitmesh.devins.idea.renderer.terminal.IdeaAnsiTerminalRenderer +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import com.intellij.openapi.project.Project +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Text + +/** + * Process output state for UI consumption + */ +data class ProcessOutputState( + val output: String = "", + val isRunning: Boolean = true, + val exitCode: Int? = null +) + +/** + * Collector that monitors a Process and emits output updates via Flow. + * Uses a listener-like pattern with periodic checks. + */ +class ProcessOutputCollector( + private val process: Process, + private val checkIntervalMs: Long = 100L +) { + private val _state = MutableStateFlow(ProcessOutputState()) + val state: StateFlow = _state.asStateFlow() + + private val buffer = StringBuilder() + private var job: Job? = null + + /** + * Start collecting output from the process. + * Call this from a coroutine scope. + */ + fun start(scope: CoroutineScope) { + job = scope.launch(Dispatchers.IO) { + try { + // Start readers in separate coroutines + val stdoutJob = launch { readStream(process.inputStream, isError = false) } + val stderrJob = launch { readStream(process.errorStream, isError = true) } + + // Periodic check for process completion + while (isActive && process.isAlive) { + delay(checkIntervalMs) + } + + // Process ended - wait a bit for streams to flush + delay(50) + stdoutJob.cancel() + stderrJob.cancel() + + // Update final state + _state.update { it.copy( + output = buffer.toString(), + isRunning = false, + exitCode = process.exitValue() + )} + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + buffer.append("\n\u001B[31mError: ${e.message}\u001B[0m") + _state.update { it.copy( + output = buffer.toString(), + isRunning = false + )} + } + } + } + + private suspend fun readStream(stream: java.io.InputStream, isError: Boolean) { + try { + val reader = stream.bufferedReader() + val charBuffer = CharArray(1024) + while (currentCoroutineContext().isActive) { + val bytesRead = reader.read(charBuffer) + if (bytesRead == -1) break + + synchronized(buffer) { + if (isError) buffer.append("\u001B[31m") + buffer.append(charBuffer, 0, bytesRead) + if (isError) buffer.append("\u001B[0m") + } + + _state.update { it.copy(output = buffer.toString()) } + } + } catch (e: Exception) { + // Stream closed + } + } + + fun stop() { + job?.cancel() + } +} + +/** + * Live terminal bubble for displaying real-time shell command output. + * Uses ProcessOutputCollector for listener-like output monitoring. + * + * Features: + * - Real-time output streaming via Flow + * - ANSI color and formatting support + * - Collapsible output with header + * - Status indicator (running/completed) + */ +@Composable +fun IdeaLiveTerminalBubble( + item: TimelineItem.LiveTerminalItem, + modifier: Modifier = Modifier, + project: Project? = null +) { + var expanded by remember { mutableStateOf(true) } + + val process = remember(item.ptyHandle) { item.ptyHandle as? Process } + + // Create collector and collect state + val collector = remember(process) { + process?.let { ProcessOutputCollector(it) } + } + + val outputState by collector?.state?.collectAsState() + ?: remember { mutableStateOf(ProcessOutputState( + output = "[No process handle available]", + isRunning = false + )) } + + // Start collector when process is available + val scope = rememberCoroutineScope() + LaunchedEffect(collector) { + collector?.start(scope) + } + + // Cleanup on dispose + DisposableEffect(collector) { + onDispose { collector?.stop() } + } + + // Override with external exitCode if provided + val actualExitCode = item.exitCode ?: outputState.exitCode + val isRunning = if (item.exitCode != null) false else outputState.isRunning + val output = outputState.output.ifEmpty { "Waiting for output..." } + + Column( + modifier = modifier + .fillMaxWidth() + .background(AutoDevColors.Neutral.c900, RoundedCornerShape(4.dp)) + .padding(8.dp) + ) { + // Header row + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Status indicator + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background( + if (isRunning) AutoDevColors.Green.c400 + else if (actualExitCode == 0) AutoDevColors.Green.c400 + else AutoDevColors.Red.c400 + ) + ) + + // Terminal icon + Text( + text = "💻", + style = JewelTheme.defaultTextStyle.copy(fontSize = 14.sp) + ) + + // Command + Text( + text = item.command, + style = JewelTheme.defaultTextStyle.copy( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = AutoDevColors.Cyan.c400 + ), + modifier = Modifier.weight(1f), + maxLines = 1 + ) + + // Status badge + val (statusText, statusColor) = when { + isRunning -> "RUNNING" to AutoDevColors.Green.c400 + actualExitCode == 0 -> "EXIT 0" to AutoDevColors.Green.c400 + else -> "EXIT ${actualExitCode ?: "?"}" to AutoDevColors.Red.c400 + } + + Box( + modifier = Modifier + .background(statusColor.copy(alpha = 0.15f), RoundedCornerShape(10.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = statusText, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = statusColor + ) + ) + } + } + + // Working directory + if (item.workingDirectory != null) { + Text( + text = "📁 ${item.workingDirectory}", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + color = AutoDevColors.Neutral.c400 + ), + modifier = Modifier.padding(start = 16.dp, top = 2.dp) + ) + } + + // Collapsible output + AnimatedVisibility( + visible = expanded, + enter = expandVertically(), + exit = shrinkVertically() + ) { + if (output.isNotEmpty()) { + IdeaAnsiTerminalRenderer( + ansiText = output, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .heightIn(min = 60.dp, max = 300.dp), + maxHeight = 300, + backgroundColor = AutoDevColors.Neutral.c900 + ) + } else if (isRunning) { + Text( + text = "Waiting for output...", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = AutoDevColors.Neutral.c400 + ), + modifier = Modifier.padding(top = 8.dp) + ) + } + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt index 323b36873e..614de2bee6 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt @@ -74,14 +74,9 @@ fun IdeaTimelineItemView(item: TimelineItem, project: Project? = null) { IdeaTerminalOutputBubble(item, project = project) } is TimelineItem.LiveTerminalItem -> { - // Live terminal not supported in IDEA yet, show placeholder - IdeaTerminalOutputBubble( - item = TimelineItem.TerminalOutputItem( - command = item.command, - output = "[Live terminal session: ${item.sessionId}]", - exitCode = 0, - executionTimeMs = 0 - ), + // Live terminal with real-time output streaming + IdeaLiveTerminalBubble( + item = item, project = project ) } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt index fa0c720cba..5281b823b7 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt @@ -413,6 +413,7 @@ class JewelRenderer : BaseRenderer() { /** * Adds a live terminal session to the timeline. * This is called when a Shell tool is executed with PTY support. + * Replaces the previous ToolCallItem (Shell Command) with a LiveTerminalItem. */ override fun addLiveTerminal( sessionId: String, @@ -420,14 +421,21 @@ class JewelRenderer : BaseRenderer() { workingDirectory: String?, ptyHandle: Any? ) { - addTimelineItem( - TimelineItem.LiveTerminalItem( + // Replace the last ToolCallItem (Shell) with LiveTerminalItem + _timeline.update { items -> + val lastItem = items.lastOrNull() + val newItems = if (lastItem is TimelineItem.ToolCallItem && lastItem.toolType == ToolType.Shell) { + items.dropLast(1) + } else { + items + } + newItems + TimelineItem.LiveTerminalItem( sessionId = sessionId, command = command, workingDirectory = workingDirectory, ptyHandle = ptyHandle ) - ) + } } /** From 559f0ead57a05aed8607615d5673ab39f9b3f714 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 14:17:45 +0800 Subject: [PATCH 11/37] feat(mpp-idea): add cancel button to live terminal bubble Adds a cancel button to terminate running shell processes in the live terminal timeline bubble. Also refines timeline logic to prevent duplicate shell command bubbles. --- .../timeline/IdeaLiveTerminalBubble.kt | 84 ++++++++++++++----- .../devins/idea/renderer/JewelRenderer.kt | 30 ++++--- 2 files changed, 81 insertions(+), 33 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt index 6bee2e83b4..5dc3e82986 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt @@ -122,12 +122,14 @@ class ProcessOutputCollector( * - ANSI color and formatting support * - Collapsible output with header * - Status indicator (running/completed) + * - Cancel button to terminate running process */ @Composable fun IdeaLiveTerminalBubble( item: TimelineItem.LiveTerminalItem, modifier: Modifier = Modifier, - project: Project? = null + project: Project? = null, + onCancel: ((Process) -> Unit)? = null ) { var expanded by remember { mutableStateOf(true) } @@ -160,6 +162,19 @@ fun IdeaLiveTerminalBubble( val isRunning = if (item.exitCode != null) false else outputState.isRunning val output = outputState.output.ifEmpty { "Waiting for output..." } + // Cancel handler + val handleCancel: () -> Unit = { + process?.let { p -> + if (onCancel != null) { + onCancel(p) + } else { + // Default: destroy the process + p.destroyForcibly() + } + collector?.stop() + } + } + Column( modifier = modifier .fillMaxWidth() @@ -240,31 +255,58 @@ fun IdeaLiveTerminalBubble( ) } - // Collapsible output + // Collapsible output with cancel button AnimatedVisibility( visible = expanded, enter = expandVertically(), exit = shrinkVertically() ) { - if (output.isNotEmpty()) { - IdeaAnsiTerminalRenderer( - ansiText = output, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - .heightIn(min = 60.dp, max = 300.dp), - maxHeight = 300, - backgroundColor = AutoDevColors.Neutral.c900 - ) - } else if (isRunning) { - Text( - text = "Waiting for output...", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 11.sp, - color = AutoDevColors.Neutral.c400 - ), - modifier = Modifier.padding(top = 8.dp) - ) + Box(modifier = Modifier.fillMaxWidth()) { + // Output content + if (output.isNotEmpty()) { + IdeaAnsiTerminalRenderer( + ansiText = output, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .heightIn(min = 60.dp, max = 300.dp), + maxHeight = 300, + backgroundColor = AutoDevColors.Neutral.c900 + ) + } else if (isRunning) { + Text( + text = "Waiting for output...", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = AutoDevColors.Neutral.c400 + ), + modifier = Modifier.padding(top = 8.dp) + ) + } + + // Cancel button - only show when running + if (isRunning && process != null) { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp) + .background( + AutoDevColors.Red.c600.copy(alpha = 0.9f), + RoundedCornerShape(4.dp) + ) + .clickable { handleCancel() } + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = "Cancel", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + color = AutoDevColors.Neutral.c50 + ) + ) + } + } } } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt index 5281b823b7..272b26f47a 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt @@ -138,6 +138,17 @@ class JewelRenderer : BaseRenderer() { updateTaskFromToolCall(params) } + // Skip adding ToolCallItem for Shell - will be replaced by LiveTerminalItem + // This prevents the "two bubbles" problem + if (toolType == ToolType.Shell) { + _currentToolCall.value = ToolCallInfo( + toolName = toolInfo.toolName, + description = toolInfo.description, + details = toolInfo.details + ) + return // Don't add ToolCallItem, wait for addLiveTerminal + } + // Extract file path for read/write operations val filePath = when (toolType) { ToolType.ReadFile, ToolType.WriteFile -> params["path"] @@ -412,8 +423,10 @@ class JewelRenderer : BaseRenderer() { /** * Adds a live terminal session to the timeline. - * This is called when a Shell tool is executed with PTY support. - * Replaces the previous ToolCallItem (Shell Command) with a LiveTerminalItem. + * This is called when a Shell tool is executed with live output support. + * + * Note: renderToolCall() skips adding ToolCallItem for Shell tools, + * so we just add the LiveTerminalItem directly without replacement logic. */ override fun addLiveTerminal( sessionId: String, @@ -421,21 +434,14 @@ class JewelRenderer : BaseRenderer() { workingDirectory: String?, ptyHandle: Any? ) { - // Replace the last ToolCallItem (Shell) with LiveTerminalItem - _timeline.update { items -> - val lastItem = items.lastOrNull() - val newItems = if (lastItem is TimelineItem.ToolCallItem && lastItem.toolType == ToolType.Shell) { - items.dropLast(1) - } else { - items - } - newItems + TimelineItem.LiveTerminalItem( + addTimelineItem( + TimelineItem.LiveTerminalItem( sessionId = sessionId, command = command, workingDirectory = workingDirectory, ptyHandle = ptyHandle ) - } + ) } /** From 63ac20f6176433566da15eee0970722b7624f1dc Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 14:36:02 +0800 Subject: [PATCH 12/37] feat(shell): add process management tools for shell sessions Introduce read-process, wait-process, and kill-process tools to manage long-running shell commands. ShellTool now supports background execution with session IDs, enabling users to read output, wait for completion, or terminate processes asynchronously. --- .../agent/tool/impl/ProcessManagementTools.kt | 331 ++++++++++++++++++ .../cc/unitmesh/agent/tool/impl/ShellTool.kt | 206 +++++++++-- .../agent/tool/shell/ShellSessionManager.kt | 160 +++++++++ 3 files changed, 672 insertions(+), 25 deletions(-) create mode 100644 mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/ProcessManagementTools.kt create mode 100644 mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/ProcessManagementTools.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/ProcessManagementTools.kt new file mode 100644 index 0000000000..085919aefb --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/ProcessManagementTools.kt @@ -0,0 +1,331 @@ +package cc.unitmesh.agent.tool.impl + +import cc.unitmesh.agent.Platform +import cc.unitmesh.agent.tool.* +import cc.unitmesh.agent.tool.schema.DeclarativeToolSchema +import cc.unitmesh.agent.tool.schema.SchemaPropertyBuilder.boolean +import cc.unitmesh.agent.tool.schema.SchemaPropertyBuilder.integer +import cc.unitmesh.agent.tool.schema.SchemaPropertyBuilder.string +import cc.unitmesh.agent.tool.schema.ToolCategory +import cc.unitmesh.agent.tool.shell.ShellSessionManager +import kotlinx.coroutines.delay +import kotlinx.serialization.Serializable + +// ============================================================================ +// ReadProcess Tool - Read output from a running process +// ============================================================================ + +@Serializable +data class ReadProcessParams( + val sessionId: String, + val wait: Boolean = false, + val maxWaitSeconds: Int = 60 +) + +object ReadProcessSchema : DeclarativeToolSchema( + description = "Read output from a running or completed process session", + properties = mapOf( + "sessionId" to string( + description = "The session ID returned by shell command with wait=false or timeout", + required = true + ), + "wait" to boolean( + description = "If true, wait for process to complete before returning output", + required = false, + default = false + ), + "maxWaitSeconds" to integer( + description = "Maximum seconds to wait if wait=true", + required = false, + default = 60, + minimum = 1, + maximum = 600 + ) + ) +) { + override fun getExampleUsage(toolName: String): String { + return "/$toolName sessionId=\"abc-123\" wait=false" + } +} + +class ReadProcessInvocation( + params: ReadProcessParams, + tool: ReadProcessTool +) : BaseToolInvocation(params, tool) { + + override fun getDescription(): String = "Read output from session: ${params.sessionId}" + override fun getToolLocations(): List = emptyList() + + override suspend fun execute(context: ToolExecutionContext): ToolResult { + val session = ShellSessionManager.getSession(params.sessionId) + ?: return ToolResult.Error( + message = "Session not found: ${params.sessionId}", + errorType = ToolErrorType.INVALID_PARAMETERS.code + ) + + if (params.wait) { + // Wait for process to complete + val timeoutMs = params.maxWaitSeconds * 1000L + val startTime = Platform.getCurrentTimestamp() + + while (session.isRunning() && (Platform.getCurrentTimestamp() - startTime) < timeoutMs) { + delay(100) + } + } + + val output = session.getOutput() + val isRunning = session.isRunning() + + val metadata = mapOf( + "session_id" to params.sessionId, + "command" to session.command, + "is_running" to isRunning.toString(), + "exit_code" to (session.exitCode?.toString() ?: ""), + "execution_time_ms" to session.getExecutionTimeMs().toString() + ) + + return if (isRunning) { + ToolResult.Pending( + sessionId = params.sessionId, + toolName = "read-process", + command = session.command, + message = "Process still running.\n\nCurrent output:\n$output", + metadata = metadata + ) + } else { + val exitCode = session.exitCode ?: -1 + if (exitCode == 0) { + ToolResult.Success(output.ifEmpty { "(no output)" }, metadata) + } else { + ToolResult.Error( + message = "Process exited with code $exitCode:\n$output", + errorType = ToolErrorType.COMMAND_FAILED.code, + metadata = metadata + ) + } + } + } +} + +class ReadProcessTool : BaseExecutableTool() { + override val name: String = "read-process" + override val description: String = "Read output from a running or completed process session" + override val metadata: ToolMetadata = ToolMetadata( + displayName = "Read Process", + tuiEmoji = "📖", + composeIcon = "terminal", + category = ToolCategory.Execution, + schema = ReadProcessSchema + ) + + override fun getParameterClass(): String = ReadProcessParams::class.simpleName ?: "ReadProcessParams" + + override fun createToolInvocation(params: ReadProcessParams): ToolInvocation { + if (params.sessionId.isBlank()) { + throw ToolException("sessionId is required", ToolErrorType.MISSING_REQUIRED_PARAMETER) + } + return ReadProcessInvocation(params, this) + } +} + +// ============================================================================ +// WaitProcess Tool - Wait for a process to complete +// ============================================================================ + +@Serializable +data class WaitProcessParams( + val sessionId: String, + val timeoutMs: Long = 60000L +) + +object WaitProcessSchema : DeclarativeToolSchema( + description = "Wait for a background process to complete", + properties = mapOf( + "sessionId" to string( + description = "The session ID of the process to wait for", + required = true + ), + "timeoutMs" to integer( + description = "Maximum milliseconds to wait for completion", + required = false, + default = 60000, + minimum = 1000, + maximum = 600000 + ) + ) +) { + override fun getExampleUsage(toolName: String): String { + return "/$toolName sessionId=\"abc-123\" timeoutMs=120000" + } +} + +class WaitProcessInvocation( + params: WaitProcessParams, + tool: WaitProcessTool +) : BaseToolInvocation(params, tool) { + + override fun getDescription(): String = "Wait for session: ${params.sessionId}" + override fun getToolLocations(): List = emptyList() + + override suspend fun execute(context: ToolExecutionContext): ToolResult { + val session = ShellSessionManager.getSession(params.sessionId) + ?: return ToolResult.Error( + message = "Session not found: ${params.sessionId}", + errorType = ToolErrorType.INVALID_PARAMETERS.code + ) + + val startTime = Platform.getCurrentTimestamp() + + while (session.isRunning() && (Platform.getCurrentTimestamp() - startTime) < params.timeoutMs) { + delay(100) + } + + val output = session.getOutput() + val isRunning = session.isRunning() + + val metadata = mapOf( + "session_id" to params.sessionId, + "command" to session.command, + "is_running" to isRunning.toString(), + "exit_code" to (session.exitCode?.toString() ?: ""), + "execution_time_ms" to session.getExecutionTimeMs().toString() + ) + + return if (isRunning) { + ToolResult.Pending( + sessionId = params.sessionId, + toolName = "wait-process", + command = session.command, + message = "Process still running after ${params.timeoutMs}ms timeout.\n\nPartial output:\n${output.take(1000)}", + metadata = metadata + ) + } else { + // Clean up completed session + ShellSessionManager.removeSession(params.sessionId) + + val exitCode = session.exitCode ?: -1 + if (exitCode == 0) { + ToolResult.Success(output.ifEmpty { "(no output)" }, metadata) + } else { + ToolResult.Error( + message = "Process exited with code $exitCode:\n$output", + errorType = ToolErrorType.COMMAND_FAILED.code, + metadata = metadata + ) + } + } + } +} + +class WaitProcessTool : BaseExecutableTool() { + override val name: String = "wait-process" + override val description: String = "Wait for a background process to complete and return its output" + override val metadata: ToolMetadata = ToolMetadata( + displayName = "Wait Process", + tuiEmoji = "⏳", + composeIcon = "terminal", + category = ToolCategory.Execution, + schema = WaitProcessSchema + ) + + override fun getParameterClass(): String = WaitProcessParams::class.simpleName ?: "WaitProcessParams" + + override fun createToolInvocation(params: WaitProcessParams): ToolInvocation { + if (params.sessionId.isBlank()) { + throw ToolException("sessionId is required", ToolErrorType.MISSING_REQUIRED_PARAMETER) + } + return WaitProcessInvocation(params, this) + } +} + +// ============================================================================ +// KillProcess Tool - Terminate a running process +// ============================================================================ + +@Serializable +data class KillProcessParams( + val sessionId: String +) + +object KillProcessSchema : DeclarativeToolSchema( + description = "Terminate a running process by session ID", + properties = mapOf( + "sessionId" to string( + description = "The session ID of the process to terminate", + required = true + ) + ) +) { + override fun getExampleUsage(toolName: String): String { + return "/$toolName sessionId=\"abc-123\"" + } +} + +class KillProcessInvocation( + params: KillProcessParams, + tool: KillProcessTool +) : BaseToolInvocation(params, tool) { + + override fun getDescription(): String = "Kill session: ${params.sessionId}" + override fun getToolLocations(): List = emptyList() + + override suspend fun execute(context: ToolExecutionContext): ToolResult { + val session = ShellSessionManager.getSession(params.sessionId) + ?: return ToolResult.Error( + message = "Session not found: ${params.sessionId}", + errorType = ToolErrorType.INVALID_PARAMETERS.code + ) + + val wasRunning = session.isRunning() + val output = session.getOutput() + + val killed = session.kill() + ShellSessionManager.removeSession(params.sessionId) + + val metadata = mapOf( + "session_id" to params.sessionId, + "command" to session.command, + "was_running" to wasRunning.toString(), + "killed" to killed.toString(), + "execution_time_ms" to session.getExecutionTimeMs().toString() + ) + + return if (killed || !wasRunning) { + ToolResult.Success( + content = if (wasRunning) { + "Process terminated successfully.\n\nFinal output:\n$output" + } else { + "Process was already completed.\n\nOutput:\n$output" + }, + metadata = metadata + ) + } else { + ToolResult.Error( + message = "Failed to terminate process", + errorType = ToolErrorType.COMMAND_FAILED.code, + metadata = metadata + ) + } + } +} + +class KillProcessTool : BaseExecutableTool() { + override val name: String = "kill-process" + override val description: String = "Terminate a running process by session ID" + override val metadata: ToolMetadata = ToolMetadata( + displayName = "Kill Process", + tuiEmoji = "🛑", + composeIcon = "stop", + category = ToolCategory.Execution, + schema = KillProcessSchema + ) + + override fun getParameterClass(): String = KillProcessParams::class.simpleName ?: "KillProcessParams" + + override fun createToolInvocation(params: KillProcessParams): ToolInvocation { + if (params.sessionId.isBlank()) { + throw ToolException("sessionId is required", ToolErrorType.MISSING_REQUIRED_PARAMETER) + } + return KillProcessInvocation(params, this) + } +} diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/ShellTool.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/ShellTool.kt index c62eba7c55..572f37d5ea 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/ShellTool.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/ShellTool.kt @@ -2,13 +2,16 @@ 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.boolean import cc.unitmesh.agent.tool.schema.SchemaPropertyBuilder.integer import cc.unitmesh.agent.tool.schema.SchemaPropertyBuilder.objectType import cc.unitmesh.agent.tool.schema.SchemaPropertyBuilder.string import cc.unitmesh.agent.tool.schema.ToolCategory import cc.unitmesh.agent.tool.shell.DefaultShellExecutor +import cc.unitmesh.agent.tool.shell.LiveShellExecutor import cc.unitmesh.agent.tool.shell.ShellExecutionConfig import cc.unitmesh.agent.tool.shell.ShellExecutor +import cc.unitmesh.agent.tool.shell.ShellSessionManager import cc.unitmesh.agent.tool.shell.ShellUtils import kotlinx.serialization.Serializable @@ -33,9 +36,17 @@ data class ShellParams( val environment: Map = emptyMap(), /** - * Timeout in milliseconds (default: 30 seconds) + * Timeout in milliseconds (default: 60 seconds) + * If wait=true and command doesn't complete within timeout, returns "still running" message */ - val timeoutMs: Long = 30000L, + val timeoutMs: Long = 60000L, + + /** + * Whether to wait for the command to complete (default: true) + * - wait=true: Wait for completion up to timeoutMs, then return result or "still running" + * - wait=false: Start command in background and return sessionId immediately + */ + val wait: Boolean = true, /** * Description of what the command does (for logging/confirmation) @@ -49,7 +60,14 @@ data class ShellParams( ) object ShellSchema : DeclarativeToolSchema( - description = "Execute shell commands with various options", + description = """Execute shell commands with live output streaming. + +If wait=true (default): Waits for command to complete up to timeoutMs. + - If completed: Returns stdout, stderr, exit code + - If timeout: Returns "still running" message with sessionId for later interaction + +If wait=false: Starts command in background and returns sessionId immediately. + Use read-process, wait-process, or kill-process to interact with the session.""", properties = mapOf( "command" to string( description = "The shell command to execute", @@ -59,6 +77,18 @@ object ShellSchema : DeclarativeToolSchema( description = "Working directory for command execution (optional)", required = false ), + "wait" to boolean( + description = "Whether to wait for command completion. true=wait up to timeout, false=run in background", + required = false, + default = true + ), + "timeoutMs" to integer( + description = "Timeout in milliseconds when wait=true. After timeout, returns 'still running' with sessionId", + required = false, + default = 60000, + minimum = 1000, + maximum = 600000 + ), "environment" to objectType( description = "Environment variables to set (optional)", properties = mapOf( @@ -69,13 +99,6 @@ object ShellSchema : DeclarativeToolSchema( required = false, additionalProperties = true ), - "timeoutMs" to integer( - description = "Timeout in milliseconds", - required = false, - default = 30000, - minimum = 1000, - maximum = 300000 - ), "description" to string( description = "Description of what the command does (for logging/confirmation)", required = false @@ -88,7 +111,10 @@ object ShellSchema : DeclarativeToolSchema( ) ) { override fun getExampleUsage(toolName: String): String { - return "/$toolName command=\"ls -la\" workingDirectory=\"/tmp\" timeoutMs=10000" + return """Examples: + /$toolName command="ls -la" (wait for completion) + /$toolName command="npm run dev" wait=false (run in background, returns sessionId) + /$toolName command="./gradlew build" timeoutMs=120000 (wait up to 2 minutes)""" } } @@ -142,31 +168,161 @@ class ShellInvocation( shell = params.shell ) - val result = shellExecutor.execute(params.command, config) - val output = ShellUtils.formatShellResult(result) + // Check if we should use live execution (async mode) + val liveExecutor = shellExecutor as? LiveShellExecutor + + if (!params.wait && liveExecutor != null && liveExecutor.supportsLiveExecution()) { + // Background mode: start and return immediately with sessionId + return@safeExecute executeBackground(liveExecutor, config) + } + + if (liveExecutor != null && liveExecutor.supportsLiveExecution()) { + // Wait mode with live execution: start, wait with timeout + return@safeExecute executeWithTimeout(liveExecutor, config) + } + + // Fallback: synchronous execution + executeSynchronous(config) + } + } + + /** + * Execute in background mode - start and return sessionId immediately + */ + private suspend fun executeBackground( + liveExecutor: LiveShellExecutor, + config: ShellExecutionConfig + ): ToolResult { + val session = liveExecutor.startLiveExecution(params.command, config) + + // Register session for later interaction + ShellSessionManager.registerSession( + sessionId = session.sessionId, + command = params.command, + workingDirectory = config.workingDirectory, + processHandle = session.ptyHandle + ) + + val metadata = mapOf( + "command" to params.command, + "session_id" to session.sessionId, + "working_directory" to (config.workingDirectory ?: ""), + "mode" to "background" + ) + + return ToolResult.Pending( + sessionId = session.sessionId, + toolName = "shell", + command = params.command, + message = "Process started in background. Use read-process, wait-process, or kill-process with sessionId: ${session.sessionId}", + metadata = metadata + ) + } + + /** + * Execute with timeout - wait for completion or return "still running" + */ + private suspend fun executeWithTimeout( + liveExecutor: LiveShellExecutor, + config: ShellExecutionConfig + ): ToolResult { + val session = liveExecutor.startLiveExecution(params.command, config) + + // Register session + val managedSession = ShellSessionManager.registerSession( + sessionId = session.sessionId, + command = params.command, + workingDirectory = config.workingDirectory, + processHandle = session.ptyHandle + ) + + return try { + val exitCode = liveExecutor.waitForSession(session, config.timeoutMs) + + // Process completed - get output and clean up + val output = managedSession.getOutput() + managedSession.markCompleted(exitCode) + ShellSessionManager.removeSession(session.sessionId) val metadata = mapOf( "command" to params.command, - "exit_code" to result.exitCode.toString(), - "execution_time_ms" to result.executionTimeMs.toString(), - "working_directory" to (result.workingDirectory ?: ""), - "shell" to (shellExecutor.getDefaultShell() ?: "unknown"), - "stdout_length" to result.stdout.length.toString(), - "stderr_length" to result.stderr.length.toString(), - "success" to result.isSuccess().toString(), - "stdout" to result.stdout, - "stderr" to result.stderr + "exit_code" to exitCode.toString(), + "working_directory" to (config.workingDirectory ?: ""), + "session_id" to session.sessionId, + "mode" to "completed" ) - if (result.isSuccess()) { - ToolResult.Success(output, metadata) + if (exitCode == 0) { + ToolResult.Success(output.ifEmpty { "(no output)" }, metadata) } else { ToolResult.Error( - message = "Command failed with exit code ${result.exitCode}: ${result.stderr.ifEmpty { result.stdout }}", + message = "Command failed with exit code $exitCode:\n$output", errorType = ToolErrorType.COMMAND_FAILED.code, metadata = metadata ) } + } catch (e: ToolException) { + if (e.errorType == ToolErrorType.TIMEOUT) { + // Timeout - process still running + val output = managedSession.getOutput() + val metadata = mapOf( + "command" to params.command, + "session_id" to session.sessionId, + "working_directory" to (config.workingDirectory ?: ""), + "mode" to "timeout", + "partial_output" to output.take(1000) + ) + + ToolResult.Pending( + sessionId = session.sessionId, + toolName = "shell", + command = params.command, + message = """Process still running after ${config.timeoutMs}ms timeout. +SessionId: ${session.sessionId} + +Use these tools to interact: +- read-process sessionId="${session.sessionId}" - Read current output +- wait-process sessionId="${session.sessionId}" timeoutMs=60000 - Wait for completion +- kill-process sessionId="${session.sessionId}" - Terminate the process + +Partial output: +${output.take(500)}${if (output.length > 500) "\n...(truncated)" else ""}""", + metadata = metadata + ) + } else { + throw e + } + } + } + + /** + * Synchronous execution (fallback) + */ + private suspend fun executeSynchronous(config: ShellExecutionConfig): ToolResult { + val result = shellExecutor.execute(params.command, config) + val output = ShellUtils.formatShellResult(result) + + val metadata = mapOf( + "command" to params.command, + "exit_code" to result.exitCode.toString(), + "execution_time_ms" to result.executionTimeMs.toString(), + "working_directory" to (result.workingDirectory ?: ""), + "shell" to (shellExecutor.getDefaultShell() ?: "unknown"), + "stdout_length" to result.stdout.length.toString(), + "stderr_length" to result.stderr.length.toString(), + "success" to result.isSuccess().toString(), + "stdout" to result.stdout, + "stderr" to result.stderr + ) + + return if (result.isSuccess()) { + ToolResult.Success(output, metadata) + } else { + ToolResult.Error( + message = "Command failed with exit code ${result.exitCode}: ${result.stderr.ifEmpty { result.stdout }}", + errorType = ToolErrorType.COMMAND_FAILED.code, + metadata = metadata + ) } } } diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt new file mode 100644 index 0000000000..f058109b51 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt @@ -0,0 +1,160 @@ +package cc.unitmesh.agent.tool.shell + +import cc.unitmesh.agent.Platform +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Manages all active shell sessions. + * Provides centralized access to running processes for read, wait, and kill operations. + * + * Design inspired by Augment's launch-process/read-process/kill-process pattern. + */ +object ShellSessionManager { + + private val mutex = Mutex() + private val sessions = mutableMapOf() + + private val _activeSessions = MutableStateFlow>(emptyList()) + val activeSessions: StateFlow> = _activeSessions.asStateFlow() + + /** + * Register a new session + */ + suspend fun registerSession( + sessionId: String, + command: String, + workingDirectory: String?, + processHandle: Any?, + startTime: Long = Platform.getCurrentTimestamp() + ): ManagedSession { + val session = ManagedSession( + sessionId = sessionId, + command = command, + workingDirectory = workingDirectory, + processHandle = processHandle, + startTime = startTime + ) + + mutex.withLock { + sessions[sessionId] = session + _activeSessions.value = sessions.keys.toList() + } + + return session + } + + /** + * Get a session by ID + */ + suspend fun getSession(sessionId: String): ManagedSession? { + return mutex.withLock { sessions[sessionId] } + } + + /** + * Get all active (running) sessions + */ + suspend fun getActiveSessions(): List { + return mutex.withLock { + sessions.values.filter { it.isRunning() }.toList() + } + } + + /** + * Remove a session (called when process completes or is killed) + */ + suspend fun removeSession(sessionId: String): ManagedSession? { + return mutex.withLock { + val removed = sessions.remove(sessionId) + _activeSessions.value = sessions.keys.toList() + removed + } + } + + /** + * Clear all sessions (for cleanup) + */ + suspend fun clearAll() { + mutex.withLock { + sessions.values.forEach { it.kill() } + sessions.clear() + _activeSessions.value = emptyList() + } + } +} + +/** + * Represents a managed shell session with output buffering and state tracking. + */ +class ManagedSession( + val sessionId: String, + val command: String, + val workingDirectory: String?, + val processHandle: Any?, + val startTime: Long +) { + private val outputBuffer = StringBuilder() + private val mutex = Mutex() + + private var _exitCode: Int? = null + private var _endTime: Long? = null + + val exitCode: Int? get() = _exitCode + val endTime: Long? get() = _endTime + + /** + * Check if the process is still running + */ + fun isRunning(): Boolean { + val process = processHandle as? Process ?: return false + return process.isAlive + } + + /** + * Get current output (thread-safe) + */ + suspend fun getOutput(): String { + return mutex.withLock { outputBuffer.toString() } + } + + /** + * Append output (called by output collector) + */ + suspend fun appendOutput(text: String) { + mutex.withLock { outputBuffer.append(text) } + } + + /** + * Mark session as completed + */ + fun markCompleted(exitCode: Int, endTime: Long = Platform.getCurrentTimestamp()) { + _exitCode = exitCode + _endTime = endTime + } + + /** + * Kill the process + */ + fun kill(): Boolean { + val process = processHandle as? Process ?: return false + return try { + process.destroyForcibly() + markCompleted(-1) + true + } catch (e: Exception) { + false + } + } + + /** + * Get execution time in milliseconds + */ + fun getExecutionTimeMs(): Long { + val end = _endTime ?: Platform.getCurrentTimestamp() + return end - startTime + } +} + From c8461fd1265968d82c660a6180de303dd7b5e751 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 14:47:23 +0800 Subject: [PATCH 13/37] feat(shell): send output log to AI on process cancel Add support for capturing and sending the current output log to the AI when a live terminal process is cancelled by the user. This includes a new CancelEvent data class and updates to the cancellation flow in the UI and view model. --- .../timeline/IdeaLiveTerminalBubble.kt | 29 +++++++++++++-- .../timeline/IdeaTimelineContent.kt | 14 +++++-- .../devins/idea/toolwindow/IdeaAgentApp.kt | 6 ++- .../idea/toolwindow/IdeaAgentViewModel.kt | 37 +++++++++++++++++++ 4 files changed, 77 insertions(+), 9 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt index 5dc3e82986..9b09f43b60 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt @@ -113,6 +113,16 @@ class ProcessOutputCollector( } } +/** + * Data class for cancel event with session info and output log. + */ +data class CancelEvent( + val sessionId: String, + val command: String, + val output: String, + val process: Process +) + /** * Live terminal bubble for displaying real-time shell command output. * Uses ProcessOutputCollector for listener-like output monitoring. @@ -123,13 +133,14 @@ class ProcessOutputCollector( * - Collapsible output with header * - Status indicator (running/completed) * - Cancel button to terminate running process + * - On cancel, sends current output log to AI */ @Composable fun IdeaLiveTerminalBubble( item: TimelineItem.LiveTerminalItem, modifier: Modifier = Modifier, project: Project? = null, - onCancel: ((Process) -> Unit)? = null + onCancel: ((CancelEvent) -> Unit)? = null ) { var expanded by remember { mutableStateOf(true) } @@ -162,13 +173,23 @@ fun IdeaLiveTerminalBubble( val isRunning = if (item.exitCode != null) false else outputState.isRunning val output = outputState.output.ifEmpty { "Waiting for output..." } - // Cancel handler + // Cancel handler - sends current output log to AI before terminating val handleCancel: () -> Unit = { process?.let { p -> + val currentOutput = outputState.output + + // Create cancel event with session info and output + val cancelEvent = CancelEvent( + sessionId = item.sessionId, + command = item.command, + output = currentOutput, + process = p + ) + if (onCancel != null) { - onCancel(p) + onCancel(cancelEvent) } else { - // Default: destroy the process + // Default: just destroy the process p.destroyForcibly() } collector?.stop() diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt index 614de2bee6..29b3079f8e 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt @@ -24,7 +24,8 @@ fun IdeaTimelineContent( streamingOutput: String, listState: LazyListState, modifier: Modifier = Modifier, - project: Project? = null + project: Project? = null, + onProcessCancel: ((CancelEvent) -> Unit)? = null ) { if (timeline.isEmpty() && streamingOutput.isEmpty()) { IdeaEmptyStateMessage("Start a conversation with your AI Assistant!") @@ -36,7 +37,7 @@ fun IdeaTimelineContent( verticalArrangement = Arrangement.spacedBy(4.dp) ) { items(timeline, key = { it.id }) { item -> - IdeaTimelineItemView(item, project) + IdeaTimelineItemView(item, project, onProcessCancel) } // Show streaming output @@ -53,7 +54,11 @@ fun IdeaTimelineContent( * Dispatch timeline item to appropriate bubble component. */ @Composable -fun IdeaTimelineItemView(item: TimelineItem, project: Project? = null) { +fun IdeaTimelineItemView( + item: TimelineItem, + project: Project? = null, + onProcessCancel: ((CancelEvent) -> Unit)? = null +) { when (item) { is TimelineItem.MessageItem -> { IdeaMessageBubble( @@ -77,7 +82,8 @@ fun IdeaTimelineItemView(item: TimelineItem, project: Project? = null) { // Live terminal with real-time output streaming IdeaLiveTerminalBubble( item = item, - project = project + project = project, + onCancel = onProcessCancel ) } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt index 656fe58752..98667eab3e 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt @@ -23,6 +23,7 @@ import cc.unitmesh.devins.idea.toolwindow.remote.IdeaRemoteAgentContent import cc.unitmesh.devins.idea.toolwindow.remote.IdeaRemoteAgentViewModel import cc.unitmesh.devins.idea.toolwindow.remote.getEffectiveProjectId import cc.unitmesh.devins.idea.components.status.IdeaToolLoadingStatusBar +import cc.unitmesh.devins.idea.components.timeline.CancelEvent import cc.unitmesh.devins.idea.components.timeline.IdeaEmptyStateMessage import cc.unitmesh.devins.idea.components.timeline.IdeaTimelineContent import cc.unitmesh.devins.ui.config.ConfigManager @@ -162,7 +163,10 @@ fun IdeaAgentApp( timeline = timeline, streamingOutput = streamingOutput, listState = listState, - project = project + project = project, + onProcessCancel = { cancelEvent -> + viewModel.handleProcessCancel(cancelEvent) + } ) }, bottom = { diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt index c0b17eda14..e00ec7a761 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt @@ -405,6 +405,43 @@ class IdeaAgentViewModel( } } + /** + * Handle process cancel event from LiveTerminal. + * Terminates the process and sends the current output log to AI. + */ + fun handleProcessCancel(cancelEvent: cc.unitmesh.devins.idea.components.timeline.CancelEvent) { + // Terminate the process + cancelEvent.process.destroyForcibly() + + // Update the timeline with cancellation info + val cancelMessage = buildString { + appendLine("Process cancelled by user.") + appendLine() + appendLine("Command: ${cancelEvent.command}") + appendLine("Session ID: ${cancelEvent.sessionId}") + appendLine() + appendLine("Output before cancellation:") + appendLine("```") + append(cancelEvent.output.ifEmpty { "(no output)" }) + appendLine() + appendLine("```") + } + + // Render the cancellation result as a tool result with metadata + renderer.renderToolResult( + toolName = "shell", + success = false, + output = cancelMessage, + fullOutput = cancelEvent.output, + metadata = mapOf( + "isLiveSession" to "true", + "sessionId" to cancelEvent.sessionId, + "exit_code" to "-1", + "cancelled" to "true" + ) + ) + } + /** * Abort the current request (alias for cancelTask for backward compatibility). */ From 4e9d3a4f7bcc28571eafd2bc747ed8c38e74cee7 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 14:59:15 +0800 Subject: [PATCH 14/37] fix(shell): fix cancel button not capturing PTY output Problem: - When user clicks Cancel on LiveTerminal, the output was empty - This was because PtyShellExecutor.waitForSession was reading from the same inputStream as ProcessOutputCollector, causing conflicts Root Cause: - Two readers competing for the same PTY inputStream - ProcessOutputCollector in UI layer and waitForSession in core layer Solution: 1. Modified PtyShellExecutor.waitForSession to NOT read output - Only waits for process completion, no output reading - Avoids conflict with UI-layer output collectors 2. Enhanced ProcessOutputCollector to sync output to ShellSessionManager - Added sessionId parameter to ProcessOutputCollector - Syncs output chunks to ManagedSession.appendOutput() - Cancel event now gets actual output from collector buffer 3. Updated IdeaLiveTerminalBubble to use collector.getCurrentOutput() - More reliable than state-based output for cancel events 4. CLI renderer (CodingCli) already reads output independently - Not affected by waitForSession changes --- .../agent/tool/shell/PtyShellExecutor.kt | 31 +-- .../timeline/IdeaLiveTerminalBubble.kt | 26 ++- .../cc/unitmesh/server/cli/CodingCli.kt | 199 ++++++++++++++++++ 3 files changed, 228 insertions(+), 28 deletions(-) diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt index c18f5bcd42..0f4b115888 100644 --- a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt +++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt @@ -256,24 +256,13 @@ class PtyShellExecutor : ShellExecutor, LiveShellExecutor { if (ptyHandle !is Process) { throw ToolException("Invalid PTY handle", ToolErrorType.INTERNAL_ERROR) } - + try { - // 启动输出读取任务 - val outputJob = launch { - try { - ptyHandle.inputStream.bufferedReader().use { reader -> - var line = reader.readLine() - while (line != null && isActive) { - session.appendStdout(line) - session.appendStdout("\n") - line = reader.readLine() - } - } - } catch (e: Exception) { - logger().error(e) { "Failed to read output from PTY process: ${e.message}" } - } - } - + // Note: We do NOT read output here to avoid conflicts with UI-layer output collectors + // (e.g., ProcessOutputCollector in IdeaLiveTerminalBubble). + // The UI layer is responsible for reading and displaying output in real-time. + // For CLI usage, the renderer's awaitSessionResult should handle output reading. + val exitCode = withTimeoutOrNull(timeoutMs) { while (ptyHandle.isAlive) { yield() @@ -281,17 +270,13 @@ class PtyShellExecutor : ShellExecutor, LiveShellExecutor { } ptyHandle.exitValue() } - + if (exitCode == null) { - outputJob.cancel() ptyHandle.destroyForcibly() ptyHandle.waitFor(3000, TimeUnit.MILLISECONDS) throw ToolException("Command timed out after ${timeoutMs}ms", ToolErrorType.TIMEOUT) } - - // 等待输出读取完成 - outputJob.join() - + session.markCompleted(exitCode) exitCode } catch (e: Exception) { diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt index 9b09f43b60..87f905a4e0 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.unitmesh.agent.render.TimelineItem +import cc.unitmesh.agent.tool.shell.ShellSessionManager import cc.unitmesh.devins.idea.renderer.terminal.IdeaAnsiTerminalRenderer import cc.unitmesh.devins.ui.compose.theme.AutoDevColors import com.intellij.openapi.project.Project @@ -37,9 +38,12 @@ data class ProcessOutputState( /** * Collector that monitors a Process and emits output updates via Flow. * Uses a listener-like pattern with periodic checks. + * + * Also syncs output to ShellSessionManager for cancel event handling. */ class ProcessOutputCollector( private val process: Process, + private val sessionId: String? = null, private val checkIntervalMs: Long = 100L ) { private val _state = MutableStateFlow(ProcessOutputState()) @@ -95,13 +99,19 @@ class ProcessOutputCollector( val bytesRead = reader.read(charBuffer) if (bytesRead == -1) break + val chunk = String(charBuffer, 0, bytesRead) synchronized(buffer) { if (isError) buffer.append("\u001B[31m") - buffer.append(charBuffer, 0, bytesRead) + buffer.append(chunk) if (isError) buffer.append("\u001B[0m") } _state.update { it.copy(output = buffer.toString()) } + + // Sync to ShellSessionManager for cancel event handling + sessionId?.let { sid -> + ShellSessionManager.getSession(sid)?.appendOutput(chunk) + } } } catch (e: Exception) { // Stream closed @@ -111,6 +121,11 @@ class ProcessOutputCollector( fun stop() { job?.cancel() } + + /** + * Get current output buffer content + */ + fun getCurrentOutput(): String = synchronized(buffer) { buffer.toString() } } /** @@ -146,9 +161,9 @@ fun IdeaLiveTerminalBubble( val process = remember(item.ptyHandle) { item.ptyHandle as? Process } - // Create collector and collect state - val collector = remember(process) { - process?.let { ProcessOutputCollector(it) } + // Create collector and collect state - pass sessionId for sync to ShellSessionManager + val collector = remember(process, item.sessionId) { + process?.let { ProcessOutputCollector(it, sessionId = item.sessionId) } } val outputState by collector?.state?.collectAsState() @@ -176,7 +191,8 @@ fun IdeaLiveTerminalBubble( // Cancel handler - sends current output log to AI before terminating val handleCancel: () -> Unit = { process?.let { p -> - val currentOutput = outputState.output + // Get current output directly from collector's buffer (more reliable than state) + val currentOutput = collector?.getCurrentOutput() ?: outputState.output // Create cancel event with session info and output val cancelEvent = CancelEvent( diff --git a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/CodingCli.kt b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/CodingCli.kt index 3d86f3be9a..023c894217 100644 --- a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/CodingCli.kt +++ b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/CodingCli.kt @@ -160,6 +160,16 @@ object CodingCli { * Console renderer for CodingCli output */ class CodingCliRenderer : CodingAgentRenderer { + // Track active sessions for awaitSessionResult + private val activeSessions = mutableMapOf() + + data class SessionInfo( + val sessionId: String, + val command: String, + val process: Process?, + val startTime: Long + ) + override fun renderIterationHeader(current: Int, max: Int) { println("\n━━━ Iteration $current/$max ━━━") } @@ -187,6 +197,195 @@ class CodingCliRenderer : CodingAgentRenderer { } } + override fun addLiveTerminal( + sessionId: String, + command: String, + workingDirectory: String?, + ptyHandle: Any? + ) { + val process = ptyHandle as? Process + activeSessions[sessionId] = SessionInfo( + sessionId = sessionId, + command = command, + process = process, + startTime = System.currentTimeMillis() + ) + println(" ⏳ Running: $command") + } + + override fun updateLiveTerminalStatus( + sessionId: String, + exitCode: Int, + executionTimeMs: Long, + output: String? + ) { + activeSessions.remove(sessionId) + val statusSymbol = if (exitCode == 0) "✓" else "✗" + val preview = (output ?: "").lines().take(3).joinToString(" ").take(100) + println(" $statusSymbol Exit code: $exitCode (${executionTimeMs}ms)") + if (preview.isNotEmpty()) { + println(" $preview${if (preview.length < (output ?: "").length) "..." else ""}") + } + } + + override suspend fun awaitSessionResult(sessionId: String, timeoutMs: Long): cc.unitmesh.agent.tool.ToolResult { + val session = activeSessions[sessionId] + if (session == null) { + // Session not found - check ShellSessionManager + val managedSession = cc.unitmesh.agent.tool.shell.ShellSessionManager.getSession(sessionId) + if (managedSession != null) { + return awaitManagedSession(managedSession, timeoutMs) + } + return cc.unitmesh.agent.tool.ToolResult.Error("Session not found: $sessionId") + } + + val process = session.process + if (process == null) { + return cc.unitmesh.agent.tool.ToolResult.Error("No process handle for session: $sessionId") + } + + return awaitProcess(process, session, timeoutMs) + } + + private suspend fun awaitManagedSession( + session: cc.unitmesh.agent.tool.shell.ManagedSession, + timeoutMs: Long + ): cc.unitmesh.agent.tool.ToolResult { + val process = session.processHandle as? Process + if (process == null) { + return cc.unitmesh.agent.tool.ToolResult.Error("No process handle for session: ${session.sessionId}") + } + + val startWait = System.currentTimeMillis() + val checkIntervalMs = 100L + + while (process.isAlive) { + val elapsed = System.currentTimeMillis() - startWait + if (elapsed >= timeoutMs) { + // Timeout - process still running + val output = session.getOutput() + return cc.unitmesh.agent.tool.ToolResult.Pending( + sessionId = session.sessionId, + toolName = "shell", + command = session.command, + message = "Process still running after ${elapsed}ms", + metadata = mapOf( + "partial_output" to output.take(1000), + "elapsed_ms" to elapsed.toString() + ) + ) + } + kotlinx.coroutines.delay(checkIntervalMs) + } + + // Process completed + val exitCode = process.exitValue() + val output = session.getOutput() + session.markCompleted(exitCode) + + return if (exitCode == 0) { + cc.unitmesh.agent.tool.ToolResult.Success( + content = output.ifEmpty { "(no output)" }, + metadata = mapOf( + "exit_code" to exitCode.toString(), + "session_id" to session.sessionId + ) + ) + } else { + cc.unitmesh.agent.tool.ToolResult.Error( + message = "Command failed with exit code $exitCode:\n$output", + errorType = cc.unitmesh.agent.tool.ToolErrorType.COMMAND_FAILED.code, + metadata = mapOf( + "exit_code" to exitCode.toString(), + "session_id" to session.sessionId + ) + ) + } + } + + private suspend fun awaitProcess( + process: Process, + session: SessionInfo, + timeoutMs: Long + ): cc.unitmesh.agent.tool.ToolResult { + val startWait = System.currentTimeMillis() + val checkIntervalMs = 100L + val outputBuilder = StringBuilder() + + // Read output in background + val stdoutReader = process.inputStream.bufferedReader() + val stderrReader = process.errorStream.bufferedReader() + + while (process.isAlive) { + // Read available output + while (stdoutReader.ready()) { + val line = stdoutReader.readLine() ?: break + outputBuilder.appendLine(line) + println(" │ $line") + } + while (stderrReader.ready()) { + val line = stderrReader.readLine() ?: break + outputBuilder.appendLine("[stderr] $line") + println(" │ [stderr] $line") + } + + val elapsed = System.currentTimeMillis() - startWait + if (elapsed >= timeoutMs) { + // Timeout - process still running + return cc.unitmesh.agent.tool.ToolResult.Pending( + sessionId = session.sessionId, + toolName = "shell", + command = session.command, + message = "Process still running after ${elapsed}ms", + metadata = mapOf( + "partial_output" to outputBuilder.toString().take(1000), + "elapsed_ms" to elapsed.toString() + ) + ) + } + kotlinx.coroutines.delay(checkIntervalMs) + } + + // Read remaining output + stdoutReader.forEachLine { line -> + outputBuilder.appendLine(line) + println(" │ $line") + } + stderrReader.forEachLine { line -> + outputBuilder.appendLine("[stderr] $line") + println(" │ [stderr] $line") + } + + // Process completed + val exitCode = process.exitValue() + val output = outputBuilder.toString() + activeSessions.remove(session.sessionId) + + val executionTimeMs = System.currentTimeMillis() - session.startTime + println(" ${if (exitCode == 0) "✓" else "✗"} Exit code: $exitCode (${executionTimeMs}ms)") + + return if (exitCode == 0) { + cc.unitmesh.agent.tool.ToolResult.Success( + content = output.ifEmpty { "(no output)" }, + metadata = mapOf( + "exit_code" to exitCode.toString(), + "session_id" to session.sessionId, + "execution_time_ms" to executionTimeMs.toString() + ) + ) + } else { + cc.unitmesh.agent.tool.ToolResult.Error( + message = "Command failed with exit code $exitCode:\n$output", + errorType = cc.unitmesh.agent.tool.ToolErrorType.COMMAND_FAILED.code, + metadata = mapOf( + "exit_code" to exitCode.toString(), + "session_id" to session.sessionId, + "execution_time_ms" to executionTimeMs.toString() + ) + ) + } + } + private fun formatCliParameters(params: String): String { val trimmed = params.trim() From 0616d6f475a71ee93264970441fb444d14d3854a Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 15:09:31 +0800 Subject: [PATCH 15/37] fix(shell): make ShellSessionManager cross-platform compatible Problem: - ShellSessionManager.kt in commonMain used JVM-specific Process class - This caused WASM build to fail with 'Unresolved reference: Process' Solution: 1. Changed LiveShellSession from data class to regular class 2. Added isAliveChecker and killHandler callback parameters to LiveShellSession 3. Updated ManagedSession to use callback-based process handlers 4. PtyShellExecutor now passes platform-specific handlers when creating session 5. ShellTool sets handlers on ManagedSession after registration This allows the shell session management to work across all platforms (JVM, JS, WASM) while still supporting platform-specific process operations. --- .../cc/unitmesh/agent/tool/impl/ShellTool.kt | 12 +++++- .../agent/tool/shell/LiveShellSession.kt | 43 ++++++++++++++----- .../agent/tool/shell/ShellSessionManager.kt | 33 +++++++++----- .../agent/tool/shell/PtyShellExecutor.kt | 6 ++- 4 files changed, 71 insertions(+), 23 deletions(-) diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/ShellTool.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/ShellTool.kt index 572f37d5ea..0af8ffdf42 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/ShellTool.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/impl/ShellTool.kt @@ -196,12 +196,17 @@ class ShellInvocation( val session = liveExecutor.startLiveExecution(params.command, config) // Register session for later interaction - ShellSessionManager.registerSession( + val managedSession = ShellSessionManager.registerSession( sessionId = session.sessionId, command = params.command, workingDirectory = config.workingDirectory, processHandle = session.ptyHandle ) + // Set process handlers from LiveShellSession + managedSession.setProcessHandlers( + isAlive = { session.isAlive() }, + kill = { session.kill() } + ) val metadata = mapOf( "command" to params.command, @@ -235,6 +240,11 @@ class ShellInvocation( workingDirectory = config.workingDirectory, processHandle = session.ptyHandle ) + // Set process handlers from LiveShellSession + managedSession.setProcessHandlers( + isAlive = { session.isAlive() }, + kill = { session.kill() } + ) return try { val exitCode = liveExecutor.waitForSession(session, config.timeoutMs) diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/LiveShellSession.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/LiveShellSession.kt index cedba6d611..5462fc0047 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/LiveShellSession.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/LiveShellSession.kt @@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow * Represents a live shell session that can stream output in real-time. * This is used on platforms that support PTY (pseudo-terminal) for rich terminal emulation. */ -data class LiveShellSession( +class LiveShellSession( val sessionId: String, val command: String, val workingDirectory: String?, @@ -19,46 +19,69 @@ data class LiveShellSession( * On other platforms: null (falls back to buffered output) */ val ptyHandle: Any? = null, - val isLiveSupported: Boolean = ptyHandle != null + val isLiveSupported: Boolean = ptyHandle != null, + /** + * Platform-specific callback to check if process is alive + */ + private val isAliveChecker: (() -> Boolean)? = null, + /** + * Platform-specific callback to kill the process + */ + private val killHandler: (() -> Unit)? = null ) { private val _isCompleted = MutableStateFlow(false) val isCompleted: StateFlow = _isCompleted.asStateFlow() - + private val _exitCode = MutableStateFlow(null) val exitCode: StateFlow = _exitCode.asStateFlow() - + private val _stdout = StringBuilder() private val _stderr = StringBuilder() - + /** * Get the captured stdout output */ fun getStdout(): String = _stdout.toString() - + /** * Get the captured stderr output */ fun getStderr(): String = _stderr.toString() - + /** * Append output to stdout (called by executor) */ internal fun appendStdout(text: String) { _stdout.append(text) } - + /** * Append output to stderr (called by executor) */ internal fun appendStderr(text: String) { _stderr.append(text) } - + fun markCompleted(exitCode: Int) { _exitCode.value = exitCode _isCompleted.value = true } - + + /** + * Check if the process is still alive + */ + fun isAlive(): Boolean { + if (_isCompleted.value) return false + return isAliveChecker?.invoke() ?: false + } + + /** + * Kill the process + */ + fun kill() { + killHandler?.invoke() + } + /** * Wait for the session to complete (expected to be overridden or handled platform-specifically) * Returns the exit code, or throws if timeout/error occurs diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt index f058109b51..66b32ae770 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt @@ -104,29 +104,43 @@ class ManagedSession( val exitCode: Int? get() = _exitCode val endTime: Long? get() = _endTime - + + // Callbacks for platform-specific process operations + private var isAliveChecker: (() -> Boolean)? = null + private var killHandler: (() -> Unit)? = null + + /** + * Set platform-specific process handlers + */ + fun setProcessHandlers(isAlive: () -> Boolean, kill: () -> Unit) { + isAliveChecker = isAlive + killHandler = kill + } + /** * Check if the process is still running */ fun isRunning(): Boolean { - val process = processHandle as? Process ?: return false - return process.isAlive + // If we have an exit code, the process has completed + if (_exitCode != null) return false + // Use the platform-specific checker if available + return isAliveChecker?.invoke() ?: false } - + /** * Get current output (thread-safe) */ suspend fun getOutput(): String { return mutex.withLock { outputBuffer.toString() } } - + /** * Append output (called by output collector) */ suspend fun appendOutput(text: String) { mutex.withLock { outputBuffer.append(text) } } - + /** * Mark session as completed */ @@ -134,21 +148,20 @@ class ManagedSession( _exitCode = exitCode _endTime = endTime } - + /** * Kill the process */ fun kill(): Boolean { - val process = processHandle as? Process ?: return false return try { - process.destroyForcibly() + killHandler?.invoke() markCompleted(-1) true } catch (e: Exception) { false } } - + /** * Get execution time in milliseconds */ diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt index 0f4b115888..7d0eaee474 100644 --- a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt +++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt @@ -238,13 +238,15 @@ class PtyShellExecutor : ShellExecutor, LiveShellExecutor { } val ptyProcess = ptyProcessBuilder.start() - + LiveShellSession( sessionId = sessionId, command = command, workingDirectory = config.workingDirectory, ptyHandle = ptyProcess, - isLiveSupported = true + isLiveSupported = true, + isAliveChecker = { ptyProcess.isAlive }, + killHandler = { ptyProcess.destroyForcibly() } ) } From 19f0892a912fdc895a81132e2a54680871992db5 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 15:17:48 +0800 Subject: [PATCH 16/37] fix(shell): get output from ShellSessionManager in startSessionMonitoring Problem: - When shell process completes, ToolOrchestrator.startSessionMonitoring was getting output from LiveShellSession.getStdout() which was empty - This is because PtyShellExecutor.waitForSession no longer reads output (to avoid conflict with UI's ProcessOutputCollector) Solution: - Get output from ShellSessionManager.getSession().getOutput() first - This output is synced by UI's ProcessOutputCollector in real-time - Fall back to LiveShellSession.getStdout() if not available Now when a shell command completes (or is cancelled), the output collected by the UI will be properly sent to the AI. --- .../cc/unitmesh/agent/orchestrator/ToolOrchestrator.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 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 90afb89b60..f9dec6a878 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 @@ -276,8 +276,10 @@ class ToolOrchestrator( logger.debug { "Session ${session.sessionId} completed with exit code: $exitCode" } - // Get output from session - val output = session.getStdout() + // Get output from ShellSessionManager (synced by UI's ProcessOutputCollector) + // or fall back to LiveShellSession's stdout buffer + val managedSession = cc.unitmesh.agent.tool.shell.ShellSessionManager.getSession(session.sessionId) + val output = managedSession?.getOutput()?.ifEmpty { null } ?: session.getStdout() // Update renderer with final status renderer.updateLiveTerminalStatus( From dfde3c44affb47c80155afb7e9a701d30f559cf7 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 15:27:54 +0800 Subject: [PATCH 17/37] fix(shell): configure PTY process to preserve output after termination Problem: - PTY process was not configured properly, causing output stream to close prematurely or not be readable by ProcessOutputCollector - Missing critical PTY configuration options Solution: - Add setInitialColumns(240) and setInitialRows(80) for proper terminal size - Add setUnixOpenTtyToPreserveOutputAfterTermination(true) to keep output stream open after process completes - This matches the configuration used in ProcessExecutor.createInteractiveProcess Now the ProcessOutputCollector should be able to read output from PTY process. --- .../cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt index 7d0eaee474..83d47eae67 100644 --- a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt +++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt @@ -232,11 +232,14 @@ class PtyShellExecutor : ShellExecutor, LiveShellExecutor { .setEnvironment(environment) .setConsole(false) .setCygwin(false) - + .setInitialColumns(240) + .setInitialRows(80) + .setUnixOpenTtyToPreserveOutputAfterTermination(true) + config.workingDirectory?.let { workDir -> ptyProcessBuilder.setDirectory(workDir) } - + val ptyProcess = ptyProcessBuilder.start() LiveShellSession( From 8e8d37588642a7f0848b54fd1ac3f8060f0027f0 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 15:32:42 +0800 Subject: [PATCH 18/37] fix(shell): fix PTY output collection - only read inputStream Problem: - ProcessOutputCollector was trying to read both inputStream and errorStream - PTY processes combine stdout and stderr into a single stream (inputStream) - Reading both streams simultaneously caused race conditions and missing output - errorStream for PTY is empty/EOF, causing stderrJob to exit immediately Solution: - Remove separate stderr reader - only read from process.inputStream - PTY combines all output into inputStream, matching PtyShellExecutor behavior - Remove isError parameter from readStream() since PTY doesn't separate streams This matches the pattern used in PtyShellExecutor.executeWithPty() which only reads inputStream and sets stderr to empty string. --- .../components/timeline/IdeaLiveTerminalBubble.kt | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt index 87f905a4e0..ea34e82c4e 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt @@ -59,9 +59,9 @@ class ProcessOutputCollector( fun start(scope: CoroutineScope) { job = scope.launch(Dispatchers.IO) { try { - // Start readers in separate coroutines - val stdoutJob = launch { readStream(process.inputStream, isError = false) } - val stderrJob = launch { readStream(process.errorStream, isError = true) } + // PTY processes combine stdout and stderr into a single stream (inputStream) + // Reading both streams simultaneously would cause issues + val outputJob = launch { readStream(process.inputStream) } // Periodic check for process completion while (isActive && process.isAlive) { @@ -70,8 +70,7 @@ class ProcessOutputCollector( // Process ended - wait a bit for streams to flush delay(50) - stdoutJob.cancel() - stderrJob.cancel() + outputJob.cancel() // Update final state _state.update { it.copy( @@ -91,7 +90,7 @@ class ProcessOutputCollector( } } - private suspend fun readStream(stream: java.io.InputStream, isError: Boolean) { + private suspend fun readStream(stream: java.io.InputStream) { try { val reader = stream.bufferedReader() val charBuffer = CharArray(1024) @@ -101,9 +100,7 @@ class ProcessOutputCollector( val chunk = String(charBuffer, 0, bytesRead) synchronized(buffer) { - if (isError) buffer.append("\u001B[31m") buffer.append(chunk) - if (isError) buffer.append("\u001B[0m") } _state.update { it.copy(output = buffer.toString()) } From c16c7835e12d77b507dff7f996c26c217412a9e8 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 17:17:11 +0800 Subject: [PATCH 19/37] feat(terminal): add user cancellation tracking and ANSI stripping for terminal output - Add cancelledByUser flag to ManagedSession for tracking user-initiated cancellations - Add markSessionCancelledByUser() method to ShellSessionManager for non-suspend context - Register sessions to ShellSessionManager in ToolOrchestrator for cancel event handling - Sync output to ManagedSession in PtyShellExecutor.waitForSession() - Add AnsiStripper utility to clean ANSI escape sequences from terminal output - Strip ANSI codes before sending output to AI for cleaner, readable text - Update CodingAgentRenderer interface with cancelledByUser parameter - Update all renderer implementations (JewelRenderer, ComposeRenderer, CodingCliRenderer) - Include captured output in error messages when commands timeout or fail --- .../agent/orchestrator/ToolOrchestrator.kt | 67 +++++++- .../agent/render/CodingAgentRenderer.kt | 4 +- .../unitmesh/agent/tool/shell/AnsiStripper.kt | 118 ++++++++++++++ .../agent/tool/shell/ShellSessionManager.kt | 22 ++- .../agent/tool/shell/PtyShellExecutor.kt | 36 ++++- .../devins/idea/renderer/JewelRenderer.kt | 61 +++++-- .../idea/toolwindow/IdeaAgentViewModel.kt | 4 + .../ui/compose/agent/ComposeRenderer.kt | 14 +- .../cc/unitmesh/server/cli/CodingCli.kt | 150 +++++++++++++----- 9 files changed, 405 insertions(+), 71 deletions(-) create mode 100644 mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/AnsiStripper.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 f9dec6a878..f2dbe2d989 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 @@ -37,7 +37,7 @@ class ToolOrchestrator( private val mcpConfigService: McpToolConfigService? = null, private val asyncShellExecution: Boolean = true ) { - private val logger = getLogger("ToolOrchestrator") + private val logger = getLogger("cc.unitmesh.agent.orchestrator.ToolOrchestrator") // Coroutine scope for background tasks (async shell monitoring) private val backgroundScope = CoroutineScope(Dispatchers.Default) @@ -113,7 +113,21 @@ class ToolOrchestrator( // 启动 PTY 会话 liveSession = shellExecutor.startLiveExecution(command, shellConfig) logger.debug { "Live session started: ${liveSession.sessionId}" } - + + // Register session to ShellSessionManager for cancel event handling + val managedSession = cc.unitmesh.agent.tool.shell.ShellSessionManager.registerSession( + sessionId = liveSession.sessionId, + command = liveSession.command, + workingDirectory = liveSession.workingDirectory, + processHandle = liveSession.ptyHandle + ) + // Set process handlers from LiveShellSession + managedSession.setProcessHandlers( + isAlive = { liveSession.isAlive() }, + kill = { liveSession.kill() } + ) + logger.debug { "Session registered to ShellSessionManager: ${liveSession.sessionId}" } + // 立即通知 renderer 添加 LiveTerminal(在执行之前!) logger.debug { "Adding LiveTerminal to renderer" } renderer.addLiveTerminal( @@ -279,26 +293,65 @@ class ToolOrchestrator( // Get output from ShellSessionManager (synced by UI's ProcessOutputCollector) // or fall back to LiveShellSession's stdout buffer val managedSession = cc.unitmesh.agent.tool.shell.ShellSessionManager.getSession(session.sessionId) - val output = managedSession?.getOutput()?.ifEmpty { null } ?: session.getStdout() + val rawOutput = managedSession?.getOutput()?.ifEmpty { null } ?: session.getStdout() + + // Strip ANSI escape sequences for clean output to AI + val output = cc.unitmesh.agent.tool.shell.AnsiStripper.stripAndNormalize(rawOutput) - // Update renderer with final status + // Check if this was a user cancellation + val wasCancelledByUser = managedSession?.cancelledByUser == true + + // Update renderer with final status (including cancellation info) renderer.updateLiveTerminalStatus( sessionId = session.sessionId, exitCode = exitCode, executionTimeMs = executionTimeMs, - output = output + output = output, + cancelledByUser = wasCancelledByUser ) logger.debug { "Updated renderer with session completion: ${session.sessionId}" } } catch (e: Exception) { logger.error(e) { "Error monitoring session ${session.sessionId}: ${e.message}" } + // Check if this was a user cancellation and get output from managedSession + val managedSession = cc.unitmesh.agent.tool.shell.ShellSessionManager.getSession(session.sessionId) + val wasCancelledByUser = managedSession?.cancelledByUser == true + + logger.debug { "managedSession for ${session.sessionId}: ${managedSession != null}, cancelledByUser: $wasCancelledByUser" } + + // Get output from managedSession (which was synced during waitForSession) + // or fall back to LiveShellSession's stdout buffer + val managedOutput = managedSession?.getOutput() + val sessionOutput = session.getStdout() + val rawOutput = managedOutput?.ifEmpty { null } ?: sessionOutput + + // Strip ANSI escape sequences for clean output to AI + val capturedOutput = rawOutput?.let { + cc.unitmesh.agent.tool.shell.AnsiStripper.stripAndNormalize(it) + } + + logger.debug { "Output sources - managedOutput length: ${managedOutput?.length ?: 0}, sessionOutput length: ${sessionOutput.length}, capturedOutput length: ${capturedOutput?.length ?: 0}" } + + // Build error message with captured output + val errorOutput = buildString { + appendLine("Error: ${e.message}") + if (!capturedOutput.isNullOrEmpty()) { + appendLine() + appendLine("Output before error:") + appendLine(capturedOutput) + } + } + + logger.debug { "Final errorOutput length: ${errorOutput.length}" } + // Update renderer with error status renderer.updateLiveTerminalStatus( sessionId = session.sessionId, exitCode = -1, executionTimeMs = Clock.System.now().toEpochMilliseconds() - startTime, - output = "Error: ${e.message}" + output = errorOutput, + cancelledByUser = wasCancelledByUser ) } } @@ -317,7 +370,7 @@ class ToolOrchestrator( // Use new ExecutableTool architecture for most tools // Only special-case tools that need custom handling (shell with PTY, etc.) - return when (val toolType = toolName.toToolType()) { + return when (toolName.toToolType()) { ToolType.Shell -> executeShellTool(tool, params, basicContext) ToolType.ReadFile -> executeReadFileTool(tool, params, basicContext) ToolType.WriteFile -> executeWriteFileTool(tool, params, basicContext) diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt index bc495e9c43..570595a1af 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt @@ -50,12 +50,14 @@ interface CodingAgentRenderer { * @param exitCode The exit code of the command (0 = success) * @param executionTimeMs The total execution time in milliseconds * @param output The captured output (optional, may be null if output is streamed via PTY) + * @param cancelledByUser Whether the command was cancelled by the user (exit code 137) */ fun updateLiveTerminalStatus( sessionId: String, exitCode: Int, executionTimeMs: Long, - output: String? = null + output: String? = null, + cancelledByUser: Boolean = false ) { // Default: no-op for renderers that don't support live terminals } diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/AnsiStripper.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/AnsiStripper.kt new file mode 100644 index 0000000000..5c8ded8dcc --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/AnsiStripper.kt @@ -0,0 +1,118 @@ +package cc.unitmesh.agent.tool.shell + +/** + * Utility object for stripping ANSI escape sequences from terminal output. + * This converts raw terminal output with color codes, cursor movements, etc. + * into clean, readable ASCII text. + */ +object AnsiStripper { + private const val ESC = '\u001B' + + /** + * Strip all ANSI escape sequences from the given text. + * Handles: + * - CSI sequences (ESC[...X) - colors, cursor movement, erase + * - OSC sequences (ESC]...BEL/ST) - window title, etc. + * - Simple escape sequences (ESC X) + * + * @param text The text containing ANSI escape sequences + * @return Clean text with all escape sequences removed + */ + fun strip(text: String): String { + if (!text.contains(ESC)) { + return text + } + + val result = StringBuilder() + var i = 0 + + while (i < text.length) { + val ch = text[i] + + when { + ch == ESC && i + 1 < text.length -> { + val next = text[i + 1] + when (next) { + '[' -> { + // CSI sequence: ESC[...X (ends with a letter) + i = skipCsiSequence(text, i + 2) + } + ']' -> { + // OSC sequence: ESC]...BEL or ESC]...ST + i = skipOscSequence(text, i + 2) + } + '(' , ')' -> { + // Character set selection: ESC(X or ESC)X + i = if (i + 2 < text.length) i + 3 else text.length + } + else -> { + // Simple escape sequence: ESC X + i += 2 + } + } + } + ch == '\r' -> { + // Carriage return - skip it (will be handled with newlines) + i++ + } + else -> { + result.append(ch) + i++ + } + } + } + + return result.toString() + } + + /** + * Skip a CSI sequence starting at the given position. + * CSI sequences end with a letter (0x40-0x7E). + */ + private fun skipCsiSequence(text: String, start: Int): Int { + var i = start + while (i < text.length) { + val ch = text[i] + if (ch in '@'..'~') { + // Found the terminating character + return i + 1 + } + i++ + } + return text.length + } + + /** + * Skip an OSC sequence starting at the given position. + * OSC sequences end with BEL (0x07) or ST (ESC\). + */ + private fun skipOscSequence(text: String, start: Int): Int { + var i = start + while (i < text.length) { + val ch = text[i] + when { + ch == '\u0007' -> { + // BEL character terminates OSC + return i + 1 + } + ch == ESC && i + 1 < text.length && text[i + 1] == '\\' -> { + // ST (String Terminator) terminates OSC + return i + 2 + } + } + i++ + } + return text.length + } + + /** + * Strip ANSI sequences and also normalize line endings. + * Converts \r\n to \n and removes standalone \r. + */ + fun stripAndNormalize(text: String): String { + return strip(text) + .replace("\r\n", "\n") + .replace("\r", "") + } +} + diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt index 66b32ae770..e217a58846 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt @@ -53,7 +53,17 @@ object ShellSessionManager { suspend fun getSession(sessionId: String): ManagedSession? { return mutex.withLock { sessions[sessionId] } } - + + /** + * Mark a session as cancelled by user. + * This is a non-suspend function for use in UI callbacks where coroutine context may not be available. + * Note: This accesses the sessions map without locking, which is safe for this specific use case + * because we're only setting a boolean flag on an existing session. + */ + fun markSessionCancelledByUser(sessionId: String) { + sessions[sessionId]?.cancelledByUser = true + } + /** * Get all active (running) sessions */ @@ -98,13 +108,19 @@ class ManagedSession( ) { private val outputBuffer = StringBuilder() private val mutex = Mutex() - + private var _exitCode: Int? = null private var _endTime: Long? = null - + val exitCode: Int? get() = _exitCode val endTime: Long? get() = _endTime + /** + * Flag to indicate if the session was cancelled by user. + * This helps distinguish between user cancellation (exit code 137) and other failures. + */ + var cancelledByUser: Boolean = false + // Callbacks for platform-specific process operations private var isAliveChecker: (() -> Boolean)? = null private var killHandler: (() -> Unit)? = null diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt index 83d47eae67..9efcec56a1 100644 --- a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt +++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt @@ -263,10 +263,31 @@ class PtyShellExecutor : ShellExecutor, LiveShellExecutor { } try { - // Note: We do NOT read output here to avoid conflicts with UI-layer output collectors - // (e.g., ProcessOutputCollector in IdeaLiveTerminalBubble). - // The UI layer is responsible for reading and displaying output in real-time. - // For CLI usage, the renderer's awaitSessionResult should handle output reading. + // Get managed session to sync output + val managedSession = ShellSessionManager.getSession(session.sessionId) + + // Start output reading job to sync to ManagedSession + // This ensures output is available for ToolOrchestrator.startSessionMonitoring() + // Note: For IDEA, ProcessOutputCollector in IdeaLiveTerminalBubble also reads output, + // but that's OK because they read from different streams or the same stream is already consumed. + val outputJob = launch { + try { + ptyHandle.inputStream.bufferedReader().use { reader -> + var line = reader.readLine() + while (line != null && isActive) { + val lineWithNewline = line + "\n" + // Sync to LiveShellSession (for backward compatibility) + session.appendStdout(lineWithNewline) + // Sync to ManagedSession (for ToolOrchestrator) + managedSession?.appendOutput(lineWithNewline) + line = reader.readLine() + } + } + } catch (e: Exception) { + // Stream closed or other error - this is expected when process terminates + logger().debug { "Output reading stopped: ${e.message}" } + } + } val exitCode = withTimeoutOrNull(timeoutMs) { while (ptyHandle.isAlive) { @@ -277,12 +298,19 @@ class PtyShellExecutor : ShellExecutor, LiveShellExecutor { } if (exitCode == null) { + outputJob.cancel() ptyHandle.destroyForcibly() ptyHandle.waitFor(3000, TimeUnit.MILLISECONDS) throw ToolException("Command timed out after ${timeoutMs}ms", ToolErrorType.TIMEOUT) } + // Wait for output reading to complete (with a short timeout) + withTimeoutOrNull(1000) { + outputJob.join() + } + session.markCompleted(exitCode) + managedSession?.markCompleted(exitCode) exitCode } catch (e: Exception) { logger().error(e) { "Error waiting for PTY process: ${e.message}" } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt index 272b26f47a..ddfcff6289 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt @@ -452,7 +452,8 @@ class JewelRenderer : BaseRenderer() { sessionId: String, exitCode: Int, executionTimeMs: Long, - output: String? + output: String?, + cancelledByUser: Boolean ) { // Find and update the LiveTerminalItem in the timeline _timeline.update { currentTimeline -> @@ -467,23 +468,51 @@ class JewelRenderer : BaseRenderer() { // Also notify any waiting coroutines via the session result channel sessionResultChannels[sessionId]?.let { channel -> - val result = if (exitCode == 0) { - cc.unitmesh.agent.tool.ToolResult.Success( - content = output ?: "", - metadata = mapOf( - "exit_code" to exitCode.toString(), - "execution_time_ms" to executionTimeMs.toString() + val result = when { + exitCode == 0 -> { + cc.unitmesh.agent.tool.ToolResult.Success( + content = output ?: "", + metadata = mapOf( + "exit_code" to exitCode.toString(), + "execution_time_ms" to executionTimeMs.toString() + ) ) - ) - } else { - cc.unitmesh.agent.tool.ToolResult.Error( - message = "Command failed with exit code: $exitCode", - metadata = mapOf( - "exit_code" to exitCode.toString(), - "execution_time_ms" to executionTimeMs.toString(), - "output" to (output ?: "") + } + cancelledByUser -> { + // User cancelled - include output in the message + val errorMessage = buildString { + appendLine("⚠️ Command cancelled by user") + appendLine() + appendLine("Exit code: $exitCode (SIGKILL)") + appendLine() + if (!output.isNullOrEmpty()) { + appendLine("Output before cancellation:") + appendLine(output) + } else { + appendLine("(no output captured before cancellation)") + } + } + cc.unitmesh.agent.tool.ToolResult.Error( + message = errorMessage, + errorType = "CANCELLED_BY_USER", + metadata = mapOf( + "exit_code" to exitCode.toString(), + "execution_time_ms" to executionTimeMs.toString(), + "output" to (output ?: ""), + "cancelled" to "true" + ) ) - ) + } + else -> { + cc.unitmesh.agent.tool.ToolResult.Error( + message = "Command failed with exit code: $exitCode\n${output ?: ""}", + metadata = mapOf( + "exit_code" to exitCode.toString(), + "execution_time_ms" to executionTimeMs.toString(), + "output" to (output ?: "") + ) + ) + } } channel.trySend(result) sessionResultChannels.remove(sessionId) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt index e00ec7a761..af188e4f9b 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt @@ -410,6 +410,10 @@ class IdeaAgentViewModel( * Terminates the process and sends the current output log to AI. */ fun handleProcessCancel(cancelEvent: cc.unitmesh.devins.idea.components.timeline.CancelEvent) { + // Mark the session as cancelled by user BEFORE terminating the process + // This allows ToolOrchestrator.startSessionMonitoring() to detect user cancellation + cc.unitmesh.agent.tool.shell.ShellSessionManager.markSessionCancelledByUser(cancelEvent.sessionId) + // Terminate the process cancelEvent.process.destroyForcibly() 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 dda0be42fc..e137880b69 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 @@ -451,7 +451,8 @@ class ComposeRenderer : BaseRenderer() { sessionId: String, exitCode: Int, executionTimeMs: Long, - output: String? + output: String?, + cancelledByUser: Boolean ) { // Find and update the LiveTerminalItem in the timeline val index = _timeline.indexOfFirst { @@ -478,12 +479,19 @@ class ComposeRenderer : BaseRenderer() { ) ) } else { + // Distinguish between user cancellation and other failures + val errorMessage = if (cancelledByUser) { + "Command cancelled by user" + } else { + "Command failed with exit code: $exitCode" + } cc.unitmesh.agent.tool.ToolResult.Error( - message = "Command failed with exit code: $exitCode", + message = errorMessage, metadata = mapOf( "exit_code" to exitCode.toString(), "execution_time_ms" to executionTimeMs.toString(), - "output" to (output ?: "") + "output" to (output ?: ""), + "cancelled" to cancelledByUser.toString() ) ) } diff --git a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/CodingCli.kt b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/CodingCli.kt index 023c894217..9e846a2dc5 100644 --- a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/CodingCli.kt +++ b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/CodingCli.kt @@ -217,12 +217,22 @@ class CodingCliRenderer : CodingAgentRenderer { sessionId: String, exitCode: Int, executionTimeMs: Long, - output: String? + output: String?, + cancelledByUser: Boolean ) { activeSessions.remove(sessionId) - val statusSymbol = if (exitCode == 0) "✓" else "✗" + val statusSymbol = when { + cancelledByUser -> "⚠" + exitCode == 0 -> "✓" + else -> "✗" + } + val statusMessage = when { + cancelledByUser -> "Cancelled by user" + exitCode == 0 -> "Exit code: $exitCode" + else -> "Exit code: $exitCode" + } val preview = (output ?: "").lines().take(3).joinToString(" ").take(100) - println(" $statusSymbol Exit code: $exitCode (${executionTimeMs}ms)") + println(" $statusSymbol $statusMessage (${executionTimeMs}ms)") if (preview.isNotEmpty()) { println(" $preview${if (preview.length < (output ?: "").length) "..." else ""}") } @@ -281,25 +291,54 @@ class CodingCliRenderer : CodingAgentRenderer { // Process completed val exitCode = process.exitValue() val output = session.getOutput() + val wasCancelledByUser = session.cancelledByUser session.markCompleted(exitCode) - return if (exitCode == 0) { - cc.unitmesh.agent.tool.ToolResult.Success( - content = output.ifEmpty { "(no output)" }, - metadata = mapOf( - "exit_code" to exitCode.toString(), - "session_id" to session.sessionId + return when { + wasCancelledByUser -> { + // User cancelled the command - return a special result with output + cc.unitmesh.agent.tool.ToolResult.Error( + message = buildString { + appendLine("⚠️ Command cancelled by user") + appendLine() + appendLine("Command: ${session.command}") + appendLine("Exit code: $exitCode (SIGKILL)") + appendLine() + if (output.isNotEmpty()) { + appendLine("Output before cancellation:") + appendLine(output) + } else { + appendLine("(no output captured before cancellation)") + } + }, + errorType = "CANCELLED_BY_USER", + metadata = mapOf( + "exit_code" to exitCode.toString(), + "session_id" to session.sessionId, + "cancelled" to "true", + "output" to output + ) ) - ) - } else { - cc.unitmesh.agent.tool.ToolResult.Error( - message = "Command failed with exit code $exitCode:\n$output", - errorType = cc.unitmesh.agent.tool.ToolErrorType.COMMAND_FAILED.code, - metadata = mapOf( - "exit_code" to exitCode.toString(), - "session_id" to session.sessionId + } + exitCode == 0 -> { + cc.unitmesh.agent.tool.ToolResult.Success( + content = output.ifEmpty { "(no output)" }, + metadata = mapOf( + "exit_code" to exitCode.toString(), + "session_id" to session.sessionId + ) + ) + } + else -> { + cc.unitmesh.agent.tool.ToolResult.Error( + message = "Command failed with exit code $exitCode:\n$output", + errorType = cc.unitmesh.agent.tool.ToolErrorType.COMMAND_FAILED.code, + metadata = mapOf( + "exit_code" to exitCode.toString(), + "session_id" to session.sessionId + ) ) - ) + } } } @@ -361,28 +400,65 @@ class CodingCliRenderer : CodingAgentRenderer { val output = outputBuilder.toString() activeSessions.remove(session.sessionId) + // Check if cancelled by user from ShellSessionManager + val managedSession = cc.unitmesh.agent.tool.shell.ShellSessionManager.getSession(session.sessionId) + val wasCancelledByUser = managedSession?.cancelledByUser == true + val executionTimeMs = System.currentTimeMillis() - session.startTime - println(" ${if (exitCode == 0) "✓" else "✗"} Exit code: $exitCode (${executionTimeMs}ms)") - - return if (exitCode == 0) { - cc.unitmesh.agent.tool.ToolResult.Success( - content = output.ifEmpty { "(no output)" }, - metadata = mapOf( - "exit_code" to exitCode.toString(), - "session_id" to session.sessionId, - "execution_time_ms" to executionTimeMs.toString() + val statusSymbol = when { + wasCancelledByUser -> "⚠" + exitCode == 0 -> "✓" + else -> "✗" + } + println(" $statusSymbol Exit code: $exitCode (${executionTimeMs}ms)${if (wasCancelledByUser) " [Cancelled by user]" else ""}") + + return when { + wasCancelledByUser -> { + cc.unitmesh.agent.tool.ToolResult.Error( + message = buildString { + appendLine("⚠️ Command cancelled by user") + appendLine() + appendLine("Command: ${session.command}") + appendLine("Exit code: $exitCode (SIGKILL)") + appendLine() + if (output.isNotEmpty()) { + appendLine("Output before cancellation:") + appendLine(output) + } else { + appendLine("(no output captured before cancellation)") + } + }, + errorType = "CANCELLED_BY_USER", + metadata = mapOf( + "exit_code" to exitCode.toString(), + "session_id" to session.sessionId, + "execution_time_ms" to executionTimeMs.toString(), + "cancelled" to "true", + "output" to output + ) + ) + } + exitCode == 0 -> { + cc.unitmesh.agent.tool.ToolResult.Success( + content = output.ifEmpty { "(no output)" }, + metadata = mapOf( + "exit_code" to exitCode.toString(), + "session_id" to session.sessionId, + "execution_time_ms" to executionTimeMs.toString() + ) ) - ) - } else { - cc.unitmesh.agent.tool.ToolResult.Error( - message = "Command failed with exit code $exitCode:\n$output", - errorType = cc.unitmesh.agent.tool.ToolErrorType.COMMAND_FAILED.code, - metadata = mapOf( - "exit_code" to exitCode.toString(), - "session_id" to session.sessionId, - "execution_time_ms" to executionTimeMs.toString() + } + else -> { + cc.unitmesh.agent.tool.ToolResult.Error( + message = "Command failed with exit code $exitCode:\n$output", + errorType = cc.unitmesh.agent.tool.ToolErrorType.COMMAND_FAILED.code, + metadata = mapOf( + "exit_code" to exitCode.toString(), + "session_id" to session.sessionId, + "execution_time_ms" to executionTimeMs.toString() + ) ) - ) + } } } From 84edcccef3ae17946bae0c684894eb58aaf718cb Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 17:38:36 +0800 Subject: [PATCH 20/37] feat(terminal): skip analysis and error rendering for user-cancelled commands - Skip AnalysisAgent for cancelled commands in CodingAgentExecutor - Skip error rendering for user-cancelled scenarios - Preserve metadata in ToolExecutionResult.failure() for cancelled flag propagation - Apply same fix to DocumentAgentExecutor When user cancels a command (e.g., bootRun), the flow now: 1. Skips triggering AnalysisAgent for the cancelled output 2. Skips displaying extra 'Tool execution failed' error message 3. Only shows the concise cancellation confirmation This eliminates unnecessary double-messaging (Summary + Error) for intentional cancellations. --- .../unitmesh/agent/executor/CodingAgentExecutor.kt | 12 ++++++++++-- .../unitmesh/agent/executor/DocumentAgentExecutor.kt | 8 ++++++++ .../agent/orchestrator/ToolExecutionResult.kt | 3 ++- 3 files changed, 20 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 899921a151..9c2ab59eb4 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 @@ -279,8 +279,9 @@ class CodingAgentExecutor( } // 错误恢复处理 - if (!executionResult.isSuccess && !executionResult.isPending) { - val command = if (toolName == "shell") params["command"] as? String else null + // 跳过用户取消的场景 - 用户取消是明确的意图,不需要显示额外的错误消息 + val wasCancelledByUser = executionResult.metadata["cancelled"] == "true" + if (!executionResult.isSuccess && !executionResult.isPending && !wasCancelledByUser) { val errorMessage = executionResult.content ?: "Unknown error" renderer.renderError("Tool execution failed: $errorMessage") @@ -468,6 +469,13 @@ class CodingAgentExecutor( return null } + // 对于用户取消的命令,不需要分析输出 + // 用户取消是明确的意图,不需要对取消前的输出做分析 + val wasCancelledByUser = executionResult.metadata["cancelled"] == "true" + if (wasCancelledByUser) { + return null + } + // 检测内容类型 val contentType = when { toolName == "glob" -> "file-list" diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/DocumentAgentExecutor.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/DocumentAgentExecutor.kt index c08d70b6c0..e151134cc5 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/DocumentAgentExecutor.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/DocumentAgentExecutor.kt @@ -300,6 +300,7 @@ class DocumentAgentExecutor( * P1: Check for long content and delegate to AnalysisAgent for summarization * NOTE: Code content (from $.code.* queries) is NOT summarized to preserve actual code * NOTE: Live Session output is NOT summarized to preserve real-time terminal output + * NOTE: User cancelled commands are NOT summarized - cancellation is explicit user intent */ private suspend fun checkForLongContent( toolName: String, @@ -318,6 +319,13 @@ class DocumentAgentExecutor( return null } + // 对于用户取消的命令,不需要分析输出 + // 用户取消是明确的意图,不需要对取消前的输出做分析 + val wasCancelledByUser = executionResult.metadata["cancelled"] == "true" + if (wasCancelledByUser) { + return null + } + val isCodeContent = output.contains("📘 class ") || output.contains("⚡ fun ") || output.contains("Found") && output.contains("entities") || diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolExecutionResult.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolExecutionResult.kt index f1069c7ee0..fe7ee3d9ba 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolExecutionResult.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolExecutionResult.kt @@ -95,10 +95,11 @@ data class ToolExecutionResult( retryCount: Int = 0, metadata: Map = emptyMap() ): ToolExecutionResult { + // Preserve metadata in ToolResult.Error to enable downstream checks (e.g., cancelled flag) return ToolExecutionResult( executionId = executionId, toolName = toolName, - result = ToolResult.Error(error), + result = ToolResult.Error(error, metadata = metadata), startTime = startTime, endTime = endTime, retryCount = retryCount, From 59e5d864d9e134fa7ddecd7e64d979cf5e27002c Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 18:04:38 +0800 Subject: [PATCH 21/37] fix(review): address PR #27 review comments - Fix thread-safety in ShellSessionManager.markSessionCancelledByUser() with synchronized block - Fix condition ordering in JewelRenderer: check cancelledByUser before exitCode == 0 - Add execution_time_ms to awaitManagedSession for metadata consistency - Extract buildCancelledMessage helper to reduce code duplication - Simplify statusMessage logic in CodingCli (remove redundant when branches) - Add errorType to ComposeRenderer for cancelled results - Fix spacing in AnsiStripper ('(' , ')' -> '(', ')') - Remove duplicate inputStream reader in PtyShellExecutor to fix data race --- .../unitmesh/agent/tool/shell/AnsiStripper.kt | 2 +- .../agent/tool/shell/ShellSessionManager.kt | 7 ++- .../agent/tool/shell/PtyShellExecutor.kt | 32 ++--------- .../devins/idea/renderer/JewelRenderer.kt | 20 ++++--- .../ui/compose/agent/ComposeRenderer.kt | 6 ++ .../cc/unitmesh/server/cli/CodingCli.kt | 55 ++++++++----------- 6 files changed, 50 insertions(+), 72 deletions(-) diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/AnsiStripper.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/AnsiStripper.kt index 5c8ded8dcc..f47799a9a6 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/AnsiStripper.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/AnsiStripper.kt @@ -41,7 +41,7 @@ object AnsiStripper { // OSC sequence: ESC]...BEL or ESC]...ST i = skipOscSequence(text, i + 2) } - '(' , ')' -> { + '(', ')' -> { // Character set selection: ESC(X or ESC)X i = if (i + 2 < text.length) i + 3 else text.length } diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt index e217a58846..16ebe11e59 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt @@ -57,11 +57,12 @@ object ShellSessionManager { /** * Mark a session as cancelled by user. * This is a non-suspend function for use in UI callbacks where coroutine context may not be available. - * Note: This accesses the sessions map without locking, which is safe for this specific use case - * because we're only setting a boolean flag on an existing session. + * Uses synchronized block for thread-safe map access. */ fun markSessionCancelledByUser(sessionId: String) { - sessions[sessionId]?.cancelledByUser = true + synchronized(sessions) { + sessions[sessionId]?.cancelledByUser = true + } } /** diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt index 9efcec56a1..9c43b18f05 100644 --- a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt +++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt @@ -266,28 +266,10 @@ class PtyShellExecutor : ShellExecutor, LiveShellExecutor { // Get managed session to sync output val managedSession = ShellSessionManager.getSession(session.sessionId) - // Start output reading job to sync to ManagedSession - // This ensures output is available for ToolOrchestrator.startSessionMonitoring() - // Note: For IDEA, ProcessOutputCollector in IdeaLiveTerminalBubble also reads output, - // but that's OK because they read from different streams or the same stream is already consumed. - val outputJob = launch { - try { - ptyHandle.inputStream.bufferedReader().use { reader -> - var line = reader.readLine() - while (line != null && isActive) { - val lineWithNewline = line + "\n" - // Sync to LiveShellSession (for backward compatibility) - session.appendStdout(lineWithNewline) - // Sync to ManagedSession (for ToolOrchestrator) - managedSession?.appendOutput(lineWithNewline) - line = reader.readLine() - } - } - } catch (e: Exception) { - // Stream closed or other error - this is expected when process terminates - logger().debug { "Output reading stopped: ${e.message}" } - } - } + // Note: In IDEA environment, ProcessOutputCollector in IdeaLiveTerminalBubble + // already reads from inputStream and syncs to ShellSessionManager. + // We don't start another reader here to avoid data race on the same stream. + // The output will be available via managedSession.getOutput() after process completes. val exitCode = withTimeoutOrNull(timeoutMs) { while (ptyHandle.isAlive) { @@ -298,17 +280,11 @@ class PtyShellExecutor : ShellExecutor, LiveShellExecutor { } if (exitCode == null) { - outputJob.cancel() ptyHandle.destroyForcibly() ptyHandle.waitFor(3000, TimeUnit.MILLISECONDS) throw ToolException("Command timed out after ${timeoutMs}ms", ToolErrorType.TIMEOUT) } - // Wait for output reading to complete (with a short timeout) - withTimeoutOrNull(1000) { - outputJob.join() - } - session.markCompleted(exitCode) managedSession?.markCompleted(exitCode) exitCode diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt index ddfcff6289..71c85f8f96 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt @@ -468,16 +468,8 @@ class JewelRenderer : BaseRenderer() { // Also notify any waiting coroutines via the session result channel sessionResultChannels[sessionId]?.let { channel -> + // Check cancelledByUser first to handle cancelled commands with exit code 0 val result = when { - exitCode == 0 -> { - cc.unitmesh.agent.tool.ToolResult.Success( - content = output ?: "", - metadata = mapOf( - "exit_code" to exitCode.toString(), - "execution_time_ms" to executionTimeMs.toString() - ) - ) - } cancelledByUser -> { // User cancelled - include output in the message val errorMessage = buildString { @@ -503,9 +495,19 @@ class JewelRenderer : BaseRenderer() { ) ) } + exitCode == 0 -> { + cc.unitmesh.agent.tool.ToolResult.Success( + content = output ?: "", + metadata = mapOf( + "exit_code" to exitCode.toString(), + "execution_time_ms" to executionTimeMs.toString() + ) + ) + } else -> { cc.unitmesh.agent.tool.ToolResult.Error( message = "Command failed with exit code: $exitCode\n${output ?: ""}", + errorType = cc.unitmesh.agent.tool.ToolErrorType.COMMAND_FAILED.code, metadata = mapOf( "exit_code" to exitCode.toString(), "execution_time_ms" to executionTimeMs.toString(), 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 e137880b69..94cb80f57e 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 @@ -485,8 +485,14 @@ class ComposeRenderer : BaseRenderer() { } else { "Command failed with exit code: $exitCode" } + val errorType = if (cancelledByUser) { + "CANCELLED_BY_USER" + } else { + cc.unitmesh.agent.tool.ToolErrorType.COMMAND_FAILED.code + } cc.unitmesh.agent.tool.ToolResult.Error( message = errorMessage, + errorType = errorType, metadata = mapOf( "exit_code" to exitCode.toString(), "execution_time_ms" to executionTimeMs.toString(), diff --git a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/CodingCli.kt b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/CodingCli.kt index 9e846a2dc5..6ab36695d8 100644 --- a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/CodingCli.kt +++ b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/CodingCli.kt @@ -226,11 +226,7 @@ class CodingCliRenderer : CodingAgentRenderer { exitCode == 0 -> "✓" else -> "✗" } - val statusMessage = when { - cancelledByUser -> "Cancelled by user" - exitCode == 0 -> "Exit code: $exitCode" - else -> "Exit code: $exitCode" - } + val statusMessage = if (cancelledByUser) "Cancelled by user" else "Exit code: $exitCode" val preview = (output ?: "").lines().take(3).joinToString(" ").take(100) println(" $statusSymbol $statusMessage (${executionTimeMs}ms)") if (preview.isNotEmpty()) { @@ -292,28 +288,18 @@ class CodingCliRenderer : CodingAgentRenderer { val exitCode = process.exitValue() val output = session.getOutput() val wasCancelledByUser = session.cancelledByUser + val executionTimeMs = System.currentTimeMillis() - startWait session.markCompleted(exitCode) return when { wasCancelledByUser -> { // User cancelled the command - return a special result with output cc.unitmesh.agent.tool.ToolResult.Error( - message = buildString { - appendLine("⚠️ Command cancelled by user") - appendLine() - appendLine("Command: ${session.command}") - appendLine("Exit code: $exitCode (SIGKILL)") - appendLine() - if (output.isNotEmpty()) { - appendLine("Output before cancellation:") - appendLine(output) - } else { - appendLine("(no output captured before cancellation)") - } - }, + message = buildCancelledMessage(session.command, exitCode, output), errorType = "CANCELLED_BY_USER", metadata = mapOf( "exit_code" to exitCode.toString(), + "execution_time_ms" to executionTimeMs.toString(), "session_id" to session.sessionId, "cancelled" to "true", "output" to output @@ -325,6 +311,7 @@ class CodingCliRenderer : CodingAgentRenderer { content = output.ifEmpty { "(no output)" }, metadata = mapOf( "exit_code" to exitCode.toString(), + "execution_time_ms" to executionTimeMs.toString(), "session_id" to session.sessionId ) ) @@ -335,6 +322,7 @@ class CodingCliRenderer : CodingAgentRenderer { errorType = cc.unitmesh.agent.tool.ToolErrorType.COMMAND_FAILED.code, metadata = mapOf( "exit_code" to exitCode.toString(), + "execution_time_ms" to executionTimeMs.toString(), "session_id" to session.sessionId ) ) @@ -342,6 +330,23 @@ class CodingCliRenderer : CodingAgentRenderer { } } + /** + * Build a consistent cancelled message for user-cancelled commands. + */ + private fun buildCancelledMessage(command: String, exitCode: Int, output: String): String = buildString { + appendLine("⚠️ Command cancelled by user") + appendLine() + appendLine("Command: $command") + appendLine("Exit code: $exitCode (SIGKILL)") + appendLine() + if (output.isNotEmpty()) { + appendLine("Output before cancellation:") + appendLine(output) + } else { + appendLine("(no output captured before cancellation)") + } + } + private suspend fun awaitProcess( process: Process, session: SessionInfo, @@ -415,19 +420,7 @@ class CodingCliRenderer : CodingAgentRenderer { return when { wasCancelledByUser -> { cc.unitmesh.agent.tool.ToolResult.Error( - message = buildString { - appendLine("⚠️ Command cancelled by user") - appendLine() - appendLine("Command: ${session.command}") - appendLine("Exit code: $exitCode (SIGKILL)") - appendLine() - if (output.isNotEmpty()) { - appendLine("Output before cancellation:") - appendLine(output) - } else { - appendLine("(no output captured before cancellation)") - } - }, + message = buildCancelledMessage(session.command, exitCode, output), errorType = "CANCELLED_BY_USER", metadata = mapOf( "exit_code" to exitCode.toString(), From 3a8534fae32f418d1bd333774dbeb6db12dcbfb1 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 18:18:27 +0800 Subject: [PATCH 22/37] fix(kmp): remove synchronized for WASM/JS compatibility synchronized is JVM-only. Direct access is safe because: - JS/WASM are single-threaded - On JVM, boolean assignment is atomic and cancelledByUser is only written once --- .../cc/unitmesh/agent/tool/shell/ShellSessionManager.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt index 16ebe11e59..5c705899e1 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt @@ -57,12 +57,12 @@ object ShellSessionManager { /** * Mark a session as cancelled by user. * This is a non-suspend function for use in UI callbacks where coroutine context may not be available. - * Uses synchronized block for thread-safe map access. + * Note: Direct access is safe because: + * - JS/WASM are single-threaded + * - On JVM, boolean assignment is atomic and cancelledByUser is only written once */ fun markSessionCancelledByUser(sessionId: String) { - synchronized(sessions) { - sessions[sessionId]?.cancelledByUser = true - } + sessions[sessionId]?.cancelledByUser = true } /** From 9a755cc0092de6ea1a309b5885149d7a0c5f8278 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 20:05:41 +0800 Subject: [PATCH 23/37] feat(mpp-idea): add file search popup and fix MCP config button - Add IdeaFileSearchPopup for adding files to context - Support searching project files using FilenameIndex - Prioritize recently opened files using EditorHistoryManager - Multi-select files with checkbox UI - Filter binary and ignored files - Update IdeaTopToolbar with file selection functionality - Add project parameter for file search - Add onFilesSelected callback for selected files - Show file search popup on Add File button click - Fix IdeaBottomToolbar right-side config button - Change from model config to MCP config dialog - MCP config dialog now opens directly from settings button - Update IdeaDevInInputArea in IdeaAgentApp - Add IdeaTopToolbar to input area - Manage selected files state - Append file paths to message on send (/file:path format) --- .../devins/idea/editor/IdeaBottomToolbar.kt | 22 +- .../devins/idea/editor/IdeaFileSearchPopup.kt | 282 ++++++++++++++++++ .../devins/idea/editor/IdeaTopToolbar.kt | 80 ++--- .../devins/idea/toolwindow/IdeaAgentApp.kt | 51 +++- 4 files changed, 370 insertions(+), 65 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt index 82b62a15d8..ec3d9b73b9 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt @@ -19,11 +19,11 @@ import org.jetbrains.jewel.ui.component.Icon /** * Bottom toolbar for the input section. - * Provides send/stop buttons, model selector, settings, and token info. + * Provides send/stop buttons, model selector, MCP config, and token info. * - * Layout: ModelSelector - Token Info | MCP Settings - Prompt Optimization - Send Button + * Layout: ModelSelector - Token Info | MCP Config - Prompt Optimization - Send Button * - Left side: Model configuration (blends with background) - * - Right side: MCP, prompt optimization, and send + * - Right side: MCP config, prompt optimization, and send * * Note: @ and / triggers are now in the top toolbar (IdeaTopToolbar). */ @@ -33,7 +33,7 @@ fun IdeaBottomToolbar( sendEnabled: Boolean, isExecuting: Boolean = false, onStopClick: () -> Unit = {}, - onSettingsClick: () -> Unit = {}, + onMcpConfigClick: () -> Unit = {}, onPromptOptimizationClick: () -> Unit = {}, totalTokens: Int? = null, // Model selector props @@ -43,6 +43,7 @@ fun IdeaBottomToolbar( onConfigureClick: () -> Unit = {}, modifier: Modifier = Modifier ) { + var showMcpConfigDialog by remember { mutableStateOf(false) } Row( modifier = modifier .fillMaxWidth() @@ -81,14 +82,14 @@ fun IdeaBottomToolbar( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { - // MCP Settings button + // MCP Config button - opens MCP configuration dialog IconButton( - onClick = onSettingsClick, + onClick = { showMcpConfigDialog = true }, modifier = Modifier.size(32.dp) ) { Icon( imageVector = IdeaComposeIcons.Settings, - contentDescription = "MCP Settings", + contentDescription = "MCP Configuration", tint = JewelTheme.globalColors.text.normal, modifier = Modifier.size(16.dp) ) @@ -156,5 +157,12 @@ fun IdeaBottomToolbar( } } } + + // MCP Configuration Dialog + if (showMcpConfigDialog) { + IdeaMcpConfigDialog( + onDismiss = { showMcpConfigDialog = false } + ) + } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt new file mode 100644 index 0000000000..72a80dca77 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt @@ -0,0 +1,282 @@ +package cc.unitmesh.devins.idea.editor + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileEditor.impl.EditorHistoryManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ProjectFileIndex +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.search.FilenameIndex +import com.intellij.psi.search.GlobalSearchScope +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.* + +/** + * File search popup for adding files to workspace. + * Similar to WorkspaceFileSearchPopup from core module but using Compose/Jewel UI. + */ +@Composable +fun IdeaFileSearchPopup( + project: Project, + onDismiss: () -> Unit, + onFilesSelected: (List) -> Unit +) { + val searchQueryState = rememberTextFieldState("") + var searchResults by remember { mutableStateOf>(emptyList()) } + var selectedFiles by remember { mutableStateOf>(emptySet()) } + var isLoading by remember { mutableStateOf(true) } + + // Derive search query from state + val searchQuery by remember { derivedStateOf { searchQueryState.text.toString() } } + + // Load recent files on first composition + LaunchedEffect(Unit) { + searchResults = loadRecentFiles(project) + isLoading = false + } + + // Search when query changes + LaunchedEffect(searchQuery) { + if (searchQuery.isBlank()) { + searchResults = loadRecentFiles(project) + } else if (searchQuery.length >= 2) { + isLoading = true + searchResults = searchFiles(project, searchQuery) + isLoading = false + } + } + + Dialog(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .width(500.dp) + .height(400.dp) + .clip(RoundedCornerShape(8.dp)) + .background(JewelTheme.globalColors.panelBackground) + .padding(16.dp) + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Add Files to Context", style = JewelTheme.defaultTextStyle.copy(fontSize = 16.sp)) + IconButton(onClick = onDismiss) { + Icon( + imageVector = IdeaComposeIcons.Close, + contentDescription = "Close", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Search field using Jewel's TextField with TextFieldState + TextField( + state = searchQueryState, + placeholder = { Text("Search files...") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // File list + if (isLoading) { + Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { + Text("Loading...") + } + } else { + LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f)) { + items(searchResults) { file -> + FileListItem( + file = file, + isSelected = file.virtualFile in selectedFiles, + onClick = { + selectedFiles = if (file.virtualFile in selectedFiles) { + selectedFiles - file.virtualFile + } else { + selectedFiles + file.virtualFile + } + } + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Footer with action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + ) { + OutlinedButton(onClick = onDismiss) { Text("Cancel") } + DefaultButton( + onClick = { onFilesSelected(selectedFiles.toList()) }, + enabled = selectedFiles.isNotEmpty() + ) { + Text("Add ${if (selectedFiles.isNotEmpty()) "(${selectedFiles.size})" else ""}") + } + } + } + } +} + +@Composable +private fun FileListItem( + file: IdeaFilePresentation, + isSelected: Boolean, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .background( + if (isSelected) JewelTheme.globalColors.borders.normal.copy(alpha = 0.3f) + else androidx.compose.ui.graphics.Color.Transparent + ) + .clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Checkbox( + checked = isSelected, + onCheckedChange = { onClick() } + ) + + Icon( + imageVector = IdeaComposeIcons.InsertDriveFile, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = file.name, + style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp), + maxLines = 1 + ) + Text( + text = file.presentablePath, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) + ), + maxLines = 1 + ) + } + + if (file.isRecentFile) { + Text( + text = "Recent", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = JewelTheme.globalColors.text.info + ) + ) + } + } +} + +/** + * File presentation data class for Compose UI. + */ +data class IdeaFilePresentation( + val virtualFile: VirtualFile, + val name: String, + val path: String, + val presentablePath: String, + val isRecentFile: Boolean = false +) { + companion object { + fun from(project: Project, file: VirtualFile, isRecent: Boolean = false): IdeaFilePresentation { + val basePath = project.basePath ?: "" + val relativePath = if (file.path.startsWith(basePath)) { + file.path.removePrefix(basePath).removePrefix("/") + } else { + file.path + } + + return IdeaFilePresentation( + virtualFile = file, + name = file.name, + path = file.path, + presentablePath = relativePath, + isRecentFile = isRecent + ) + } + } +} + +private fun loadRecentFiles(project: Project): List { + val recentFiles = mutableListOf() + + try { + val fileList = EditorHistoryManager.getInstance(project).fileList + fileList.take(30) + .filter { it.isValid && !it.isDirectory && canBeAdded(project, it) } + .forEach { file -> + recentFiles.add(IdeaFilePresentation.from(project, file, isRecent = true)) + } + } catch (e: Exception) { + // Ignore errors loading recent files + } + + return recentFiles +} + +private fun searchFiles(project: Project, query: String): List { + val results = mutableListOf() + val scope = GlobalSearchScope.projectScope(project) + + try { + ApplicationManager.getApplication().runReadAction { + FilenameIndex.processFilesByName(query, false, scope) { file -> + if (canBeAdded(project, file) && results.size < 50) { + results.add(IdeaFilePresentation.from(project, file)) + } + results.size < 50 + } + } + } catch (e: Exception) { + // Ignore search errors + } + + return results.sortedBy { it.name } +} + +private fun canBeAdded(project: Project, file: VirtualFile): Boolean { + if (!file.isValid || file.isDirectory) return false + + val fileIndex = ProjectFileIndex.getInstance(project) + if (!fileIndex.isInContent(file)) return false + if (fileIndex.isUnderIgnored(file)) return false + + // Skip binary files + val extension = file.extension?.lowercase() ?: "" + val binaryExtensions = setOf("jar", "class", "exe", "dll", "so", "dylib", "png", "jpg", "jpeg", "gif", "ico", "pdf", "zip", "tar", "gz") + if (extension in binaryExtensions) return false + + return true +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt index fecb461afc..f9ea0fb258 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt @@ -12,10 +12,11 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.Icon import org.jetbrains.jewel.ui.component.Text @@ -24,21 +25,22 @@ import org.jetbrains.jewel.ui.component.Tooltip /** * Top toolbar for the input section. * Contains @ trigger, file selection, and other context-related actions. - * + * * Layout: @ - / - Clipboard - Save - Cursor | Selected Files... | Add */ @Composable fun IdeaTopToolbar( + project: Project? = null, onAtClick: () -> Unit = {}, onSlashClick: () -> Unit = {}, - onClipboardClick: () -> Unit = {}, - onSaveClick: () -> Unit = {}, - onCursorClick: () -> Unit = {}, onAddFileClick: () -> Unit = {}, selectedFiles: List = emptyList(), onRemoveFile: (SelectedFileItem) -> Unit = {}, + onFilesSelected: (List) -> Unit = {}, modifier: Modifier = Modifier ) { + var showFileSearchPopup by remember { mutableStateOf(false) } + Row( modifier = modifier .fillMaxWidth() @@ -51,54 +53,28 @@ fun IdeaTopToolbar( horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically ) { - // @ trigger button - ToolbarIconButton(onClick = onAtClick, tooltip = "@ Agent/File Reference") { - Icon( - imageVector = IdeaComposeIcons.AlternateEmail, - contentDescription = "@ Agent", - tint = JewelTheme.globalColors.text.normal, - modifier = Modifier.size(16.dp) - ) - } - // / trigger button - ToolbarIconButton(onClick = onSlashClick, tooltip = "/ Commands") { - Text(text = "/", style = JewelTheme.defaultTextStyle.copy(fontSize = 14.sp, fontWeight = FontWeight.Bold)) - } - // Clipboard button - ToolbarIconButton(onClick = onClipboardClick, tooltip = "Paste from Clipboard") { + ToolbarIconButton( + onClick = { + if (project != null) { + showFileSearchPopup = true + } + onAddFileClick() + }, + tooltip = "Add File" + ) { Icon( - imageVector = IdeaComposeIcons.ContentPaste, - contentDescription = "Clipboard", - modifier = Modifier.size(16.dp), - tint = JewelTheme.globalColors.text.normal - ) - } - // Save button - ToolbarIconButton(onClick = onSaveClick, tooltip = "Save to Workspace") { - Icon( - imageVector = IdeaComposeIcons.Save, - contentDescription = "Save", - modifier = Modifier.size(16.dp), - tint = JewelTheme.globalColors.text.normal - ) - } - // Cursor button - ToolbarIconButton(onClick = onCursorClick, tooltip = "Current Selection") { - Icon( - imageVector = IdeaComposeIcons.TextFields, - contentDescription = "Cursor", + imageVector = IdeaComposeIcons.Add, + contentDescription = "Add File", modifier = Modifier.size(16.dp), tint = JewelTheme.globalColors.text.normal ) } } - // Separator if (selectedFiles.isNotEmpty()) { Box(Modifier.width(1.dp).height(20.dp).background(JewelTheme.globalColors.borders.normal)) } - // Selected files as chips Row( modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -108,16 +84,18 @@ fun IdeaTopToolbar( FileChip(file = file, onRemove = { onRemoveFile(file) }) } } + } - // Add file button - ToolbarIconButton(onClick = onAddFileClick, tooltip = "Add File") { - Icon( - imageVector = IdeaComposeIcons.Add, - contentDescription = "Add File", - modifier = Modifier.size(16.dp), - tint = JewelTheme.globalColors.text.normal - ) - } + // File search popup + if (showFileSearchPopup && project != null) { + IdeaFileSearchPopup( + project = project, + onDismiss = { showFileSearchPopup = false }, + onFilesSelected = { files -> + onFilesSelected(files) + showFileSearchPopup = false + } + ) } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt index 98667eab3e..cdbf724294 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt @@ -13,6 +13,8 @@ import cc.unitmesh.devins.idea.editor.IdeaDevInInput import cc.unitmesh.devins.idea.editor.IdeaInputListener import cc.unitmesh.devins.idea.editor.IdeaInputTrigger import cc.unitmesh.devins.idea.editor.IdeaModelConfigDialogWrapper +import cc.unitmesh.devins.idea.editor.IdeaTopToolbar +import cc.unitmesh.devins.idea.editor.SelectedFileItem import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewContent import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewViewModel import cc.unitmesh.devins.idea.components.header.IdeaAgentTabsHeader @@ -178,7 +180,6 @@ fun IdeaAgentApp( onAbort = { viewModel.cancelTask() }, workspacePath = project.basePath, totalTokens = null, - onSettingsClick = { viewModel.setShowConfigDialog(true) }, onAtClick = {}, availableConfigs = availableConfigs, currentConfigName = currentConfigName, @@ -224,7 +225,6 @@ fun IdeaAgentApp( onAbort = { remoteVm.cancelTask() }, workspacePath = project.basePath, totalTokens = null, - onSettingsClick = { viewModel.setShowConfigDialog(true) }, onAtClick = {}, availableConfigs = availableConfigs, currentConfigName = currentConfigName, @@ -323,7 +323,6 @@ private fun IdeaDevInInputArea( onAbort: () -> Unit, workspacePath: String? = null, totalTokens: Int? = null, - onSettingsClick: () -> Unit = {}, onAtClick: () -> Unit = {}, availableConfigs: List = emptyList(), currentConfigName: String? = null, @@ -332,10 +331,31 @@ private fun IdeaDevInInputArea( ) { var inputText by remember { mutableStateOf("") } var devInInput by remember { mutableStateOf(null) } + var selectedFiles by remember { mutableStateOf>(emptyList()) } Column( modifier = Modifier.fillMaxSize().padding(8.dp) ) { + // Top toolbar with file selection + IdeaTopToolbar( + project = project, + onAtClick = onAtClick, + selectedFiles = selectedFiles, + onRemoveFile = { file -> + selectedFiles = selectedFiles.filter { it.path != file.path } + }, + onFilesSelected = { files -> + val newItems = files.map { vf -> + SelectedFileItem( + name = vf.name, + path = vf.path, + virtualFile = vf + ) + } + selectedFiles = (selectedFiles + newItems).distinctBy { it.path } + } + ) + // DevIn Editor via SwingPanel - uses weight(1f) to fill available space SwingPanel( modifier = Modifier @@ -356,9 +376,18 @@ private fun IdeaDevInInputArea( override fun onSubmit(text: String, trigger: IdeaInputTrigger) { if (text.isNotBlank() && !isProcessing) { - onSend(text) + // Append file references to the message + val filesText = selectedFiles.joinToString("\n") { "/file:${it.path}" } + val fullText = if (filesText.isNotEmpty()) { + "$text\n$filesText" + } else { + text + } + onSend(fullText) clearInput() inputText = "" + // Clear selected files after sending + selectedFiles = emptyList() } } @@ -388,20 +417,28 @@ private fun IdeaDevInInputArea( } ) - // Bottom toolbar with Compose + // Bottom toolbar with Compose (MCP config is handled internally) IdeaBottomToolbar( onSendClick = { val text = devInInput?.text?.trim() ?: inputText.trim() if (text.isNotBlank() && !isProcessing) { - onSend(text) + // Append file references to the message + val filesText = selectedFiles.joinToString("\n") { "/file:${it.path}" } + val fullText = if (filesText.isNotEmpty()) { + "$text\n$filesText" + } else { + text + } + onSend(fullText) devInInput?.clearInput() inputText = "" + // Clear selected files after sending + selectedFiles = emptyList() } }, sendEnabled = inputText.isNotBlank() && !isProcessing, isExecuting = isProcessing, onStopClick = onAbort, - onSettingsClick = onSettingsClick, totalTokens = totalTokens, availableConfigs = availableConfigs, currentConfigName = currentConfigName, From 98ead2f7df81ff3bb6851592063ecd343ebe3834 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 20:18:30 +0800 Subject: [PATCH 24/37] fix: address PR review comments - Fix EDT violation: move searchAllItems to background thread using Dispatchers.IO - Remove unused onMcpConfigClick parameter from IdeaBottomToolbar - Add History icon to IdeaComposeIcons for recently opened files menu --- .../devins/idea/editor/IdeaBottomToolbar.kt | 1 - .../devins/idea/editor/IdeaFileSearchPopup.kt | 400 ++++++++++++------ .../idea/toolwindow/IdeaComposeIcons.kt | 42 ++ 3 files changed, 322 insertions(+), 121 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt index ec3d9b73b9..b6118271d3 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt @@ -33,7 +33,6 @@ fun IdeaBottomToolbar( sendEnabled: Boolean, isExecuting: Boolean = false, onStopClick: () -> Unit = {}, - onMcpConfigClick: () -> Unit = {}, onPromptOptimizationClick: () -> Unit = {}, totalTokens: Int? = null, // Model selector props diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt index 72a80dca77..cd77397667 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt @@ -1,10 +1,7 @@ package cc.unitmesh.devins.idea.editor import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.* @@ -13,21 +10,30 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.readAction import com.intellij.openapi.fileEditor.impl.EditorHistoryManager import com.intellij.openapi.project.Project import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.search.FilenameIndex import com.intellij.psi.search.GlobalSearchScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.* /** - * File search popup for adding files to workspace. - * Similar to WorkspaceFileSearchPopup from core module but using Compose/Jewel UI. + * Context menu popup for adding files/folders to workspace. + * Uses Jewel's PopupMenu for native IntelliJ look and feel. + * + * Layout: + * - Files (submenu with matching files) + * - Folders (submenu with matching folders) + * - Recently Opened Files (submenu) + * - Clear Context + * - Search field at bottom */ @Composable fun IdeaFileSearchPopup( @@ -36,140 +42,244 @@ fun IdeaFileSearchPopup( onFilesSelected: (List) -> Unit ) { val searchQueryState = rememberTextFieldState("") - var searchResults by remember { mutableStateOf>(emptyList()) } - var selectedFiles by remember { mutableStateOf>(emptySet()) } - var isLoading by remember { mutableStateOf(true) } - - // Derive search query from state val searchQuery by remember { derivedStateOf { searchQueryState.text.toString() } } - // Load recent files on first composition - LaunchedEffect(Unit) { - searchResults = loadRecentFiles(project) - isLoading = false - } + // Grouped search results + var files by remember { mutableStateOf>(emptyList()) } + var folders by remember { mutableStateOf>(emptyList()) } + var recentFiles by remember { mutableStateOf>(emptyList()) } - // Search when query changes + // Submenu expansion states + var filesExpanded by remember { mutableStateOf(false) } + var foldersExpanded by remember { mutableStateOf(false) } + var recentExpanded by remember { mutableStateOf(false) } + + // Load data based on search query - run on background thread to avoid EDT blocking LaunchedEffect(searchQuery) { - if (searchQuery.isBlank()) { - searchResults = loadRecentFiles(project) - } else if (searchQuery.length >= 2) { - isLoading = true - searchResults = searchFiles(project, searchQuery) - isLoading = false + val results = withContext(Dispatchers.IO) { + if (searchQuery.length >= 2) { + searchAllItems(project, searchQuery) + } else { + SearchResults(emptyList(), emptyList(), loadRecentFiles(project)) + } } + files = results.files + folders = results.folders + recentFiles = results.recentFiles } - Dialog(onDismissRequest = onDismiss) { - Column( - modifier = Modifier - .width(500.dp) - .height(400.dp) - .clip(RoundedCornerShape(8.dp)) - .background(JewelTheme.globalColors.panelBackground) - .padding(16.dp) - ) { - // Header - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + // Initial load - run on background thread + LaunchedEffect(Unit) { + recentFiles = withContext(Dispatchers.IO) { + loadRecentFiles(project) + } + } + + PopupMenu( + onDismissRequest = { + onDismiss() + true + }, + horizontalAlignment = Alignment.Start, + modifier = Modifier.widthIn(min = 280.dp, max = 450.dp) + ) { + // Files submenu + if (files.isNotEmpty() || searchQuery.length >= 2) { + submenu( + submenu = { + if (files.isEmpty()) { + passiveItem { + Text( + "No files found", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 13.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + ) + ) + } + } else { + files.take(10).forEach { file -> + selectableItem( + selected = false, + onClick = { + onFilesSelected(listOf(file.virtualFile)) + onDismiss() + } + ) { + FileMenuItem(file) + } + } + if (files.size > 10) { + passiveItem { + Text( + "... and ${files.size - 10} more", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + ) + ) + } + } + } + } + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = IdeaComposeIcons.InsertDriveFile, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + Text("Files", style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp)) + } + } + } + } + + // Folders submenu + if (folders.isNotEmpty() || searchQuery.length >= 2) { + submenu( + submenu = { + if (folders.isEmpty()) { + passiveItem { + Text( + "No folders found", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 13.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + ) + ) + } + } else { + folders.take(10).forEach { folder -> + selectableItem( + selected = false, + onClick = { + onFilesSelected(listOf(folder.virtualFile)) + onDismiss() + } + ) { + FolderMenuItem(folder) + } + } + } + } ) { - Text("Add Files to Context", style = JewelTheme.defaultTextStyle.copy(fontSize = 16.sp)) - IconButton(onClick = onDismiss) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { Icon( - imageVector = IdeaComposeIcons.Close, - contentDescription = "Close", + imageVector = IdeaComposeIcons.Folder, + contentDescription = null, tint = JewelTheme.globalColors.text.normal, modifier = Modifier.size(16.dp) ) + Text("Folders", style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp)) } } + } - Spacer(modifier = Modifier.height(12.dp)) - - // Search field using Jewel's TextField with TextFieldState - TextField( - state = searchQueryState, - placeholder = { Text("Search files...") }, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.height(12.dp)) - - // File list - if (isLoading) { - Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { - Text("Loading...") - } - } else { - LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f)) { - items(searchResults) { file -> - FileListItem( - file = file, - isSelected = file.virtualFile in selectedFiles, + // Recently Opened Files submenu + submenu( + submenu = { + if (recentFiles.isEmpty()) { + passiveItem { + Text( + "No recent files", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 13.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + ) + ) + } + } else { + recentFiles.take(15).forEach { file -> + selectableItem( + selected = false, onClick = { - selectedFiles = if (file.virtualFile in selectedFiles) { - selectedFiles - file.virtualFile - } else { - selectedFiles + file.virtualFile - } + onFilesSelected(listOf(file.virtualFile)) + onDismiss() } - ) + ) { + FileMenuItem(file) + } } } } + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = IdeaComposeIcons.History, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + Text("Recently Opened Files", style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp)) + } + } - Spacer(modifier = Modifier.height(12.dp)) + separator() - // Footer with action buttons + // Clear Context action + selectableItem( + selected = false, + onClick = { + onFilesSelected(emptyList()) + onDismiss() + } + ) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically ) { - OutlinedButton(onClick = onDismiss) { Text("Cancel") } - DefaultButton( - onClick = { onFilesSelected(selectedFiles.toList()) }, - enabled = selectedFiles.isNotEmpty() - ) { - Text("Add ${if (selectedFiles.isNotEmpty()) "(${selectedFiles.size})" else ""}") - } + Icon( + imageVector = IdeaComposeIcons.Close, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + Text("Clear Context", style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp)) } } + + separator() + + // Search field at bottom + passiveItem { + TextField( + state = searchQueryState, + placeholder = { Text("Focus context") }, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + ) + } } } @Composable -private fun FileListItem( - file: IdeaFilePresentation, - isSelected: Boolean, - onClick: () -> Unit -) { +private fun FileMenuItem(file: IdeaFilePresentation) { Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(4.dp)) - .background( - if (isSelected) JewelTheme.globalColors.borders.normal.copy(alpha = 0.3f) - else androidx.compose.ui.graphics.Color.Transparent - ) - .clickable(onClick = onClick) - .padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically ) { - Checkbox( - checked = isSelected, - onCheckedChange = { onClick() } - ) - Icon( imageVector = IdeaComposeIcons.InsertDriveFile, contentDescription = null, tint = JewelTheme.globalColors.text.normal, modifier = Modifier.size(16.dp) ) - Column(modifier = Modifier.weight(1f)) { Text( text = file.name, @@ -185,19 +295,39 @@ private fun FileListItem( maxLines = 1 ) } + } +} - if (file.isRecentFile) { - Text( - text = "Recent", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 10.sp, - color = JewelTheme.globalColors.text.info - ) - ) - } +@Composable +private fun FolderMenuItem(folder: IdeaFilePresentation) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = IdeaComposeIcons.Folder, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + Text( + text = folder.presentablePath, + style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp), + maxLines = 1 + ) } } +/** + * Search results grouped by type. + */ +data class SearchResults( + val files: List, + val folders: List, + val recentFiles: List +) + /** * File presentation data class for Compose UI. */ @@ -206,7 +336,8 @@ data class IdeaFilePresentation( val name: String, val path: String, val presentablePath: String, - val isRecentFile: Boolean = false + val isRecentFile: Boolean = false, + val isDirectory: Boolean = false ) { companion object { fun from(project: Project, file: VirtualFile, isRecent: Boolean = false): IdeaFilePresentation { @@ -222,7 +353,8 @@ data class IdeaFilePresentation( name = file.name, path = file.path, presentablePath = relativePath, - isRecentFile = isRecent + isRecentFile = isRecent, + isDirectory = file.isDirectory ) } } @@ -245,28 +377,56 @@ private fun loadRecentFiles(project: Project): List { return recentFiles } -private fun searchFiles(project: Project, query: String): List { - val results = mutableListOf() +private fun searchAllItems(project: Project, query: String): SearchResults { + val files = mutableListOf() + val folders = mutableListOf() val scope = GlobalSearchScope.projectScope(project) + val lowerQuery = query.lowercase() try { ApplicationManager.getApplication().runReadAction { + // Search files by name FilenameIndex.processFilesByName(query, false, scope) { file -> - if (canBeAdded(project, file) && results.size < 50) { - results.add(IdeaFilePresentation.from(project, file)) + if (file.isDirectory) { + if (folders.size < 20) { + folders.add(IdeaFilePresentation.from(project, file)) + } + } else if (canBeAdded(project, file) && files.size < 50) { + files.add(IdeaFilePresentation.from(project, file)) } - results.size < 50 + files.size < 50 || folders.size < 20 + } + + // Also search for folders containing the query + val fileIndex = ProjectFileIndex.getInstance(project) + fileIndex.iterateContent { file -> + if (file.isDirectory && file.name.lowercase().contains(lowerQuery)) { + if (folders.size < 20 && !folders.any { it.path == file.path }) { + folders.add(IdeaFilePresentation.from(project, file)) + } + } + folders.size < 20 } } } catch (e: Exception) { // Ignore search errors } - return results.sortedBy { it.name } + // Filter recent files by query + val recentFiles = loadRecentFiles(project).filter { + it.name.lowercase().contains(lowerQuery) || it.presentablePath.lowercase().contains(lowerQuery) + } + + return SearchResults( + files = files.sortedBy { it.name }, + folders = folders.sortedBy { it.presentablePath }, + recentFiles = recentFiles + ) } private fun canBeAdded(project: Project, file: VirtualFile): Boolean { - if (!file.isValid || file.isDirectory) return false + if (!file.isValid) return false + if (file.isDirectory) return true // Allow directories val fileIndex = ProjectFileIndex.getInstance(project) if (!fileIndex.isInContent(file)) return false diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt index 5ffb0dbc7f..a188c29394 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt @@ -1540,5 +1540,47 @@ object IdeaComposeIcons { }.build() } + /** + * History icon (clock with arrow) + */ + val History: ImageVector by lazy { + ImageVector.Builder( + name = "History", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(13f, 3f) + curveToRelative(-4.97f, 0f, -9f, 4.03f, -9f, 9f) + horizontalLineTo(1f) + lineToRelative(3.89f, 3.89f) + lineToRelative(0.07f, 0.14f) + lineTo(9f, 12f) + horizontalLineTo(6f) + curveToRelative(0f, -3.87f, 3.13f, -7f, 7f, -7f) + reflectiveCurveToRelative(7f, 3.13f, 7f, 7f) + reflectiveCurveToRelative(-3.13f, 7f, -7f, 7f) + curveToRelative(-1.93f, 0f, -3.68f, -0.79f, -4.94f, -2.06f) + lineToRelative(-1.42f, 1.42f) + curveTo(8.27f, 19.99f, 10.51f, 21f, 13f, 21f) + curveToRelative(4.97f, 0f, 9f, -4.03f, 9f, -9f) + reflectiveCurveToRelative(-4.03f, -9f, -9f, -9f) + close() + moveTo(12f, 8f) + verticalLineToRelative(5f) + lineToRelative(4.28f, 2.54f) + lineToRelative(0.72f, -1.21f) + lineToRelative(-3.5f, -2.08f) + verticalLineTo(8f) + horizontalLineTo(12f) + close() + } + }.build() + } + } From 4863d854a1284c895ebb2b1b5b8449533f031209 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 21:26:10 +0800 Subject: [PATCH 25/37] feat(mpp-idea): improve file search popup with proper PopupMenu pattern - Restructure IdeaFileSearchPopup to use Box container with trigger button - Add IdeaContextManager for context state management - Simplify popup content with search field at top - Add recent files display and file/folder search - Add new icons: CheckBox, CheckBoxOutlineBlank, Remove - Update IdeaTopToolbar to use new popup API --- .../devins/idea/editor/IdeaContextManager.kt | 251 +++++++++ .../devins/idea/editor/IdeaFileSearchPopup.kt | 514 ++++++++++-------- .../devins/idea/editor/IdeaTopToolbar.kt | 141 ++++- .../idea/toolwindow/IdeaComposeIcons.kt | 117 ++++ 4 files changed, 778 insertions(+), 245 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaContextManager.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaContextManager.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaContextManager.kt new file mode 100644 index 0000000000..86596df46f --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaContextManager.kt @@ -0,0 +1,251 @@ +package cc.unitmesh.devins.idea.editor + +import com.intellij.codeInsight.lookup.LookupManagerListener +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorManagerEvent +import com.intellij.openapi.fileEditor.FileEditorManagerListener +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Manages context files for the AI assistant. + * Provides state management for selected files, default context, and rules. + * + * Features: + * - Auto-add current editor file to context + * - Related classes suggestion via LookupManagerListener + * - Default context preset management + * - Context rules (file patterns, include/exclude) + */ +@Service(Service.Level.PROJECT) +class IdeaContextManager(private val project: Project) : Disposable { + + // Selected files in the current context + private val _selectedFiles = MutableStateFlow>(emptyList()) + val selectedFiles: StateFlow> = _selectedFiles.asStateFlow() + + // Default context files (saved preset) + private val _defaultContextFiles = MutableStateFlow>(emptyList()) + val defaultContextFiles: StateFlow> = _defaultContextFiles.asStateFlow() + + // Context rules + private val _rules = MutableStateFlow>(emptyList()) + val rules: StateFlow> = _rules.asStateFlow() + + // Related files suggested by the system + private val _relatedFiles = MutableStateFlow>(emptyList()) + val relatedFiles: StateFlow> = _relatedFiles.asStateFlow() + + // Auto-add current file setting + private val _autoAddCurrentFile = MutableStateFlow(true) + val autoAddCurrentFile: StateFlow = _autoAddCurrentFile.asStateFlow() + + // Listeners setup flag + private var listenersSetup = false + + init { + setupListeners() + } + + /** + * Setup editor and lookup listeners for auto-adding files + */ + private fun setupListeners() { + if (listenersSetup) return + listenersSetup = true + + // Listen to file editor changes + project.messageBus.connect(this).subscribe( + FileEditorManagerListener.FILE_EDITOR_MANAGER, + object : FileEditorManagerListener { + override fun selectionChanged(event: FileEditorManagerEvent) { + if (!_autoAddCurrentFile.value) return + val file = event.newFile ?: return + if (canBeAdded(file)) { + ApplicationManager.getApplication().invokeLater { + addRelatedFile(file) + } + } + } + } + ) + + // Initialize with current file + val currentFile = FileEditorManager.getInstance(project).selectedFiles.firstOrNull() + currentFile?.let { + if (canBeAdded(it)) { + addRelatedFile(it) + } + } + } + + /** + * Add a file to the selected context + */ + fun addFile(file: VirtualFile) { + if (!file.isValid) return + val current = _selectedFiles.value.toMutableList() + if (current.none { it.path == file.path }) { + current.add(file) + _selectedFiles.value = current + } + } + + /** + * Add multiple files to the selected context + */ + fun addFiles(files: List) { + val current = _selectedFiles.value.toMutableList() + files.filter { it.isValid && current.none { existing -> existing.path == it.path } } + .forEach { current.add(it) } + _selectedFiles.value = current + } + + /** + * Remove a file from the selected context + */ + fun removeFile(file: VirtualFile) { + _selectedFiles.value = _selectedFiles.value.filter { it.path != file.path } + } + + /** + * Clear all selected files + */ + fun clearContext() { + _selectedFiles.value = emptyList() + _relatedFiles.value = emptyList() + } + + /** + * Set the current selection as default context + */ + fun setAsDefaultContext() { + _defaultContextFiles.value = _selectedFiles.value.toList() + } + + /** + * Load the default context + */ + fun loadDefaultContext() { + val defaults = _defaultContextFiles.value + if (defaults.isNotEmpty()) { + _selectedFiles.value = defaults.filter { it.isValid } + } + } + + /** + * Clear the default context + */ + fun clearDefaultContext() { + _defaultContextFiles.value = emptyList() + } + + /** + * Check if default context is set + */ + fun hasDefaultContext(): Boolean = _defaultContextFiles.value.isNotEmpty() + + /** + * Add a related file (from editor listener or lookup) + */ + private fun addRelatedFile(file: VirtualFile) { + if (!file.isValid) return + val current = _relatedFiles.value.toMutableList() + if (current.none { it.path == file.path }) { + // Keep only the most recent 10 related files + if (current.size >= 10) { + current.removeAt(current.size - 1) + } + current.add(0, file) + _relatedFiles.value = current + } + } + + /** + * Add a context rule + */ + fun addRule(rule: ContextRule) { + val current = _rules.value.toMutableList() + current.add(rule) + _rules.value = current + } + + /** + * Remove a context rule + */ + fun removeRule(rule: ContextRule) { + _rules.value = _rules.value.filter { it.id != rule.id } + } + + /** + * Clear all rules + */ + fun clearRules() { + _rules.value = emptyList() + } + + /** + * Toggle auto-add current file setting + */ + fun setAutoAddCurrentFile(enabled: Boolean) { + _autoAddCurrentFile.value = enabled + } + + /** + * Check if a file can be added to context + */ + private fun canBeAdded(file: VirtualFile): Boolean { + if (!file.isValid) return false + if (file.isDirectory) return false + + // Skip binary files + val extension = file.extension?.lowercase() ?: "" + val binaryExtensions = setOf( + "jar", "class", "exe", "dll", "so", "dylib", + "png", "jpg", "jpeg", "gif", "ico", "pdf", + "zip", "tar", "gz", "rar", "7z" + ) + if (extension in binaryExtensions) return false + + return true + } + + override fun dispose() { + // Cleanup if needed + } + + companion object { + fun getInstance(project: Project): IdeaContextManager = project.service() + } +} + +/** + * Represents a context rule for filtering files + */ +data class ContextRule( + val id: String = java.util.UUID.randomUUID().toString(), + val name: String, + val type: ContextRuleType, + val pattern: String, + val enabled: Boolean = true +) + +/** + * Types of context rules + */ +enum class ContextRuleType { + INCLUDE_PATTERN, // Include files matching pattern + EXCLUDE_PATTERN, // Exclude files matching pattern + FILE_EXTENSION, // Filter by file extension + DIRECTORY // Include/exclude directory +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt index cd77397667..ec9a98b548 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt @@ -1,6 +1,10 @@ package cc.unitmesh.devins.idea.editor import androidx.compose.foundation.background +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.foundation.text.input.rememberTextFieldState @@ -8,19 +12,19 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.readAction import com.intellij.openapi.fileEditor.impl.EditorHistoryManager import com.intellij.openapi.project.Project import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.search.FilenameIndex import com.intellij.psi.search.GlobalSearchScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext + import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.* @@ -28,15 +32,64 @@ import org.jetbrains.jewel.ui.component.* * Context menu popup for adding files/folders to workspace. * Uses Jewel's PopupMenu for native IntelliJ look and feel. * + * This component includes both the trigger button and the popup menu. + * The popup is positioned relative to the trigger button. + * * Layout: - * - Files (submenu with matching files) - * - Folders (submenu with matching folders) - * - Recently Opened Files (submenu) - * - Clear Context + * - Recently Opened Files (direct items) + * - Files (submenu with matching files, only when searching) + * - Folders (submenu with matching folders, only when searching) * - Search field at bottom */ @Composable fun IdeaFileSearchPopup( + project: Project, + showPopup: Boolean, + onShowPopupChange: (Boolean) -> Unit, + onFilesSelected: (List) -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + + Box(modifier = modifier) { + // Trigger button + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .hoverable(interactionSource = interactionSource) + .background( + if (isHovered || showPopup) + JewelTheme.globalColors.borders.normal.copy(alpha = 0.3f) + else + androidx.compose.ui.graphics.Color.Transparent + ) + .clickable { onShowPopupChange(true) } + .padding(4.dp) + ) { + Tooltip(tooltip = { Text("Add File to Context") }) { + Icon( + imageVector = IdeaComposeIcons.Add, + contentDescription = "Add File", + modifier = Modifier.size(16.dp), + tint = JewelTheme.globalColors.text.normal + ) + } + } + + // Popup menu + if (showPopup) { + FileSearchPopupContent( + project = project, + onDismiss = { onShowPopupChange(false) }, + onFilesSelected = onFilesSelected + ) + } + } +} + +@Composable +private fun FileSearchPopupContent( project: Project, onDismiss: () -> Unit, onFilesSelected: (List) -> Unit @@ -44,35 +97,24 @@ fun IdeaFileSearchPopup( val searchQueryState = rememberTextFieldState("") val searchQuery by remember { derivedStateOf { searchQueryState.text.toString() } } - // Grouped search results - var files by remember { mutableStateOf>(emptyList()) } - var folders by remember { mutableStateOf>(emptyList()) } - var recentFiles by remember { mutableStateOf>(emptyList()) } - - // Submenu expansion states - var filesExpanded by remember { mutableStateOf(false) } - var foldersExpanded by remember { mutableStateOf(false) } - var recentExpanded by remember { mutableStateOf(false) } - - // Load data based on search query - run on background thread to avoid EDT blocking - LaunchedEffect(searchQuery) { - val results = withContext(Dispatchers.IO) { - if (searchQuery.length >= 2) { - searchAllItems(project, searchQuery) - } else { - SearchResults(emptyList(), emptyList(), loadRecentFiles(project)) - } + // Load recent files immediately (not in LaunchedEffect) + val recentFiles = remember(project) { loadRecentFiles(project) } + + // Search results - only computed when query is long enough + val searchResults = remember(searchQuery, project) { + if (searchQuery.length >= 2) { + searchAllItems(project, searchQuery) + } else { + null } - files = results.files - folders = results.folders - recentFiles = results.recentFiles } - // Initial load - run on background thread - LaunchedEffect(Unit) { - recentFiles = withContext(Dispatchers.IO) { - loadRecentFiles(project) - } + val files = searchResults?.files ?: emptyList() + val folders = searchResults?.folders ?: emptyList() + val filteredRecentFiles = if (searchQuery.length >= 2) { + searchResults?.recentFiles ?: emptyList() + } else { + recentFiles } PopupMenu( @@ -81,192 +123,152 @@ fun IdeaFileSearchPopup( true }, horizontalAlignment = Alignment.Start, - modifier = Modifier.widthIn(min = 280.dp, max = 450.dp) + modifier = Modifier.widthIn(min = 300.dp, max = 480.dp) ) { - // Files submenu - if (files.isNotEmpty() || searchQuery.length >= 2) { - submenu( - submenu = { - if (files.isEmpty()) { - passiveItem { - Text( - "No files found", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 13.sp, - color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) - ) - ) - } - } else { - files.take(10).forEach { file -> - selectableItem( - selected = false, - onClick = { - onFilesSelected(listOf(file.virtualFile)) - onDismiss() - } - ) { - FileMenuItem(file) - } - } - if (files.size > 10) { - passiveItem { - Text( - "... and ${files.size - 10} more", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 11.sp, - color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) - ) - ) - } + // Search field at top + passiveItem { + TextField( + state = searchQueryState, + placeholder = { Text("Search files...") }, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + ) + } + + separator() + + // Show search results if searching + if (searchQuery.length >= 2) { + // Files from search + if (files.isNotEmpty()) { + passiveItem { + Text( + "Files (${files.size})", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) + ) + } + files.take(10).forEach { file -> + selectableItem( + selected = false, + onClick = { + onFilesSelected(listOf(file.virtualFile)) + onDismiss() } + ) { + FileMenuItem(file) } } - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = IdeaComposeIcons.InsertDriveFile, - contentDescription = null, - tint = JewelTheme.globalColors.text.normal, - modifier = Modifier.size(16.dp) + if (files.size > 10) { + passiveItem { + Text( + "... and ${files.size - 10} more", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + ) ) - Text("Files", style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp)) } } } - } - // Folders submenu - if (folders.isNotEmpty() || searchQuery.length >= 2) { - submenu( - submenu = { - if (folders.isEmpty()) { - passiveItem { - Text( - "No folders found", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 13.sp, - color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) - ) - ) - } - } else { - folders.take(10).forEach { folder -> - selectableItem( - selected = false, - onClick = { - onFilesSelected(listOf(folder.virtualFile)) - onDismiss() - } - ) { - FolderMenuItem(folder) - } + // Folders from search + if (folders.isNotEmpty()) { + if (files.isNotEmpty()) separator() + passiveItem { + Text( + "Folders (${folders.size})", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) + ) + } + folders.take(5).forEach { folder -> + selectableItem( + selected = false, + onClick = { + onFilesSelected(listOf(folder.virtualFile)) + onDismiss() } + ) { + FolderMenuItem(folder) } } - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = IdeaComposeIcons.Folder, - contentDescription = null, - tint = JewelTheme.globalColors.text.normal, - modifier = Modifier.size(16.dp) + } + + // No results message + if (files.isEmpty() && folders.isEmpty()) { + passiveItem { + Text( + "No files or folders found", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 13.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + ) ) - Text("Folders", style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp)) } } - } - - // Recently Opened Files submenu - submenu( - submenu = { - if (recentFiles.isEmpty()) { - passiveItem { + } else { + // Show recent files when not searching + if (filteredRecentFiles.isNotEmpty()) { + passiveItem { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = IdeaComposeIcons.History, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f), + modifier = Modifier.size(14.dp) + ) Text( - "No recent files", + "Recent Files", style = JewelTheme.defaultTextStyle.copy( - fontSize = 13.sp, - color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) ) ) } - } else { - recentFiles.take(15).forEach { file -> - selectableItem( - selected = false, - onClick = { - onFilesSelected(listOf(file.virtualFile)) - onDismiss() - } - ) { - FileMenuItem(file) + } + filteredRecentFiles.take(15).forEach { file -> + selectableItem( + selected = false, + onClick = { + onFilesSelected(listOf(file.virtualFile)) + onDismiss() } + ) { + FileMenuItem(file) } } + } else { + passiveItem { + Text( + "No recent files. Type to search...", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 13.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + ) + ) + } } - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = IdeaComposeIcons.History, - contentDescription = null, - tint = JewelTheme.globalColors.text.normal, - modifier = Modifier.size(16.dp) - ) - Text("Recently Opened Files", style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp)) - } - } - - separator() - - // Clear Context action - selectableItem( - selected = false, - onClick = { - onFilesSelected(emptyList()) - onDismiss() - } - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = IdeaComposeIcons.Close, - contentDescription = null, - tint = JewelTheme.globalColors.text.normal, - modifier = Modifier.size(16.dp) - ) - Text("Clear Context", style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp)) - } - } - - separator() - - // Search field at bottom - passiveItem { - TextField( - state = searchQueryState, - placeholder = { Text("Focus context") }, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) - ) } } } +/** + * File menu item with improved layout: + * - Icon on the left + * - Bold file name + * - Truncated path in gray (e.g., "...cc/unitmesh/devins/idea/editor") + * - History icon for recent files + */ @Composable private fun FileMenuItem(file: IdeaFilePresentation) { Row( @@ -274,25 +276,41 @@ private fun FileMenuItem(file: IdeaFilePresentation) { horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { + // File icon or history icon for recent files Icon( - imageVector = IdeaComposeIcons.InsertDriveFile, + imageVector = if (file.isRecentFile) IdeaComposeIcons.History else IdeaComposeIcons.InsertDriveFile, contentDescription = null, tint = JewelTheme.globalColors.text.normal, modifier = Modifier.size(16.dp) ) - Column(modifier = Modifier.weight(1f)) { + + // File name (bold) and truncated path (gray) in a row + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Bold file name Text( text = file.name, - style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp), - maxLines = 1 + style = JewelTheme.defaultTextStyle.copy( + fontSize = 13.sp, + fontWeight = FontWeight.Medium + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis ) + + // Truncated path in gray Text( - text = file.presentablePath, + text = file.truncatedPath, style = JewelTheme.defaultTextStyle.copy( fontSize = 11.sp, - color = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) ), - maxLines = 1 + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) ) } } @@ -311,11 +329,34 @@ private fun FolderMenuItem(folder: IdeaFilePresentation) { tint = JewelTheme.globalColors.text.normal, modifier = Modifier.size(16.dp) ) - Text( - text = folder.presentablePath, - style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp), - maxLines = 1 - ) + + // Folder name (bold) and truncated path + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = folder.name, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 13.sp, + fontWeight = FontWeight.Medium + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = folder.truncatedPath, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + } } } @@ -339,6 +380,27 @@ data class IdeaFilePresentation( val isRecentFile: Boolean = false, val isDirectory: Boolean = false ) { + /** + * 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 = presentablePath.substringBeforeLast("/", "") + if (parentPath.isEmpty()) return "" + + // If path is short enough, show it as-is + if (parentPath.length <= 40) return parentPath + + // Truncate from the beginning with "..." + val parts = parentPath.split("/") + if (parts.size <= 2) return "...$parentPath" + + // Keep the last 3-4 parts of the path + val keepParts = parts.takeLast(4) + return "...${keepParts.joinToString("/")}" + } + companion object { fun from(project: Project, file: VirtualFile, isRecent: Boolean = false): IdeaFilePresentation { val basePath = project.basePath ?: "" @@ -364,14 +426,17 @@ private fun loadRecentFiles(project: Project): List { val recentFiles = mutableListOf() try { - val fileList = EditorHistoryManager.getInstance(project).fileList - fileList.take(30) - .filter { it.isValid && !it.isDirectory && canBeAdded(project, it) } - .forEach { file -> - recentFiles.add(IdeaFilePresentation.from(project, file, isRecent = true)) - } + ApplicationManager.getApplication().runReadAction { + val fileList = EditorHistoryManager.getInstance(project).fileList + fileList.take(30) + .filter { it.isValid && !it.isDirectory && canBeAdded(project, it) } + .forEach { file -> + recentFiles.add(IdeaFilePresentation.from(project, file, isRecent = true)) + } + } } catch (e: Exception) { - // Ignore errors loading recent files + com.intellij.openapi.diagnostic.Logger.getInstance("IdeaFileSearchPopup") + .warn("Error loading recent files: ${e.message}", e) } return recentFiles @@ -385,7 +450,9 @@ private fun searchAllItems(project: Project, query: String): SearchResults { try { ApplicationManager.getApplication().runReadAction { - // Search files by name + val fileIndex = ProjectFileIndex.getInstance(project) + + // Search files by exact name match using FilenameIndex FilenameIndex.processFilesByName(query, false, scope) { file -> if (file.isDirectory) { if (folders.size < 20) { @@ -394,22 +461,31 @@ private fun searchAllItems(project: Project, query: String): SearchResults { } else if (canBeAdded(project, file) && files.size < 50) { files.add(IdeaFilePresentation.from(project, file)) } - files.size < 50 || folders.size < 20 + files.size < 50 && folders.size < 20 } - // Also search for folders containing the query - val fileIndex = ProjectFileIndex.getInstance(project) + // Also do fuzzy search by iterating project content + val existingFilePaths = files.map { it.path }.toSet() + val existingFolderPaths = folders.map { it.path }.toSet() + fileIndex.iterateContent { file -> - if (file.isDirectory && file.name.lowercase().contains(lowerQuery)) { - if (folders.size < 20 && !folders.any { it.path == file.path }) { - folders.add(IdeaFilePresentation.from(project, file)) + val nameLower = file.name.lowercase() + if (nameLower.contains(lowerQuery)) { + if (file.isDirectory) { + if (folders.size < 20 && file.path !in existingFolderPaths) { + folders.add(IdeaFilePresentation.from(project, file)) + } + } else if (canBeAdded(project, file) && files.size < 50 && file.path !in existingFilePaths) { + files.add(IdeaFilePresentation.from(project, file)) } } - folders.size < 20 + files.size < 50 && folders.size < 20 } } } catch (e: Exception) { - // Ignore search errors + // Log error for debugging + com.intellij.openapi.diagnostic.Logger.getInstance("IdeaFileSearchPopup") + .warn("Error searching files: ${e.message}", e) } // Filter recent files by query diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt index f9ea0fb258..ae7f4d1591 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.* 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.idea.toolwindow.IdeaComposeIcons @@ -26,7 +27,12 @@ import org.jetbrains.jewel.ui.component.Tooltip * Top toolbar for the input section. * Contains @ trigger, file selection, and other context-related actions. * - * Layout: @ - / - Clipboard - Save - Cursor | Selected Files... | Add + * Layout: Add Button | Selected Files... | Context indicator + * + * Features: + * - Integrates with IdeaContextManager for state management + * - Shows selected files as chips with remove button on hover + * - Shows context indicator when default context or rules are active */ @Composable fun IdeaTopToolbar( @@ -41,6 +47,15 @@ fun IdeaTopToolbar( ) { var showFileSearchPopup by remember { mutableStateOf(false) } + // Get context manager state if project is available + val contextManager = remember(project) { project?.let { IdeaContextManager.getInstance(it) } } + val hasDefaultContext by contextManager?.defaultContextFiles?.collectAsState() + ?: remember { mutableStateOf(emptyList()) } + val rules by contextManager?.rules?.collectAsState() + ?: remember { mutableStateOf(emptyList()) } + val relatedFiles by contextManager?.relatedFiles?.collectAsState() + ?: remember { mutableStateOf(emptyList()) } + Row( modifier = modifier .fillMaxWidth() @@ -48,54 +63,128 @@ fun IdeaTopToolbar( horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically ) { - // Left side: Action buttons + // Left side: Add button with popup Row( horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically ) { - ToolbarIconButton( - onClick = { - if (project != null) { - showFileSearchPopup = true + // File search popup with trigger button + if (project != null) { + IdeaFileSearchPopup( + project = project, + showPopup = showFileSearchPopup, + onShowPopupChange = { showFileSearchPopup = it }, + onFilesSelected = { files -> + onFilesSelected(files) + showFileSearchPopup = false } - onAddFileClick() - }, - tooltip = "Add File" - ) { - Icon( - imageVector = IdeaComposeIcons.Add, - contentDescription = "Add File", - modifier = Modifier.size(16.dp), - tint = JewelTheme.globalColors.text.normal + ) + } else { + ToolbarIconButton( + onClick = { onAddFileClick() }, + tooltip = "Add File to Context" + ) { + Icon( + imageVector = IdeaComposeIcons.Add, + contentDescription = "Add File", + modifier = Modifier.size(16.dp), + tint = JewelTheme.globalColors.text.normal + ) + } + } + + // Context indicator: show if default context or rules are active + if (hasDefaultContext.isNotEmpty() || rules.isNotEmpty()) { + ContextIndicator( + hasDefaultContext = hasDefaultContext.isNotEmpty(), + rulesCount = rules.size ) } } - if (selectedFiles.isNotEmpty()) { + if (selectedFiles.isNotEmpty() || relatedFiles.isNotEmpty()) { Box(Modifier.width(1.dp).height(20.dp).background(JewelTheme.globalColors.borders.normal)) } + // Selected files as chips Row( modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { - selectedFiles.forEach { file -> + // Show selected files + selectedFiles.take(5).forEach { file -> FileChip(file = file, onRemove = { onRemoveFile(file) }) } + + // Show overflow indicator if more than 5 files + if (selectedFiles.size > 5) { + Text( + text = "+${selectedFiles.size - 5}", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) + ) + ) + } } } +} + +/** + * Context indicator showing active default context or rules + */ +@Composable +private fun ContextIndicator( + hasDefaultContext: Boolean, + rulesCount: Int +) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + + val tooltipText = buildString { + if (hasDefaultContext) append("Default context active") + if (hasDefaultContext && rulesCount > 0) append(" | ") + if (rulesCount > 0) append("$rulesCount rule(s) active") + } - // File search popup - if (showFileSearchPopup && project != null) { - IdeaFileSearchPopup( - project = project, - onDismiss = { showFileSearchPopup = false }, - onFilesSelected = { files -> - onFilesSelected(files) - showFileSearchPopup = false + Tooltip(tooltip = { Text(tooltipText) }) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .hoverable(interactionSource = interactionSource) + .background( + if (isHovered) JewelTheme.globalColors.borders.normal.copy(alpha = 0.3f) + else JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f) + ) + .padding(horizontal = 4.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (hasDefaultContext) { + Icon( + imageVector = IdeaComposeIcons.Book, + contentDescription = "Default context", + modifier = Modifier.size(12.dp), + tint = JewelTheme.globalColors.text.info + ) } - ) + if (rulesCount > 0) { + Icon( + imageVector = IdeaComposeIcons.Settings, + contentDescription = "Rules", + modifier = Modifier.size(12.dp), + tint = JewelTheme.globalColors.text.info + ) + Text( + text = rulesCount.toString(), + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = JewelTheme.globalColors.text.info + ) + ) + } + } } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt index a188c29394..3b5608af71 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt @@ -1582,5 +1582,122 @@ object IdeaComposeIcons { }.build() } + /** + * CheckBox icon (checked checkbox) + */ + val CheckBox: ImageVector by lazy { + ImageVector.Builder( + name = "CheckBox", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero + ) { + moveTo(19f, 3f) + horizontalLineTo(5f) + curveToRelative(-1.1f, 0f, -2f, 0.9f, -2f, 2f) + verticalLineToRelative(14f) + curveToRelative(0f, 1.1f, 0.9f, 2f, 2f, 2f) + horizontalLineToRelative(14f) + curveToRelative(1.1f, 0f, 2f, -0.9f, 2f, -2f) + verticalLineTo(5f) + curveToRelative(0f, -1.1f, -0.9f, -2f, -2f, -2f) + close() + moveTo(10f, 17f) + lineToRelative(-5f, -5f) + lineToRelative(1.41f, -1.41f) + lineTo(10f, 14.17f) + lineToRelative(7.59f, -7.59f) + lineTo(19f, 8f) + lineToRelative(-9f, 9f) + close() + } + }.build() + } + + /** + * CheckBoxOutlineBlank icon (unchecked checkbox) + */ + val CheckBoxOutlineBlank: ImageVector by lazy { + ImageVector.Builder( + name = "CheckBoxOutlineBlank", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero + ) { + moveTo(19f, 5f) + verticalLineToRelative(14f) + horizontalLineTo(5f) + verticalLineTo(5f) + horizontalLineToRelative(14f) + moveTo(19f, 3f) + horizontalLineTo(5f) + curveToRelative(-1.1f, 0f, -2f, 0.9f, -2f, 2f) + verticalLineToRelative(14f) + curveToRelative(0f, 1.1f, 0.9f, 2f, 2f, 2f) + horizontalLineToRelative(14f) + curveToRelative(1.1f, 0f, 2f, -0.9f, 2f, -2f) + verticalLineTo(5f) + curveToRelative(0f, -1.1f, -0.9f, -2f, -2f, -2f) + close() + } + }.build() + } + + /** + * Remove icon (minus sign) + */ + val Remove: ImageVector by lazy { + ImageVector.Builder( + name = "Remove", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero + ) { + moveTo(19f, 13f) + horizontalLineTo(5f) + verticalLineToRelative(-2f) + horizontalLineToRelative(14f) + verticalLineToRelative(2f) + close() + } + }.build() + } + } From ea875c7cb78f1a00bb9c4ee4655ce50382cc6f3a Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 21:29:23 +0800 Subject: [PATCH 26/37] fix(mpp-idea): fix folder selection and improve search UI - Add isDirectory field to SelectedFileItem - Use /dir: command for directories, /file: for files - Add Search icon to IdeaComposeIcons - Improve search field UI with icon and better padding - Update placeholder text to 'Search files and folders...' --- .../devins/idea/editor/IdeaFileSearchPopup.kt | 33 +++++++++++++--- .../devins/idea/editor/IdeaTopToolbar.kt | 13 ++++++- .../devins/idea/toolwindow/IdeaAgentApp.kt | 11 +++--- .../idea/toolwindow/IdeaComposeIcons.kt | 39 +++++++++++++++++++ 4 files changed, 83 insertions(+), 13 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt index ec9a98b548..8b1a7c636f 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt @@ -125,13 +125,34 @@ private fun FileSearchPopupContent( horizontalAlignment = Alignment.Start, modifier = Modifier.widthIn(min = 300.dp, max = 480.dp) ) { - // Search field at top + // Search field at top with improved styling passiveItem { - TextField( - state = searchQueryState, - placeholder = { Text("Search files...") }, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.Search, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f), + modifier = Modifier.size(16.dp) + ) + TextField( + state = searchQueryState, + placeholder = { + Text( + "Search files and folders...", + style = JewelTheme.defaultTextStyle.copy( + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + ) + ) + }, + modifier = Modifier.weight(1f) + ) + } } separator() diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt index ae7f4d1591..0867aed1a3 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt @@ -251,6 +251,15 @@ data class SelectedFileItem( val name: String, val path: String, val icon: androidx.compose.ui.graphics.vector.ImageVector? = null, - val virtualFile: com.intellij.openapi.vfs.VirtualFile? = null -) + val virtualFile: com.intellij.openapi.vfs.VirtualFile? = null, + val isDirectory: 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" + } +} diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt index cdbf724294..1f8a2cc42f 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt @@ -349,7 +349,8 @@ private fun IdeaDevInInputArea( SelectedFileItem( name = vf.name, path = vf.path, - virtualFile = vf + virtualFile = vf, + isDirectory = vf.isDirectory ) } selectedFiles = (selectedFiles + newItems).distinctBy { it.path } @@ -376,8 +377,8 @@ private fun IdeaDevInInputArea( override fun onSubmit(text: String, trigger: IdeaInputTrigger) { if (text.isNotBlank() && !isProcessing) { - // Append file references to the message - val filesText = selectedFiles.joinToString("\n") { "/file:${it.path}" } + // Append file references to the message (use /dir: for directories, /file: for files) + val filesText = selectedFiles.joinToString("\n") { it.toDevInsCommand() } val fullText = if (filesText.isNotEmpty()) { "$text\n$filesText" } else { @@ -422,8 +423,8 @@ private fun IdeaDevInInputArea( onSendClick = { val text = devInInput?.text?.trim() ?: inputText.trim() if (text.isNotBlank() && !isProcessing) { - // Append file references to the message - val filesText = selectedFiles.joinToString("\n") { "/file:${it.path}" } + // Append file references to the message (use /dir: for directories, /file: for files) + val filesText = selectedFiles.joinToString("\n") { it.toDevInsCommand() } val fullText = if (filesText.isNotEmpty()) { "$text\n$filesText" } else { diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt index 3b5608af71..730f95be57 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt @@ -1699,5 +1699,44 @@ object IdeaComposeIcons { }.build() } + /** + * Search icon (magnifying glass) + */ + val Search: ImageVector by lazy { + ImageVector.Builder( + name = "Search", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + // Magnifying glass icon + moveTo(15.5f, 14f) + horizontalLineToRelative(-0.79f) + lineToRelative(-0.28f, -0.27f) + curveTo(15.41f, 12.59f, 16f, 11.11f, 16f, 9.5f) + curveTo(16f, 5.91f, 13.09f, 3f, 9.5f, 3f) + reflectiveCurveTo(3f, 5.91f, 3f, 9.5f) + reflectiveCurveTo(5.91f, 16f, 9.5f, 16f) + curveToRelative(1.61f, 0f, 3.09f, -0.59f, 4.23f, -1.57f) + lineToRelative(0.27f, 0.28f) + verticalLineToRelative(0.79f) + lineToRelative(5f, 4.99f) + lineTo(20.49f, 19f) + lineToRelative(-4.99f, -5f) + close() + moveTo(9.5f, 14f) + curveTo(7.01f, 14f, 5f, 11.99f, 5f, 9.5f) + reflectiveCurveTo(7.01f, 5f, 9.5f, 5f) + reflectiveCurveTo(14f, 7.01f, 14f, 9.5f) + reflectiveCurveTo(11.99f, 14f, 9.5f, 14f) + close() + } + }.build() + } + } From 679895b8e0bb9b60c8b8a4533fba0237b2856648 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 21:33:15 +0800 Subject: [PATCH 27/37] fix(devins-lang): replace ClsFileImpl with PsiCompiledFile interface - Use PsiCompiledFile interface instead of ClsFileImpl implementation class - This fixes NoClassDefFoundError in some IDE configurations - Remove unnecessary null check for content --- .../language/compiler/exec/file/FileInsCommand.kt | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/file/FileInsCommand.kt b/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/file/FileInsCommand.kt index f15cf3b37d..c756548053 100644 --- a/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/file/FileInsCommand.kt +++ b/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/compiler/exec/file/FileInsCommand.kt @@ -13,8 +13,8 @@ import cc.unitmesh.devti.util.relativePath import com.intellij.openapi.application.runReadAction import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiCompiledFile import com.intellij.psi.PsiManager -import com.intellij.psi.impl.compiled.ClsFileImpl import com.intellij.psi.search.FilenameIndex import com.intellij.psi.search.GlobalSearchScope import com.intellij.psi.search.PsiShortNamesCache @@ -65,23 +65,19 @@ class FileInsCommand(private val myProject: Project, private val prop: String) : val language = psiFile?.language?.displayName ?: "" val fileContent = when (psiFile) { - is ClsFileImpl -> { - psiFile.text + is PsiCompiledFile -> { + // For compiled files (like .class files), get the decompiled text + psiFile.decompiledPsiFile?.text ?: virtualFile.readText() } else -> { - runReadAction { virtualFile.readText() } + virtualFile.readText() } } Pair(fileContent, language) } - if (content == null) { - AutoDevNotifications.warn(myProject, "Cannot read file: $prop") - return "Cannot read file: $prop" - } - val fileContent = splitLines(range, content) val realPath = virtualFile.relativePath(myProject) From 125394c1ed4385dddcb08f74dd7dbc73ca507749 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 21:34:44 +0800 Subject: [PATCH 28/37] refactor(mpp-idea): move IdeaDevInInputArea to separate file Extract IdeaDevInInputArea composable from IdeaAgentApp.kt into its own file for better modularity and code organization. No functional changes. --- .../devins/idea/toolwindow/IdeaAgentApp.kt | 162 ----------------- .../idea/toolwindow/IdeaDevInInputArea.kt | 167 ++++++++++++++++++ 2 files changed, 167 insertions(+), 162 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt index 1f8a2cc42f..de7c68a21e 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt @@ -5,16 +5,9 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.SwingPanel import androidx.compose.ui.unit.dp import cc.unitmesh.agent.AgentType -import cc.unitmesh.devins.idea.editor.IdeaBottomToolbar -import cc.unitmesh.devins.idea.editor.IdeaDevInInput -import cc.unitmesh.devins.idea.editor.IdeaInputListener -import cc.unitmesh.devins.idea.editor.IdeaInputTrigger import cc.unitmesh.devins.idea.editor.IdeaModelConfigDialogWrapper -import cc.unitmesh.devins.idea.editor.IdeaTopToolbar -import cc.unitmesh.devins.idea.editor.SelectedFileItem import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewContent import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewViewModel import cc.unitmesh.devins.idea.components.header.IdeaAgentTabsHeader @@ -25,23 +18,16 @@ import cc.unitmesh.devins.idea.toolwindow.remote.IdeaRemoteAgentContent import cc.unitmesh.devins.idea.toolwindow.remote.IdeaRemoteAgentViewModel import cc.unitmesh.devins.idea.toolwindow.remote.getEffectiveProjectId import cc.unitmesh.devins.idea.components.status.IdeaToolLoadingStatusBar -import cc.unitmesh.devins.idea.components.timeline.CancelEvent import cc.unitmesh.devins.idea.components.timeline.IdeaEmptyStateMessage import cc.unitmesh.devins.idea.components.timeline.IdeaTimelineContent import cc.unitmesh.devins.ui.config.ConfigManager import cc.unitmesh.llm.ModelConfig import cc.unitmesh.llm.NamedModelConfig -import com.intellij.openapi.Disposable -import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.project.Project -import com.intellij.openapi.util.Disposer import kotlinx.coroutines.CoroutineScope import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.Orientation import org.jetbrains.jewel.ui.component.Divider -import java.awt.BorderLayout -import java.awt.Dimension -import javax.swing.JPanel /** * Main Compose application for Agent ToolWindow. @@ -301,151 +287,3 @@ fun IdeaAgentApp( } } -/** - * Advanced chat input area with full DevIn language support. - * - * Uses IdeaDevInInput (EditorTextField-based) embedded via SwingPanel for: - * - DevIn language syntax highlighting and completion - * - IntelliJ's native completion popup integration - * - Enter to submit, Shift+Enter for newline - * - @ trigger for agent completion - * - Token usage display - * - Settings access - * - Stop/Send button based on execution state - * - Model selector for switching between LLM configurations - */ -@Composable -private fun IdeaDevInInputArea( - project: Project, - parentDisposable: Disposable, - isProcessing: Boolean, - onSend: (String) -> Unit, - onAbort: () -> Unit, - workspacePath: String? = null, - totalTokens: Int? = null, - onAtClick: () -> Unit = {}, - availableConfigs: List = emptyList(), - currentConfigName: String? = null, - onConfigSelect: (NamedModelConfig) -> Unit = {}, - onConfigureClick: () -> Unit = {} -) { - var inputText by remember { mutableStateOf("") } - var devInInput by remember { mutableStateOf(null) } - var selectedFiles by remember { mutableStateOf>(emptyList()) } - - Column( - modifier = Modifier.fillMaxSize().padding(8.dp) - ) { - // Top toolbar with file selection - IdeaTopToolbar( - project = project, - onAtClick = onAtClick, - selectedFiles = selectedFiles, - onRemoveFile = { file -> - selectedFiles = selectedFiles.filter { it.path != file.path } - }, - onFilesSelected = { files -> - val newItems = files.map { vf -> - SelectedFileItem( - name = vf.name, - path = vf.path, - virtualFile = vf, - isDirectory = vf.isDirectory - ) - } - selectedFiles = (selectedFiles + newItems).distinctBy { it.path } - } - ) - - // DevIn Editor via SwingPanel - uses weight(1f) to fill available space - SwingPanel( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - factory = { - val input = IdeaDevInInput( - project = project, - disposable = parentDisposable, - showAgent = true - ).apply { - recreateDocument() - - addInputListener(object : IdeaInputListener { - override fun editorAdded(editor: EditorEx) { - // Editor is ready - } - - override fun onSubmit(text: String, trigger: IdeaInputTrigger) { - if (text.isNotBlank() && !isProcessing) { - // Append file references to the message (use /dir: for directories, /file: for files) - val filesText = selectedFiles.joinToString("\n") { it.toDevInsCommand() } - val fullText = if (filesText.isNotEmpty()) { - "$text\n$filesText" - } else { - text - } - onSend(fullText) - clearInput() - inputText = "" - // Clear selected files after sending - selectedFiles = emptyList() - } - } - - override fun onStop() { - onAbort() - } - - override fun onTextChanged(text: String) { - inputText = text - } - }) - } - - // Register for disposal - Disposer.register(parentDisposable, input) - devInInput = input - - // Wrap in a JPanel to handle dynamic sizing - JPanel(BorderLayout()).apply { - add(input, BorderLayout.CENTER) - // Don't set fixed preferredSize - let it fill available space - minimumSize = Dimension(200, 60) - } - }, - update = { panel -> - // Update panel if needed - } - ) - - // Bottom toolbar with Compose (MCP config is handled internally) - IdeaBottomToolbar( - onSendClick = { - val text = devInInput?.text?.trim() ?: inputText.trim() - if (text.isNotBlank() && !isProcessing) { - // Append file references to the message (use /dir: for directories, /file: for files) - val filesText = selectedFiles.joinToString("\n") { it.toDevInsCommand() } - val fullText = if (filesText.isNotEmpty()) { - "$text\n$filesText" - } else { - text - } - onSend(fullText) - devInInput?.clearInput() - inputText = "" - // Clear selected files after sending - selectedFiles = emptyList() - } - }, - sendEnabled = inputText.isNotBlank() && !isProcessing, - isExecuting = isProcessing, - onStopClick = onAbort, - totalTokens = totalTokens, - availableConfigs = availableConfigs, - currentConfigName = currentConfigName, - onConfigSelect = onConfigSelect, - onConfigureClick = onConfigureClick - ) - } -} - diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt new file mode 100644 index 0000000000..be171bba68 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt @@ -0,0 +1,167 @@ +package cc.unitmesh.devins.idea.toolwindow + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.SwingPanel +import androidx.compose.ui.unit.dp +import cc.unitmesh.devins.idea.editor.* +import cc.unitmesh.llm.NamedModelConfig +import com.intellij.openapi.Disposable +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import java.awt.BorderLayout +import java.awt.Dimension +import javax.swing.JPanel + +/** + * Advanced chat input area with full DevIn language support. + * + * Uses IdeaDevInInput (EditorTextField-based) embedded via SwingPanel for: + * - DevIn language syntax highlighting and completion + * - IntelliJ's native completion popup integration + * - Enter to submit, Shift+Enter for newline + * - @ trigger for agent completion + * - Token usage display + * - Settings access + * - Stop/Send button based on execution state + * - Model selector for switching between LLM configurations + */ +@Composable +fun IdeaDevInInputArea( + project: Project, + parentDisposable: Disposable, + isProcessing: Boolean, + onSend: (String) -> Unit, + onAbort: () -> Unit, + workspacePath: String? = null, + totalTokens: Int? = null, + onAtClick: () -> Unit = {}, + availableConfigs: List = emptyList(), + currentConfigName: String? = null, + onConfigSelect: (NamedModelConfig) -> Unit = {}, + onConfigureClick: () -> Unit = {} +) { + var inputText by remember { mutableStateOf("") } + var devInInput by remember { mutableStateOf(null) } + var selectedFiles by remember { mutableStateOf>(emptyList()) } + + Column( + modifier = Modifier.Companion.fillMaxSize().padding(8.dp) + ) { + // Top toolbar with file selection + IdeaTopToolbar( + project = project, + onAtClick = onAtClick, + selectedFiles = selectedFiles, + onRemoveFile = { file -> + selectedFiles = selectedFiles.filter { it.path != file.path } + }, + onFilesSelected = { files -> + val newItems = files.map { vf -> + SelectedFileItem( + name = vf.name, + path = vf.path, + virtualFile = vf, + isDirectory = vf.isDirectory + ) + } + selectedFiles = (selectedFiles + newItems).distinctBy { it.path } + } + ) + + // DevIn Editor via SwingPanel - uses weight(1f) to fill available space + SwingPanel( + modifier = Modifier.Companion + .fillMaxWidth() + .weight(1f), + factory = { + val input = IdeaDevInInput( + project = project, + disposable = parentDisposable, + showAgent = true + ).apply { + recreateDocument() + + addInputListener(object : IdeaInputListener { + override fun editorAdded(editor: EditorEx) { + // Editor is ready + } + + override fun onSubmit(text: String, trigger: IdeaInputTrigger) { + if (text.isNotBlank() && !isProcessing) { + // Append file references to the message (use /dir: for directories, /file: for files) + val filesText = selectedFiles.joinToString("\n") { it.toDevInsCommand() } + val fullText = if (filesText.isNotEmpty()) { + "$text\n$filesText" + } else { + text + } + onSend(fullText) + clearInput() + inputText = "" + // Clear selected files after sending + selectedFiles = emptyList() + } + } + + override fun onStop() { + onAbort() + } + + override fun onTextChanged(text: String) { + inputText = text + } + }) + } + + // Register for disposal + Disposer.register(parentDisposable, input) + devInInput = input + + // Wrap in a JPanel to handle dynamic sizing + JPanel(BorderLayout()).apply { + add(input, BorderLayout.CENTER) + // Don't set fixed preferredSize - let it fill available space + minimumSize = Dimension(200, 60) + } + }, + update = { panel -> + // Update panel if needed + } + ) + + // Bottom toolbar with Compose (MCP config is handled internally) + IdeaBottomToolbar( + onSendClick = { + val text = devInInput?.text?.trim() ?: inputText.trim() + if (text.isNotBlank() && !isProcessing) { + // Append file references to the message (use /dir: for directories, /file: for files) + val filesText = selectedFiles.joinToString("\n") { it.toDevInsCommand() } + val fullText = if (filesText.isNotEmpty()) { + "$text\n$filesText" + } else { + text + } + onSend(fullText) + devInInput?.clearInput() + inputText = "" + // Clear selected files after sending + selectedFiles = emptyList() + } + }, + sendEnabled = inputText.isNotBlank() && !isProcessing, + isExecuting = isProcessing, + onStopClick = onAbort, + totalTokens = totalTokens, + availableConfigs = availableConfigs, + currentConfigName = currentConfigName, + onConfigSelect = onConfigSelect, + onConfigureClick = onConfigureClick + ) + } +} \ No newline at end of file From 9d117d23ed0ee8dbba73d1c0827791b24aa38371 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 21:41:48 +0800 Subject: [PATCH 29/37] feat(mpp-idea): redesign IdeaDevInInputArea layout with unified border - Add unified border around IdeaDevInInputArea for cohesive look - IdeaTopToolbar now supports horizontal scroll in collapsed mode - Add expand/collapse button to toggle between horizontal and vertical file list - FileChipExpanded shows full path in expanded mode - Use animateContentSize for smooth expand/collapse animation - Remove duplicate ExpandLess/ExpandMore icon definitions --- .../devins/idea/editor/IdeaTopToolbar.kt | 207 +++++++++++++----- .../idea/toolwindow/IdeaDevInInputArea.kt | 25 ++- 2 files changed, 175 insertions(+), 57 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt index 0867aed1a3..08206617f5 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt @@ -1,12 +1,15 @@ package cc.unitmesh.devins.idea.editor +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll 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.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -27,11 +30,14 @@ import org.jetbrains.jewel.ui.component.Tooltip * Top toolbar for the input section. * Contains @ trigger, file selection, and other context-related actions. * - * Layout: Add Button | Selected Files... | Context indicator + * Layout: + * - Collapsed mode: Add Button | [Horizontal scrollable file chips] | Expand button + * - Expanded mode: Add Button | [Vertical list of all files] | Collapse button * * Features: * - Integrates with IdeaContextManager for state management * - Shows selected files as chips with remove button on hover + * - Horizontal scroll in collapsed mode, vertical list in expanded mode * - Shows context indicator when default context or rules are active */ @Composable @@ -46,6 +52,7 @@ fun IdeaTopToolbar( modifier: Modifier = Modifier ) { var showFileSearchPopup by remember { mutableStateOf(false) } + var isExpanded by remember { mutableStateOf(false) } // Get context manager state if project is available val contextManager = remember(project) { project?.let { IdeaContextManager.getInstance(it) } } @@ -56,76 +63,115 @@ fun IdeaTopToolbar( val relatedFiles by contextManager?.relatedFiles?.collectAsState() ?: remember { mutableStateOf(emptyList()) } - Row( + Column( modifier = modifier .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalAlignment = Alignment.CenterVertically + .animateContentSize() ) { - // Left side: Add button with popup + // Main toolbar row Row( - horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { - // File search popup with trigger button - if (project != null) { - IdeaFileSearchPopup( - project = project, - showPopup = showFileSearchPopup, - onShowPopupChange = { showFileSearchPopup = it }, - onFilesSelected = { files -> - onFilesSelected(files) - showFileSearchPopup = false + // Left side: Add button with popup + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // File search popup with trigger button + if (project != null) { + IdeaFileSearchPopup( + project = project, + showPopup = showFileSearchPopup, + onShowPopupChange = { showFileSearchPopup = it }, + onFilesSelected = { files -> + onFilesSelected(files) + showFileSearchPopup = false + } + ) + } else { + ToolbarIconButton( + onClick = { onAddFileClick() }, + tooltip = "Add File to Context" + ) { + Icon( + imageVector = IdeaComposeIcons.Add, + contentDescription = "Add File", + modifier = Modifier.size(16.dp), + tint = JewelTheme.globalColors.text.normal + ) } + } + + // Context indicator: show if default context or rules are active + if (hasDefaultContext.isNotEmpty() || rules.isNotEmpty()) { + ContextIndicator( + hasDefaultContext = hasDefaultContext.isNotEmpty(), + rulesCount = rules.size + ) + } + } + + // Separator + if (selectedFiles.isNotEmpty() || relatedFiles.isNotEmpty()) { + Box( + Modifier + .width(1.dp) + .height(20.dp) + .background(JewelTheme.globalColors.borders.normal.copy(alpha = 0.5f)) ) + } + + // Selected files - horizontal scrollable in collapsed mode + if (!isExpanded && selectedFiles.isNotEmpty()) { + val scrollState = rememberScrollState() + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(scrollState), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + selectedFiles.forEach { file -> + FileChip(file = file, onRemove = { onRemoveFile(file) }) + } + } + } else if (!isExpanded) { + Spacer(Modifier.weight(1f)) } else { + Spacer(Modifier.weight(1f)) + } + + // Expand/Collapse button - only show if there are files + if (selectedFiles.size > 1) { ToolbarIconButton( - onClick = { onAddFileClick() }, - tooltip = "Add File to Context" + onClick = { isExpanded = !isExpanded }, + tooltip = if (isExpanded) "Collapse file list" else "Expand file list" ) { Icon( - imageVector = IdeaComposeIcons.Add, - contentDescription = "Add File", + imageVector = if (isExpanded) IdeaComposeIcons.ExpandLess else IdeaComposeIcons.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand", modifier = Modifier.size(16.dp), tint = JewelTheme.globalColors.text.normal ) } } - - // Context indicator: show if default context or rules are active - if (hasDefaultContext.isNotEmpty() || rules.isNotEmpty()) { - ContextIndicator( - hasDefaultContext = hasDefaultContext.isNotEmpty(), - rulesCount = rules.size - ) - } - } - - if (selectedFiles.isNotEmpty() || relatedFiles.isNotEmpty()) { - Box(Modifier.width(1.dp).height(20.dp).background(JewelTheme.globalColors.borders.normal)) } - // Selected files as chips - Row( - modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Show selected files - selectedFiles.take(5).forEach { file -> - FileChip(file = file, onRemove = { onRemoveFile(file) }) - } - - // Show overflow indicator if more than 5 files - if (selectedFiles.size > 5) { - Text( - text = "+${selectedFiles.size - 5}", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 11.sp, - color = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) - ) - ) + // Expanded view - vertical list of all files + if (isExpanded && selectedFiles.isNotEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 32.dp, end = 8.dp, bottom = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + selectedFiles.forEach { file -> + FileChipExpanded(file = file, onRemove = { onRemoveFile(file) }) + } } } } @@ -230,7 +276,7 @@ private fun FileChip(file: SelectedFileItem, onRemove: () -> Unit, modifier: Mod horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Icon( - imageVector = file.icon ?: IdeaComposeIcons.InsertDriveFile, + imageVector = if (file.isDirectory) IdeaComposeIcons.Folder else (file.icon ?: IdeaComposeIcons.InsertDriveFile), contentDescription = null, modifier = Modifier.size(14.dp), tint = JewelTheme.globalColors.text.normal @@ -247,6 +293,61 @@ private fun FileChip(file: SelectedFileItem, onRemove: () -> Unit, modifier: Mod } } +/** + * Expanded file chip showing full path - used in vertical expanded mode + */ +@Composable +private 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) JewelTheme.globalColors.panelBackground + else JewelTheme.globalColors.panelBackground.copy(alpha = 0.6f) + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = if (file.isDirectory) IdeaComposeIcons.Folder else (file.icon ?: IdeaComposeIcons.InsertDriveFile), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = JewelTheme.globalColors.text.normal + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = file.name, + style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = file.path, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Icon( + imageVector = IdeaComposeIcons.Close, + contentDescription = "Remove", + modifier = Modifier + .size(16.dp) + .clickable(onClick = onRemove), + tint = if (isHovered) JewelTheme.globalColors.text.normal else JewelTheme.globalColors.text.normal.copy(alpha = 0.4f) + ) + } +} + data class SelectedFileItem( val name: String, val path: String, diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt index be171bba68..197b8f52fb 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt @@ -1,12 +1,15 @@ package cc.unitmesh.devins.idea.toolwindow +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.awt.SwingPanel +import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import cc.unitmesh.devins.idea.editor.* import cc.unitmesh.llm.NamedModelConfig @@ -14,6 +17,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer +import org.jetbrains.jewel.foundation.theme.JewelTheme import java.awt.BorderLayout import java.awt.Dimension import javax.swing.JPanel @@ -30,6 +34,8 @@ import javax.swing.JPanel * - Settings access * - Stop/Send button based on execution state * - Model selector for switching between LLM configurations + * + * Layout: Unified border around the entire input area for a cohesive look. */ @Composable fun IdeaDevInInputArea( @@ -50,10 +56,21 @@ fun IdeaDevInInputArea( var devInInput by remember { mutableStateOf(null) } var selectedFiles by remember { mutableStateOf>(emptyList()) } + val borderShape = RoundedCornerShape(8.dp) + + // Outer container with unified border Column( - modifier = Modifier.Companion.fillMaxSize().padding(8.dp) + modifier = Modifier + .fillMaxSize() + .padding(8.dp) + .clip(borderShape) + .border( + width = 1.dp, + color = JewelTheme.globalColors.borders.normal, + shape = borderShape + ) ) { - // Top toolbar with file selection + // Top toolbar with file selection (no individual border) IdeaTopToolbar( project = project, onAtClick = onAtClick, @@ -76,7 +93,7 @@ fun IdeaDevInInputArea( // DevIn Editor via SwingPanel - uses weight(1f) to fill available space SwingPanel( - modifier = Modifier.Companion + modifier = Modifier .fillMaxWidth() .weight(1f), factory = { @@ -135,7 +152,7 @@ fun IdeaDevInInputArea( } ) - // Bottom toolbar with Compose (MCP config is handled internally) + // Bottom toolbar with Compose (no individual border) IdeaBottomToolbar( onSendClick = { val text = devInInput?.text?.trim() ?: inputText.trim() From f4b1e5777984fe7aec6f24968c53745306b7cbcb Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 21:58:59 +0800 Subject: [PATCH 30/37] feat(mpp-idea): improve MCP config dialog and implement prompt enhancement 1. Restyle IdeaMcpConfigDialog to match IdeaModelConfigDialog pattern: - Use styled Box container with rounded corners and proper background - Add proper tab selector with visual feedback - Improve spacing and visual hierarchy - Add Escape key handling for dialog dismissal - Extract IdeaMcpConfigDialogContent for reusability 2. Implement prompt enhancement feature: - Create IdeaPromptEnhancer service for AI-powered prompt optimization - Load domain dictionary from project's .autodev/domain.csv - Load README for project context - Use LLM to enhance prompts with domain-specific vocabulary - Add loading state indicator on enhancement button - Integrate with IdeaBottomToolbar and IdeaDevInInputArea --- .../devins/idea/editor/IdeaBottomToolbar.kt | 34 ++-- .../devins/idea/editor/IdeaMcpConfigDialog.kt | 174 +++++++++++++----- .../devins/idea/editor/IdeaPromptEnhancer.kt | 152 +++++++++++++++ .../idea/toolwindow/IdeaDevInInputArea.kt | 25 +++ 4 files changed, 321 insertions(+), 64 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt index b6118271d3..0afb57656c 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt @@ -1,18 +1,16 @@ package cc.unitmesh.devins.idea.editor -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.compose.theme.AutoDevColors import cc.unitmesh.llm.NamedModelConfig +import com.intellij.openapi.project.Project +import kotlinx.coroutines.launch import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.* import org.jetbrains.jewel.ui.component.Icon @@ -29,11 +27,13 @@ import org.jetbrains.jewel.ui.component.Icon */ @Composable fun IdeaBottomToolbar( + project: Project? = null, onSendClick: () -> Unit, sendEnabled: Boolean, isExecuting: Boolean = false, onStopClick: () -> Unit = {}, onPromptOptimizationClick: () -> Unit = {}, + isEnhancing: Boolean = false, totalTokens: Int? = null, // Model selector props availableConfigs: List = emptyList(), @@ -95,16 +95,22 @@ fun IdeaBottomToolbar( } // Prompt Optimization button - IconButton( - onClick = onPromptOptimizationClick, - modifier = Modifier.size(32.dp) - ) { - Icon( - imageVector = IdeaComposeIcons.AutoAwesome, - contentDescription = "Prompt Optimization", - tint = JewelTheme.globalColors.text.normal, - modifier = Modifier.size(16.dp) - ) + Tooltip({ + Text(if (isEnhancing) "Enhancing prompt..." else "Enhance prompt with AI") + }) { + IconButton( + onClick = onPromptOptimizationClick, + enabled = !isEnhancing && !isExecuting, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.AutoAwesome, + contentDescription = "Prompt Optimization", + tint = if (isEnhancing) JewelTheme.globalColors.text.info + else JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + } } // Send or Stop button diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt index 345a885664..ca18877ced 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt @@ -1,18 +1,29 @@ package cc.unitmesh.devins.idea.editor +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import kotlinx.coroutines.flow.distinctUntilChanged import cc.unitmesh.agent.config.McpLoadingState import cc.unitmesh.agent.config.McpLoadingStateCallback @@ -21,10 +32,12 @@ import cc.unitmesh.agent.config.McpToolConfigManager import cc.unitmesh.agent.config.ToolConfigFile import cc.unitmesh.agent.config.ToolItem import cc.unitmesh.agent.mcp.McpServerConfig +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.config.ConfigManager import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.* // JSON serialization helpers @@ -52,18 +65,34 @@ private fun deserializeMcpConfig(jsonString: String): Result Unit +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + IdeaMcpConfigDialogContent(onDismiss = onDismiss) + } +} + +/** + * Content for the MCP configuration dialog. + * Extracted to be used both in Compose Dialog and DialogWrapper. + */ +@Composable +fun IdeaMcpConfigDialogContent( + onDismiss: () -> Unit ) { var toolConfig by remember { mutableStateOf(ToolConfigFile.default()) } var mcpTools by remember { mutableStateOf>>(emptyMap()) } @@ -84,7 +113,7 @@ fun IdeaMcpConfigDialog( hasUnsavedChanges = true autoSaveJob?.cancel() autoSaveJob = scope.launch { - kotlinx.coroutines.delay(2000) // Wait 2 seconds before auto-saving + kotlinx.coroutines.delay(2000) try { val enabledMcpTools = mcpTools.values .flatten() @@ -102,10 +131,9 @@ fun IdeaMcpConfigDialog( ConfigManager.saveToolConfig(updatedConfig) toolConfig = updatedConfig hasUnsavedChanges = false - println("✅ Auto-saved tool configuration") } } catch (e: Exception) { - println("❌ Auto-save failed: ${e.message}") + // Silent fail for auto-save } } } @@ -119,12 +147,9 @@ fun IdeaMcpConfigDialog( if (toolConfig.mcpServers.isNotEmpty()) { scope.launch { - // Create callback for incremental loading val callback = object : McpLoadingStateCallback { override fun onServerStateChanged(serverName: String, state: McpServerState) { mcpLoadingState = mcpLoadingState.updateServerState(serverName, state) - - // Update tools when server is loaded if (state.isLoaded) { mcpTools = mcpTools + (serverName to state.tools) } @@ -140,7 +165,6 @@ fun IdeaMcpConfigDialog( } try { - // Use incremental loading mcpLoadingState = McpToolConfigManager.discoverMcpToolsIncremental( toolConfig.mcpServers, toolConfig.enabledMcpTools.toSet(), @@ -149,35 +173,41 @@ fun IdeaMcpConfigDialog( mcpLoadError = null } catch (e: Exception) { mcpLoadError = "Failed to load MCP tools: ${e.message}" - println("❌ Error loading MCP tools: ${e.message}") } } } isLoading = false } catch (e: Exception) { - println("Error loading tool config: ${e.message}") mcpLoadError = "Failed to load configuration: ${e.message}" isLoading = false } } } - // Cancel auto-save job on dispose DisposableEffect(Unit) { onDispose { autoSaveJob?.cancel() } } - Dialog(onDismissRequest = onDismiss) { + Box( + modifier = Modifier + .width(600.dp) + .heightIn(max = 700.dp) + .clip(RoundedCornerShape(12.dp)) + .background(JewelTheme.globalColors.panelBackground) + .onKeyEvent { event -> + if (event.key == Key.Escape) { + onDismiss() + true + } else false + } + ) { Column( - modifier = Modifier - .width(800.dp) - .height(600.dp) - .padding(16.dp) + modifier = Modifier.padding(24.dp) ) { - // Header + // Title Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -187,55 +217,69 @@ fun IdeaMcpConfigDialog( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text("Tool Configuration") + Text( + text = "MCP Configuration", + style = JewelTheme.defaultTextStyle.copy(fontSize = 18.sp) + ) if (hasUnsavedChanges) { - Text("(Auto-saving...)", color = org.jetbrains.jewel.foundation.theme.JewelTheme.globalColors.text.info) + Text( + text = "(Saving...)", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = JewelTheme.globalColors.text.info + ) + ) } } - IconButton(onClick = onDismiss) { - Text("×") - } } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(16.dp)) if (isLoading) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center ) { - Text("Loading...") + Text("Loading configuration...") } } else { - // Tab Row + // Tab selector Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - DefaultButton( - onClick = { selectedTab = 0 }, - enabled = selectedTab != 0 - ) { - Text("Tools") - } - DefaultButton( - onClick = { selectedTab = 1 }, - enabled = selectedTab != 1 - ) { - Text("MCP Servers") - } + McpTabButton( + text = "Tools", + selected = selectedTab == 0, + onClick = { selectedTab = 0 } + ) + McpTabButton( + text = "MCP Servers", + selected = selectedTab == 1, + onClick = { selectedTab = 1 } + ) } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(16.dp)) // Error message mcpLoadError?.let { error -> - Text(error, color = org.jetbrains.jewel.foundation.theme.JewelTheme.globalColors.text.error) + Text( + text = error, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = JewelTheme.globalColors.text.error + ) + ) Spacer(modifier = Modifier.height(8.dp)) } // Tab content - Box(modifier = Modifier.weight(1f)) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { when (selectedTab) { 0 -> McpToolsTab( mcpTools = mcpTools, @@ -271,7 +315,6 @@ fun IdeaMcpConfigDialog( val newServers = result.getOrThrow() toolConfig = toolConfig.copy(mcpServers = newServers) ConfigManager.saveToolConfig(toolConfig) - // Reload MCP tools try { val callback = object : McpLoadingStateCallback { override fun onServerStateChanged(serverName: String, state: McpServerState) { @@ -304,7 +347,7 @@ fun IdeaMcpConfigDialog( } } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(16.dp)) // Footer Row( @@ -314,12 +357,16 @@ fun IdeaMcpConfigDialog( ) { val enabledMcp = mcpTools.values.flatten().count { it.enabled } val totalMcp = mcpTools.values.flatten().size - Text("MCP Tools: $enabledMcp/$totalMcp enabled") + Text( + text = "MCP Tools: $enabledMcp/$totalMcp enabled", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) + ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = onDismiss) { - Text("Close") - } + OutlinedButton(onClick = onDismiss) { + Text("Close") } } } @@ -327,6 +374,33 @@ fun IdeaMcpConfigDialog( } } +@Composable +private fun McpTabButton( + text: String, + selected: Boolean, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background( + if (selected) JewelTheme.globalColors.borders.normal.copy(alpha = 0.3f) + else androidx.compose.ui.graphics.Color.Transparent + ) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text( + text = text, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 13.sp, + color = if (selected) JewelTheme.globalColors.text.normal + else JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) + ) + } +} + @Composable private fun McpToolsTab( mcpTools: Map>, diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt new file mode 100644 index 0000000000..221ce5da83 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt @@ -0,0 +1,152 @@ +package cc.unitmesh.devins.idea.editor + +import cc.unitmesh.devins.ui.config.ConfigManager +import cc.unitmesh.llm.KoogLLMService +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.components.Service +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.guessProjectDir +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Prompt enhancement service for IntelliJ IDEA. + * + * Enhances user prompts by: + * 1. Loading domain dictionary from project's prompts directory + * 2. Loading README file for project context + * 3. Using LLM to optimize the prompt with domain-specific vocabulary + * + * Based on core/src/main/kotlin/cc/unitmesh/devti/indexer/usage/PromptEnhancer.kt + */ +@Service(Service.Level.PROJECT) +class IdeaPromptEnhancer(private val project: Project) { + + /** + * Enhance the user's prompt using LLM. + * + * @param input The original user prompt + * @return The enhanced prompt, or the original if enhancement fails + */ + suspend fun enhance(input: String): String = withContext(Dispatchers.IO) { + try { + val dict = loadDomainDict() + val readme = loadReadme() + val prompt = buildEnhancePrompt(input, dict, readme) + + val config = ConfigManager.load() + val modelConfig = config.getActiveModelConfig() + ?: return@withContext input + + val llmService = KoogLLMService(modelConfig) + val result = StringBuilder() + + // Use streamPrompt with compileDevIns=false since we're sending a raw prompt + llmService.streamPrompt(prompt, compileDevIns = false).collect { chunk -> + result.append(chunk) + } + + extractEnhancedPrompt(result.toString()) ?: input + } catch (e: Exception) { + // Return original input if enhancement fails + input + } + } + + /** + * Load domain dictionary from project's prompts directory. + * Looks for domain.csv in the team prompts directory. + */ + private fun loadDomainDict(): String { + return try { + runReadAction { + val baseDir = project.guessProjectDir() ?: return@runReadAction "" + val promptsDir = baseDir.findChild(".autodev") ?: baseDir.findChild("prompts") + val dictFile = promptsDir?.findChild("domain.csv") + dictFile?.contentsToByteArray()?.toString(Charsets.UTF_8) ?: "" + } + } catch (e: Exception) { + "" + } + } + + /** + * Load README file from project root. + */ + private fun loadReadme(): String { + return try { + runReadAction { + val baseDir = project.guessProjectDir() ?: return@runReadAction "" + val readmeFile = baseDir.findChild("README.md") + ?: baseDir.findChild("README") + ?: baseDir.findChild("readme.md") + + val content = readmeFile?.contentsToByteArray()?.toString(Charsets.UTF_8) ?: "" + // Limit README content to avoid token overflow + if (content.length > 2000) content.take(2000) + "\n..." else content + } + } catch (e: Exception) { + "" + } + } + + /** + * Build the enhancement prompt. + * Based on core/src/main/resources/genius/en/code/enhance.vm + */ + private fun buildEnhancePrompt(input: String, dict: String, readme: String): String { + return buildString { + appendLine("You are a professional AI prompt optimization expert. Please help me optimize the following prompt and return it in the specified format.") + appendLine() + if (dict.isNotBlank()) { + appendLine("Here is a vocabulary reference provided by the user. Please only consider parts relevant to the user's question.") + appendLine() + appendLine("```csv") + appendLine(dict) + appendLine("```") + appendLine() + } + if (readme.isNotBlank()) { + appendLine("Here is the project's README information:") + appendLine("==========") + appendLine(readme) + appendLine("==========") + appendLine() + } + appendLine("Output format requirements:") + appendLine() + appendLine("- Return the result in a markdown code block for easy parsing") + appendLine("- The improved example should be in the same language as the user's prompt") + appendLine("- The improved example should be consistent with the information described in the user's prompt") + appendLine("- The output should only contain the improved example, without any other content") + appendLine("- Only include the improved example, do not add any other content or overly rich content") + appendLine("- Please do not make extensive associations, just enrich the vocabulary for the user's question") + appendLine() + appendLine("Now, the user's question is: $input") + } + } + + /** + * Extract the enhanced prompt from LLM response. + * Looks for content in markdown code blocks. + */ + private fun extractEnhancedPrompt(response: String): String? { + // Try to extract from markdown code block + val codeBlockRegex = Regex("```(?:\\w+)?\\s*\\n([\\s\\S]*?)\\n```") + val match = codeBlockRegex.find(response) + if (match != null) { + return match.groupValues[1].trim() + } + + // If no code block, return trimmed response if it's not too long + val trimmed = response.trim() + return if (trimmed.length < 500) trimmed else null + } + + companion object { + fun getInstance(project: Project): IdeaPromptEnhancer { + return project.getService(IdeaPromptEnhancer::class.java) + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt index 197b8f52fb..659a47c645 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt @@ -17,6 +17,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer +import kotlinx.coroutines.launch import org.jetbrains.jewel.foundation.theme.JewelTheme import java.awt.BorderLayout import java.awt.Dimension @@ -55,7 +56,9 @@ fun IdeaDevInInputArea( var inputText by remember { mutableStateOf("") } var devInInput by remember { mutableStateOf(null) } var selectedFiles by remember { mutableStateOf>(emptyList()) } + var isEnhancing by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() val borderShape = RoundedCornerShape(8.dp) // Outer container with unified border @@ -154,6 +157,7 @@ fun IdeaDevInInputArea( // Bottom toolbar with Compose (no individual border) IdeaBottomToolbar( + project = project, onSendClick = { val text = devInInput?.text?.trim() ?: inputText.trim() if (text.isNotBlank() && !isProcessing) { @@ -174,6 +178,27 @@ fun IdeaDevInInputArea( sendEnabled = inputText.isNotBlank() && !isProcessing, isExecuting = isProcessing, onStopClick = onAbort, + onPromptOptimizationClick = { + val currentText = devInInput?.text?.trim() ?: inputText.trim() + if (currentText.isNotBlank() && !isEnhancing && !isProcessing) { + isEnhancing = true + scope.launch { + try { + val enhancer = IdeaPromptEnhancer.getInstance(project) + val enhanced = enhancer.enhance(currentText) + if (enhanced != currentText) { + devInInput?.setText(enhanced) + inputText = enhanced + } + } catch (e: Exception) { + // Silently fail - keep original text + } finally { + isEnhancing = false + } + } + } + }, + isEnhancing = isEnhancing, totalTokens = totalTokens, availableConfigs = availableConfigs, currentConfigName = currentConfigName, From b54c313b74ccb58ce0b6b65b07981abce7b2291e Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 22:09:57 +0800 Subject: [PATCH 31/37] fix(mpp-idea): fix prompt enhancement functionality 1. Add replaceText() method to IdeaDevInInput for setting text content - Renamed from setText() to avoid conflict with EditorTextField.setText() - Uses WriteCommandAction for proper document modification 2. Fix prompt enhancement in IdeaDevInInputArea: - Use IntelliJ Logger instead of KLogger (not available in mpp-idea) - Move logger to file-level to avoid Composable context issues - Use Dispatchers.IO for LLM calls - Use ApplicationManager.invokeLater for EDT updates - Add proper logging for debugging 3. Add logging to IdeaPromptEnhancer: - Log enhancement progress and results - Log model configuration and LLM response details - Log errors with stack traces --- .../devins/idea/editor/IdeaDevInInput.kt | 11 +++++++ .../devins/idea/editor/IdeaPromptEnhancer.kt | 25 ++++++++++++-- .../idea/toolwindow/IdeaDevInInputArea.kt | 33 +++++++++++++++---- 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInInput.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInInput.kt index 6ee6e1bac2..ca03f61685 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInInput.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInInput.kt @@ -242,6 +242,17 @@ class IdeaDevInInput( }) } + /** + * Replace the text content of the input. + * Clears existing content and sets new text. + */ + fun replaceText(newText: String) { + WriteCommandAction.runWriteCommandAction(project, "Replace text", "intentions.write.action", { + val document = this.editor?.document ?: return@runWriteCommandAction + document.setText(newText) + }) + } + /** * Clear the input and recreate document. */ diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt index 221ce5da83..28875d593b 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt @@ -4,6 +4,7 @@ import cc.unitmesh.devins.ui.config.ConfigManager import cc.unitmesh.llm.KoogLLMService import com.intellij.openapi.application.runReadAction import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project import com.intellij.openapi.project.guessProjectDir import kotlinx.coroutines.Dispatchers @@ -21,6 +22,7 @@ import kotlinx.coroutines.withContext */ @Service(Service.Level.PROJECT) class IdeaPromptEnhancer(private val project: Project) { + private val logger = Logger.getInstance(IdeaPromptEnhancer::class.java) /** * Enhance the user's prompt using LLM. @@ -30,13 +32,23 @@ class IdeaPromptEnhancer(private val project: Project) { */ suspend fun enhance(input: String): String = withContext(Dispatchers.IO) { try { + logger.info("Starting enhancement for input: ${input.take(50)}...") + val dict = loadDomainDict() val readme = loadReadme() + logger.info("Loaded domain dict (${dict.length} chars), readme (${readme.length} chars)") + val prompt = buildEnhancePrompt(input, dict, readme) + logger.info("Built enhancement prompt (${prompt.length} chars)") val config = ConfigManager.load() val modelConfig = config.getActiveModelConfig() - ?: return@withContext input + if (modelConfig == null) { + logger.warn("No active model config found, returning original input") + return@withContext input + } + + logger.info("Using model: ${modelConfig.modelName}") val llmService = KoogLLMService(modelConfig) val result = StringBuilder() @@ -46,8 +58,17 @@ class IdeaPromptEnhancer(private val project: Project) { result.append(chunk) } - extractEnhancedPrompt(result.toString()) ?: input + logger.info("LLM response received (${result.length} chars)") + val enhanced = extractEnhancedPrompt(result.toString()) + if (enhanced != null) { + logger.info("Extracted enhanced prompt (${enhanced.length} chars)") + } else { + logger.warn("Failed to extract enhanced prompt from response") + } + + enhanced ?: input } catch (e: Exception) { + logger.error("Enhancement failed: ${e.message}", e) // Return original input if enhancement fails input } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt index 659a47c645..65f45f4e34 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt @@ -14,10 +14,14 @@ import androidx.compose.ui.unit.dp import cc.unitmesh.devins.idea.editor.* import cc.unitmesh.llm.NamedModelConfig import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jetbrains.jewel.foundation.theme.JewelTheme import java.awt.BorderLayout import java.awt.Dimension @@ -38,6 +42,8 @@ import javax.swing.JPanel * * Layout: Unified border around the entire input area for a cohesive look. */ +private val inputAreaLogger = Logger.getInstance("IdeaDevInInputArea") + @Composable fun IdeaDevInInputArea( project: Project, @@ -180,20 +186,35 @@ fun IdeaDevInInputArea( onStopClick = onAbort, onPromptOptimizationClick = { val currentText = devInInput?.text?.trim() ?: inputText.trim() + inputAreaLogger.info("Prompt optimization clicked, text length: ${currentText.length}") + if (currentText.isNotBlank() && !isEnhancing && !isProcessing) { isEnhancing = true - scope.launch { + scope.launch(Dispatchers.IO) { try { + inputAreaLogger.info("Starting prompt enhancement...") val enhancer = IdeaPromptEnhancer.getInstance(project) val enhanced = enhancer.enhance(currentText) - if (enhanced != currentText) { - devInInput?.setText(enhanced) - inputText = enhanced + inputAreaLogger.info("Enhancement completed, result length: ${enhanced.length}") + + if (enhanced != currentText && enhanced.isNotBlank()) { + // Update UI on EDT + withContext(Dispatchers.Main) { + ApplicationManager.getApplication().invokeLater { + devInInput?.replaceText(enhanced) + inputText = enhanced + inputAreaLogger.info("Text updated in input field") + } + } + } else { + inputAreaLogger.info("No enhancement made (same text or empty result)") } } catch (e: Exception) { - // Silently fail - keep original text + inputAreaLogger.error("Prompt enhancement failed: ${e.message}", e) } finally { - isEnhancing = false + withContext(Dispatchers.Main) { + isEnhancing = false + } } } } From 43347220e3ffcd1024a6358088252a606e8e336e Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 22:17:26 +0800 Subject: [PATCH 32/37] feat(mpp-idea): use DialogWrapper for IdeaMcpConfigDialog to center in IDE - Create IdeaMcpConfigDialogWrapper using IntelliJ's DialogWrapper - Dialog now appears centered in the IDE window like IdeaModelConfigDialog - Proper z-index handling when used with SwingPanel components - Update IdeaBottomToolbar to use IdeaMcpConfigDialogWrapper.show() - Mark old IdeaMcpConfigDialog as deprecated --- .../devins/idea/editor/IdeaBottomToolbar.kt | 12 +---- .../devins/idea/editor/IdeaMcpConfigDialog.kt | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt index 0afb57656c..3db02028ff 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt @@ -42,7 +42,6 @@ fun IdeaBottomToolbar( onConfigureClick: () -> Unit = {}, modifier: Modifier = Modifier ) { - var showMcpConfigDialog by remember { mutableStateOf(false) } Row( modifier = modifier .fillMaxWidth() @@ -81,9 +80,9 @@ fun IdeaBottomToolbar( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { - // MCP Config button - opens MCP configuration dialog + // MCP Config button - opens MCP configuration dialog using DialogWrapper IconButton( - onClick = { showMcpConfigDialog = true }, + onClick = { IdeaMcpConfigDialogWrapper.show(project) }, modifier = Modifier.size(32.dp) ) { Icon( @@ -162,12 +161,5 @@ fun IdeaBottomToolbar( } } } - - // MCP Configuration Dialog - if (showMcpConfigDialog) { - IdeaMcpConfigDialog( - onDismiss = { showMcpConfigDialog = false } - ) - } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt index ca18877ced..3371695dde 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt @@ -24,6 +24,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.util.ui.JBUI import kotlinx.coroutines.flow.distinctUntilChanged import cc.unitmesh.agent.config.McpLoadingState import cc.unitmesh.agent.config.McpLoadingStateCallback @@ -37,8 +40,11 @@ import cc.unitmesh.devins.ui.config.ConfigManager import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import org.jetbrains.jewel.bridge.compose import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.* +import java.awt.Dimension +import javax.swing.JComponent // JSON serialization helpers private val json = Json { @@ -63,6 +69,43 @@ private fun deserializeMcpConfig(jsonString: String): Result Date: Tue, 2 Dec 2025 22:30:06 +0800 Subject: [PATCH 33/37] ci(github-actions): free disk space in build workflow Add step to maximize available disk space in GitHub Actions by using jlumbroso/free-disk-space before fetching sources. --- .github/workflows/build.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 822379228c..3a8cc0c204 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -117,6 +117,13 @@ jobs: continue-on-error: true steps: # Check out the current repository + # Free GitHub Actions Environment Disk Space + - name: Maximize Build Space + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + large-packages: false + - name: Fetch Sources uses: actions/checkout@v4 From 45da8a8c72fdad7726eea6d2ca1c3e483c3e4c22 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 22:34:13 +0800 Subject: [PATCH 34/37] fix(mpp-idea): address PR review comments 1. IdeaPromptEnhancer: - Log only metadata (length) instead of user prompt content to avoid sensitive info leakage - Fix domain dictionary fallback logic to properly check both paths - Add debug logging for file loading failures - Fix regex to handle code blocks without trailing newline 2. IdeaMcpConfigDialog: - Add proper @Deprecated annotation with ReplaceWith suggestion 3. IdeaBottomToolbar: - Remove unused kotlinx.coroutines.launch import 4. IdeaDevInInputArea: - Extract duplicate send logic into buildAndSendMessage helper function - Use isProcessingRef to fix stale closure issue in SwingPanel listener - Remove redundant withContext(Dispatchers.Main) + invokeLater combination --- .../devins/idea/editor/IdeaBottomToolbar.kt | 1 - .../devins/idea/editor/IdeaMcpConfigDialog.kt | 7 +- .../devins/idea/editor/IdeaPromptEnhancer.kt | 14 +-- .../idea/toolwindow/IdeaDevInInputArea.kt | 86 +++++++++++-------- 4 files changed, 66 insertions(+), 42 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt index 3db02028ff..949cc1f42e 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt @@ -10,7 +10,6 @@ import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.compose.theme.AutoDevColors import cc.unitmesh.llm.NamedModelConfig import com.intellij.openapi.project.Project -import kotlinx.coroutines.launch import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.* import org.jetbrains.jewel.ui.component.Icon diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt index 3371695dde..4207f54678 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt @@ -116,9 +116,12 @@ class IdeaMcpConfigDialogWrapper( * - Incremental MCP server loading * * Styled to match IdeaModelConfigDialog for consistency. - * - * @deprecated Use IdeaMcpConfigDialogWrapper.show() instead for proper z-index handling with SwingPanel. */ +@Deprecated( + message = "Use IdeaMcpConfigDialogWrapper.show() instead for proper z-index handling with SwingPanel.", + replaceWith = ReplaceWith("IdeaMcpConfigDialogWrapper.show(project)"), + level = DeprecationLevel.WARNING +) @Composable fun IdeaMcpConfigDialog( onDismiss: () -> Unit diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt index 28875d593b..cb9c6b5c13 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt @@ -32,7 +32,8 @@ class IdeaPromptEnhancer(private val project: Project) { */ suspend fun enhance(input: String): String = withContext(Dispatchers.IO) { try { - logger.info("Starting enhancement for input: ${input.take(50)}...") + // Log only metadata to avoid leaking sensitive information + logger.info("Starting enhancement for input (length: ${input.length})") val dict = loadDomainDict() val readme = loadReadme() @@ -82,11 +83,13 @@ class IdeaPromptEnhancer(private val project: Project) { return try { runReadAction { val baseDir = project.guessProjectDir() ?: return@runReadAction "" - val promptsDir = baseDir.findChild(".autodev") ?: baseDir.findChild("prompts") - val dictFile = promptsDir?.findChild("domain.csv") + // Try .autodev/domain.csv first, then prompts/domain.csv + val dictFile = baseDir.findChild(".autodev")?.findChild("domain.csv") + ?: baseDir.findChild("prompts")?.findChild("domain.csv") dictFile?.contentsToByteArray()?.toString(Charsets.UTF_8) ?: "" } } catch (e: Exception) { + logger.debug("Failed to load domain dictionary: ${e.message}") "" } } @@ -107,6 +110,7 @@ class IdeaPromptEnhancer(private val project: Project) { if (content.length > 2000) content.take(2000) + "\n..." else content } } catch (e: Exception) { + logger.debug("Failed to load README: ${e.message}") "" } } @@ -152,8 +156,8 @@ class IdeaPromptEnhancer(private val project: Project) { * Looks for content in markdown code blocks. */ private fun extractEnhancedPrompt(response: String): String? { - // Try to extract from markdown code block - val codeBlockRegex = Regex("```(?:\\w+)?\\s*\\n([\\s\\S]*?)\\n```") + // Try to extract from markdown code block (trailing newline is optional) + val codeBlockRegex = Regex("```(?:\\w+)?\\s*\\n([\\s\\S]*?)\\n?```") val match = codeBlockRegex.find(response) if (match != null) { return match.groupValues[1].trim() diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt index 65f45f4e34..6354f4077b 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt @@ -21,7 +21,6 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.jetbrains.jewel.foundation.theme.JewelTheme import java.awt.BorderLayout import java.awt.Dimension @@ -44,6 +43,26 @@ import javax.swing.JPanel */ private val inputAreaLogger = Logger.getInstance("IdeaDevInInputArea") +/** + * Helper function to build and send message with file references. + * Extracts common logic from onSubmit and onSendClick. + */ +private fun buildAndSendMessage( + text: String, + selectedFiles: List, + onSend: (String) -> Unit, + clearInput: () -> Unit, + clearFiles: () -> Unit +) { + if (text.isBlank()) return + + val filesText = selectedFiles.joinToString("\n") { it.toDevInsCommand() } + val fullText = if (filesText.isNotEmpty()) "$text\n$filesText" else text + onSend(fullText) + clearInput() + clearFiles() +} + @Composable fun IdeaDevInInputArea( project: Project, @@ -64,6 +83,10 @@ fun IdeaDevInInputArea( var selectedFiles by remember { mutableStateOf>(emptyList()) } var isEnhancing by remember { mutableStateOf(false) } + // Use a ref to track current processing state for the SwingPanel listener + val isProcessingRef = remember { mutableStateOf(isProcessing) } + LaunchedEffect(isProcessing) { isProcessingRef.value = isProcessing } + val scope = rememberCoroutineScope() val borderShape = RoundedCornerShape(8.dp) @@ -119,19 +142,18 @@ fun IdeaDevInInputArea( } override fun onSubmit(text: String, trigger: IdeaInputTrigger) { - if (text.isNotBlank() && !isProcessing) { - // Append file references to the message (use /dir: for directories, /file: for files) - val filesText = selectedFiles.joinToString("\n") { it.toDevInsCommand() } - val fullText = if (filesText.isNotEmpty()) { - "$text\n$filesText" - } else { - text - } - onSend(fullText) - clearInput() - inputText = "" - // Clear selected files after sending - selectedFiles = emptyList() + // Use ref to get current processing state + if (text.isNotBlank() && !isProcessingRef.value) { + buildAndSendMessage( + text = text, + selectedFiles = selectedFiles, + onSend = onSend, + clearInput = { + clearInput() + inputText = "" + }, + clearFiles = { selectedFiles = emptyList() } + ) } } @@ -167,18 +189,16 @@ fun IdeaDevInInputArea( onSendClick = { val text = devInInput?.text?.trim() ?: inputText.trim() if (text.isNotBlank() && !isProcessing) { - // Append file references to the message (use /dir: for directories, /file: for files) - val filesText = selectedFiles.joinToString("\n") { it.toDevInsCommand() } - val fullText = if (filesText.isNotEmpty()) { - "$text\n$filesText" - } else { - text - } - onSend(fullText) - devInInput?.clearInput() - inputText = "" - // Clear selected files after sending - selectedFiles = emptyList() + buildAndSendMessage( + text = text, + selectedFiles = selectedFiles, + onSend = onSend, + clearInput = { + devInInput?.clearInput() + inputText = "" + }, + clearFiles = { selectedFiles = emptyList() } + ) } }, sendEnabled = inputText.isNotBlank() && !isProcessing, @@ -198,13 +218,11 @@ fun IdeaDevInInputArea( inputAreaLogger.info("Enhancement completed, result length: ${enhanced.length}") if (enhanced != currentText && enhanced.isNotBlank()) { - // Update UI on EDT - withContext(Dispatchers.Main) { - ApplicationManager.getApplication().invokeLater { - devInInput?.replaceText(enhanced) - inputText = enhanced - inputAreaLogger.info("Text updated in input field") - } + // Update UI on EDT using invokeLater + ApplicationManager.getApplication().invokeLater { + devInInput?.replaceText(enhanced) + inputText = enhanced + inputAreaLogger.info("Text updated in input field") } } else { inputAreaLogger.info("No enhancement made (same text or empty result)") @@ -212,7 +230,7 @@ fun IdeaDevInInputArea( } catch (e: Exception) { inputAreaLogger.error("Prompt enhancement failed: ${e.message}", e) } finally { - withContext(Dispatchers.Main) { + ApplicationManager.getApplication().invokeLater { isEnhancing = false } } From 236ca62e8f9dec9a89f1e912ef7058b69d483b49 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 22:51:26 +0800 Subject: [PATCH 35/37] feat(mpp-idea): enhance IdeaMcpConfigDialog UI to match ToolConfigDialog 1. McpServersTab improvements: - Add header row with title and real-time JSON validation status - Show validation status indicator (Loading/Invalid JSON/Valid JSON) - Add styled error container with icon for error details - Add border around JSON editor with error state styling - Add footer with example hint and Save & Reload button with icon - Use CircularProgressIndicator for loading states 2. Fix Icon component usage: - Change 'key =' to 'imageVector =' for all Icon components - Replace IdeaComposeIcons.Schedule with IdeaComposeIcons.History 3. UI consistency: - Match the design patterns from ToolConfigDialog.kt - Use consistent spacing, colors, and typography --- .../devins/idea/editor/IdeaMcpConfigDialog.kt | 658 ++++++++++++++---- 1 file changed, 535 insertions(+), 123 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt index 4207f54678..f857379a08 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt @@ -1,48 +1,42 @@ package cc.unitmesh.devins.idea.editor import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties +import cc.unitmesh.agent.config.* +import cc.unitmesh.agent.mcp.McpServerConfig +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons +import cc.unitmesh.devins.ui.config.ConfigManager import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogWrapper import com.intellij.util.ui.JBUI import kotlinx.coroutines.flow.distinctUntilChanged -import cc.unitmesh.agent.config.McpLoadingState -import cc.unitmesh.agent.config.McpLoadingStateCallback -import cc.unitmesh.agent.config.McpServerState -import cc.unitmesh.agent.config.McpToolConfigManager -import cc.unitmesh.agent.config.ToolConfigFile -import cc.unitmesh.agent.config.ToolItem -import cc.unitmesh.agent.mcp.McpServerConfig -import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons -import cc.unitmesh.devins.ui.config.ConfigManager import kotlinx.coroutines.launch -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.jetbrains.jewel.bridge.compose import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.* +import org.jetbrains.jewel.ui.component.Text import java.awt.Dimension import javax.swing.JComponent @@ -50,24 +44,50 @@ import javax.swing.JComponent private val json = Json { prettyPrint = true ignoreUnknownKeys = true + isLenient = true } private fun serializeMcpConfig(servers: Map): String { + if (servers.isEmpty()) return getDefaultMcpConfigTemplate() return try { json.encodeToString(servers) } catch (e: Exception) { - "{}" + getDefaultMcpConfigTemplate() } } private fun deserializeMcpConfig(jsonString: String): Result> { + if (jsonString.isBlank()) return Result.success(emptyMap()) return try { val servers = json.decodeFromString>(jsonString) + // Validate each server config + servers.forEach { (name, config) -> + if (!config.validate()) { + return Result.failure(Exception("Invalid config for '$name': must have 'command' or 'url'")) + } + } Result.success(servers) } catch (e: Exception) { - Result.failure(e) + Result.failure(Exception("Failed to parse JSON: ${e.message}")) + } +} + +private fun getDefaultMcpConfigTemplate(): String = """ +{ + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + "env": {} + }, + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_TOKEN": "" } + } } +""".trimIndent() /** * DialogWrapper for MCP configuration that uses IntelliJ's native dialog system. @@ -90,7 +110,7 @@ class IdeaMcpConfigDialogWrapper( val dialogPanel = compose { IdeaMcpConfigDialogContent(onDismiss = { close(CANCEL_EXIT_CODE) }) } - dialogPanel.preferredSize = Dimension(600, 600) + dialogPanel.preferredSize = Dimension(850, 650) return dialogPanel } @@ -106,34 +126,6 @@ class IdeaMcpConfigDialogWrapper( } } -/** - * MCP Configuration Dialog for IntelliJ IDEA. - * - * Features: - * - Two tabs: Tools and MCP Servers - * - Auto-save functionality (2 seconds delay) - * - Real-time JSON validation - * - Incremental MCP server loading - * - * Styled to match IdeaModelConfigDialog for consistency. - */ -@Deprecated( - message = "Use IdeaMcpConfigDialogWrapper.show() instead for proper z-index handling with SwingPanel.", - replaceWith = ReplaceWith("IdeaMcpConfigDialogWrapper.show(project)"), - level = DeprecationLevel.WARNING -) -@Composable -fun IdeaMcpConfigDialog( - onDismiss: () -> Unit -) { - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - IdeaMcpConfigDialogContent(onDismiss = onDismiss) - } -} - /** * Content for the MCP configuration dialog. * Extracted to be used both in Compose Dialog and DialogWrapper. @@ -241,8 +233,8 @@ fun IdeaMcpConfigDialogContent( Box( modifier = Modifier - .width(600.dp) - .heightIn(max = 700.dp) + .width(850.dp) + .heightIn(max = 650.dp) .clip(RoundedCornerShape(12.dp)) .background(JewelTheme.globalColors.panelBackground) .onKeyEvent { event -> @@ -253,9 +245,9 @@ fun IdeaMcpConfigDialogContent( } ) { Column( - modifier = Modifier.padding(24.dp) + modifier = Modifier.fillMaxSize().padding(16.dp) ) { - // Title + // Title row with auto-save indicator Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -266,22 +258,49 @@ fun IdeaMcpConfigDialogContent( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - text = "MCP Configuration", - style = JewelTheme.defaultTextStyle.copy(fontSize = 18.sp) + text = "Tool Configuration", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) ) if (hasUnsavedChanges) { - Text( - text = "(Saving...)", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 12.sp, - color = JewelTheme.globalColors.text.info - ) - ) + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(JewelTheme.globalColors.borders.normal.copy(alpha = 0.2f)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.History, + contentDescription = "Auto-saving", + modifier = Modifier.size(14.dp), + tint = JewelTheme.globalColors.text.info + ) + Text( + text = "Auto-saving...", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.info + ) + ) + } + } } } + IconButton(onClick = onDismiss) { + Icon( + imageVector = IdeaComposeIcons.Close, + contentDescription = "Close" + ) + } } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(12.dp)) if (isLoading) { Box( @@ -291,34 +310,59 @@ fun IdeaMcpConfigDialogContent( Text("Loading configuration...") } } else { - // Tab selector + // Tab row - styled like Material TabRow Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(JewelTheme.globalColors.borders.normal.copy(alpha = 0.1f)) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { McpTabButton( text = "Tools", selected = selectedTab == 0, - onClick = { selectedTab = 0 } + onClick = { selectedTab = 0 }, + modifier = Modifier.weight(1f) ) McpTabButton( text = "MCP Servers", selected = selectedTab == 1, - onClick = { selectedTab = 1 } + onClick = { selectedTab = 1 }, + modifier = Modifier.weight(1f) ) } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(12.dp)) - // Error message + // Error message with styled container mcpLoadError?.let { error -> - Text( - text = error, - style = JewelTheme.defaultTextStyle.copy( - fontSize = 12.sp, - color = JewelTheme.globalColors.text.error - ) - ) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFFFFEBEE)) + .padding(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.Error, + contentDescription = "Error", + tint = Color(0xFFD32F2F), + modifier = Modifier.size(18.dp) + ) + Text( + text = error, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = Color(0xFFD32F2F) + ) + ) + } + } Spacer(modifier = Modifier.height(8.dp)) } @@ -395,26 +439,51 @@ fun IdeaMcpConfigDialogContent( } } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(12.dp)) - // Footer + // Footer with summary and actions Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - val enabledMcp = mcpTools.values.flatten().count { it.enabled } - val totalMcp = mcpTools.values.flatten().size - Text( - text = "MCP Tools: $enabledMcp/$totalMcp enabled", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 12.sp, - color = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + // Summary column + Column(modifier = Modifier.weight(1f)) { + val enabledMcp = mcpTools.values.flatten().count { it.enabled } + val totalMcp = mcpTools.values.flatten().size + Text( + text = "MCP Tools: $enabledMcp/$totalMcp enabled | Built-in tools: Always enabled", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) ) - ) + if (hasUnsavedChanges) { + Text( + text = "Changes will be auto-saved in 2 seconds...", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.info + ) + ) + } else { + Text( + text = "All changes saved", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + ) + ) + } + } - OutlinedButton(onClick = onDismiss) { - Text("Close") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onDismiss) { + Text("Cancel") + } + DefaultButton(onClick = onDismiss) { + Text("Apply & Close") + } } } } @@ -426,24 +495,27 @@ fun IdeaMcpConfigDialogContent( private fun McpTabButton( text: String, selected: Boolean, - onClick: () -> Unit + onClick: () -> Unit, + modifier: Modifier = Modifier ) { Box( - modifier = Modifier + modifier = modifier .clip(RoundedCornerShape(6.dp)) .background( - if (selected) JewelTheme.globalColors.borders.normal.copy(alpha = 0.3f) - else androidx.compose.ui.graphics.Color.Transparent + if (selected) JewelTheme.globalColors.panelBackground + else Color.Transparent ) .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 8.dp) + .padding(horizontal = 16.dp, vertical = 10.dp), + contentAlignment = Alignment.Center ) { Text( text = text, style = JewelTheme.defaultTextStyle.copy( fontSize = 13.sp, + fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal, color = if (selected) JewelTheme.globalColors.text.normal - else JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + else JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) ) ) } @@ -455,27 +527,73 @@ private fun McpToolsTab( mcpLoadingState: McpLoadingState, onToolToggle: (String, Boolean) -> Unit ) { + val expandedServers = remember { mutableStateMapOf() } + LazyColumn( modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(8.dp) + contentPadding = PaddingValues(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { - mcpTools.forEach { (serverName, tools) -> - item { - Text(serverName, modifier = Modifier.padding(vertical = 4.dp)) - } - items(tools) { tool -> + // Info banner about built-in tools + item { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(JewelTheme.globalColors.borders.normal.copy(alpha = 0.1f)) + .padding(12.dp) + ) { Row( - modifier = Modifier.fillMaxWidth().padding(start = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Column(modifier = Modifier.weight(1f)) { - Text(tool.displayName) - Text(tool.description, color = org.jetbrains.jewel.foundation.theme.JewelTheme.globalColors.text.info) + Icon( + imageVector = IdeaComposeIcons.Info, + contentDescription = "Info", + tint = JewelTheme.globalColors.text.info, + modifier = Modifier.size(20.dp) + ) + Column { + Text( + text = "Built-in Tools Always Enabled", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = JewelTheme.globalColors.text.info + ) + ) + Text( + text = "File operations, search, shell, and other essential tools are always available", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) + ) } - Checkbox( - checked = tool.enabled, - onCheckedChange = { onToolToggle(tool.name, it) } + } + } + } + + // MCP servers with tools + mcpTools.forEach { (serverName, tools) -> + val isExpanded = expandedServers.getOrPut(serverName) { true } + val serverState = mcpLoadingState.servers[serverName] + + item(key = "server_$serverName") { + McpServerHeader( + serverName = serverName, + serverState = serverState, + tools = tools, + isExpanded = isExpanded, + onToggle = { expandedServers[serverName] = !isExpanded } + ) + } + + if (isExpanded) { + items(tools, key = { "tool_${it.name}" }) { tool -> + CompactToolItemRow( + tool = tool, + onToggle = { enabled -> onToolToggle(tool.name, enabled) } ) } } @@ -485,13 +603,178 @@ private fun McpToolsTab( if (mcpTools.isEmpty() && !isLoading) { item { - Text("No MCP tools configured. Add MCP servers in the 'MCP Servers' tab.") + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "No MCP tools configured. Add MCP servers in the 'MCP Servers' tab.", + style = JewelTheme.defaultTextStyle.copy( + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) + ) + ) + } } } if (isLoading) { item { - Text("Loading MCP tools...") + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator(modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Loading MCP tools...") + } + } + } + } +} + +@Composable +private fun McpServerHeader( + serverName: String, + serverState: McpServerState?, + tools: List, + isExpanded: Boolean, + onToggle: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(JewelTheme.globalColors.borders.normal.copy(alpha = 0.15f)) + .clickable(onClick = onToggle) + .padding(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // Status icon + val (statusIcon, statusColor) = when (serverState?.status) { + McpServerLoadingStatus.LOADING -> IdeaComposeIcons.Refresh to JewelTheme.globalColors.text.info + McpServerLoadingStatus.LOADED -> IdeaComposeIcons.Cloud to Color(0xFF4CAF50) + McpServerLoadingStatus.ERROR -> IdeaComposeIcons.Error to Color(0xFFD32F2F) + else -> IdeaComposeIcons.Cloud to JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + } + + Icon( + imageVector = statusIcon, + contentDescription = null, + tint = statusColor, + modifier = Modifier.size(18.dp) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = "MCP: $serverName", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 13.sp, + fontWeight = FontWeight.Medium + ), + modifier = Modifier.weight(1f) + ) + + if (tools.isNotEmpty()) { + Text( + text = "${tools.count { it.enabled }}/${tools.size}", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = JewelTheme.globalColors.text.info + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + } + + if (serverState?.isLoading == true) { + CircularProgressIndicator(modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(8.dp)) + } + + Icon( + imageVector = if (isExpanded) IdeaComposeIcons.ExpandLess else IdeaComposeIcons.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f), + modifier = Modifier.size(18.dp) + ) + } + } +} + +@Composable +private fun CompactToolItemRow( + tool: ToolItem, + onToggle: (Boolean) -> Unit +) { + var isChecked by remember { mutableStateOf(tool.enabled) } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp) + .clip(RoundedCornerShape(4.dp)) + .background( + if (isChecked) JewelTheme.globalColors.borders.normal.copy(alpha = 0.08f) + else Color.Transparent + ) + .clickable { + isChecked = !isChecked + onToggle(isChecked) + } + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = isChecked, + onCheckedChange = { + isChecked = it + onToggle(it) + } + ) + + Spacer(modifier = Modifier.width(8.dp)) + + // Tool name + Text( + text = tool.displayName, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ), + modifier = Modifier.width(140.dp), + maxLines = 1 + ) + + Spacer(modifier = Modifier.width(8.dp)) + + // Description + Text( + text = tool.description, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) + ), + modifier = Modifier.weight(1f), + maxLines = 1 + ) + + // Server badge + if (tool.serverName.isNotEmpty()) { + Spacer(modifier = Modifier.width(4.dp)) + Box( + modifier = Modifier + .clip(RoundedCornerShape(2.dp)) + .background(JewelTheme.globalColors.borders.normal.copy(alpha = 0.2f)) + .padding(horizontal = 4.dp, vertical = 1.dp) + ) { + Text( + text = tool.serverName, + style = JewelTheme.defaultTextStyle.copy(fontSize = 9.sp) + ) } } } @@ -529,34 +812,163 @@ private fun McpServersTab( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text("MCP Server Configuration (JSON)") + // Header with title and validation status + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "MCP Server Configuration", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + ) + Text( + text = "JSON is validated in real-time", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) + ) + ) + } + // Validation status indicator + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (isReloading) { + CircularProgressIndicator(modifier = Modifier.size(14.dp)) + Text( + text = "Loading...", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.info + ) + ) + } else if (errorMessage != null) { + Icon( + imageVector = IdeaComposeIcons.Error, + contentDescription = "Error", + tint = Color(0xFFD32F2F), + modifier = Modifier.size(14.dp) + ) + Text( + text = "Invalid JSON", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = Color(0xFFD32F2F) + ) + ) + } else if (mcpConfigJson.isNotBlank()) { + Icon( + imageVector = IdeaComposeIcons.CheckCircle, + contentDescription = "Valid", + tint = Color(0xFF4CAF50), + modifier = Modifier.size(14.dp) + ) + Text( + text = "Valid JSON", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = Color(0xFF4CAF50) + ) + ) + } + } + } + + // Error message detail errorMessage?.let { error -> - Text(error, color = org.jetbrains.jewel.foundation.theme.JewelTheme.globalColors.text.error) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(Color(0xFFFFEBEE)) + .padding(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.Error, + contentDescription = "Error", + tint = Color(0xFFD32F2F), + modifier = Modifier.size(16.dp) + ) + Text( + text = error, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = Color(0xFFD32F2F) + ) + ) + } + } } - // Use BasicTextField for multi-line text input - BasicTextField( - state = textFieldState, - modifier = Modifier.fillMaxWidth().weight(1f), - textStyle = TextStyle( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, - color = org.jetbrains.jewel.foundation.theme.JewelTheme.globalColors.text.normal - ), - cursorBrush = SolidColor(org.jetbrains.jewel.foundation.theme.JewelTheme.globalColors.text.normal) - ) + // JSON editor with border + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clip(RoundedCornerShape(6.dp)) + .border( + width = 1.dp, + color = if (errorMessage != null) Color(0xFFD32F2F) + else JewelTheme.globalColors.borders.normal, + shape = RoundedCornerShape(6.dp) + ) + .background(JewelTheme.globalColors.panelBackground) + .padding(8.dp) + ) { + BasicTextField( + state = textFieldState, + modifier = Modifier.fillMaxSize(), + textStyle = TextStyle( + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontSize = 12.sp, + color = JewelTheme.globalColors.text.normal + ), + cursorBrush = SolidColor(JewelTheme.globalColors.text.normal) + ) + } + // Footer with hint and reload button Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { + Text( + text = "Example: uvx for Python tools, npx for Node.js tools", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) + ) + ) + DefaultButton( onClick = onReload, enabled = !isReloading && errorMessage == null ) { - Text(if (isReloading) "Reloading..." else "Reload MCP Tools") + if (isReloading) { + CircularProgressIndicator(modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(4.dp)) + } else { + Icon( + imageVector = IdeaComposeIcons.Refresh, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text(if (isReloading) "Loading..." else "Save & Reload") } } } } - From fa5decf4ee3a319107611fb165b0702a7e2fa775 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Tue, 2 Dec 2025 23:03:34 +0800 Subject: [PATCH 36/37] feat(mpp-idea): add IdeaToolConfigService for tool config state management 1. Create IdeaToolConfigService: - Project-level service for managing tool configuration state - Provides StateFlow for observing config changes - Uses configVersion counter to trigger UI recomposition - Centralized save/load with notification to listeners 2. Update IdeaToolLoadingStatusBar: - Add project parameter - Observe configVersion from IdeaToolConfigService - Recompute toolStatus when config version changes 3. Update IdeaAgentViewModel: - Use IdeaToolConfigService for loading tool config - Get fresh config from service in getToolLoadingStatus() 4. Update IdeaMcpConfigDialog: - Add project parameter to IdeaMcpConfigDialogContent - Use IdeaToolConfigService.saveAndUpdateConfig() for auto-save - Notify listeners when tools are toggled 5. Register service in plugin.xml This ensures the status bar updates when MCP tools are enabled/disabled in the configuration dialog. --- .../status/IdeaToolLoadingStatusBar.kt | 12 +- .../devins/idea/editor/IdeaMcpConfigDialog.kt | 19 ++- .../idea/services/IdeaToolConfigService.kt | 118 ++++++++++++++++++ .../devins/idea/toolwindow/IdeaAgentApp.kt | 1 + .../idea/toolwindow/IdeaAgentViewModel.kt | 20 ++- .../src/main/resources/META-INF/plugin.xml | 2 + 6 files changed, 164 insertions(+), 8 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaToolConfigService.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/status/IdeaToolLoadingStatusBar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/status/IdeaToolLoadingStatusBar.kt index 1c27e4a70d..4b46dc92ec 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/status/IdeaToolLoadingStatusBar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/status/IdeaToolLoadingStatusBar.kt @@ -12,8 +12,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import cc.unitmesh.devins.idea.services.IdeaToolConfigService import cc.unitmesh.devins.idea.toolwindow.IdeaAgentViewModel import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import com.intellij.openapi.project.Project import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.Text @@ -23,12 +25,18 @@ import org.jetbrains.jewel.ui.component.Text @Composable fun IdeaToolLoadingStatusBar( viewModel: IdeaAgentViewModel, + project: Project, modifier: Modifier = Modifier ) { val mcpPreloadingMessage by viewModel.mcpPreloadingMessage.collectAsState() val mcpPreloadingStatus by viewModel.mcpPreloadingStatus.collectAsState() - // Recompute when preloading status changes to make it reactive - val toolStatus = remember(mcpPreloadingStatus) { viewModel.getToolLoadingStatus() } + + // Observe tool config service for configuration changes + val toolConfigService = remember { IdeaToolConfigService.getInstance(project) } + val configVersion by toolConfigService.configVersion.collectAsState() + + // Recompute when preloading status OR config version changes + val toolStatus = remember(mcpPreloadingStatus, configVersion) { viewModel.getToolLoadingStatus() } Row( modifier = modifier diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt index f857379a08..fcbabe5e6c 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.unitmesh.agent.config.* import cc.unitmesh.agent.mcp.McpServerConfig +import cc.unitmesh.devins.idea.services.IdeaToolConfigService import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.config.ConfigManager import com.intellij.openapi.project.Project @@ -108,7 +109,10 @@ class IdeaMcpConfigDialogWrapper( override fun createCenterPanel(): JComponent { val dialogPanel = compose { - IdeaMcpConfigDialogContent(onDismiss = { close(CANCEL_EXIT_CODE) }) + IdeaMcpConfigDialogContent( + project = project, + onDismiss = { close(CANCEL_EXIT_CODE) } + ) } dialogPanel.preferredSize = Dimension(850, 650) return dialogPanel @@ -132,6 +136,7 @@ class IdeaMcpConfigDialogWrapper( */ @Composable fun IdeaMcpConfigDialogContent( + project: Project?, onDismiss: () -> Unit ) { var toolConfig by remember { mutableStateOf(ToolConfigFile.default()) } @@ -148,6 +153,11 @@ fun IdeaMcpConfigDialogContent( val scope = rememberCoroutineScope() + // Get tool config service for notifying state changes + val toolConfigService = remember(project) { + project?.let { IdeaToolConfigService.getInstance(it) } + } + // Auto-save function fun scheduleAutoSave() { hasUnsavedChanges = true @@ -168,7 +178,12 @@ fun IdeaMcpConfigDialogContent( mcpServers = newMcpServers ) - ConfigManager.saveToolConfig(updatedConfig) + // Use service to save and notify listeners + if (toolConfigService != null) { + toolConfigService.saveAndUpdateConfig(updatedConfig) + } else { + ConfigManager.saveToolConfig(updatedConfig) + } toolConfig = updatedConfig hasUnsavedChanges = false } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaToolConfigService.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaToolConfigService.kt new file mode 100644 index 0000000000..bb2a2de647 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaToolConfigService.kt @@ -0,0 +1,118 @@ +package cc.unitmesh.devins.idea.services + +import cc.unitmesh.agent.config.ToolConfigFile +import cc.unitmesh.devins.ui.config.ConfigManager +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.runBlocking + +/** + * Project-level service for managing tool configuration state. + * + * This service provides a centralized way to: + * 1. Load and cache tool configuration + * 2. Notify listeners when configuration changes + * 3. Track enabled/disabled MCP tools count + * + * Components like IdeaToolLoadingStatusBar and IdeaAgentViewModel can observe + * the toolConfigState to react to configuration changes. + */ +@Service(Service.Level.PROJECT) +class IdeaToolConfigService(private val project: Project) : Disposable { + + private val logger = Logger.getInstance(IdeaToolConfigService::class.java) + + // Tool configuration state + private val _toolConfigState = MutableStateFlow(ToolConfigState()) + val toolConfigState: StateFlow = _toolConfigState.asStateFlow() + + // Version counter to force recomposition when config changes + private val _configVersion = MutableStateFlow(0L) + val configVersion: StateFlow = _configVersion.asStateFlow() + + init { + // Load initial configuration + reloadConfig() + } + + /** + * Reload configuration from disk and update state. + * Uses runBlocking since this is called from non-suspend context. + */ + fun reloadConfig() { + try { + val toolConfig = runBlocking { ConfigManager.loadToolConfig() } + updateState(toolConfig) + logger.debug("Tool configuration reloaded: ${toolConfig.enabledMcpTools.size} enabled tools") + } catch (e: Exception) { + logger.warn("Failed to reload tool configuration: ${e.message}") + } + } + + /** + * Update the tool configuration state. + * Call this after saving configuration changes. + */ + fun updateState(toolConfig: ToolConfigFile) { + val enabledMcpToolsCount = toolConfig.enabledMcpTools.size + val mcpServersCount = toolConfig.mcpServers.filter { !it.value.disabled }.size + + _toolConfigState.value = ToolConfigState( + toolConfig = toolConfig, + enabledMcpToolsCount = enabledMcpToolsCount, + mcpServersCount = mcpServersCount, + lastUpdated = System.currentTimeMillis() + ) + + // Increment version to trigger recomposition + _configVersion.value++ + + logger.debug("Tool config state updated: $enabledMcpToolsCount enabled tools, $mcpServersCount servers") + } + + /** + * Save tool configuration and update state. + * Uses runBlocking since this is called from non-suspend context. + */ + fun saveAndUpdateConfig(toolConfig: ToolConfigFile) { + try { + runBlocking { ConfigManager.saveToolConfig(toolConfig) } + updateState(toolConfig) + logger.debug("Tool configuration saved and state updated") + } catch (e: Exception) { + logger.error("Failed to save tool configuration: ${e.message}") + } + } + + /** + * Get the current tool configuration. + */ + fun getToolConfig(): ToolConfigFile { + return _toolConfigState.value.toolConfig + } + + override fun dispose() { + // Cleanup if needed + } + + companion object { + fun getInstance(project: Project): IdeaToolConfigService = project.service() + } +} + +/** + * Data class representing the current tool configuration state. + */ +data class ToolConfigState( + val toolConfig: ToolConfigFile = ToolConfigFile.default(), + val enabledMcpToolsCount: Int = 0, + val mcpServersCount: Int = 0, + val lastUpdated: Long = 0L +) + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt index de7c68a21e..ceaa0284f8 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt @@ -245,6 +245,7 @@ fun IdeaAgentApp( // Tool loading status bar IdeaToolLoadingStatusBar( viewModel = viewModel, + project = project, modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp) ) } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt index af188e4f9b..1fbd874f5e 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt @@ -12,6 +12,7 @@ import cc.unitmesh.agent.tool.schema.ToolCategory import cc.unitmesh.devins.compiler.service.DevInsCompilerService import cc.unitmesh.devins.idea.compiler.IdeaDevInsCompilerService import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.devins.idea.services.IdeaToolConfigService import cc.unitmesh.devins.ui.config.AutoDevConfigWrapper import cc.unitmesh.devins.ui.config.ConfigManager import cc.unitmesh.llm.KoogLLMService @@ -156,7 +157,11 @@ class IdeaAgentViewModel( private suspend fun startMcpPreloading() { try { _mcpPreloadingMessage.value = "Loading MCP servers configuration..." - val toolConfig = ConfigManager.loadToolConfig() + + // Use IdeaToolConfigService to get and cache tool config + val toolConfigService = IdeaToolConfigService.getInstance(project) + toolConfigService.reloadConfig() + val toolConfig = toolConfigService.getToolConfig() cachedToolConfig = toolConfig if (toolConfig.mcpServers.isEmpty()) { @@ -463,18 +468,25 @@ class IdeaAgentViewModel( /** * Get tool loading status. * Aligned with CodingAgentViewModel's getToolLoadingStatus(). + * Uses IdeaToolConfigService for up-to-date configuration. */ fun getToolLoadingStatus(): ToolLoadingStatus { - val toolConfig = cachedToolConfig + // Get fresh config from service to ensure we have latest changes + val toolConfigService = IdeaToolConfigService.getInstance(project) + val toolConfig = toolConfigService.getToolConfig() + + // Update cached config + cachedToolConfig = toolConfig + val subAgentTools = ToolType.byCategory(ToolCategory.SubAgent) val subAgentsEnabled = subAgentTools.size - val mcpServersTotal = toolConfig?.mcpServers?.filter { !it.value.disabled }?.size ?: 0 + val mcpServersTotal = toolConfig.mcpServers.filter { !it.value.disabled }.size val mcpServersLoaded = _mcpPreloadingStatus.value.preloadedServers.size val mcpToolsEnabled = if (McpToolConfigManager.isPreloading()) { 0 } else { - val enabledMcpToolsCount = toolConfig?.enabledMcpTools?.size ?: 0 + val enabledMcpToolsCount = toolConfig.enabledMcpTools.size if (enabledMcpToolsCount > 0) enabledMcpToolsCount else 0 } diff --git a/mpp-idea/src/main/resources/META-INF/plugin.xml b/mpp-idea/src/main/resources/META-INF/plugin.xml index 6101d71f1c..7fa411017a 100644 --- a/mpp-idea/src/main/resources/META-INF/plugin.xml +++ b/mpp-idea/src/main/resources/META-INF/plugin.xml @@ -58,6 +58,8 @@ + + Date: Tue, 2 Dec 2025 23:10:31 +0800 Subject: [PATCH 37/37] fix(devins-lang): fix memory leak in DevInsProgramRunner Problem: DevInsProgramRunner was implementing Disposable and registering a MessageBusConnection with 'this' as parent, but the runner itself was never properly disposed, causing memory leak warnings. Solution: - Remove Disposable interface from DevInsProgramRunner - Connect to project's message bus instead of application's - Register the connection with the project as parent disposable - This ensures proper cleanup when the project is closed The connection is now tied to the project lifecycle instead of the runner's lifecycle, which is the correct pattern for ProgramRunner implementations. --- .../devti/language/run/DevInsProgramRunner.kt | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/run/DevInsProgramRunner.kt b/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/run/DevInsProgramRunner.kt index 9f73480b26..78107a3b13 100644 --- a/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/run/DevInsProgramRunner.kt +++ b/exts/devins-lang/src/main/kotlin/cc/unitmesh/devti/language/run/DevInsProgramRunner.kt @@ -13,17 +13,18 @@ import com.intellij.execution.runners.ExecutionEnvironment import com.intellij.execution.runners.GenericProgramRunner import com.intellij.execution.runners.showRunContent import com.intellij.execution.ui.RunContentDescriptor -import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.util.Disposer +import com.intellij.util.messages.MessageBusConnection import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicReference -class DevInsProgramRunner : GenericProgramRunner(), Disposable { +class DevInsProgramRunner : GenericProgramRunner() { private val RUNNER_ID: String = "DevInsProgramRunner" - private val connection = ApplicationManager.getApplication().messageBus.connect(this) - + // Use lazy initialization to avoid memory leak - connection is created per execution + // and tied to the project's lifecycle, not the runner's lifecycle + private var connection: MessageBusConnection? = null private var isSubscribed = false override fun getRunnerId(): String = RUNNER_ID @@ -40,7 +41,15 @@ class DevInsProgramRunner : GenericProgramRunner(), Disposable { ApplicationManager.getApplication().invokeAndWait { if (!isSubscribed) { - connection.subscribe(DevInsRunListener.TOPIC, object : DevInsRunListener { + // Connect to project's message bus instead of application's + // This ensures proper disposal when the project is closed + val projectConnection = environment.project.messageBus.connect() + connection = projectConnection + + // Register for disposal with the project + Disposer.register(environment.project, projectConnection) + + projectConnection.subscribe(DevInsRunListener.TOPIC, object : DevInsRunListener { override fun runFinish( allOutput: String, llmOutput: String, @@ -67,8 +76,4 @@ class DevInsProgramRunner : GenericProgramRunner(), Disposable { return result.get() } - - override fun dispose() { - connection.disconnect() - } }