package cc.unitmesh.devins.idea.renderer import cc.unitmesh.agent.database.DryRunResult import cc.unitmesh.agent.plan.AgentPlan import cc.unitmesh.agent.plan.MarkdownPlanParser import cc.unitmesh.agent.render.BaseRenderer import cc.unitmesh.agent.render.ChatDBStepStatus import cc.unitmesh.agent.render.ChatDBStepType import cc.unitmesh.devins.llm.MessageRole import cc.unitmesh.agent.render.RendererUtils import cc.unitmesh.agent.render.TaskInfo import cc.unitmesh.agent.render.TaskStatus import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.agent.render.ToolCallDisplayInfo import cc.unitmesh.agent.render.ToolCallInfo import cc.unitmesh.agent.subagent.SqlOperationType import cc.unitmesh.agent.tool.ToolType import cc.unitmesh.agent.tool.toToolType import cc.unitmesh.devins.llm.Message import cc.unitmesh.llm.compression.TokenInfo import com.intellij.openapi.diagnostic.Logger import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update private val jewelRendererLogger = Logger.getInstance("JewelRenderer") /** * Jewel-compatible Renderer for IntelliJ IDEA plugin. * * Uses Kotlin StateFlow instead of Compose mutableStateOf to avoid * ClassLoader conflicts with IntelliJ's bundled Compose runtime. * * Implements CodingAgentRenderer interface from mpp-core. * * This renderer is designed for maximum reusability and can be used with * any Compose-based UI (Jewel, Material Design, etc.) as it only exposes * StateFlow-based state. */ class JewelRenderer( private val streamingUpdateDebounceMs: Long = 50L ) : BaseRenderer() { // Timeline of all events (messages, tool calls, results) private val _timeline = MutableStateFlow>(emptyList()) val timeline: StateFlow> = _timeline.asStateFlow() // Current streaming output from LLM private val _currentStreamingOutput = MutableStateFlow("") val currentStreamingOutput: StateFlow = _currentStreamingOutput.asStateFlow() // Thinking content state - displayed in a collapsible, scrolling area private val _currentThinkingOutput = MutableStateFlow("") val currentThinkingOutput: StateFlow = _currentThinkingOutput.asStateFlow() private val _isThinking = MutableStateFlow(false) val isThinking: StateFlow = _isThinking.asStateFlow() // Debouncing mechanism for streaming updates to prevent EDT spam private var lastStreamingUpdateTime = 0L // Update UI at most every streamingUpdateDebounceMs private var pendingStreamingContent: String? = null // Processing state private val _isProcessing = MutableStateFlow(false) val isProcessing: StateFlow = _isProcessing.asStateFlow() // Iteration tracking private val _currentIteration = MutableStateFlow(0) val currentIteration: StateFlow = _currentIteration.asStateFlow() private val _maxIterations = MutableStateFlow(100) val maxIterations: StateFlow = _maxIterations.asStateFlow() // Current active tool call private val _currentToolCall = MutableStateFlow(null) val currentToolCall: StateFlow = _currentToolCall.asStateFlow() // Error state private val _errorMessage = MutableStateFlow(null) val errorMessage: StateFlow = _errorMessage.asStateFlow() // Task completion state private val _taskCompleted = MutableStateFlow(false) val taskCompleted: StateFlow = _taskCompleted.asStateFlow() // Token tracking private val _totalTokenInfo = MutableStateFlow(TokenInfo()) val totalTokenInfo: StateFlow = _totalTokenInfo.asStateFlow() private var _lastMessageTokenInfo: TokenInfo? = null // Execution timing private var executionStartTime = 0L private val _currentExecutionTime = MutableStateFlow(0L) val currentExecutionTime: StateFlow = _currentExecutionTime.asStateFlow() // Plan tracking (from plan management tool) private val _currentPlan = MutableStateFlow(null) val currentPlan: StateFlow = _currentPlan.asStateFlow() // Task tracking (from task-boundary tool) private val _tasks = MutableStateFlow>(emptyList()) val tasks: StateFlow> = _tasks.asStateFlow() // SQL approval state private val _pendingSqlApproval = MutableStateFlow(null) val pendingSqlApproval: StateFlow = _pendingSqlApproval.asStateFlow() /** * Set the current plan directly. * Used to sync with PlanStateService from CodingAgent. */ fun setPlan(plan: AgentPlan?) { jewelRendererLogger.info("setPlan: plan=${plan != null}, tasks=${plan?.tasks?.size ?: 0}") _currentPlan.value = plan } // BaseRenderer implementation override fun renderIterationHeader(current: Int, max: Int) { _currentIteration.value = current _maxIterations.value = max } override fun renderLLMResponseStart() { super.renderLLMResponseStart() _currentStreamingOutput.value = "" _currentThinkingOutput.value = "" _isThinking.value = false _isProcessing.value = true if (executionStartTime == 0L) { executionStartTime = System.currentTimeMillis() } _currentExecutionTime.value = System.currentTimeMillis() - executionStartTime } override fun renderLLMResponseChunk(chunk: String) { reasoningBuffer.append(chunk) // Wait for more content if we detect an incomplete devin block if (hasIncompleteDevinBlock(reasoningBuffer.toString())) { return } // Extract thinking content val extraction = extractThinkingContent(reasoningBuffer.toString()) // Handle thinking content if (extraction.hasThinking) { val thinkContent = extraction.thinkingContent.toString() if (thinkContent.isNotEmpty()) { val wasInThinkBlock = isInThinkBlock isInThinkBlock = extraction.hasIncompleteThinkBlock renderThinkingChunk( thinkContent, isStart = !wasInThinkBlock && (extraction.hasCompleteThinkBlock || extraction.hasIncompleteThinkBlock), isEnd = extraction.hasCompleteThinkBlock && !extraction.hasIncompleteThinkBlock ) } } else if (isInThinkBlock && !extraction.hasIncompleteThinkBlock) { isInThinkBlock = false _isThinking.value = false } // Filter devin blocks and output clean content val processedContent = filterDevinBlocks(extraction.contentWithoutThinking) val cleanContent = cleanNewlines(processedContent) // Debounce streaming updates to prevent EDT spam during rapid LLM streaming val now = System.currentTimeMillis() pendingStreamingContent = cleanContent if (now - lastStreamingUpdateTime >= streamingUpdateDebounceMs) { lastStreamingUpdateTime = now _currentStreamingOutput.value = cleanContent } } override fun renderThinkingChunk(chunk: String, isStart: Boolean, isEnd: Boolean) { if (isStart) { _currentThinkingOutput.value = "" _isThinking.value = true } // Append thinking content - keep only last N lines for scrolling effect val maxLines = 5 val currentLines = _currentThinkingOutput.value.lines().toMutableList() val newLines = chunk.lines() currentLines.addAll(newLines) // Keep only last N lines val trimmedLines = if (currentLines.size > maxLines) { currentLines.takeLast(maxLines) } else { currentLines } _currentThinkingOutput.value = trimmedLines.joinToString("\n") if (isEnd) { _isThinking.value = false } } override fun renderLLMResponseEnd() { super.renderLLMResponseEnd() // Flush any pending content that was debounced val finalContent = pendingStreamingContent ?: _currentStreamingOutput.value pendingStreamingContent = null lastStreamingUpdateTime = 0L val content = finalContent.trim() if (content.isNotEmpty()) { addTimelineItem( TimelineItem.MessageItem( role = MessageRole.ASSISTANT, content = content, tokenInfo = _lastMessageTokenInfo ) ) } _currentStreamingOutput.value = "" _currentThinkingOutput.value = "" _isThinking.value = false _isProcessing.value = false _lastMessageTokenInfo = null } override fun renderToolCall(toolName: String, paramsStr: String) { jewelRendererLogger.info("renderToolCall: toolName=$toolName, paramsStr length=${paramsStr.length}") val toolInfo = formatToolCallDisplay(toolName, paramsStr) val params = parseParamsString(paramsStr) val toolType = toolName.toToolType() jewelRendererLogger.info("renderToolCall: parsed params keys=${params.keys}") // Handle task-boundary tool - update task list if (toolName == "task-boundary") { updateTaskFromToolCall(params) } // Handle plan management tool - update plan state if (toolName == "plan") { jewelRendererLogger.info("renderToolCall: detected plan tool, calling updatePlanFromToolCall") updatePlanFromToolCall(params) // Skip rendering plan tool to timeline - it's shown in PlanSummaryBar return } renderToolCallInternal(toolName, toolInfo, params, paramsStr, toolType) } /** * Render a tool call with parsed parameters. * This is the preferred method as it avoids string parsing issues with complex values. */ override fun renderToolCallWithParams(toolName: String, params: Map) { // Convert params to string format for display val paramsStr = params.entries.joinToString(" ") { (key, value) -> "$key=\"$value\"" } val toolInfo = formatToolCallDisplay(toolName, paramsStr) val toolType = toolName.toToolType() // Convert Map to Map for internal use val stringParams = params.mapValues { it.value.toString() } // Handle task-boundary tool - update task list if (toolName == "task-boundary") { updateTaskFromToolCall(stringParams) } // Handle plan management tool - update plan state with original params if (toolName == "plan") { updatePlanFromToolCallWithAnyParams(params) // Skip rendering plan tool to timeline - it's shown in PlanSummaryBar return } renderToolCallInternal(toolName, toolInfo, stringParams, paramsStr, toolType) } /** * Internal method to render tool call UI elements */ private fun renderToolCallInternal( toolName: String, toolInfo: ToolCallDisplayInfo, params: Map, paramsStr: String, toolType: ToolType? ) { // Skip adding ToolCallItem for Shell - will be replaced by LiveTerminalItem // This prevents the "two bubbles" problem if (toolType == ToolType.Shell) { _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"] else -> null } _currentToolCall.value = ToolCallInfo( toolName = toolInfo.toolName, description = toolInfo.description, details = toolInfo.details ) addTimelineItem( TimelineItem.ToolCallItem( toolName = toolInfo.toolName, description = toolInfo.description, params = toolInfo.details ?: paramsStr, fullParams = paramsStr, filePath = filePath, toolType = toolType, success = null, summary = null, output = null, fullOutput = null, executionTimeMs = null ) ) } /** * Update plan state from plan management tool call (string params version) */ private fun updatePlanFromToolCall(params: Map) { val action = params["action"]?.uppercase() ?: return val planMarkdown = params["planMarkdown"] ?: "" val taskIndex = params["taskIndex"]?.toIntOrNull() val stepIndex = params["stepIndex"]?.toIntOrNull() updatePlanState(action, planMarkdown, taskIndex, stepIndex) } /** * Update plan state from plan management tool call with Any params. * This is the preferred method as it handles complex values correctly. */ private fun updatePlanFromToolCallWithAnyParams(params: Map) { val action = (params["action"] as? String)?.uppercase() ?: return val planMarkdown = params["planMarkdown"] as? String ?: "" val taskIndex = when (val v = params["taskIndex"]) { is Number -> v.toInt() is String -> v.toIntOrNull() else -> null } val stepIndex = when (val v = params["stepIndex"]) { is Number -> v.toInt() is String -> v.toIntOrNull() else -> null } updatePlanState(action, planMarkdown, taskIndex, stepIndex) } /** * Internal method to update plan state */ private fun updatePlanState(action: String, planMarkdown: String, taskIndex: Int?, stepIndex: Int?) { jewelRendererLogger.info("updatePlanState: action=$action, planMarkdown length=${planMarkdown.length}") when (action) { "CREATE", "UPDATE" -> { if (planMarkdown.isNotBlank()) { try { val plan = MarkdownPlanParser.parseToPlan(planMarkdown) jewelRendererLogger.info("Parsed plan: ${plan.tasks.size} tasks") _currentPlan.value = plan } catch (e: Exception) { jewelRendererLogger.warn("Failed to parse plan markdown", e) // Keep previous valid plan on parse failure } } } "COMPLETE_STEP" -> { if (taskIndex == null || stepIndex == null) return _currentPlan.value?.let { plan -> if (taskIndex in 1..plan.tasks.size) { val task = plan.tasks[taskIndex - 1] if (stepIndex in 1..task.steps.size) { val step = task.steps[stepIndex - 1] step.complete() task.updateStatusFromSteps() // Trigger recomposition by creating a new plan instance _currentPlan.value = plan.copy(updatedAt = System.currentTimeMillis()) } } } } "FAIL_STEP" -> { if (taskIndex == null || stepIndex == null) return _currentPlan.value?.let { plan -> if (taskIndex in 1..plan.tasks.size) { val task = plan.tasks[taskIndex - 1] if (stepIndex in 1..task.steps.size) { val step = task.steps[stepIndex - 1] step.fail() task.updateStatusFromSteps() _currentPlan.value = plan.copy(updatedAt = System.currentTimeMillis()) } } } } // VIEW action doesn't modify state } } /** * Update task list from task-boundary tool call */ private fun updateTaskFromToolCall(params: Map) { val taskName = params["taskName"] ?: return val statusStr = params["status"] ?: "WORKING" val summary = params["summary"] ?: "" val status = TaskStatus.fromString(statusStr) _tasks.update { currentTasks -> val existingIndex = currentTasks.indexOfFirst { it.taskName == taskName } if (existingIndex >= 0) { // Update existing task currentTasks.toMutableList().apply { this[existingIndex] = currentTasks[existingIndex].copy( status = status, summary = summary, timestamp = System.currentTimeMillis() ) } } else { // Add new task currentTasks + TaskInfo( taskName = taskName, status = status, summary = summary ) } } } /** * Handle task-boundary tool call to update task progress display. * Overrides the interface method to provide UI-specific task tracking. */ override fun handleTaskBoundary(taskName: String, status: String, summary: String) { updateTaskFromToolCall(mapOf( "taskName" to taskName, "status" to status, "summary" to summary )) } override fun renderToolResult( toolName: String, success: Boolean, output: String?, fullOutput: String?, metadata: Map ) { // Capture current tool call info before clearing val currentToolCallInfo = _currentToolCall.value _currentToolCall.value = null val summary = formatToolResultSummary(toolName, success, output) val toolType = toolName.toToolType() val executionTime = metadata["execution_time_ms"]?.toLongOrNull() // For shell commands, check if it's a live session val isLiveSession = metadata["isLiveSession"] == "true" val sessionId = metadata["sessionId"] val liveExitCode = metadata["exit_code"]?.toIntOrNull() val stillRunning = metadata["stillRunning"] == "true" if (toolType == ToolType.Shell && stillRunning) { // Don't mark the session as completed on initial timeout. // Live output is already visible in the LiveTerminalItem. return } if (toolType == ToolType.Shell && output != null) { val exitCode = liveExitCode ?: (if (success) 0 else 1) val executionTimeMs = executionTime ?: 0L val command = currentToolCallInfo?.details?.removePrefix("Executing: ") ?: "unknown" if (isLiveSession && sessionId != null) { // Update the existing LiveTerminalItem with completion status _timeline.update { items -> items.map { item -> if (item is TimelineItem.LiveTerminalItem && item.sessionId == sessionId) { item.copy( exitCode = exitCode, executionTimeMs = executionTimeMs, output = fullOutput ?: output ) } else { item } } } } else { // Replace the last tool call with terminal output _timeline.update { items -> val lastItem = items.lastOrNull() if (lastItem is TimelineItem.ToolCallItem && lastItem.toolType == ToolType.Shell) { items.dropLast(1) } else { items } } addTimelineItem( TimelineItem.TerminalOutputItem( command = command, output = fullOutput ?: output, exitCode = exitCode, executionTimeMs = executionTimeMs ) ) } } else { // Update the last tool call item with result _timeline.update { items -> items.mapIndexed { index, item -> if (index == items.lastIndex && item is TimelineItem.ToolCallItem && item.success == null) { item.copy( success = success, summary = summary, output = if (success && output != null) { when (toolName) { "glob", "grep" -> output "docql" -> output else -> if (output.length <= 2000) output else "${output.take(2000)}...\n[Output truncated]" } } else null, fullOutput = fullOutput ?: output, executionTimeMs = executionTime ) } else { item } } } } } override fun renderTaskComplete(executionTimeMs: Long, toolsUsedCount: Int) { _taskCompleted.value = true _isProcessing.value = false // Add a completion message with execution time and tool usage to the timeline val parts = mutableListOf() if (executionTimeMs > 0) { val seconds = executionTimeMs / 1000.0 val rounded = (seconds * 100).toLong() / 100.0 parts.add("${rounded}s") } if (toolsUsedCount > 0) { parts.add("$toolsUsedCount tools") } if (parts.isNotEmpty()) { _timeline.update { items -> items + TimelineItem.MessageItem( message = Message( role = MessageRole.ASSISTANT, content = "✓ Task marked as complete (${parts.joinToString(", ")})" ) ) } } } override fun renderFinalResult(success: Boolean, message: String, iterations: Int) { addTimelineItem( TimelineItem.TaskCompleteItem( success = success, message = message, iterations = iterations ) ) _isProcessing.value = false executionStartTime = 0L } override fun renderError(message: String) { _errorMessage.value = message addTimelineItem(TimelineItem.ErrorItem(message)) _isProcessing.value = false } override fun renderRepeatWarning(toolName: String, count: Int) { val warning = "Tool '$toolName' called repeatedly ($count times)" addTimelineItem(TimelineItem.ErrorItem(warning)) } override fun renderRecoveryAdvice(recoveryAdvice: String) { addTimelineItem( TimelineItem.MessageItem( role = MessageRole.ASSISTANT, content = "Recovery Advice:\n$recoveryAdvice" ) ) } override fun renderUserConfirmationRequest(toolName: String, params: Map) { addTimelineItem( TimelineItem.MessageItem( role = MessageRole.SYSTEM, content = "Confirmation required for tool: $toolName" ) ) } override fun renderSqlApprovalRequest( sql: String, operationType: SqlOperationType, affectedTables: List, isHighRisk: Boolean, dryRunResult: DryRunResult?, onApprove: () -> Unit, onReject: () -> Unit ) { _pendingSqlApproval.value = SqlApprovalRequest( sql = sql, operationType = operationType, affectedTables = affectedTables, isHighRisk = isHighRisk, dryRunResult = dryRunResult, onApprove = { _pendingSqlApproval.value = null onApprove() }, onReject = { _pendingSqlApproval.value = null onReject() } ) // Build details map with dry run info val details = mutableMapOf( "sql" to sql, "operationType" to operationType.name, "affectedTables" to affectedTables.joinToString(", "), "isHighRisk" to isHighRisk ) if (dryRunResult != null) { details["dryRunValid"] = dryRunResult.isValid if (dryRunResult.estimatedRows != null) { details["estimatedRows"] = dryRunResult.estimatedRows!! } if (dryRunResult.warnings.isNotEmpty()) { details["warnings"] = dryRunResult.warnings.joinToString(", ") } } // Also add to timeline for visibility renderChatDBStep( stepType = ChatDBStepType.AWAIT_APPROVAL, status = ChatDBStepStatus.AWAITING_APPROVAL, title = "Awaiting Approval: ${operationType.name}", details = details ) } /** * Approve the pending SQL operation */ fun approveSqlOperation() { _pendingSqlApproval.value?.onApprove?.invoke() } /** * Reject the pending SQL operation */ fun rejectSqlOperation() { _pendingSqlApproval.value?.onReject?.invoke() } override fun updateTokenInfo(tokenInfo: TokenInfo) { _lastMessageTokenInfo = tokenInfo _totalTokenInfo.update { current -> TokenInfo( totalTokens = current.totalTokens + tokenInfo.totalTokens, inputTokens = current.inputTokens + tokenInfo.inputTokens, outputTokens = current.outputTokens + tokenInfo.outputTokens, timestamp = tokenInfo.timestamp ) } } // Public methods for UI interaction fun addUserMessage(content: String) { addTimelineItem( TimelineItem.MessageItem( role = MessageRole.USER, content = content ) ) } fun clearTimeline() { _timeline.value = emptyList() _currentStreamingOutput.value = "" _isProcessing.value = false _currentIteration.value = 0 _errorMessage.value = null _taskCompleted.value = false _totalTokenInfo.value = TokenInfo() _lastMessageTokenInfo = null executionStartTime = 0L _currentExecutionTime.value = 0L } fun clearError() { _errorMessage.value = null } fun reset() { clearTimeline() } /** * Force stop all processing states. * Used when user cancels a task. */ fun forceStop() { val currentOutput = _currentStreamingOutput.value.trim() if (currentOutput.isNotEmpty()) { addTimelineItem( TimelineItem.MessageItem( role = MessageRole.ASSISTANT, content = "$currentOutput\n\n[Interrupted]" ) ) } _isProcessing.value = false _currentStreamingOutput.value = "" _currentToolCall.value = null } private fun addTimelineItem(item: TimelineItem) { _timeline.update { it + item } } private fun formatToolCallDisplay(toolName: String, paramsStr: String) = RendererUtils.formatToolCallDisplay(toolName, paramsStr) private fun formatToolResultSummary(toolName: String, success: Boolean, output: String?) = RendererUtils.formatToolResultSummary(toolName, success, output) 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, output = output) } 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) // Session is likely still running (e.g. long-running server). Return Pending with an output snapshot // so the executor can include it in the prompt. val runningItem = _timeline.value.find { it is TimelineItem.LiveTerminalItem && it.sessionId == sessionId } as? TimelineItem.LiveTerminalItem val command = runningItem?.command?.ifBlank { "unknown" } ?: "unknown" val rawOutput = try { val session = cc.unitmesh.agent.tool.shell.ShellSessionManager.getSession(sessionId) session?.getOutput().orEmpty() } catch (_: Exception) { "" } val cleanOutput = cc.unitmesh.agent.tool.shell.AnsiStripper.stripAndNormalize(rawOutput) val outputLength = cleanOutput.length val maxChars = 4000 val snippet = if (outputLength > maxChars) cleanOutput.takeLast(maxChars) else cleanOutput cc.unitmesh.agent.tool.ToolResult.Pending( sessionId = sessionId, toolName = "shell", command = command, message = "Process still running after ${timeoutMs}ms", metadata = mapOf( "sessionId" to sessionId, "command" to command, "elapsed_ms" to timeoutMs.toString(), "partial_output" to snippet, "output_length" to outputLength.toString() ) ) } } /** * Render an Agent-generated sketch block (chart, nanodsl, mermaid, etc.) * Adds the sketch block to the timeline for interactive rendering. */ override fun renderAgentSketchBlock( agentName: String, language: String, code: String, metadata: Map ) { _timeline.update { current -> current + TimelineItem.AgentSketchBlockItem( agentName = agentName, language = language, code = code, metadata = metadata ) } } // ========== Multimodal Analysis Support ========== // Track the current multimodal analysis item ID for updates private var currentMultimodalAnalysisId: String? = null private var multimodalAnalysisStartTime: Long = 0L /** * Start a multimodal analysis session. * Adds a MultimodalAnalysisItem to the timeline with ANALYZING status. */ fun startMultimodalAnalysis( imageCount: Int, prompt: String, visionModel: String = "glm-4.6v" ) { multimodalAnalysisStartTime = System.currentTimeMillis() // Create image info list (we may not have full details at this point) val images = (1..imageCount).map { index -> cc.unitmesh.agent.render.ImageInfo( id = "image-$index", name = "Image $index", displaySize = "" ) } val item = TimelineItem.MultimodalAnalysisItem( images = images, prompt = prompt, visionModel = visionModel, status = cc.unitmesh.agent.render.MultimodalAnalysisStatus.ANALYZING, streamingResult = "" ) currentMultimodalAnalysisId = item.id addTimelineItem(item) } /** * Update the multimodal analysis with a streaming chunk. * Appends the chunk to the streamingResult. */ fun updateMultimodalAnalysisChunk(chunk: String) { val analysisId = currentMultimodalAnalysisId ?: return _timeline.update { items -> items.map { item -> if (item is TimelineItem.MultimodalAnalysisItem && item.id == analysisId) { item.copy( status = cc.unitmesh.agent.render.MultimodalAnalysisStatus.STREAMING, streamingResult = item.streamingResult + chunk ) } else { item } } } } /** * Complete the multimodal analysis. * Updates status to COMPLETED or FAILED. */ fun completeMultimodalAnalysis(finalResult: String?, error: String?) { val analysisId = currentMultimodalAnalysisId ?: return val executionTime = System.currentTimeMillis() - multimodalAnalysisStartTime _timeline.update { items -> items.map { item -> if (item is TimelineItem.MultimodalAnalysisItem && item.id == analysisId) { item.copy( status = if (error != null) { cc.unitmesh.agent.render.MultimodalAnalysisStatus.FAILED } else { cc.unitmesh.agent.render.MultimodalAnalysisStatus.COMPLETED }, finalResult = finalResult, error = error, executionTimeMs = executionTime ) } else { item } } } currentMultimodalAnalysisId = null multimodalAnalysisStartTime = 0L } } /** * Data class representing a pending SQL approval request */ data class SqlApprovalRequest( val sql: String, val operationType: SqlOperationType, val affectedTables: List, val isHighRisk: Boolean, val dryRunResult: DryRunResult? = null, val onApprove: () -> Unit, val onReject: () -> Unit )