From e435d1cbb362ea9b6951f6aa9d0d61b04cf46fd3 Mon Sep 17 00:00:00 2001 From: Fengda Huang Date: Mon, 1 Dec 2025 00:57:58 +0000 Subject: [PATCH 1/2] feat(mpp-idea): add IdeaDevInBlockRenderer for devin block support Add missing devin block renderer to IdeaSketchRenderer to achieve feature parity with mpp-ui SketchRenderer. Changes: - Create IdeaDevInBlockRenderer.kt with Jewel theming - Parses devin blocks using ToolCallParser - Renders tool calls in expandable card format - Shows tool name, icon, and truncated parameters - Supports expand/collapse for full JSON parameter view - Falls back to IdeaCodeBlockRenderer for incomplete content - Update IdeaSketchRenderer.kt to route 'devin' blocks to new renderer - Update doc comment to reflect new DevIn support This ensures IdeaSketchRenderer supports all block types that SketchRenderer in mpp-ui supports: - markdown, md, empty -> SimpleJewelMarkdown - diff, patch -> IdeaDiffRenderer - thinking -> IdeaThinkingBlockRenderer - walkthrough -> IdeaWalkthroughBlockRenderer - mermaid, mmd -> MermaidDiagramView - devin -> IdeaDevInBlockRenderer (NEW) - else -> IdeaCodeBlockRenderer --- .../renderer/sketch/IdeaDevInBlockRenderer.kt | 263 ++++++++++++++++++ .../renderer/sketch/IdeaSketchRenderer.kt | 12 + 2 files changed, 275 insertions(+) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDevInBlockRenderer.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDevInBlockRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDevInBlockRenderer.kt new file mode 100644 index 0000000000..4ba0e4fe7d --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDevInBlockRenderer.kt @@ -0,0 +1,263 @@ +package cc.unitmesh.devins.idea.renderer.sketch + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.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.parser.ToolCallParser +import cc.unitmesh.agent.tool.ToolType +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import cc.unitmesh.devins.workspace.WorkspaceManager +import kotlinx.serialization.json.Json +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.Text + +/** + * DevIn Block Renderer for IntelliJ IDEA with Jewel styling. + * + * Parses devin blocks (language id = "devin") and renders them as tool call items + * when the block is complete. Similar to DevInBlockRenderer in mpp-ui but using + * Jewel theming. + */ +@Composable +fun IdeaDevInBlockRenderer( + devinContent: String, + isComplete: Boolean, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + if (isComplete) { + // Parse the devin block to extract tool calls + val parser = remember { ToolCallParser() } + val wrappedContent = "\n$devinContent\n" + val toolCalls = remember(devinContent) { parser.parseToolCalls(wrappedContent) } + + if (toolCalls.isNotEmpty()) { + // Get workspace root path for resolving relative paths + val workspaceRoot = WorkspaceManager.currentWorkspace?.rootPath + + toolCalls.forEach { toolCall -> + val toolName = toolCall.toolName + val params = toolCall.params + + // Format details string (for display) + val details = formatToolCallDetails(params) + + // Resolve relative path to absolute path using workspace root + val relativePath = params["path"] as? String + val filePath = resolveAbsolutePath(relativePath, workspaceRoot) + + // Map tool name to ToolType + val toolType = ToolType.fromName(toolName) + + IdeaDevInToolItem( + toolName = toolName, + details = details, + filePath = filePath, + toolType = toolType, + params = params, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + } else { + // If no tool calls found, render as code block + IdeaCodeBlockRenderer( + code = devinContent, + language = "devin", + modifier = Modifier.fillMaxWidth() + ) + } + } else { + // If not complete, show as code block (streaming) + IdeaCodeBlockRenderer( + code = devinContent, + language = "devin", + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +/** + * Tool item display for DevIn block parsing results. + * Shows tool name, type icon, and parameters in a compact expandable format. + */ +@Composable +private fun IdeaDevInToolItem( + toolName: String, + details: String, + filePath: String?, + toolType: ToolType?, + params: Map, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + val hasParams = params.isNotEmpty() + + Box( + modifier = modifier + .background( + color = JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f), + shape = RoundedCornerShape(4.dp) + ) + .padding(8.dp) + ) { + Column { + // Header row: Tool icon + Tool name + Details + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { if (hasParams) expanded = !expanded }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Tool type icon + Icon( + imageVector = IdeaComposeIcons.Build, + contentDescription = "Tool", + modifier = Modifier.size(16.dp), + tint = AutoDevColors.Blue.c400 + ) + + // Tool name + Text( + text = toolName, + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 12.sp + ) + ) + + // Details (truncated parameters) + if (details.isNotEmpty() && !expanded) { + Text( + text = details.take(60) + if (details.length > 60) "..." else "", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.info.copy(alpha = 0.7f) + ), + modifier = Modifier.weight(1f), + maxLines = 1 + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + } + + // Expand/collapse icon + if (hasParams) { + Icon( + imageVector = if (expanded) IdeaComposeIcons.ExpandLess else IdeaComposeIcons.ExpandMore, + contentDescription = if (expanded) "Collapse" else "Expand", + modifier = Modifier.size(16.dp), + tint = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) + } + } + + // Expanded parameters section + if (expanded && hasParams) { + Spacer(modifier = Modifier.height(8.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = JewelTheme.globalColors.panelBackground.copy(alpha = 0.3f), + shape = RoundedCornerShape(4.dp) + ) + .padding(8.dp) + ) { + Text( + text = formatParamsAsJson(params), + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + fontFamily = FontFamily.Monospace + ) + ) + } + } + } + } +} + +/** + * Resolve relative path to absolute path using workspace root + */ +private fun resolveAbsolutePath(relativePath: String?, workspaceRoot: String?): String? { + if (relativePath == null) return null + if (workspaceRoot == null) return relativePath + + // If already an absolute path, return as-is + if (relativePath.startsWith("/") || relativePath.matches(Regex("^[A-Za-z]:.*"))) { + return relativePath + } + + // Combine workspace root with relative path + val separator = if (workspaceRoot.endsWith("/") || workspaceRoot.endsWith("\\")) "" else "/" + return "$workspaceRoot$separator$relativePath" +} + +/** + * Format tool call parameters as a human-readable details string + */ +private fun formatToolCallDetails(params: Map): String { + return params.entries.joinToString(", ") { (key, value) -> + "$key=${truncateValue(value)}" + } +} + +/** + * Truncate long values for display + */ +private fun truncateValue(value: String, maxLength: Int = 100): String { + return if (value.length > maxLength) { + value.take(maxLength) + "..." + } else { + value + } +} + +/** + * Format parameters as JSON string for full display + */ +private fun formatParamsAsJson(params: Map): String { + return try { + Json { + prettyPrint = true + }.encodeToString( + kotlinx.serialization.serializer(), + params + ) + } catch (e: Exception) { + // Fallback to manual formatting + buildString { + appendLine("{") + params.entries.forEachIndexed { index, (key, value) -> + append(" \"$key\": ") + append("\"${value.replace("\"", "\\\"")}\"") + if (index < params.size - 1) { + appendLine(",") + } else { + appendLine() + } + } + append("}") + } + } +} + 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 db517348d2..209768341d 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 @@ -22,6 +22,7 @@ import org.jetbrains.jewel.ui.component.CircularProgressIndicator * - Thinking -> IdeaThinkingBlockRenderer * - Walkthrough -> IdeaWalkthroughBlockRenderer * - Mermaid -> MermaidDiagramView + * - DevIn -> IdeaDevInBlockRenderer */ object IdeaSketchRenderer { @@ -98,6 +99,17 @@ object IdeaSketchRenderer { } } + "devin" -> { + if (fence.text.isNotBlank()) { + IdeaDevInBlockRenderer( + devinContent = fence.text, + isComplete = blockIsComplete, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + else -> { if (fence.text.isNotBlank()) { IdeaCodeBlockRenderer( From e69d23e9b201e0aa601d30d5c20a3ddace63e7ba Mon Sep 17 00:00:00 2001 From: Fengda Huang Date: Mon, 1 Dec 2025 01:21:13 +0000 Subject: [PATCH 2/2] fix(mpp-idea): address review comments for IdeaDevInBlockRenderer Address review feedback from Augment Code and CodeRabbit: 1. Remove unused parameters from IdeaDevInToolItem: - Removed filePath and toolType parameters that were passed but not used - Removed associated computation (resolveAbsolutePath, ToolType.fromName) - Removed unused imports (ToolType, WorkspaceManager, Paths) 2. Hoist Json configuration to reusable instance: - Added PrettyJson val at module level to avoid repeated allocations - Updated formatParamsAsJson to use the hoisted instance 3. Fix JSON escaping in fallback formatter: - Added escapeJsonString helper function - Properly escape backslashes, quotes, newlines, carriage returns, and tabs - This prevents invalid JSON in the expanded parameters view --- .../renderer/sketch/IdeaDevInBlockRenderer.kt | 60 +++++++------------ 1 file changed, 21 insertions(+), 39 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDevInBlockRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDevInBlockRenderer.kt index 4ba0e4fe7d..f0739b5348 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDevInBlockRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDevInBlockRenderer.kt @@ -13,15 +13,18 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.unitmesh.agent.parser.ToolCallParser -import cc.unitmesh.agent.tool.ToolType import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.compose.theme.AutoDevColors -import cc.unitmesh.devins.workspace.WorkspaceManager import kotlinx.serialization.json.Json import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.Icon import org.jetbrains.jewel.ui.component.Text +/** + * Reusable Json instance with pretty print configuration + */ +private val PrettyJson = Json { prettyPrint = true } + /** * DevIn Block Renderer for IntelliJ IDEA with Jewel styling. * @@ -43,9 +46,6 @@ fun IdeaDevInBlockRenderer( val toolCalls = remember(devinContent) { parser.parseToolCalls(wrappedContent) } if (toolCalls.isNotEmpty()) { - // Get workspace root path for resolving relative paths - val workspaceRoot = WorkspaceManager.currentWorkspace?.rootPath - toolCalls.forEach { toolCall -> val toolName = toolCall.toolName val params = toolCall.params @@ -53,18 +53,9 @@ fun IdeaDevInBlockRenderer( // Format details string (for display) val details = formatToolCallDetails(params) - // Resolve relative path to absolute path using workspace root - val relativePath = params["path"] as? String - val filePath = resolveAbsolutePath(relativePath, workspaceRoot) - - // Map tool name to ToolType - val toolType = ToolType.fromName(toolName) - IdeaDevInToolItem( toolName = toolName, details = details, - filePath = filePath, - toolType = toolType, params = params, modifier = Modifier.fillMaxWidth() ) @@ -98,8 +89,6 @@ fun IdeaDevInBlockRenderer( private fun IdeaDevInToolItem( toolName: String, details: String, - filePath: String?, - toolType: ToolType?, params: Map, modifier: Modifier = Modifier ) { @@ -195,23 +184,6 @@ private fun IdeaDevInToolItem( } } -/** - * Resolve relative path to absolute path using workspace root - */ -private fun resolveAbsolutePath(relativePath: String?, workspaceRoot: String?): String? { - if (relativePath == null) return null - if (workspaceRoot == null) return relativePath - - // If already an absolute path, return as-is - if (relativePath.startsWith("/") || relativePath.matches(Regex("^[A-Za-z]:.*"))) { - return relativePath - } - - // Combine workspace root with relative path - val separator = if (workspaceRoot.endsWith("/") || workspaceRoot.endsWith("\\")) "" else "/" - return "$workspaceRoot$separator$relativePath" -} - /** * Format tool call parameters as a human-readable details string */ @@ -237,19 +209,17 @@ private fun truncateValue(value: String, maxLength: Int = 100): String { */ private fun formatParamsAsJson(params: Map): String { return try { - Json { - prettyPrint = true - }.encodeToString( + PrettyJson.encodeToString( kotlinx.serialization.serializer(), params ) } catch (e: Exception) { - // Fallback to manual formatting + // Fallback to manual formatting with proper JSON escaping buildString { appendLine("{") params.entries.forEachIndexed { index, (key, value) -> - append(" \"$key\": ") - append("\"${value.replace("\"", "\\\"")}\"") + append(" \"${escapeJsonString(key)}\": ") + append("\"${escapeJsonString(value)}\"") if (index < params.size - 1) { appendLine(",") } else { @@ -261,3 +231,15 @@ private fun formatParamsAsJson(params: Map): String { } } +/** + * Escape special characters for valid JSON string + */ +private fun escapeJsonString(value: String): String { + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") +} +