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 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) 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() - } } 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/agent/executor/CodingAgentExecutor.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/CodingAgentExecutor.kt index e74fc0ac5d..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 @@ -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,8 +279,9 @@ class CodingAgentExecutor( } // 错误恢复处理 - if (!executionResult.isSuccess) { - 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") @@ -271,6 +291,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 @@ -343,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 c79d90ae3d..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 @@ -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 } /** @@ -87,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, @@ -98,5 +107,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..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 @@ -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") + private val logger = getLogger("cc.unitmesh.agent.orchestrator.ToolOrchestrator") + + // Coroutine scope for background tasks (async shell monitoring) + private val backgroundScope = CoroutineScope(Dispatchers.Default) /** * Execute a single tool call with full orchestration @@ -101,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( @@ -116,53 +142,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 +240,7 @@ class ToolOrchestrator( } else { metadata } - + return ToolExecutionResult( executionId = context.executionId, toolName = toolName, @@ -205,6 +269,94 @@ 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 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 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) + + // 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, + 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 = errorOutput, + cancelledByUser = wasCancelledByUser + ) + } + } + } + private suspend fun executeToolInternal( toolName: String, params: Map, @@ -218,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) @@ -571,6 +723,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 +731,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..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 @@ -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,37 @@ 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) + * @param cancelledByUser Whether the command was cancelled by the user (exit code 137) + */ + fun updateLiveTerminalStatus( + sessionId: String, + exitCode: Int, + executionTimeMs: Long, + output: String? = null, + cancelledByUser: Boolean = false + ) { + // 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/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..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 @@ -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,171 @@ 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 + 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, + "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 + ) + // Set process handlers from LiveShellSession + managedSession.setProcessHandlers( + isAlive = { session.isAlive() }, + kill = { session.kill() } + ) + + 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/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/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..f47799a9a6 --- /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/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 new file mode 100644 index 0000000000..5c705899e1 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt @@ -0,0 +1,190 @@ +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] } + } + + /** + * 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: 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) { + sessions[sessionId]?.cancelledByUser = true + } + + /** + * 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 + + /** + * 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 + + /** + * 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 { + // 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 + */ + fun markCompleted(exitCode: Int, endTime: Long = Platform.getCurrentTimestamp()) { + _exitCode = exitCode + _endTime = endTime + } + + /** + * Kill the process + */ + fun kill(): Boolean { + return try { + killHandler?.invoke() + markCompleted(-1) + true + } catch (e: Exception) { + false + } + } + + /** + * Get execution time in milliseconds + */ + fun getExecutionTimeMs(): Long { + val end = _endTime ?: Platform.getCurrentTimestamp() + return end - startTime + } +} + 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/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-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-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..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 @@ -232,19 +232,24 @@ 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( sessionId = sessionId, command = command, workingDirectory = config.workingDirectory, ptyHandle = ptyProcess, - isLiveSupported = true + isLiveSupported = true, + isAliveChecker = { ptyProcess.isAlive }, + killHandler = { ptyProcess.destroyForcibly() } ) } @@ -256,24 +261,16 @@ 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}" } - } - } - + // Get managed session to sync output + val managedSession = ShellSessionManager.getSession(session.sessionId) + + // 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) { yield() @@ -281,18 +278,15 @@ 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) + managedSession?.markCompleted(exitCode) exitCode } catch (e: Exception) { logger().error(e) { "Error waiting for PTY process: ${e.message}" } 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/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/components/timeline/IdeaLiveTerminalBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt new file mode 100644 index 0000000000..ea34e82c4e --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaLiveTerminalBubble.kt @@ -0,0 +1,348 @@ +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.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 +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. + * + * 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()) + 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 { + // 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) { + delay(checkIntervalMs) + } + + // Process ended - wait a bit for streams to flush + delay(50) + outputJob.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) { + try { + val reader = stream.bufferedReader() + val charBuffer = CharArray(1024) + while (currentCoroutineContext().isActive) { + val bytesRead = reader.read(charBuffer) + if (bytesRead == -1) break + + val chunk = String(charBuffer, 0, bytesRead) + synchronized(buffer) { + buffer.append(chunk) + } + + _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 + } + } + + fun stop() { + job?.cancel() + } + + /** + * Get current output buffer content + */ + fun getCurrentOutput(): String = synchronized(buffer) { buffer.toString() } +} + +/** + * 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. + * + * Features: + * - Real-time output streaming via Flow + * - ANSI color and formatting support + * - 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: ((CancelEvent) -> Unit)? = null +) { + var expanded by remember { mutableStateOf(true) } + + val process = remember(item.ptyHandle) { item.ptyHandle as? Process } + + // 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() + ?: 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..." } + + // Cancel handler - sends current output log to AI before terminating + val handleCancel: () -> Unit = { + process?.let { p -> + // 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( + sessionId = item.sessionId, + command = item.command, + output = currentOutput, + process = p + ) + + if (onCancel != null) { + onCancel(cancelEvent) + } else { + // Default: just destroy the process + p.destroyForcibly() + } + collector?.stop() + } + } + + 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 with cancel button + AnimatedVisibility( + visible = expanded, + enter = expandVertically(), + exit = shrinkVertically() + ) { + 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/components/timeline/IdeaTimelineContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt index 323b36873e..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( @@ -74,15 +79,11 @@ 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 - ), - project = project + // Live terminal with real-time output streaming + IdeaLiveTerminalBubble( + item = item, + project = project, + onCancel = onProcessCancel ) } } 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..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 @@ -1,40 +1,38 @@ 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 org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.* 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). */ @Composable fun IdeaBottomToolbar( + project: Project? = null, onSendClick: () -> Unit, sendEnabled: Boolean, isExecuting: Boolean = false, onStopClick: () -> Unit = {}, - onSettingsClick: () -> Unit = {}, onPromptOptimizationClick: () -> Unit = {}, + isEnhancing: Boolean = false, totalTokens: Int? = null, // Model selector props availableConfigs: List = emptyList(), @@ -81,30 +79,36 @@ fun IdeaBottomToolbar( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { - // MCP Settings button + // MCP Config button - opens MCP configuration dialog using DialogWrapper IconButton( - onClick = onSettingsClick, + onClick = { IdeaMcpConfigDialogWrapper.show(project) }, 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) ) } // 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/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/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/IdeaFileSearchPopup.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt new file mode 100644 index 0000000000..8b1a7c636f --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaFileSearchPopup.kt @@ -0,0 +1,539 @@ +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 +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.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.* + +/** + * 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: + * - 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 +) { + val searchQueryState = rememberTextFieldState("") + val searchQuery by remember { derivedStateOf { searchQueryState.text.toString() } } + + // 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 + } + } + + val files = searchResults?.files ?: emptyList() + val folders = searchResults?.folders ?: emptyList() + val filteredRecentFiles = if (searchQuery.length >= 2) { + searchResults?.recentFiles ?: emptyList() + } else { + recentFiles + } + + PopupMenu( + onDismissRequest = { + onDismiss() + true + }, + horizontalAlignment = Alignment.Start, + modifier = Modifier.widthIn(min = 300.dp, max = 480.dp) + ) { + // Search field at top with improved styling + passiveItem { + 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() + + // 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) + } + } + 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) + ) + ) + } + } + } + + // 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) + } + } + } + + // 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) + ) + ) + } + } + } 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( + "Recent Files", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) + ) + } + } + 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) + ) + ) + } + } + } + } +} + +/** + * 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( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // File icon or history icon for recent files + Icon( + imageVector = if (file.isRecentFile) IdeaComposeIcons.History else IdeaComposeIcons.InsertDriveFile, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + + // 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, + fontWeight = FontWeight.Medium + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + // Truncated path in gray + Text( + text = file.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) + ) + } + } +} + +@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) + ) + + // 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) + ) + } + } +} + +/** + * Search results grouped by type. + */ +data class SearchResults( + val files: List, + val folders: List, + val recentFiles: List +) + +/** + * 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, + 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 ?: "" + 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, + isDirectory = file.isDirectory + ) + } + } +} + +private fun loadRecentFiles(project: Project): List { + val recentFiles = mutableListOf() + + try { + 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) { + com.intellij.openapi.diagnostic.Logger.getInstance("IdeaFileSearchPopup") + .warn("Error loading recent files: ${e.message}", e) + } + + return recentFiles +} + +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 { + 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) { + folders.add(IdeaFilePresentation.from(project, file)) + } + } else if (canBeAdded(project, file) && files.size < 50) { + files.add(IdeaFilePresentation.from(project, file)) + } + files.size < 50 && folders.size < 20 + } + + // 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 -> + 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)) + } + } + files.size < 50 && folders.size < 20 + } + } + } catch (e: Exception) { + // Log error for debugging + com.intellij.openapi.diagnostic.Logger.getInstance("IdeaFileSearchPopup") + .warn("Error searching files: ${e.message}", e) + } + + // 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) return false + if (file.isDirectory) return true // Allow directories + + 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/IdeaMcpConfigDialog.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt index 345a885664..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 @@ -1,68 +1,142 @@ 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.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.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.window.Dialog -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 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 +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.util.ui.JBUI +import kotlinx.coroutines.flow.distinctUntilChanged 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 // JSON serialization helpers 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. + * This ensures proper z-index handling and centers the dialog in the IDE window. + */ +class IdeaMcpConfigDialogWrapper( + private val project: Project? +) : DialogWrapper(project) { + + init { + title = "MCP Configuration" + init() + contentPanel.border = JBUI.Borders.empty() + rootPane.border = JBUI.Borders.empty() + } + + override fun createSouthPanel(): JComponent? = null + + override fun createCenterPanel(): JComponent { + val dialogPanel = compose { + IdeaMcpConfigDialogContent( + project = project, + onDismiss = { close(CANCEL_EXIT_CODE) } + ) + } + dialogPanel.preferredSize = Dimension(850, 650) + return dialogPanel + } + + companion object { + /** + * Show the MCP configuration dialog. + * @return true if the dialog was closed with OK, false otherwise + */ + fun show(project: Project?): Boolean { + val dialog = IdeaMcpConfigDialogWrapper(project) + return dialog.showAndGet() + } } } /** - * 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 - * - * Migrated from mpp-ui/ToolConfigDialog.kt to use Jewel UI components. + * Content for the MCP configuration dialog. + * Extracted to be used both in Compose Dialog and DialogWrapper. */ @Composable -fun IdeaMcpConfigDialog( +fun IdeaMcpConfigDialogContent( + project: Project?, onDismiss: () -> Unit ) { var toolConfig by remember { mutableStateOf(ToolConfigFile.default()) } @@ -79,12 +153,17 @@ fun IdeaMcpConfigDialog( 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 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() @@ -99,13 +178,17 @@ fun IdeaMcpConfigDialog( 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 - println("✅ Auto-saved tool configuration") } } catch (e: Exception) { - println("❌ Auto-save failed: ${e.message}") + // Silent fail for auto-save } } } @@ -119,12 +202,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 +220,6 @@ fun IdeaMcpConfigDialog( } try { - // Use incremental loading mcpLoadingState = McpToolConfigManager.discoverMcpToolsIncremental( toolConfig.mcpServers, toolConfig.enabledMcpTools.toSet(), @@ -149,35 +228,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(850.dp) + .heightIn(max = 650.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.fillMaxSize().padding(16.dp) ) { - // Header + // Title row with auto-save indicator Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -187,13 +272,46 @@ fun IdeaMcpConfigDialog( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text("Tool Configuration") + Text( + text = "Tool Configuration", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + ) if (hasUnsavedChanges) { - Text("(Auto-saving...)", color = org.jetbrains.jewel.foundation.theme.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) { - Text("×") + Icon( + imageVector = IdeaComposeIcons.Close, + contentDescription = "Close" + ) } } @@ -201,41 +319,74 @@ fun IdeaMcpConfigDialog( if (isLoading) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center ) { - Text("Loading...") + Text("Loading configuration...") } } else { - // Tab Row + // 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) ) { - DefaultButton( + McpTabButton( + text = "Tools", + selected = selectedTab == 0, onClick = { selectedTab = 0 }, - enabled = selectedTab != 0 - ) { - Text("Tools") - } - DefaultButton( + modifier = Modifier.weight(1f) + ) + McpTabButton( + text = "MCP Servers", + selected = selectedTab == 1, onClick = { selectedTab = 1 }, - enabled = selectedTab != 1 - ) { - Text("MCP Servers") - } + modifier = Modifier.weight(1f) + ) } Spacer(modifier = Modifier.height(12.dp)) - // Error message + // Error message with styled container mcpLoadError?.let { error -> - Text(error, color = org.jetbrains.jewel.foundation.theme.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)) } // Tab content - Box(modifier = Modifier.weight(1f)) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { when (selectedTab) { 0 -> McpToolsTab( mcpTools = mcpTools, @@ -271,7 +422,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) { @@ -306,19 +456,48 @@ fun IdeaMcpConfigDialog( 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("MCP Tools: $enabledMcp/$totalMcp enabled") + // 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) + ) + ) + } + } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedButton(onClick = onDismiss) { - Text("Close") + Text("Cancel") + } + DefaultButton(onClick = onDismiss) { + Text("Apply & Close") } } } @@ -327,33 +506,109 @@ fun IdeaMcpConfigDialog( } } +@Composable +private fun McpTabButton( + text: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(6.dp)) + .background( + if (selected) JewelTheme.globalColors.panelBackground + else Color.Transparent + ) + .clickable(onClick = onClick) + .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.6f) + ) + ) + } +} + @Composable private fun McpToolsTab( mcpTools: Map>, 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) } ) } } @@ -363,13 +618,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) + ) } } } @@ -407,34 +827,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") } } } } - 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..cb9c6b5c13 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptEnhancer.kt @@ -0,0 +1,177 @@ +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.diagnostic.Logger +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) { + private val logger = Logger.getInstance(IdeaPromptEnhancer::class.java) + + /** + * 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 { + // Log only metadata to avoid leaking sensitive information + logger.info("Starting enhancement for input (length: ${input.length})") + + 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() + 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() + + // Use streamPrompt with compileDevIns=false since we're sending a raw prompt + llmService.streamPrompt(prompt, compileDevIns = false).collect { chunk -> + result.append(chunk) + } + + 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 + } + } + + /** + * 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 "" + // 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}") + "" + } + } + + /** + * 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) { + logger.debug("Failed to load README: ${e.message}") + "" + } + } + + /** + * 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 (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() + } + + // 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/editor/IdeaTopToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt index fecb461afc..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,21 +1,26 @@ 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 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.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,99 +29,207 @@ 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: + * - 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 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 ) { - Row( + 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) } } + 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()) } + + Column( modifier = modifier .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalAlignment = Alignment.CenterVertically + .animateContentSize() ) { - // Left side: Action buttons + // 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 ) { - // @ 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)) + // 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 + ) + } } - // Clipboard button - ToolbarIconButton(onClick = onClipboardClick, tooltip = "Paste from Clipboard") { - Icon( - imageVector = IdeaComposeIcons.ContentPaste, - contentDescription = "Clipboard", - modifier = Modifier.size(16.dp), - tint = JewelTheme.globalColors.text.normal + + // Separator + if (selectedFiles.isNotEmpty() || relatedFiles.isNotEmpty()) { + Box( + Modifier + .width(1.dp) + .height(20.dp) + .background(JewelTheme.globalColors.borders.normal.copy(alpha = 0.5f)) ) } - // 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 - ) + + // 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)) } - // Cursor button - ToolbarIconButton(onClick = onCursorClick, tooltip = "Current Selection") { - Icon( - imageVector = IdeaComposeIcons.TextFields, - contentDescription = "Cursor", - modifier = Modifier.size(16.dp), - tint = JewelTheme.globalColors.text.normal - ) + + // Expand/Collapse button - only show if there are files + if (selectedFiles.size > 1) { + ToolbarIconButton( + onClick = { isExpanded = !isExpanded }, + tooltip = if (isExpanded) "Collapse file list" else "Expand file list" + ) { + Icon( + imageVector = if (isExpanded) IdeaComposeIcons.ExpandLess else IdeaComposeIcons.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand", + 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)) + // 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) }) + } + } } + } +} + +/** + * 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") + } - // Selected files as chips + Tooltip(tooltip = { Text(tooltipText) }) { Row( - modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.spacedBy(4.dp), + 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 ) { - selectedFiles.forEach { file -> - FileChip(file = file, onRemove = { onRemoveFile(file) }) + 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 + ) + ) } - } - - // 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 - ) } } } @@ -163,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 @@ -180,10 +293,74 @@ 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, 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/renderer/JewelRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt index 1c601da634..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 @@ -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"] @@ -404,5 +415,155 @@ 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 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, + 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?, + cancelledByUser: Boolean + ) { + // 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 -> + // Check cancelledByUser first to handle cancelled commands with exit code 0 + val result = when { + 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" + ) + ) + } + 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(), + "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/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/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/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/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 a30043e1f4..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 @@ -5,10 +5,11 @@ 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 +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,19 @@ object IdeaSketchRenderer { if (fence.text.isNotBlank()) { IdeaDiffRenderer( diffContent = fence.text, + project = project, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + + "plan" -> { + if (fence.text.isNotBlank()) { + IdeaPlanRenderer( + planContent = fence.text, + project = project, + isComplete = blockIsComplete, modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(8.dp)) @@ -75,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( @@ -89,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() @@ -111,14 +147,11 @@ object IdeaSketchRenderer { } else -> { - if (fence.text.isNotBlank()) { - IdeaCodeBlockRenderer( - code = fence.text, - language = fence.languageId, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(8.dp)) - } + JewelMarkdownRenderer( + fence.text, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) } } } @@ -130,4 +163,3 @@ object IdeaSketchRenderer { } } } - 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/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" + } +} + 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 +} + 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/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() + } +} + 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 +} + 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 656fe58752..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 @@ -5,13 +5,8 @@ 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.toolwindow.codereview.IdeaCodeReviewContent import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewViewModel @@ -28,17 +23,11 @@ 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. @@ -162,7 +151,10 @@ fun IdeaAgentApp( timeline = timeline, streamingOutput = streamingOutput, listState = listState, - project = project + project = project, + onProcessCancel = { cancelEvent -> + viewModel.handleProcessCancel(cancelEvent) + } ) }, bottom = { @@ -174,7 +166,6 @@ fun IdeaAgentApp( onAbort = { viewModel.cancelTask() }, workspacePath = project.basePath, totalTokens = null, - onSettingsClick = { viewModel.setShowConfigDialog(true) }, onAtClick = {}, availableConfigs = availableConfigs, currentConfigName = currentConfigName, @@ -220,7 +211,6 @@ fun IdeaAgentApp( onAbort = { remoteVm.cancelTask() }, workspacePath = project.basePath, totalTokens = null, - onSettingsClick = { viewModel.setShowConfigDialog(true) }, onAtClick = {}, availableConfigs = availableConfigs, currentConfigName = currentConfigName, @@ -255,6 +245,7 @@ fun IdeaAgentApp( // Tool loading status bar IdeaToolLoadingStatusBar( viewModel = viewModel, + project = project, modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp) ) } @@ -297,113 +288,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, - onSettingsClick: () -> Unit = {}, - onAtClick: () -> Unit = {}, - availableConfigs: List = emptyList(), - currentConfigName: String? = null, - onConfigSelect: (NamedModelConfig) -> Unit = {}, - onConfigureClick: () -> Unit = {} -) { - var inputText by remember { mutableStateOf("") } - var devInInput by remember { mutableStateOf(null) } - - Column( - modifier = Modifier.fillMaxSize().padding(8.dp) - ) { - // 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) { - onSend(text) - clearInput() - inputText = "" - } - } - - 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 - IdeaBottomToolbar( - onSendClick = { - val text = devInInput?.text?.trim() ?: inputText.trim() - if (text.isNotBlank() && !isProcessing) { - onSend(text) - devInInput?.clearInput() - inputText = "" - } - }, - sendEnabled = inputText.isNotBlank() && !isProcessing, - isExecuting = isProcessing, - onStopClick = onAbort, - onSettingsClick = onSettingsClick, - totalTokens = totalTokens, - availableConfigs = availableConfigs, - currentConfigName = currentConfigName, - onConfigSelect = onConfigSelect, - onConfigureClick = onConfigureClick - ) - } -} - 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..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 @@ -9,7 +9,10 @@ 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.idea.services.IdeaToolConfigService import cc.unitmesh.devins.ui.config.AutoDevConfigWrapper import cc.unitmesh.devins.ui.config.ConfigManager import cc.unitmesh.llm.KoogLLMService @@ -65,6 +68,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 +129,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() } @@ -145,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()) { @@ -394,6 +410,47 @@ 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) { + // 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() + + // 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). */ @@ -411,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/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt index 5ffb0dbc7f..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 @@ -1540,5 +1540,203 @@ 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() + } + + /** + * 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() + } + + /** + * 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() + } + } 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..6354f4077b --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt @@ -0,0 +1,248 @@ +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 +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 org.jetbrains.jewel.foundation.theme.JewelTheme +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 + * + * Layout: Unified border around the entire input area for a cohesive look. + */ +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, + 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()) } + 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) + + // Outer container with unified border + Column( + modifier = Modifier + .fillMaxSize() + .padding(8.dp) + .clip(borderShape) + .border( + width = 1.dp, + color = JewelTheme.globalColors.borders.normal, + shape = borderShape + ) + ) { + // Top toolbar with file selection (no individual border) + 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) { + // 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() } + ) + } + } + + 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 (no individual border) + IdeaBottomToolbar( + project = project, + onSendClick = { + val text = devInInput?.text?.trim() ?: inputText.trim() + if (text.isNotBlank() && !isProcessing) { + buildAndSendMessage( + text = text, + selectedFiles = selectedFiles, + onSend = onSend, + clearInput = { + devInInput?.clearInput() + inputText = "" + }, + clearFiles = { selectedFiles = emptyList() } + ) + } + }, + sendEnabled = inputText.isNotBlank() && !isProcessing, + isExecuting = isProcessing, + 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(Dispatchers.IO) { + try { + inputAreaLogger.info("Starting prompt enhancement...") + val enhancer = IdeaPromptEnhancer.getInstance(project) + val enhanced = enhancer.enhance(currentText) + inputAreaLogger.info("Enhancement completed, result length: ${enhanced.length}") + + if (enhanced != currentText && enhanced.isNotBlank()) { + // 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)") + } + } catch (e: Exception) { + inputAreaLogger.error("Prompt enhancement failed: ${e.message}", e) + } finally { + ApplicationManager.getApplication().invokeLater { + isEnhancing = false + } + } + } + } + }, + isEnhancing = isEnhancing, + totalTokens = totalTokens, + availableConfigs = availableConfigs, + currentConfigName = currentConfigName, + onConfigSelect = onConfigSelect, + onConfigureClick = onConfigureClick + ) + } +} \ No newline at end of file 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)) } 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 @@ + + = 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 { + // Distinguish between user cancellation and other failures + val errorMessage = if (cancelledByUser) { + "Command cancelled by user" + } 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(), + "output" to (output ?: ""), + "cancelled" to cancelledByUser.toString() + ) + ) + } + 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() 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..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 @@ -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,264 @@ 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?, + cancelledByUser: Boolean + ) { + activeSessions.remove(sessionId) + val statusSymbol = when { + cancelledByUser -> "⚠" + exitCode == 0 -> "✓" + else -> "✗" + } + 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()) { + 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() + 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 = 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 + ) + ) + } + exitCode == 0 -> { + cc.unitmesh.agent.tool.ToolResult.Success( + content = output.ifEmpty { "(no output)" }, + metadata = mapOf( + "exit_code" to exitCode.toString(), + "execution_time_ms" to executionTimeMs.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(), + "execution_time_ms" to executionTimeMs.toString(), + "session_id" to session.sessionId + ) + ) + } + } + } + + /** + * 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, + 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) + + // 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 + 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 = buildCancelledMessage(session.command, exitCode, output), + 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() + ) + ) + } + } + } + private fun formatCliParameters(params: String): String { val trimmed = params.trim()