diff --git a/mpp-core/src/androidMain/kotlin/cc/unitmesh/agent/tool/impl/AndroidCodeParserFactory.kt b/mpp-core/src/androidMain/kotlin/cc/unitmesh/agent/tool/impl/AndroidCodeParserFactory.kt index 2126a4ec20..aa8ffbc166 100644 --- a/mpp-core/src/androidMain/kotlin/cc/unitmesh/agent/tool/impl/AndroidCodeParserFactory.kt +++ b/mpp-core/src/androidMain/kotlin/cc/unitmesh/agent/tool/impl/AndroidCodeParserFactory.kt @@ -1,15 +1,262 @@ package cc.unitmesh.agent.tool.impl +import cc.unitmesh.codegraph.model.* import cc.unitmesh.codegraph.parser.CodeParser -import cc.unitmesh.codegraph.parser.ios.IosCodeParser +import cc.unitmesh.codegraph.parser.Language /** - * Android implementation of CodeParser factory - * Uses the same JVM-based implementation as regular JVM + * Android implementation of CodeParser factory. + * + * Note: Android cannot access jvmMain code directly, so we provide a simplified + * regex-based implementation similar to iOS. For full TreeSitter functionality, + * consider using server-side parsing. */ actual fun createCodeParser(): CodeParser { - // Android uses JVM backend, but IosCodeParser is a fallback - // In practice, we should use JvmCodeParser but it's not accessible from androidMain - // For now, use the simplified iOS implementation - return IosCodeParser() + return AndroidCodeParser() +} + +/** + * Simplified CodeParser for Android platform. + * Uses regex-based parsing to extract basic code structure information. + */ +private class AndroidCodeParser : CodeParser { + + override suspend fun parseNodes( + sourceCode: String, + filePath: String, + language: Language + ): List { + return when (language) { + Language.JAVA, Language.KOTLIN -> parseOOPNodes(sourceCode, filePath, language) + Language.JAVASCRIPT, Language.TYPESCRIPT -> parseJSNodes(sourceCode, filePath, language) + Language.PYTHON -> parsePythonNodes(sourceCode, filePath, language) + else -> emptyList() + } + } + + override suspend fun parseNodesAndRelationships( + sourceCode: String, + filePath: String, + language: Language + ): Pair, List> { + val nodes = parseNodes(sourceCode, filePath, language) + val relationships = buildRelationships(nodes) + return Pair(nodes, relationships) + } + + override suspend fun parseCodeGraph( + files: Map, + language: Language + ): CodeGraph { + val allNodes = mutableListOf() + val allRelationships = mutableListOf() + + for ((filePath, sourceCode) in files) { + val (nodes, relationships) = parseNodesAndRelationships(sourceCode, filePath, language) + allNodes.addAll(nodes) + allRelationships.addAll(relationships) + } + + return CodeGraph( + nodes = allNodes, + relationships = allRelationships, + metadata = mapOf( + "language" to language.name, + "fileCount" to files.size.toString(), + "platform" to "Android" + ) + ) + } + + override suspend fun parseImports( + sourceCode: String, + filePath: String, + language: Language + ): List { + return when (language) { + Language.JAVA, Language.KOTLIN -> extractJvmImports(sourceCode, filePath) + Language.PYTHON -> extractPythonImports(sourceCode, filePath) + Language.JAVASCRIPT, Language.TYPESCRIPT -> extractJsImports(sourceCode, filePath) + else -> emptyList() + } + } + + private fun extractJvmImports(content: String, filePath: String): List { + val importRegex = Regex("""import\s+(static\s+)?([a-zA-Z_][\w.]*[\w*])""") + return importRegex.findAll(content).map { match -> + // Calculate actual line number from match position + val lineNumber = content.substring(0, match.range.first).count { it == '\n' } + 1 + ImportInfo( + path = match.groupValues[2].removeSuffix(".*"), + type = ImportType.MODULE, + filePath = filePath, + startLine = lineNumber, + endLine = lineNumber + ) + }.toList() + } + + private fun extractPythonImports(content: String, filePath: String): List { + val imports = mutableListOf() + + val fromImportRegex = Regex("""from\s+([\w.]+)\s+import""") + fromImportRegex.findAll(content).forEach { match -> + // Calculate actual line number from match position + val lineNumber = content.substring(0, match.range.first).count { it == '\n' } + 1 + imports.add(ImportInfo( + path = match.groupValues[1], + type = ImportType.MODULE, + filePath = filePath, + startLine = lineNumber, + endLine = lineNumber + )) + } + + val importRegex = Regex("""^import\s+([\w.]+)""", RegexOption.MULTILINE) + importRegex.findAll(content).forEach { match -> + // Calculate actual line number from match position + val lineNumber = content.substring(0, match.range.first).count { it == '\n' } + 1 + imports.add(ImportInfo( + path = match.groupValues[1], + type = ImportType.MODULE, + filePath = filePath, + startLine = lineNumber, + endLine = lineNumber + )) + } + + return imports + } + + private fun extractJsImports(content: String, filePath: String): List { + val imports = mutableListOf() + + val es6ImportRegex = Regex("""import\s+(?:.+\s+from\s+)?['"]([@\w./-]+)['"]""") + es6ImportRegex.findAll(content).forEach { match -> + // Calculate actual line number from match position + val lineNumber = content.substring(0, match.range.first).count { it == '\n' } + 1 + imports.add(ImportInfo( + path = match.groupValues[1], + type = ImportType.MODULE, + filePath = filePath, + startLine = lineNumber, + endLine = lineNumber + )) + } + + return imports + } + + private fun parseOOPNodes( + sourceCode: String, + filePath: String, + language: Language + ): List { + val nodes = mutableListOf() + val lines = sourceCode.lines() + val packageName = extractPackageName(sourceCode) + + val classPattern = Regex("""(class|interface|enum|object)\s+(\w+)""") + + for ((index, line) in lines.withIndex()) { + val currentLine = index + 1 + + classPattern.find(line)?.let { match -> + val type = when (match.groupValues[1]) { + "class", "object" -> CodeElementType.CLASS + "interface" -> CodeElementType.INTERFACE + "enum" -> CodeElementType.ENUM + else -> CodeElementType.CLASS + } + val name = match.groupValues[2] + nodes.add(createCodeNode(name, type, packageName, filePath, currentLine, language)) + } + } + + return nodes + } + + private fun parseJSNodes( + sourceCode: String, + filePath: String, + language: Language + ): List { + val nodes = mutableListOf() + val lines = sourceCode.lines() + + val classPattern = Regex("""class\s+(\w+)""") + + for ((index, line) in lines.withIndex()) { + val currentLine = index + 1 + + classPattern.find(line)?.let { match -> + val name = match.groupValues[1] + nodes.add(createCodeNode(name, CodeElementType.CLASS, "", filePath, currentLine, language)) + } + } + + return nodes + } + + private fun parsePythonNodes( + sourceCode: String, + filePath: String, + language: Language + ): List { + val nodes = mutableListOf() + val lines = sourceCode.lines() + + val classPattern = Regex("""class\s+(\w+)""") + + for ((index, line) in lines.withIndex()) { + val currentLine = index + 1 + + classPattern.find(line)?.let { match -> + val name = match.groupValues[1] + nodes.add(createCodeNode(name, CodeElementType.CLASS, "", filePath, currentLine, language)) + } + } + + return nodes + } + + private fun extractPackageName(sourceCode: String): String { + val packagePattern = Regex("""package\s+([\w.]+)""") + return packagePattern.find(sourceCode)?.groupValues?.get(1) ?: "" + } + + private fun createCodeNode( + name: String, + type: CodeElementType, + packageName: String, + filePath: String, + startLine: Int, + language: Language + ): CodeNode { + val qualifiedName = if (packageName.isNotEmpty()) "$packageName.$name" else name + // Use deterministic composite ID to avoid collisions + val id = "$filePath:$startLine:$qualifiedName" + + return CodeNode( + id = id, + type = type, + name = name, + packageName = packageName, + filePath = filePath, + startLine = startLine, + // Approximate end line: regex parsing cannot determine actual end line, + // so we use a reasonable default. For accurate end lines, use TreeSitter-based parsing. + endLine = startLine + 10, + startColumn = 0, + endColumn = 0, + qualifiedName = qualifiedName, + content = "", + metadata = mapOf("language" to language.name, "platform" to "Android") + ) + } + + private fun buildRelationships(nodes: List): List { + // Simplified: no relationships for basic parsing + return emptyList() + } } diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt new file mode 100644 index 0000000000..d8f5c94277 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt @@ -0,0 +1,191 @@ +package cc.unitmesh.agent.render + +import cc.unitmesh.agent.Platform +import cc.unitmesh.agent.tool.ToolType +import cc.unitmesh.agent.tool.impl.docql.DocQLSearchStats +import cc.unitmesh.devins.llm.Message +import cc.unitmesh.devins.llm.MessageRole +import cc.unitmesh.llm.compression.TokenInfo + +/** + * Shared data models for Renderer implementations. + * Used by both ComposeRenderer and JewelRenderer. + */ + +/** + * Information about a tool call for display purposes. + */ +data class ToolCallInfo( + val toolName: String, + val description: String, + val details: String? = null +) + +/** + * Internal display info for formatting tool calls. + */ +data class ToolCallDisplayInfo( + val toolName: String, + val description: String, + val details: String? +) + +/** + * Task information from task-boundary tool. + */ +data class TaskInfo( + val taskName: String, + val status: TaskStatus, + val summary: String = "", + val timestamp: Long = Platform.getCurrentTimestamp(), + val startTime: Long = Platform.getCurrentTimestamp() +) + +/** + * Task status enum with display names. + */ +enum class TaskStatus(val displayName: String) { + PLANNING("Planning"), + WORKING("Working"), + COMPLETED("Completed"), + BLOCKED("Blocked"), + CANCELLED("Cancelled"); + + companion object { + fun fromString(status: String): TaskStatus { + return entries.find { it.name.equals(status, ignoreCase = true) } ?: WORKING + } + } +} + +/** + * Base timeline item for chronological rendering. + * This is the shared base class for timeline items in both ComposeRenderer and JewelRenderer. + * + * **Important**: When using `copy()` on data class instances, the `id` and `timestamp` + * default parameters are NOT re-evaluated. This means copied items will retain the + * original `id` and `timestamp` unless explicitly overridden: + * ```kotlin + * val item1 = TimelineItem.MessageItem(role = MessageRole.USER, content = "Hello") + * val item2 = item1.copy(content = "World") // item2.id == item1.id (same ID!) + * // To get a new ID: + * val item3 = item1.copy(content = "World", id = TimelineItem.generateId()) + * ``` + */ +sealed class TimelineItem( + open val timestamp: Long = Platform.getCurrentTimestamp(), + open val id: String = generateId() +) { + /** + * Message item for user/assistant/system messages. + * Supports both simple role+content and full Message object. + */ + data class MessageItem( + val message: Message? = null, + val role: MessageRole = message?.role ?: MessageRole.USER, + val content: String = message?.content ?: "", + val tokenInfo: TokenInfo? = null, + override val timestamp: Long = message?.timestamp ?: Platform.getCurrentTimestamp(), + override val id: String = generateId() + ) : TimelineItem(timestamp, id) { + /** + * Secondary constructor for simple role+content usage (JewelRenderer). + */ + constructor( + role: MessageRole, + content: String, + tokenInfo: TokenInfo? = null, + timestamp: Long = Platform.getCurrentTimestamp(), + id: String = generateId() + ) : this( + message = null, + role = role, + content = content, + tokenInfo = tokenInfo, + timestamp = timestamp, + id = id + ) + } + + /** + * Combined tool call and result item - displays both in a single compact row. + * This is the primary way to display tool executions. + */ + data class ToolCallItem( + val toolName: String, + val description: String = "", + val params: String = "", + val fullParams: String? = null, + val filePath: String? = null, + val toolType: ToolType? = null, + val success: Boolean? = null, // null means still executing + val summary: String? = null, + val output: String? = null, + val fullOutput: String? = null, + val executionTimeMs: Long? = null, + // DocQL-specific search statistics + val docqlStats: DocQLSearchStats? = null, + override val timestamp: Long = Platform.getCurrentTimestamp(), + override val id: String = generateId() + ) : TimelineItem(timestamp, id) + + /** + * Error item for displaying errors. + */ + data class ErrorItem( + val message: String, + override val timestamp: Long = Platform.getCurrentTimestamp(), + override val id: String = generateId() + ) : TimelineItem(timestamp, id) + + /** + * Task completion item. + */ + data class TaskCompleteItem( + val success: Boolean, + val message: String, + val iterations: Int = 0, + override val timestamp: Long = Platform.getCurrentTimestamp(), + override val id: String = generateId() + ) : TimelineItem(timestamp, id) + + /** + * Terminal output item for shell command results. + */ + data class TerminalOutputItem( + val command: String, + val output: String, + val exitCode: Int, + val executionTimeMs: Long, + override val timestamp: Long = Platform.getCurrentTimestamp(), + override val id: String = generateId() + ) : TimelineItem(timestamp, id) + + /** + * Live terminal session - connected to a PTY process for real-time output. + * This is only used on platforms that support PTY (JVM with JediTerm). + */ + data class LiveTerminalItem( + val sessionId: String, + val command: String, + val workingDirectory: String?, + val ptyHandle: Any?, // Platform-specific: on JVM this is a PtyProcess + override val timestamp: Long = Platform.getCurrentTimestamp(), + override val id: String = generateId() + ) : TimelineItem(timestamp, id) + + companion object { + /** + * Thread-safe counter for generating unique IDs. + * Uses timestamp + random component to avoid collisions across threads/instances. + */ + private val random = kotlin.random.Random + + /** + * Generates a unique ID for timeline items. + * Uses timestamp + random component for thread-safety without requiring atomic operations. + */ + fun generateId(): String = "${Platform.getCurrentTimestamp()}-${random.nextInt(0, Int.MAX_VALUE)}" + } +} + diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererUtils.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererUtils.kt new file mode 100644 index 0000000000..479f70bfac --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererUtils.kt @@ -0,0 +1,116 @@ +package cc.unitmesh.agent.render + +import cc.unitmesh.agent.tool.ToolType +import cc.unitmesh.agent.tool.toToolType + +/** + * Shared utility functions for Renderer implementations. + * Used by both ComposeRenderer and JewelRenderer. + */ +object RendererUtils { + + /** + * Format tool call for display in UI. + */ + fun formatToolCallDisplay(toolName: String, paramsStr: String): ToolCallDisplayInfo { + val params = parseParamsString(paramsStr) + val toolType = toolName.toToolType() + + return when (toolType) { + ToolType.ReadFile -> ToolCallDisplayInfo( + toolName = "${params["path"] ?: "unknown"} - ${toolType.displayName}", + description = "file reader", + details = "Reading file: ${params["path"] ?: "unknown"}" + ) + + ToolType.WriteFile -> ToolCallDisplayInfo( + toolName = "${params["path"] ?: "unknown"} - ${toolType.displayName}", + description = "file writer", + details = "Writing to file: ${params["path"] ?: "unknown"}" + ) + + ToolType.Glob -> ToolCallDisplayInfo( + toolName = toolType.displayName, + description = "pattern matcher", + details = "Searching for files matching pattern: ${params["pattern"] ?: "*"}" + ) + + ToolType.Shell -> ToolCallDisplayInfo( + toolName = toolType.displayName, + description = "command executor", + details = "Executing: ${params["command"] ?: params["cmd"] ?: "unknown command"}" + ) + + else -> ToolCallDisplayInfo( + toolName = if (toolName == "docql") "DocQL" else toolName, + description = "tool execution", + details = paramsStr + ) + } + } + + /** + * Format tool result summary for display. + */ + fun formatToolResultSummary(toolName: String, success: Boolean, output: String?): String { + if (!success) return "Failed" + + val toolType = toolName.toToolType() + return when (toolType) { + ToolType.ReadFile -> { + val lines = output?.lines()?.size ?: 0 + "Read $lines lines" + } + + ToolType.WriteFile -> "File written successfully" + + ToolType.Glob -> { + val firstLine = output?.lines()?.firstOrNull() ?: "" + when { + firstLine.contains("Found ") && firstLine.contains(" files matching") -> { + val count = firstLine.substringAfter("Found ").substringBefore(" files").toIntOrNull() ?: 0 + "Found $count files" + } + output?.contains("No files found") == true -> "No files found" + else -> "Search completed" + } + } + + ToolType.Shell -> { + val lines = output?.lines()?.size ?: 0 + if (lines > 0) "Executed ($lines lines output)" else "Executed successfully" + } + + else -> "Success" + } + } + + /** + * Parse parameter string into a map. + * Handles both quoted and unquoted values. + */ + fun parseParamsString(paramsStr: String): Map { + val params = mutableMapOf() + val regex = Regex("""(\w+)="([^"]*)"|\s*(\w+)=([^\s]+)""") + regex.findAll(paramsStr).forEach { match -> + val key = match.groups[1]?.value ?: match.groups[3]?.value + val value = match.groups[2]?.value ?: match.groups[4]?.value + if (key != null && value != null) { + params[key] = value + } + } + return params + } + + /** + * Convert ToolCallDisplayInfo to ToolCallInfo. + */ + fun toToolCallInfo(displayInfo: ToolCallDisplayInfo): ToolCallInfo { + return ToolCallInfo( + toolName = displayInfo.toolName, + description = displayInfo.description, + details = displayInfo.details + ) + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaMessageBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaMessageBubble.kt index c9ebc1e066..9cac0bfe30 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaMessageBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaMessageBubble.kt @@ -5,7 +5,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.devins.llm.MessageRole import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.Text import org.jetbrains.jewel.ui.theme.defaultBannerStyle @@ -16,11 +16,11 @@ import org.jetbrains.jewel.ui.theme.defaultBannerStyle */ @Composable fun IdeaMessageBubble( - role: JewelRenderer.MessageRole, + role: MessageRole, content: String, modifier: Modifier = Modifier ) { - val isUser = role == JewelRenderer.MessageRole.USER + val isUser = role == MessageRole.USER Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTaskCompleteBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTaskCompleteBubble.kt index f739cf2481..6c30a2efc5 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTaskCompleteBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTaskCompleteBubble.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.compose.theme.AutoDevColors import org.jetbrains.jewel.foundation.theme.JewelTheme @@ -21,7 +21,7 @@ import org.jetbrains.jewel.ui.component.Text */ @Composable fun IdeaTaskCompleteBubble( - item: JewelRenderer.TimelineItem.TaskCompleteItem, + item: TimelineItem.TaskCompleteItem, modifier: Modifier = Modifier ) { Row( diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTerminalOutputBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTerminalOutputBubble.kt index 331f0f19f9..0e80fa194d 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTerminalOutputBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTerminalOutputBubble.kt @@ -19,7 +19,7 @@ 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.JewelRenderer +import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.compose.theme.AutoDevColors import com.intellij.openapi.ide.CopyPasteManager @@ -34,7 +34,7 @@ import java.awt.datatransfer.StringSelection */ @Composable fun IdeaTerminalOutputBubble( - item: JewelRenderer.TimelineItem.TerminalOutputItem, + item: TimelineItem.TerminalOutputItem, modifier: Modifier = Modifier ) { var expanded by remember { mutableStateOf(true) } 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 7ea4769a0d..bf4b6e51e7 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 @@ -9,7 +9,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.agent.render.TimelineItem import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.Text @@ -19,7 +19,7 @@ import org.jetbrains.jewel.ui.component.Text */ @Composable fun IdeaTimelineContent( - timeline: List, + timeline: List, streamingOutput: String, listState: LazyListState, modifier: Modifier = Modifier @@ -51,26 +51,37 @@ fun IdeaTimelineContent( * Dispatch timeline item to appropriate bubble component. */ @Composable -fun IdeaTimelineItemView(item: JewelRenderer.TimelineItem) { +fun IdeaTimelineItemView(item: TimelineItem) { when (item) { - is JewelRenderer.TimelineItem.MessageItem -> { + is TimelineItem.MessageItem -> { IdeaMessageBubble( role = item.role, content = item.content ) } - is JewelRenderer.TimelineItem.ToolCallItem -> { + is TimelineItem.ToolCallItem -> { IdeaToolCallBubble(item) } - is JewelRenderer.TimelineItem.ErrorItem -> { + is TimelineItem.ErrorItem -> { IdeaErrorBubble(item.message) } - is JewelRenderer.TimelineItem.TaskCompleteItem -> { + is TimelineItem.TaskCompleteItem -> { IdeaTaskCompleteBubble(item) } - is JewelRenderer.TimelineItem.TerminalOutputItem -> { + is TimelineItem.TerminalOutputItem -> { IdeaTerminalOutputBubble(item) } + is TimelineItem.LiveTerminalItem -> { + // Live terminal not supported in IDEA yet, show placeholder + IdeaTerminalOutputBubble( + TimelineItem.TerminalOutputItem( + command = item.command, + output = "[Live terminal session: ${item.sessionId}]", + exitCode = 0, + executionTimeMs = 0 + ) + ) + } } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaToolCallBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaToolCallBubble.kt index 45cb389413..2f82880e33 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaToolCallBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaToolCallBubble.kt @@ -17,7 +17,7 @@ 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.JewelRenderer +import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.compose.theme.AutoDevColors import com.intellij.openapi.ide.CopyPasteManager @@ -39,7 +39,7 @@ import java.awt.datatransfer.StringSelection */ @Composable fun IdeaToolCallBubble( - item: JewelRenderer.TimelineItem.ToolCallItem, + item: TimelineItem.ToolCallItem, modifier: Modifier = Modifier ) { // Auto-expand on error @@ -123,8 +123,8 @@ fun IdeaToolCallBubble( } // Execution time (if available) - item.executionTimeMs?.let { time -> - if (time > 0) { + item.executionTimeMs?.let { time: Long -> + if (time > 0L) { Text( text = "${time}ms", style = JewelTheme.defaultTextStyle.copy( 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 4e47dfaeee..da5a05efd5 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 @@ -1,6 +1,12 @@ package cc.unitmesh.devins.idea.renderer import cc.unitmesh.agent.render.BaseRenderer +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.ToolCallInfo import cc.unitmesh.agent.tool.ToolType import cc.unitmesh.agent.tool.toToolType import cc.unitmesh.llm.compression.TokenInfo @@ -70,101 +76,6 @@ class JewelRenderer : BaseRenderer() { private val _tasks = MutableStateFlow>(emptyList()) val tasks: StateFlow> = _tasks.asStateFlow() - // Data classes for timeline items - aligned with ComposeRenderer - sealed class TimelineItem(val timestamp: Long = System.currentTimeMillis(), val id: String = generateId()) { - data class MessageItem( - val role: MessageRole, - val content: String, - val tokenInfo: TokenInfo? = null, - val itemTimestamp: Long = System.currentTimeMillis(), - val itemId: String = generateId() - ) : TimelineItem(itemTimestamp, itemId) - - /** - * Combined tool call and result item - displays both in a single compact row. - * This is aligned with ComposeRenderer's CombinedToolItem for consistency. - */ - data class ToolCallItem( - val toolName: String, - val description: String = "", - val params: String, - val fullParams: String? = null, - val filePath: String? = null, - val toolType: ToolType? = null, - val success: Boolean? = null, - val summary: String? = null, - val output: String? = null, - val fullOutput: String? = null, - val executionTimeMs: Long? = null, - val itemTimestamp: Long = System.currentTimeMillis(), - val itemId: String = generateId() - ) : TimelineItem(itemTimestamp, itemId) - - data class ErrorItem( - val message: String, - val itemTimestamp: Long = System.currentTimeMillis(), - val itemId: String = generateId() - ) : TimelineItem(itemTimestamp, itemId) - - data class TaskCompleteItem( - val success: Boolean, - val message: String, - val iterations: Int, - val itemTimestamp: Long = System.currentTimeMillis(), - val itemId: String = generateId() - ) : TimelineItem(itemTimestamp, itemId) - - data class TerminalOutputItem( - val command: String, - val output: String, - val exitCode: Int, - val executionTimeMs: Long, - val itemTimestamp: Long = System.currentTimeMillis(), - val itemId: String = generateId() - ) : TimelineItem(itemTimestamp, itemId) - - companion object { - private var idCounter = 0L - fun generateId(): String = "${System.currentTimeMillis()}-${idCounter++}" - } - } - - data class ToolCallInfo( - val toolName: String, - val description: String, - val details: String? = null - ) - - enum class MessageRole { - USER, ASSISTANT, SYSTEM - } - - /** - * Task information from task-boundary tool. - * Aligned with ComposeRenderer's TaskInfo for consistency. - */ - data class TaskInfo( - val taskName: String, - val status: TaskStatus, - val summary: String = "", - val timestamp: Long = System.currentTimeMillis(), - val startTime: Long = System.currentTimeMillis() - ) - - enum class TaskStatus(val displayName: String) { - PLANNING("Planning"), - WORKING("Working"), - COMPLETED("Completed"), - BLOCKED("Blocked"), - CANCELLED("Cancelled"); - - companion object { - fun fromString(status: String): TaskStatus { - return entries.find { it.name.equals(status, ignoreCase = true) } ?: WORKING - } - } - } - // BaseRenderer implementation override fun renderIterationHeader(current: Int, max: Int) { @@ -479,85 +390,13 @@ class JewelRenderer : BaseRenderer() { _timeline.update { it + item } } - private fun formatToolCallDisplay(toolName: String, paramsStr: String): ToolCallDisplayInfo { - val params = parseParamsString(paramsStr) - val toolType = toolName.toToolType() + private fun formatToolCallDisplay(toolName: String, paramsStr: String) = + RendererUtils.formatToolCallDisplay(toolName, paramsStr) - return when (toolType) { - ToolType.ReadFile -> ToolCallDisplayInfo( - toolName = "${params["path"] ?: "unknown"} - ${toolType.displayName}", - description = "file reader", - details = "Reading file: ${params["path"] ?: "unknown"}" - ) - ToolType.WriteFile -> ToolCallDisplayInfo( - toolName = "${params["path"] ?: "unknown"} - ${toolType.displayName}", - description = "file writer", - details = "Writing to file: ${params["path"] ?: "unknown"}" - ) - ToolType.Glob -> ToolCallDisplayInfo( - toolName = toolType.displayName, - description = "pattern matcher", - details = "Searching for files matching pattern: ${params["pattern"] ?: "*"}" - ) - ToolType.Shell -> ToolCallDisplayInfo( - toolName = toolType.displayName, - description = "command executor", - details = "Executing: ${params["command"] ?: params["cmd"] ?: "unknown command"}" - ) - else -> ToolCallDisplayInfo( - toolName = if (toolName == "docql") "DocQL" else toolName, - description = "tool execution", - details = paramsStr - ) - } - } - - private fun formatToolResultSummary(toolName: String, success: Boolean, output: String?): String { - if (!success) return "Failed" - - val toolType = toolName.toToolType() - return when (toolType) { - ToolType.ReadFile -> { - val lines = output?.lines()?.size ?: 0 - "Read $lines lines" - } - ToolType.WriteFile -> "File written successfully" - ToolType.Glob -> { - val firstLine = output?.lines()?.firstOrNull() ?: "" - when { - firstLine.contains("Found ") && firstLine.contains(" files matching") -> { - val count = firstLine.substringAfter("Found ").substringBefore(" files").toIntOrNull() ?: 0 - "Found $count files" - } - output?.contains("No files found") == true -> "No files found" - else -> "Search completed" - } - } - ToolType.Shell -> { - val lines = output?.lines()?.size ?: 0 - if (lines > 0) "Executed ($lines lines output)" else "Executed successfully" - } - else -> "Success" - } - } - - private fun parseParamsString(paramsStr: String): Map { - val params = mutableMapOf() - val regex = Regex("""(\w+)="([^"]*)"|\s*(\w+)=([^\s]+)""") - regex.findAll(paramsStr).forEach { match -> - val key = match.groups[1]?.value ?: match.groups[3]?.value - val value = match.groups[2]?.value ?: match.groups[4]?.value - if (key != null && value != null) { - params[key] = value - } - } - return params - } + private fun formatToolResultSummary(toolName: String, success: Boolean, output: String?) = + RendererUtils.formatToolResultSummary(toolName, success, output) - private data class ToolCallDisplayInfo( - val toolName: String, - val description: String, - val details: String? - ) + private fun parseParamsString(paramsStr: String) = + RendererUtils.parseParamsString(paramsStr) } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt index 425fef0f8b..fb860b8086 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt @@ -14,7 +14,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.devins.llm.MessageRole +import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.idea.components.IdeaResizableSplitPane import cc.unitmesh.devins.idea.components.IdeaVerticalResizableSplitPane @@ -443,7 +444,7 @@ private fun DocumentContentPanel( */ @Composable private fun AIChatPanel( - timeline: List, + timeline: List, streamingOutput: String, isGenerating: Boolean, onSendMessage: (String) -> Unit, @@ -602,10 +603,10 @@ private fun AIChatPanel( * Chat message item renderer */ @Composable -private fun ChatMessageItem(item: JewelRenderer.TimelineItem) { +private fun ChatMessageItem(item: TimelineItem) { when (item) { - is JewelRenderer.TimelineItem.MessageItem -> { - val isUser = item.role == JewelRenderer.MessageRole.USER + is TimelineItem.MessageItem -> { + val isUser = item.role == MessageRole.USER Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start @@ -627,7 +628,7 @@ private fun ChatMessageItem(item: JewelRenderer.TimelineItem) { } } - is JewelRenderer.TimelineItem.ToolCallItem -> { + is TimelineItem.ToolCallItem -> { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start @@ -665,10 +666,10 @@ private fun ChatMessageItem(item: JewelRenderer.TimelineItem) { ) ) } - if (item.output != null) { + item.output?.let { output -> Spacer(modifier = Modifier.height(4.dp)) Text( - text = item.output.take(200) + if (item.output.length > 200) "..." else "", + text = output.take(200) + if (output.length > 200) "..." else "", style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp) ) } @@ -677,7 +678,7 @@ private fun ChatMessageItem(item: JewelRenderer.TimelineItem) { } } - is JewelRenderer.TimelineItem.ErrorItem -> { + is TimelineItem.ErrorItem -> { Box( modifier = Modifier .fillMaxWidth() @@ -705,7 +706,7 @@ private fun ChatMessageItem(item: JewelRenderer.TimelineItem) { } } - is JewelRenderer.TimelineItem.TaskCompleteItem -> { + is TimelineItem.TaskCompleteItem -> { Box( modifier = Modifier .fillMaxWidth() @@ -726,7 +727,7 @@ private fun ChatMessageItem(item: JewelRenderer.TimelineItem) { } } - is JewelRenderer.TimelineItem.TerminalOutputItem -> { + is TimelineItem.TerminalOutputItem -> { Box( modifier = Modifier .fillMaxWidth() @@ -752,6 +753,34 @@ private fun ChatMessageItem(item: JewelRenderer.TimelineItem) { } } } + + is TimelineItem.LiveTerminalItem -> { + // Live terminal not supported in knowledge content, show placeholder + Box( + modifier = Modifier + .fillMaxWidth() + .background(AutoDevColors.Neutral.c900) + .padding(8.dp) + ) { + Column { + Text( + text = "$ ${item.command}", + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold, + color = AutoDevColors.Cyan.c400, + fontSize = 12.sp + ) + ) + Text( + text = "[Live terminal session: ${item.sessionId}]", + style = JewelTheme.defaultTextStyle.copy( + color = AutoDevColors.Neutral.c300, + fontSize = 11.sp + ) + ) + } + } + } } } diff --git a/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/JewelRendererTest.kt b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/JewelRendererTest.kt index a0957dffc1..a5f6afdb76 100644 --- a/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/JewelRendererTest.kt +++ b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/JewelRendererTest.kt @@ -1,5 +1,8 @@ package cc.unitmesh.devins.idea.renderer +import cc.unitmesh.devins.llm.MessageRole +import cc.unitmesh.agent.render.TaskStatus +import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.llm.compression.TokenInfo import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -52,8 +55,8 @@ class JewelRendererTest { assertEquals(1, timeline.size) val item = timeline.first() - assertTrue(item is JewelRenderer.TimelineItem.MessageItem) - assertEquals(JewelRenderer.MessageRole.USER, (item as JewelRenderer.TimelineItem.MessageItem).role) + assertTrue(item is TimelineItem.MessageItem) + assertEquals(MessageRole.USER, (item as TimelineItem.MessageItem).role) assertEquals("Hello, world!", item.content) } @@ -90,7 +93,7 @@ class JewelRendererTest { val timeline = renderer.timeline.first() assertEquals(1, timeline.size) - assertTrue(timeline.first() is JewelRenderer.TimelineItem.ToolCallItem) + assertTrue(timeline.first() is TimelineItem.ToolCallItem) } @Test @@ -110,7 +113,7 @@ class JewelRendererTest { val timeline = renderer.timeline.first() assertEquals(1, timeline.size) - val toolItem = timeline.first() as JewelRenderer.TimelineItem.ToolCallItem + val toolItem = timeline.first() as TimelineItem.ToolCallItem assertTrue(toolItem.success == true) assertNotNull(toolItem.output) } @@ -124,7 +127,7 @@ class JewelRendererTest { val timeline = renderer.timeline.first() assertEquals(1, timeline.size) - assertTrue(timeline.first() is JewelRenderer.TimelineItem.ErrorItem) + assertTrue(timeline.first() is TimelineItem.ErrorItem) } @Test @@ -134,7 +137,7 @@ class JewelRendererTest { val timeline = renderer.timeline.first() assertEquals(1, timeline.size) - val item = timeline.first() as JewelRenderer.TimelineItem.TaskCompleteItem + val item = timeline.first() as TimelineItem.TaskCompleteItem assertTrue(item.success) assertEquals("Task completed successfully", item.message) assertEquals(5, item.iterations) @@ -199,8 +202,8 @@ class JewelRendererTest { val timeline = renderer.timeline.first() assertTrue(timeline.isNotEmpty()) val lastItem = timeline.last() - assertTrue(lastItem is JewelRenderer.TimelineItem.MessageItem) - assertTrue((lastItem as JewelRenderer.TimelineItem.MessageItem).content.contains("[Interrupted]")) + assertTrue(lastItem is TimelineItem.MessageItem) + assertTrue((lastItem as TimelineItem.MessageItem).content.contains("[Interrupted]")) } @Test @@ -210,7 +213,7 @@ class JewelRendererTest { val tasks = renderer.tasks.first() assertEquals(1, tasks.size) assertEquals("Build", tasks.first().taskName) - assertEquals(JewelRenderer.TaskStatus.WORKING, tasks.first().status) + assertEquals(TaskStatus.WORKING, tasks.first().status) } } diff --git a/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt index 0340e0598e..b26c454cf7 100644 --- a/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt +++ b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt @@ -1,5 +1,7 @@ package cc.unitmesh.devins.idea.toolwindow.remote +import cc.unitmesh.devins.llm.MessageRole +import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.devins.idea.renderer.JewelRenderer import kotlinx.coroutines.* import kotlinx.coroutines.flow.first @@ -103,7 +105,7 @@ class IdeaRemoteAgentViewModelTest { val timeline = renderer.timeline.first() assertEquals(1, timeline.size) - assertTrue(timeline.first() is JewelRenderer.TimelineItem.ToolCallItem) + assertTrue(timeline.first() is TimelineItem.ToolCallItem) } @Test @@ -126,7 +128,7 @@ class IdeaRemoteAgentViewModelTest { val timeline = renderer.timeline.first() assertEquals(1, timeline.size) - val toolItem = timeline.first() as JewelRenderer.TimelineItem.ToolCallItem + val toolItem = timeline.first() as TimelineItem.ToolCallItem assertEquals(true, toolItem.success) } @@ -141,7 +143,7 @@ class IdeaRemoteAgentViewModelTest { val timeline = renderer.timeline.first() assertEquals(1, timeline.size) - assertTrue(timeline.first() is JewelRenderer.TimelineItem.ErrorItem) + assertTrue(timeline.first() is TimelineItem.ErrorItem) } @Test @@ -153,7 +155,7 @@ class IdeaRemoteAgentViewModelTest { val timeline = renderer.timeline.first() assertEquals(1, timeline.size) - val item = timeline.first() as JewelRenderer.TimelineItem.TaskCompleteItem + val item = timeline.first() as TimelineItem.TaskCompleteItem assertTrue(item.success) assertEquals("Task completed", item.message) assertEquals(5, item.iterations) @@ -199,8 +201,8 @@ class IdeaRemoteAgentViewModelTest { val timeline = renderer.timeline.first() assertTrue(timeline.isNotEmpty()) val lastItem = timeline.last() - assertTrue(lastItem is JewelRenderer.TimelineItem.MessageItem) - assertTrue((lastItem as JewelRenderer.TimelineItem.MessageItem).content.contains("[Interrupted]")) + assertTrue(lastItem is TimelineItem.MessageItem) + assertTrue((lastItem as TimelineItem.MessageItem).content.contains("[Interrupted]")) } @Test @@ -225,8 +227,8 @@ class IdeaRemoteAgentViewModelTest { val timeline = renderer.timeline.first() assertEquals(1, timeline.size) - val item = timeline.first() as JewelRenderer.TimelineItem.MessageItem - assertEquals(JewelRenderer.MessageRole.USER, item.role) + val item = timeline.first() as TimelineItem.MessageItem + assertEquals(MessageRole.USER, item.role) assertEquals("Hello from user", item.content) } diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentChatInterface.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentChatInterface.kt index 474b646c76..88d2b2d900 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentChatInterface.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentChatInterface.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import cc.unitmesh.agent.AgentType import cc.unitmesh.agent.Platform +import cc.unitmesh.agent.render.TaskStatus import cc.unitmesh.devins.ui.base.ResizableSplitPane import cc.unitmesh.devins.ui.compose.chat.TopBarMenu import cc.unitmesh.devins.ui.compose.editor.DevInEditorInput diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentMessageList.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentMessageList.kt index d7e8308be8..40fe81236c 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentMessageList.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentMessageList.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.unit.sp import autodev_intellij.mpp_ui.generated.resources.NotoSansSC_Regular import autodev_intellij.mpp_ui.generated.resources.Res import cc.unitmesh.agent.Platform +import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.devins.llm.Message import cc.unitmesh.devins.llm.MessageRole import cc.unitmesh.devins.ui.compose.icons.AutoDevComposeIcons @@ -159,23 +160,28 @@ fun AgentMessageList( @Composable fun RenderMessageItem( - timelineItem: ComposeRenderer.TimelineItem, + timelineItem: TimelineItem, onOpenFileViewer: ((String) -> Unit)?, renderer: ComposeRenderer, onExpand: () -> Unit = {} ) { when (timelineItem) { - is ComposeRenderer.TimelineItem.MessageItem -> { + is TimelineItem.MessageItem -> { + val msg = timelineItem.message ?: Message( + role = timelineItem.role, + content = timelineItem.content, + timestamp = timelineItem.timestamp + ) MessageItem( - message = timelineItem.message, + message = msg, tokenInfo = timelineItem.tokenInfo ) } - is ComposeRenderer.TimelineItem.CombinedToolItem -> { + is TimelineItem.ToolCallItem -> { ToolItem( toolName = timelineItem.toolName, - details = timelineItem.details, + details = timelineItem.params, fullParams = timelineItem.fullParams, filePath = timelineItem.filePath, toolType = timelineItem.toolType, @@ -190,28 +196,18 @@ fun RenderMessageItem( ) } - is ComposeRenderer.TimelineItem.ToolResultItem -> { - ToolResultItem( - toolName = timelineItem.toolName, - success = timelineItem.success, - summary = timelineItem.summary, - output = timelineItem.output, - fullOutput = timelineItem.fullOutput - ) - } - - is ComposeRenderer.TimelineItem.ToolErrorItem -> { - ToolErrorItem(error = timelineItem.error, onDismiss = { renderer.clearError() }) + is TimelineItem.ErrorItem -> { + ToolErrorItem(error = timelineItem.message, onDismiss = { renderer.clearError() }) } - is ComposeRenderer.TimelineItem.TaskCompleteItem -> { + is TimelineItem.TaskCompleteItem -> { TaskCompletedItem( success = timelineItem.success, message = timelineItem.message ) } - is ComposeRenderer.TimelineItem.TerminalOutputItem -> { + is TimelineItem.TerminalOutputItem -> { TerminalOutputItem( command = timelineItem.command, output = timelineItem.output, @@ -221,7 +217,7 @@ fun RenderMessageItem( ) } - is ComposeRenderer.TimelineItem.LiveTerminalItem -> { + is TimelineItem.LiveTerminalItem -> { LiveTerminalItem( sessionId = timelineItem.sessionId, command = timelineItem.command, diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt index be6753fbf2..d7da6fd1ce 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt @@ -2,13 +2,18 @@ package cc.unitmesh.devins.ui.compose.agent import androidx.compose.runtime.* import cc.unitmesh.agent.render.BaseRenderer +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.TimelineItem.* +import cc.unitmesh.agent.render.ToolCallInfo import cc.unitmesh.agent.tool.ToolType import cc.unitmesh.agent.tool.impl.docql.DocQLSearchStats import cc.unitmesh.agent.tool.toToolType import cc.unitmesh.devins.llm.Message import cc.unitmesh.devins.llm.MessageRole import cc.unitmesh.devins.llm.TimelineItemType -import cc.unitmesh.devins.ui.compose.agent.ComposeRenderer.TimelineItem.* import cc.unitmesh.llm.compression.TokenInfo import kotlinx.datetime.Clock @@ -70,84 +75,6 @@ class ComposeRenderer : BaseRenderer() { private val _tasks = mutableStateListOf() val tasks: List = _tasks - // Timeline data structures for chronological rendering - sealed class TimelineItem(val timestamp: Long = Clock.System.now().toEpochMilliseconds()) { - data class MessageItem( - val message: Message, - val tokenInfo: TokenInfo? = null, - val itemTimestamp: Long = Clock.System.now().toEpochMilliseconds() - ) : TimelineItem(itemTimestamp) - - /** - * Combined tool call and result item - displays both in a single compact row - * This replaces the separate ToolCallItem and ToolResultItem for better space efficiency - */ - data class CombinedToolItem( - val toolName: String, - val description: String, - val details: String? = null, - val fullParams: String? = null, // 完整的原始参数,用于折叠展示 - val filePath: String? = null, // 文件路径,用于点击查看 - val toolType: ToolType? = null, // 工具类型,用于判断是否可点击 - // Result fields - val success: Boolean? = null, // null means still executing - val summary: String? = null, - val output: String? = null, // 截断的输出用于直接展示 - val fullOutput: String? = null, // 完整的输出,用于折叠展示或错误诊断 - val executionTimeMs: Long? = null, // 执行时间 - // DocQL-specific search statistics - val docqlStats: DocQLSearchStats? = null, - val itemTimestamp: Long = Clock.System.now().toEpochMilliseconds() - ) : TimelineItem(itemTimestamp) - @Deprecated("Use CombinedToolItem instead") - data class ToolResultItem( - val toolName: String, - val success: Boolean, - val summary: String, - val output: String? = null, // 截断的输出用于直接展示 - val fullOutput: String? = null, // 完整的输出,用于折叠展示或错误诊断 - val itemTimestamp: Long = Clock.System.now().toEpochMilliseconds() - ) : TimelineItem(itemTimestamp) - - data class ToolErrorItem( - val error: String, - val itemTimestamp: Long = Clock.System.now().toEpochMilliseconds() - ) : TimelineItem(itemTimestamp) - - data class TaskCompleteItem( - val success: Boolean, - val message: String, - val itemTimestamp: Long = Clock.System.now().toEpochMilliseconds() - ) : TimelineItem(itemTimestamp) - - data class TerminalOutputItem( - val command: String, - val output: String, - val exitCode: Int, - val executionTimeMs: Long, - val itemTimestamp: Long = Clock.System.now().toEpochMilliseconds() - ) : TimelineItem(itemTimestamp) - - /** - * Live terminal session - connected to a PTY process for real-time output - * This is only used on platforms that support PTY (JVM with JediTerm) - */ - data class LiveTerminalItem( - val sessionId: String, - val command: String, - val workingDirectory: String?, - val ptyHandle: Any?, // Platform-specific: on JVM this is a PtyProcess - val itemTimestamp: Long = Clock.System.now().toEpochMilliseconds() - ) : TimelineItem(itemTimestamp) - } - - // Legacy data classes for compatibility - data class ToolCallInfo( - val toolName: String, - val description: String, - val details: String? = null - ) - // BaseRenderer implementation override fun renderIterationHeader( @@ -230,12 +157,12 @@ class ComposeRenderer : BaseRenderer() { else -> null } - // Create a combined tool item with only call information (result will be added later) + // Create a tool call item with only call information (result will be added later) _timeline.add( - TimelineItem.CombinedToolItem( + ToolCallItem( toolName = toolInfo.toolName, description = toolInfo.description, - details = toolInfo.details, + params = toolInfo.details ?: "", fullParams = paramsStr, // 保存完整的原始参数 filePath = filePath, // 保存文件路径 toolType = toolType, // 保存工具类型 @@ -329,9 +256,9 @@ class ComposeRenderer : BaseRenderer() { ) ) } else { - // For non-live sessions, replace the combined tool item with terminal output + // For non-live sessions, replace the tool call item with terminal output val lastItem = _timeline.lastOrNull() - if (lastItem is TimelineItem.CombinedToolItem && lastItem.toolType == ToolType.Shell) { + if (lastItem is ToolCallItem && lastItem.toolType == ToolType.Shell) { _timeline.removeAt(_timeline.size - 1) } @@ -345,9 +272,9 @@ class ComposeRenderer : BaseRenderer() { ) } } else { - // Update the last CombinedToolItem with result information + // Update the last ToolCallItem with result information val lastItem = _timeline.lastOrNull() - if (lastItem is TimelineItem.CombinedToolItem && lastItem.success == null) { + if (lastItem is ToolCallItem && lastItem.success == null) { // Remove the incomplete item _timeline.removeAt(_timeline.size - 1) @@ -415,7 +342,7 @@ class ComposeRenderer : BaseRenderer() { } override fun renderError(message: String) { - _timeline.add(TimelineItem.ToolErrorItem(error = message)) + _timeline.add(ErrorItem(message = message)) _errorMessage = message _isProcessing = false } @@ -536,100 +463,16 @@ class ComposeRenderer : BaseRenderer() { _currentToolCall = null } - private fun formatToolCallDisplay( - toolName: String, - paramsStr: String - ): ToolCallInfo { - val params = parseParamsString(paramsStr) - val toolType = toolName.toToolType() - - return when (toolType) { - ToolType.ReadFile -> - ToolCallInfo( - toolName = "${params["path"] ?: "unknown"} - ${toolType.displayName}", - description = "file reader", - details = "Reading file: ${params["path"] ?: "unknown"}" - ) - - ToolType.WriteFile -> - ToolCallInfo( - toolName = "${params["path"] ?: "unknown"} - ${toolType.displayName}", - description = "file writer", - details = "Writing to file: ${params["path"] ?: "unknown"}" - ) - - ToolType.Glob -> - ToolCallInfo( - toolName = toolType.displayName, - description = "pattern matcher", - details = "Searching for files matching pattern: ${params["pattern"] ?: "*"}" - ) - - ToolType.Shell -> - ToolCallInfo( - toolName = toolType.displayName, - description = "command executor", - details = "Executing: ${params["command"] ?: params["cmd"] ?: "unknown command"}" - ) - - else -> - ToolCallInfo( - toolName = if (toolName == "docql") "DocQL" else toolName, - description = "tool execution", - details = paramsStr - ) - } + private fun formatToolCallDisplay(toolName: String, paramsStr: String): ToolCallInfo { + return RendererUtils.toToolCallInfo(RendererUtils.formatToolCallDisplay(toolName, paramsStr)) } - private fun formatToolResultSummary( - toolName: String, - success: Boolean, - output: String? - ): String { - if (!success) return "Failed" - - val toolType = toolName.toToolType() - return when (toolType) { - ToolType.ReadFile -> { - val lines = output?.lines()?.size ?: 0 - "Read $lines lines" - } - - ToolType.WriteFile -> "File written successfully" - ToolType.Glob -> { - val firstLine = output?.lines()?.firstOrNull() ?: "" - if (firstLine.contains("Found ") && firstLine.contains(" files matching")) { - val count = firstLine.substringAfter("Found ").substringBefore(" files").toIntOrNull() ?: 0 - "Found $count files" - } else if (output?.contains("No files found") == true) { - "No files found" - } else { - "Search completed" - } - } - - ToolType.Shell -> { - val lines = output?.lines()?.size ?: 0 - if (lines > 0) "Executed ($lines lines output)" else "Executed successfully" - } - - else -> "Success" - } + private fun formatToolResultSummary(toolName: String, success: Boolean, output: String?): String { + return RendererUtils.formatToolResultSummary(toolName, success, output) } private fun parseParamsString(paramsStr: String): Map { - val params = mutableMapOf() - - val regex = Regex("""(\w+)="([^"]*)"|\s*(\w+)=([^\s]+)""") - regex.findAll(paramsStr).forEach { match -> - val key = match.groups[1]?.value ?: match.groups[3]?.value - val value = match.groups[2]?.value ?: match.groups[4]?.value - if (key != null && value != null) { - params[key] = value - } - } - - return params + return RendererUtils.parseParamsString(paramsStr) } /** @@ -661,13 +504,13 @@ class ComposeRenderer : BaseRenderer() { ) } - is TimelineItem.CombinedToolItem -> { + is ToolCallItem -> { val stats = item.docqlStats cc.unitmesh.devins.llm.MessageMetadata( itemType = cc.unitmesh.devins.llm.TimelineItemType.COMBINED_TOOL, toolName = item.toolName, description = item.description, - details = item.details, + details = item.params, fullParams = item.fullParams, filePath = item.filePath, toolType = item.toolType?.name, @@ -690,21 +533,11 @@ class ComposeRenderer : BaseRenderer() { docqlSmartSummary = stats?.smartSummary ) } - is TimelineItem.ToolResultItem -> { - cc.unitmesh.devins.llm.MessageMetadata( - itemType = cc.unitmesh.devins.llm.TimelineItemType.TOOL_RESULT, - toolName = item.toolName, - success = item.success, - summary = item.summary, - output = item.output, - fullOutput = item.fullOutput - ) - } - is TimelineItem.ToolErrorItem -> { + is ErrorItem -> { cc.unitmesh.devins.llm.MessageMetadata( itemType = cc.unitmesh.devins.llm.TimelineItemType.TOOL_ERROR, - taskMessage = item.error + taskMessage = item.message ) } @@ -751,10 +584,10 @@ class ComposeRenderer : BaseRenderer() { ) } else null - TimelineItem.MessageItem( + MessageItem( message = message, tokenInfo = tokenInfo, - itemTimestamp = message.timestamp + timestamp = message.timestamp ) } @@ -785,10 +618,10 @@ class ComposeRenderer : BaseRenderer() { } } else null - TimelineItem.CombinedToolItem( + ToolCallItem( toolName = metadata.toolName ?: "", description = metadata.description ?: "", - details = metadata.details, + params = metadata.details ?: "", fullParams = metadata.fullParams, filePath = metadata.filePath, toolType = metadata.toolType?.toToolType(), @@ -798,25 +631,28 @@ class ComposeRenderer : BaseRenderer() { fullOutput = metadata.fullOutput, executionTimeMs = metadata.executionTimeMs, docqlStats = docqlStats, - itemTimestamp = message.timestamp + timestamp = message.timestamp ) } TimelineItemType.TOOL_RESULT -> { - TimelineItem.ToolResultItem( + // Legacy support: convert old ToolResultItem to ToolCallItem + ToolCallItem( toolName = metadata.toolName ?: "", - success = metadata.success ?: false, - summary = metadata.summary ?: "", + description = "", + params = "", + success = metadata.success, + summary = metadata.summary, output = metadata.output, fullOutput = metadata.fullOutput, - itemTimestamp = message.timestamp + timestamp = message.timestamp ) } TimelineItemType.TOOL_ERROR -> { - TimelineItem.ToolErrorItem( - error = metadata.taskMessage ?: "Unknown error", - itemTimestamp = message.timestamp + ErrorItem( + message = metadata.taskMessage ?: "Unknown error", + timestamp = message.timestamp ) } @@ -824,17 +660,17 @@ class ComposeRenderer : BaseRenderer() { TaskCompleteItem( success = metadata.taskSuccess ?: false, message = metadata.taskMessage ?: "", - itemTimestamp = message.timestamp + timestamp = message.timestamp ) } TimelineItemType.TERMINAL_OUTPUT -> { - TimelineItem.TerminalOutputItem( + TerminalOutputItem( command = metadata.command ?: "", output = message.content, exitCode = metadata.exitCode ?: 0, executionTimeMs = metadata.executionTimeMs ?: 0, - itemTimestamp = message.timestamp + timestamp = message.timestamp ) } else -> null @@ -855,10 +691,10 @@ class ComposeRenderer : BaseRenderer() { fromMessageMetadata(messageMetadata, message) } else { // Fallback: create a simple MessageItem for messages without metadata - TimelineItem.MessageItem( + MessageItem( message = message, tokenInfo = null, - itemTimestamp = message.timestamp + timestamp = message.timestamp ) } @@ -873,58 +709,63 @@ class ComposeRenderer : BaseRenderer() { fun getTimelineSnapshot(): List { return _timeline.mapNotNull { item -> when (item) { - is TimelineItem.MessageItem -> { + is MessageItem -> { // Return the original message with metadata - item.message.copy( + item.message?.copy( + metadata = toMessageMetadata(item) + ) ?: cc.unitmesh.devins.llm.Message( + role = item.role, + content = item.content, + timestamp = item.timestamp, metadata = toMessageMetadata(item) ) } - is TimelineItem.CombinedToolItem -> { + is ToolCallItem -> { // Create a message representing the tool call and result val content = buildString { append("[${item.toolName}] ") append(item.description) if (item.summary != null) { - append(" → ${item.summary}") + append(" -> ${item.summary}") } } cc.unitmesh.devins.llm.Message( role = MessageRole.ASSISTANT, content = content, - timestamp = item.itemTimestamp, + timestamp = item.timestamp, metadata = toMessageMetadata(item) ) } - is TimelineItem.TerminalOutputItem -> { + is TerminalOutputItem -> { cc.unitmesh.devins.llm.Message( role = MessageRole.ASSISTANT, content = item.output, - timestamp = item.itemTimestamp, + timestamp = item.timestamp, metadata = toMessageMetadata(item) ) } - is TimelineItem.TaskCompleteItem -> { + is TaskCompleteItem -> { cc.unitmesh.devins.llm.Message( role = MessageRole.ASSISTANT, content = item.message, - timestamp = item.itemTimestamp, + timestamp = item.timestamp, metadata = toMessageMetadata(item) ) } - is TimelineItem.ToolErrorItem -> { + is ErrorItem -> { cc.unitmesh.devins.llm.Message( role = MessageRole.ASSISTANT, - content = item.error, - timestamp = item.itemTimestamp, + content = item.message, + timestamp = item.timestamp, metadata = toMessageMetadata(item) ) } - is TimelineItem.ToolResultItem, - is TimelineItem.LiveTerminalItem -> null + + is LiveTerminalItem -> null } } } diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/TaskPanel.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/TaskPanel.kt index cde787a9d7..c1e4a71a61 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/TaskPanel.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/TaskPanel.kt @@ -18,33 +18,31 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import cc.unitmesh.agent.Platform +import cc.unitmesh.agent.render.TaskInfo +import cc.unitmesh.agent.render.TaskStatus import cc.unitmesh.devins.ui.compose.theme.AutoDevColors -import kotlinx.datetime.Clock /** - * Task information from task-boundary tool + * UI extension for TaskStatus - provides icon and color for display */ -data class TaskInfo( - val taskName: String, - val status: TaskStatus, - val summary: String = "", - val timestamp: Long = Clock.System.now().toEpochMilliseconds(), - val startTime: Long = Clock.System.now().toEpochMilliseconds() -) - -enum class TaskStatus(val displayName: String, val icon: @Composable () -> Unit, val color: Color) { - PLANNING("Planning", { Icon(Icons.Default.Create, null) }, Color(0xFF9C27B0)), - WORKING("Working", { Icon(Icons.Default.Build, null) }, Color(0xFF2196F3)), - COMPLETED("Completed", { Icon(Icons.Default.CheckCircle, null) }, Color(0xFF4CAF50)), - BLOCKED("Blocked", { Icon(Icons.Default.Warning, null) }, Color(0xFFFF9800)), - CANCELLED("Cancelled", { Icon(Icons.Default.Cancel, null) }, Color(0xFF9E9E9E)); +@Composable +fun TaskStatus.icon(): Unit = when (this) { + TaskStatus.PLANNING -> Icon(Icons.Default.Create, null) + TaskStatus.WORKING -> Icon(Icons.Default.Build, null) + TaskStatus.COMPLETED -> Icon(Icons.Default.CheckCircle, null) + TaskStatus.BLOCKED -> Icon(Icons.Default.Warning, null) + TaskStatus.CANCELLED -> Icon(Icons.Default.Cancel, null) +} - companion object { - fun fromString(status: String): TaskStatus { - return entries.find { it.name.equals(status, ignoreCase = true) } ?: WORKING - } +val TaskStatus.color: Color + get() = when (this) { + TaskStatus.PLANNING -> Color(0xFF9C27B0) + TaskStatus.WORKING -> Color(0xFF2196F3) + TaskStatus.COMPLETED -> Color(0xFF4CAF50) + TaskStatus.BLOCKED -> Color(0xFFFF9800) + TaskStatus.CANCELLED -> Color(0xFF9E9E9E) } -} /** * Task Panel Component - displays active tasks from task-boundary tool @@ -225,7 +223,7 @@ private fun TaskCard(task: TaskInfo, modifier: Modifier = Modifier) { } // Time elapsed - val elapsed = (Clock.System.now().toEpochMilliseconds() - task.startTime) / 1000 + val elapsed = (Platform.getCurrentTimestamp() - task.startTime) / 1000 Text( formatDuration(elapsed), style = MaterialTheme.typography.labelSmall, diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ToolItem.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ToolItem.kt index 01478a519d..ea8cfe6ba0 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ToolItem.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ToolItem.kt @@ -33,6 +33,7 @@ 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.agent.render.ToolCallInfo import cc.unitmesh.devins.ui.compose.icons.AutoDevComposeIcons @Composable @@ -265,7 +266,7 @@ fun ToolErrorItem( } @Composable -fun CurrentToolCallItem(toolCall: ComposeRenderer.ToolCallInfo) { +fun CurrentToolCallItem(toolCall: ToolCallInfo) { Surface( color = MaterialTheme.colorScheme.primaryContainer, shape = RoundedCornerShape(8.dp), diff --git a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/agent/test/AgentMessageListPreviews.kt b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/agent/test/AgentMessageListPreviews.kt index 7fc3d64c86..3239deba94 100644 --- a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/agent/test/AgentMessageListPreviews.kt +++ b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/agent/test/AgentMessageListPreviews.kt @@ -6,6 +6,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import cc.unitmesh.agent.render.ToolCallInfo import cc.unitmesh.devins.llm.Message import cc.unitmesh.devins.llm.MessageRole import cc.unitmesh.devins.ui.compose.agent.AgentMessageList @@ -101,7 +102,7 @@ fun Preview_CurrentToolCallItem() { MaterialTheme { Surface { CurrentToolCallItem( - toolCall = ComposeRenderer.ToolCallInfo( + toolCall = ToolCallInfo( toolName = "Shell", description = "Executing sample command", details = "Executing: echo hello"