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/document/DocumentAgent.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/document/DocumentAgent.kt index eaff3c5422..48463606c4 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/document/DocumentAgent.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/document/DocumentAgent.kt @@ -17,19 +17,32 @@ import cc.unitmesh.agent.tool.registry.ToolRegistry import cc.unitmesh.agent.tool.schema.AgentToolFormatter import cc.unitmesh.agent.tool.shell.DefaultShellExecutor import cc.unitmesh.agent.tool.shell.ShellExecutor +import cc.unitmesh.devins.compiler.template.TemplateCompiler +import cc.unitmesh.devins.compiler.variable.VariableTable import cc.unitmesh.devins.document.DocumentParserService import cc.unitmesh.llm.KoogLLMService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.datetime.Clock /** - * Document Task - represents a user query about a document + * Document Agent Mode - determines the agent's behavior + */ +enum class DocumentAgentMode { + DOCUMENT_QUERY, // Original document query mode using DocQL + FEATURE_TREE // Product feature tree generation mode +} + +/** + * Document Task - represents a user query about a document or feature tree request */ data class DocumentTask( val query: String, - val documentPath: String? = null + val documentPath: String? = null, + val mode: DocumentAgentMode = DocumentAgentMode.DOCUMENT_QUERY, + val language: String = "EN" ) /** @@ -154,11 +167,35 @@ class DocumentAgent( private fun buildContext(task: DocumentTask): DocumentContext { return DocumentContext( query = task.query, - documentPath = task.documentPath + documentPath = task.documentPath, + mode = task.mode, + language = task.language ) } private fun buildSystemPrompt(context: DocumentContext): String { + return when (context.mode) { + DocumentAgentMode.FEATURE_TREE -> buildFeatureTreePrompt(context) + DocumentAgentMode.DOCUMENT_QUERY -> buildDocumentQueryPrompt(context) + } + } + + private fun buildFeatureTreePrompt(context: DocumentContext): String { + val template = when (context.language.uppercase()) { + "ZH", "CN" -> ProductFeatureTreeTemplate.ZH + else -> ProductFeatureTreeTemplate.EN + } + + val variableTable = VariableTable() + variableTable.addVariable("projectPath", cc.unitmesh.devins.compiler.variable.VariableType.STRING, actualFileSystem.getProjectPath() ?: ".") + variableTable.addVariable("timestamp", cc.unitmesh.devins.compiler.variable.VariableType.STRING, Clock.System.now().toString()) + variableTable.addVariable("toolList", cc.unitmesh.devins.compiler.variable.VariableType.STRING, AgentToolFormatter.formatToolListForAI(toolRegistry.getAllTools().values.toList())) + + val compiler = TemplateCompiler(variableTable) + return compiler.compile(template) + } + + private fun buildDocumentQueryPrompt(context: DocumentContext): String { return """ You are a Code-First Project Research Assistant. Your job is to answer developer questions based on the source code (should be exist can be run by DocQL) and project documentation. DocQL Tool supports structured code search using a TreeSitter parser. @@ -270,6 +307,8 @@ ${AgentToolFormatter.formatToolListForAI(toolRegistry.getAllTools().values.toLis data class DocumentContext( val query: String, - val documentPath: String? + val documentPath: String?, + val mode: DocumentAgentMode = DocumentAgentMode.DOCUMENT_QUERY, + val language: String = "EN" ) } diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/document/ProductFeature.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/document/ProductFeature.kt new file mode 100644 index 0000000000..6dd8eab7b6 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/document/ProductFeature.kt @@ -0,0 +1,185 @@ +package cc.unitmesh.agent.document + +import kotlinx.serialization.Serializable +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +/** + * Product Feature - represents a node in the product feature tree + */ +@Serializable +data class ProductFeature( + val id: String = generateFeatureId(), + val name: String, + val description: String, + val level: FeatureLevel, + val confidence: Float = 1.0f, + val children: MutableList = mutableListOf(), + val codeRefs: MutableList = mutableListOf(), + val status: FeatureStatus = FeatureStatus.PENDING +) { + companion object { + @OptIn(ExperimentalAtomicApi::class) + private val counter = AtomicInt(0) + + @OptIn(ExperimentalAtomicApi::class) + fun generateFeatureId(): String = "feature-${counter.addAndFetch(1)}" + } +} + +/** + * Feature Level - represents the hierarchy level in the feature tree + */ +@Serializable +enum class FeatureLevel { + PRODUCT, // Product level (root node) + MODULE, // Module level (e.g., "Payment System") + FEATURE, // Feature level (e.g., "Order Payment") + ATOMIC // Atomic function level (e.g., "Validate Payment Amount") +} + +/** + * Feature Status - represents the analysis status of a feature + */ +@Serializable +enum class FeatureStatus { + PENDING, // Not yet analyzed + ANALYZING, // Currently being analyzed + CONFIRMED, // Analysis complete, confirmed + NEEDS_REVIEW // Needs human review (low confidence) +} + +/** + * Code Reference - links a feature to its source code + */ +@Serializable +data class CodeRef( + val filePath: String, + val className: String? = null, + val methods: List = emptyList(), + val inferredFrom: InferenceSource = InferenceSource.FILENAME +) + +/** + * Inference Source - indicates how a feature was inferred + */ +@Serializable +enum class InferenceSource { + FILENAME, // Inferred from file name + CLASS_DEFINITION, // Inferred from class definition/comments + METHOD_SIGNATURE, // Inferred from method signatures + CODE_ANALYSIS, // Inferred from code content analysis + DIRECTORY_STRUCTURE // Inferred from directory structure +} + +/** + * Output format for the feature tree + */ +enum class FeatureTreeOutputFormat { + MERMAID_MINDMAP, // Mermaid mindmap format + PLANTUML_MINDMAP, // PlantUML mindmap format + DOT_GRAPH, // Graphviz DOT format + MARKDOWN_LIST, // Markdown nested list + JSON // JSON tree structure +} + +/** + * Renders feature tree to various output formats + */ +object FeatureTreeRenderer { + + fun render(root: ProductFeature, format: FeatureTreeOutputFormat): String { + return when (format) { + FeatureTreeOutputFormat.MERMAID_MINDMAP -> renderMermaidMindmap(root) + FeatureTreeOutputFormat.PLANTUML_MINDMAP -> renderPlantUmlMindmap(root) + FeatureTreeOutputFormat.DOT_GRAPH -> renderDotGraph(root) + FeatureTreeOutputFormat.MARKDOWN_LIST -> renderMarkdownList(root) + FeatureTreeOutputFormat.JSON -> renderJson(root) + } + } + + private fun renderMermaidMindmap(root: ProductFeature): String { + return buildString { + appendLine("```mermaid") + appendLine("mindmap") + appendLine(" root((${root.name}))") + renderMermaidChildren(root.children, " ", this) + appendLine("```") + } + } + + private fun renderMermaidChildren(children: List, indent: String, sb: StringBuilder) { + for (child in children) { + val marker = if (child.confidence < 0.7) "?" else "" + sb.appendLine("$indent${child.name}$marker") + renderMermaidChildren(child.children, "$indent ", sb) + } + } + + private fun renderPlantUmlMindmap(root: ProductFeature): String { + return buildString { + appendLine("@startmindmap") + appendLine("* ${root.name}") + renderPlantUmlChildren(root.children, 2, this) + appendLine("@endmindmap") + } + } + + private fun renderPlantUmlChildren(children: List, level: Int, sb: StringBuilder) { + val marker = "*".repeat(level) + for (child in children) { + val confidence = if (child.confidence < 0.7) " [?]" else "" + sb.appendLine("$marker ${child.name}$confidence") + renderPlantUmlChildren(child.children, level + 1, sb) + } + } + + private fun renderDotGraph(root: ProductFeature): String { + return buildString { + appendLine("digraph FeatureTree {") + appendLine(" rankdir=TB;") + appendLine(" node [shape=box];") + appendLine(" \"${root.id}\" [label=\"${root.name}\", style=filled, fillcolor=lightblue];") + renderDotChildren(root, this) + appendLine("}") + } + } + + private fun renderDotChildren(parent: ProductFeature, sb: StringBuilder) { + for (child in parent.children) { + val color = if (child.confidence < 0.7) "lightyellow" else "white" + sb.appendLine(" \"${child.id}\" [label=\"${child.name}\", style=filled, fillcolor=$color];") + sb.appendLine(" \"${parent.id}\" -> \"${child.id}\";") + renderDotChildren(child, sb) + } + } + + private fun renderMarkdownList(root: ProductFeature): String { + return buildString { + appendLine("# ${root.name}") + appendLine() + appendLine(root.description) + appendLine() + renderMarkdownChildren(root.children, 0, this) + } + } + + private fun renderMarkdownChildren(children: List, level: Int, sb: StringBuilder) { + val indent = " ".repeat(level) + for (child in children) { + val marker = if (child.confidence < 0.7) " ⚠️" else "" + sb.appendLine("$indent- **${child.name}**$marker: ${child.description}") + if (child.codeRefs.isNotEmpty()) { + sb.appendLine("$indent - 📁 ${child.codeRefs.joinToString(", ") { it.filePath }}") + } + renderMarkdownChildren(child.children, level + 1, sb) + } + } + + private fun renderJson(root: ProductFeature): String { + return kotlinx.serialization.json.Json { prettyPrint = true }.encodeToString( + ProductFeature.serializer(), root + ) + } +} + diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/document/ProductFeatureTreeTemplate.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/document/ProductFeatureTreeTemplate.kt new file mode 100644 index 0000000000..9a03ac6a70 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/document/ProductFeatureTreeTemplate.kt @@ -0,0 +1,259 @@ +package cc.unitmesh.agent.document + +/** + * Template for Product Feature Tree Agent system prompt + * Supports ReAct-style iterative reasoning with tool usage + */ +object ProductFeatureTreeTemplate { + + /** + * English version of the product feature tree agent system prompt + */ + const val EN = """You are ProductFeatureTreeAgent, a specialized AI agent that extracts and builds hierarchical product feature trees from source code. + +## Environment +- Project Path: ${'$'}{projectPath} +- Current Time: ${'$'}{timestamp} + +## Your Goal +Analyze the source code repository and build a **Product Feature Tree** that represents the business capabilities of the software from a product manager's perspective. + +## Available Tools +${'$'}{toolList} + +## Tool Usage Format + +/tool-name +```json +{"parameter": "value"} +``` + + +## ReAct Workflow + +You work in a **Thought → Action → Observation → Update** loop: + +### Phase 1: Initial Exploration +1. Use `/glob` to scan project directory structure +2. Identify top-level modules (src/, packages/, modules/, etc.) +3. Build initial module-level skeleton + +### Phase 2: Deep Analysis (ReAct Loop) +For each module, execute the following cycle: + +**Thought**: Analyze what you know and what you need to learn +- What module am I analyzing? +- What information do I have? +- What questions need answers? +- What should I do next? + +**Action**: Call ONE tool to gather information +- `/glob` - scan file patterns +- `/read-file` - read file content +- `/grep` - search code patterns + +**Observation**: Process tool results + +**Update**: Extract features and update the tree +```json +{ + "feature": { + "name": "Feature Name (business term)", + "description": "User value description (20 chars max)", + "level": "MODULE|FEATURE|ATOMIC", + "confidence": 0.8, + "codeRefs": [{"path": "src/example.kt", "inferredFrom": "class_definition"}] + } +} +``` + +### Phase 3: Consolidation +- Merge similar features +- Generate parent node descriptions +- Output final feature tree + +## Feature Extraction Rules + +### Confidence Standards +- **1.0**: Class comments/docs clearly describe the feature +- **0.8**: Class name + method names clearly express intent +- **0.6**: Inferred only from filename +- **0.4**: Inferred only from directory location + +### Skip Rules +Skip the following: +- test/, __tests__/, *Test.*, *Spec.* +- build/, dist/, node_modules/, target/ +- Pure utility classes: Utils, Helper, Constants, Extensions +- Config files: *.config.*, *.yml, *.yaml, *.json (non-source) + +### Business Feature Criteria +Only extract features that represent business value: +- User-perceivable capabilities +- Features that would appear in product documentation +- NOT technical implementation details + +## Important Constraints + +1. **Don't read every file** - First infer from names, only read when uncertain +2. **Mark low confidence** - Features inferred only from names should have confidence ≤ 0.6 +3. **One module per iteration** - Stay focused on context +4. **Stop at MAX_ITERATIONS** - Output best results when limit reached + +## Output Format + +When analysis is complete, output: + +``` +TASK_COMPLETE + +## Product Feature Tree + +[Mermaid MindMap - preferred format for visualization] + +## Analysis Summary +- Modules analyzed: X +- Features extracted: Y +- High confidence (≥0.8): Z +- Needs human review (<0.7): W + +## Feature Details +[Markdown list with code references] +``` + +## IMPORTANT: One Tool Per Response + +Execute ONLY ONE tool per response. After each tool execution, you will see results and can decide the next step. + +- ✅ CORRECT: One block with ONE tool call +- ❌ WRONG: Multiple blocks or multiple tools + +Begin by exploring the project structure to understand the codebase layout. +""" + + /** + * Chinese version of the product feature tree agent system prompt + */ + const val ZH = """你是 ProductFeatureTreeAgent,一个专业的 AI Agent,负责从源代码中提取并构建层级化的产品功能树。 + +## 环境信息 +- 项目路径: ${'$'}{projectPath} +- 当前时间: ${'$'}{timestamp} + +## 你的目标 +分析源代码仓库,从产品经理的视角构建一棵**产品功能树**,展示软件的业务能力。 + +## 可用工具 +${'$'}{toolList} + +## 工具使用格式 + +/tool-name +```json +{"parameter": "value"} +``` + + +## ReAct 工作流程 + +你以 **思考 → 行动 → 观察 → 更新** 的循环方式工作: + +### 阶段一:初始探索 +1. 使用 `/glob` 扫描项目目录结构 +2. 识别顶层模块(src/、packages/、modules/ 等) +3. 构建初始模块层级骨架 + +### 阶段二:深度分析(ReAct 循环) +对每个模块执行以下循环: + +**思考**:分析已知信息和待学习内容 +- 我正在分析什么模块? +- 我有哪些信息? +- 需要回答什么问题? +- 下一步应该做什么? + +**行动**:调用一个工具获取信息 +- `/glob` - 扫描文件模式 +- `/read-file` - 读取文件内容 +- `/grep` - 搜索代码模式 + +**观察**:处理工具返回结果 + +**更新**:提取功能并更新功能树 +```json +{ + "feature": { + "name": "功能名称(业务术语)", + "description": "用户价值描述(20字以内)", + "level": "MODULE|FEATURE|ATOMIC", + "confidence": 0.8, + "codeRefs": [{"path": "src/example.kt", "inferredFrom": "class_definition"}] + } +} +``` + +### 阶段三:合并输出 +- 合并相似功能 +- 生成父节点描述 +- 输出最终功能树 + +## 功能提取规则 + +### 置信度标准 +- **1.0**:类注释/文档明确描述功能 +- **0.8**:类名+方法名清晰表达意图 +- **0.6**:仅从文件名推断 +- **0.4**:仅从目录位置推断 + +### 跳过规则 +跳过以下内容: +- test/、__tests__/、*Test.*、*Spec.* +- build/、dist/、node_modules/、target/ +- 纯工具类:Utils、Helper、Constants、Extensions +- 配置文件:*.config.*、*.yml、*.yaml、*.json(非源码) + +### 业务功能标准 +只提取代表业务价值的功能: +- 用户可感知的能力 +- 产品文档中会描述的功能 +- 而非技术实现细节 + +## 重要约束 + +1. **不要读取每个文件** - 先从命名推断,不确定时才读取 +2. **标注低置信度** - 仅从命名推断的功能置信度应 ≤ 0.6 +3. **每次迭代一个模块** - 保持上下文聚焦 +4. **达到最大迭代时停止** - 输出当前最佳结果 + +## 输出格式 + +分析完成时输出: + +``` +TASK_COMPLETE + +## 产品功能树 + +[Mermaid MindMap - 推荐的可视化格式] + +## 分析摘要 +- 分析模块数:X +- 提取功能数:Y +- 高置信度(≥0.8):Z +- 需人工确认(<0.7):W + +## 功能详情 +[Markdown 列表,包含代码引用] +``` + +## 重要:每次响应只执行一个工具 + +每次响应只执行一个工具。每次工具执行后,你会看到结果,然后决定下一步。 + +- ✅ 正确:一个 块包含一个工具调用 +- ❌ 错误:多个 块或多个工具 + +首先探索项目结构,了解代码库布局。 +""" +} + diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/CodingAgentExecutor.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/CodingAgentExecutor.kt index 4b6355dfe5..e74fc0ac5d 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/CodingAgentExecutor.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/CodingAgentExecutor.kt @@ -336,6 +336,13 @@ class CodingAgentExecutor( return null } + // 对于 Live Session,不要用分析结果替换原始输出 + // Live Terminal 已经在 Timeline 中显示实时输出了 + val isLiveSession = executionResult.metadata["isLiveSession"] == "true" + if (isLiveSession) { + return null + } + // 检测内容类型 val contentType = when { toolName == "glob" -> "file-list" diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/DocumentAgentExecutor.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/DocumentAgentExecutor.kt index 30bd19d553..c08d70b6c0 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/DocumentAgentExecutor.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/DocumentAgentExecutor.kt @@ -299,17 +299,25 @@ class DocumentAgentExecutor( /** * P1: Check for long content and delegate to AnalysisAgent for summarization * NOTE: Code content (from $.code.* queries) is NOT summarized to preserve actual code + * NOTE: Live Session output is NOT summarized to preserve real-time terminal output */ private suspend fun checkForLongContent( toolName: String, output: String, executionResult: ToolExecutionResult ): ToolResult.AgentResult? { - + if (subAgentManager == null) { return null } - + + // 对于 Live Session,不要用分析结果替换原始输出 + // Live Terminal 已经在 Timeline 中显示实时输出了 + val isLiveSession = executionResult.metadata["isLiveSession"] == "true" + if (isLiveSession) { + return null + } + val isCodeContent = output.contains("📘 class ") || output.contains("⚡ fun ") || output.contains("Found") && output.contains("entities") || @@ -317,7 +325,7 @@ class DocumentAgentExecutor( output.contains("fun ") && output.contains("(") || output.contains("def ") && output.contains(":") || output.contains("function ") && output.contains("{") - + val contentType = when { isCodeContent -> "code" // Don't summarize code! toolName == "docql" -> "document-content" @@ -326,23 +334,23 @@ class DocumentAgentExecutor( output.contains("```") -> "code" else -> "text" } - + // Skip summarization for code content - we want to show actual code if (contentType == "code") { logger.debug { "📊 Skipping summarization for code content (${output.length} chars)" } return null } - + // Build metadata val metadata = mutableMapOf() metadata["toolName"] = toolName metadata["executionId"] = executionResult.executionId metadata["success"] = executionResult.isSuccess.toString() - + executionResult.metadata.forEach { (key, value) -> metadata["tool_$key"] = value } - + return subAgentManager.checkAndHandleLongContent( content = output, contentType = contentType, diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolOrchestrator.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolOrchestrator.kt index c2211123ca..32b71dd67e 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolOrchestrator.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolOrchestrator.kt @@ -356,9 +356,9 @@ class ToolOrchestrator( val readFileTool = tool as cc.unitmesh.agent.tool.impl.ReadFileTool val readFileParams = cc.unitmesh.agent.tool.impl.ReadFileParams( path = params["path"] as? String ?: "", - startLine = params["startLine"] as? Int, - endLine = params["endLine"] as? Int, - maxLines = params["maxLines"] as? Int + startLine = (params["startLine"] as? Number)?.toInt(), + endLine = (params["endLine"] as? Number)?.toInt(), + maxLines = (params["maxLines"] as? Number)?.toInt() ) val invocation = readFileTool.createInvocation(readFileParams) return invocation.execute(context) @@ -436,7 +436,14 @@ class ToolOrchestrator( ): ToolResult { val globTool = tool as cc.unitmesh.agent.tool.impl.GlobTool val globParams = cc.unitmesh.agent.tool.impl.GlobParams( - pattern = params["pattern"] as? String ?: "" + pattern = params["pattern"] as? String ?: "", + path = params["path"] as? String, + includeDirectories = params["includeDirectories"] as? Boolean ?: false, + includeHidden = params["includeHidden"] as? Boolean ?: false, + maxResults = (params["maxResults"] as? Number)?.toInt() ?: 1000, + sortByTime = params["sortByTime"] as? Boolean ?: false, + includeFileInfo = params["includeFileInfo"] as? Boolean ?: false, + respectGitIgnore = params["respectGitIgnore"] as? Boolean ?: true ) val invocation = globTool.createInvocation(globParams) return invocation.execute(context) @@ -454,8 +461,8 @@ class ToolOrchestrator( include = params["include"] as? String, exclude = params["exclude"] as? String, caseSensitive = params["caseSensitive"] as? Boolean ?: false, - maxMatches = params["maxMatches"] as? Int ?: 100, - contextLines = params["contextLines"] as? Int ?: 0, + maxMatches = (params["maxMatches"] as? Number)?.toInt() ?: 100, + contextLines = (params["contextLines"] as? Number)?.toInt() ?: 0, recursive = params["recursive"] as? Boolean ?: true ) val invocation = grepTool.createInvocation(grepParams) diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/parser/ToolCallParser.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/parser/ToolCallParser.kt index 4aee490e5a..e68d8940f0 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/parser/ToolCallParser.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/parser/ToolCallParser.kt @@ -75,12 +75,30 @@ class ToolCallParser { for (i in jsonStartIndex until lines.size) { val line = lines[i].trim() + // Handle both "```json" on its own line and "```json{...}" on single line if (line == "```json") { inJsonBlock = true continue + } else if (line.startsWith("```json") && line.length > 7) { + // Handle format like: ```json{"pattern": "..."} + val jsonContent = line.removePrefix("```json").removeSuffix("```") + if (jsonContent.isNotEmpty()) { + jsonLines.add(jsonContent) + // If the line also ends with ```, we're done + if (line.endsWith("```")) { + break + } + inJsonBlock = true + } + continue } else if (line == "```") { break } else if (inJsonBlock) { + // Handle content that might end with ``` + if (line.endsWith("```")) { + jsonLines.add(line.removeSuffix("```")) + break + } jsonLines.add(lines[i]) // Keep original indentation for JSON } } 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..67d5ff4b72 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt @@ -0,0 +1,207 @@ +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). + * + * When the session completes, exitCode and executionTimeMs will be set. + * The UI can use these to show completion status without creating a separate TerminalOutputItem. + */ + data class LiveTerminalItem( + val sessionId: String, + val command: String, + val workingDirectory: String?, + val ptyHandle: Any?, // Platform-specific: on JVM this is a PtyProcess + val exitCode: Int? = null, // null = still running, non-null = completed + val executionTimeMs: Long? = null, // null = still running + val output: String? = null, // Captured output when completed (optional) + override val timestamp: Long = Platform.getCurrentTimestamp(), + override val id: String = generateId() + ) : TimelineItem(timestamp, id) { + /** + * Check if the terminal session is still running + */ + fun isRunning(): Boolean = exitCode == null + + /** + * Check if the terminal session completed successfully + */ + fun isSuccess(): Boolean = exitCode == 0 + } + + 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/build.gradle.kts b/mpp-idea/build.gradle.kts index 9e808eed86..323b6c2987 100644 --- a/mpp-idea/build.gradle.kts +++ b/mpp-idea/build.gradle.kts @@ -5,7 +5,6 @@ plugins { kotlin("jvm") id("org.jetbrains.intellij.platform") kotlin("plugin.compose") - kotlin("plugin.serialization") } group = "cc.unitmesh.devins" @@ -25,6 +24,7 @@ kotlin { } repositories { + mavenLocal() // For locally published mpp-ui and mpp-core artifacts mavenCentral() google() // Required for mpp-ui's webview dependencies (jogamp) @@ -41,7 +41,9 @@ dependencies { // Depend on mpp-ui and mpp-core JVM targets for shared UI components and ConfigManager // For KMP projects, we need to depend on the JVM target specifically // IMPORTANT: Exclude ALL transitive dependencies that conflict with IntelliJ's bundled libraries - implementation("cc.unitmesh.devins:mpp-ui-jvm") { + // Note: For KMP projects, the module is published as "group:artifact-jvm" but the project + // dependency substitution should map "group:artifact" to the project ":artifact" + implementation("AutoDev-Intellij:mpp-ui:$mppVersion") { // Exclude all Compose dependencies - IntelliJ provides its own via bundledModules exclude(group = "org.jetbrains.compose") exclude(group = "org.jetbrains.compose.runtime") @@ -75,10 +77,8 @@ dependencies { exclude(group = "org.jetbrains.jediterm") exclude(group = "org.jetbrains.pty4j") exclude(group = "io.github.vinceglb") - // Exclude SQLDelight - not needed in IntelliJ plugin - exclude(group = "app.cash.sqldelight") } - implementation("cc.unitmesh.devins:mpp-core-jvm") { + implementation("cc.unitmesh:mpp-core:$mppVersion") { // Exclude Compose dependencies from mpp-core as well exclude(group = "org.jetbrains.compose") exclude(group = "org.jetbrains.compose.runtime") @@ -101,9 +101,40 @@ dependencies { } // Use platform-provided kotlinx libraries to avoid classloader conflicts - compileOnly("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") compileOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1") + // Gson for JSON serialization (used by IdeaRemoteAgentClient) + compileOnly("com.google.code.gson:gson:2.11.0") + + // Note: We use SimpleJewelMarkdown with intellij-markdown parser instead of mikepenz + // to avoid Compose runtime version mismatch with IntelliJ's bundled Compose + + // SQLite JDBC driver for SQLDelight (required at runtime) + implementation("org.xerial:sqlite-jdbc:3.49.1.0") + + // DevIn language support for @ and / completion + // These provide the DevIn language parser, completion contributors, and core functionality + implementation("AutoDev-Intellij:exts-devins-lang:$mppVersion") { + // Exclude kotlinx libraries - IntelliJ provides its own + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core") + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core-jvm") + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-swing") + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-serialization-json") + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-serialization-json-jvm") + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-serialization-core") + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-serialization-core-jvm") + } + implementation("AutoDev-Intellij:core:$mppVersion") { + // Exclude kotlinx libraries - IntelliJ provides its own + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core") + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core-jvm") + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-swing") + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-serialization-json") + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-serialization-json-jvm") + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-serialization-core") + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-serialization-core-jvm") + } + // Ktor HTTP Client for LLM API calls - use compileOnly for libraries that may conflict compileOnly("io.ktor:ktor-client-core:3.2.2") compileOnly("io.ktor:ktor-client-cio:3.2.2") @@ -121,7 +152,7 @@ dependencies { // Target IntelliJ IDEA 2025.2+ for Compose support create("IC", "2025.2.1") - bundledPlugins("com.intellij.java") + bundledPlugins("com.intellij.java", "org.intellij.plugins.markdown", "com.jetbrains.sh", "Git4Idea") // Compose support dependencies (bundled in IDEA 252+) bundledModules( diff --git a/mpp-idea/gradle.properties b/mpp-idea/gradle.properties new file mode 100644 index 0000000000..c4921efaaf --- /dev/null +++ b/mpp-idea/gradle.properties @@ -0,0 +1,15 @@ +# Gradle JVM memory settings +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8 + +# Kotlin daemon memory settings +kotlin.daemon.jvmargs=-Xmx4g + +# Enable Gradle Configuration Cache +org.gradle.configuration-cache = true + +# Enable Gradle Build Cache +org.gradle.caching = true + +# Kotlin stdlib +kotlin.stdlib.default.dependency = false + diff --git a/mpp-idea/settings.gradle.kts b/mpp-idea/settings.gradle.kts index a205d98f32..d7a203841c 100644 --- a/mpp-idea/settings.gradle.kts +++ b/mpp-idea/settings.gradle.kts @@ -10,19 +10,30 @@ pluginManagement { } plugins { - kotlin("jvm") version "2.1.20" - kotlin("plugin.compose") version "2.1.20" - kotlin("plugin.serialization") version "2.1.20" + kotlin("jvm") version "2.2.0" + kotlin("plugin.compose") version "2.2.0" id("org.jetbrains.intellij.platform") version "2.10.2" } } // Include mpp-ui from parent project for shared UI components and ConfigManager -// For KMP projects, we substitute the JVM target artifacts +// For KMP projects, we substitute the Maven coordinates with local project dependencies +// Note: The group IDs must match what's defined in the respective build.gradle.kts files: +// - mpp-ui: uses root project name "AutoDev-Intellij" as group +// - mpp-core: group = "cc.unitmesh" +// - mpp-codegraph: uses root project name +// - mpp-viewer: group = "cc.unitmesh.viewer" +// - devins-lang, core: uses root project name "AutoDev-Intellij" as group includeBuild("..") { dependencySubstitution { - substitute(module("cc.unitmesh.devins:mpp-ui-jvm")).using(project(":mpp-ui")) - substitute(module("cc.unitmesh.devins:mpp-core-jvm")).using(project(":mpp-core")) + // Substitute Maven coordinates with project dependencies + substitute(module("AutoDev-Intellij:mpp-ui")).using(project(":mpp-ui")).because("Using local project") + substitute(module("cc.unitmesh:mpp-core")).using(project(":mpp-core")).because("Using local project") + substitute(module("AutoDev-Intellij:mpp-codegraph")).using(project(":mpp-codegraph")).because("Using local project") + substitute(module("cc.unitmesh.viewer:mpp-viewer")).using(project(":mpp-viewer")).because("Using local project") + // DevIn language support for @ and / completion + substitute(module("AutoDev-Intellij:exts-devins-lang")).using(project(":exts:devins-lang")).because("Using local project") + substitute(module("AutoDev-Intellij:core")).using(project(":core")).because("Using local project") } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/IdeaResizableSplitPane.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/IdeaResizableSplitPane.kt new file mode 100644 index 0000000000..5fcc8cec0d --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/IdeaResizableSplitPane.kt @@ -0,0 +1,141 @@ +package cc.unitmesh.devins.idea.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.foundation.theme.JewelTheme +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * A high-performance resizable split pane for IntelliJ IDEA plugin using Jewel theming. + * Divides two composables horizontally with smooth drag handling and visual feedback. + * + * @param modifier The modifier to apply to this layout + * @param initialSplitRatio The initial split ratio (0.0 to 1.0) for the first pane + * @param minRatio The minimum split ratio for the first pane + * @param maxRatio The maximum split ratio for the first pane + * @param dividerWidth The width of the divider in dp + * @param first The first composable (left side) + * @param second The second composable (right side) + */ +@Composable +fun IdeaResizableSplitPane( + modifier: Modifier = Modifier, + initialSplitRatio: Float = 0.5f, + minRatio: Float = 0.2f, + maxRatio: Float = 0.8f, + dividerWidth: Int = 4, + first: @Composable () -> Unit, + second: @Composable () -> Unit +) { + var splitRatio by remember { mutableStateOf(initialSplitRatio.coerceIn(minRatio, maxRatio)) } + var isDragging by remember { mutableStateOf(false) } + var containerWidth by remember { mutableStateOf(0) } + + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + + val dividerAlpha by animateFloatAsState( + targetValue = when { + isDragging -> 1f + isHovered -> 0.8f + else -> 0.4f + }, + animationSpec = tween(durationMillis = 150), + label = "dividerAlpha" + ) + + val dividerScale by animateFloatAsState( + targetValue = when { + isDragging -> 1.2f + isHovered -> 1.1f + else -> 1f + }, + animationSpec = tween(durationMillis = 150), + label = "dividerScale" + ) + + Layout( + modifier = modifier, + content = { + Box(modifier = Modifier.fillMaxHeight()) { first() } + + Box( + modifier = Modifier + .width(dividerWidth.dp) + .fillMaxHeight() + .hoverable(interactionSource) + .pointerHoverIcon(PointerIcon.Crosshair) + ) { + Spacer( + modifier = Modifier + .fillMaxSize() + .background(JewelTheme.globalColors.borders.normal.copy(alpha = 0.3f)) + ) + Spacer( + modifier = Modifier + .width((dividerWidth * dividerScale).dp) + .fillMaxHeight() + .alpha(dividerAlpha) + .background( + when { + isDragging -> JewelTheme.globalColors.outlines.focused + isHovered -> JewelTheme.globalColors.outlines.focused.copy(alpha = 0.7f) + else -> JewelTheme.globalColors.borders.normal + } + ) + .pointerInput(containerWidth) { + detectDragGestures( + onDragStart = { isDragging = true }, + onDragEnd = { isDragging = false }, + onDragCancel = { isDragging = false } + ) { change, dragAmount -> + change.consume() + if (containerWidth > 0) { + val delta = dragAmount.x / containerWidth + val newRatio = (splitRatio + delta).coerceIn(minRatio, maxRatio) + if (abs(newRatio - splitRatio) > 0.001f) { + splitRatio = newRatio + } + } + } + } + ) + } + + Box(modifier = Modifier.fillMaxHeight()) { second() } + } + ) { measurables, constraints -> + containerWidth = constraints.maxWidth + val dividerWidthPx = (dividerWidth.dp).roundToPx() + val availableWidth = constraints.maxWidth - dividerWidthPx + val firstWidth = (availableWidth * splitRatio).roundToInt().coerceAtLeast(0) + val secondWidth = (availableWidth - firstWidth).coerceAtLeast(0) + + val firstPlaceable = measurables[0].measure(Constraints.fixed(firstWidth, constraints.maxHeight)) + val dividerPlaceable = measurables[1].measure(Constraints.fixed(dividerWidthPx, constraints.maxHeight)) + val secondPlaceable = measurables[2].measure(Constraints.fixed(secondWidth, constraints.maxHeight)) + + layout(constraints.maxWidth, constraints.maxHeight) { + firstPlaceable.placeRelative(0, 0) + dividerPlaceable.placeRelative(firstWidth, 0) + secondPlaceable.placeRelative(firstWidth + dividerWidthPx, 0) + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/IdeaVerticalResizableSplitPane.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/IdeaVerticalResizableSplitPane.kt new file mode 100644 index 0000000000..5bd61539aa --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/IdeaVerticalResizableSplitPane.kt @@ -0,0 +1,141 @@ +package cc.unitmesh.devins.idea.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.foundation.theme.JewelTheme +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * A high-performance vertical resizable split pane for IntelliJ IDEA plugin using Jewel theming. + * Divides two composables vertically with smooth drag handling and visual feedback. + * + * @param modifier The modifier to apply to this layout + * @param initialSplitRatio The initial split ratio (0.0 to 1.0) for the top pane + * @param minRatio The minimum split ratio for the top pane + * @param maxRatio The maximum split ratio for the top pane + * @param dividerHeight The height of the divider in dp + * @param top The first composable (top side) + * @param bottom The second composable (bottom side) + */ +@Composable +fun IdeaVerticalResizableSplitPane( + modifier: Modifier = Modifier, + initialSplitRatio: Float = 0.5f, + minRatio: Float = 0.2f, + maxRatio: Float = 0.8f, + dividerHeight: Int = 4, + top: @Composable () -> Unit, + bottom: @Composable () -> Unit +) { + var splitRatio by remember { mutableStateOf(initialSplitRatio.coerceIn(minRatio, maxRatio)) } + var isDragging by remember { mutableStateOf(false) } + var containerHeight by remember { mutableStateOf(0) } + + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + + val dividerAlpha by animateFloatAsState( + targetValue = when { + isDragging -> 1f + isHovered -> 0.8f + else -> 0.4f + }, + animationSpec = tween(durationMillis = 150), + label = "dividerAlpha" + ) + + val dividerScale by animateFloatAsState( + targetValue = when { + isDragging -> 1.2f + isHovered -> 1.1f + else -> 1f + }, + animationSpec = tween(durationMillis = 150), + label = "dividerScale" + ) + + Layout( + modifier = modifier, + content = { + Box(modifier = Modifier.fillMaxWidth()) { top() } + + Box( + modifier = Modifier + .height(dividerHeight.dp) + .fillMaxWidth() + .hoverable(interactionSource) + .pointerHoverIcon(PointerIcon.Crosshair) + ) { + Spacer( + modifier = Modifier + .fillMaxSize() + .background(JewelTheme.globalColors.borders.normal.copy(alpha = 0.3f)) + ) + Spacer( + modifier = Modifier + .height((dividerHeight * dividerScale).dp) + .fillMaxWidth() + .alpha(dividerAlpha) + .background( + when { + isDragging -> JewelTheme.globalColors.outlines.focused + isHovered -> JewelTheme.globalColors.outlines.focused.copy(alpha = 0.7f) + else -> JewelTheme.globalColors.borders.normal + } + ) + .pointerInput(containerHeight) { + detectDragGestures( + onDragStart = { isDragging = true }, + onDragEnd = { isDragging = false }, + onDragCancel = { isDragging = false } + ) { change, dragAmount -> + change.consume() + if (containerHeight > 0) { + val delta = dragAmount.y / containerHeight + val newRatio = (splitRatio + delta).coerceIn(minRatio, maxRatio) + if (abs(newRatio - splitRatio) > 0.001f) { + splitRatio = newRatio + } + } + } + } + ) + } + + Box(modifier = Modifier.fillMaxWidth()) { bottom() } + } + ) { measurables, constraints -> + containerHeight = constraints.maxHeight + val dividerHeightPx = (dividerHeight.dp).roundToPx() + val availableHeight = constraints.maxHeight - dividerHeightPx + val topHeight = (availableHeight * splitRatio).roundToInt().coerceAtLeast(0) + val bottomHeight = (availableHeight - topHeight).coerceAtLeast(0) + + val topPlaceable = measurables[0].measure(Constraints.fixed(constraints.maxWidth, topHeight)) + val dividerPlaceable = measurables[1].measure(Constraints.fixed(constraints.maxWidth, dividerHeightPx)) + val bottomPlaceable = measurables[2].measure(Constraints.fixed(constraints.maxWidth, bottomHeight)) + + layout(constraints.maxWidth, constraints.maxHeight) { + topPlaceable.placeRelative(0, 0) + dividerPlaceable.placeRelative(0, topHeight) + bottomPlaceable.placeRelative(0, topHeight + dividerHeightPx) + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/header/IdeaAgentTabsHeader.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/header/IdeaAgentTabsHeader.kt new file mode 100644 index 0000000000..a822ebc50e --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/header/IdeaAgentTabsHeader.kt @@ -0,0 +1,273 @@ +package cc.unitmesh.devins.idea.components.header + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.agent.AgentType +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.IconButton +import org.jetbrains.jewel.ui.component.Text + +/** + * Agent tabs header for switching between agent types. + * Modern segmented control design with smooth animations and hover effects. + */ +@Composable +fun IdeaAgentTabsHeader( + currentAgentType: AgentType, + onAgentTypeChange: (AgentType) -> Unit, + onNewChat: () -> Unit, + onSettings: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(40.dp) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Left: Segmented Agent Type Control + SegmentedAgentTabs( + currentAgentType = currentAgentType, + onAgentTypeChange = onAgentTypeChange + ) + + // Right: Action buttons + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // New Chat button with plus icon + FancyActionButton( + icon = IdeaComposeIcons.Add, + contentDescription = "New Chat", + onClick = onNewChat + ) + + // Settings button + FancyActionButton( + icon = IdeaComposeIcons.Settings, + contentDescription = "Settings", + onClick = onSettings + ) + } + } +} + +/** + * Segmented control container for agent tabs. + * Provides a unified background with individual tab pills. + */ +@Composable +private fun SegmentedAgentTabs( + currentAgentType: AgentType, + onAgentTypeChange: (AgentType) -> Unit, + modifier: Modifier = Modifier +) { + // Note: LOCAL_CHAT is intentionally excluded from the tabs as it represents + // a different interaction mode (direct local chat without agent routing). + // It's handled separately in IdeaAgentApp but not exposed as a user-selectable tab. + val agentTypes = listOf(AgentType.CODING, AgentType.CODE_REVIEW, AgentType.KNOWLEDGE, AgentType.REMOTE) + + Row( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f)) + .padding(2.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + agentTypes.forEach { type -> + IdeaAgentTabPill( + type = type, + isSelected = type == currentAgentType, + onClick = { onAgentTypeChange(type) } + ) + } + } +} + +/** + * Individual agent tab pill with icon, animated selection state, and hover effect. + */ +@Composable +private fun IdeaAgentTabPill( + type: AgentType, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + + // Animated background color + val backgroundColor by animateColorAsState( + targetValue = when { + isSelected -> getAgentTypeColor(type).copy(alpha = 0.2f) + isHovered -> JewelTheme.globalColors.panelBackground.copy(alpha = 0.8f) + else -> Color.Transparent + }, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "tabBackground" + ) + + // Animated text color + val textColor by animateColorAsState( + targetValue = when { + isSelected -> getAgentTypeColor(type) + isHovered -> JewelTheme.globalColors.text.normal + else -> JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + }, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "tabText" + ) + + // Animated indicator height + val indicatorHeight by animateDpAsState( + targetValue = if (isSelected) 2.dp else 0.dp, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "indicator" + ) + + Column( + modifier = modifier + .clip(RoundedCornerShape(6.dp)) + .background(backgroundColor) + .hoverable(interactionSource) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick + ) + .padding(horizontal = 10.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Icon for agent type + Icon( + imageVector = getAgentTypeIcon(type), + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = textColor + ) + + Text( + text = type.getDisplayName(), + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + color = textColor + ) + ) + } + + // Bottom indicator + Spacer(modifier = Modifier.height(2.dp)) + Box( + modifier = Modifier + .width(20.dp) + .height(indicatorHeight) + .clip(RoundedCornerShape(1.dp)) + .background(getAgentTypeColor(type)) + ) + } +} + +/** + * Fancy action button with hover effect. + */ +@Composable +private fun FancyActionButton( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + + val backgroundColor by animateColorAsState( + targetValue = if (isHovered) { + JewelTheme.globalColors.panelBackground.copy(alpha = 0.8f) + } else { + Color.Transparent + }, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "actionBg" + ) + + val iconColor by animateColorAsState( + targetValue = if (isHovered) { + AutoDevColors.Blue.c400 + } else { + JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + }, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "actionIcon" + ) + + IconButton( + onClick = onClick, + modifier = modifier + .size(28.dp) + .clip(RoundedCornerShape(6.dp)) + .background(backgroundColor) + .hoverable(interactionSource) + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + modifier = Modifier.size(16.dp), + tint = iconColor + ) + } +} + +/** + * Get themed color for each agent type. + */ +@Composable +private fun getAgentTypeColor(type: AgentType): Color = when (type) { + AgentType.CODING -> AutoDevColors.Blue.c400 + AgentType.CODE_REVIEW -> AutoDevColors.Indigo.c400 + AgentType.KNOWLEDGE -> AutoDevColors.Green.c400 + AgentType.REMOTE -> AutoDevColors.Amber.c400 + AgentType.LOCAL_CHAT -> JewelTheme.globalColors.text.normal +} + +/** + * Get icon for each agent type. + */ +private fun getAgentTypeIcon(type: AgentType): ImageVector = when (type) { + AgentType.CODING -> IdeaComposeIcons.Code + AgentType.CODE_REVIEW -> IdeaComposeIcons.Review + AgentType.KNOWLEDGE -> IdeaComposeIcons.Book + AgentType.REMOTE -> IdeaComposeIcons.Cloud + AgentType.LOCAL_CHAT -> IdeaComposeIcons.Chat +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/status/IdeaToolLoadingStatusBar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/status/IdeaToolLoadingStatusBar.kt new file mode 100644 index 0000000000..1c27e4a70d --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/status/IdeaToolLoadingStatusBar.kt @@ -0,0 +1,125 @@ +package cc.unitmesh.devins.idea.components.status + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.devins.idea.toolwindow.IdeaAgentViewModel +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Text + +/** + * Tool loading status bar for displaying MCP tools and SubAgents status. + */ +@Composable +fun IdeaToolLoadingStatusBar( + viewModel: IdeaAgentViewModel, + modifier: Modifier = Modifier +) { + val mcpPreloadingMessage by viewModel.mcpPreloadingMessage.collectAsState() + val mcpPreloadingStatus by viewModel.mcpPreloadingStatus.collectAsState() + // Recompute when preloading status changes to make it reactive + val toolStatus = remember(mcpPreloadingStatus) { viewModel.getToolLoadingStatus() } + + Row( + modifier = modifier + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.8f)) + .padding(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // SubAgents status + IdeaToolStatusChip( + label = "SubAgents", + count = toolStatus.subAgentsEnabled, + total = toolStatus.subAgentsTotal, + isLoading = false, + color = AutoDevColors.Blue.c400 + ) + + // MCP Tools status + IdeaToolStatusChip( + label = "MCP Tools", + count = toolStatus.mcpToolsEnabled, + total = if (toolStatus.isLoading) -1 else toolStatus.mcpToolsTotal, + isLoading = toolStatus.isLoading, + color = if (!toolStatus.isLoading && toolStatus.mcpToolsEnabled > 0) + AutoDevColors.Green.c400 + else + JewelTheme.globalColors.text.info + ) + + Spacer(modifier = Modifier.weight(1f)) + + // Status message + if (mcpPreloadingMessage.isNotEmpty()) { + Text( + text = mcpPreloadingMessage, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.info + ), + maxLines = 1 + ) + } else if (!toolStatus.isLoading && toolStatus.mcpServersLoaded > 0) { + Text( + text = "✓ All tools ready", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = AutoDevColors.Green.c400 + ) + ) + } + } +} + +/** + * Individual tool status chip with count indicator. + */ +@Composable +fun IdeaToolStatusChip( + label: String, + count: Int, + total: Int, + isLoading: Boolean, + color: Color, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Status indicator dot + Box( + modifier = Modifier + .size(8.dp) + .background( + color = if (isLoading) JewelTheme.globalColors.text.info.copy(alpha = 0.5f) else color, + shape = CircleShape + ) + ) + + val totalDisplay = if (total < 0) "..." else total.toString() + Text( + text = "$label ($count/$totalDisplay)", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = if (isLoading) + JewelTheme.globalColors.text.info.copy(alpha = 0.7f) + else + JewelTheme.globalColors.text.info + ) + ) + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaErrorBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaErrorBubble.kt new file mode 100644 index 0000000000..e4eb1fdec4 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaErrorBubble.kt @@ -0,0 +1,54 @@ +package cc.unitmesh.devins.idea.components.timeline + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.Text + +/** + * Error bubble for displaying error messages. + * Uses AutoDevColors design system for consistent error styling. + */ +@Composable +fun IdeaErrorBubble( + message: String, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start + ) { + Box( + modifier = Modifier + .widthIn(max = 500.dp) + .background(AutoDevColors.Red.c400.copy(alpha = 0.2f)) + .padding(8.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = IdeaComposeIcons.Error, + contentDescription = "Error", + modifier = Modifier.size(16.dp), + tint = AutoDevColors.Red.c400 + ) + Text( + text = message, + style = JewelTheme.defaultTextStyle.copy( + color = AutoDevColors.Red.c400 + ) + ) + } + } + } +} + 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 new file mode 100644 index 0000000000..9cac0bfe30 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaMessageBubble.kt @@ -0,0 +1,72 @@ +package cc.unitmesh.devins.idea.components.timeline + +import androidx.compose.foundation.background +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.llm.MessageRole +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.theme.defaultBannerStyle + +/** + * Message bubble for displaying user and assistant messages. + * Uses Jewel theming aligned with IntelliJ IDEA. + */ +@Composable +fun IdeaMessageBubble( + role: MessageRole, + content: String, + modifier: Modifier = Modifier +) { + val isUser = role == MessageRole.USER + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start + ) { + Box( + modifier = Modifier + .widthIn(max = 500.dp) + .background( + if (isUser) + JewelTheme.defaultBannerStyle.information.colors.background.copy(alpha = 0.75f) + else + JewelTheme.globalColors.panelBackground + ) + .padding(8.dp) + ) { + Text( + text = content, + style = JewelTheme.defaultTextStyle + ) + } + } +} + +/** + * Streaming message bubble with cursor indicator. + */ +@Composable +fun IdeaStreamingMessageBubble( + content: String, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start + ) { + Box( + modifier = Modifier + .widthIn(max = 500.dp) + .background(JewelTheme.globalColors.panelBackground) + .padding(8.dp) + ) { + Text( + text = content + "|", + style = JewelTheme.defaultTextStyle + ) + } + } +} + 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 new file mode 100644 index 0000000000..6c30a2efc5 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTaskCompleteBubble.kt @@ -0,0 +1,62 @@ +package cc.unitmesh.devins.idea.components.timeline + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +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.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 +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.Text + +/** + * Task complete bubble for displaying task completion status. + * Similar to TaskCompletedItem in mpp-ui but using Jewel theming. + */ +@Composable +fun IdeaTaskCompleteBubble( + item: TimelineItem.TaskCompleteItem, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .background( + color = if (item.success) + AutoDevColors.Green.c400.copy(alpha = 0.2f) + else + AutoDevColors.Red.c400.copy(alpha = 0.2f), + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (item.success) IdeaComposeIcons.CheckCircle else IdeaComposeIcons.Error, + contentDescription = if (item.success) "Success" else "Failed", + modifier = Modifier.size(16.dp), + tint = if (item.success) AutoDevColors.Green.c400 else AutoDevColors.Red.c400 + ) + Text( + text = "${item.message} (${item.iterations} iterations)", + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold + ) + ) + } + } + } +} + 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 new file mode 100644 index 0000000000..052f95d2e1 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTerminalOutputBubble.kt @@ -0,0 +1,247 @@ +package cc.unitmesh.devins.idea.components.timeline + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.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.draw.clip +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.agent.render.TimelineItem +import cc.unitmesh.devins.idea.renderer.terminal.IdeaAnsiTerminalRenderer +import cc.unitmesh.devins.idea.terminal.TerminalApiCompat +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.project.Project +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.Text +import java.awt.datatransfer.StringSelection + +/** + * Terminal output bubble for displaying shell command results. + * Uses Jewel-themed ANSI terminal renderer for proper color and formatting support. + * Shows output with scrollable area, full width layout. + * + * Features: + * - ANSI color and formatting support + * - Collapsible output with header + * - Copy to clipboard + * - Open in native terminal (when available) + */ +@Composable +fun IdeaTerminalOutputBubble( + item: TimelineItem.TerminalOutputItem, + modifier: Modifier = Modifier, + project: Project? = null +) { + var expanded by remember { mutableStateOf(true) } + + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(AutoDevColors.Neutral.c900) + ) { + Column { + // Header with command and status + TerminalHeader( + command = item.command, + exitCode = item.exitCode, + executionTimeMs = item.executionTimeMs, + expanded = expanded, + onExpandToggle = { expanded = !expanded }, + onCopy = { + CopyPasteManager.getInstance().setContents(StringSelection(item.output)) + }, + onOpenInTerminal = project?.let { proj -> + { openCommandInTerminal(proj, item.command) } + } + ) + + // Collapsible output content using Jewel ANSI terminal renderer + AnimatedVisibility( + visible = expanded, + enter = expandVertically(), + exit = shrinkVertically() + ) { + // Use Jewel-themed ANSI terminal renderer + IdeaAnsiTerminalRenderer( + ansiText = item.output, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 80.dp, max = 300.dp), + maxHeight = 300, + backgroundColor = AutoDevColors.Neutral.c900 + ) + } + } + } +} + +/** + * Opens the command in IDEA's native terminal using compatibility layer. + */ +private fun openCommandInTerminal(project: Project, command: String) { + TerminalApiCompat.openCommandInTerminal( + project = project, + command = command, + tabName = "AutoDev: $command", + requestFocus = true + ) +} + +/** + * Header component for terminal bubble with command, status, and action buttons. + */ +@Composable +private fun TerminalHeader( + command: String, + exitCode: Int, + executionTimeMs: Long, + expanded: Boolean, + onExpandToggle: () -> Unit, + onCopy: () -> Unit, + onOpenInTerminal: (() -> Unit)? = null +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(AutoDevColors.Neutral.c800) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onExpandToggle() } + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Left side: Command + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + // Expand/Collapse icon + Icon( + imageVector = if (expanded) IdeaComposeIcons.ExpandLess else IdeaComposeIcons.ExpandMore, + contentDescription = if (expanded) "Collapse" else "Expand", + tint = AutoDevColors.Neutral.c400, + modifier = Modifier.size(16.dp) + ) + + // Terminal icon + Icon( + imageVector = IdeaComposeIcons.Terminal, + contentDescription = "Terminal", + tint = AutoDevColors.Cyan.c400, + modifier = Modifier.size(14.dp) + ) + + // Command text (truncated if too long) + val displayCommand = if (command.length > 50) command.take(50) + "..." else command + Text( + text = "$ $displayCommand", + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Medium, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + color = AutoDevColors.Cyan.c400 + ) + ) + } + + // Right side: Status and actions + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Status badge + TerminalStatusBadge(exitCode = exitCode, executionTimeMs = executionTimeMs) + + // Open in terminal button (if available) + if (onOpenInTerminal != null) { + Box( + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(4.dp)) + .background(AutoDevColors.Neutral.c700) + .clickable { onOpenInTerminal() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = IdeaComposeIcons.Terminal, + contentDescription = "Open in Terminal", + tint = AutoDevColors.Neutral.c300, + modifier = Modifier.size(14.dp) + ) + } + } + + // Copy button + Box( + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(4.dp)) + .background(AutoDevColors.Neutral.c700) + .clickable { onCopy() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = IdeaComposeIcons.ContentCopy, + contentDescription = "Copy output", + tint = AutoDevColors.Neutral.c300, + modifier = Modifier.size(14.dp) + ) + } + } + } +} + +/** + * Status badge showing exit code and execution time. + */ +@Composable +private fun TerminalStatusBadge( + exitCode: Int, + executionTimeMs: Long +) { + val (bgColor, textColor, text) = when { + exitCode == 0 -> Triple( + AutoDevColors.Green.c600.copy(alpha = 0.3f), + AutoDevColors.Green.c400, + "exit: 0 ${executionTimeMs}ms" + ) + + else -> Triple( + AutoDevColors.Red.c600.copy(alpha = 0.3f), + AutoDevColors.Red.c400, + "exit: $exitCode ${executionTimeMs}ms" + ) + } + + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(bgColor) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = text, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = textColor + ) + ) + } +} 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 new file mode 100644 index 0000000000..323b36873e --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt @@ -0,0 +1,109 @@ +package cc.unitmesh.devins.idea.components.timeline + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +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.agent.render.TimelineItem +import com.intellij.openapi.project.Project +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Text + +/** + * Timeline content container for displaying chat history. + * Similar to AgentMessageList in mpp-ui but using Jewel theming. + */ +@Composable +fun IdeaTimelineContent( + timeline: List, + streamingOutput: String, + listState: LazyListState, + modifier: Modifier = Modifier, + project: Project? = null +) { + if (timeline.isEmpty() && streamingOutput.isEmpty()) { + IdeaEmptyStateMessage("Start a conversation with your AI Assistant!") + } else { + LazyColumn( + state = listState, + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(timeline, key = { it.id }) { item -> + IdeaTimelineItemView(item, project) + } + + // Show streaming output + if (streamingOutput.isNotEmpty()) { + item { + IdeaStreamingMessageBubble(streamingOutput) + } + } + } + } +} + +/** + * Dispatch timeline item to appropriate bubble component. + */ +@Composable +fun IdeaTimelineItemView(item: TimelineItem, project: Project? = null) { + when (item) { + is TimelineItem.MessageItem -> { + IdeaMessageBubble( + role = item.role, + content = item.content + ) + } + is TimelineItem.ToolCallItem -> { + IdeaToolCallBubble(item) + } + is TimelineItem.ErrorItem -> { + IdeaErrorBubble(item.message) + } + is TimelineItem.TaskCompleteItem -> { + IdeaTaskCompleteBubble(item) + } + is TimelineItem.TerminalOutputItem -> { + IdeaTerminalOutputBubble(item, project = project) + } + is TimelineItem.LiveTerminalItem -> { + // Live terminal not supported in IDEA yet, show placeholder + IdeaTerminalOutputBubble( + item = TimelineItem.TerminalOutputItem( + command = item.command, + output = "[Live terminal session: ${item.sessionId}]", + exitCode = 0, + executionTimeMs = 0 + ), + project = project + ) + } + } +} + +/** + * Empty state message displayed when timeline is empty. + */ +@Composable +fun IdeaEmptyStateMessage(text: String) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 14.sp, + color = JewelTheme.globalColors.text.info + ) + ) + } +} + 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 new file mode 100644 index 0000000000..2f82880e33 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaToolCallBubble.kt @@ -0,0 +1,389 @@ +package cc.unitmesh.devins.idea.components.timeline + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +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.render.TimelineItem +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import com.intellij.openapi.ide.CopyPasteManager +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.IconButton +import org.jetbrains.jewel.ui.component.Text +import java.awt.datatransfer.StringSelection + +/** + * Tool call bubble for displaying tool execution with status. + * Similar to ToolItem/CombinedToolItem in mpp-ui but using Jewel theming. + * + * Features aligned with mpp-ui: + * - Expand/collapse for params and output + * - Copy to clipboard functionality + * - Status indicators with colors + * - Execution time display + */ +@Composable +fun IdeaToolCallBubble( + item: TimelineItem.ToolCallItem, + modifier: Modifier = Modifier +) { + // Auto-expand on error + var expanded by remember { mutableStateOf(item.success == false) } + var showFullParams by remember { mutableStateOf(false) } + var showFullOutput by remember { mutableStateOf(item.success == false) } + + val isExecuting = item.success == null + val hasParams = item.params.isNotEmpty() + val hasOutput = !item.output.isNullOrEmpty() + val hasExpandableContent = hasParams || hasOutput + + // Determine display content + val displayParams = if (showFullParams) item.params else item.params.take(100) + val displayOutput = if (showFullOutput) item.output else item.output?.take(200) + val hasMoreParams = item.params.length > 100 + val hasMoreOutput = (item.output?.length ?: 0) > 200 + + Box( + modifier = modifier + .fillMaxWidth() + .background( + color = JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f), + shape = RoundedCornerShape(4.dp) + ) + .padding(8.dp) + ) { + Column { + // Header row: Status + Tool name + Summary + Expand icon + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { if (hasExpandableContent) expanded = !expanded }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Status icon + Icon( + imageVector = when { + isExecuting -> IdeaComposeIcons.PlayArrow + item.success == true -> IdeaComposeIcons.CheckCircle + else -> IdeaComposeIcons.Error + }, + contentDescription = when { + isExecuting -> "Executing" + item.success == true -> "Success" + else -> "Failed" + }, + modifier = Modifier.size(16.dp), + tint = when { + isExecuting -> AutoDevColors.Blue.c400 + item.success == true -> AutoDevColors.Green.c400 + else -> AutoDevColors.Red.c400 + } + ) + + // Tool name + Text( + text = item.toolName, + style = JewelTheme.defaultTextStyle.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.weight(1f) + ) + + // Summary (truncated params as summary) + if (hasParams && !expanded) { + Text( + text = "-> ${item.params.take(40)}${if (item.params.length > 40) "..." else ""}", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = when { + item.success == true -> AutoDevColors.Green.c400 + item.success == false -> AutoDevColors.Red.c400 + else -> JewelTheme.globalColors.text.info + } + ), + maxLines = 1 + ) + } + + // Execution time (if available) + item.executionTimeMs?.let { time: Long -> + if (time > 0L) { + Text( + text = "${time}ms", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.info.copy(alpha = 0.6f) + ) + ) + } + } + + // Expand/collapse icon + if (hasExpandableContent) { + Icon( + imageVector = if (expanded) IdeaComposeIcons.ExpandLess else IdeaComposeIcons.ExpandMore, + contentDescription = if (expanded) "Collapse" else "Expand", + modifier = Modifier.size(20.dp), + tint = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) + } + } + + // Expandable content + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column { + // Parameters section + if (hasParams) { + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Parameters:", + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Medium, + fontSize = 12.sp + ) + ) + + if (hasMoreParams) { + Text( + text = if (showFullParams) "Show Less" else "Show All", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = AutoDevColors.Blue.c400 + ), + modifier = Modifier.clickable { showFullParams = !showFullParams } + ) + } + } + + // Copy button + IconButton( + onClick = { copyToClipboard(item.params) }, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.ContentCopy, + contentDescription = "Copy parameters", + modifier = Modifier.size(14.dp), + tint = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) + } + } + + // Parameters content + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = JewelTheme.globalColors.panelBackground.copy(alpha = 0.3f), + shape = RoundedCornerShape(4.dp) + ) + .padding(8.dp) + ) { + Text( + text = displayParams + if (!showFullParams && hasMoreParams) "..." else "", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + fontFamily = FontFamily.Monospace + ) + ) + } + } + + // Output section + if (hasOutput) { + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Output:", + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Medium, + fontSize = 12.sp + ) + ) + + if (hasMoreOutput) { + Text( + text = if (showFullOutput) "Show Less" else "Show Full", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = AutoDevColors.Blue.c400 + ), + modifier = Modifier.clickable { showFullOutput = !showFullOutput } + ) + } + } + + // Copy button + IconButton( + onClick = { copyToClipboard(item.output ?: "") }, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.ContentCopy, + contentDescription = "Copy output", + modifier = Modifier.size(14.dp), + tint = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) + } + } + + // Output content + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = JewelTheme.globalColors.panelBackground.copy(alpha = 0.3f), + shape = RoundedCornerShape(4.dp) + ) + .padding(8.dp) + ) { + Text( + text = formatToolOutput(displayOutput ?: "") + + if (!showFullOutput && hasMoreOutput) "..." else "", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + fontFamily = FontFamily.Monospace + ) + ) + } + } + } + } + } + } +} + +/** + * Currently executing tool call indicator with progress animation. + * Similar to CurrentToolCallItem in mpp-ui. + */ +@Composable +fun IdeaCurrentToolCallItem( + toolName: String, + description: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .background( + color = AutoDevColors.Blue.c400.copy(alpha = 0.15f), + shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Spinning indicator (using text for simplicity in Jewel) + Text( + text = "...", + style = JewelTheme.defaultTextStyle.copy( + color = AutoDevColors.Blue.c400, + fontWeight = FontWeight.Bold + ) + ) + + // Tool icon + Icon( + imageVector = IdeaComposeIcons.Build, + contentDescription = "Tool", + modifier = Modifier.size(16.dp), + tint = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) + + // Tool name and description + Text( + text = "$toolName - $description", + style = JewelTheme.defaultTextStyle, + modifier = Modifier.weight(1f), + maxLines = 1 + ) + + // Executing badge + Box( + modifier = Modifier + .background( + color = AutoDevColors.Blue.c400, + shape = RoundedCornerShape(12.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = "EXECUTING", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = AutoDevColors.Neutral.c50 + ) + ) + } + } + } +} + +/** + * Copy text to system clipboard using IntelliJ CopyPasteManager + */ +private fun copyToClipboard(text: String) { + try { + CopyPasteManager.getInstance().setContents(StringSelection(text)) + } catch (e: Exception) { + // Ignore clipboard errors + } +} + +/** + * Format tool output for better readability. + * Returns output as-is to avoid breaking valid JSON/table formats. + */ +private fun formatToolOutput(output: String): String { + return when { + output.contains("|") -> output // Table format + output.contains("\n") -> output // Already formatted + output.length > 100 -> "${output.take(100)}..." // Truncate long single-line output + else -> output + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt index 3469e91972..82b62a15d8 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaBottomToolbar.kt @@ -2,21 +2,30 @@ package cc.unitmesh.devins.idea.editor import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import cc.unitmesh.llm.NamedModelConfig import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.* +import org.jetbrains.jewel.ui.component.Icon /** * Bottom toolbar for the input section. - * Provides send/stop buttons, @ trigger for agent completion, settings, and token info. - * - * Uses Jewel components for native IntelliJ IDEA look and feel. + * Provides send/stop buttons, model selector, settings, and token info. + * + * Layout: ModelSelector - Token Info | MCP Settings - Prompt Optimization - Send Button + * - Left side: Model configuration (blends with background) + * - Right side: MCP, prompt optimization, and send + * + * Note: @ and / triggers are now in the top toolbar (IdeaTopToolbar). */ @Composable fun IdeaBottomToolbar( @@ -24,81 +33,46 @@ fun IdeaBottomToolbar( sendEnabled: Boolean, isExecuting: Boolean = false, onStopClick: () -> Unit = {}, - onAtClick: () -> Unit = {}, onSettingsClick: () -> Unit = {}, - workspacePath: String? = null, + onPromptOptimizationClick: () -> Unit = {}, totalTokens: Int? = null, + // Model selector props + availableConfigs: List = emptyList(), + currentConfigName: String? = null, + onConfigSelect: (NamedModelConfig) -> Unit = {}, + onConfigureClick: () -> Unit = {}, modifier: Modifier = Modifier ) { Row( modifier = modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 6.dp), + .padding(horizontal = 4.dp, vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - // Left side: workspace and token info + // Left side: Model selector and token info Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f, fill = false) ) { - // Workspace indicator - if (!workspacePath.isNullOrEmpty()) { - // Extract project name from path, handling both Unix and Windows separators - val projectName = workspacePath - .replace('\\', '/') // Normalize to Unix separator - .substringAfterLast('/') - .ifEmpty { "Project" } - - Box( - modifier = Modifier - .background( - JewelTheme.globalColors.panelBackground.copy(alpha = 0.8f) - ) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = "📁", - style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp) - ) - Text( - text = projectName, - style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp), - maxLines = 1 - ) - } - } - } + // Model selector (transparent, blends with background) + IdeaModelSelector( + availableConfigs = availableConfigs, + currentConfigName = currentConfigName, + onConfigSelect = onConfigSelect, + onConfigureClick = onConfigureClick + ) - // Token usage indicator + // Token usage indicator (subtle) if (totalTokens != null && totalTokens > 0) { - Box( - modifier = Modifier - .background(AutoDevColors.Blue.c400.copy(alpha = 0.2f)) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = "Token", - style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp) - ) - Text( - text = "$totalTokens", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 11.sp, - fontWeight = FontWeight.Bold - ) - ) - } - } + Text( + text = "${totalTokens}t", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) + ) + ) } } @@ -107,28 +81,29 @@ fun IdeaBottomToolbar( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { - // @ trigger button for agent completion + // MCP Settings button IconButton( - onClick = onAtClick, + onClick = onSettingsClick, modifier = Modifier.size(32.dp) ) { - Text( - text = "@", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 16.sp, - fontWeight = FontWeight.Bold - ) + Icon( + imageVector = IdeaComposeIcons.Settings, + contentDescription = "MCP Settings", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) ) } - // Settings button + // Prompt Optimization button IconButton( - onClick = onSettingsClick, + onClick = onPromptOptimizationClick, modifier = Modifier.size(32.dp) ) { - Text( - text = "⚙", - style = JewelTheme.defaultTextStyle.copy(fontSize = 14.sp) + Icon( + imageVector = IdeaComposeIcons.AutoAwesome, + contentDescription = "Prompt Optimization", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) ) } @@ -138,12 +113,23 @@ fun IdeaBottomToolbar( onClick = onStopClick, modifier = Modifier.height(32.dp) ) { - Text( - text = "⏹ Stop", - style = JewelTheme.defaultTextStyle.copy( - color = AutoDevColors.Red.c400 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.Stop, + contentDescription = "Stop", + tint = AutoDevColors.Red.c400, + modifier = Modifier.size(14.dp) ) - ) + Text( + text = "Stop", + style = JewelTheme.defaultTextStyle.copy( + color = AutoDevColors.Red.c400 + ) + ) + } } } else { DefaultButton( @@ -151,10 +137,21 @@ fun IdeaBottomToolbar( enabled = sendEnabled, modifier = Modifier.height(32.dp) ) { - Text( - text = "Send", - style = JewelTheme.defaultTextStyle - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.Send, + contentDescription = "Send", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(14.dp) + ) + Text( + text = "Send", + style = JewelTheme.defaultTextStyle + ) + } } } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInInput.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInInput.kt index 3cc8f36784..6ee6e1bac2 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInInput.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInInput.kt @@ -1,7 +1,9 @@ package cc.unitmesh.devins.idea.editor +import cc.unitmesh.devti.language.DevInLanguage +import cc.unitmesh.devti.util.InsertUtil +import com.intellij.codeInsight.AutoPopupController import com.intellij.codeInsight.lookup.LookupManagerListener -import com.intellij.lang.Language import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.CustomShortcutSet import com.intellij.openapi.actionSystem.KeyboardShortcut @@ -9,9 +11,9 @@ import com.intellij.openapi.command.CommandProcessor import com.intellij.openapi.command.WriteCommandAction import com.intellij.openapi.editor.Document import com.intellij.openapi.editor.Editor -import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.editor.EditorModificationUtil import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.application.runReadAction import com.intellij.openapi.editor.actions.IncrementalFindAction import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.editor.event.DocumentListener @@ -20,6 +22,8 @@ import com.intellij.openapi.fileTypes.FileTypes import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.Project import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiFileFactory import com.intellij.testFramework.LightVirtualFile import com.intellij.ui.EditorTextField import com.intellij.util.EventDispatcher @@ -31,17 +35,18 @@ import javax.swing.KeyStroke /** * DevIn language input component for mpp-idea module. - * + * * Features: * - DevIn language support with syntax highlighting and completion * - Enter to submit, Shift/Ctrl/Cmd+Enter for newline * - Integration with IntelliJ's completion system (lookup listener) + * - Auto-completion for @, /, $, : characters * - Placeholder text support - * + * * Based on AutoDevInput from core module but adapted for standalone mpp-idea usage. */ class IdeaDevInInput( - project: Project, + private val project: Project, private val listeners: List = emptyList(), val disposable: Disposable?, private val showAgent: Boolean = true @@ -123,10 +128,10 @@ class IdeaDevInInput( document.addDocumentListener(listener) } - // Add internal document listener to notify text changes - document.addDocumentListener(internalDocumentListener) + runReadAction { + document.addDocumentListener(internalDocumentListener) + } - // Listen for completion popup state to disable Enter submit when completing project.messageBus.connect(disposable ?: this) .subscribe(LookupManagerListener.TOPIC, object : LookupManagerListener { override fun activeLookupChanged( @@ -159,7 +164,7 @@ class IdeaDevInInput( editor.setVerticalScrollbarVisible(true) setBorder(JBUI.Borders.empty()) editor.setShowPlaceholderWhenFocused(true) - editor.caretModel.moveToOffset(0) + runReadAction { editor.caretModel.moveToOffset(0) } editor.scrollPane.setBorder(border) editor.contentComponent.setOpaque(false) return editor @@ -190,13 +195,18 @@ class IdeaDevInInput( } } - // Create new document using EditorFactory + // Create new document with DevIn language support + val id = UUID.randomUUID() val document = ReadAction.compute { - EditorFactory.getInstance().createDocument("") + val psiFile = PsiFileFactory.getInstance(project) + .createFileFromText("IdeaDevInInput-$id.devin", DevInLanguage, "") + PsiDocumentManager.getInstance(project).getDocument(psiFile) } - initializeDocumentListeners(document) - setDocument(document) + if (document != null) { + initializeDocumentListeners(document) + setDocument(document) + } } private fun initializeDocumentListeners(inputDocument: Document) { @@ -223,11 +233,12 @@ class IdeaDevInInput( /** * Append text at the end of the document. + * Uses InsertUtil for proper text insertion with DevIn language support. */ fun appendText(textToAppend: String) { WriteCommandAction.runWriteCommandAction(project, "Append text", "intentions.write.action", { val document = this.editor?.document ?: return@runWriteCommandAction - document.insertString(document.textLength, textToAppend) + InsertUtil.insertStringAndSaveChange(project, textToAppend, document, document.textLength, false) }) } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaInputSection.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaInputSection.kt deleted file mode 100644 index 8b20f968a2..0000000000 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaInputSection.kt +++ /dev/null @@ -1,156 +0,0 @@ -package cc.unitmesh.devins.idea.editor - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.key.* -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.flow.distinctUntilChanged -import org.jetbrains.jewel.foundation.theme.JewelTheme -import org.jetbrains.jewel.ui.Orientation -import org.jetbrains.jewel.ui.component.* - -/** - * Complete input section for mpp-idea module. - * - * Combines a text input field with a bottom toolbar for actions. - * Uses Jewel components for native IntelliJ IDEA integration. - * - * Features: - * - Multi-line text input with DevIn command support - * - Enter to submit, Shift+Enter for newline - * - Bottom toolbar with send/stop, @ trigger, settings - * - Workspace and token info display - * - * Note: This is a pure Compose implementation. For full DevIn language support - * with completion, use IdeaDevInInput (Swing-based) embedded via ComposePanel. - */ -@Composable -fun IdeaInputSection( - isProcessing: Boolean, - onSend: (String) -> Unit, - onStop: () -> Unit = {}, - onAtClick: () -> Unit = {}, - onSettingsClick: () -> Unit = {}, - workspacePath: String? = null, - totalTokens: Int? = null, - modifier: Modifier = Modifier -) { - val textFieldState = rememberTextFieldState() - var inputText by remember { mutableStateOf("") } - - // Sync text field state to inputText - LaunchedEffect(Unit) { - snapshotFlow { textFieldState.text.toString() } - .distinctUntilChanged() - .collect { inputText = it } - } - - // Extract send logic to avoid duplication - val doSend: () -> Unit = { - if (inputText.isNotBlank()) { - onSend(inputText) - textFieldState.edit { replace(0, length, "") } - } - } - - Column( - modifier = modifier - .fillMaxWidth() - .background(JewelTheme.globalColors.panelBackground) - .border( - width = 1.dp, - color = JewelTheme.globalColors.borders.normal, - shape = RoundedCornerShape(4.dp) - ) - ) { - // Input area - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 60.dp, max = 200.dp) - .padding(8.dp) - ) { - TextField( - state = textFieldState, - placeholder = { - Text( - text = "Type your message or /help for commands...", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 14.sp, - color = JewelTheme.globalColors.text.info.copy(alpha = 0.6f) - ) - ) - }, - modifier = Modifier - .fillMaxWidth() - .onPreviewKeyEvent { keyEvent -> - // Enter to send (without modifiers) - if (keyEvent.key == Key.Enter && - keyEvent.type == KeyEventType.KeyDown && - !keyEvent.isShiftPressed && - !keyEvent.isCtrlPressed && - !keyEvent.isMetaPressed && - !isProcessing - ) { - doSend() - true - } else { - false - } - }, - enabled = !isProcessing - ) - } - - Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth()) - - // Bottom toolbar - IdeaBottomToolbar( - onSendClick = doSend, - sendEnabled = inputText.isNotBlank() && !isProcessing, - isExecuting = isProcessing, - onStopClick = onStop, - onAtClick = { - // Insert @ character and trigger completion - textFieldState.edit { - append("@") - } - onAtClick() - }, - onSettingsClick = onSettingsClick, - workspacePath = workspacePath, - totalTokens = totalTokens - ) - } -} - -/** - * Preview hints display for available commands. - */ -@Composable -fun InputHints( - modifier: Modifier = Modifier -) { - Row( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.End - ) { - Text( - text = "Enter to send, Shift+Enter for newline", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 11.sp, - color = JewelTheme.globalColors.text.info.copy(alpha = 0.6f) - ) - ) - } -} - diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt new file mode 100644 index 0000000000..345a885664 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt @@ -0,0 +1,440 @@ +package cc.unitmesh.devins.idea.editor + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import kotlinx.coroutines.flow.distinctUntilChanged +import cc.unitmesh.agent.config.McpLoadingState +import cc.unitmesh.agent.config.McpLoadingStateCallback +import cc.unitmesh.agent.config.McpServerState +import cc.unitmesh.agent.config.McpToolConfigManager +import cc.unitmesh.agent.config.ToolConfigFile +import cc.unitmesh.agent.config.ToolItem +import cc.unitmesh.agent.mcp.McpServerConfig +import cc.unitmesh.devins.ui.config.ConfigManager +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.jetbrains.jewel.ui.component.* + +// JSON serialization helpers +private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true +} + +private fun serializeMcpConfig(servers: Map): String { + return try { + json.encodeToString(servers) + } catch (e: Exception) { + "{}" + } +} + +private fun deserializeMcpConfig(jsonString: String): Result> { + return try { + val servers = json.decodeFromString>(jsonString) + Result.success(servers) + } catch (e: Exception) { + Result.failure(e) + } +} + +/** + * MCP Configuration Dialog for IntelliJ IDEA. + * + * Features: + * - Two tabs: Tools and MCP Servers + * - Auto-save functionality (2 seconds delay) + * - Real-time JSON validation + * - Incremental MCP server loading + * + * Migrated from mpp-ui/ToolConfigDialog.kt to use Jewel UI components. + */ +@Composable +fun IdeaMcpConfigDialog( + onDismiss: () -> Unit +) { + var toolConfig by remember { mutableStateOf(ToolConfigFile.default()) } + var mcpTools by remember { mutableStateOf>>(emptyMap()) } + var mcpLoadingState by remember { mutableStateOf(McpLoadingState()) } + var isLoading by remember { mutableStateOf(true) } + var selectedTab by remember { mutableStateOf(0) } + var mcpConfigJson by remember { mutableStateOf("") } + var mcpConfigError by remember { mutableStateOf(null) } + var mcpLoadError by remember { mutableStateOf(null) } + var isReloading by remember { mutableStateOf(false) } + var hasUnsavedChanges by remember { mutableStateOf(false) } + var autoSaveJob by remember { mutableStateOf(null) } + + val scope = rememberCoroutineScope() + + // Auto-save function + fun scheduleAutoSave() { + hasUnsavedChanges = true + autoSaveJob?.cancel() + autoSaveJob = scope.launch { + kotlinx.coroutines.delay(2000) // Wait 2 seconds before auto-saving + try { + val enabledMcpTools = mcpTools.values + .flatten() + .filter { it.enabled } + .map { it.name } + + val result = deserializeMcpConfig(mcpConfigJson) + if (result.isSuccess) { + val newMcpServers = result.getOrThrow() + val updatedConfig = toolConfig.copy( + enabledMcpTools = enabledMcpTools, + mcpServers = newMcpServers + ) + + ConfigManager.saveToolConfig(updatedConfig) + toolConfig = updatedConfig + hasUnsavedChanges = false + println("✅ Auto-saved tool configuration") + } + } catch (e: Exception) { + println("❌ Auto-save failed: ${e.message}") + } + } + } + + // Load configuration on startup + LaunchedEffect(Unit) { + scope.launch { + try { + toolConfig = ConfigManager.loadToolConfig() + mcpConfigJson = serializeMcpConfig(toolConfig.mcpServers) + + if (toolConfig.mcpServers.isNotEmpty()) { + scope.launch { + // Create callback for incremental loading + val callback = object : McpLoadingStateCallback { + override fun onServerStateChanged(serverName: String, state: McpServerState) { + mcpLoadingState = mcpLoadingState.updateServerState(serverName, state) + + // Update tools when server is loaded + if (state.isLoaded) { + mcpTools = mcpTools + (serverName to state.tools) + } + } + + override fun onLoadingStateChanged(loadingState: McpLoadingState) { + mcpLoadingState = loadingState + } + + override fun onBuiltinToolsLoaded(tools: List) { + mcpLoadingState = mcpLoadingState.copy(builtinToolsLoaded = true) + } + } + + try { + // Use incremental loading + mcpLoadingState = McpToolConfigManager.discoverMcpToolsIncremental( + toolConfig.mcpServers, + toolConfig.enabledMcpTools.toSet(), + callback + ) + mcpLoadError = null + } catch (e: Exception) { + mcpLoadError = "Failed to load MCP tools: ${e.message}" + println("❌ Error loading MCP tools: ${e.message}") + } + } + } + + isLoading = false + } catch (e: Exception) { + println("Error loading tool config: ${e.message}") + mcpLoadError = "Failed to load configuration: ${e.message}" + isLoading = false + } + } + } + + // Cancel auto-save job on dispose + DisposableEffect(Unit) { + onDispose { + autoSaveJob?.cancel() + } + } + + Dialog(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .width(800.dp) + .height(600.dp) + .padding(16.dp) + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Tool Configuration") + if (hasUnsavedChanges) { + Text("(Auto-saving...)", color = org.jetbrains.jewel.foundation.theme.JewelTheme.globalColors.text.info) + } + } + IconButton(onClick = onDismiss) { + Text("×") + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + if (isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text("Loading...") + } + } else { + // Tab Row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + DefaultButton( + onClick = { selectedTab = 0 }, + enabled = selectedTab != 0 + ) { + Text("Tools") + } + DefaultButton( + onClick = { selectedTab = 1 }, + enabled = selectedTab != 1 + ) { + Text("MCP Servers") + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Error message + mcpLoadError?.let { error -> + Text(error, color = org.jetbrains.jewel.foundation.theme.JewelTheme.globalColors.text.error) + Spacer(modifier = Modifier.height(8.dp)) + } + + // Tab content + Box(modifier = Modifier.weight(1f)) { + when (selectedTab) { + 0 -> McpToolsTab( + mcpTools = mcpTools, + mcpLoadingState = mcpLoadingState, + onToolToggle = { toolName, enabled -> + mcpTools = mcpTools.mapValues { (_, tools) -> + tools.map { tool -> + if (tool.name == toolName) tool.copy(enabled = enabled) else tool + } + } + scheduleAutoSave() + } + ) + 1 -> McpServersTab( + mcpConfigJson = mcpConfigJson, + errorMessage = mcpConfigError, + isReloading = isReloading, + onConfigChange = { newJson -> + mcpConfigJson = newJson + val result = deserializeMcpConfig(newJson) + mcpConfigError = if (result.isFailure) { + result.exceptionOrNull()?.message + } else { + null + } + scheduleAutoSave() + }, + onReload = { + scope.launch { + isReloading = true + val result = deserializeMcpConfig(mcpConfigJson) + if (result.isSuccess) { + val newServers = result.getOrThrow() + toolConfig = toolConfig.copy(mcpServers = newServers) + ConfigManager.saveToolConfig(toolConfig) + // Reload MCP tools + try { + val callback = object : McpLoadingStateCallback { + override fun onServerStateChanged(serverName: String, state: McpServerState) { + mcpLoadingState = mcpLoadingState.updateServerState(serverName, state) + if (state.isLoaded) { + mcpTools = mcpTools + (serverName to state.tools) + } + } + override fun onLoadingStateChanged(loadingState: McpLoadingState) { + mcpLoadingState = loadingState + } + override fun onBuiltinToolsLoaded(tools: List) { + mcpLoadingState = mcpLoadingState.copy(builtinToolsLoaded = true) + } + } + mcpLoadingState = McpToolConfigManager.discoverMcpToolsIncremental( + newServers, + toolConfig.enabledMcpTools.toSet(), + callback + ) + mcpLoadError = null + } catch (e: Exception) { + mcpLoadError = "Failed to reload: ${e.message}" + } + } + isReloading = false + } + } + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Footer + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val enabledMcp = mcpTools.values.flatten().count { it.enabled } + val totalMcp = mcpTools.values.flatten().size + Text("MCP Tools: $enabledMcp/$totalMcp enabled") + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onDismiss) { + Text("Close") + } + } + } + } + } + } +} + +@Composable +private fun McpToolsTab( + mcpTools: Map>, + mcpLoadingState: McpLoadingState, + onToolToggle: (String, Boolean) -> Unit +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + mcpTools.forEach { (serverName, tools) -> + item { + Text(serverName, modifier = Modifier.padding(vertical = 4.dp)) + } + items(tools) { tool -> + Row( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(tool.displayName) + Text(tool.description, color = org.jetbrains.jewel.foundation.theme.JewelTheme.globalColors.text.info) + } + Checkbox( + checked = tool.enabled, + onCheckedChange = { onToolToggle(tool.name, it) } + ) + } + } + } + + val isLoading = mcpLoadingState.loadingServers.isNotEmpty() + + if (mcpTools.isEmpty() && !isLoading) { + item { + Text("No MCP tools configured. Add MCP servers in the 'MCP Servers' tab.") + } + } + + if (isLoading) { + item { + Text("Loading MCP tools...") + } + } + } +} + +@Composable +private fun McpServersTab( + mcpConfigJson: String, + errorMessage: String?, + isReloading: Boolean, + onConfigChange: (String) -> Unit, + onReload: () -> Unit +) { + val textFieldState = rememberTextFieldState(mcpConfigJson) + + // Sync text field state to callback + LaunchedEffect(Unit) { + snapshotFlow { textFieldState.text.toString() } + .distinctUntilChanged() + .collect { newText -> + if (newText != mcpConfigJson) { + onConfigChange(newText) + } + } + } + + // Update text field when external value changes + LaunchedEffect(mcpConfigJson) { + if (textFieldState.text.toString() != mcpConfigJson) { + textFieldState.setTextAndPlaceCursorAtEnd(mcpConfigJson) + } + } + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("MCP Server Configuration (JSON)") + + errorMessage?.let { error -> + Text(error, color = org.jetbrains.jewel.foundation.theme.JewelTheme.globalColors.text.error) + } + + // Use BasicTextField for multi-line text input + BasicTextField( + state = textFieldState, + modifier = Modifier.fillMaxWidth().weight(1f), + textStyle = TextStyle( + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + color = org.jetbrains.jewel.foundation.theme.JewelTheme.globalColors.text.normal + ), + cursorBrush = SolidColor(org.jetbrains.jewel.foundation.theme.JewelTheme.globalColors.text.normal) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + DefaultButton( + onClick = onReload, + enabled = !isReloading && errorMessage == null + ) { + Text(if (isReloading) "Reloading..." else "Reload MCP Tools") + } + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaModelConfigDialog.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaModelConfigDialog.kt new file mode 100644 index 0000000000..d6a2fa535d --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaModelConfigDialog.kt @@ -0,0 +1,447 @@ +package cc.unitmesh.devins.idea.editor + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons +import cc.unitmesh.llm.LLMProviderType +import cc.unitmesh.llm.ModelConfig +import cc.unitmesh.llm.ModelRegistry +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.* +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.TextField + +/** + * Dialog for configuring LLM model settings for IntelliJ IDEA plugin. + * Uses Jewel components for native IntelliJ look and feel. + */ +@Composable +fun IdeaModelConfigDialog( + currentConfig: ModelConfig, + currentConfigName: String? = null, + onDismiss: () -> Unit, + onSave: (configName: String, config: ModelConfig) -> Unit +) { + // Use TextFieldState for Jewel TextField + val configNameState = rememberTextFieldState(currentConfigName ?: "") + var provider by remember { mutableStateOf(currentConfig.provider) } + val modelNameState = rememberTextFieldState(currentConfig.modelName) + val apiKeyState = rememberTextFieldState(currentConfig.apiKey) + val temperatureState = rememberTextFieldState(currentConfig.temperature.toString()) + val maxTokensState = rememberTextFieldState(currentConfig.maxTokens.toString()) + val baseUrlState = rememberTextFieldState(currentConfig.baseUrl) + var showApiKey by remember { mutableStateOf(false) } + var providerExpanded by remember { mutableStateOf(false) } + var modelExpanded by remember { mutableStateOf(false) } + var showAdvanced by remember { mutableStateOf(false) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .width(500.dp) + .heightIn(max = 600.dp) + .clip(RoundedCornerShape(12.dp)) + .background(JewelTheme.globalColors.panelBackground) + .onKeyEvent { event -> + if (event.key == Key.Escape) { + onDismiss() + true + } else false + } + ) { + Column( + modifier = Modifier + .padding(24.dp) + .verticalScroll(rememberScrollState()) + ) { + // Title + Text( + text = "Model Configuration", + style = JewelTheme.defaultTextStyle.copy(fontSize = 18.sp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Config Name + IdeaConfigFormField(label = "Config Name") { + TextField( + state = configNameState, + placeholder = { Text("e.g., my-gpt4") }, + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Provider Selector + IdeaConfigFormField(label = "Provider") { + IdeaProviderSelector( + provider = provider, + expanded = providerExpanded, + onExpandedChange = { providerExpanded = it }, + onProviderSelect = { selectedProvider -> + provider = selectedProvider + val defaultModels = ModelRegistry.getAvailableModels(selectedProvider) + if (defaultModels.isNotEmpty()) { + modelNameState.edit { replace(0, length, defaultModels[0]) } + } + if (selectedProvider == LLMProviderType.OLLAMA && baseUrlState.text.isEmpty()) { + baseUrlState.edit { replace(0, length, "http://localhost:11434") } + } + providerExpanded = false + } + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Model Name + val availableModels = remember(provider) { ModelRegistry.getAvailableModels(provider) } + IdeaConfigFormField(label = "Model") { + if (availableModels.isNotEmpty()) { + IdeaModelNameSelector( + modelNameState = modelNameState, + availableModels = availableModels, + expanded = modelExpanded, + onExpandedChange = { modelExpanded = it }, + onModelSelect = { selectedModel -> + modelNameState.edit { replace(0, length, selectedModel) } + modelExpanded = false + } + ) + } else { + TextField( + state = modelNameState, + placeholder = { Text("Enter model name") }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // API Key + IdeaConfigFormField(label = "API Key") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + state = apiKeyState, + placeholder = { Text("Enter API key") }, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { showApiKey = !showApiKey }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = if (showApiKey) IdeaComposeIcons.VisibilityOff else IdeaComposeIcons.Visibility, + contentDescription = if (showApiKey) "Hide" else "Show", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(18.dp) + ) + } + } + } + + // Base URL (for certain providers) + val needsBaseUrl = provider in listOf( + LLMProviderType.OLLAMA, LLMProviderType.GLM, LLMProviderType.QWEN, + LLMProviderType.KIMI, LLMProviderType.CUSTOM_OPENAI_BASE + ) + if (needsBaseUrl) { + Spacer(modifier = Modifier.height(12.dp)) + IdeaConfigFormField(label = "Base URL") { + TextField( + state = baseUrlState, + placeholder = { Text("e.g., http://localhost:11434") }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Advanced Settings Toggle + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { showAdvanced = !showAdvanced } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = if (showAdvanced) IdeaComposeIcons.ExpandLess else IdeaComposeIcons.ExpandMore, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(20.dp) + ) + Text( + text = "Advanced Settings", + style = JewelTheme.defaultTextStyle.copy(fontSize = 14.sp) + ) + } + + if (showAdvanced) { + Spacer(modifier = Modifier.height(8.dp)) + + // Temperature + IdeaConfigFormField(label = "Temperature") { + TextField( + state = temperatureState, + placeholder = { Text("0.0 - 2.0") }, + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Max Tokens + IdeaConfigFormField(label = "Max Tokens") { + TextField( + state = maxTokensState, + placeholder = { Text("e.g., 128000") }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Action Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton(onClick = onDismiss) { + Text("Cancel") + } + Spacer(modifier = Modifier.width(12.dp)) + DefaultButton( + onClick = { + val modelName = modelNameState.text.toString() + val apiKey = apiKeyState.text.toString() + val config = ModelConfig( + provider = provider, + modelName = modelName, + apiKey = apiKey, + temperature = temperatureState.text.toString().toDoubleOrNull() ?: 0.0, + maxTokens = maxTokensState.text.toString().toIntOrNull() ?: 128000, + baseUrl = baseUrlState.text.toString() + ) + val configName = configNameState.text.toString() + val name = configName.ifEmpty { "${provider.name.lowercase()}-${modelName}" } + onSave(name, config) + }, + enabled = modelNameState.text.isNotEmpty() && apiKeyState.text.isNotEmpty() + ) { + Text("Save") + } + } + } + } + } +} + +/** + * Form field wrapper with label + */ +@Composable +private fun IdeaConfigFormField( + label: String, + content: @Composable () -> Unit +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp), + modifier = Modifier.padding(bottom = 4.dp) + ) + content() + } +} + +/** + * Provider selector dropdown + */ +@Composable +private fun IdeaProviderSelector( + provider: LLMProviderType, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onProviderSelect: (LLMProviderType) -> Unit +) { + Box { + OutlinedButton( + onClick = { onExpandedChange(true) }, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(provider.name) + Icon( + imageVector = IdeaComposeIcons.ArrowDropDown, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + } + } + + if (expanded) { + Popup( + onDismissRequest = { onExpandedChange(false) }, + properties = PopupProperties(focusable = true) + ) { + Box( + modifier = Modifier + .widthIn(min = 200.dp, max = 300.dp) + .heightIn(max = 300.dp) + .clip(RoundedCornerShape(8.dp)) + .background(JewelTheme.globalColors.panelBackground) + .padding(4.dp) + ) { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + LLMProviderType.entries.forEach { providerType -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .clickable { onProviderSelect(providerType) } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = providerType.name, + style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp), + modifier = Modifier.weight(1f) + ) + if (providerType == provider) { + Icon( + imageVector = IdeaComposeIcons.Check, + contentDescription = "Selected", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + } + } + } + } + } + } + } + } +} + +/** + * Model name selector with dropdown for known models + */ +@Composable +private fun IdeaModelNameSelector( + modelNameState: TextFieldState, + availableModels: List, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onModelSelect: (String) -> Unit +) { + val modelName = modelNameState.text.toString() + + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + state = modelNameState, + placeholder = { Text("Enter or select model") }, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { onExpandedChange(!expanded) }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.ArrowDropDown, + contentDescription = "Select model", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(18.dp) + ) + } + } + + if (expanded) { + Popup( + onDismissRequest = { onExpandedChange(false) }, + properties = PopupProperties(focusable = true) + ) { + Box( + modifier = Modifier + .widthIn(min = 200.dp, max = 400.dp) + .heightIn(max = 250.dp) + .clip(RoundedCornerShape(8.dp)) + .background(JewelTheme.globalColors.panelBackground) + .padding(4.dp) + ) { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + availableModels.forEach { model -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .clickable { onModelSelect(model) } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = model, + style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp), + modifier = Modifier.weight(1f) + ) + if (model == modelName) { + Icon( + imageVector = IdeaComposeIcons.Check, + contentDescription = "Selected", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + } + } + } + } + } + } + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaModelSelector.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaModelSelector.kt new file mode 100644 index 0000000000..5cb2da0250 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaModelSelector.kt @@ -0,0 +1,216 @@ +package cc.unitmesh.devins.idea.editor + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.compose.ui.Alignment as ComposeAlignment +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons +import cc.unitmesh.llm.ModelConfig +import cc.unitmesh.llm.NamedModelConfig +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Divider +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.OutlinedButton +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.Orientation + +/** + * Model selector for IntelliJ IDEA plugin. + * Provides a dropdown for selecting LLM models with a configure option. + * + * Uses Jewel components for native IntelliJ IDEA look and feel. + * Designed to blend seamlessly with the toolbar background. + */ +@Composable +fun IdeaModelSelector( + availableConfigs: List, + currentConfigName: String?, + onConfigSelect: (NamedModelConfig) -> Unit, + onConfigureClick: () -> Unit, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + + val currentConfig = remember(currentConfigName, availableConfigs) { + availableConfigs.find { it.name == currentConfigName } + } + + val displayText = remember(currentConfig) { + currentConfig?.model ?: "Configure Model" + } + + Box(modifier = modifier) { + // Transparent selector that blends with background + Row( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .hoverable(interactionSource = interactionSource) + .background( + if (isHovered || expanded) + JewelTheme.globalColors.borders.normal.copy(alpha = 0.3f) + else + androidx.compose.ui.graphics.Color.Transparent + ) + .clickable { expanded = true } + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.SmartToy, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f), + modifier = Modifier.size(14.dp) + ) + Text( + text = displayText, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = JewelTheme.globalColors.text.normal + ), + maxLines = 1 + ) + Icon( + imageVector = IdeaComposeIcons.ArrowDropDown, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f), + modifier = Modifier.size(14.dp) + ) + } + + // Dropdown popup - positioned above the selector to avoid covering input area + if (expanded) { + Popup( + alignment = ComposeAlignment.BottomStart, + onDismissRequest = { expanded = false }, + properties = PopupProperties(focusable = true) + ) { + Box( + modifier = Modifier + .widthIn(min = 200.dp, max = 300.dp) + .clip(RoundedCornerShape(8.dp)) + .background(JewelTheme.globalColors.panelBackground) + .padding(4.dp) + ) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + if (availableConfigs.isNotEmpty()) { + availableConfigs.forEach { config -> + IdeaDropdownMenuItem( + text = "${config.provider} / ${config.model}", + isSelected = config.name == currentConfigName, + onClick = { + onConfigSelect(config) + expanded = false + } + ) + } + + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) + } else { + IdeaDropdownMenuItem( + text = "No saved configs", + isSelected = false, + enabled = false, + onClick = {} + ) + + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) + } + + // Configure button + IdeaDropdownMenuItem( + text = "Configure Model...", + isSelected = false, + leadingIcon = IdeaComposeIcons.Settings, + onClick = { + onConfigureClick() + expanded = false + } + ) + } + } + } + } + } +} + +/** + * Individual menu item for IdeaModelSelector dropdown. + */ +@Composable +private fun IdeaDropdownMenuItem( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + leadingIcon: androidx.compose.ui.graphics.vector.ImageVector? = null +) { + val backgroundColor = when { + !enabled -> JewelTheme.globalColors.panelBackground + isSelected -> JewelTheme.globalColors.panelBackground.copy(alpha = 0.6f) + else -> JewelTheme.globalColors.panelBackground + } + + val textColor = when { + !enabled -> JewelTheme.globalColors.text.normal.copy(alpha = 0.5f) + else -> JewelTheme.globalColors.text.normal + } + + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .background(backgroundColor) + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (leadingIcon != null) { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = textColor, + modifier = Modifier.size(16.dp) + ) + } + + Text( + text = text, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 13.sp, + color = textColor + ), + modifier = Modifier.weight(1f) + ) + + if (isSelected) { + Icon( + imageVector = IdeaComposeIcons.Check, + contentDescription = "Selected", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptOptimizationDialog.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptOptimizationDialog.kt new file mode 100644 index 0000000000..c5f6f77723 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptOptimizationDialog.kt @@ -0,0 +1,186 @@ +package cc.unitmesh.devins.idea.editor + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import cc.unitmesh.llm.KoogLLMService +import cc.unitmesh.llm.PromptEnhancer +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.* + +/** + * Prompt Optimization Dialog for IntelliJ IDEA. + * + * Features: + * - Display original and enhanced prompts side by side + * - Real-time enhancement using PromptEnhancer + * - Apply or cancel the enhancement + * + * Migrated from mpp-ui/DevInEditorInput.kt prompt enhancement functionality. + */ +@Composable +fun IdeaPromptOptimizationDialog( + originalText: String, + enhancer: PromptEnhancer?, + onApply: (String) -> Unit, + onDismiss: () -> Unit +) { + var enhancedText by remember { mutableStateOf("") } + var isEnhancing by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + + val scope = rememberCoroutineScope() + + // Auto-enhance on dialog open + LaunchedEffect(Unit) { + if (enhancer != null && originalText.isNotBlank()) { + isEnhancing = true + errorMessage = null + try { + val enhanced = enhancer.enhance(originalText.trim(), "zh") + if (enhanced.isNotEmpty() && enhanced != originalText.trim()) { + enhancedText = enhanced + } else { + enhancedText = originalText + errorMessage = "No enhancement needed or enhancement failed" + } + } catch (e: Exception) { + errorMessage = "Enhancement failed: ${e.message}" + enhancedText = originalText + } finally { + isEnhancing = false + } + } else { + enhancedText = originalText + if (enhancer == null) { + errorMessage = "Enhancer not available. Please configure LLM settings." + } + } + } + + Dialog(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .width(700.dp) + .height(500.dp) + .padding(16.dp) + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Prompt Optimization (Ctrl+P)") + IconButton(onClick = onDismiss) { + Text("×") + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Error message + errorMessage?.let { error -> + Text(error, color = JewelTheme.globalColors.text.error) + Spacer(modifier = Modifier.height(8.dp)) + } + + // Content area + Row( + modifier = Modifier.weight(1f).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Original text + Column(modifier = Modifier.weight(1f)) { + Text("Original") + Spacer(modifier = Modifier.height(4.dp)) + BasicTextField( + value = originalText, + onValueChange = {}, + readOnly = true, + modifier = Modifier.fillMaxSize(), + textStyle = TextStyle( + fontFamily = FontFamily.Monospace, + color = JewelTheme.globalColors.text.normal + ), + cursorBrush = SolidColor(JewelTheme.globalColors.text.normal) + ) + } + + // Enhanced text + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Enhanced") + if (isEnhancing) { + Text("(Enhancing...)", color = JewelTheme.globalColors.text.info) + } + } + Spacer(modifier = Modifier.height(4.dp)) + + val enhancedTextState = rememberTextFieldState(enhancedText) + + LaunchedEffect(enhancedText) { + if (enhancedTextState.text.toString() != enhancedText) { + enhancedTextState.setTextAndPlaceCursorAtEnd(enhancedText) + } + } + + LaunchedEffect(Unit) { + snapshotFlow { enhancedTextState.text.toString() } + .distinctUntilChanged() + .collect { newText -> + if (newText != enhancedText) { + enhancedText = newText + } + } + } + + BasicTextField( + state = enhancedTextState, + modifier = Modifier.fillMaxSize(), + textStyle = TextStyle( + fontFamily = FontFamily.Monospace, + color = JewelTheme.globalColors.text.normal + ), + cursorBrush = SolidColor(JewelTheme.globalColors.text.normal) + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Footer buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton(onClick = onDismiss) { + Text("Cancel") + } + Spacer(modifier = Modifier.width(8.dp)) + DefaultButton( + onClick = { onApply(enhancedText) }, + enabled = !isEnhancing && enhancedText.isNotBlank() + ) { + Text("Apply") + } + } + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt new file mode 100644 index 0000000000..fecb461afc --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt @@ -0,0 +1,189 @@ +package cc.unitmesh.devins.idea.editor + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.Tooltip + +/** + * Top toolbar for the input section. + * Contains @ trigger, file selection, and other context-related actions. + * + * Layout: @ - / - Clipboard - Save - Cursor | Selected Files... | Add + */ +@Composable +fun IdeaTopToolbar( + onAtClick: () -> Unit = {}, + onSlashClick: () -> Unit = {}, + onClipboardClick: () -> Unit = {}, + onSaveClick: () -> Unit = {}, + onCursorClick: () -> Unit = {}, + onAddFileClick: () -> Unit = {}, + selectedFiles: List = emptyList(), + onRemoveFile: (SelectedFileItem) -> Unit = {}, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Left side: Action buttons + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // @ trigger button + ToolbarIconButton(onClick = onAtClick, tooltip = "@ Agent/File Reference") { + Icon( + imageVector = IdeaComposeIcons.AlternateEmail, + contentDescription = "@ Agent", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + } + // / trigger button + ToolbarIconButton(onClick = onSlashClick, tooltip = "/ Commands") { + Text(text = "/", style = JewelTheme.defaultTextStyle.copy(fontSize = 14.sp, fontWeight = FontWeight.Bold)) + } + // Clipboard button + ToolbarIconButton(onClick = onClipboardClick, tooltip = "Paste from Clipboard") { + Icon( + imageVector = IdeaComposeIcons.ContentPaste, + contentDescription = "Clipboard", + modifier = Modifier.size(16.dp), + tint = JewelTheme.globalColors.text.normal + ) + } + // Save button + ToolbarIconButton(onClick = onSaveClick, tooltip = "Save to Workspace") { + Icon( + imageVector = IdeaComposeIcons.Save, + contentDescription = "Save", + modifier = Modifier.size(16.dp), + tint = JewelTheme.globalColors.text.normal + ) + } + // Cursor button + ToolbarIconButton(onClick = onCursorClick, tooltip = "Current Selection") { + Icon( + imageVector = IdeaComposeIcons.TextFields, + contentDescription = "Cursor", + modifier = Modifier.size(16.dp), + tint = JewelTheme.globalColors.text.normal + ) + } + } + + // Separator + if (selectedFiles.isNotEmpty()) { + Box(Modifier.width(1.dp).height(20.dp).background(JewelTheme.globalColors.borders.normal)) + } + + // Selected files as chips + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + selectedFiles.forEach { file -> + FileChip(file = file, onRemove = { onRemoveFile(file) }) + } + } + + // Add file button + ToolbarIconButton(onClick = onAddFileClick, tooltip = "Add File") { + Icon( + imageVector = IdeaComposeIcons.Add, + contentDescription = "Add File", + modifier = Modifier.size(16.dp), + tint = JewelTheme.globalColors.text.normal + ) + } + } +} + +@Composable +private fun ToolbarIconButton( + onClick: () -> Unit, + tooltip: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + + Tooltip(tooltip = { Text(tooltip) }) { + Box( + modifier = modifier + .size(28.dp) + .clip(RoundedCornerShape(4.dp)) + .hoverable(interactionSource = interactionSource) + .background( + if (isHovered) JewelTheme.globalColors.borders.normal.copy(alpha = 0.3f) + else androidx.compose.ui.graphics.Color.Transparent + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { content() } + } +} + +@Composable +private fun FileChip(file: SelectedFileItem, onRemove: () -> Unit, modifier: Modifier = Modifier) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + + Row( + modifier = modifier + .clip(RoundedCornerShape(4.dp)) + .hoverable(interactionSource = interactionSource) + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.8f)) + .border(1.dp, JewelTheme.globalColors.borders.normal, RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = file.icon ?: IdeaComposeIcons.InsertDriveFile, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = JewelTheme.globalColors.text.normal + ) + Text(text = file.name, style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp), maxLines = 1) + if (isHovered) { + Icon( + imageVector = IdeaComposeIcons.Close, + contentDescription = "Remove", + modifier = Modifier.size(14.dp).clickable(onClick = onRemove), + tint = JewelTheme.globalColors.text.normal.copy(alpha = 0.6f) + ) + } + } +} + +data class SelectedFileItem( + val name: String, + val path: String, + val icon: androidx.compose.ui.graphics.vector.ImageVector? = null, + val virtualFile: com.intellij.openapi.vfs.VirtualFile? = null +) + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/IdeaMarkdownRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/IdeaMarkdownRenderer.kt new file mode 100644 index 0000000000..ecf6830023 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/IdeaMarkdownRenderer.kt @@ -0,0 +1,138 @@ +package cc.unitmesh.devins.idea.renderer + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +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.devins.idea.renderer.markdown.JewelMarkdownRenderer +import cc.unitmesh.devins.parser.CodeFence +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import com.intellij.openapi.Disposable +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Text + +/** + * IntelliJ IDEA-specific Markdown renderer with Mermaid diagram support. + * Uses Jewel components for native IntelliJ look and feel. + * Uses multiplatform-markdown-renderer for proper markdown parsing. + * + * @param content The markdown content to render + * @param isComplete Whether the content is complete (not streaming) + * @param parentDisposable Parent disposable for JCEF resource cleanup + * @param modifier Compose modifier + */ +@Composable +fun IdeaMarkdownRenderer( + content: String, + isComplete: Boolean, + parentDisposable: Disposable, + modifier: Modifier = Modifier +) { + // Check if content contains mermaid code blocks + val hasMermaid = remember(content) { + content.contains("```mermaid") || content.contains("```mmd") + } + + if (hasMermaid && isComplete) { + // Use custom rendering with Mermaid support + MermaidAwareMarkdownRenderer( + content = content, + parentDisposable = parentDisposable, + modifier = modifier + ) + } else { + // Use simple Jewel Markdown renderer + JewelMarkdownRenderer( + content = content, + modifier = modifier + ) + } +} + +/** + * Custom markdown renderer that handles Mermaid code blocks separately. + * Parses markdown into blocks and renders Mermaid diagrams using JCEF. + */ +@Composable +private fun MermaidAwareMarkdownRenderer( + content: String, + parentDisposable: Disposable, + modifier: Modifier = Modifier +) { + val codeFences = remember(content) { CodeFence.parseAll(content) } + + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + codeFences.forEach { fence -> + when (fence.languageId.lowercase()) { + "mermaid", "mmd" -> { + if (fence.text.isNotBlank()) { + MermaidDiagramView( + mermaidCode = fence.text, + isDarkTheme = true, + parentDisposable = parentDisposable, + modifier = Modifier.fillMaxWidth() + ) + } + } + "markdown", "md", "" -> { + if (fence.text.isNotBlank()) { + JewelMarkdownRenderer( + content = fence.text, + modifier = Modifier.fillMaxWidth() + ) + } + } + else -> { + if (fence.text.isNotBlank()) { + CodeBlockView( + code = fence.text, + language = fence.languageId, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + } +} + +/** + * Code block renderer with syntax highlighting placeholder. + */ +@Composable +private fun CodeBlockView( + code: String, + language: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f)) + .padding(8.dp) + ) { + if (language.isNotBlank()) { + Text( + text = language, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = AutoDevColors.Blue.c400 + ) + ) + Spacer(modifier = Modifier.height(4.dp)) + } + Text( + text = code, + style = JewelTheme.defaultTextStyle.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ), + modifier = Modifier.fillMaxWidth() + ) + } +} + 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..1c601da634 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) { @@ -302,23 +213,29 @@ class JewelRenderer : BaseRenderer() { // For shell commands, check if it's a live session val isLiveSession = metadata["isLiveSession"] == "true" - val liveExitCode = metadata["live_exit_code"]?.toIntOrNull() + val sessionId = metadata["sessionId"] + val liveExitCode = metadata["exit_code"]?.toIntOrNull() 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) { - // Add terminal output after live terminal - addTimelineItem( - TimelineItem.TerminalOutputItem( - command = command, - output = fullOutput ?: output, - exitCode = exitCode, - executionTimeMs = executionTimeMs - ) - ) + 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 -> @@ -479,85 +396,13 @@ class JewelRenderer : BaseRenderer() { _timeline.update { it + item } } - private 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 - ) - } - } - - private fun formatToolResultSummary(toolName: String, success: Boolean, output: String?): String { - if (!success) return "Failed" + private fun formatToolCallDisplay(toolName: String, paramsStr: String) = + RendererUtils.formatToolCallDisplay(toolName, paramsStr) - 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/renderer/MermaidDiagramView.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/MermaidDiagramView.kt new file mode 100644 index 0000000000..32c7eea7d8 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/MermaidDiagramView.kt @@ -0,0 +1,121 @@ +package cc.unitmesh.devins.idea.renderer + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.SwingPanel +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import com.intellij.openapi.Disposable +import com.intellij.ui.jcef.JBCefApp +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.CircularProgressIndicator +import org.jetbrains.jewel.ui.component.Text + +/** + * Compose wrapper for MermaidRenderer using SwingPanel. + * Renders Mermaid diagrams using JCEF (embedded Chromium). + * + * @param mermaidCode The Mermaid diagram code to render + * @param isDarkTheme Whether to use dark theme for rendering + * @param parentDisposable Parent disposable for resource cleanup + * @param modifier Compose modifier + */ +@Composable +fun MermaidDiagramView( + mermaidCode: String, + isDarkTheme: Boolean, + parentDisposable: Disposable, + modifier: Modifier = Modifier +) { + // Check if JCEF is available + if (!JBCefApp.isSupported()) { + JcefNotAvailableView(modifier) + return + } + + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + + val renderer = remember { + MermaidRenderer(parentDisposable) { success, message -> + isLoading = false + if (!success) { + errorMessage = message + } + } + } + + LaunchedEffect(mermaidCode, isDarkTheme) { + isLoading = true + errorMessage = null + renderer.renderMermaid(mermaidCode, isDarkTheme) + } + + Box(modifier = modifier.heightIn(min = 200.dp)) { + SwingPanel( + factory = { renderer.component }, + modifier = Modifier.fillMaxWidth().heightIn(min = 200.dp) + ) + + if (isLoading) { + LoadingOverlay() + } + + errorMessage?.let { error -> + ErrorOverlay(error) + } + } +} + +@Composable +private fun JcefNotAvailableView(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 100.dp) + .background(JewelTheme.globalColors.panelBackground), + contentAlignment = Alignment.Center + ) { + Text( + text = "JCEF not available - cannot render Mermaid diagrams", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = AutoDevColors.Amber.c500 + ) + ) + } +} + +@Composable +private fun LoadingOverlay() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun ErrorOverlay(error: String) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.9f)) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Error: $error", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = AutoDevColors.Red.c500 + ) + ) + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/MermaidRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/MermaidRenderer.kt new file mode 100644 index 0000000000..949dea96b3 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/MermaidRenderer.kt @@ -0,0 +1,114 @@ +package cc.unitmesh.devins.idea.renderer + +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.ui.jcef.JBCefApp +import com.intellij.ui.jcef.JBCefBrowser +import com.intellij.ui.jcef.JBCefJSQuery +import org.cef.browser.CefBrowser +import org.cef.browser.CefFrame +import org.cef.handler.CefLoadHandlerAdapter +import javax.swing.JComponent + +/** + * JCEF-based Mermaid diagram renderer for IntelliJ IDEA. + * Uses embedded Chromium browser to render Mermaid diagrams. + * + * @param parentDisposable Parent disposable for resource cleanup + * @param onRenderComplete Callback when rendering completes (success, message) + */ +class MermaidRenderer( + parentDisposable: Disposable, + private val onRenderComplete: (Boolean, String) -> Unit = { _, _ -> } +) : Disposable { + + private val browser: JBCefBrowser = JBCefBrowser() + private val renderCallbackQuery: JBCefJSQuery + private var isInitialized = false + + val component: JComponent get() = browser.component + + init { + Disposer.register(parentDisposable, this) + + renderCallbackQuery = JBCefJSQuery.create(browser).apply { + addHandler { result -> + val success = result.startsWith("success") + val message = result.substringAfter(":") + onRenderComplete(success, message) + null + } + } + + browser.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() { + override fun onLoadEnd(browser: CefBrowser?, frame: CefFrame?, httpStatusCode: Int) { + if (frame?.isMain == true) { + isInitialized = true + } + } + }, browser.cefBrowser) + + browser.loadHTML(createMermaidHtml()) + } + + fun renderMermaid(mermaidCode: String, darkTheme: Boolean = true) { + val escapedCode = mermaidCode + .replace("\\", "\\\\") + .replace("`", "\\`") + .replace("\$", "\\\$") + .replace("\n", "\\n") + + val theme = if (darkTheme) "dark" else "default" + val js = """ + renderMermaid(`$escapedCode`, '$theme') + .then(() => { ${renderCallbackQuery.inject("'success:rendered'")} }) + .catch(e => { ${renderCallbackQuery.inject("'error:' + e.message")} }); + """.trimIndent() + + browser.cefBrowser.executeJavaScript(js, browser.cefBrowser.url, 0) + } + + fun setZoomLevel(zoom: Float) { + browser.zoomLevel = zoom.toDouble() + } + + override fun dispose() { + Disposer.dispose(renderCallbackQuery) + } + + companion object { + fun isSupported(): Boolean = JBCefApp.isSupported() + } + + private fun createMermaidHtml(): String = """ + + + + + + + + +
+ + + + """.trimIndent() +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownRenderer.kt new file mode 100644 index 0000000000..a8e7f4f545 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownRenderer.kt @@ -0,0 +1,454 @@ +package cc.unitmesh.devins.idea.renderer.markdown + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.* +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.markdown.MarkdownInlineRenderer.appendMarkdownChildren +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import org.intellij.markdown.MarkdownElementTypes +import org.intellij.markdown.MarkdownTokenTypes +import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.ast.getTextInNode +import org.intellij.markdown.flavours.gfm.GFMElementTypes +import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor +import org.intellij.markdown.flavours.gfm.GFMTokenTypes +import org.intellij.markdown.parser.MarkdownParser +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Text +import java.awt.Desktop +import java.net.URI + +/** + * Full-featured Jewel-themed Markdown renderer using JetBrains' intellij-markdown parser. + * Supports: + * - Headers (H1-H6) + * - Paragraphs with inline formatting (bold, italic, strikethrough, code) + * - Code blocks and fenced code with language detection + * - Block quotes + * - Ordered and unordered lists (with nesting) + * - Links (inline and auto-detected) + * - Tables (GFM) + * - Horizontal rules + * - Checkboxes (GFM task lists) + */ +@Composable +fun JewelMarkdownRenderer( + content: String, + modifier: Modifier = Modifier.fillMaxWidth(), + onLinkClick: ((String) -> Unit)? = null +) { + val flavour = remember { GFMFlavourDescriptor() } + val parser = remember { MarkdownParser(flavour) } + val tree = remember(content) { parser.buildMarkdownTreeFromString(content) } + + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { + RenderNode(node = tree, content = content, onLinkClick = onLinkClick) + } +} + +@Composable +private fun RenderNode( + node: ASTNode, + content: String, + listDepth: Int = 0, + onLinkClick: ((String) -> Unit)? = null +) { + when (node.type) { + MarkdownElementTypes.MARKDOWN_FILE -> { + node.children.forEach { child -> + RenderNode(node = child, content = content, listDepth = listDepth, onLinkClick = onLinkClick) + } + } + MarkdownElementTypes.PARAGRAPH -> { + MarkdownParagraph(node = node, content = content, onLinkClick = onLinkClick) + } + MarkdownElementTypes.ATX_1 -> { + MarkdownHeader(node = node, content = content, level = 1) + } + MarkdownElementTypes.ATX_2 -> { + MarkdownHeader(node = node, content = content, level = 2) + } + MarkdownElementTypes.ATX_3 -> { + MarkdownHeader(node = node, content = content, level = 3) + } + MarkdownElementTypes.ATX_4 -> { + MarkdownHeader(node = node, content = content, level = 4) + } + MarkdownElementTypes.ATX_5 -> { + MarkdownHeader(node = node, content = content, level = 5) + } + MarkdownElementTypes.ATX_6 -> { + MarkdownHeader(node = node, content = content, level = 6) + } + MarkdownElementTypes.SETEXT_1 -> { + MarkdownHeader(node = node, content = content, level = 1) + } + MarkdownElementTypes.SETEXT_2 -> { + MarkdownHeader(node = node, content = content, level = 2) + } + MarkdownElementTypes.CODE_FENCE -> { + MarkdownCodeFence(node = node, content = content) + } + MarkdownElementTypes.CODE_BLOCK -> { + MarkdownCodeBlock(node = node, content = content) + } + MarkdownElementTypes.BLOCK_QUOTE -> { + MarkdownBlockQuote(node = node, content = content, onLinkClick = onLinkClick) + } + MarkdownElementTypes.UNORDERED_LIST -> { + MarkdownUnorderedList(node = node, content = content, depth = listDepth, onLinkClick = onLinkClick) + } + MarkdownElementTypes.ORDERED_LIST -> { + MarkdownOrderedList(node = node, content = content, depth = listDepth, onLinkClick = onLinkClick) + } + MarkdownTokenTypes.HORIZONTAL_RULE -> { + MarkdownHorizontalRule() + } + GFMElementTypes.TABLE -> { + MarkdownTable(node = node, content = content) + } + GFMElementTypes.STRIKETHROUGH -> { + // Handled inline + } + else -> { + // For other node types, try to render children + if (node.children.isNotEmpty()) { + node.children.forEach { child -> + RenderNode(node = child, content = content, listDepth = listDepth, onLinkClick = onLinkClick) + } + } + } + } +} + +@Composable +private fun MarkdownHeader(node: ASTNode, content: String, level: Int) { + val text = MarkdownTextParser.extractHeaderText(node, content) + val (fontSize, fontWeight) = when (level) { + 1 -> 24.sp to FontWeight.Bold + 2 -> 20.sp to FontWeight.Bold + 3 -> 18.sp to FontWeight.SemiBold + 4 -> 16.sp to FontWeight.SemiBold + 5 -> 14.sp to FontWeight.Medium + else -> 13.sp to FontWeight.Medium + } + val verticalPadding = when (level) { + 1 -> 8.dp + 2 -> 6.dp + else -> 4.dp + } + + Text( + text = text, + style = JewelTheme.defaultTextStyle.copy( + fontSize = fontSize, + fontWeight = fontWeight + ), + modifier = Modifier.padding(vertical = verticalPadding) + ) +} + +@Composable +private fun MarkdownParagraph( + node: ASTNode, + content: String, + onLinkClick: ((String) -> Unit)? = null +) { + // Capture the color in composable context + val codeBackground = JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f) + + val annotatedString = buildAnnotatedString { + appendMarkdownChildren(node, content, codeBackground) + } + + if (annotatedString.getStringAnnotations("URL", 0, annotatedString.length).isNotEmpty()) { + @Suppress("DEPRECATION") + ClickableText( + text = annotatedString, + style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp), + modifier = Modifier.padding(vertical = 2.dp), + onClick = { offset -> + annotatedString.getStringAnnotations("URL", offset, offset) + .firstOrNull()?.let { annotation -> + if (onLinkClick != null) { + onLinkClick(annotation.item) + } else { + try { + Desktop.getDesktop().browse(URI(annotation.item)) + } catch (e: Exception) { + // Ignore + } + } + } + } + ) + } else { + Text( + text = annotatedString, + style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp), + modifier = Modifier.padding(vertical = 2.dp) + ) + } +} + +// ============ Code Block Components ============ + +@Composable +private fun MarkdownCodeFence(node: ASTNode, content: String) { + val language = MarkdownTextParser.extractCodeFenceLanguage(node, content) + val codeText = MarkdownTextParser.extractCodeFenceContent(node, content) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .background( + JewelTheme.globalColors.panelBackground.copy(alpha = 0.6f), + RoundedCornerShape(6.dp) + ) + .border( + 1.dp, + JewelTheme.globalColors.borders.normal, + RoundedCornerShape(6.dp) + ) + ) { + // Language header if present + if (!language.isNullOrBlank()) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.8f)) + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = language, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + color = JewelTheme.globalColors.text.info + ) + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(JewelTheme.globalColors.borders.normal.copy(alpha = 0.5f)) + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(12.dp) + ) { + Text( + text = codeText, + style = JewelTheme.defaultTextStyle.copy( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 18.sp + ) + ) + } + } +} + +@Composable +private fun MarkdownCodeBlock(node: ASTNode, content: String) { + val codeText = node.getTextInNode(content).toString() + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .background( + JewelTheme.globalColors.panelBackground.copy(alpha = 0.6f), + RoundedCornerShape(6.dp) + ) + .border( + 1.dp, + JewelTheme.globalColors.borders.normal, + RoundedCornerShape(6.dp) + ) + .padding(12.dp) + .horizontalScroll(rememberScrollState()) + ) { + Text( + text = codeText, + style = JewelTheme.defaultTextStyle.copy( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 18.sp + ) + ) + } +} + +@Composable +private fun MarkdownBlockQuote( + node: ASTNode, + content: String, + onLinkClick: ((String) -> Unit)? = null +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .background( + JewelTheme.globalColors.panelBackground.copy(alpha = 0.3f), + RoundedCornerShape(4.dp) + ) + .padding(8.dp) + ) { + Box( + modifier = Modifier + .width(3.dp) + .fillMaxHeight() + .background(AutoDevColors.Blue.c400, RoundedCornerShape(2.dp)) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + node.children.forEach { child -> + if (child.type != MarkdownTokenTypes.BLOCK_QUOTE) { + RenderNode(node = child, content = content, onLinkClick = onLinkClick) + } + } + } + } +} + +@Composable +private fun MarkdownUnorderedList( + node: ASTNode, + content: String, + depth: Int = 0, + onLinkClick: ((String) -> Unit)? = null +) { + val bulletChar = when (depth % 3) { + 0 -> "\u2022" // • + 1 -> "\u25E6" // ◦ + else -> "\u25AA" // ▪ + } + val indent = (depth * 16).dp + + Column(modifier = Modifier.padding(start = indent, top = 2.dp, bottom = 2.dp)) { + node.children.forEach { child -> + if (child.type == MarkdownElementTypes.LIST_ITEM) { + MarkdownListItem( + node = child, + content = content, + bullet = "$bulletChar ", + depth = depth, + onLinkClick = onLinkClick + ) + } + } + } +} + +@Composable +private fun MarkdownOrderedList( + node: ASTNode, + content: String, + depth: Int = 0, + onLinkClick: ((String) -> Unit)? = null +) { + val indent = (depth * 16).dp + var index = 1 + + Column(modifier = Modifier.padding(start = indent, top = 2.dp, bottom = 2.dp)) { + node.children.forEach { child -> + if (child.type == MarkdownElementTypes.LIST_ITEM) { + MarkdownListItem( + node = child, + content = content, + bullet = "${index++}. ", + depth = depth, + onLinkClick = onLinkClick + ) + } + } + } +} + +@Composable +private fun MarkdownListItem( + node: ASTNode, + content: String, + bullet: String, + depth: Int, + onLinkClick: ((String) -> Unit)? = null +) { + // Check for GFM checkbox + val hasCheckbox = node.children.any { + it.type == GFMTokenTypes.CHECK_BOX || + it.children.any { c -> c.type == GFMTokenTypes.CHECK_BOX } + } + + Row( + modifier = Modifier.padding(vertical = 1.dp), + verticalAlignment = Alignment.Top + ) { + if (!hasCheckbox) { + Text( + text = bullet, + style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp), + modifier = Modifier.width(20.dp) + ) + } + Column(modifier = Modifier.weight(1f)) { + node.children.forEach { child -> + when (child.type) { + MarkdownElementTypes.PARAGRAPH -> { + MarkdownParagraph(node = child, content = content, onLinkClick = onLinkClick) + } + MarkdownElementTypes.UNORDERED_LIST -> { + MarkdownUnorderedList(node = child, content = content, depth = depth + 1, onLinkClick = onLinkClick) + } + MarkdownElementTypes.ORDERED_LIST -> { + MarkdownOrderedList(node = child, content = content, depth = depth + 1, onLinkClick = onLinkClick) + } + GFMTokenTypes.CHECK_BOX -> { + val isChecked = child.getTextInNode(content).toString().contains("x", ignoreCase = true) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (isChecked) "\u2611 " else "\u2610 ", // ☑ or ☐ + style = JewelTheme.defaultTextStyle.copy(fontSize = 14.sp) + ) + } + } + else -> { + RenderNode(node = child, content = content, listDepth = depth + 1, onLinkClick = onLinkClick) + } + } + } + } + } +} + +@Composable +private fun MarkdownHorizontalRule() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .height(1.dp) + .background(JewelTheme.globalColors.borders.normal) + ) +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownInlineRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownInlineRenderer.kt new file mode 100644 index 0000000000..91d879e657 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownInlineRenderer.kt @@ -0,0 +1,147 @@ +package cc.unitmesh.devins.idea.renderer.markdown + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.sp +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import org.intellij.markdown.MarkdownElementTypes +import org.intellij.markdown.MarkdownTokenTypes +import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.ast.getTextInNode +import org.intellij.markdown.flavours.gfm.GFMElementTypes +import org.intellij.markdown.flavours.gfm.GFMTokenTypes + +/** + * Utility object for rendering inline Markdown formatting to AnnotatedString. + * Handles bold, italic, strikethrough, code spans, links, and images. + */ +object MarkdownInlineRenderer { + + /** + * Build annotated string with inline formatting support. + * Recursively processes child nodes and applies appropriate styles. + */ + fun AnnotatedString.Builder.appendMarkdownChildren( + node: ASTNode, + content: String, + codeBackground: Color + ) { + node.children.forEach { child -> + when (child.type) { + MarkdownTokenTypes.TEXT -> { + append(child.getTextInNode(content).toString()) + } + MarkdownTokenTypes.WHITE_SPACE -> append(" ") + MarkdownTokenTypes.EOL -> append(" ") + MarkdownTokenTypes.SINGLE_QUOTE -> append("'") + MarkdownTokenTypes.DOUBLE_QUOTE -> append("\"") + MarkdownTokenTypes.LPAREN -> append("(") + MarkdownTokenTypes.RPAREN -> append(")") + MarkdownTokenTypes.LBRACKET -> append("[") + MarkdownTokenTypes.RBRACKET -> append("]") + MarkdownTokenTypes.LT -> append("<") + MarkdownTokenTypes.GT -> append(">") + MarkdownTokenTypes.COLON -> append(":") + MarkdownTokenTypes.EXCLAMATION_MARK -> append("!") + MarkdownTokenTypes.HARD_LINE_BREAK -> append("\n") + + MarkdownElementTypes.EMPH -> { + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + appendMarkdownChildren(child, content, codeBackground) + } + } + MarkdownElementTypes.STRONG -> { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + appendMarkdownChildren(child, content, codeBackground) + } + } + GFMElementTypes.STRIKETHROUGH -> { + withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { + appendMarkdownChildren(child, content, codeBackground) + } + } + MarkdownElementTypes.CODE_SPAN -> { + withStyle(SpanStyle( + fontFamily = FontFamily.Monospace, + background = codeBackground, + fontSize = 12.sp + )) { + val codeText = MarkdownTextParser.extractCodeSpanText(child, content) + append(" $codeText ") + } + } + MarkdownElementTypes.INLINE_LINK -> { + appendInlineLink(child, content) + } + MarkdownElementTypes.AUTOLINK -> { + appendAutoLink(child, content) + } + GFMTokenTypes.GFM_AUTOLINK -> { + val url = child.getTextInNode(content).toString() + pushStringAnnotation("URL", url) + withStyle(SpanStyle( + color = AutoDevColors.Blue.c400, + textDecoration = TextDecoration.Underline + )) { + append(url) + } + pop() + } + MarkdownElementTypes.SHORT_REFERENCE_LINK, + MarkdownElementTypes.FULL_REFERENCE_LINK -> { + appendMarkdownChildren(child, content, codeBackground) + } + MarkdownElementTypes.IMAGE -> { + val altText = MarkdownTextParser.extractImageAltText(child, content) + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + append("[$altText]") + } + } + else -> { + if (child.children.isNotEmpty()) { + appendMarkdownChildren(child, content, codeBackground) + } + } + } + } + } + + /** + * Append an inline link with URL annotation. + */ + fun AnnotatedString.Builder.appendInlineLink(node: ASTNode, content: String) { + val text = MarkdownTextParser.extractLinkText(node, content) + val url = MarkdownTextParser.extractLinkDestination(node, content) + + pushStringAnnotation("URL", url) + withStyle(SpanStyle( + color = AutoDevColors.Blue.c400, + textDecoration = TextDecoration.Underline + )) { + append(text) + } + pop() + } + + /** + * Append an autolink with URL annotation. + */ + fun AnnotatedString.Builder.appendAutoLink(node: ASTNode, content: String) { + val url = MarkdownTextParser.extractAutoLinkUrl(node, content) + pushStringAnnotation("URL", url) + withStyle(SpanStyle( + color = AutoDevColors.Blue.c400, + textDecoration = TextDecoration.Underline + )) { + append(url) + } + pop() + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTable.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTable.kt new file mode 100644 index 0000000000..694816dbe7 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTable.kt @@ -0,0 +1,187 @@ +package cc.unitmesh.devins.idea.renderer.markdown + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.times +import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.flavours.gfm.GFMElementTypes +import org.intellij.markdown.flavours.gfm.GFMTokenTypes +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Text + +/** Default cell width for table columns */ +private val TABLE_CELL_WIDTH = 120.dp + +/** + * GFM Table renderer following the intellij-markdown AST structure. + * Table structure: + * - TABLE (GFMElementTypes.TABLE) + * - HEADER (GFMElementTypes.HEADER) - first row with column headers + * - CELL (GFMTokenTypes.CELL) - individual header cells + * - TABLE_SEPARATOR (GFMTokenTypes.TABLE_SEPARATOR) - the |---|---| row + * - ROW (GFMElementTypes.ROW) - data rows + * - CELL (GFMTokenTypes.CELL) - individual data cells + * + * Uses BoxWithConstraints to determine if horizontal scrolling is needed. + */ +@Composable +fun MarkdownTable(node: ASTNode, content: String) { + val headerRow = node.children.find { it.type == GFMElementTypes.HEADER } + val bodyRows = node.children.filter { it.type == GFMElementTypes.ROW } + + // Calculate column count from header + val columnsCount = headerRow?.children?.count { it.type == GFMTokenTypes.CELL } ?: 0 + if (columnsCount == 0) return + + // Calculate table width based on column count + val tableWidth = columnsCount * TABLE_CELL_WIDTH + + // Calculate adaptive column weights based on content length + val columnWeights = remember(node, content) { + val lengths = IntArray(columnsCount) { 0 } + // Iterate header + rows to find max length per column + node.children + .filter { it.type == GFMElementTypes.HEADER || it.type == GFMElementTypes.ROW } + .forEach { rowNode -> + val cells = rowNode.children.filter { it.type == GFMTokenTypes.CELL } + cells.forEachIndexed { idx, cell -> + if (idx < columnsCount) { + val raw = MarkdownTextParser.extractCellText(cell, content) + if (raw.length > lengths[idx]) lengths[idx] = raw.length + } + } + } + // Convert to weights with min/max constraints + val floatLengths = lengths.map { it.coerceAtLeast(1).toFloat() } + val total = floatLengths.sum() + val constrained = floatLengths.map { (it / total).coerceIn(0.15f, 0.65f) } + val constrainedTotal = constrained.sum() + constrained.map { it / constrainedTotal } + } + + BoxWithConstraints( + modifier = Modifier.Companion + .padding(vertical = 4.dp) + .background( + JewelTheme.Companion.globalColors.panelBackground.copy(alpha = 0.3f), + RoundedCornerShape(6.dp) + ) + .border( + 1.dp, + JewelTheme.Companion.globalColors.borders.normal, + androidx.compose.foundation.shape.RoundedCornerShape(6.dp) + ) + ) { + // Determine if scrolling is needed + val scrollable = maxWidth < tableWidth + + Column( + modifier = if (scrollable) { + Modifier.Companion.horizontalScroll(rememberScrollState()).requiredWidth(tableWidth) + } else { + Modifier.Companion.fillMaxWidth() + } + ) { + // Header row + if (headerRow != null) { + MarkdownTableRow( + node = headerRow, + content = content, + isHeader = true, + columnWeights = columnWeights, + tableWidth = tableWidth + ) + Box( + modifier = Modifier.Companion + .fillMaxWidth() + .height(1.dp) + .background(JewelTheme.Companion.globalColors.borders.normal) + ) + } + + // Body rows (skip TABLE_SEPARATOR which is handled implicitly) + bodyRows.forEachIndexed { index, row -> + MarkdownTableRow( + node = row, + content = content, + isHeader = false, + columnWeights = columnWeights, + tableWidth = tableWidth + ) + if (index < bodyRows.size - 1) { + Box( + modifier = Modifier.Companion + .fillMaxWidth() + .height(1.dp) + .background(JewelTheme.Companion.globalColors.borders.normal.copy(alpha = 0.5f)) + ) + } + } + } + } +} + +@Composable +private fun MarkdownTableRow( + node: ASTNode, + content: String, + isHeader: Boolean, + columnWeights: List, + tableWidth: Dp +) { + val cells = node.children.filter { it.type == GFMTokenTypes.CELL } + + if (cells.isEmpty()) return + + Row( + modifier = Modifier.Companion + .widthIn(min = tableWidth) + .height(IntrinsicSize.Max) + .then( + if (isHeader) { + Modifier.Companion.background(JewelTheme.Companion.globalColors.panelBackground.copy(alpha = 0.5f)) + } else { + Modifier.Companion + } + ) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.Companion.CenterVertically + ) { + cells.forEachIndexed { idx, cell -> + val weight = if (idx < columnWeights.size) columnWeights[idx] else 1f / cells.size.coerceAtLeast(1) + val cellText = MarkdownTextParser.extractCellText(cell, content) + + Text( + text = cellText, + style = JewelTheme.Companion.defaultTextStyle.copy( + fontSize = 12.sp, + fontWeight = if (isHeader) FontWeight.Companion.SemiBold else FontWeight.Companion.Normal + ), + modifier = Modifier.Companion + .weight(weight) + .padding(horizontal = 8.dp) + ) + } + } +} \ No newline at end of file diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTextParser.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTextParser.kt new file mode 100644 index 0000000000..e1883c1801 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTextParser.kt @@ -0,0 +1,136 @@ +package cc.unitmesh.devins.idea.renderer.markdown + +import org.intellij.markdown.MarkdownTokenTypes +import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.ast.findChildOfType +import org.intellij.markdown.ast.getTextInNode + +/** + * Utility functions for parsing and extracting text from Markdown AST nodes. + * These functions are pure and can be easily tested without Compose dependencies. + */ +object MarkdownTextParser { + + /** + * Extract header text, removing the # prefix. + * Supports both ATX headers (# Header) and SETEXT headers (underlined). + */ + fun extractHeaderText(node: ASTNode, content: String): String { + // For ATX headers, find the ATX_CONTENT child + val contentNode = node.findChildOfType(MarkdownTokenTypes.ATX_CONTENT) + if (contentNode != null) { + return contentNode.getTextInNode(content).toString().trim() + } + + // For SETEXT headers, find the SETEXT_CONTENT child + val setextContent = node.findChildOfType(MarkdownTokenTypes.SETEXT_CONTENT) + if (setextContent != null) { + return setextContent.getTextInNode(content).toString().trim() + } + + // Fallback: remove # prefix manually + val fullText = node.getTextInNode(content).toString() + return fullText.trimStart('#').trim() + } + + /** + * Extract code fence content, removing the ``` markers and language identifier. + */ + fun extractCodeFenceContent(node: ASTNode, content: String): String { + val children = node.children + if (children.size < 3) return "" + + // Find the start of actual code content (after FENCE_LANG and EOL) + var startIndex = 0 + for (i in children.indices) { + if (children[i].type == MarkdownTokenTypes.EOL) { + startIndex = i + 1 + break + } + } + + // Find the end (before CODE_FENCE_END) + var endIndex = children.size - 1 + for (i in children.indices.reversed()) { + if (children[i].type == MarkdownTokenTypes.CODE_FENCE_END) { + endIndex = i - 1 + break + } + } + + if (startIndex > endIndex) return "" + + // Collect code content + val codeBuilder = StringBuilder() + for (i in startIndex..endIndex) { + codeBuilder.append(children[i].getTextInNode(content)) + } + + return codeBuilder.toString().trimEnd() + } + + /** + * Extract clean text from a table cell node. + * Strips markdown formatting characters. + */ + fun extractCellText(cell: ASTNode, content: String): String { + return cell.getTextInNode(content).toString() + .replace("|", "") + .replace("`", "") + .replace("**", "") + .replace("*", "") + .trim() + } + + /** + * Extract text from inline code span, removing backticks. + */ + fun extractCodeSpanText(node: ASTNode, content: String): String { + return node.children + .filter { it.type != MarkdownTokenTypes.BACKTICK } + .joinToString("") { it.getTextInNode(content).toString() } + .trim() + } + + /** + * Extract language identifier from a code fence node. + */ + fun extractCodeFenceLanguage(node: ASTNode, content: String): String? { + return node.findChildOfType(MarkdownTokenTypes.FENCE_LANG) + ?.getTextInNode(content)?.toString()?.trim() + } + + /** + * Extract link text from an inline link node. + */ + fun extractLinkText(node: ASTNode, content: String): String { + val linkText = node.findChildOfType(org.intellij.markdown.MarkdownElementTypes.LINK_TEXT) + return linkText?.children?.filter { it.type == MarkdownTokenTypes.TEXT } + ?.joinToString("") { it.getTextInNode(content).toString() } + ?: node.getTextInNode(content).toString() + } + + /** + * Extract link destination URL from an inline link node. + */ + fun extractLinkDestination(node: ASTNode, content: String): String { + val linkDest = node.findChildOfType(org.intellij.markdown.MarkdownElementTypes.LINK_DESTINATION) + return linkDest?.getTextInNode(content)?.toString() ?: "" + } + + /** + * Extract image alt text from an image node. + */ + fun extractImageAltText(node: ASTNode, content: String): String { + return node.findChildOfType(org.intellij.markdown.MarkdownElementTypes.LINK_TEXT) + ?.getTextInNode(content)?.toString()?.trim('[', ']') ?: "image" + } + + /** + * Extract autolink URL, removing angle brackets. + */ + fun extractAutoLinkUrl(node: ASTNode, content: String): String { + return node.getTextInNode(content).toString().trim('<', '>') + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaCodeBlockRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaCodeBlockRenderer.kt new file mode 100644 index 0000000000..d670ae0855 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaCodeBlockRenderer.kt @@ -0,0 +1,53 @@ +package cc.unitmesh.devins.idea.renderer.sketch + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +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.devins.ui.compose.theme.AutoDevColors +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Text + +/** + * Code block renderer for IntelliJ IDEA with Jewel styling. + */ +@Composable +fun IdeaCodeBlockRenderer( + code: String, + language: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f)) + .padding(8.dp) + ) { + // Language header + if (language.isNotBlank()) { + Text( + text = language, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = AutoDevColors.Blue.c400 + ) + ) + Spacer(modifier = Modifier.height(4.dp)) + } + + // Code content + Text( + text = code, + style = JewelTheme.defaultTextStyle.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ), + modifier = Modifier.fillMaxWidth() + ) + } +} + 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..f0739b5348 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDevInBlockRenderer.kt @@ -0,0 +1,245 @@ +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.devins.idea.toolwindow.IdeaComposeIcons +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +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. + * + * 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()) { + toolCalls.forEach { toolCall -> + val toolName = toolCall.toolName + val params = toolCall.params + + // Format details string (for display) + val details = formatToolCallDetails(params) + + IdeaDevInToolItem( + toolName = toolName, + details = details, + 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, + 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 + ) + ) + } + } + } + } +} + +/** + * 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 { + PrettyJson.encodeToString( + kotlinx.serialization.serializer(), + params + ) + } catch (e: Exception) { + // Fallback to manual formatting with proper JSON escaping + buildString { + appendLine("{") + params.entries.forEachIndexed { index, (key, value) -> + append(" \"${escapeJsonString(key)}\": ") + append("\"${escapeJsonString(value)}\"") + if (index < params.size - 1) { + appendLine(",") + } else { + appendLine() + } + } + append("}") + } + } +} + +/** + * 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") +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDiffRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDiffRenderer.kt new file mode 100644 index 0000000000..ec7af491ad --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDiffRenderer.kt @@ -0,0 +1,124 @@ +package cc.unitmesh.devins.idea.renderer.sketch + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.agent.diff.DiffLineType +import cc.unitmesh.agent.diff.DiffParser +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Text + +/** + * Diff renderer for IntelliJ IDEA with Jewel styling. + * Renders unified diff format with syntax highlighting. + */ +@Composable +fun IdeaDiffRenderer( + diffContent: String, + modifier: Modifier = Modifier +) { + val fileDiffs = remember(diffContent) { DiffParser.parse(diffContent) } + + Column(modifier = modifier) { + if (fileDiffs.isEmpty()) { + Text( + text = "Unable to parse diff content", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = AutoDevColors.Red.c400 + ), + modifier = Modifier.padding(8.dp) + ) + return@Column + } + + fileDiffs.forEach { fileDiff -> + // File header + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp)) + .background(JewelTheme.globalColors.panelBackground) + .padding(8.dp) + ) { + val displayPath = fileDiff.newPath?.takeIf { it.isNotBlank() } + ?: fileDiff.oldPath + ?: "unknown" + Text( + text = displayPath, + style = JewelTheme.defaultTextStyle.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = AutoDevColors.Blue.c400 + ) + ) + } + + // Diff hunks + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(bottomStart = 4.dp, bottomEnd = 4.dp)) + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.3f)) + ) { + fileDiff.hunks.forEach { hunk -> + // Hunk header + Text( + text = "@@ -${hunk.oldStartLine},${hunk.oldLineCount} +${hunk.newStartLine},${hunk.newLineCount} @@", + style = JewelTheme.defaultTextStyle.copy( + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = AutoDevColors.Blue.c300 + ), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + + // Diff lines + hunk.lines.forEach { diffLine -> + val (bgColor, textColor) = when (diffLine.type) { + DiffLineType.ADDED -> Pair( + AutoDevColors.Diff.Dark.addedBg, + AutoDevColors.Green.c400 + ) + DiffLineType.DELETED -> Pair( + AutoDevColors.Diff.Dark.deletedBg, + AutoDevColors.Red.c400 + ) + else -> Pair( + Color.Transparent, + JewelTheme.globalColors.text.normal + ) + } + + Text( + text = diffLine.content, + style = JewelTheme.defaultTextStyle.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = textColor + ), + modifier = Modifier + .fillMaxWidth() + .background(bgColor) + .padding(horizontal = 8.dp, vertical = 1.dp) + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + 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 new file mode 100644 index 0000000000..a30043e1f4 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt @@ -0,0 +1,133 @@ +package cc.unitmesh.devins.idea.renderer.sketch + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import cc.unitmesh.devins.idea.renderer.MermaidDiagramView +import cc.unitmesh.devins.idea.renderer.markdown.JewelMarkdownRenderer +import cc.unitmesh.devins.parser.CodeFence +import com.intellij.openapi.Disposable +import org.jetbrains.jewel.ui.component.CircularProgressIndicator + +/** + * IntelliJ IDEA-specific Sketch Renderer. + * Uses Jewel components for native IntelliJ look and feel. + * + * Handles various content block types: + * - Markdown/Text -> JewelMarkdown + * - Code -> IdeaCodeBlockRenderer + * - Diff -> IdeaDiffRenderer + * - Thinking -> IdeaThinkingBlockRenderer + * - Walkthrough -> IdeaWalkthroughBlockRenderer + * - Mermaid -> MermaidDiagramView + * - DevIn -> IdeaDevInBlockRenderer + */ +object IdeaSketchRenderer { + + /** + * Render LLM response content with full sketch support. + */ + @Composable + fun RenderResponse( + content: String, + isComplete: Boolean = false, + parentDisposable: Disposable, + modifier: Modifier = Modifier + ) { + Column(modifier = modifier) { + val codeFences = remember(content) { CodeFence.parseAll(content) } + + codeFences.forEachIndexed { index, fence -> + val isLastBlock = index == codeFences.lastIndex + val blockIsComplete = fence.isComplete && (isComplete || !isLastBlock) + + when (fence.languageId.lowercase()) { + "markdown", "md", "" -> { + if (fence.text.isNotBlank()) { + JewelMarkdownRenderer( + content = fence.text, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + + "diff", "patch" -> { + if (fence.text.isNotBlank()) { + IdeaDiffRenderer( + diffContent = fence.text, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + + "thinking" -> { + if (fence.text.isNotBlank()) { + IdeaThinkingBlockRenderer( + thinkingContent = fence.text, + isComplete = blockIsComplete, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + + "walkthrough" -> { + if (fence.text.isNotBlank()) { + IdeaWalkthroughBlockRenderer( + walkthroughContent = fence.text, + isComplete = blockIsComplete, + parentDisposable = parentDisposable, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + + "mermaid", "mmd" -> { + if (fence.text.isNotBlank() && blockIsComplete) { + MermaidDiagramView( + mermaidCode = fence.text, + isDarkTheme = true, // TODO: detect theme + parentDisposable = parentDisposable, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + + "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( + code = fence.text, + language = fence.languageId, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } + + if (!isComplete && content.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + CircularProgressIndicator() + } + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaThinkingBlockRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaThinkingBlockRenderer.kt new file mode 100644 index 0000000000..b533259c81 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaThinkingBlockRenderer.kt @@ -0,0 +1,111 @@ +package cc.unitmesh.devins.idea.renderer.sketch + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.icons.AllIconsKeys + +/** + * Thinking block renderer for IntelliJ IDEA with Jewel styling. + * Displays model's reasoning process in a collapsible, scrollable container. + */ +@Composable +fun IdeaThinkingBlockRenderer( + thinkingContent: String, + isComplete: Boolean = true, + modifier: Modifier = Modifier +) { + var isExpanded by remember { mutableStateOf(true) } + val scrollState = rememberScrollState() + var userHasScrolled by remember { mutableStateOf(false) } + + // Track if user manually scrolled away from bottom + LaunchedEffect(scrollState.value, scrollState.maxValue) { + if (scrollState.maxValue > 0) { + val isAtBottom = scrollState.value >= scrollState.maxValue - 10 + if (!isAtBottom && scrollState.isScrollInProgress) { + userHasScrolled = true + } else if (isAtBottom) { + userHasScrolled = false + } + } + } + + // Auto-scroll to bottom during streaming + LaunchedEffect(thinkingContent) { + if (!isComplete && isExpanded && !userHasScrolled && thinkingContent.isNotBlank()) { + kotlinx.coroutines.delay(16) + val targetScroll = scrollState.maxValue + if (targetScroll > scrollState.value) { + scrollState.scrollTo(targetScroll) + } + } + } + + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.3f)) + .padding(8.dp) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + // Header with expand/collapse toggle + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { isExpanded = !isExpanded }, + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + key = if (isExpanded) AllIconsKeys.General.ArrowDown else AllIconsKeys.General.ArrowRight, + contentDescription = if (isExpanded) "Collapse" else "Expand", + modifier = Modifier.size(12.dp), + tint = JewelTheme.globalColors.text.info.copy(alpha = 0.6f) + ) + + Text( + text = "Thinking process", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.info.copy(alpha = 0.7f) + ) + ) + } + + if (isExpanded) { + Spacer(modifier = Modifier.height(4.dp)) + + // Scrollable content (max ~5 lines) + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 80.dp) + .verticalScroll(scrollState) + ) { + Text( + text = thinkingContent, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.info.copy(alpha = 0.6f) + ) + ) + } + } + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaWalkthroughBlockRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaWalkthroughBlockRenderer.kt new file mode 100644 index 0000000000..9cb2701558 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaWalkthroughBlockRenderer.kt @@ -0,0 +1,35 @@ +package cc.unitmesh.devins.idea.renderer.sketch + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import cc.unitmesh.devins.idea.renderer.IdeaMarkdownRenderer +import com.intellij.openapi.Disposable + +/** + * Walkthrough block renderer for IntelliJ IDEA. + * + * Renders ... blocks + * containing structured code review summaries with: + * - Walkthrough section (2-3 paragraphs) + * - Changes table (markdown table) + * - Optional sequence diagrams + */ +@Composable +fun IdeaWalkthroughBlockRenderer( + walkthroughContent: String, + isComplete: Boolean = true, + parentDisposable: Disposable, + modifier: Modifier = Modifier +) { + Box(modifier = modifier.fillMaxWidth()) { + IdeaMarkdownRenderer( + content = walkthroughContent, + isComplete = isComplete, + parentDisposable = parentDisposable, + modifier = modifier + ) + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/terminal/IdeaAnsiTerminalRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/terminal/IdeaAnsiTerminalRenderer.kt new file mode 100644 index 0000000000..c7be1c12eb --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/terminal/IdeaAnsiTerminalRenderer.kt @@ -0,0 +1,208 @@ +package cc.unitmesh.devins.idea.renderer.terminal + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import cc.unitmesh.devins.ui.compose.terminal.AnsiParser +import cc.unitmesh.devins.ui.compose.terminal.TerminalCell +import cc.unitmesh.devins.ui.compose.terminal.TerminalLine +import cc.unitmesh.devins.ui.compose.terminal.TerminalState +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Text + +/** + * Jewel-themed ANSI terminal renderer for IntelliJ IDEA. + * + * Renders terminal output with ANSI escape sequence support including: + * - Colors (16 standard colors + 256 color palette) + * - Text styles (bold, italic, underline, dim) + * - Cursor movements and screen manipulation + * + * Uses Jewel theme colors for better integration with IntelliJ IDEA UI. + * + * @param ansiText Text containing ANSI escape sequences + * @param modifier Modifier for the terminal container + * @param maxHeight Maximum height of the terminal display in dp + * @param backgroundColor Background color (defaults to Jewel panel background) + */ +@Composable +fun IdeaAnsiTerminalRenderer( + ansiText: String, + modifier: Modifier = Modifier, + maxHeight: Int = 600, + backgroundColor: Color = AutoDevColors.Neutral.c900 +) { + val terminalState = remember { TerminalState() } + val parser = remember { AnsiParser() } + + // Parse the ANSI text when it changes + LaunchedEffect(ansiText) { + terminalState.clearScreen() + parser.parse(ansiText, terminalState) + } + + IdeaTerminalRenderer( + terminalState = terminalState, + modifier = modifier, + showCursor = false, + maxHeight = maxHeight, + backgroundColor = backgroundColor + ) +} + +/** + * Core terminal renderer component using Jewel theming. + */ +@Composable +private fun IdeaTerminalRenderer( + terminalState: TerminalState, + modifier: Modifier = Modifier, + showCursor: Boolean = false, + maxHeight: Int = 600, + backgroundColor: Color = AutoDevColors.Neutral.c900 +) { + val defaultForeground = AutoDevColors.Neutral.c300 + val verticalScrollState = rememberScrollState() + val horizontalScrollState = rememberScrollState() + + Box( + modifier = modifier + .background(backgroundColor) + .heightIn(max = maxHeight.dp) + .fillMaxWidth() + ) { + Column( + modifier = Modifier + .verticalScroll(verticalScrollState) + .horizontalScroll(horizontalScrollState) + .padding(12.dp) + ) { + val visibleLines = terminalState.getVisibleLines() + + visibleLines.forEachIndexed { lineIndex, line -> + IdeaTerminalLineRenderer( + line = line, + defaultForeground = defaultForeground, + showCursor = showCursor && lineIndex == terminalState.cursorY, + cursorX = if (lineIndex == terminalState.cursorY) terminalState.cursorX else -1 + ) + } + + // Show cursor on empty line if at the end + if (showCursor && terminalState.cursorY >= visibleLines.size) { + IdeaCursorIndicator() + } + } + } +} + +/** + * Renders a single terminal line with styled text using Jewel components. + */ +@Composable +private fun IdeaTerminalLineRenderer( + line: TerminalLine, + defaultForeground: Color, + showCursor: Boolean, + cursorX: Int +) { + val annotatedString = buildAnnotatedString { + line.cells.forEachIndexed { index, cell -> + val fgColor = getCellColor(cell, defaultForeground, isBackground = false) + val bgColor = getCellBackgroundColor(cell) + val alpha = if (cell.dim) 0.6f else 1.0f + + pushStyle( + SpanStyle( + color = fgColor.copy(alpha = alpha), + background = bgColor, + fontWeight = if (cell.bold) FontWeight.Bold else FontWeight.Normal, + fontStyle = if (cell.italic) FontStyle.Italic else FontStyle.Normal, + textDecoration = if (cell.underline) TextDecoration.Underline else null + ) + ) + + append(cell.char) + pop() + } + + // Add cursor if needed + if (showCursor && cursorX >= 0) { + // Ensure we have enough space for cursor + while (length <= cursorX) { + append(' ') + } + } + } + + Row { + Text( + text = annotatedString, + style = JewelTheme.defaultTextStyle.copy( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 18.sp + ), + modifier = Modifier.padding(vertical = 1.dp) + ) + + if (showCursor && cursorX >= 0 && cursorX >= line.cells.size) { + IdeaCursorIndicator() + } + } +} + +/** + * Get the foreground color for a cell, handling inverse video. + */ +private fun getCellColor(cell: TerminalCell, defaultColor: Color, isBackground: Boolean): Color { + val fg = cell.foregroundColor ?: defaultColor + val bg = cell.backgroundColor ?: Color.Transparent + + return if (cell.inverse) { + if (isBackground) fg else bg.takeIf { it != Color.Transparent } ?: defaultColor + } else { + if (isBackground) bg else fg + } +} + +/** + * Get the background color for a cell. + */ +private fun getCellBackgroundColor(cell: TerminalCell): Color { + val bg = cell.backgroundColor ?: Color.Transparent + val fg = cell.foregroundColor ?: Color.Transparent + + return if (cell.inverse) { + fg.takeIf { it != Color.Transparent } ?: Color.Transparent + } else { + bg + } +} + +/** + * Cursor indicator for live terminals. + */ +@Composable +private fun IdeaCursorIndicator() { + Box( + modifier = Modifier + .size(width = 8.dp, height = 16.dp) + .background(AutoDevColors.Cyan.c400.copy(alpha = 0.7f)) + ) +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/terminal/TerminalApiCompat.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/terminal/TerminalApiCompat.kt new file mode 100644 index 0000000000..698ba85b0d --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/terminal/TerminalApiCompat.kt @@ -0,0 +1,184 @@ +package cc.unitmesh.devins.idea.terminal + +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project + +/** + * Compatibility layer for IntelliJ Terminal API. + * + * Supports both: + * - 2025.2: Uses reflection to access old Terminal API + * - 2025.3+: Uses new TerminalToolWindowTabsManager API + * + * Based on IntelliJ Platform Plugin SDK documentation: + * https://plugins.jetbrains.com/docs/intellij/embedded-terminal.html + */ +object TerminalApiCompat { + private val LOG = logger() + + /** + * Opens a command in IDEA's native terminal. + * + * @param project The current project + * @param command The command to execute + * @param tabName Optional custom tab name (defaults to "AutoDev: ") + * @param requestFocus Whether to focus the terminal tab + * @return true if successful, false otherwise + */ + fun openCommandInTerminal( + project: Project, + command: String, + tabName: String? = null, + requestFocus: Boolean = true + ): Boolean { + return try { + // Try new API first (2025.3+) + tryNewTerminalApi(project, command, tabName, requestFocus) + } catch (e: ClassNotFoundException) { + LOG.info("New Terminal API not available, trying fallback approach") + // Fallback: Try old API or alternative approach + tryFallbackTerminalApi(project, command, tabName, requestFocus) + } catch (e: Exception) { + LOG.warn("Failed to open command in terminal: ${e.message}", e) + false + } + } + + /** + * Try using the new Terminal API (2025.3+). + * Uses TerminalToolWindowTabsManager from com.intellij.terminal.frontend.toolwindow + */ + private fun tryNewTerminalApi( + project: Project, + command: String, + tabName: String?, + requestFocus: Boolean + ): Boolean { + try { + // Load classes using reflection to avoid compile-time dependency + val managerClass = Class.forName("com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTabsManager") + val getInstanceMethod = managerClass.getMethod("getInstance", Project::class.java) + val manager = getInstanceMethod.invoke(null, project) + + // Create tab builder + val createTabBuilderMethod = managerClass.getMethod("createTabBuilder") + val tabBuilder = createTabBuilderMethod.invoke(manager) + val tabBuilderClass = tabBuilder.javaClass + + // Configure tab + val effectiveTabName = tabName ?: "AutoDev: $command" + val tabNameMethod = tabBuilderClass.getMethod("tabName", String::class.java) + tabNameMethod.invoke(tabBuilder, effectiveTabName) + + val requestFocusMethod = tabBuilderClass.getMethod("requestFocus", Boolean::class.javaPrimitiveType) + requestFocusMethod.invoke(tabBuilder, requestFocus) + + // Create tab + val createTabMethod = tabBuilderClass.getMethod("createTab") + val tab = createTabMethod.invoke(tabBuilder) + + // Get view and send text + val tabClass = tab.javaClass + val getViewMethod = tabClass.getMethod("getView") + val view = getViewMethod.invoke(tab) + + val viewClass = view.javaClass + val sendTextMethod = viewClass.getMethod("sendText", String::class.java) + sendTextMethod.invoke(view, command + "\n") + + LOG.info("Successfully opened command in terminal using new API: $command") + return true + } catch (e: NoSuchMethodException) { + LOG.warn("New Terminal API method not found: ${e.message}") + throw e + } + } + + /** + * Fallback approach for older IDEA versions or when new API is not available. + * Uses TerminalToolWindowManager to create shell widget and execute command. + */ + private fun tryFallbackTerminalApi( + project: Project, + command: String, + tabName: String?, + requestFocus: Boolean + ): Boolean { + try { + // Try to use TerminalToolWindowManager (Classic Terminal API) + val managerClass = Class.forName("org.jetbrains.plugins.terminal.TerminalToolWindowManager") + val getInstanceMethod = managerClass.getMethod("getInstance", Project::class.java) + val manager = getInstanceMethod.invoke(null, project) + + // Create shell widget + val effectiveTabName = tabName ?: "AutoDev: $command" + val createShellWidgetMethod = managerClass.getMethod( + "createShellWidget", + String::class.java, // workingDirectory + String::class.java, // tabName + Boolean::class.javaPrimitiveType, // requestFocus + Boolean::class.javaPrimitiveType // deferSessionStartUntilUiShown + ) + + val widget = createShellWidgetMethod.invoke( + manager, + null, // workingDirectory (use default) + effectiveTabName, + requestFocus, + false // don't defer, start immediately + ) + + // Execute command on the widget + val widgetClass = widget.javaClass + val executeCommandMethod = widgetClass.getMethod("executeCommand", String::class.java) + executeCommandMethod.invoke(widget, command) + + LOG.info("Successfully executed command in terminal using fallback API: $command") + return true + } catch (e: ClassNotFoundException) { + LOG.warn("TerminalToolWindowManager not found, trying basic activation") + return tryBasicTerminalActivation(project, requestFocus) + } catch (e: NoSuchMethodException) { + LOG.warn("Terminal API method not found: ${e.message}") + return tryBasicTerminalActivation(project, requestFocus) + } catch (e: Exception) { + LOG.warn("Fallback terminal API failed: ${e.message}", e) + return tryBasicTerminalActivation(project, requestFocus) + } + } + + /** + * Last resort: just activate the terminal tool window without executing command. + */ + private fun tryBasicTerminalActivation(project: Project, requestFocus: Boolean): Boolean { + return try { + val toolWindowManager = com.intellij.openapi.wm.ToolWindowManager.getInstance(project) + val terminalToolWindow = toolWindowManager.getToolWindow("Terminal") + + if (terminalToolWindow != null && requestFocus) { + terminalToolWindow.activate(null) + LOG.info("Activated Terminal tool window (command not sent)") + return true + } + + LOG.warn("Terminal tool window not found") + false + } catch (e: Exception) { + LOG.warn("Basic terminal activation failed: ${e.message}", e) + false + } + } + + /** + * Check if Terminal API is available in the current IDEA version. + */ + fun isTerminalApiAvailable(project: Project): Boolean { + return try { + val toolWindowManager = com.intellij.openapi.wm.ToolWindowManager.getInstance(project) + toolWindowManager.getToolWindow("Terminal") != null + } catch (e: Exception) { + false + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt index aaac4d9b3b..2f1f07e8c4 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt @@ -2,40 +2,39 @@ package cc.unitmesh.devins.idea.toolwindow import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.awt.SwingPanel -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.key.* -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import cc.unitmesh.agent.AgentType import cc.unitmesh.devins.idea.editor.IdeaBottomToolbar import cc.unitmesh.devins.idea.editor.IdeaDevInInput import cc.unitmesh.devins.idea.editor.IdeaInputListener import cc.unitmesh.devins.idea.editor.IdeaInputTrigger -import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.devins.idea.editor.IdeaModelConfigDialog import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewContent import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewViewModel -import cc.unitmesh.devins.ui.compose.icons.AutoDevComposeIcons -import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import cc.unitmesh.devins.idea.components.header.IdeaAgentTabsHeader +import cc.unitmesh.devins.idea.components.IdeaVerticalResizableSplitPane +import cc.unitmesh.devins.idea.toolwindow.knowledge.IdeaKnowledgeContent +import cc.unitmesh.devins.idea.toolwindow.knowledge.IdeaKnowledgeViewModel +import cc.unitmesh.devins.idea.toolwindow.remote.IdeaRemoteAgentContent +import cc.unitmesh.devins.idea.toolwindow.remote.IdeaRemoteAgentViewModel +import cc.unitmesh.devins.idea.toolwindow.remote.getEffectiveProjectId +import cc.unitmesh.devins.idea.components.status.IdeaToolLoadingStatusBar +import cc.unitmesh.devins.idea.components.timeline.IdeaEmptyStateMessage +import cc.unitmesh.devins.idea.components.timeline.IdeaTimelineContent +import cc.unitmesh.llm.ModelConfig +import cc.unitmesh.llm.NamedModelConfig import com.intellij.openapi.Disposable import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.distinctUntilChanged import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.Orientation -import org.jetbrains.jewel.ui.component.* -import org.jetbrains.jewel.ui.theme.defaultBannerStyle +import org.jetbrains.jewel.ui.component.Divider import java.awt.BorderLayout import java.awt.Dimension import javax.swing.JPanel @@ -64,11 +63,31 @@ fun IdeaAgentApp( val isExecuting by viewModel.isExecuting.collectAsState() val showConfigDialog by viewModel.showConfigDialog.collectAsState() val mcpPreloadingMessage by viewModel.mcpPreloadingMessage.collectAsState() + val configWrapper by viewModel.configWrapper.collectAsState() + val currentModelConfig by viewModel.currentModelConfig.collectAsState() val listState = rememberLazyListState() + // Get available configs and current config name + val availableConfigs = remember(configWrapper) { + configWrapper?.getAllConfigs() ?: emptyList() + } + val currentConfigName = remember(configWrapper) { + configWrapper?.getActiveName() + } + // Code Review ViewModel (created lazily when needed) var codeReviewViewModel by remember { mutableStateOf(null) } + // Knowledge ViewModel (created lazily when needed) + var knowledgeViewModel by remember { mutableStateOf(null) } + + // Remote Agent ViewModel (created lazily when needed) + var remoteAgentViewModel by remember { mutableStateOf(null) } + + // Remote agent state for input handling + var remoteProjectId by remember { mutableStateOf("") } + var remoteGitUrl by remember { mutableStateOf("") } + // Auto-scroll to bottom when new items arrive LaunchedEffect(timeline.size, streamingOutput) { if (timeline.isNotEmpty() || streamingOutput.isNotEmpty()) { @@ -79,20 +98,38 @@ fun IdeaAgentApp( } } - // Create CodeReviewViewModel when switching to CODE_REVIEW tab + // Create ViewModels when switching tabs LaunchedEffect(currentAgentType) { if (currentAgentType == AgentType.CODE_REVIEW && codeReviewViewModel == null) { codeReviewViewModel = IdeaCodeReviewViewModel(project, coroutineScope) } + if (currentAgentType == AgentType.KNOWLEDGE && knowledgeViewModel == null) { + knowledgeViewModel = IdeaKnowledgeViewModel(project, coroutineScope) + } + if (currentAgentType == AgentType.REMOTE && remoteAgentViewModel == null) { + remoteAgentViewModel = IdeaRemoteAgentViewModel( + project = project, + coroutineScope = coroutineScope, + serverUrl = "http://localhost:8080" + ) + } } - // Dispose CodeReviewViewModel when leaving CODE_REVIEW tab + // Dispose ViewModels when leaving their tabs DisposableEffect(currentAgentType) { onDispose { if (currentAgentType != AgentType.CODE_REVIEW) { codeReviewViewModel?.dispose() codeReviewViewModel = null } + if (currentAgentType != AgentType.KNOWLEDGE) { + knowledgeViewModel?.dispose() + knowledgeViewModel = null + } + if (currentAgentType != AgentType.REMOTE) { + remoteAgentViewModel?.dispose() + remoteAgentViewModel = null + } } } @@ -102,7 +139,7 @@ fun IdeaAgentApp( .background(JewelTheme.globalColors.panelBackground) ) { // Agent Type Tabs Header - AgentTabsHeader( + IdeaAgentTabsHeader( currentAgentType = currentAgentType, onAgentTypeChange = { viewModel.onAgentTypeChange(it) }, onNewChat = { viewModel.clearHistory() }, @@ -111,551 +148,128 @@ fun IdeaAgentApp( Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) - // Content based on agent type - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) { - when (currentAgentType) { - AgentType.CODING, AgentType.REMOTE, AgentType.LOCAL_CHAT -> { - TimelineContent( - timeline = timeline, - streamingOutput = streamingOutput, - listState = listState + // Main content area with resizable split pane for chat-based modes + when (currentAgentType) { + AgentType.CODING, AgentType.LOCAL_CHAT -> { + IdeaVerticalResizableSplitPane( + modifier = Modifier.fillMaxWidth().weight(1f), + initialSplitRatio = 0.75f, + minRatio = 0.3f, + maxRatio = 0.9f, + top = { + IdeaTimelineContent( + timeline = timeline, + streamingOutput = streamingOutput, + listState = listState, + project = project + ) + }, + bottom = { + IdeaDevInInputArea( + project = project, + parentDisposable = viewModel, + isProcessing = isExecuting, + onSend = { viewModel.sendMessage(it) }, + onAbort = { viewModel.cancelTask() }, + workspacePath = project.basePath, + totalTokens = null, + onSettingsClick = { viewModel.setShowConfigDialog(true) }, + onAtClick = {}, + availableConfigs = availableConfigs, + currentConfigName = currentConfigName, + onConfigSelect = { config -> + viewModel.setActiveConfig(config.name) + }, + onConfigureClick = { viewModel.setShowConfigDialog(true) } + ) + } + ) + } + AgentType.REMOTE -> { + remoteAgentViewModel?.let { remoteVm -> + val remoteIsExecuting by remoteVm.isExecuting.collectAsState() + val remoteIsConnected by remoteVm.isConnected.collectAsState() + + IdeaVerticalResizableSplitPane( + modifier = Modifier.fillMaxWidth().weight(1f), + initialSplitRatio = 0.75f, + minRatio = 0.3f, + maxRatio = 0.9f, + top = { + IdeaRemoteAgentContent( + viewModel = remoteVm, + listState = listState, + onProjectIdChange = { remoteProjectId = it }, + onGitUrlChange = { remoteGitUrl = it } + ) + }, + bottom = { + IdeaDevInInputArea( + project = project, + parentDisposable = viewModel, + isProcessing = remoteIsExecuting, + onSend = { task -> + val effectiveProjectId = getEffectiveProjectId(remoteProjectId, remoteGitUrl) + if (effectiveProjectId.isNotBlank()) { + remoteVm.executeTask(effectiveProjectId, task, remoteGitUrl) + } else { + remoteVm.renderer.renderError("Please provide a project or Git URL") + } + }, + onAbort = { remoteVm.cancelTask() }, + workspacePath = project.basePath, + totalTokens = null, + onSettingsClick = { viewModel.setShowConfigDialog(true) }, + onAtClick = {}, + availableConfigs = availableConfigs, + currentConfigName = currentConfigName, + onConfigSelect = { config -> + viewModel.setActiveConfig(config.name) + }, + onConfigureClick = { viewModel.setShowConfigDialog(true) } + ) + } ) - } - AgentType.CODE_REVIEW -> { + } ?: IdeaEmptyStateMessage("Loading Remote Agent...") + } + AgentType.CODE_REVIEW -> { + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { codeReviewViewModel?.let { vm -> - IdeaCodeReviewContent(viewModel = vm) - } ?: EmptyStateMessage("Loading Code Review...") - } - AgentType.KNOWLEDGE -> { - KnowledgeContent() + IdeaCodeReviewContent( + viewModel = vm, + parentDisposable = viewModel + ) + } ?: IdeaEmptyStateMessage("Loading Code Review...") } } - } - - Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) - - // Input area (only for chat-based modes) - if (currentAgentType == AgentType.CODING || currentAgentType == AgentType.REMOTE || currentAgentType == AgentType.LOCAL_CHAT) { - DevInInputArea( - project = project, - parentDisposable = viewModel, - isProcessing = isExecuting, - onSend = { viewModel.sendMessage(it) }, - onAbort = { viewModel.cancelTask() }, - workspacePath = project.basePath, - totalTokens = null, // TODO: integrate token counting from renderer - onSettingsClick = { viewModel.setShowConfigDialog(true) }, - onAtClick = { - // @ click triggers agent completion - placeholder for now + AgentType.KNOWLEDGE -> { + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + knowledgeViewModel?.let { vm -> + IdeaKnowledgeContent(viewModel = vm) + } ?: IdeaEmptyStateMessage("Loading Knowledge Agent...") } - ) + } } // Tool loading status bar - ToolLoadingStatusBar( + IdeaToolLoadingStatusBar( viewModel = viewModel, modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp) ) } -} - -@Composable -private fun TimelineContent( - timeline: List, - streamingOutput: String, - listState: androidx.compose.foundation.lazy.LazyListState -) { - if (timeline.isEmpty() && streamingOutput.isEmpty()) { - EmptyStateMessage("Start a conversation with your AI Assistant!") - } else { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - items(timeline, key = { it.id }) { item -> - TimelineItemView(item) - } - - // Show streaming output - if (streamingOutput.isNotEmpty()) { - item { - StreamingMessageBubble(streamingOutput) - } - } - } - } -} - -@Composable -private fun TimelineItemView(item: JewelRenderer.TimelineItem) { - when (item) { - is JewelRenderer.TimelineItem.MessageItem -> { - MessageBubble( - role = item.role, - content = item.content - ) - } - is JewelRenderer.TimelineItem.ToolCallItem -> { - ToolCallBubble(item) - } - is JewelRenderer.TimelineItem.ErrorItem -> { - ErrorBubble(item.message) - } - is JewelRenderer.TimelineItem.TaskCompleteItem -> { - TaskCompleteBubble(item) - } - is JewelRenderer.TimelineItem.TerminalOutputItem -> { - TerminalOutputBubble(item) - } - } -} - -@Composable -private fun KnowledgeContent() { - EmptyStateMessage("Knowledge mode - Coming soon!") -} - -@Composable -private fun EmptyStateMessage(text: String) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = text, - style = JewelTheme.defaultTextStyle.copy( - fontSize = 14.sp, - color = JewelTheme.globalColors.text.info - ) - ) - } -} - -@Composable -private fun MessageBubble(role: JewelRenderer.MessageRole, content: String) { - val isUser = role == JewelRenderer.MessageRole.USER - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start - ) { - Box( - modifier = Modifier - .widthIn(max = 500.dp) - .background( - if (isUser) - JewelTheme.defaultBannerStyle.information.colors.background.copy(alpha = 0.75f) - else - JewelTheme.globalColors.panelBackground - ) - .padding(8.dp) - ) { - Text( - text = content, - style = JewelTheme.defaultTextStyle - ) - } - } -} - -@Composable -private fun ToolCallBubble(item: JewelRenderer.TimelineItem.ToolCallItem) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start - ) { - Box( - modifier = Modifier - .widthIn(max = 500.dp) - .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f)) - .padding(8.dp) - ) { - Column { - // Tool name with icon - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - val statusIcon = when (item.success) { - true -> "✓" - false -> "✗" - null -> "⏳" - } - // Use AutoDevColors design system - use lighter colors for dark theme compatibility - val statusColor = when (item.success) { - true -> AutoDevColors.Green.c400 // Success color from design system - false -> AutoDevColors.Red.c400 // Error color from design system - null -> JewelTheme.globalColors.text.info - } - Text( - text = statusIcon, - style = JewelTheme.defaultTextStyle.copy(color = statusColor) - ) - Icon( - imageVector = AutoDevComposeIcons.Build, - contentDescription = "Tool", - modifier = Modifier.size(14.dp), - tint = JewelTheme.globalColors.text.normal - ) - Text( - text = item.toolName, - style = JewelTheme.defaultTextStyle.copy(fontWeight = FontWeight.Bold) - ) - } - - // Tool parameters (truncated) - if (item.params.isNotEmpty()) { - Text( - text = item.params.take(200) + if (item.params.length > 200) "..." else "", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 12.sp, - color = JewelTheme.globalColors.text.info - ) - ) - } - - // Tool output (if available) - item.output?.let { output -> - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = output.take(300) + if (output.length > 300) "..." else "", - style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp) - ) - } - } - } - } -} - -@Composable -private fun ErrorBubble(message: String) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start - ) { - Box( - modifier = Modifier - .widthIn(max = 500.dp) - .background(AutoDevColors.Red.c400.copy(alpha = 0.2f)) // Error background from design system - .padding(8.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = AutoDevComposeIcons.Error, - contentDescription = "Error", - modifier = Modifier.size(16.dp), - tint = AutoDevColors.Red.c400 - ) - Text( - text = message, - style = JewelTheme.defaultTextStyle.copy( - color = AutoDevColors.Red.c400 // Error text color from design system - ) - ) - } - } - } -} - -@Composable -private fun TaskCompleteBubble(item: JewelRenderer.TimelineItem.TaskCompleteItem) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - Box( - modifier = Modifier - .background( - // Use AutoDevColors design system with alpha for background - if (item.success) - AutoDevColors.Green.c400.copy(alpha = 0.2f) - else - AutoDevColors.Red.c400.copy(alpha = 0.2f) - ) - .padding(horizontal = 16.dp, vertical = 8.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = if (item.success) AutoDevComposeIcons.CheckCircle else AutoDevComposeIcons.Error, - contentDescription = if (item.success) "Success" else "Failed", - modifier = Modifier.size(16.dp), - tint = if (item.success) AutoDevColors.Green.c400 else AutoDevColors.Red.c400 - ) - Text( - text = "${item.message} (${item.iterations} iterations)", - style = JewelTheme.defaultTextStyle.copy( - fontWeight = FontWeight.Bold - ) - ) - } - } - } -} - -@Composable -private fun StreamingMessageBubble(content: String) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start - ) { - Box( - modifier = Modifier - .widthIn(max = 500.dp) - .background(JewelTheme.globalColors.panelBackground) - .padding(8.dp) - ) { - Text( - text = content + "▌", - style = JewelTheme.defaultTextStyle - ) - } - } -} - -@Composable -private fun AgentTabsHeader( - currentAgentType: AgentType, - onAgentTypeChange: (AgentType) -> Unit, - onNewChat: () -> Unit, - onSettings: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(36.dp) - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - // Left: Agent Type Tabs (show main agent types, skip LOCAL_CHAT as it's similar to CODING) - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Show only main agent types for cleaner UI - listOf(AgentType.CODING, AgentType.CODE_REVIEW, AgentType.KNOWLEDGE, AgentType.REMOTE).forEach { type -> - AgentTab( - type = type, - isSelected = type == currentAgentType, - onClick = { onAgentTypeChange(type) } - ) - } - } - - // Right: Actions - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = onNewChat) { - Text("+", style = JewelTheme.defaultTextStyle.copy(fontWeight = FontWeight.Bold)) - } - IconButton(onClick = onSettings) { - Icon( - imageVector = AutoDevComposeIcons.Settings, - contentDescription = "Settings", - modifier = Modifier.size(16.dp), - tint = JewelTheme.globalColors.text.normal - ) - } - } - } -} - -@Composable -private fun AgentTab( - type: AgentType, - isSelected: Boolean, - onClick: () -> Unit -) { - val backgroundColor = if (isSelected) { - JewelTheme.defaultBannerStyle.information.colors.background.copy(alpha = 0.5f) - } else { - JewelTheme.globalColors.panelBackground - } - - OutlinedButton( - onClick = onClick, - modifier = Modifier - .height(28.dp) - .background(backgroundColor) - .padding(horizontal = 4.dp, vertical = 2.dp) - ) { - Text( - text = type.getDisplayName(), - style = JewelTheme.defaultTextStyle.copy( - fontSize = 12.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal - ) - ) - } -} - -@Composable -private fun TerminalOutputBubble(item: JewelRenderer.TimelineItem.TerminalOutputItem) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start - ) { - Box( - modifier = Modifier - .widthIn(max = 600.dp) - .background(AutoDevColors.Neutral.c900) // Terminal background from design system - .padding(8.dp) - ) { - Column { - // Command header - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "$ ${item.command}", - style = JewelTheme.defaultTextStyle.copy( - fontWeight = FontWeight.Bold, - color = AutoDevColors.Cyan.c400 // Cyan for commands from design system - ) - ) - val exitColor = if (item.exitCode == 0) AutoDevColors.Green.c400 else AutoDevColors.Red.c400 - Text( - text = "exit: ${item.exitCode}", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 11.sp, - color = exitColor - ) - ) - Text( - text = "${item.executionTimeMs}ms", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 11.sp, - color = JewelTheme.globalColors.text.info - ) - ) - } - - Spacer(modifier = Modifier.height(4.dp)) - // Output content - val outputText = item.output.take(1000) + if (item.output.length > 1000) "\n..." else "" - Text( - text = outputText, - style = JewelTheme.defaultTextStyle.copy( - fontSize = 12.sp, - color = AutoDevColors.Neutral.c300 // Light gray for output from design system - ) - ) + // Model Configuration Dialog + if (showConfigDialog) { + val dialogConfig = currentModelConfig ?: ModelConfig() + IdeaModelConfigDialog( + currentConfig = dialogConfig, + currentConfigName = currentConfigName, + onDismiss = { viewModel.setShowConfigDialog(false) }, + onSave = { name, config -> + val namedConfig = NamedModelConfig.fromModelConfig(name, config) + viewModel.saveModelConfig(namedConfig, setActive = true) + viewModel.setShowConfigDialog(false) } - } - } -} - -@Composable -private fun ToolLoadingStatusBar( - viewModel: IdeaAgentViewModel, - modifier: Modifier = Modifier -) { - val mcpPreloadingMessage by viewModel.mcpPreloadingMessage.collectAsState() - val mcpPreloadingStatus by viewModel.mcpPreloadingStatus.collectAsState() - // Recompute when preloading status changes to make it reactive - val toolStatus = remember(mcpPreloadingStatus) { viewModel.getToolLoadingStatus() } - - Row( - modifier = modifier - .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.8f)) - .padding(horizontal = 12.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // SubAgents status - ToolStatusChip( - label = "SubAgents", - count = toolStatus.subAgentsEnabled, - total = toolStatus.subAgentsTotal, - isLoading = false, - color = AutoDevColors.Blue.c400 - ) - - // MCP Tools status - ToolStatusChip( - label = "MCP Tools", - count = toolStatus.mcpToolsEnabled, - total = if (toolStatus.isLoading) -1 else toolStatus.mcpToolsTotal, - isLoading = toolStatus.isLoading, - color = if (!toolStatus.isLoading && toolStatus.mcpToolsEnabled > 0) - AutoDevColors.Green.c400 - else - JewelTheme.globalColors.text.info - ) - - Spacer(modifier = Modifier.weight(1f)) - - // Status message - if (mcpPreloadingMessage.isNotEmpty()) { - Text( - text = mcpPreloadingMessage, - style = JewelTheme.defaultTextStyle.copy( - fontSize = 11.sp, - color = JewelTheme.globalColors.text.info - ), - maxLines = 1 - ) - } else if (!toolStatus.isLoading && toolStatus.mcpServersLoaded > 0) { - Text( - text = "✓ All tools ready", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 11.sp, - color = AutoDevColors.Green.c400 - ) - ) - } - } -} - -@Composable -private fun ToolStatusChip( - label: String, - count: Int, - total: Int, - isLoading: Boolean, - color: Color, - modifier: Modifier = Modifier -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - // Status indicator dot - Box( - modifier = Modifier - .size(8.dp) - .background( - color = if (isLoading) JewelTheme.globalColors.text.info.copy(alpha = 0.5f) else color, - shape = CircleShape - ) - ) - - val totalDisplay = if (total < 0) "∞" else total.toString() - Text( - text = "$label ($count/$totalDisplay)", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 11.sp, - color = if (isLoading) - JewelTheme.globalColors.text.info.copy(alpha = 0.7f) - else - JewelTheme.globalColors.text.info - ) ) } } @@ -671,9 +285,10 @@ private fun ToolStatusChip( * - Token usage display * - Settings access * - Stop/Send button based on execution state + * - Model selector for switching between LLM configurations */ @Composable -private fun DevInInputArea( +private fun IdeaDevInInputArea( project: Project, parentDisposable: Disposable, isProcessing: Boolean, @@ -682,19 +297,23 @@ private fun DevInInputArea( workspacePath: String? = null, totalTokens: Int? = null, onSettingsClick: () -> Unit = {}, - onAtClick: () -> Unit = {} + onAtClick: () -> Unit = {}, + availableConfigs: List = emptyList(), + currentConfigName: String? = null, + onConfigSelect: (NamedModelConfig) -> Unit = {}, + onConfigureClick: () -> Unit = {} ) { var inputText by remember { mutableStateOf("") } var devInInput by remember { mutableStateOf(null) } Column( - modifier = Modifier.fillMaxWidth().padding(8.dp) + modifier = Modifier.fillMaxSize().padding(8.dp) ) { - // DevIn Editor via SwingPanel + // DevIn Editor via SwingPanel - uses weight(1f) to fill available space SwingPanel( modifier = Modifier .fillMaxWidth() - .height(120.dp), + .weight(1f), factory = { val input = IdeaDevInInput( project = project, @@ -730,11 +349,11 @@ private fun DevInInputArea( Disposer.register(parentDisposable, input) devInInput = input - // Wrap in a JPanel to handle sizing + // Wrap in a JPanel to handle dynamic sizing JPanel(BorderLayout()).apply { add(input, BorderLayout.CENTER) - preferredSize = Dimension(800, 120) - minimumSize = Dimension(200, 80) + // Don't set fixed preferredSize - let it fill available space + minimumSize = Dimension(200, 60) } }, update = { panel -> @@ -755,13 +374,12 @@ private fun DevInInputArea( sendEnabled = inputText.isNotBlank() && !isProcessing, isExecuting = isProcessing, onStopClick = onAbort, - onAtClick = { - devInInput?.appendText("@") - onAtClick() - }, onSettingsClick = onSettingsClick, - workspacePath = workspacePath, - totalTokens = totalTokens + totalTokens = totalTokens, + availableConfigs = availableConfigs, + currentConfigName = currentConfigName, + onConfigSelect = onConfigSelect, + onConfigureClick = onConfigureClick ) } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt index d4e8b3fd9f..b94a41189a 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt @@ -43,7 +43,8 @@ class IdeaAgentViewModel( val renderer = JewelRenderer() // Current agent type tab (using mpp-core's AgentType) - private val _currentAgentType = MutableStateFlow(AgentType.CODING) + // Initialize with value from config to avoid flicker + private val _currentAgentType = MutableStateFlow(loadInitialAgentType()) val currentAgentType: StateFlow = _currentAgentType.asStateFlow() // Is executing a task @@ -90,6 +91,24 @@ class IdeaAgentViewModel( loadConfiguration() } + /** + * Load initial agent type synchronously to avoid UI flicker. + * This is called during initialization before the UI is rendered. + */ + private fun loadInitialAgentType(): AgentType { + return try { + // Use runBlocking to load config synchronously during initialization + // This is acceptable here as it only happens once during ViewModel creation + runBlocking { + val wrapper = ConfigManager.load() + wrapper.getAgentType() + } + } catch (e: Exception) { + // If config doesn't exist or is invalid, default to CODING + AgentType.CODING + } + } + /** * Load configuration from ConfigManager (~/.autodev/config.yaml) */ @@ -108,8 +127,8 @@ class IdeaAgentViewModel( startMcpPreloading() } - // Set agent type from config - _currentAgentType.value = wrapper.getAgentType() + // Agent type is already loaded in initialization, no need to update again + // This prevents the flicker issue where the tab changes after UI is rendered } catch (e: Exception) { // Config file doesn't exist or is invalid, use defaults _configWrapper.value = null @@ -177,10 +196,27 @@ class IdeaAgentViewModel( } /** - * Change the current agent type tab. + * Change the current agent type tab and persist to config. */ fun onAgentTypeChange(agentType: AgentType) { _currentAgentType.value = agentType + + // Save to config file for persistence + coroutineScope.launch { + try { + val typeString = when (agentType) { + AgentType.REMOTE -> "Remote" + AgentType.LOCAL_CHAT -> "Local" + AgentType.CODING -> "Coding" + AgentType.CODE_REVIEW -> "CodeReview" + AgentType.KNOWLEDGE -> "Documents" + } + cc.unitmesh.devins.ui.config.saveAgentTypePreference(typeString) + } catch (e: Exception) { + // Silently fail - not critical if we can't save preference + println("⚠️ Failed to save agent type preference: ${e.message}") + } + } } /** @@ -430,6 +466,21 @@ class IdeaAgentViewModel( _showConfigDialog.value = show } + /** + * Set the active configuration by name + */ + fun setActiveConfig(configName: String) { + coroutineScope.launch { + try { + ConfigManager.setActive(configName) + // Reload configuration after changing active config + reloadConfiguration() + } catch (e: Exception) { + renderer.renderError("Failed to set active configuration: ${e.message}") + } + } + } + /** * Check if configuration is valid for LLM calls */ diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt new file mode 100644 index 0000000000..5ffb0dbc7f --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt @@ -0,0 +1,1544 @@ +package cc.unitmesh.devins.idea.toolwindow + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +/** + * Icon provider for IntelliJ IDEA Compose UI. + * These icons are defined using ImageVector paths to avoid dependency on Material Icons + * which is not available in IntelliJ's Compose environment. + */ +object IdeaComposeIcons { + + /** + * Settings icon (gear/cog) + */ + val Settings: ImageVector by lazy { + ImageVector.Builder( + name = "Settings", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.EvenOdd + ) { + // Gear icon path + moveTo(19.14f, 12.94f) + curveToRelative(0.04f, -0.31f, 0.06f, -0.63f, 0.06f, -0.94f) + curveToRelative(0f, -0.32f, -0.02f, -0.64f, -0.07f, -0.94f) + lineToRelative(2.03f, -1.58f) + curveToRelative(0.18f, -0.14f, 0.23f, -0.41f, 0.12f, -0.61f) + lineToRelative(-1.92f, -3.32f) + curveToRelative(-0.12f, -0.22f, -0.37f, -0.29f, -0.59f, -0.22f) + lineToRelative(-2.39f, 0.96f) + curveToRelative(-0.5f, -0.38f, -1.03f, -0.7f, -1.62f, -0.94f) + lineToRelative(-0.36f, -2.54f) + curveToRelative(-0.04f, -0.24f, -0.24f, -0.41f, -0.48f, -0.41f) + horizontalLineToRelative(-3.84f) + curveToRelative(-0.24f, 0f, -0.43f, 0.17f, -0.47f, 0.41f) + lineToRelative(-0.36f, 2.54f) + curveToRelative(-0.59f, 0.24f, -1.13f, 0.56f, -1.62f, 0.94f) + lineToRelative(-2.39f, -0.96f) + curveToRelative(-0.22f, -0.08f, -0.47f, 0f, -0.59f, 0.22f) + lineTo(2.74f, 8.87f) + curveToRelative(-0.12f, 0.21f, -0.08f, 0.47f, 0.12f, 0.61f) + lineToRelative(2.03f, 1.58f) + curveToRelative(-0.05f, 0.3f, -0.09f, 0.63f, -0.09f, 0.94f) + curveToRelative(0f, 0.31f, 0.02f, 0.64f, 0.07f, 0.94f) + lineToRelative(-2.03f, 1.58f) + curveToRelative(-0.18f, 0.14f, -0.23f, 0.41f, -0.12f, 0.61f) + lineToRelative(1.92f, 3.32f) + curveToRelative(0.12f, 0.22f, 0.37f, 0.29f, 0.59f, 0.22f) + lineToRelative(2.39f, -0.96f) + curveToRelative(0.5f, 0.38f, 1.03f, 0.7f, 1.62f, 0.94f) + lineToRelative(0.36f, 2.54f) + curveToRelative(0.05f, 0.24f, 0.24f, 0.41f, 0.48f, 0.41f) + horizontalLineToRelative(3.84f) + curveToRelative(0.24f, 0f, 0.44f, -0.17f, 0.47f, -0.41f) + lineToRelative(0.36f, -2.54f) + curveToRelative(0.59f, -0.24f, 1.13f, -0.56f, 1.62f, -0.94f) + lineToRelative(2.39f, 0.96f) + curveToRelative(0.22f, 0.08f, 0.47f, 0f, 0.59f, -0.22f) + lineToRelative(1.92f, -3.32f) + curveToRelative(0.12f, -0.22f, 0.07f, -0.47f, -0.12f, -0.61f) + lineToRelative(-2.01f, -1.58f) + close() + moveTo(12f, 15.6f) + curveToRelative(-1.98f, 0f, -3.6f, -1.62f, -3.6f, -3.6f) + reflectiveCurveToRelative(1.62f, -3.6f, 3.6f, -3.6f) + reflectiveCurveToRelative(3.6f, 1.62f, 3.6f, 3.6f) + reflectiveCurveToRelative(-1.62f, 3.6f, -3.6f, 3.6f) + close() + } + }.build() + } + + /** + * Build/Tool icon (wrench) + */ + val Build: ImageVector by lazy { + ImageVector.Builder( + name = "Build", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(22.7f, 19f) + lineToRelative(-9.1f, -9.1f) + curveToRelative(0.9f, -2.3f, 0.4f, -5f, -1.5f, -6.9f) + curveToRelative(-2f, -2f, -5f, -2.4f, -7.4f, -1.3f) + lineTo(9f, 6f) + lineTo(6f, 9f) + lineTo(1.6f, 4.7f) + curveTo(0.4f, 7.1f, 0.9f, 10.1f, 2.9f, 12.1f) + curveToRelative(1.9f, 1.9f, 4.6f, 2.4f, 6.9f, 1.5f) + lineToRelative(9.1f, 9.1f) + curveToRelative(0.4f, 0.4f, 1f, 0.4f, 1.4f, 0f) + lineToRelative(2.3f, -2.3f) + curveToRelative(0.5f, -0.4f, 0.5f, -1.1f, 0.1f, -1.4f) + close() + } + }.build() + } + + /** + * Error icon (circle with X) + */ + val Error: ImageVector by lazy { + ImageVector.Builder( + name = "Error", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(12f, 2f) + curveTo(6.48f, 2f, 2f, 6.48f, 2f, 12f) + reflectiveCurveToRelative(4.48f, 10f, 10f, 10f) + reflectiveCurveToRelative(10f, -4.48f, 10f, -10f) + reflectiveCurveTo(17.52f, 2f, 12f, 2f) + close() + moveTo(13f, 17f) + horizontalLineToRelative(-2f) + verticalLineToRelative(-2f) + horizontalLineToRelative(2f) + verticalLineToRelative(2f) + close() + moveTo(13f, 13f) + horizontalLineToRelative(-2f) + lineTo(11f, 7f) + horizontalLineToRelative(2f) + verticalLineToRelative(6f) + close() + } + }.build() + } + + /** + * CheckCircle icon (circle with checkmark) + */ + val CheckCircle: ImageVector by lazy { + ImageVector.Builder( + name = "CheckCircle", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(12f, 2f) + curveTo(6.48f, 2f, 2f, 6.48f, 2f, 12f) + reflectiveCurveToRelative(4.48f, 10f, 10f, 10f) + reflectiveCurveToRelative(10f, -4.48f, 10f, -10f) + reflectiveCurveTo(17.52f, 2f, 12f, 2f) + close() + moveTo(10f, 17f) + lineToRelative(-5f, -5f) + lineToRelative(1.41f, -1.41f) + lineTo(10f, 14.17f) + lineToRelative(7.59f, -7.59f) + lineTo(19f, 8f) + lineToRelative(-9f, 9f) + close() + } + }.build() + } + + /** + * Refresh icon (circular arrow) + */ + val Refresh: ImageVector by lazy { + ImageVector.Builder( + name = "Refresh", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(17.65f, 6.35f) + curveTo(16.2f, 4.9f, 14.21f, 4f, 12f, 4f) + curveToRelative(-4.42f, 0f, -7.99f, 3.58f, -7.99f, 8f) + reflectiveCurveToRelative(3.57f, 8f, 7.99f, 8f) + curveToRelative(3.73f, 0f, 6.84f, -2.55f, 7.73f, -6f) + horizontalLineToRelative(-2.08f) + curveToRelative(-0.82f, 2.33f, -3.04f, 4f, -5.65f, 4f) + curveToRelative(-3.31f, 0f, -6f, -2.69f, -6f, -6f) + reflectiveCurveToRelative(2.69f, -6f, 6f, -6f) + curveToRelative(1.66f, 0f, 3.14f, 0.69f, 4.22f, 1.78f) + lineTo(13f, 11f) + horizontalLineToRelative(7f) + lineTo(20f, 4f) + lineToRelative(-2.35f, 2.35f) + close() + } + }.build() + } + + /** + * Description icon (document) + */ + val Description: ImageVector by lazy { + ImageVector.Builder( + name = "Description", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(14f, 2f) + lineTo(6f, 2f) + curveToRelative(-1.1f, 0f, -1.99f, 0.9f, -1.99f, 2f) + lineTo(4f, 20f) + curveToRelative(0f, 1.1f, 0.89f, 2f, 1.99f, 2f) + lineTo(18f, 22f) + curveToRelative(1.1f, 0f, 2f, -0.9f, 2f, -2f) + lineTo(20f, 8f) + lineToRelative(-6f, -6f) + close() + moveTo(16f, 18f) + lineTo(8f, 18f) + verticalLineToRelative(-2f) + horizontalLineToRelative(8f) + verticalLineToRelative(2f) + close() + moveTo(16f, 14f) + lineTo(8f, 14f) + verticalLineToRelative(-2f) + horizontalLineToRelative(8f) + verticalLineToRelative(2f) + close() + moveTo(13f, 9f) + lineTo(13f, 3.5f) + lineTo(18.5f, 9f) + lineTo(13f, 9f) + close() + } + }.build() + } + + /** + * Code icon (angle brackets) + */ + val Code: ImageVector by lazy { + ImageVector.Builder( + name = "Code", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(9.4f, 16.6f) + lineTo(4.8f, 12f) + lineToRelative(4.6f, -4.6f) + lineTo(8f, 6f) + lineToRelative(-6f, 6f) + lineToRelative(6f, 6f) + lineToRelative(1.4f, -1.4f) + close() + moveTo(14.6f, 16.6f) + lineToRelative(4.6f, -4.6f) + lineToRelative(-4.6f, -4.6f) + lineTo(16f, 6f) + lineToRelative(6f, 6f) + lineToRelative(-6f, 6f) + lineToRelative(-1.4f, -1.4f) + close() + } + }.build() + } + + /** + * Delete icon (trash can) + */ + val Delete: ImageVector by lazy { + ImageVector.Builder( + name = "Delete", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(6f, 19f) + curveToRelative(0f, 1.1f, 0.9f, 2f, 2f, 2f) + horizontalLineToRelative(8f) + curveToRelative(1.1f, 0f, 2f, -0.9f, 2f, -2f) + lineTo(18f, 7f) + lineTo(6f, 7f) + verticalLineToRelative(12f) + close() + moveTo(19f, 4f) + horizontalLineToRelative(-3.5f) + lineToRelative(-1f, -1f) + horizontalLineToRelative(-5f) + lineToRelative(-1f, 1f) + lineTo(5f, 4f) + verticalLineToRelative(2f) + horizontalLineToRelative(14f) + lineTo(19f, 4f) + close() + } + }.build() + } + + /** + * PlayArrow icon (triangle pointing right) + */ + val PlayArrow: ImageVector by lazy { + ImageVector.Builder( + name = "PlayArrow", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(8f, 5f) + verticalLineToRelative(14f) + lineToRelative(11f, -7f) + close() + } + }.build() + } + + /** + * ExpandLess icon (chevron up) + */ + val ExpandLess: ImageVector by lazy { + ImageVector.Builder( + name = "ExpandLess", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(12f, 8f) + lineToRelative(-6f, 6f) + lineToRelative(1.41f, 1.41f) + lineTo(12f, 10.83f) + lineToRelative(4.59f, 4.58f) + lineTo(18f, 14f) + close() + } + }.build() + } + + /** + * ExpandMore icon (chevron down) + */ + val ExpandMore: ImageVector by lazy { + ImageVector.Builder( + name = "ExpandMore", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(16.59f, 8.59f) + lineTo(12f, 13.17f) + lineTo(7.41f, 8.59f) + lineTo(6f, 10f) + lineToRelative(6f, 6f) + lineToRelative(6f, -6f) + close() + } + }.build() + } + + /** + * ContentCopy icon (two overlapping rectangles) + */ + val ContentCopy: ImageVector by lazy { + ImageVector.Builder( + name = "ContentCopy", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(16f, 1f) + lineTo(4f, 1f) + curveToRelative(-1.1f, 0f, -2f, 0.9f, -2f, 2f) + verticalLineToRelative(14f) + horizontalLineToRelative(2f) + lineTo(4f, 3f) + horizontalLineToRelative(12f) + lineTo(16f, 1f) + close() + moveTo(19f, 5f) + lineTo(8f, 5f) + curveToRelative(-1.1f, 0f, -2f, 0.9f, -2f, 2f) + verticalLineToRelative(14f) + curveToRelative(0f, 1.1f, 0.9f, 2f, 2f, 2f) + horizontalLineToRelative(11f) + curveToRelative(1.1f, 0f, 2f, -0.9f, 2f, -2f) + lineTo(21f, 7f) + curveToRelative(0f, -1.1f, -0.9f, -2f, -2f, -2f) + close() + moveTo(19f, 21f) + lineTo(8f, 21f) + lineTo(8f, 7f) + horizontalLineToRelative(11f) + verticalLineToRelative(14f) + close() + } + }.build() + } + + /** + * Terminal icon (command prompt) + */ + val Terminal: ImageVector by lazy { + ImageVector.Builder( + name = "Terminal", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(20f, 4f) + lineTo(4f, 4f) + curveToRelative(-1.1f, 0f, -2f, 0.9f, -2f, 2f) + verticalLineToRelative(12f) + curveToRelative(0f, 1.1f, 0.9f, 2f, 2f, 2f) + horizontalLineToRelative(16f) + curveToRelative(1.1f, 0f, 2f, -0.9f, 2f, -2f) + lineTo(22f, 6f) + curveToRelative(0f, -1.1f, -0.9f, -2f, -2f, -2f) + close() + moveTo(20f, 18f) + lineTo(4f, 18f) + lineTo(4f, 8f) + horizontalLineToRelative(16f) + verticalLineToRelative(10f) + close() + // Terminal prompt arrow + moveTo(5.5f, 11.5f) + lineToRelative(3f, 2.5f) + lineToRelative(-3f, 2.5f) + close() + // Cursor line + moveTo(10f, 15f) + horizontalLineToRelative(8f) + verticalLineToRelative(1.5f) + horizontalLineToRelative(-8f) + close() + } + }.build() + } + + /** + * Stop icon (square) + */ + val Stop: ImageVector by lazy { + ImageVector.Builder( + name = "Stop", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(6f, 6f) + horizontalLineToRelative(12f) + verticalLineToRelative(12f) + lineTo(6f, 18f) + close() + } + }.build() + } + + /** + * Send icon (paper plane / arrow pointing right) + */ + val Send: ImageVector by lazy { + ImageVector.Builder( + name = "Send", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(2.01f, 21f) + lineTo(23f, 12f) + lineTo(2.01f, 3f) + lineTo(2f, 10f) + lineToRelative(15f, 2f) + lineToRelative(-15f, 2f) + close() + } + }.build() + } + + /** + * Folder icon + */ + val Folder: ImageVector by lazy { + ImageVector.Builder( + name = "Folder", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(10f, 4f) + lineTo(12f, 6f) + lineTo(20f, 6f) + curveToRelative(1.1f, 0f, 2f, 0.9f, 2f, 2f) + verticalLineToRelative(10f) + curveToRelative(0f, 1.1f, -0.9f, 2f, -2f, 2f) + lineTo(4f, 20f) + curveToRelative(-1.1f, 0f, -2f, -0.9f, -2f, -2f) + lineTo(2f, 6f) + curveToRelative(0f, -1.1f, 0.9f, -2f, 2f, -2f) + horizontalLineToRelative(6f) + close() + } + }.build() + } + + /** + * AlternateEmail icon (@ symbol) + */ + val AlternateEmail: ImageVector by lazy { + ImageVector.Builder( + name = "AlternateEmail", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(12f, 2f) + curveTo(6.48f, 2f, 2f, 6.48f, 2f, 12f) + reflectiveCurveToRelative(4.48f, 10f, 10f, 10f) + horizontalLineToRelative(5f) + verticalLineToRelative(-2f) + horizontalLineToRelative(-5f) + curveToRelative(-4.34f, 0f, -8f, -3.66f, -8f, -8f) + reflectiveCurveToRelative(3.66f, -8f, 8f, -8f) + reflectiveCurveToRelative(8f, 3.66f, 8f, 8f) + verticalLineToRelative(1.43f) + curveToRelative(0f, 0.79f, -0.71f, 1.57f, -1.5f, 1.57f) + reflectiveCurveToRelative(-1.5f, -0.78f, -1.5f, -1.57f) + lineTo(17f, 12f) + curveToRelative(0f, -2.76f, -2.24f, -5f, -5f, -5f) + reflectiveCurveToRelative(-5f, 2.24f, -5f, 5f) + reflectiveCurveToRelative(2.24f, 5f, 5f, 5f) + curveToRelative(1.38f, 0f, 2.64f, -0.56f, 3.54f, -1.47f) + curveToRelative(0.65f, 0.89f, 1.77f, 1.47f, 2.96f, 1.47f) + curveToRelative(1.97f, 0f, 3.5f, -1.6f, 3.5f, -3.57f) + lineTo(22f, 12f) + curveToRelative(0f, -5.52f, -4.48f, -10f, -10f, -10f) + close() + moveTo(12f, 15f) + curveToRelative(-1.66f, 0f, -3f, -1.34f, -3f, -3f) + reflectiveCurveToRelative(1.34f, -3f, 3f, -3f) + reflectiveCurveToRelative(3f, 1.34f, 3f, 3f) + reflectiveCurveToRelative(-1.34f, 3f, -3f, 3f) + close() + } + }.build() + } + + /** + * ArrowDropDown icon (dropdown arrow) + */ + val ArrowDropDown: ImageVector by lazy { + ImageVector.Builder( + name = "ArrowDropDown", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(7f, 10f) + lineToRelative(5f, 5f) + lineToRelative(5f, -5f) + close() + } + }.build() + } + + /** + * Check icon (checkmark) + */ + val Check: ImageVector by lazy { + ImageVector.Builder( + name = "Check", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(9f, 16.17f) + lineTo(4.83f, 12f) + lineToRelative(-1.42f, 1.41f) + lineTo(9f, 19f) + lineTo(21f, 7f) + lineToRelative(-1.41f, -1.41f) + close() + } + }.build() + } + + /** + * Visibility icon (eye open) + */ + val Visibility: ImageVector by lazy { + ImageVector.Builder( + name = "Visibility", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(12f, 4.5f) + curveTo(7f, 4.5f, 2.73f, 7.61f, 1f, 12f) + curveToRelative(1.73f, 4.39f, 6f, 7.5f, 11f, 7.5f) + reflectiveCurveToRelative(9.27f, -3.11f, 11f, -7.5f) + curveToRelative(-1.73f, -4.39f, -6f, -7.5f, -11f, -7.5f) + close() + moveTo(12f, 17f) + curveToRelative(-2.76f, 0f, -5f, -2.24f, -5f, -5f) + reflectiveCurveToRelative(2.24f, -5f, 5f, -5f) + reflectiveCurveToRelative(5f, 2.24f, 5f, 5f) + reflectiveCurveToRelative(-2.24f, 5f, -5f, 5f) + close() + moveTo(12f, 9f) + curveToRelative(-1.66f, 0f, -3f, 1.34f, -3f, 3f) + reflectiveCurveToRelative(1.34f, 3f, 3f, 3f) + reflectiveCurveToRelative(3f, -1.34f, 3f, -3f) + reflectiveCurveToRelative(-1.34f, -3f, -3f, -3f) + close() + } + }.build() + } + + /** + * VisibilityOff icon (eye closed) + */ + val VisibilityOff: ImageVector by lazy { + ImageVector.Builder( + name = "VisibilityOff", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(12f, 7f) + curveToRelative(2.76f, 0f, 5f, 2.24f, 5f, 5f) + curveToRelative(0f, 0.65f, -0.13f, 1.26f, -0.36f, 1.83f) + lineToRelative(2.92f, 2.92f) + curveToRelative(1.51f, -1.26f, 2.7f, -2.89f, 3.43f, -4.75f) + curveToRelative(-1.73f, -4.39f, -6f, -7.5f, -11f, -7.5f) + curveToRelative(-1.4f, 0f, -2.74f, 0.25f, -3.98f, 0.7f) + lineToRelative(2.16f, 2.16f) + curveTo(10.74f, 7.13f, 11.35f, 7f, 12f, 7f) + close() + moveTo(2f, 4.27f) + lineToRelative(2.28f, 2.28f) + lineToRelative(0.46f, 0.46f) + curveTo(3.08f, 8.3f, 1.78f, 10.02f, 1f, 12f) + curveToRelative(1.73f, 4.39f, 6f, 7.5f, 11f, 7.5f) + curveToRelative(1.55f, 0f, 3.03f, -0.3f, 4.38f, -0.84f) + lineToRelative(0.42f, 0.42f) + lineTo(19.73f, 22f) + lineTo(21f, 20.73f) + lineTo(3.27f, 3f) + lineTo(2f, 4.27f) + close() + moveTo(7.53f, 9.8f) + lineToRelative(1.55f, 1.55f) + curveToRelative(-0.05f, 0.21f, -0.08f, 0.43f, -0.08f, 0.65f) + curveToRelative(0f, 1.66f, 1.34f, 3f, 3f, 3f) + curveToRelative(0.22f, 0f, 0.44f, -0.03f, 0.65f, -0.08f) + lineToRelative(1.55f, 1.55f) + curveToRelative(-0.67f, 0.33f, -1.41f, 0.53f, -2.2f, 0.53f) + curveToRelative(-2.76f, 0f, -5f, -2.24f, -5f, -5f) + curveToRelative(0f, -0.79f, 0.2f, -1.53f, 0.53f, -2.2f) + close() + moveTo(11.84f, 9.02f) + lineToRelative(3.15f, 3.15f) + lineToRelative(0.02f, -0.16f) + curveToRelative(0f, -1.66f, -1.34f, -3f, -3f, -3f) + lineToRelative(-0.17f, 0.01f) + close() + } + }.build() + } + + /** + * Add/Plus icon + */ + val Add: ImageVector by lazy { + ImageVector.Builder( + name = "Add", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(19f, 13f) + horizontalLineToRelative(-6f) + verticalLineToRelative(6f) + horizontalLineToRelative(-2f) + verticalLineToRelative(-6f) + horizontalLineTo(5f) + verticalLineToRelative(-2f) + horizontalLineToRelative(6f) + verticalLineTo(5f) + horizontalLineToRelative(2f) + verticalLineToRelative(6f) + horizontalLineToRelative(6f) + close() + } + }.build() + } + + /** + * Review icon (magnifying glass with checkmark) + */ + val Review: ImageVector by lazy { + ImageVector.Builder( + name = "Review", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + // Magnifying glass (fill path only, checkmark is drawn separately as stroke) + path( + fill = SolidColor(Color.Black) + ) { + moveTo(15.5f, 14f) + horizontalLineToRelative(-0.79f) + lineToRelative(-0.28f, -0.27f) + curveToRelative(1.2f, -1.4f, 1.82f, -3.31f, 1.48f, -5.34f) + curveToRelative(-0.47f, -2.78f, -2.79f, -5f, -5.59f, -5.34f) + curveToRelative(-4.23f, -0.52f, -7.79f, 3.04f, -7.27f, 7.27f) + curveToRelative(0.34f, 2.8f, 2.56f, 5.12f, 5.34f, 5.59f) + curveToRelative(2.03f, 0.34f, 3.94f, -0.28f, 5.34f, -1.48f) + lineToRelative(0.27f, 0.28f) + verticalLineToRelative(0.79f) + lineToRelative(4.25f, 4.25f) + curveToRelative(0.41f, 0.41f, 1.08f, 0.41f, 1.49f, 0f) + curveToRelative(0.41f, -0.41f, 0.41f, -1.08f, 0f, -1.49f) + lineTo(15.5f, 14f) + close() + moveTo(9.5f, 14f) + curveToRelative(-2.49f, 0f, -4.5f, -2.01f, -4.5f, -4.5f) + reflectiveCurveTo(7.01f, 5f, 9.5f, 5f) + reflectiveCurveTo(14f, 7.01f, 14f, 9.5f) + reflectiveCurveTo(11.99f, 14f, 9.5f, 14f) + close() + } + // Checkmark inside (stroke only to avoid visual artifacts) + path( + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(7.5f, 9.5f) + lineToRelative(1.5f, 1.5f) + lineToRelative(3f, -3f) + } + }.build() + } + + /** + * Book icon for Knowledge + */ + val Book: ImageVector by lazy { + ImageVector.Builder( + name = "Book", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(18f, 2f) + horizontalLineTo(6f) + curveToRelative(-1.1f, 0f, -2f, 0.9f, -2f, 2f) + verticalLineToRelative(16f) + curveToRelative(0f, 1.1f, 0.9f, 2f, 2f, 2f) + horizontalLineToRelative(12f) + curveToRelative(1.1f, 0f, 2f, -0.9f, 2f, -2f) + verticalLineTo(4f) + curveToRelative(0f, -1.1f, -0.9f, -2f, -2f, -2f) + close() + moveTo(6f, 4f) + horizontalLineToRelative(5f) + verticalLineToRelative(8f) + lineToRelative(-2.5f, -1.5f) + lineTo(6f, 12f) + verticalLineTo(4f) + close() + } + }.build() + } + + /** + * Cloud icon for Remote + */ + val Cloud: ImageVector by lazy { + ImageVector.Builder( + name = "Cloud", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(19.35f, 10.04f) + curveToRelative(-0.68f, -3.45f, -3.71f, -6.04f, -7.35f, -6.04f) + curveToRelative(-2.89f, 0f, -5.4f, 1.64f, -6.65f, 4.04f) + curveToRelative(-3.01f, 0.32f, -5.35f, 2.87f, -5.35f, 5.96f) + curveToRelative(0f, 3.31f, 2.69f, 6f, 6f, 6f) + horizontalLineToRelative(13f) + curveToRelative(2.76f, 0f, 5f, -2.24f, 5f, -5f) + curveToRelative(0f, -2.64f, -2.05f, -4.78f, -4.65f, -4.96f) + close() + } + }.build() + } + + /** + * Chat icon + */ + val Chat: ImageVector by lazy { + ImageVector.Builder( + name = "Chat", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(20f, 2f) + horizontalLineTo(4f) + curveToRelative(-1.1f, 0f, -2f, 0.9f, -2f, 2f) + verticalLineToRelative(18f) + lineToRelative(4f, -4f) + horizontalLineToRelative(14f) + curveToRelative(1.1f, 0f, 2f, -0.9f, 2f, -2f) + verticalLineTo(4f) + curveToRelative(0f, -1.1f, -0.9f, -2f, -2f, -2f) + close() + } + }.build() + } + + /** + * List icon (horizontal lines) + */ + val List: ImageVector by lazy { + ImageVector.Builder( + name = "List", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(3f, 13f) + horizontalLineToRelative(2f) + verticalLineToRelative(-2f) + horizontalLineTo(3f) + verticalLineToRelative(2f) + close() + moveTo(3f, 17f) + horizontalLineToRelative(2f) + verticalLineToRelative(-2f) + horizontalLineTo(3f) + verticalLineToRelative(2f) + close() + moveTo(3f, 9f) + horizontalLineToRelative(2f) + verticalLineTo(7f) + horizontalLineTo(3f) + verticalLineToRelative(2f) + close() + moveTo(7f, 13f) + horizontalLineToRelative(14f) + verticalLineToRelative(-2f) + horizontalLineTo(7f) + verticalLineToRelative(2f) + close() + moveTo(7f, 17f) + horizontalLineToRelative(14f) + verticalLineToRelative(-2f) + horizontalLineTo(7f) + verticalLineToRelative(2f) + close() + moveTo(7f, 7f) + verticalLineToRelative(2f) + horizontalLineToRelative(14f) + verticalLineTo(7f) + horizontalLineTo(7f) + close() + } + }.build() + } + + /** + * AccountTree icon (tree structure) + */ + val AccountTree: ImageVector by lazy { + ImageVector.Builder( + name = "AccountTree", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(22f, 11f) + verticalLineTo(3f) + horizontalLineToRelative(-8f) + verticalLineToRelative(3f) + horizontalLineTo(9f) + verticalLineTo(3f) + horizontalLineTo(1f) + verticalLineToRelative(8f) + horizontalLineToRelative(3f) + verticalLineToRelative(6f) + horizontalLineTo(1f) + verticalLineToRelative(8f) + horizontalLineToRelative(8f) + verticalLineToRelative(-8f) + horizontalLineTo(6f) + verticalLineToRelative(-6f) + horizontalLineToRelative(5f) + verticalLineToRelative(3f) + horizontalLineToRelative(8f) + verticalLineToRelative(-3f) + horizontalLineToRelative(3f) + close() + } + }.build() + } + + /** + * Edit icon (pencil) + */ + val Edit: ImageVector by lazy { + ImageVector.Builder( + name = "Edit", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(3f, 17.25f) + verticalLineTo(21f) + horizontalLineToRelative(3.75f) + lineTo(17.81f, 9.94f) + lineToRelative(-3.75f, -3.75f) + lineTo(3f, 17.25f) + close() + moveTo(20.71f, 7.04f) + curveToRelative(0.39f, -0.39f, 0.39f, -1.02f, 0f, -1.41f) + lineToRelative(-2.34f, -2.34f) + curveToRelative(-0.39f, -0.39f, -1.02f, -0.39f, -1.41f, 0f) + lineToRelative(-1.83f, 1.83f) + lineToRelative(3.75f, 3.75f) + lineToRelative(1.83f, -1.83f) + close() + } + }.build() + } + + /** + * DriveFileRenameOutline icon (rename) + */ + val DriveFileRenameOutline: ImageVector by lazy { + ImageVector.Builder( + name = "DriveFileRenameOutline", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(18.41f, 5.8f) + lineTo(17.2f, 4.59f) + curveToRelative(-0.78f, -0.78f, -2.05f, -0.78f, -2.83f, 0f) + lineToRelative(-2.68f, 2.68f) + lineTo(3f, 15.96f) + verticalLineTo(20f) + horizontalLineToRelative(4.04f) + lineToRelative(8.74f, -8.74f) + lineToRelative(2.63f, -2.63f) + curveToRelative(0.79f, -0.79f, 0.79f, -2.05f, 0f, -2.83f) + close() + moveTo(6.21f, 18f) + horizontalLineTo(5f) + verticalLineToRelative(-1.21f) + lineToRelative(8.66f, -8.66f) + lineToRelative(1.21f, 1.21f) + lineTo(6.21f, 18f) + close() + } + }.build() + } + + /** + * FolderOpen icon + */ + val FolderOpen: ImageVector by lazy { + ImageVector.Builder( + name = "FolderOpen", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(20f, 6f) + horizontalLineToRelative(-8f) + lineToRelative(-2f, -2f) + horizontalLineTo(4f) + curveToRelative(-1.1f, 0f, -1.99f, 0.9f, -1.99f, 2f) + lineTo(2f, 18f) + curveToRelative(0f, 1.1f, 0.9f, 2f, 2f, 2f) + horizontalLineToRelative(16f) + curveToRelative(1.1f, 0f, 2f, -0.9f, 2f, -2f) + verticalLineTo(8f) + curveToRelative(0f, -1.1f, -0.9f, -2f, -2f, -2f) + close() + moveTo(20f, 18f) + horizontalLineTo(4f) + verticalLineTo(8f) + horizontalLineToRelative(16f) + verticalLineToRelative(10f) + close() + } + }.build() + } + + /** + * ChevronRight icon + */ + val ChevronRight: ImageVector by lazy { + ImageVector.Builder( + name = "ChevronRight", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(10f, 6f) + lineTo(8.59f, 7.41f) + lineTo(13.17f, 12f) + lineToRelative(-4.58f, 4.59f) + lineTo(10f, 18f) + lineToRelative(6f, -6f) + close() + } + }.build() + } + + /** + * BugReport icon + */ + val BugReport: ImageVector by lazy { + ImageVector.Builder( + name = "BugReport", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(20f, 8f) + horizontalLineToRelative(-2.81f) + curveToRelative(-0.45f, -0.78f, -1.07f, -1.45f, -1.82f, -1.96f) + lineTo(17f, 4.41f) + lineTo(15.59f, 3f) + lineToRelative(-2.17f, 2.17f) + curveTo(12.96f, 5.06f, 12.49f, 5f, 12f, 5f) + curveToRelative(-0.49f, 0f, -0.96f, 0.06f, -1.41f, 0.17f) + lineTo(8.41f, 3f) + lineTo(7f, 4.41f) + lineToRelative(1.62f, 1.63f) + curveTo(7.88f, 6.55f, 7.26f, 7.22f, 6.81f, 8f) + horizontalLineTo(4f) + verticalLineToRelative(2f) + horizontalLineToRelative(2.09f) + curveTo(6.04f, 10.33f, 6f, 10.66f, 6f, 11f) + verticalLineToRelative(1f) + horizontalLineTo(4f) + verticalLineToRelative(2f) + horizontalLineToRelative(2f) + verticalLineToRelative(1f) + curveToRelative(0f, 0.34f, 0.04f, 0.67f, 0.09f, 1f) + horizontalLineTo(4f) + verticalLineToRelative(2f) + horizontalLineToRelative(2.81f) + curveTo(8.47f, 19.87f, 10.1f, 21f, 12f, 21f) + reflectiveCurveToRelative(3.53f, -1.13f, 5.19f, -3f) + horizontalLineTo(20f) + verticalLineToRelative(-2f) + horizontalLineToRelative(-2.09f) + curveToRelative(0.05f, -0.33f, 0.09f, -0.66f, 0.09f, -1f) + verticalLineToRelative(-1f) + horizontalLineToRelative(2f) + verticalLineToRelative(-2f) + horizontalLineToRelative(-2f) + verticalLineToRelative(-1f) + curveToRelative(0f, -0.34f, -0.04f, -0.67f, -0.09f, -1f) + horizontalLineTo(20f) + verticalLineTo(8f) + close() + moveTo(14f, 16f) + horizontalLineToRelative(-4f) + verticalLineToRelative(-2f) + horizontalLineToRelative(4f) + verticalLineToRelative(2f) + close() + moveTo(14f, 12f) + horizontalLineToRelative(-4f) + verticalLineToRelative(-2f) + horizontalLineToRelative(4f) + verticalLineToRelative(2f) + close() + } + }.build() + } + + /** + * Info icon + */ + val Info: ImageVector by lazy { + ImageVector.Builder( + name = "Info", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + moveTo(12f, 2f) + curveTo(6.48f, 2f, 2f, 6.48f, 2f, 12f) + reflectiveCurveToRelative(4.48f, 10f, 10f, 10f) + reflectiveCurveToRelative(10f, -4.48f, 10f, -10f) + reflectiveCurveTo(17.52f, 2f, 12f, 2f) + close() + moveTo(13f, 17f) + horizontalLineToRelative(-2f) + verticalLineToRelative(-6f) + horizontalLineToRelative(2f) + verticalLineToRelative(6f) + close() + moveTo(13f, 9f) + horizontalLineToRelative(-2f) + verticalLineTo(7f) + horizontalLineToRelative(2f) + verticalLineToRelative(2f) + close() + } + }.build() + } + + /** + * SmartToy icon (robot/AI model) + */ + val SmartToy: ImageVector by lazy { + ImageVector.Builder( + name = "SmartToy", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + // Robot head + moveTo(20f, 9f) + verticalLineTo(7f) + curveToRelative(0f, -1.1f, -0.9f, -2f, -2f, -2f) + horizontalLineToRelative(-3f) + curveToRelative(0f, -1.66f, -1.34f, -3f, -3f, -3f) + reflectiveCurveTo(9f, 3.34f, 9f, 5f) + horizontalLineTo(6f) + curveToRelative(-1.1f, 0f, -2f, 0.9f, -2f, 2f) + verticalLineToRelative(2f) + curveToRelative(-1.66f, 0f, -3f, 1.34f, -3f, 3f) + reflectiveCurveToRelative(1.34f, 3f, 3f, 3f) + verticalLineToRelative(4f) + curveToRelative(0f, 1.1f, 0.9f, 2f, 2f, 2f) + horizontalLineToRelative(12f) + curveToRelative(1.1f, 0f, 2f, -0.9f, 2f, -2f) + verticalLineToRelative(-4f) + curveToRelative(1.66f, 0f, 3f, -1.34f, 3f, -3f) + reflectiveCurveToRelative(-1.34f, -3f, -3f, -3f) + close() + // Left eye + moveTo(7.5f, 11.5f) + curveToRelative(0f, -0.83f, 0.67f, -1.5f, 1.5f, -1.5f) + reflectiveCurveToRelative(1.5f, 0.67f, 1.5f, 1.5f) + reflectiveCurveTo(9.83f, 13f, 9f, 13f) + reflectiveCurveToRelative(-1.5f, -0.67f, -1.5f, -1.5f) + close() + // Right eye + moveTo(13.5f, 11.5f) + curveToRelative(0f, -0.83f, 0.67f, -1.5f, 1.5f, -1.5f) + reflectiveCurveToRelative(1.5f, 0.67f, 1.5f, 1.5f) + reflectiveCurveTo(15.83f, 13f, 15f, 13f) + reflectiveCurveToRelative(-1.5f, -0.67f, -1.5f, -1.5f) + close() + // Mouth + moveTo(8f, 15f) + horizontalLineToRelative(8f) + verticalLineToRelative(2f) + horizontalLineTo(8f) + close() + } + }.build() + } + + /** + * AutoAwesome icon (sparkles/stars for AI/optimization) + */ + val AutoAwesome: ImageVector by lazy { + ImageVector.Builder( + name = "AutoAwesome", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + fill = SolidColor(Color.Black) + ) { + // Large star + moveTo(19f, 9f) + lineToRelative(1.25f, -2.75f) + lineTo(23f, 5f) + lineToRelative(-2.75f, -1.25f) + lineTo(19f, 1f) + lineToRelative(-1.25f, 2.75f) + lineTo(15f, 5f) + lineToRelative(2.75f, 1.25f) + close() + // Medium star + moveTo(19f, 15f) + lineToRelative(-1.25f, 2.75f) + lineTo(15f, 19f) + lineToRelative(2.75f, 1.25f) + lineTo(19f, 23f) + lineToRelative(1.25f, -2.75f) + lineTo(23f, 19f) + lineToRelative(-2.75f, -1.25f) + close() + // Large center star + moveTo(11.5f, 9.5f) + lineTo(9f, 4f) + lineTo(6.5f, 9.5f) + lineTo(1f, 12f) + lineToRelative(5.5f, 2.5f) + lineTo(9f, 20f) + lineToRelative(2.5f, -5.5f) + lineTo(17f, 12f) + close() + } + }.build() + } + + /** + * ContentPaste icon (clipboard paste) + */ + val ContentPaste: ImageVector by lazy { + ImageVector.Builder( + name = "ContentPaste", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(19f, 2f) + horizontalLineToRelative(-4.18f) + curveTo(14.4f, 0.84f, 13.3f, 0f, 12f, 0f) + curveToRelative(-1.3f, 0f, -2.4f, 0.84f, -2.82f, 2f) + horizontalLineTo(5f) + curveToRelative(-1.1f, 0f, -2f, 0.9f, -2f, 2f) + verticalLineToRelative(16f) + curveToRelative(0f, 1.1f, 0.9f, 2f, 2f, 2f) + horizontalLineToRelative(14f) + curveToRelative(1.1f, 0f, 2f, -0.9f, 2f, -2f) + verticalLineTo(4f) + curveToRelative(0f, -1.1f, -0.9f, -2f, -2f, -2f) + close() + moveTo(12f, 2f) + curveToRelative(0.55f, 0f, 1f, 0.45f, 1f, 1f) + reflectiveCurveToRelative(-0.45f, 1f, -1f, 1f) + reflectiveCurveToRelative(-1f, -0.45f, -1f, -1f) + reflectiveCurveToRelative(0.45f, -1f, 1f, -1f) + close() + moveTo(19f, 20f) + horizontalLineTo(5f) + verticalLineTo(4f) + horizontalLineToRelative(2f) + verticalLineToRelative(3f) + horizontalLineToRelative(10f) + verticalLineTo(4f) + horizontalLineToRelative(2f) + verticalLineToRelative(16f) + close() + } + }.build() + } + + /** + * Save icon (floppy disk) + */ + val Save: ImageVector by lazy { + ImageVector.Builder( + name = "Save", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(17f, 3f) + horizontalLineTo(5f) + curveToRelative(-1.11f, 0f, -2f, 0.9f, -2f, 2f) + verticalLineToRelative(14f) + curveToRelative(0f, 1.1f, 0.89f, 2f, 2f, 2f) + horizontalLineToRelative(14f) + curveToRelative(1.1f, 0f, 2f, -0.9f, 2f, -2f) + verticalLineTo(7f) + lineToRelative(-4f, -4f) + close() + moveTo(12f, 19f) + curveToRelative(-1.66f, 0f, -3f, -1.34f, -3f, -3f) + reflectiveCurveToRelative(1.34f, -3f, 3f, -3f) + reflectiveCurveToRelative(3f, 1.34f, 3f, 3f) + reflectiveCurveToRelative(-1.34f, 3f, -3f, 3f) + close() + moveTo(15f, 9f) + horizontalLineTo(5f) + verticalLineTo(5f) + horizontalLineToRelative(10f) + verticalLineToRelative(4f) + close() + } + }.build() + } + + /** + * TextFields icon (cursor/text selection) + */ + val TextFields: ImageVector by lazy { + ImageVector.Builder( + name = "TextFields", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(2.5f, 4f) + verticalLineToRelative(3f) + horizontalLineToRelative(5f) + verticalLineToRelative(12f) + horizontalLineToRelative(3f) + verticalLineTo(7f) + horizontalLineToRelative(5f) + verticalLineTo(4f) + horizontalLineTo(2.5f) + close() + moveTo(21.5f, 9f) + horizontalLineToRelative(-9f) + verticalLineToRelative(3f) + horizontalLineToRelative(3f) + verticalLineToRelative(7f) + horizontalLineToRelative(3f) + verticalLineToRelative(-7f) + horizontalLineToRelative(3f) + verticalLineTo(9f) + close() + } + }.build() + } + + /** + * InsertDriveFile icon (file document) + */ + val InsertDriveFile: ImageVector by lazy { + ImageVector.Builder( + name = "InsertDriveFile", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(6f, 2f) + curveToRelative(-1.1f, 0f, -1.99f, 0.9f, -1.99f, 2f) + lineTo(4f, 20f) + curveToRelative(0f, 1.1f, 0.89f, 2f, 1.99f, 2f) + horizontalLineTo(18f) + curveToRelative(1.1f, 0f, 2f, -0.9f, 2f, -2f) + verticalLineTo(8f) + lineToRelative(-6f, -6f) + horizontalLineTo(6f) + close() + moveTo(13f, 9f) + verticalLineTo(3.5f) + lineTo(18.5f, 9f) + horizontalLineTo(13f) + close() + } + }.build() + } + + /** + * Close icon (X) + */ + val Close: ImageVector by lazy { + ImageVector.Builder( + name = "Close", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(19f, 6.41f) + lineTo(17.59f, 5f) + lineTo(12f, 10.59f) + lineTo(6.41f, 5f) + lineTo(5f, 6.41f) + lineTo(10.59f, 12f) + lineTo(5f, 17.59f) + lineTo(6.41f, 19f) + lineTo(12f, 13.41f) + lineTo(17.59f, 19f) + lineTo(19f, 17.59f) + lineTo(13.41f, 12f) + close() + } + }.build() + } + +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaAnalysisComponents.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaAnalysisComponents.kt new file mode 100644 index 0000000000..2f073d58d7 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaAnalysisComponents.kt @@ -0,0 +1,218 @@ +package cc.unitmesh.devins.idea.toolwindow.codereview + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.agent.codereview.ModifiedCodeRange +import cc.unitmesh.agent.linter.LintFileResult +import cc.unitmesh.agent.linter.LintIssue +import cc.unitmesh.agent.linter.LintSeverity +import cc.unitmesh.devins.idea.renderer.sketch.IdeaSketchRenderer +import cc.unitmesh.devins.ui.compose.agent.codereview.AnalysisStage +import cc.unitmesh.devins.ui.compose.agent.codereview.CodeReviewState +import cc.unitmesh.devins.ui.compose.agent.codereview.DiffFileInfo +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import com.intellij.openapi.Disposable +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.Orientation +import org.jetbrains.jewel.ui.component.* + +@Composable +internal fun IdeaAIAnalysisPanel(state: CodeReviewState, viewModel: IdeaCodeReviewViewModel, parentDisposable: Disposable, modifier: Modifier = Modifier) { + val progress = state.aiProgress + Column(modifier = modifier.background(JewelTheme.globalColors.panelBackground)) { + IdeaAnalysisHeader(progress.stage, state.diffFiles.isNotEmpty(), { viewModel.startAnalysis() }, { viewModel.cancelAnalysis() }) + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) + state.error?.let { Text(it, style = JewelTheme.defaultTextStyle.copy(color = AutoDevColors.Red.c400, fontSize = 12.sp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) } + Box(modifier = Modifier.fillMaxSize().padding(8.dp)) { + if (progress.stage == AnalysisStage.IDLE && progress.lintResults.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Click 'Start Review' to analyze code changes with AI", style = JewelTheme.defaultTextStyle.copy(color = JewelTheme.globalColors.text.info, fontSize = 12.sp)) + } + } else { + LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (progress.lintResults.isNotEmpty() || progress.lintOutput.isNotEmpty()) { + item { IdeaLintAnalysisCard(progress.lintResults, progress.lintOutput, progress.stage == AnalysisStage.RUNNING_LINT, state.diffFiles, progress.modifiedCodeRanges) } + } + if (progress.analysisOutput.isNotEmpty()) { + item { IdeaAIAnalysisSection(progress.analysisOutput, progress.stage == AnalysisStage.ANALYZING_LINT, parentDisposable) } + } + if (progress.planOutput.isNotEmpty()) { + item { IdeaModificationPlanSection(progress.planOutput, progress.stage == AnalysisStage.GENERATING_PLAN, parentDisposable) } + } + if (progress.stage == AnalysisStage.WAITING_FOR_USER_INPUT) { + item { IdeaUserInputSection({ viewModel.proceedToGenerateFixes(it) }, { viewModel.cancelAnalysis() }) } + } + if (progress.fixRenderer != null || progress.stage == AnalysisStage.GENERATING_FIX) { + item { IdeaSuggestedFixesSection(progress.fixOutput, progress.stage == AnalysisStage.GENERATING_FIX, parentDisposable) } + } + } + } + } + } +} + +@Composable +internal fun IdeaAnalysisHeader(stage: AnalysisStage, hasDiffFiles: Boolean, onStartAnalysis: () -> Unit, onCancelAnalysis: () -> Unit) { + Row(modifier = Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text("AI Code Review", style = JewelTheme.defaultTextStyle.copy(fontWeight = FontWeight.Bold, fontSize = 14.sp)) + val (statusText, statusColor) = when (stage) { + AnalysisStage.IDLE -> "Ready" to JewelTheme.globalColors.text.info + AnalysisStage.RUNNING_LINT -> "Linting..." to AutoDevColors.Amber.c400 + AnalysisStage.ANALYZING_LINT -> "Analyzing..." to AutoDevColors.Blue.c400 + AnalysisStage.GENERATING_PLAN -> "Planning..." to AutoDevColors.Blue.c400 + AnalysisStage.WAITING_FOR_USER_INPUT -> "Awaiting Input" to AutoDevColors.Amber.c400 + AnalysisStage.GENERATING_FIX -> "Fixing..." to AutoDevColors.Indigo.c400 + AnalysisStage.COMPLETED -> "Done" to AutoDevColors.Green.c400 + AnalysisStage.ERROR -> "Error" to AutoDevColors.Red.c400 + } + if (stage != AnalysisStage.IDLE) { + Box(modifier = Modifier.background(statusColor.copy(alpha = 0.15f), RoundedCornerShape(4.dp)).padding(horizontal = 6.dp, vertical = 2.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + if (stage != AnalysisStage.COMPLETED && stage != AnalysisStage.ERROR) CircularProgressIndicator() + Text(statusText, style = JewelTheme.defaultTextStyle.copy(color = statusColor, fontSize = 11.sp, fontWeight = FontWeight.Medium)) + } + } + } + } + when (stage) { + AnalysisStage.IDLE, AnalysisStage.COMPLETED, AnalysisStage.ERROR -> DefaultButton(onClick = onStartAnalysis, enabled = hasDiffFiles) { Text("Start Review") } + else -> OutlinedButton(onClick = onCancelAnalysis) { Text("Cancel") } + } + } +} + +@Composable +internal fun IdeaLintAnalysisCard(lintResults: List, lintOutput: String, isActive: Boolean, diffFiles: List, modifiedCodeRanges: Map>) { + var isExpanded by remember { mutableStateOf(true) } + val totalErrors = lintResults.sumOf { it.errorCount } + val totalWarnings = lintResults.sumOf { it.warningCount } + IdeaCollapsibleCard("Lint Analysis", isExpanded, { isExpanded = it }, isActive, { + if (totalErrors > 0 || totalWarnings > 0) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (totalErrors > 0) IdeaBadge("$totalErrors errors", AutoDevColors.Red.c400) + if (totalWarnings > 0) IdeaBadge("$totalWarnings warnings", AutoDevColors.Amber.c400) + } + } + }) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + lintResults.forEach { result -> if (result.issues.isNotEmpty()) IdeaLintFileCard(result, modifiedCodeRanges[result.filePath] ?: emptyList()) } + if (lintOutput.isNotEmpty() && lintResults.isEmpty()) Text(lintOutput, style = JewelTheme.defaultTextStyle.copy(fontFamily = FontFamily.Monospace, fontSize = 11.sp), modifier = Modifier.horizontalScroll(rememberScrollState())) + } + } +} + +@Composable +private fun IdeaLintFileCard(fileResult: LintFileResult, modifiedRanges: List) { + Column(modifier = Modifier.fillMaxWidth().background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f), RoundedCornerShape(4.dp)).padding(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(fileResult.filePath.substringAfterLast("/"), style = JewelTheme.defaultTextStyle.copy(fontWeight = FontWeight.Medium, fontSize = 12.sp)) + fileResult.issues.take(5).forEach { IdeaLintIssueRow(it, modifiedRanges) } + if (fileResult.issues.size > 5) Text("...and ${fileResult.issues.size - 5} more issues", style = JewelTheme.defaultTextStyle.copy(color = JewelTheme.globalColors.text.info, fontSize = 11.sp)) + } +} + +@Composable +private fun IdeaLintIssueRow(issue: LintIssue, modifiedRanges: List) { + val isInModifiedRange = modifiedRanges.any { issue.line in it.startLine..it.endLine } + val severityColor = when (issue.severity) { LintSeverity.ERROR -> AutoDevColors.Red.c400; LintSeverity.WARNING -> AutoDevColors.Amber.c400; LintSeverity.INFO -> AutoDevColors.Blue.c400 } + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Top) { + Text("L${issue.line}", style = JewelTheme.defaultTextStyle.copy(fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = if (isInModifiedRange) severityColor else JewelTheme.globalColors.text.info), modifier = Modifier.width(40.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(issue.message, style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp)) + issue.rule?.let { Text(it, style = JewelTheme.defaultTextStyle.copy(fontSize = 10.sp, color = JewelTheme.globalColors.text.info)) } + } + } +} + +@Composable +internal fun IdeaAIAnalysisSection(analysisOutput: String, isActive: Boolean, parentDisposable: Disposable) { + var isExpanded by remember { mutableStateOf(true) } + IdeaCollapsibleCard("AI Analysis", isExpanded, { isExpanded = it }, isActive) { + IdeaSketchRenderer.RenderResponse(analysisOutput, !isActive, parentDisposable, Modifier.fillMaxWidth()) + } +} + +@Composable +internal fun IdeaModificationPlanSection(planOutput: String, isActive: Boolean, parentDisposable: Disposable) { + var isExpanded by remember { mutableStateOf(true) } + IdeaCollapsibleCard("Modification Plan", isExpanded, { isExpanded = it }, isActive, { if (isActive) IdeaBadge("Generating...", AutoDevColors.Blue.c400) }) { + IdeaSketchRenderer.RenderResponse(planOutput, !isActive, parentDisposable, Modifier.fillMaxWidth()) + } +} + +@Composable +internal fun IdeaUserInputSection(onGenerate: (String) -> Unit, onCancel: () -> Unit) { + var userInput by remember { mutableStateOf(TextFieldValue("")) } + IdeaCollapsibleCard("Your Feedback", true, {}, true, { IdeaBadge("Action Required", AutoDevColors.Amber.c400) }) { + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Review the plan above and provide any additional instructions:", style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp)) + TextArea(value = userInput, onValueChange = { userInput = it }, modifier = Modifier.fillMaxWidth().height(80.dp), placeholder = { Text("Optional: Add specific instructions or constraints...") }) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) { + OutlinedButton(onClick = onCancel) { Text("Cancel") } + DefaultButton(onClick = { onGenerate(userInput.text) }) { Text("Generate Fixes") } + } + } + } +} + +@Composable +internal fun IdeaSuggestedFixesSection(fixOutput: String, isGenerating: Boolean, parentDisposable: Disposable) { + var isExpanded by remember { mutableStateOf(true) } + IdeaCollapsibleCard("Fix Generation", isExpanded, { isExpanded = it }, isGenerating, { + if (isGenerating) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator() + IdeaBadge("Generating...", AutoDevColors.Indigo.c400) + } + } else if (fixOutput.isNotEmpty()) IdeaBadge("Complete", AutoDevColors.Green.c400) + }) { + when { + fixOutput.isNotEmpty() -> IdeaSketchRenderer.RenderResponse(fixOutput, !isGenerating, parentDisposable, Modifier.fillMaxWidth()) + isGenerating -> Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + else -> Text("No fixes generated yet.", style = JewelTheme.defaultTextStyle.copy(color = JewelTheme.globalColors.text.info, fontSize = 12.sp)) + } + } +} + +@Composable +internal fun IdeaCollapsibleCard(title: String, isExpanded: Boolean, onExpandChange: (Boolean) -> Unit, isActive: Boolean = false, badge: @Composable (() -> Unit)? = null, content: @Composable () -> Unit) { + val backgroundColor = if (isActive) AutoDevColors.Blue.c600.copy(alpha = 0.08f) else JewelTheme.globalColors.panelBackground + Column(modifier = Modifier.fillMaxWidth().background(backgroundColor, RoundedCornerShape(6.dp))) { + Row(modifier = Modifier.fillMaxWidth().clickable { onExpandChange(!isExpanded) }.padding(12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(if (isExpanded) "-" else "+", style = JewelTheme.defaultTextStyle.copy(fontWeight = FontWeight.Bold, fontSize = 14.sp)) + Text(title, style = JewelTheme.defaultTextStyle.copy(fontWeight = FontWeight.Bold, fontSize = 13.sp)) + badge?.invoke() + } + } + AnimatedVisibility(visible = isExpanded, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) { + Box(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { content() } + } + } +} + +@Composable +internal fun IdeaBadge(text: String, color: Color) { + Box(modifier = Modifier.background(color.copy(alpha = 0.15f), RoundedCornerShape(4.dp)).padding(horizontal = 6.dp, vertical = 2.dp)) { + Text(text, style = JewelTheme.defaultTextStyle.copy(color = color, fontSize = 10.sp, fontWeight = FontWeight.Medium)) + } +} diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCodeReviewContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCodeReviewContent.kt index ca3cc1fd8a..3ed2de9a70 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCodeReviewContent.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCodeReviewContent.kt @@ -1,421 +1,77 @@ package cc.unitmesh.devins.idea.toolwindow.codereview -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue 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.diff.ChangeType -import cc.unitmesh.devins.ui.compose.theme.AutoDevColors -import org.jetbrains.jewel.foundation.theme.JewelTheme -import org.jetbrains.jewel.ui.Orientation -import org.jetbrains.jewel.ui.component.* +import cc.unitmesh.devins.idea.components.IdeaResizableSplitPane +import com.intellij.openapi.Disposable /** * Main Code Review content composable for IntelliJ IDEA plugin. * Uses Jewel UI components for IntelliJ-native look and feel. + * + * Features a three-column resizable layout: + * - Left: Commit history list + * - Center: Diff viewer with file tabs + * - Right: AI Analysis with Plan, User Input, and Fix generation sections */ @Composable -fun IdeaCodeReviewContent(viewModel: IdeaCodeReviewViewModel) { +fun IdeaCodeReviewContent( + viewModel: IdeaCodeReviewViewModel, + parentDisposable: Disposable +) { val state by viewModel.state.collectAsState() - Row(modifier = Modifier.fillMaxSize()) { - // Left panel: Commit list - CommitListPanel( - commits = state.commitHistory, - selectedIndices = state.selectedCommitIndices, - isLoading = state.isLoading, - onCommitSelect = { index -> - viewModel.selectCommits(setOf(index)) - }, - modifier = Modifier.width(280.dp).fillMaxHeight() - ) - - Divider(Orientation.Vertical, modifier = Modifier.fillMaxHeight().width(1.dp)) - - // Center panel: Diff viewer - DiffViewerPanel( - diffFiles = state.diffFiles, - selectedFileIndex = state.selectedFileIndex, - isLoading = state.isLoadingDiff, - onFileSelect = { index -> viewModel.selectFile(index) }, - modifier = Modifier.weight(1f).fillMaxHeight() - ) - - Divider(Orientation.Vertical, modifier = Modifier.fillMaxHeight().width(1.dp)) - - // Right panel: AI Analysis - AIAnalysisPanel( - progress = state.aiProgress, - error = state.error, - onStartAnalysis = { viewModel.startAnalysis() }, - onCancelAnalysis = { viewModel.cancelAnalysis() }, - modifier = Modifier.width(350.dp).fillMaxHeight() - ) - } -} - -@Composable -private fun CommitListPanel( - commits: List, - selectedIndices: Set, - isLoading: Boolean, - onCommitSelect: (Int) -> Unit, - modifier: Modifier = Modifier -) { - Column(modifier = modifier.background(JewelTheme.globalColors.panelBackground)) { - // Header - Box( - modifier = Modifier.fillMaxWidth().padding(12.dp), - contentAlignment = Alignment.CenterStart - ) { - Text( - text = "Commits", - style = JewelTheme.defaultTextStyle.copy( - fontWeight = FontWeight.Bold, - fontSize = 14.sp - ) + IdeaResizableSplitPane( + modifier = Modifier.fillMaxSize(), + initialSplitRatio = 0.18f, + minRatio = 0.12f, + maxRatio = 0.35f, + first = { + // Left panel: Commit list + CommitListPanel( + commits = state.commitHistory, + selectedIndices = state.selectedCommitIndices, + isLoading = state.isLoading, + onCommitSelect = { index -> viewModel.selectCommit(index) }, + modifier = Modifier.fillMaxSize() ) - } - - Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) - - if (isLoading) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } else if (commits.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = "No commits found", - style = JewelTheme.defaultTextStyle.copy( - color = JewelTheme.globalColors.text.info - ) - ) - } - } else { - LazyColumn( + }, + second = { + // Center + Right: Diff view and AI analysis + IdeaResizableSplitPane( modifier = Modifier.fillMaxSize(), - state = rememberLazyListState() - ) { - itemsIndexed(commits) { index, commit -> - CommitItem( - commit = commit, - isSelected = index in selectedIndices, - onClick = { onCommitSelect(index) } - ) - } - } - } - } -} - -@Composable -private fun CommitItem( - commit: IdeaCommitInfo, - isSelected: Boolean, - onClick: () -> Unit -) { - val backgroundColor = if (isSelected) { - JewelTheme.globalColors.panelBackground.copy(alpha = 0.8f) - } else { - JewelTheme.globalColors.panelBackground - } - - Column( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .background(backgroundColor) - .padding(horizontal = 12.dp, vertical = 8.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = commit.shortHash, - style = JewelTheme.defaultTextStyle.copy( - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - color = AutoDevColors.Blue.c400 - ) - ) - Text( - text = commit.date, - style = JewelTheme.defaultTextStyle.copy( - fontSize = 11.sp, - color = JewelTheme.globalColors.text.info - ) - ) - } - - Spacer(modifier = Modifier.height(4.dp)) - - Text( - text = commit.message.lines().firstOrNull() ?: "", - style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp), - maxLines = 2 - ) - } -} - -@Composable -private fun DiffViewerPanel( - diffFiles: List, - selectedFileIndex: Int, - isLoading: Boolean, - onFileSelect: (Int) -> Unit, - modifier: Modifier = Modifier -) { - Column(modifier = modifier.background(JewelTheme.globalColors.panelBackground)) { - // File tabs - if (diffFiles.isNotEmpty()) { - Row( - modifier = Modifier.fillMaxWidth().padding(8.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - diffFiles.forEachIndexed { index, file -> - val isSelected = index == selectedFileIndex - val changeIcon = when (file.changeType) { - ChangeType.CREATE -> "+" - ChangeType.DELETE -> "-" - ChangeType.RENAME -> "R" - else -> "M" - } - val changeColor = when (file.changeType) { - ChangeType.CREATE -> AutoDevColors.Green.c400 - ChangeType.DELETE -> AutoDevColors.Red.c400 - else -> AutoDevColors.Amber.c400 + initialSplitRatio = 0.55f, + minRatio = 0.35f, + maxRatio = 0.75f, + first = { + // Center panel: Diff viewer with commit info and file list + val selectedCommits = state.selectedCommitIndices.mapNotNull { index -> + state.commitHistory.getOrNull(index) } - - Box( - modifier = Modifier - .clickable { onFileSelect(index) } - .background( - if (isSelected) JewelTheme.globalColors.panelBackground.copy(alpha = 0.8f) - else JewelTheme.globalColors.panelBackground - ) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = changeIcon, - style = JewelTheme.defaultTextStyle.copy( - color = changeColor, - fontWeight = FontWeight.Bold, - fontSize = 12.sp - ) - ) - Text( - text = file.path.split("/").lastOrNull() ?: file.path, - style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp) - ) - } - } - } - } - - Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) - } - - // Diff content - if (isLoading) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } else if (diffFiles.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = "Select a commit to view diff", - style = JewelTheme.defaultTextStyle.copy(color = JewelTheme.globalColors.text.info) - ) - } - } else { - val selectedFile = diffFiles.getOrNull(selectedFileIndex) - if (selectedFile != null) { - DiffContent(file = selectedFile) - } - } - } -} - -@Composable -private fun DiffContent(file: IdeaDiffFileInfo) { - val scrollState = rememberScrollState() - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(8.dp) - ) { - // File path header - Text( - text = file.path, - style = JewelTheme.defaultTextStyle.copy( - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - fontWeight = FontWeight.Bold - ) - ) - - Spacer(modifier = Modifier.height(8.dp)) - - // Hunks - file.hunks.forEach { hunk -> - // Hunk header - Text( - text = "@@ -${hunk.oldStartLine},${hunk.oldLineCount} +${hunk.newStartLine},${hunk.newLineCount} @@", - style = JewelTheme.defaultTextStyle.copy( - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - color = AutoDevColors.Blue.c400 - ) - ) - - Spacer(modifier = Modifier.height(4.dp)) - - // Lines - hunk.lines.forEach { diffLine -> - val color = when (diffLine.type) { - cc.unitmesh.agent.diff.DiffLineType.ADDED -> AutoDevColors.Green.c400 - cc.unitmesh.agent.diff.DiffLineType.DELETED -> AutoDevColors.Red.c400 - else -> JewelTheme.globalColors.text.normal - } - - Text( - text = diffLine.content, - style = JewelTheme.defaultTextStyle.copy( - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - color = color + DiffViewerPanel( + diffFiles = state.diffFiles, + selectedCommits = selectedCommits, + selectedCommitIndices = state.selectedCommitIndices, + isLoadingDiff = state.isLoadingDiff, + onViewFile = { path -> viewModel.openFileViewer(path) }, + onRefreshIssue = { index -> viewModel.refreshIssueForCommit(index) }, + onConfigureToken = { /* TODO: Open token configuration */ }, + modifier = Modifier.fillMaxSize() + ) + }, + second = { + // Right panel: AI Analysis with Plan and Fix UI + IdeaAIAnalysisPanel( + state = state, + viewModel = viewModel, + parentDisposable = parentDisposable, + modifier = Modifier.fillMaxSize() ) - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - } - } -} - -@Composable -private fun AIAnalysisPanel( - progress: IdeaAIAnalysisProgress, - error: String?, - onStartAnalysis: () -> Unit, - onCancelAnalysis: () -> Unit, - modifier: Modifier = Modifier -) { - Column(modifier = modifier.background(JewelTheme.globalColors.panelBackground)) { - // Header with action button - Row( - modifier = Modifier.fillMaxWidth().padding(12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "AI Analysis", - style = JewelTheme.defaultTextStyle.copy( - fontWeight = FontWeight.Bold, - fontSize = 14.sp - ) - ) - - when (progress.stage) { - IdeaAnalysisStage.IDLE, IdeaAnalysisStage.COMPLETED, IdeaAnalysisStage.ERROR -> { - DefaultButton(onClick = onStartAnalysis) { - Text("Start Analysis") - } - } - else -> { - OutlinedButton(onClick = onCancelAnalysis) { - Text("Cancel") - } } - } - } - - Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) - - // Status - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - val (statusText, statusColor) = when (progress.stage) { - IdeaAnalysisStage.IDLE -> "Ready" to JewelTheme.globalColors.text.info - IdeaAnalysisStage.RUNNING_LINT -> "Running lint..." to AutoDevColors.Amber.c400 - IdeaAnalysisStage.ANALYZING -> "Analyzing code..." to AutoDevColors.Blue.c400 - IdeaAnalysisStage.GENERATING_PLAN -> "Generating plan..." to AutoDevColors.Blue.c400 - IdeaAnalysisStage.GENERATING_FIX -> "Generating fixes..." to AutoDevColors.Blue.c400 - IdeaAnalysisStage.COMPLETED -> "Completed" to AutoDevColors.Green.c400 - IdeaAnalysisStage.ERROR -> "Error" to AutoDevColors.Red.c400 - } - - if (progress.stage != IdeaAnalysisStage.IDLE && - progress.stage != IdeaAnalysisStage.COMPLETED && - progress.stage != IdeaAnalysisStage.ERROR) { - CircularProgressIndicator() - } - - Text( - text = statusText, - style = JewelTheme.defaultTextStyle.copy(color = statusColor) ) } - - // Error message - if (error != null) { - Text( - text = error, - style = JewelTheme.defaultTextStyle.copy( - color = AutoDevColors.Red.c400, - fontSize = 12.sp - ), - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) - ) - } - - Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) - - // Analysis output - val scrollState = rememberScrollState() - Box( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(12.dp) - ) { - if (progress.analysisOutput.isNotEmpty()) { - Text( - text = progress.analysisOutput, - style = JewelTheme.defaultTextStyle.copy( - fontFamily = FontFamily.Monospace, - fontSize = 11.sp - ) - ) - } else { - Text( - text = "Click 'Start Analysis' to begin AI code review", - style = JewelTheme.defaultTextStyle.copy( - color = JewelTheme.globalColors.text.info, - fontSize = 12.sp - ) - ) - } - } - } + ) } - diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCodeReviewModels.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCodeReviewModels.kt deleted file mode 100644 index ffc383d424..0000000000 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCodeReviewModels.kt +++ /dev/null @@ -1,71 +0,0 @@ -package cc.unitmesh.devins.idea.toolwindow.codereview - -import cc.unitmesh.agent.diff.ChangeType -import cc.unitmesh.agent.diff.DiffHunk - -/** - * State for Code Review UI in IntelliJ IDEA plugin. - * Adapted from mpp-ui's CodeReviewState. - */ -data class IdeaCodeReviewState( - val isLoading: Boolean = false, - val isLoadingDiff: Boolean = false, - val error: String? = null, - val commitHistory: List = emptyList(), - val selectedCommitIndices: Set = emptySet(), - val diffFiles: List = emptyList(), - val selectedFileIndex: Int = 0, - val aiProgress: IdeaAIAnalysisProgress = IdeaAIAnalysisProgress(), - val hasMoreCommits: Boolean = false, - val isLoadingMore: Boolean = false, - val totalCommitCount: Int? = null, - val originDiff: String? = null -) - -/** - * Information about a commit. - */ -data class IdeaCommitInfo( - val hash: String, - val shortHash: String, - val author: String, - val timestamp: Long, - val date: String, - val message: String -) - -/** - * Information about a file in the diff. - */ -data class IdeaDiffFileInfo( - val path: String, - val oldPath: String? = null, - val changeType: ChangeType = ChangeType.EDIT, - val hunks: List = emptyList(), - val language: String? = null -) - -/** - * AI analysis progress for streaming display. - */ -data class IdeaAIAnalysisProgress( - val stage: IdeaAnalysisStage = IdeaAnalysisStage.IDLE, - val currentFile: String? = null, - val analysisOutput: String = "", - val planOutput: String = "", - val fixOutput: String = "" -) - -/** - * Stages of AI analysis. - */ -enum class IdeaAnalysisStage { - IDLE, - RUNNING_LINT, - ANALYZING, - GENERATING_PLAN, - GENERATING_FIX, - COMPLETED, - ERROR -} - diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCodeReviewViewModel.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCodeReviewViewModel.kt index b97307e38d..5442fbb21b 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCodeReviewViewModel.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCodeReviewViewModel.kt @@ -1,345 +1,84 @@ package cc.unitmesh.devins.idea.toolwindow.codereview -import cc.unitmesh.agent.CodeReviewAgent -import cc.unitmesh.agent.ReviewTask -import cc.unitmesh.agent.ReviewType -import cc.unitmesh.agent.config.McpToolConfigService -import cc.unitmesh.agent.config.ToolConfigFile -import cc.unitmesh.agent.diff.ChangeType -import cc.unitmesh.agent.diff.DiffParser -import cc.unitmesh.agent.language.LanguageDetector -import cc.unitmesh.agent.platform.GitOperations import cc.unitmesh.devins.idea.renderer.JewelRenderer -import cc.unitmesh.devins.ui.config.ConfigManager -import cc.unitmesh.devins.workspace.GitFileStatus -import cc.unitmesh.llm.KoogLLMService +import cc.unitmesh.devins.ui.compose.agent.codereview.CodeReviewViewModel +import cc.unitmesh.devins.workspace.DefaultWorkspace +import cc.unitmesh.devins.workspace.Workspace import com.intellij.openapi.Disposable +import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import java.text.SimpleDateFormat -import java.util.* +import kotlinx.coroutines.CoroutineScope /** * ViewModel for Code Review in IntelliJ IDEA plugin. - * Adapted from mpp-ui's CodeReviewViewModel for IntelliJ platform. * - * Uses mpp-core's GitOperations (JVM implementation) for git operations - * and JewelRenderer for UI rendering. + * This class extends the common CodeReviewViewModel from mpp-ui, + * adapting it for the IntelliJ platform by: + * - Creating a Workspace from IntelliJ Project + * - Using JewelRenderer for native IntelliJ theme integration + * - Implementing Disposable for proper resource cleanup + * + * All core functionality (git operations, analysis, plan generation, fix generation) + * is inherited from the base CodeReviewViewModel. */ class IdeaCodeReviewViewModel( private val project: Project, private val coroutineScope: CoroutineScope -) : Disposable { - - private val projectPath: String = project.basePath ?: "" - private val gitOps = GitOperations(projectPath) - - // Renderer for agent output - val renderer = JewelRenderer() - - // State - private val _state = MutableStateFlow(IdeaCodeReviewState()) - val state: StateFlow = _state.asStateFlow() - - // Control execution - private var currentJob: Job? = null - private var codeReviewAgent: CodeReviewAgent? = null - private var agentInitialized = false - - init { - if (projectPath.isEmpty()) { - updateState { it.copy(error = "No project path available") } - } else { - coroutineScope.launch { - try { - loadCommitHistory() - } catch (e: Exception) { - updateState { it.copy(error = "Failed to initialize: ${e.message}") } - } - } - } - } +) : CodeReviewViewModel( + workspace = createWorkspaceFromProject(project) +), Disposable { - /** - * Load recent git commits - */ - suspend fun loadCommitHistory(count: Int = 50) { - updateState { it.copy(isLoading = true, error = null) } + private val logger = Logger.getInstance(IdeaCodeReviewViewModel::class.java) - try { - val totalCount = gitOps.getTotalCommitCount() - val gitCommits = gitOps.getRecentCommits(count) + // JewelRenderer for IntelliJ native theme + val jewelRenderer = JewelRenderer() - val hasMore = totalCount?.let { it > gitCommits.size } ?: false - val commits = gitCommits.map { git -> - IdeaCommitInfo( - hash = git.hash, - shortHash = git.shortHash, - author = git.author, - timestamp = git.date, - date = formatDate(git.date), - message = git.message - ) - } - - updateState { - it.copy( - isLoading = false, - commitHistory = commits, - selectedCommitIndices = if (commits.isNotEmpty()) setOf(0) else emptySet(), - hasMoreCommits = hasMore, - totalCommitCount = totalCount, - error = null - ) - } + companion object { + /** + * Create a Workspace from an IntelliJ Project + */ + private fun createWorkspaceFromProject(project: Project): Workspace { + val projectPath = project.basePath + val projectName = project.name - if (commits.isNotEmpty()) { - loadCommitDiff(setOf(0)) - } - } catch (e: Exception) { - updateState { - it.copy(isLoading = false, error = "Failed to load commits: ${e.message}") + return if (projectPath != null) { + DefaultWorkspace.create(projectName, projectPath) + } else { + DefaultWorkspace.createEmpty(projectName) } } } /** - * Select commits and load their diff + * Open a file in the IDE editor */ - fun selectCommits(indices: Set) { - coroutineScope.launch { - loadCommitDiff(indices) - } - } - - /** - * Load diff for selected commits - */ - private suspend fun loadCommitDiff(selectedIndices: Set) { - if (selectedIndices.isEmpty()) { - updateState { - it.copy( - isLoadingDiff = false, - selectedCommitIndices = emptySet(), - diffFiles = emptyList(), - error = null - ) - } + fun openFileViewer(path: String) { + val basePath = project.basePath ?: run { + logger.warn("Cannot open file: project basePath is null") return } - - updateState { - it.copy(isLoadingDiff = true, selectedCommitIndices = selectedIndices, error = null) - } - - try { - val sortedIndices = selectedIndices.sorted() - val newestIndex = sortedIndices.first() - val oldestIndex = sortedIndices.last() - - val currentState = _state.value - val newestCommit = currentState.commitHistory[newestIndex] - val oldestCommit = currentState.commitHistory[oldestIndex] - - val gitDiff = if (newestIndex == oldestIndex) { - gitOps.getCommitDiff(newestCommit.hash) - } else { - val hasParent = gitOps.hasParent(oldestCommit.hash) - if (hasParent) { - gitOps.getDiff("${oldestCommit.hash}^", newestCommit.hash) - } else { - gitOps.getDiff("4b825dc642cb6eb9a060e54bf8d69288fbee4904", newestCommit.hash) - } - } - - if (gitDiff == null) { - updateState { it.copy(isLoadingDiff = false, error = "No diff available") } - return - } - - val diffFiles = gitDiff.files.map { file -> - val parsedDiff = DiffParser.parse(file.diff) - val hunks = parsedDiff.firstOrNull()?.hunks ?: emptyList() - - IdeaDiffFileInfo( - path = file.path, - oldPath = file.oldPath, - changeType = when (file.status) { - GitFileStatus.ADDED -> ChangeType.CREATE - GitFileStatus.DELETED -> ChangeType.DELETE - GitFileStatus.MODIFIED -> ChangeType.EDIT - GitFileStatus.RENAMED -> ChangeType.RENAME - GitFileStatus.COPIED -> ChangeType.EDIT - }, - hunks = hunks, - language = LanguageDetector.detectLanguage(file.path) - ) - } - - updateState { - it.copy( - isLoadingDiff = false, - diffFiles = diffFiles, - selectedFileIndex = 0, - error = null, - originDiff = gitDiff.originDiff - ) - } - } catch (e: Exception) { - updateState { it.copy(isLoadingDiff = false, error = "Failed to load diff: ${e.message}") } - } - } - - /** - * Select a file from the diff list - */ - fun selectFile(index: Int) { - updateState { it.copy(selectedFileIndex = index) } - } - - /** - * Start AI analysis on the selected commits - */ - fun startAnalysis() { - val currentState = _state.value - if (currentState.diffFiles.isEmpty()) { - updateState { it.copy(error = "No files to analyze") } + val file = java.io.File(basePath, path) + if (!file.exists()) { + logger.warn("File not found in openFileViewer: ${file.path}") return } - currentJob?.cancel() - currentJob = coroutineScope.launch { - try { - updateState { - it.copy( - aiProgress = IdeaAIAnalysisProgress(stage = IdeaAnalysisStage.RUNNING_LINT), - error = null - ) - } - - val agent = initializeCodeReviewAgent() - val filePaths = currentState.diffFiles.map { it.path } - - val additionalContext = buildString { - val selectedCommits = currentState.selectedCommitIndices - .mapNotNull { currentState.commitHistory.getOrNull(it) } - - if (selectedCommits.isNotEmpty()) { - appendLine("## Selected Commits") - selectedCommits.forEach { commit -> - appendLine("- ${commit.shortHash}: ${commit.message.lines().firstOrNull()}") - } - appendLine() - } - } - - val reviewTask = ReviewTask( - filePaths = filePaths, - reviewType = ReviewType.COMPREHENSIVE, - projectPath = projectPath, - patch = currentState.originDiff, - lintResults = emptyList(), - additionalContext = additionalContext - ) - - updateState { - it.copy(aiProgress = it.aiProgress.copy( - stage = IdeaAnalysisStage.ANALYZING, - analysisOutput = "Starting code review analysis...\n" - )) - } - - val analysisOutputBuilder = StringBuilder() - try { - agent.execute(reviewTask) { progressMessage -> - analysisOutputBuilder.append(progressMessage) - updateState { - it.copy(aiProgress = it.aiProgress.copy( - analysisOutput = analysisOutputBuilder.toString() - )) - } - } - - updateState { - it.copy(aiProgress = it.aiProgress.copy(stage = IdeaAnalysisStage.COMPLETED)) - } - } catch (e: Exception) { - analysisOutputBuilder.append("\nError: ${e.message}") - updateState { - it.copy(aiProgress = it.aiProgress.copy( - stage = IdeaAnalysisStage.ERROR, - analysisOutput = analysisOutputBuilder.toString() - )) - } - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - updateState { - it.copy( - aiProgress = it.aiProgress.copy(stage = IdeaAnalysisStage.ERROR), - error = "Analysis failed: ${e.message}" - ) - } + val localFileSystem = com.intellij.openapi.vfs.LocalFileSystem.getInstance() + com.intellij.openapi.application.ApplicationManager.getApplication().invokeLater { + val virtualFile = localFileSystem.refreshAndFindFileByIoFile(file) + if (virtualFile != null) { + com.intellij.openapi.fileEditor.FileEditorManager.getInstance(project).openFile(virtualFile, true) + } else { + logger.warn("VirtualFile not found for file: ${file.path}") } } } /** - * Cancel current analysis + * Dispose resources when the ViewModel is no longer needed */ - fun cancelAnalysis() { - currentJob?.cancel() - updateState { it.copy(aiProgress = IdeaAIAnalysisProgress(stage = IdeaAnalysisStage.IDLE)) } - } - - /** - * Initialize the CodeReviewAgent - */ - private suspend fun initializeCodeReviewAgent(): CodeReviewAgent { - if (codeReviewAgent != null && agentInitialized) { - return codeReviewAgent!! - } - - val toolConfig = ToolConfigFile.default() - val configWrapper = ConfigManager.load() - val modelConfig = configWrapper.getActiveModelConfig() - ?: error("No active model configuration found. Please configure a model in settings.") - - val llmService = KoogLLMService.create(modelConfig) - val mcpToolConfigService = McpToolConfigService(toolConfig) - - codeReviewAgent = CodeReviewAgent( - projectPath = projectPath, - llmService = llmService, - maxIterations = 50, - renderer = renderer, - mcpToolConfigService = mcpToolConfigService, - enableLLMStreaming = true - ) - agentInitialized = true - - return codeReviewAgent!! - } - - private fun updateState(update: (IdeaCodeReviewState) -> IdeaCodeReviewState) { - _state.value = update(_state.value) - } - - private fun formatDate(timestamp: Long): String { - return try { - val date = Date(timestamp * 1000) - SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(date) - } catch (e: Exception) { - "Unknown" - } - } - override fun dispose() { - currentJob?.cancel() + logger.info("Disposing IdeaCodeReviewViewModel") + // The parent class cleanup will happen when the scope is cancelled } } - diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCommitComponents.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCommitComponents.kt new file mode 100644 index 0000000000..a20af8b113 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCommitComponents.kt @@ -0,0 +1,571 @@ +package cc.unitmesh.devins.idea.toolwindow.codereview + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.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.toolwindow.IdeaComposeIcons +import cc.unitmesh.devins.ui.compose.agent.codereview.CommitInfo +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.Orientation +import org.jetbrains.jewel.ui.component.* + +/** + * Commit list panel showing all commits with selection support + */ +@Composable +internal fun CommitListPanel( + commits: List, + selectedIndices: Set, + isLoading: Boolean, + onCommitSelect: (Int) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.background(JewelTheme.globalColors.panelBackground)) { + Box( + modifier = Modifier.fillMaxWidth().padding(12.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = "Commits", + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + ) + } + + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) + + if (isLoading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else if (commits.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = "No commits found", + style = JewelTheme.defaultTextStyle.copy( + color = JewelTheme.globalColors.text.info + ) + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = rememberLazyListState() + ) { + itemsIndexed(commits) { index, commit -> + CommitItem( + commit = commit, + isSelected = index in selectedIndices, + onClick = { onCommitSelect(index) } + ) + } + } + } + } +} + +/** + * Single commit item in the list + */ +@Composable +internal fun CommitItem( + commit: CommitInfo, + isSelected: Boolean, + onClick: () -> Unit +) { + val backgroundColor = if (isSelected) { + JewelTheme.globalColors.panelBackground.copy(alpha = 0.8f) + } else { + JewelTheme.globalColors.panelBackground + } + + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .background(backgroundColor) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = commit.shortHash, + style = JewelTheme.defaultTextStyle.copy( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = AutoDevColors.Blue.c400 + ) + ) + Text( + text = commit.date, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.info + ) + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = commit.message.lines().firstOrNull() ?: "", + style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp), + maxLines = 2 + ) + } +} + +/** + * Commit info card with issue display + */ +@Composable +internal fun IdeaCommitInfoCard( + selectedCommits: List, + selectedCommitIndices: List, + onRefreshIssue: ((Int) -> Unit)?, + onConfigureToken: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .background( + JewelTheme.globalColors.panelBackground.copy(alpha = 0.6f), + RoundedCornerShape(6.dp) + ) + .padding(12.dp) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + if (selectedCommits.size == 1) { + val selectedCommit = selectedCommits.first() + SingleCommitInfoView( + selectedCommit = selectedCommit, + actualCommitIndex = selectedCommitIndices.firstOrNull() ?: 0, + onRefreshIssue = onRefreshIssue, + onConfigureToken = onConfigureToken + ) + } else { + MultipleCommitsInfoView(selectedCommits = selectedCommits) + } + } + } +} + +@Composable +private fun SingleCommitInfoView( + selectedCommit: CommitInfo, + actualCommitIndex: Int, + onRefreshIssue: ((Int) -> Unit)?, + onConfigureToken: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Text( + text = selectedCommit.message.lines().firstOrNull() ?: selectedCommit.message, + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ), + modifier = Modifier.weight(1f) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + IdeaIssueIndicator( + commit = selectedCommit, + commitIndex = actualCommitIndex, + onRefreshIssue = onRefreshIssue, + onConfigureToken = onConfigureToken + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = selectedCommit.author, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = JewelTheme.globalColors.text.info + ) + ) + Text( + text = selectedCommit.shortHash, + style = JewelTheme.defaultTextStyle.copy( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = JewelTheme.globalColors.text.info.copy(alpha = 0.7f) + ) + ) + } + + selectedCommit.issueInfo?.let { issueInfo -> + Spacer(modifier = Modifier.height(8.dp)) + IdeaIssueInfoCard(issueInfo = issueInfo) + } +} + +@Composable +private fun MultipleCommitsInfoView(selectedCommits: List) { + val newest = selectedCommits.first() + val oldest = selectedCommits.last() + + Text( + text = "${selectedCommits.size} commits selected", + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "Range: ${oldest.shortHash}..${newest.shortHash}", + style = JewelTheme.defaultTextStyle.copy( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = JewelTheme.globalColors.text.info + ) + ) + + Spacer(modifier = Modifier.height(4.dp)) + + val authors = selectedCommits.map { it.author }.distinct() + Text( + text = "Authors: ${authors.joinToString(", ")}", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = JewelTheme.globalColors.text.info + ) + ) +} + +/** + * Issue indicator for commit (loading, info chip, error with retry) + */ +@Composable +internal fun IdeaIssueIndicator( + commit: CommitInfo, + commitIndex: Int, + onRefreshIssue: ((Int) -> Unit)?, + onConfigureToken: () -> Unit +) { + when { + commit.isLoadingIssue -> { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } + commit.issueInfo != null -> { + IssueInfoIndicator( + commit = commit, + commitIndex = commitIndex, + onRefreshIssue = onRefreshIssue + ) + } + commit.issueLoadError != null -> { + IssueErrorIndicator( + errorMessage = commit.issueLoadError!!, + commitIndex = commitIndex, + onRefreshIssue = onRefreshIssue, + onConfigureToken = onConfigureToken + ) + } + } +} + +@Composable +private fun IssueInfoIndicator( + commit: CommitInfo, + commitIndex: Int, + onRefreshIssue: ((Int) -> Unit)? +) { + val issueInfo = commit.issueInfo!! + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IdeaInlineIssueChip(issueInfo = issueInfo) + + val cacheAge = commit.issueCacheAge + if (commit.issueFromCache && cacheAge != null) { + Text( + text = cacheAge, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = JewelTheme.globalColors.text.info.copy(alpha = 0.6f) + ) + ) + } + + if (onRefreshIssue != null) { + IconButton( + onClick = { onRefreshIssue(commitIndex) }, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.Refresh, + contentDescription = "Refresh issue", + tint = JewelTheme.globalColors.text.info.copy(alpha = 0.6f), + modifier = Modifier.size(14.dp) + ) + } + } + } +} + +@Composable +private fun IssueErrorIndicator( + errorMessage: String, + commitIndex: Int, + onRefreshIssue: ((Int) -> Unit)?, + onConfigureToken: () -> Unit +) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = errorMessage, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = AutoDevColors.Red.c400.copy(alpha = 0.8f) + ) + ) + + if (onRefreshIssue != null) { + IconButton( + onClick = { onRefreshIssue(commitIndex) }, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.Refresh, + contentDescription = "Retry", + tint = AutoDevColors.Red.c400.copy(alpha = 0.8f), + modifier = Modifier.size(14.dp) + ) + } + } + + if (errorMessage.contains("Authentication", ignoreCase = true)) { + DefaultButton( + onClick = onConfigureToken, + modifier = Modifier.height(24.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = IdeaComposeIcons.Settings, + contentDescription = "Configure", + modifier = Modifier.size(12.dp) + ) + Text( + text = "Token", + style = JewelTheme.defaultTextStyle.copy(fontSize = 10.sp) + ) + } + } + } + } +} + +/** + * Inline compact issue chip + */ +@Composable +internal fun IdeaInlineIssueChip(issueInfo: cc.unitmesh.agent.tracker.IssueInfo) { + val (bgColor, iconVector, textColor) = when (issueInfo.status.lowercase()) { + "open" -> Triple( + AutoDevColors.Green.c600.copy(alpha = 0.15f), + IdeaComposeIcons.BugReport, + AutoDevColors.Green.c600 + ) + "closed" -> Triple( + AutoDevColors.Neutral.c600.copy(alpha = 0.15f), + IdeaComposeIcons.CheckCircle, + AutoDevColors.Neutral.c600 + ) + else -> Triple( + AutoDevColors.Indigo.c600.copy(alpha = 0.15f), + IdeaComposeIcons.Info, + AutoDevColors.Indigo.c600 + ) + } + + Box( + modifier = Modifier + .background(bgColor, RoundedCornerShape(4.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = iconVector, + contentDescription = issueInfo.status, + tint = textColor, + modifier = Modifier.size(14.dp) + ) + Text( + text = "#${issueInfo.id}", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = textColor + ) + ) + } + } +} + +/** + * Issue info card with full details + */ +@Composable +internal fun IdeaIssueInfoCard(issueInfo: cc.unitmesh.agent.tracker.IssueInfo) { + Box( + modifier = Modifier + .fillMaxWidth() + .background( + AutoDevColors.Indigo.c600.copy(alpha = 0.1f), + RoundedCornerShape(4.dp) + ) + .padding(8.dp) + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + IssueInfoHeader(issueInfo = issueInfo) + + Text( + text = issueInfo.title, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ), + maxLines = 2 + ) + + if (issueInfo.description.isNotBlank()) { + Text( + text = issueInfo.description, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.info + ), + maxLines = 3 + ) + } + + if (issueInfo.labels.isNotEmpty()) { + IssueLabelsRow(labels = issueInfo.labels) + } + } + } +} + +@Composable +private fun IssueInfoHeader(issueInfo: cc.unitmesh.agent.tracker.IssueInfo) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = IdeaComposeIcons.BugReport, + contentDescription = "Issue", + tint = AutoDevColors.Indigo.c600, + modifier = Modifier.size(16.dp) + ) + Text( + text = "#${issueInfo.id}", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = AutoDevColors.Indigo.c600 + ) + ) + } + + val (statusBgColor, statusTextColor) = when (issueInfo.status.lowercase()) { + "open" -> AutoDevColors.Green.c600.copy(alpha = 0.2f) to AutoDevColors.Green.c600 + "closed" -> AutoDevColors.Red.c600.copy(alpha = 0.2f) to AutoDevColors.Red.c600 + else -> JewelTheme.globalColors.panelBackground to JewelTheme.globalColors.text.info + } + + Box( + modifier = Modifier + .background(statusBgColor, RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = issueInfo.status, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = statusTextColor + ) + ) + } + } +} + +@Composable +private fun IssueLabelsRow(labels: List) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()) + ) { + labels.take(5).forEach { label -> + Box( + modifier = Modifier + .background( + AutoDevColors.Indigo.c600.copy(alpha = 0.15f), + RoundedCornerShape(3.dp) + ) + .padding(horizontal = 4.dp, vertical = 2.dp) + ) { + Text( + text = label, + style = JewelTheme.defaultTextStyle.copy(fontSize = 10.sp) + ) + } + } + if (labels.size > 5) { + Text( + text = "+${labels.size - 5}", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = JewelTheme.globalColors.text.info + ) + ) + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaDiffComponents.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaDiffComponents.kt new file mode 100644 index 0000000000..cbafae987f --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaDiffComponents.kt @@ -0,0 +1,251 @@ +package cc.unitmesh.devins.idea.toolwindow.codereview + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.agent.diff.ChangeType +import cc.unitmesh.agent.diff.DiffHunk +import cc.unitmesh.agent.diff.DiffLine +import cc.unitmesh.agent.diff.DiffLineType +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons +import cc.unitmesh.devins.ui.compose.agent.codereview.CommitInfo +import cc.unitmesh.devins.ui.compose.agent.codereview.DiffFileInfo +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.Orientation +import org.jetbrains.jewel.ui.component.* + +internal enum class IdeaFileViewMode { LIST, TREE } + +@Composable +internal fun DiffViewerPanel( + diffFiles: List, + selectedCommits: List, + selectedCommitIndices: Set, + isLoadingDiff: Boolean, + onViewFile: ((String) -> Unit)? = null, + onRefreshIssue: ((Int) -> Unit)? = null, + onConfigureToken: () -> Unit = {}, + modifier: Modifier = Modifier +) { + var viewMode by remember { mutableStateOf(IdeaFileViewMode.LIST) } + Column(modifier = modifier.fillMaxSize().background(JewelTheme.globalColors.panelBackground).padding(8.dp)) { + if (selectedCommits.isNotEmpty()) { + IdeaCommitInfoCard(selectedCommits, selectedCommitIndices.toList(), onRefreshIssue, onConfigureToken) + Spacer(modifier = Modifier.height(8.dp)) + } + DiffFilesHeader(diffFiles.size, viewMode) { viewMode = it } + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) + DiffContentArea(diffFiles, selectedCommits, isLoadingDiff, viewMode, onViewFile) + } +} + +@Composable +private fun DiffFilesHeader(fileCount: Int, viewMode: IdeaFileViewMode, onViewModeChange: (IdeaFileViewMode) -> Unit) { + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(text = "Files changed ($fileCount)", style = JewelTheme.defaultTextStyle.copy(fontWeight = FontWeight.Medium, fontSize = 13.sp)) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + IconButton(onClick = { onViewModeChange(IdeaFileViewMode.LIST) }, modifier = Modifier.size(28.dp)) { + Icon(IdeaComposeIcons.List, "List view", tint = if (viewMode == IdeaFileViewMode.LIST) AutoDevColors.Indigo.c600 else JewelTheme.globalColors.text.info, modifier = Modifier.size(16.dp)) + } + IconButton(onClick = { onViewModeChange(IdeaFileViewMode.TREE) }, modifier = Modifier.size(28.dp)) { + Icon(IdeaComposeIcons.AccountTree, "Tree view", tint = if (viewMode == IdeaFileViewMode.TREE) AutoDevColors.Indigo.c600 else JewelTheme.globalColors.text.info, modifier = Modifier.size(16.dp)) + } + } + } +} + +@Composable +private fun DiffContentArea(diffFiles: List, selectedCommits: List, isLoadingDiff: Boolean, viewMode: IdeaFileViewMode, onViewFile: ((String) -> Unit)?) { + when { + isLoadingDiff -> Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { + CircularProgressIndicator() + Text("Loading diff...", style = JewelTheme.defaultTextStyle.copy(color = JewelTheme.globalColors.text.info)) + } + } + diffFiles.isEmpty() -> Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Text(if (selectedCommits.isEmpty()) "Select a commit to view diff" else "No file changes in this commit", style = JewelTheme.defaultTextStyle.copy(color = JewelTheme.globalColors.text.info)) + } + else -> when (viewMode) { + IdeaFileViewMode.LIST -> IdeaCompactFileListView(diffFiles, onViewFile) + IdeaFileViewMode.TREE -> IdeaFileTreeView(diffFiles, onViewFile) + } + } +} + +@Composable +internal fun IdeaCompactFileListView(files: List, onViewFile: ((String) -> Unit)?) { + var expandedFileIndex by remember { mutableStateOf(null) } + LazyColumn(state = rememberLazyListState(), modifier = Modifier.fillMaxSize()) { + itemsIndexed(files) { index, file -> + IdeaCompactFileDiffItem(file, expandedFileIndex == index, { expandedFileIndex = if (expandedFileIndex == index) null else index }, onViewFile) + } + } +} + +@Composable +private fun IdeaCompactFileDiffItem(file: DiffFileInfo, isExpanded: Boolean, onToggleExpand: () -> Unit, onViewFile: ((String) -> Unit)?) { + val changeColor = when (file.changeType) { ChangeType.CREATE -> AutoDevColors.Green.c400; ChangeType.DELETE -> AutoDevColors.Red.c400; ChangeType.RENAME -> AutoDevColors.Amber.c400; else -> AutoDevColors.Blue.c400 } + val changeIcon = when (file.changeType) { ChangeType.CREATE -> IdeaComposeIcons.Add; ChangeType.DELETE -> IdeaComposeIcons.Delete; ChangeType.RENAME -> IdeaComposeIcons.DriveFileRenameOutline; else -> IdeaComposeIcons.Edit } + Column(modifier = Modifier.fillMaxWidth()) { + FileDiffItemHeader(file, isExpanded, changeColor, changeIcon, onToggleExpand, onViewFile) + AnimatedVisibility(visible = isExpanded, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) { + Column(modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 8.dp, bottom = 8.dp).background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f), RoundedCornerShape(4.dp)).padding(8.dp)) { + file.hunks.forEachIndexed { i, hunk -> if (i > 0) Spacer(Modifier.height(8.dp)); IdeaDiffHunkView(hunk) } + } + } + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) + } +} + +@Composable +private fun FileDiffItemHeader(file: DiffFileInfo, isExpanded: Boolean, changeColor: Color, changeIcon: androidx.compose.ui.graphics.vector.ImageVector, onToggleExpand: () -> Unit, onViewFile: ((String) -> Unit)?) { + Row(modifier = Modifier.fillMaxWidth().clickable { onToggleExpand() }.padding(horizontal = 8.dp, vertical = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Icon(if (isExpanded) IdeaComposeIcons.ExpandMore else IdeaComposeIcons.ChevronRight, if (isExpanded) "Collapse" else "Expand", tint = JewelTheme.globalColors.text.info, modifier = Modifier.size(16.dp)) + Icon(changeIcon, file.changeType.name, tint = changeColor, modifier = Modifier.size(14.dp)) + Text(file.path.split("/").lastOrNull() ?: file.path, style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp, fontWeight = FontWeight.Medium)) + val dir = file.path.substringBeforeLast("/", "") + if (dir.isNotEmpty()) Text(dir, style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp, color = JewelTheme.globalColors.text.info.copy(alpha = 0.6f))) + } + FileLineCountBadges(file, onViewFile) + } +} + +@Composable +private fun FileLineCountBadges(file: DiffFileInfo, onViewFile: ((String) -> Unit)?) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + onViewFile?.let { IconButton(onClick = { it(file.path) }, modifier = Modifier.size(24.dp)) { Icon(IdeaComposeIcons.Visibility, "View file", tint = JewelTheme.globalColors.text.info, modifier = Modifier.size(14.dp)) } } + val added = file.hunks.sumOf { h -> h.lines.count { it.type == DiffLineType.ADDED } } + val deleted = file.hunks.sumOf { h -> h.lines.count { it.type == DiffLineType.DELETED } } + if (added > 0) Text("+$added", style = JewelTheme.defaultTextStyle.copy(fontSize = 10.sp, color = AutoDevColors.Green.c400, fontWeight = FontWeight.Bold)) + if (deleted > 0) Text("-$deleted", style = JewelTheme.defaultTextStyle.copy(fontSize = 10.sp, color = AutoDevColors.Red.c400, fontWeight = FontWeight.Bold)) + } +} + +@Composable +internal fun IdeaDiffHunkView(hunk: DiffHunk) { + Column(modifier = Modifier.fillMaxWidth()) { + Text(hunk.header, style = JewelTheme.defaultTextStyle.copy(fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = AutoDevColors.Indigo.c400)) + Spacer(modifier = Modifier.height(4.dp)) + hunk.lines.forEach { line -> if (line.type != DiffLineType.HEADER) IdeaDiffLineView(line) } + } +} + +@Composable +private fun IdeaDiffLineView(line: DiffLine) { + val (bgColor, textColor, prefix) = when (line.type) { + DiffLineType.ADDED -> Triple(AutoDevColors.Green.c400.copy(alpha = 0.15f), AutoDevColors.Green.c400, "+") + DiffLineType.DELETED -> Triple(AutoDevColors.Red.c400.copy(alpha = 0.15f), AutoDevColors.Red.c400, "-") + DiffLineType.CONTEXT -> Triple(Color.Transparent, JewelTheme.globalColors.text.normal, " ") + DiffLineType.HEADER -> return + } + Row(modifier = Modifier.fillMaxWidth().background(bgColor).padding(horizontal = 4.dp, vertical = 1.dp).horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(line.oldLineNumber?.toString()?.padStart(4) ?: " ", style = JewelTheme.defaultTextStyle.copy(fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = JewelTheme.globalColors.text.info.copy(alpha = 0.5f))) + Text(line.newLineNumber?.toString()?.padStart(4) ?: " ", style = JewelTheme.defaultTextStyle.copy(fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = JewelTheme.globalColors.text.info.copy(alpha = 0.5f))) + Text(prefix, style = JewelTheme.defaultTextStyle.copy(fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = textColor, fontWeight = FontWeight.Bold)) + Text(line.content, style = JewelTheme.defaultTextStyle.copy(fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = textColor)) + } +} + +internal sealed class FileTreeNode { + data class Directory(val name: String, val path: String, val files: List) : FileTreeNode() + data class File(val file: DiffFileInfo) : FileTreeNode() +} + +private fun buildFileTreeStructure(files: List): List { + val result = mutableListOf() + val directoryMap = mutableMapOf>() + files.forEach { file -> + val directory = file.path.substringBeforeLast("/", "") + if (directory.isEmpty()) result.add(FileTreeNode.File(file)) + else directoryMap.getOrPut(directory) { mutableListOf() }.add(file) + } + directoryMap.entries.sortedBy { it.key }.forEach { (path, dirFiles) -> + result.add(FileTreeNode.Directory(path.split("/").lastOrNull() ?: path, path, dirFiles.sortedBy { it.path })) + } + return result +} + +@Composable +internal fun IdeaFileTreeView(files: List, onViewFile: ((String) -> Unit)?) { + val treeNodes = remember(files) { buildFileTreeStructure(files) } + var expandedDirs by remember { mutableStateOf(setOf()) } + var expandedFilePath by remember { mutableStateOf(null) } + + LazyColumn(state = rememberLazyListState(), modifier = Modifier.fillMaxSize()) { + treeNodes.forEach { node -> + when (node) { + is FileTreeNode.Directory -> { + item(key = "dir_${node.path}") { + IdeaDirectoryTreeItem(node, expandedDirs.contains(node.path)) { + expandedDirs = if (expandedDirs.contains(node.path)) expandedDirs - node.path else expandedDirs + node.path + } + } + if (expandedDirs.contains(node.path)) { + node.files.forEachIndexed { index, file -> + item(key = "file_${node.path}_$index") { + IdeaFileTreeItemCompact(file, expandedFilePath == file.path, { expandedFilePath = if (expandedFilePath == file.path) null else file.path }, onViewFile, 1) + } + } + } + } + is FileTreeNode.File -> { + item(key = "file_root_${node.file.path}") { + IdeaFileTreeItemCompact(node.file, expandedFilePath == node.file.path, { expandedFilePath = if (expandedFilePath == node.file.path) null else node.file.path }, onViewFile, 0) + } + } + } + } + } +} + +@Composable +private fun IdeaDirectoryTreeItem(directory: FileTreeNode.Directory, isExpanded: Boolean, onToggle: () -> Unit) { + Row(modifier = Modifier.fillMaxWidth().clickable { onToggle() }.padding(horizontal = 8.dp, vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(if (isExpanded) IdeaComposeIcons.ExpandMore else IdeaComposeIcons.ChevronRight, if (isExpanded) "Collapse" else "Expand", tint = JewelTheme.globalColors.text.info, modifier = Modifier.size(16.dp)) + Icon(if (isExpanded) IdeaComposeIcons.FolderOpen else IdeaComposeIcons.Folder, "Directory", tint = AutoDevColors.Amber.c400, modifier = Modifier.size(16.dp)) + Text(directory.name, style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp, fontWeight = FontWeight.Medium)) + Text("(${directory.files.size})", style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp, color = JewelTheme.globalColors.text.info.copy(alpha = 0.6f))) + } +} + +@Composable +private fun IdeaFileTreeItemCompact(file: DiffFileInfo, isExpanded: Boolean, onToggleExpand: () -> Unit, onViewFile: ((String) -> Unit)?, indentLevel: Int) { + val changeColor = when (file.changeType) { ChangeType.CREATE -> AutoDevColors.Green.c400; ChangeType.DELETE -> AutoDevColors.Red.c400; ChangeType.RENAME -> AutoDevColors.Amber.c400; else -> AutoDevColors.Blue.c400 } + Column(modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.fillMaxWidth().clickable { onToggleExpand() }.padding(start = (8 + indentLevel * 16).dp, end = 8.dp, top = 4.dp, bottom = 4.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Icon(if (isExpanded) IdeaComposeIcons.ExpandMore else IdeaComposeIcons.ChevronRight, if (isExpanded) "Collapse" else "Expand", tint = JewelTheme.globalColors.text.info, modifier = Modifier.size(14.dp)) + Icon(IdeaComposeIcons.Description, "File", tint = changeColor, modifier = Modifier.size(14.dp)) + Text(file.path.split("/").lastOrNull() ?: file.path, style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp)) + } + onViewFile?.let { IconButton(onClick = { it(file.path) }, modifier = Modifier.size(20.dp)) { Icon(IdeaComposeIcons.Visibility, "View file", tint = JewelTheme.globalColors.text.info, modifier = Modifier.size(12.dp)) } } + } + AnimatedVisibility(visible = isExpanded, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) { + Column(modifier = Modifier.fillMaxWidth().padding(start = (24 + indentLevel * 16).dp, end = 8.dp, bottom = 8.dp).background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f), RoundedCornerShape(4.dp)).padding(8.dp)) { + file.hunks.forEachIndexed { i, hunk -> if (i > 0) Spacer(Modifier.height(8.dp)); IdeaDiffHunkView(hunk) } + } + } + } +} 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 new file mode 100644 index 0000000000..fb860b8086 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt @@ -0,0 +1,809 @@ +package cc.unitmesh.devins.idea.toolwindow.knowledge + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +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.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 +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import kotlinx.coroutines.flow.distinctUntilChanged +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.Orientation +import org.jetbrains.jewel.ui.component.* + +/** + * Main content view for Knowledge Agent in IntelliJ IDEA. + * Provides document browsing, search, and AI-powered document querying. + * + * Layout (using resizable split panes): + * - Left: Document list with search (resizable) + * - Center: Document content viewer + Structured info pane (vertical split) + * - Right: AI Chat interface (resizable) + */ +@Composable +fun IdeaKnowledgeContent( + viewModel: IdeaKnowledgeViewModel, + modifier: Modifier = Modifier +) { + val state by viewModel.state.collectAsState() + val timeline by viewModel.renderer.timeline.collectAsState() + val streamingOutput by viewModel.renderer.currentStreamingOutput.collectAsState() + + // Left panel + (Center + Right) split + IdeaResizableSplitPane( + modifier = modifier.fillMaxSize(), + initialSplitRatio = 0.18f, + minRatio = 0.12f, + maxRatio = 0.35f, + first = { + // Left panel: Document list with search + DocumentListPanel( + documents = state.filteredDocuments, + selectedDocument = state.selectedDocument, + searchQuery = state.searchQuery, + isLoading = state.isLoading, + onSearchQueryChange = { viewModel.updateSearchQuery(it) }, + onDocumentSelect = { viewModel.selectDocument(it) }, + onRefresh = { viewModel.refreshDocuments() }, + modifier = Modifier.fillMaxSize() + ) + }, + second = { + // Center + Right split + IdeaResizableSplitPane( + modifier = Modifier.fillMaxSize(), + initialSplitRatio = 0.65f, + minRatio = 0.4f, + maxRatio = 0.85f, + first = { + // Center panel: Document content viewer + Structured info (vertical split) + IdeaVerticalResizableSplitPane( + modifier = Modifier.fillMaxSize(), + initialSplitRatio = 0.7f, + minRatio = 0.3f, + maxRatio = 0.9f, + top = { + DocumentContentPanel( + document = state.selectedDocument, + content = state.documentContent ?: state.parsedContent, + isLoading = state.isLoading, + targetLineNumber = state.targetLineNumber, + highlightedText = state.highlightedText, + modifier = Modifier.fillMaxSize() + ) + }, + bottom = { + // Structured info pane (TOC + Entities) + IdeaStructuredInfoPane( + toc = state.selectedDocument?.toc ?: emptyList(), + entities = state.selectedDocument?.entities ?: emptyList(), + onTocSelected = { viewModel.navigateToTocItem(it) }, + onEntitySelected = { viewModel.navigateToEntity(it) }, + modifier = Modifier.fillMaxSize() + ) + } + ) + }, + second = { + // Right panel: AI Chat interface + AIChatPanel( + timeline = timeline, + streamingOutput = streamingOutput, + isGenerating = state.isGenerating, + onSendMessage = { viewModel.sendMessage(it) }, + onStopGeneration = { viewModel.stopGeneration() }, + onClearHistory = { viewModel.clearChatHistory() }, + modifier = Modifier.fillMaxSize() + ) + } + ) + } + ) +} + +/** + * Document list panel with search functionality + */ +@Composable +private fun DocumentListPanel( + documents: List, + selectedDocument: IdeaDocumentFile?, + searchQuery: String, + isLoading: Boolean, + onSearchQueryChange: (String) -> Unit, + onDocumentSelect: (IdeaDocumentFile) -> Unit, + onRefresh: () -> Unit, + modifier: Modifier = Modifier +) { + val searchTextFieldState = rememberTextFieldState(searchQuery) + + // Sync text field state changes to callback + LaunchedEffect(Unit) { + snapshotFlow { searchTextFieldState.text.toString() } + .distinctUntilChanged() + .collect { onSearchQueryChange(it) } + } + + // Sync external searchQuery changes to text field state + LaunchedEffect(searchQuery) { + if (searchTextFieldState.text.toString() != searchQuery) { + searchTextFieldState.setTextAndPlaceCursorAtEnd(searchQuery) + } + } + + Column( + modifier = modifier + .fillMaxHeight() + .background(JewelTheme.globalColors.panelBackground) + .padding(8.dp) + ) { + // Header with title and refresh button + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Documents", + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + ) + IconButton(onClick = onRefresh) { + Icon( + imageVector = IdeaComposeIcons.Refresh, + contentDescription = "Refresh", + modifier = Modifier.size(16.dp), + tint = JewelTheme.globalColors.text.normal + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Search input + TextField( + state = searchTextFieldState, + placeholder = { Text("Search documents...") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Document count + Text( + text = "${documents.size} documents", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = JewelTheme.globalColors.text.info + ) + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Document list + if (isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text("Loading...", style = JewelTheme.defaultTextStyle) + } + } else if (documents.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No documents found", + style = JewelTheme.defaultTextStyle.copy( + color = JewelTheme.globalColors.text.info + ) + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + items(documents, key = { it.path }) { doc -> + DocumentListItem( + document = doc, + isSelected = doc.path == selectedDocument?.path, + onClick = { onDocumentSelect(doc) } + ) + } + } + } + } +} + +/** + * Single document list item + */ +@Composable +private fun DocumentListItem( + document: IdeaDocumentFile, + isSelected: Boolean, + onClick: () -> Unit +) { + val backgroundColor = if (isSelected) { + AutoDevColors.Blue.c400.copy(alpha = 0.15f) + } else { + JewelTheme.globalColors.panelBackground + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor) + .clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // File icon based on type + val icon = when (document.metadata.formatType) { + "MARKDOWN" -> IdeaComposeIcons.Description + "PDF" -> IdeaComposeIcons.Description + "SOURCE_CODE" -> IdeaComposeIcons.Code + else -> IdeaComposeIcons.Description + } + + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = if (isSelected) AutoDevColors.Blue.c400 else JewelTheme.globalColors.text.info + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = document.name, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 13.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ), + maxLines = 1 + ) + Text( + text = document.path, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.info + ), + maxLines = 1 + ) + } + } +} + +/** + * Document content viewer panel (TOC moved to IdeaStructuredInfoPane) + */ +@Composable +private fun DocumentContentPanel( + document: IdeaDocumentFile?, + content: String?, + isLoading: Boolean, + targetLineNumber: Int?, + highlightedText: String?, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxHeight() + .background(JewelTheme.globalColors.panelBackground) + ) { + if (document == null) { + // Empty state + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.Description, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = JewelTheme.globalColors.text.info.copy(alpha = 0.5f) + ) + Text( + text = "Select a document to view", + style = JewelTheme.defaultTextStyle.copy( + color = JewelTheme.globalColors.text.info + ) + ) + } + } + } else if (isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text("Loading document...", style = JewelTheme.defaultTextStyle) + } + } else { + // Document header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = document.name, + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + ) + Text( + text = document.path, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.info + ) + ) + } + } + + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) + + // Content viewer + if (content != null) { + val listState = rememberLazyListState() + val lines = remember(content) { content.lines() } + + // Auto-scroll to target line + LaunchedEffect(targetLineNumber) { + targetLineNumber?.let { lineNum -> + if (lineNum > 0 && lineNum <= lines.size) { + listState.animateScrollToItem(lineNum - 1) + } + } + } + + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(8.dp) + ) { + items(lines.size) { index -> + val lineNumber = index + 1 + val line = lines[index] + val isHighlighted = targetLineNumber == lineNumber || + (highlightedText != null && line.contains(highlightedText)) + + Row( + modifier = Modifier + .fillMaxWidth() + .background( + if (isHighlighted) AutoDevColors.Amber.c400.copy(alpha = 0.2f) + else JewelTheme.globalColors.panelBackground + ) + ) { + // Line number + Text( + text = lineNumber.toString().padStart(4, ' '), + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = JewelTheme.globalColors.text.info + ), + modifier = Modifier.width(40.dp) + ) + // Line content + Text( + text = line, + style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp), + modifier = Modifier.weight(1f) + ) + } + } + } + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No content available", + style = JewelTheme.defaultTextStyle.copy( + color = JewelTheme.globalColors.text.info + ) + ) + } + } + } + } +} + +/** + * AI Chat panel for document queries + */ +@Composable +private fun AIChatPanel( + timeline: List, + streamingOutput: String, + isGenerating: Boolean, + onSendMessage: (String) -> Unit, + onStopGeneration: () -> Unit, + onClearHistory: () -> Unit, + modifier: Modifier = Modifier +) { + val inputTextFieldState = rememberTextFieldState() + var inputText by remember { mutableStateOf("") } + val listState = rememberLazyListState() + + // Sync text field state to inputText + LaunchedEffect(Unit) { + snapshotFlow { inputTextFieldState.text.toString() } + .distinctUntilChanged() + .collect { inputText = it } + } + + // Auto-scroll to bottom when new messages arrive + LaunchedEffect(timeline.size, streamingOutput) { + if (timeline.isNotEmpty() || streamingOutput.isNotEmpty()) { + val targetIndex = if (streamingOutput.isNotEmpty()) timeline.size else timeline.lastIndex.coerceAtLeast(0) + if (targetIndex >= 0) { + listState.animateScrollToItem(targetIndex) + } + } + } + + Column( + modifier = modifier + .fillMaxHeight() + .background(JewelTheme.globalColors.panelBackground) + ) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Knowledge Assistant", + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + ) + IconButton(onClick = onClearHistory) { + Icon( + imageVector = IdeaComposeIcons.Delete, + contentDescription = "Clear history", + modifier = Modifier.size(16.dp), + tint = JewelTheme.globalColors.text.normal + ) + } + } + + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) + + // Chat messages + LazyColumn( + state = listState, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (timeline.isEmpty() && streamingOutput.isEmpty()) { + item { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Ask questions about your documents", + style = JewelTheme.defaultTextStyle.copy( + color = JewelTheme.globalColors.text.info + ) + ) + Text( + text = "Examples:", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + ) + Text( + text = "• What is the main topic of this document?\n• Summarize the architecture section\n• Find all mentions of 'API'", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = JewelTheme.globalColors.text.info + ) + ) + } + } + } + } else { + items(timeline, key = { it.id }) { item -> + ChatMessageItem(item) + } + + // Show streaming output + if (streamingOutput.isNotEmpty()) { + item { + StreamingMessageItem(streamingOutput) + } + } + } + } + + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) + + // Input area + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + state = inputTextFieldState, + placeholder = { Text("Ask about your documents...") }, + modifier = Modifier.weight(1f), + enabled = !isGenerating + ) + + if (isGenerating) { + OutlinedButton(onClick = onStopGeneration) { + Text("Stop") + } + } else { + DefaultButton( + onClick = { + if (inputText.isNotBlank()) { + onSendMessage(inputText) + inputTextFieldState.edit { replace(0, length, "") } + } + }, + enabled = inputText.isNotBlank() + ) { + Text("Send") + } + } + } + } +} + +/** + * Chat message item renderer + */ +@Composable +private fun ChatMessageItem(item: TimelineItem) { + when (item) { + is TimelineItem.MessageItem -> { + val isUser = item.role == MessageRole.USER + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start + ) { + Box( + modifier = Modifier + .widthIn(max = 350.dp) + .background( + if (isUser) AutoDevColors.Blue.c400.copy(alpha = 0.2f) + else JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f) + ) + .padding(8.dp) + ) { + Text( + text = item.content, + style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp) + ) + } + } + } + + is TimelineItem.ToolCallItem -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start + ) { + Box( + modifier = Modifier + .widthIn(max = 350.dp) + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.3f)) + .padding(8.dp) + ) { + Column { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val statusIcon = when (item.success) { + true -> "✓" + false -> "✗" + null -> "⏳" + } + val statusColor = when (item.success) { + true -> AutoDevColors.Green.c400 + false -> AutoDevColors.Red.c400 + null -> JewelTheme.globalColors.text.info + } + Text( + text = statusIcon, + style = JewelTheme.defaultTextStyle.copy(color = statusColor) + ) + Text( + text = item.toolName, + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 12.sp + ) + ) + } + item.output?.let { output -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = output.take(200) + if (output.length > 200) "..." else "", + style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp) + ) + } + } + } + } + } + + is TimelineItem.ErrorItem -> { + Box( + modifier = Modifier + .fillMaxWidth() + .background(AutoDevColors.Red.c400.copy(alpha = 0.2f)) + .padding(8.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = IdeaComposeIcons.Error, + contentDescription = "Error", + modifier = Modifier.size(16.dp), + tint = AutoDevColors.Red.c400 + ) + Text( + text = item.message, + style = JewelTheme.defaultTextStyle.copy( + color = AutoDevColors.Red.c400, + fontSize = 12.sp + ) + ) + } + } + } + + is TimelineItem.TaskCompleteItem -> { + Box( + modifier = Modifier + .fillMaxWidth() + .background( + if (item.success) AutoDevColors.Green.c400.copy(alpha = 0.2f) + else AutoDevColors.Red.c400.copy(alpha = 0.2f) + ) + .padding(8.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "${item.message} (${item.iterations} iterations)", + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 12.sp + ) + ) + } + } + + is TimelineItem.TerminalOutputItem -> { + 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 = item.output.take(500) + if (item.output.length > 500) "..." else "", + style = JewelTheme.defaultTextStyle.copy( + color = AutoDevColors.Neutral.c300, + fontSize = 11.sp + ) + ) + } + } + } + + 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 + ) + ) + } + } + } + } +} + +/** + * Streaming message item + */ +@Composable +private fun StreamingMessageItem(content: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start + ) { + Box( + modifier = Modifier + .widthIn(max = 350.dp) + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f)) + .padding(8.dp) + ) { + Text( + text = content + "▌", + style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp) + ) + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeModels.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeModels.kt new file mode 100644 index 0000000000..d327958be1 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeModels.kt @@ -0,0 +1,122 @@ +package cc.unitmesh.devins.idea.toolwindow.knowledge + +import cc.unitmesh.devins.document.DocumentFile +import cc.unitmesh.devins.document.DocumentMetadata +import cc.unitmesh.devins.document.TOCItem +import cc.unitmesh.devins.document.Entity + +/** + * State for Knowledge Agent in IntelliJ IDEA plugin. + * Aligned with DocumentReaderViewModel from mpp-ui. + */ +data class IdeaKnowledgeState( + // Document list + val documents: List = emptyList(), + val filteredDocuments: List = emptyList(), + val selectedDocument: IdeaDocumentFile? = null, + + // Document content + val documentContent: String? = null, + val parsedContent: String? = null, + + // Search + val searchQuery: String = "", + + // Loading states + val isLoading: Boolean = false, + val isIndexing: Boolean = false, + val isGenerating: Boolean = false, + + // Index status + val indexingProgress: IndexingProgress = IndexingProgress(), + + // Navigation + val targetLineNumber: Int? = null, + val highlightedText: String? = null, + + // Error + val error: String? = null +) + +/** + * Document file representation for IDEA plugin. + * Simplified version of DocumentFile from mpp-core. + */ +data class IdeaDocumentFile( + val name: String, + val path: String, + val metadata: IdeaDocumentMetadata, + val toc: List = emptyList(), + val entities: List = emptyList() +) { + companion object { + fun fromDocumentFile(doc: DocumentFile): IdeaDocumentFile { + return IdeaDocumentFile( + name = doc.name, + path = doc.path, + metadata = IdeaDocumentMetadata( + totalPages = doc.metadata.totalPages, + chapterCount = doc.metadata.chapterCount, + lastModified = doc.metadata.lastModified, + fileSize = doc.metadata.fileSize, + language = doc.metadata.language, + mimeType = doc.metadata.mimeType, + formatType = doc.metadata.formatType.name + ), + toc = doc.toc, + entities = doc.entities + ) + } + } +} + +/** + * Document metadata for IDEA plugin. + */ +data class IdeaDocumentMetadata( + val totalPages: Int? = null, + val chapterCount: Int = 0, + val lastModified: Long = 0, + val fileSize: Long = 0, + val language: String? = null, + val mimeType: String? = null, + val formatType: String = "PLAIN_TEXT" +) + +/** + * Indexing progress information. + */ +data class IndexingProgress( + val status: IndexingStatus = IndexingStatus.IDLE, + val totalDocuments: Int = 0, + val processedDocuments: Int = 0, + val currentDocument: String? = null, + val message: String? = null +) + +/** + * Indexing status enum. + */ +enum class IndexingStatus { + IDLE, + SCANNING, + INDEXING, + COMPLETED, + ERROR +} + +// TODO: Implement document indexing feature using IdeaDocumentIndexRecord +// /** +// * Document index record for tracking indexed documents. +// * Reserved for future indexing features. +// */ +// data class IdeaDocumentIndexRecord( +// val path: String, +// val hash: String, +// val lastModified: Long, +// val status: String, +// val content: String? = null, +// val error: String? = null, +// val indexedAt: Long +// ) + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeViewModel.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeViewModel.kt new file mode 100644 index 0000000000..4ce05dffce --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeViewModel.kt @@ -0,0 +1,415 @@ +package cc.unitmesh.devins.idea.toolwindow.knowledge + +import cc.unitmesh.agent.config.McpToolConfigService +import cc.unitmesh.agent.config.ToolConfigFile +import cc.unitmesh.agent.document.DocumentAgent +import cc.unitmesh.agent.document.DocumentTask +import cc.unitmesh.devins.document.* +import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.devins.ui.config.ConfigManager +import cc.unitmesh.llm.KoogLLMService +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File + +/** + * ViewModel for Knowledge Agent in IntelliJ IDEA plugin. + * Adapted from mpp-ui's DocumentReaderViewModel for IntelliJ platform. + * + * Uses mpp-core's DocumentAgent for document queries and + * JewelRenderer for UI rendering. + */ +class IdeaKnowledgeViewModel( + private val project: Project, + private val coroutineScope: CoroutineScope +) : Disposable { + + private val projectPath: String = project.basePath ?: "" + + // Renderer for agent output + val renderer = JewelRenderer() + + // State + private val _state = MutableStateFlow(IdeaKnowledgeState()) + val state: StateFlow = _state.asStateFlow() + + // Control execution + private var initJob: Job? = null + private var currentJob: Job? = null + private var documentAgent: DocumentAgent? = null + private var llmService: KoogLLMService? = null + private var agentInitialized = false + + init { + // Initialize platform-specific parsers (Tika on JVM) + DocumentRegistry.initializePlatformParsers() + + if (projectPath.isEmpty()) { + updateState { it.copy(error = "No project path available") } + } else { + // Launch initialization on IO dispatcher to avoid EDT violations + initJob = coroutineScope.launch(Dispatchers.IO) { + try { + initializeLLMService() + loadDocuments() + } catch (e: CancellationException) { + // Intentional cancellation - no error message needed + } catch (e: Exception) { + updateState { it.copy(error = "Failed to initialize: ${e.message}") } + } + } + } + } + + /** + * Initialize LLM service and DocumentAgent + */ + private suspend fun initializeLLMService() { + try { + val configWrapper = ConfigManager.load() + val activeConfig = configWrapper.getActiveModelConfig() + if (activeConfig != null && activeConfig.isValid()) { + llmService = KoogLLMService.create(activeConfig) + + // Create DocumentAgent + val toolConfigFile = ToolConfigFile.default() + val mcpConfigService = McpToolConfigService(toolConfigFile) + + documentAgent = DocumentAgent( + llmService = llmService!!, + parserService = MarkdownDocumentParser(), + renderer = renderer, + fileSystem = null, + shellExecutor = null, + mcpToolConfigService = mcpConfigService, + enableLLMStreaming = true + ) + agentInitialized = true + } + } catch (e: Exception) { + updateState { it.copy(error = "Failed to initialize LLM service: ${e.message}") } + } + } + + /** + * Load documents from project + */ + private suspend fun loadDocuments() { + updateState { it.copy(isLoading = true, error = null) } + + try { + val documents = scanProjectDocuments() + updateState { + it.copy( + isLoading = false, + documents = documents, + filteredDocuments = documents, + error = null + ) + } + } catch (e: Exception) { + updateState { + it.copy(isLoading = false, error = "Failed to load documents: ${e.message}") + } + } + } + + /** + * Scan project for supported documents + */ + private fun scanProjectDocuments(): List { + val projectDir = File(projectPath) + if (!projectDir.exists()) return emptyList() + + val supportedExtensions = setOf( + "md", "markdown", "txt", "pdf", "doc", "docx", + "kt", "java", "py", "js", "ts", "html", "xml" + ) + + return projectDir.walkTopDown() + .filter { file -> + file.isFile && + file.extension.lowercase() in supportedExtensions && + !file.path.contains(".git") && + !file.path.contains("node_modules") && + !file.path.contains("build") && + !file.path.contains("target") + } + .take(1000) // Limit to first 1000 files + .map { file -> + val relativePath = file.relativeTo(projectDir).path + val formatType = DocumentParserFactory.detectFormat(file.name) + ?: DocumentFormatType.PLAIN_TEXT + + IdeaDocumentFile( + name = file.name, + path = relativePath, + metadata = IdeaDocumentMetadata( + lastModified = file.lastModified(), + fileSize = file.length(), + language = file.extension, + mimeType = DocumentParserFactory.getMimeType(file.name), + formatType = formatType.name + ) + ) + } + .toList() + } + + /** + * Update search query and filter documents + */ + fun updateSearchQuery(query: String) { + val currentState = _state.value + val filtered = if (query.isBlank()) { + currentState.documents + } else { + val lowerQuery = query.lowercase() + currentState.documents.filter { doc -> + doc.name.lowercase().contains(lowerQuery) || + doc.path.lowercase().contains(lowerQuery) + } + } + + updateState { + it.copy( + searchQuery = query, + filteredDocuments = filtered + ) + } + } + + /** + * Select a document + */ + fun selectDocument(document: IdeaDocumentFile) { + // Launch on IO dispatcher to avoid EDT violations + coroutineScope.launch(Dispatchers.IO) { + loadDocumentContent(document) + } + } + + /** + * Load document content - runs on IO dispatcher + */ + private suspend fun loadDocumentContent(document: IdeaDocumentFile) { + updateState { it.copy(isLoading = true, error = null) } + + try { + val file = withContext(Dispatchers.IO) { File(projectPath, document.path) } + if (!file.exists()) { + updateState { + it.copy(isLoading = false, error = "File not found: ${document.path}") + } + return + } + + val formatType = DocumentParserFactory.detectFormat(document.path) + val isBinary = formatType?.let { DocumentParserFactory.isBinaryFormat(it) } ?: false + + val parser = DocumentParserFactory.createParserForFile(document.path) + if (parser == null) { + updateState { + it.copy(isLoading = false, error = "No parser available for: ${document.path}") + } + return + } + + val docFile = DocumentFile( + name = document.name, + path = document.path, + metadata = DocumentMetadata( + lastModified = file.lastModified(), + fileSize = file.length(), + formatType = formatType ?: DocumentFormatType.PLAIN_TEXT + ) + ) + + // Read and parse file content on IO dispatcher + val (content, parsedDoc) = withContext(Dispatchers.IO) { + if (isBinary) { + val bytes = file.readBytes() + val parsed = parser.parseBytes(docFile, bytes) + null to parsed + } else { + val textContent = file.readText() + val parsed = parser.parse(docFile, textContent) + textContent to parsed + } + } + + // Register document with DocumentRegistry for DocQL queries + DocumentRegistry.registerDocument(document.path, parsedDoc, parser) + + val parsedContent = parser.getDocumentContent() + + // Update state with loaded content + val updatedDoc = if (parsedDoc is DocumentFile) { + document.copy( + toc = parsedDoc.toc, + entities = parsedDoc.entities + ) + } else { + document + } + + updateState { + it.copy( + isLoading = false, + selectedDocument = updatedDoc, + documentContent = content, + parsedContent = parsedContent, + error = null + ) + } + } catch (e: Exception) { + updateState { + it.copy( + isLoading = false, + documentContent = null, + parsedContent = null, + error = "Failed to load document: ${e.message}" + ) + } + } + } + + /** + * Send a message to the DocumentAgent + */ + fun sendMessage(text: String) { + if (_state.value.isGenerating) return + + currentJob?.cancel() + currentJob = coroutineScope.launch { + try { + updateState { it.copy(isGenerating = true, error = null) } + renderer.addUserMessage(text) + + val agent = documentAgent + if (agent == null) { + renderer.renderError("LLM service not initialized. Please configure your model settings.") + updateState { it.copy(isGenerating = false) } + return@launch + } + + val task = DocumentTask( + query = text, + documentPath = _state.value.selectedDocument?.path + ) + + agent.execute(task) { _ -> } + } catch (e: CancellationException) { + renderer.forceStop() + // Intentional cancellation - no error message needed + } catch (e: Exception) { + renderer.renderError("Error: ${e.message}") + } finally { + updateState { it.copy(isGenerating = false) } + currentJob = null + } + } + } + + /** + * Stop current generation + */ + fun stopGeneration() { + currentJob?.cancel() + currentJob = null + renderer.forceStop() + updateState { it.copy(isGenerating = false) } + } + + /** + * Clear chat history + */ + fun clearChatHistory() { + renderer.clearTimeline() + currentJob?.cancel() + currentJob = null + updateState { it.copy(isGenerating = false) } + } + + /** + * Navigate to a specific line number + */ + fun navigateToLine(lineNumber: Int, highlightText: String? = null) { + updateState { + it.copy( + targetLineNumber = lineNumber, + highlightedText = highlightText + ) + } + } + + /** + * Navigate to a TOC item + */ + fun navigateToTocItem(tocItem: TOCItem) { + val lineNum = tocItem.lineNumber + if (lineNum != null) { + navigateToLine(lineNum, tocItem.title) + } else { + // Fallback: search for the heading text in content + val content = _state.value.documentContent ?: return + val headingPattern = Regex("^#{1,6}\\s+${Regex.escape(tocItem.title)}\\s*$", RegexOption.MULTILINE) + val match = headingPattern.find(content) + if (match != null) { + val lineNumber = content.substring(0, match.range.first).count { it == '\n' } + 1 + navigateToLine(lineNumber, tocItem.title) + } + } + } + + /** + * Navigate to an entity in the document + */ + fun navigateToEntity(entity: cc.unitmesh.devins.document.Entity) { + val content = _state.value.documentContent ?: return + // Search for the entity name in the content + val entityName = entity.name + val pattern = Regex("\\b${Regex.escape(entityName)}\\b") + val match = pattern.find(content) + if (match != null) { + val lineNumber = content.substring(0, match.range.first).count { it == '\n' } + 1 + navigateToLine(lineNumber, entityName) + } + } + + /** + * Clear navigation state + */ + fun clearNavigation() { + updateState { + it.copy( + targetLineNumber = null, + highlightedText = null + ) + } + } + + /** + * Refresh documents list + */ + fun refreshDocuments() { + // Launch on IO dispatcher to avoid EDT violations + coroutineScope.launch(Dispatchers.IO) { + loadDocuments() + } + } + + private fun updateState(update: (IdeaKnowledgeState) -> IdeaKnowledgeState) { + _state.value = update(_state.value) + } + + override fun dispose() { + initJob?.cancel() + currentJob?.cancel() + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaStructuredInfoPane.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaStructuredInfoPane.kt new file mode 100644 index 0000000000..046e0b8b02 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaStructuredInfoPane.kt @@ -0,0 +1,218 @@ +package cc.unitmesh.devins.idea.toolwindow.knowledge + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.devins.document.Entity +import cc.unitmesh.devins.document.TOCItem +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.Orientation +import org.jetbrains.jewel.ui.component.* + +/** + * Structured information pane for displaying TOC and entities. + * Adapted from mpp-ui's StructuredInfoPane for Jewel theme. + */ +@Composable +fun IdeaStructuredInfoPane( + toc: List, + entities: List, + onTocSelected: (TOCItem) -> Unit, + onEntitySelected: (Entity) -> Unit, + modifier: Modifier = Modifier +) { + var tocExpanded by remember { mutableStateOf(true) } + var entitiesExpanded by remember { mutableStateOf(false) } + + // Reset expansion state when content changes + LaunchedEffect(toc, entities) { + tocExpanded = true + entitiesExpanded = false + } + + Column(modifier = modifier.fillMaxSize().padding(8.dp)) { + // TOC Section + IdeaCollapsibleSection( + title = "Table of Contents", + count = toc.size, + expanded = tocExpanded, + onToggle = { tocExpanded = !tocExpanded }, + icon = IdeaComposeIcons.List + ) { + if (toc.isEmpty()) { + Text( + text = "No table of contents", + style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp, color = JewelTheme.globalColors.text.info), + modifier = Modifier.padding(12.dp) + ) + } else { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(2.dp)) { + toc.forEach { item -> IdeaTocItemRow(item, onTocSelected) } + } + } + } + + Spacer(Modifier.height(8.dp)) + + // Entities Section + IdeaCollapsibleSection( + title = "Entities", + count = entities.size, + expanded = entitiesExpanded, + onToggle = { entitiesExpanded = !entitiesExpanded }, + icon = IdeaComposeIcons.Code + ) { + if (entities.isEmpty()) { + Text( + text = "No entities extracted", + style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp, color = JewelTheme.globalColors.text.info), + modifier = Modifier.padding(12.dp) + ) + } else { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + entities.forEach { entity -> IdeaEntityItemRow(entity, onEntitySelected) } + } + } + } + } +} + +@Composable +private fun IdeaCollapsibleSection( + title: String, + count: Int, + expanded: Boolean, + onToggle: () -> Unit, + icon: ImageVector, + content: @Composable () -> Unit +) { + Box( + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f)) + ) { + Column { + // Header + Row( + Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(icon, null, Modifier.size(16.dp), AutoDevColors.Indigo.c400) + Text(title, style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp, fontWeight = FontWeight.SemiBold)) + if (count > 0) { + Box( + Modifier.clip(RoundedCornerShape(4.dp)) + .background(AutoDevColors.Indigo.c100.copy(0.5f)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text(count.toString(), style = JewelTheme.defaultTextStyle.copy(fontSize = 10.sp, color = AutoDevColors.Indigo.c700)) + } + } + } + Icon( + if (expanded) IdeaComposeIcons.ExpandLess else IdeaComposeIcons.ExpandMore, + if (expanded) "Collapse" else "Expand", + Modifier.size(16.dp), + JewelTheme.globalColors.text.info + ) + } + + // Content + if (expanded) { + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) + Box(Modifier.padding(8.dp)) { content() } + } + } + } +} + +@Composable +private fun IdeaTocItemRow(item: TOCItem, onTocSelected: (TOCItem) -> Unit) { + val safeLevel = item.level.coerceAtLeast(1) + Column { + Row( + Modifier.fillMaxWidth() + .clickable { onTocSelected(item) } + .padding(start = ((safeLevel - 1) * 12).dp, top = 4.dp, bottom = 4.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + item.title, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + fontWeight = if (safeLevel == 1) FontWeight.SemiBold else FontWeight.Normal + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + item.page?.let { page -> + Text("P$page", style = JewelTheme.defaultTextStyle.copy(fontSize = 10.sp, color = JewelTheme.globalColors.text.info)) + } + } + item.children.forEach { child -> IdeaTocItemRow(child, onTocSelected) } + } +} + +@Composable +private fun IdeaEntityItemRow(entity: Entity, onEntitySelected: (Entity) -> Unit) { + Box( + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.3f)) + .clickable { onEntitySelected(entity) } + .padding(8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val (icon, color) = when (entity) { + is Entity.Term -> IdeaComposeIcons.Description to AutoDevColors.Green.c400 + is Entity.API -> IdeaComposeIcons.Cloud to AutoDevColors.Blue.c400 + is Entity.ClassEntity -> IdeaComposeIcons.Code to AutoDevColors.Indigo.c400 + is Entity.FunctionEntity -> IdeaComposeIcons.Terminal to AutoDevColors.Amber.c400 + is Entity.ConstructorEntity -> IdeaComposeIcons.Build to AutoDevColors.Cyan.c400 + } + Icon(icon, null, Modifier.size(16.dp), color) + + Column(Modifier.weight(1f)) { + Text( + entity.name, + style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp, fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + val desc = when (entity) { + is Entity.Term -> entity.definition + is Entity.API -> entity.signature + is Entity.ClassEntity -> "Class" + is Entity.FunctionEntity -> entity.signature + is Entity.ConstructorEntity -> entity.signature ?: "Constructor" + } + if (!desc.isNullOrEmpty()) { + Text( + desc, + style = JewelTheme.defaultTextStyle.copy(fontSize = 10.sp, color = JewelTheme.globalColors.text.info), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentClient.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentClient.kt new file mode 100644 index 0000000000..e6ba3bc69d --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentClient.kt @@ -0,0 +1,154 @@ +package cc.unitmesh.devins.idea.toolwindow.remote + +import cc.unitmesh.agent.RemoteAgentEvent +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.sse.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.mapNotNull +import kotlin.time.Duration.Companion.seconds + +/** + * Remote Agent Client for IntelliJ IDEA plugin. + * + * Connects to mpp-server and streams agent execution events via SSE. + * This is adapted from mpp-ui's RemoteAgentClient for use in the IDE plugin. + */ +class IdeaRemoteAgentClient( + private val baseUrl: String = "http://localhost:8080" +) { + private val httpClient: HttpClient = HttpClient(CIO) { + install(SSE) { + reconnectionTime = 30.seconds + maxReconnectionAttempts = 3 + } + + // We handle HTTP errors manually to provide better error messages + // SSE connections need explicit status checking + expectSuccess = false + + engine { + maxConnectionsCount = 1000 + endpoint { + maxConnectionsPerRoute = 100 + pipelineMaxSize = 20 + keepAliveTime = 5000 + connectTimeout = 5000 + connectAttempts = 5 + } + } + } + + private val gson = Gson() + + /** + * Health check to verify server is running + */ + suspend fun healthCheck(): HealthResponse { + val response = httpClient.get("$baseUrl/health") + if (!response.status.isSuccess()) { + throw RemoteAgentException("Health check failed: ${response.status}") + } + return gson.fromJson(response.bodyAsText(), HealthResponse::class.java) + } + + /** + * Get list of available projects from server + */ + suspend fun getProjects(): ProjectListResponse { + val response = httpClient.get("$baseUrl/api/projects") + if (!response.status.isSuccess()) { + throw RemoteAgentException("Failed to fetch projects: ${response.status}") + } + return gson.fromJson(response.bodyAsText(), ProjectListResponse::class.java) + } + + /** + * Execute agent task with SSE streaming + * Returns a Flow of RemoteAgentEvent for reactive processing + */ + fun executeStream(request: RemoteAgentRequest): Flow = flow { + try { + httpClient.sse( + urlString = "$baseUrl/api/agent/stream", + request = { + method = HttpMethod.Post + contentType(ContentType.Application.Json) + setBody(gson.toJson(request)) + } + ) { + // Check HTTP status before processing SSE events + if (!call.response.status.isSuccess()) { + throw RemoteAgentException("Stream connection failed: ${call.response.status}") + } + + incoming + .mapNotNull { event -> + event.data?.takeIf { data -> + !data.trim().equals("[DONE]", ignoreCase = true) + }?.let { data -> + val eventType = event.event ?: "message" + RemoteAgentEvent.from(eventType, data) + } + } + .collect { parsedEvent -> + emit(parsedEvent) + } + } + } catch (e: Exception) { + e.printStackTrace() + throw RemoteAgentException("Stream connection failed: ${e.message}", e) + } + } + + fun close() { + httpClient.close() + } +} + +/** + * Request/Response Data Classes + */ +data class RemoteAgentRequest( + val projectId: String, + val task: String, + val llmConfig: LLMConfig? = null, + val gitUrl: String? = null, + val branch: String? = null, + val username: String? = null, + val password: String? = null +) + +data class LLMConfig( + val provider: String, + val modelName: String, + val apiKey: String, + val baseUrl: String? = null +) + +data class HealthResponse( + val status: String +) + +data class ProjectInfo( + val id: String, + val name: String, + val path: String, + val description: String +) + +data class ProjectListResponse( + val projects: List +) + +/** + * Exception for remote agent errors + */ +class RemoteAgentException(message: String, cause: Throwable? = null) : Exception(message, cause) + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentContent.kt new file mode 100644 index 0000000000..b37f6424f8 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentContent.kt @@ -0,0 +1,325 @@ +package cc.unitmesh.devins.idea.toolwindow.remote + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import cc.unitmesh.devins.idea.components.timeline.IdeaTimelineContent +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import kotlinx.coroutines.flow.distinctUntilChanged +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.* + +/** + * Remote Agent Content UI for IntelliJ IDEA plugin. + * + * Displays: + * - Server configuration inputs (URL, project/git URL) + * - Connection status indicator + * - Timeline content from remote agent execution + */ +@Composable +fun IdeaRemoteAgentContent( + viewModel: IdeaRemoteAgentViewModel, + listState: LazyListState, + onProjectIdChange: (String) -> Unit = {}, + onGitUrlChange: (String) -> Unit = {}, + modifier: Modifier = Modifier +) { + val timeline by viewModel.renderer.timeline.collectAsState() + val streamingOutput by viewModel.renderer.currentStreamingOutput.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + val connectionError by viewModel.connectionError.collectAsState() + val availableProjects by viewModel.availableProjects.collectAsState() + + var serverUrl by remember { mutableStateOf(viewModel.serverUrl) } + var projectId by remember { mutableStateOf("") } + var gitUrl by remember { mutableStateOf("") } + + // Check connection on initial load and when server URL changes + LaunchedEffect(serverUrl) { + if (serverUrl.isNotBlank()) { + viewModel.updateServerUrl(serverUrl) + viewModel.checkConnection() + } + } + + // Propagate changes to parent + LaunchedEffect(projectId) { + onProjectIdChange(projectId) + } + LaunchedEffect(gitUrl) { + onGitUrlChange(gitUrl) + } + + Column( + modifier = modifier.fillMaxSize() + ) { + // Server Configuration Panel + RemoteConfigPanel( + serverUrl = serverUrl, + onServerUrlChange = { serverUrl = it }, + projectId = projectId, + onProjectIdChange = { projectId = it }, + gitUrl = gitUrl, + onGitUrlChange = { gitUrl = it }, + isConnected = isConnected, + connectionError = connectionError, + availableProjects = availableProjects, + onConnect = { viewModel.checkConnection() }, + modifier = Modifier.fillMaxWidth() + ) + + // Timeline Content + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + IdeaTimelineContent( + timeline = timeline, + streamingOutput = streamingOutput, + listState = listState, + project = viewModel.project + ) + } + } +} + +/** + * Configuration panel for remote server settings. + * Uses TextFieldState for Jewel TextField compatibility. + */ +@Composable +private fun RemoteConfigPanel( + serverUrl: String, + onServerUrlChange: (String) -> Unit, + projectId: String, + onProjectIdChange: (String) -> Unit, + gitUrl: String, + onGitUrlChange: (String) -> Unit, + isConnected: Boolean, + connectionError: String?, + availableProjects: List, + onConnect: () -> Unit, + modifier: Modifier = Modifier +) { + // TextFieldState for Jewel TextField + val serverUrlState = rememberTextFieldState(serverUrl) + val projectIdState = rememberTextFieldState(projectId) + val gitUrlState = rememberTextFieldState(gitUrl) + + // Sync server URL state to callback + LaunchedEffect(Unit) { + snapshotFlow { serverUrlState.text.toString() } + .distinctUntilChanged() + .collect { onServerUrlChange(it) } + } + + // Sync project ID state to callback + LaunchedEffect(Unit) { + snapshotFlow { projectIdState.text.toString() } + .distinctUntilChanged() + .collect { onProjectIdChange(it) } + } + + // Sync git URL state to callback + LaunchedEffect(Unit) { + snapshotFlow { gitUrlState.text.toString() } + .distinctUntilChanged() + .collect { onGitUrlChange(it) } + } + + // Sync external changes to text field states + LaunchedEffect(serverUrl) { + if (serverUrlState.text.toString() != serverUrl) { + serverUrlState.setTextAndPlaceCursorAtEnd(serverUrl) + } + } + LaunchedEffect(projectId) { + if (projectIdState.text.toString() != projectId) { + projectIdState.setTextAndPlaceCursorAtEnd(projectId) + } + } + LaunchedEffect(gitUrl) { + if (gitUrlState.text.toString() != gitUrl) { + gitUrlState.setTextAndPlaceCursorAtEnd(gitUrl) + } + } + + Column( + modifier = modifier + .background(JewelTheme.globalColors.panelBackground) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Server URL row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Server:", + style = JewelTheme.defaultTextStyle, + modifier = Modifier.width(60.dp) + ) + + TextField( + state = serverUrlState, + placeholder = { Text("http://localhost:8080") }, + modifier = Modifier.weight(1f) + ) + + DefaultButton( + onClick = onConnect, + modifier = Modifier.height(32.dp) + ) { + Text("Connect") + } + } + + // Connection Status + ConnectionStatusBar( + isConnected = isConnected, + serverUrl = serverUrl, + connectionError = connectionError, + modifier = Modifier.fillMaxWidth() + ) + + // Project/Git URL inputs (only show when connected) + if (isConnected) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Project:", + style = JewelTheme.defaultTextStyle, + modifier = Modifier.width(60.dp) + ) + + if (availableProjects.isNotEmpty()) { + Dropdown( + menuContent = { + availableProjects.forEach { project -> + selectableItem( + selected = projectId == project.id, + onClick = { + onProjectIdChange(project.id) + projectIdState.setTextAndPlaceCursorAtEnd(project.id) + } + ) { + Text(project.name) + } + } + }, + modifier = Modifier.weight(1f) + ) { + Text( + text = availableProjects.find { it.id == projectId }?.name ?: "Select project..." + ) + } + } else { + TextField( + state = projectIdState, + placeholder = { Text("Project ID or name") }, + modifier = Modifier.weight(1f) + ) + } + } + + // Git URL input (optional) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Git URL:", + style = JewelTheme.defaultTextStyle, + modifier = Modifier.width(60.dp) + ) + + TextField( + state = gitUrlState, + placeholder = { Text("https://github.com/user/repo.git (optional)") }, + modifier = Modifier.weight(1f) + ) + } + } + } +} + +/** + * Connection status indicator + */ +@Composable +private fun ConnectionStatusBar( + isConnected: Boolean, + serverUrl: String, + connectionError: String?, + modifier: Modifier = Modifier +) { + val statusColor by animateColorAsState( + targetValue = if (isConnected) AutoDevColors.Green.c400 else AutoDevColors.Red.c400, + label = "statusColor" + ) + + Row( + modifier = modifier + .clip(RoundedCornerShape(6.dp)) + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f)) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Status indicator dot + Box( + modifier = Modifier + .size(8.dp) + .background(color = statusColor, shape = CircleShape) + ) + + Text( + text = if (isConnected) { + "Connected to $serverUrl" + } else if (connectionError != null) { + "Error: $connectionError" + } else { + "Not connected" + }, + style = JewelTheme.defaultTextStyle.copy( + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) + ) + } +} + +/** + * Get the project ID or Git URL for task execution. + * Handles trailing slashes and empty segments in Git URLs. + */ +fun getEffectiveProjectId(projectId: String, gitUrl: String): String { + return if (gitUrl.isNotBlank()) { + gitUrl.trimEnd('/') + .split('/') + .lastOrNull { it.isNotBlank() } + ?.removeSuffix(".git") + ?.ifBlank { projectId } + ?: projectId + } else { + projectId + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModel.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModel.kt new file mode 100644 index 0000000000..005fad22e9 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModel.kt @@ -0,0 +1,289 @@ +package cc.unitmesh.devins.idea.toolwindow.remote + +import cc.unitmesh.agent.RemoteAgentEvent +import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.devins.ui.config.ConfigManager +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * ViewModel for Remote Agent in IntelliJ IDEA plugin. + * + * Connects to mpp-server and streams agent execution events, + * forwarding them to JewelRenderer for UI rendering. + * + * This is adapted from mpp-ui's RemoteCodingAgentViewModel. + */ +class IdeaRemoteAgentViewModel( + val project: Project, + private val coroutineScope: CoroutineScope, + serverUrl: String = "http://localhost:8080", + private val useServerConfig: Boolean = false +) : Disposable { + + private var _serverUrl = serverUrl + val serverUrl: String get() = _serverUrl + + private var client = IdeaRemoteAgentClient(_serverUrl) + + val renderer = JewelRenderer() + + private val _isExecuting = MutableStateFlow(false) + val isExecuting: StateFlow = _isExecuting.asStateFlow() + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _connectionError = MutableStateFlow(null) + val connectionError: StateFlow = _connectionError.asStateFlow() + + private val _availableProjects = MutableStateFlow>(emptyList()) + val availableProjects: StateFlow> = _availableProjects.asStateFlow() + + private var currentExecutionJob: Job? = null + + /** + * Update server URL and recreate client + */ + fun updateServerUrl(newUrl: String) { + if (newUrl != _serverUrl) { + _serverUrl = newUrl + client.close() + client = IdeaRemoteAgentClient(_serverUrl) + _isConnected.value = false + _connectionError.value = null + _availableProjects.value = emptyList() + } + } + + /** + * Check connection to server + */ + fun checkConnection() { + coroutineScope.launch { + try { + val health = client.healthCheck() + _isConnected.value = health.status == "ok" + _connectionError.value = null + + if (_isConnected.value) { + val projectList = client.getProjects() + _availableProjects.value = projectList.projects + } + } catch (e: Exception) { + _isConnected.value = false + _connectionError.value = e.message ?: "Failed to connect to server" + } + } + } + + /** + * Execute a task on the remote server + */ + fun executeTask(projectId: String, task: String, gitUrl: String = "") { + if (_isExecuting.value) { + println("Agent is already executing") + return + } + + if (!_isConnected.value) { + renderer.renderError("Not connected to server. Please check server URL.") + return + } + + _isExecuting.value = true + renderer.clearError() + renderer.addUserMessage(task) + + currentExecutionJob = coroutineScope.launch { + try { + val llmConfig = if (!useServerConfig) { + val config = ConfigManager.load() + val activeConfig = config.getActiveModelConfig() + + if (activeConfig == null) { + renderer.renderError("No active LLM configuration found. Please configure your model first.") + _isExecuting.value = false + return@launch + } + + LLMConfig( + provider = activeConfig.provider.name, + modelName = activeConfig.modelName, + apiKey = activeConfig.apiKey ?: "", + baseUrl = activeConfig.baseUrl + ) + } else { + null + } + + val request = buildRequest(projectId, task, gitUrl, llmConfig) + + client.executeStream(request).collect { event -> + handleRemoteEvent(event) + + if (event is RemoteAgentEvent.Complete) { + _isExecuting.value = false + currentExecutionJob = null + } + } + + } catch (e: CancellationException) { + renderer.forceStop() + renderer.renderError("Task cancelled by user") + _isExecuting.value = false + currentExecutionJob = null + } catch (e: Exception) { + renderer.renderError(e.message ?: "Unknown error") + _isExecuting.value = false + currentExecutionJob = null + } + } + } + + private fun buildRequest( + projectId: String, + task: String, + gitUrl: String, + llmConfig: LLMConfig? + ): RemoteAgentRequest { + return if (gitUrl.isNotBlank()) { + RemoteAgentRequest( + projectId = extractProjectIdFromUrl(gitUrl) ?: "temp-project", + task = task, + llmConfig = llmConfig, + gitUrl = gitUrl + ) + } else { + val isGitUrl = projectId.startsWith("http://") || + projectId.startsWith("https://") || + projectId.startsWith("git@") + + if (isGitUrl) { + RemoteAgentRequest( + projectId = extractProjectIdFromUrl(projectId) ?: "temp-project", + task = task, + llmConfig = llmConfig, + gitUrl = projectId + ) + } else { + RemoteAgentRequest( + projectId = projectId, + task = task, + llmConfig = llmConfig + ) + } + } + } + + /** + * Extract project ID from a Git URL, handling trailing slashes and empty segments. + */ + private fun extractProjectIdFromUrl(url: String): String? { + return url.trimEnd('/') + .split('/') + .lastOrNull { it.isNotBlank() } + ?.removeSuffix(".git") + ?.ifBlank { null } + } + + /** + * Handle remote events and forward to JewelRenderer + */ + private fun handleRemoteEvent(event: RemoteAgentEvent) { + when (event) { + is RemoteAgentEvent.CloneProgress -> { + if (event.progress != null) { + renderer.renderLLMResponseStart() + renderer.renderLLMResponseChunk("📦 Cloning repository: ${event.stage} (${event.progress}%)") + renderer.renderLLMResponseEnd() + } + } + + is RemoteAgentEvent.CloneLog -> { + if (!event.isError && (event.message.contains("✓") || event.message.contains("ready"))) { + renderer.renderLLMResponseStart() + renderer.renderLLMResponseChunk(event.message) + renderer.renderLLMResponseEnd() + } else if (event.isError) { + renderer.renderError(event.message) + } + } + + is RemoteAgentEvent.Iteration -> { + renderer.renderIterationHeader(event.current, event.max) + } + + is RemoteAgentEvent.LLMChunk -> { + if (!renderer.isProcessing.value) { + renderer.renderLLMResponseStart() + } + renderer.renderLLMResponseChunk(event.chunk) + } + + is RemoteAgentEvent.ToolCall -> { + if (renderer.isProcessing.value) { + renderer.renderLLMResponseEnd() + } + renderer.renderToolCall(event.toolName, event.params) + } + + is RemoteAgentEvent.ToolResult -> { + renderer.renderToolResult( + toolName = event.toolName, + success = event.success, + output = event.output, + fullOutput = event.output, + metadata = emptyMap() + ) + } + + is RemoteAgentEvent.Error -> { + renderer.renderError(event.message) + } + + is RemoteAgentEvent.Complete -> { + if (renderer.isProcessing.value) { + renderer.renderLLMResponseEnd() + } + renderer.renderFinalResult(event.success, event.message, event.iterations) + } + } + } + + /** + * Cancel current task + */ + fun cancelTask() { + if (_isExecuting.value && currentExecutionJob != null) { + currentExecutionJob?.cancel("Task cancelled by user") + currentExecutionJob = null + _isExecuting.value = false + } + } + + /** + * Clear chat history + */ + fun clearHistory() { + renderer.clearTimeline() + } + + /** + * Clear error state + */ + fun clearError() { + renderer.clearError() + _connectionError.value = null + } + + override fun dispose() { + currentExecutionJob?.cancel() + client.close() + } +} + diff --git a/mpp-idea/src/main/resources/META-INF/plugin.xml b/mpp-idea/src/main/resources/META-INF/plugin.xml index d2f4c2aa10..f3c963d3f7 100644 --- a/mpp-idea/src/main/resources/META-INF/plugin.xml +++ b/mpp-idea/src/main/resources/META-INF/plugin.xml @@ -1,21 +1,60 @@ - + cc.unitmesh.devins.idea AutoDev Compose UI UnitMesh + + + + + messages.AutoDevIdeaBundle + + + + + + + + + + + + + + + + + + + + diff --git a/mpp-idea/src/main/resources/icons/autodev-toolwindow.svg b/mpp-idea/src/main/resources/icons/autodev-toolwindow.svg index 01d6213962..2d9cea8527 100644 --- a/mpp-idea/src/main/resources/icons/autodev-toolwindow.svg +++ b/mpp-idea/src/main/resources/icons/autodev-toolwindow.svg @@ -1,5 +1,14 @@ - - - - - + + + + ai-copilot + Created with Sketch. + + + + + + + + + \ No newline at end of file diff --git a/mpp-idea/src/main/resources/icons/autodev.svg b/mpp-idea/src/main/resources/icons/autodev.svg index d78018638d..2d9cea8527 100644 --- a/mpp-idea/src/main/resources/icons/autodev.svg +++ b/mpp-idea/src/main/resources/icons/autodev.svg @@ -1,5 +1,14 @@ - - - - - + + + + ai-copilot + Created with Sketch. + + + + + + + + + \ No newline at end of file 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/renderer/markdown/MarkdownTableParserTest.kt b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTableParserTest.kt new file mode 100644 index 0000000000..df7d1f2384 --- /dev/null +++ b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTableParserTest.kt @@ -0,0 +1,154 @@ +package cc.unitmesh.devins.idea.renderer.markdown + +import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.ast.getTextInNode +import org.intellij.markdown.flavours.gfm.GFMElementTypes +import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor +import org.intellij.markdown.flavours.gfm.GFMTokenTypes +import org.intellij.markdown.parser.MarkdownParser +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Tests for Markdown table parsing using intellij-markdown GFM parser. + * These tests verify that the parser correctly identifies table structure. + */ +class MarkdownTableParserTest { + + private val flavour = GFMFlavourDescriptor() + private val parser = MarkdownParser(flavour) + + @Test + fun `should parse simple table with header and rows`() { + val markdown = """ + | Header 1 | Header 2 | Header 3 | + |----------|----------|----------| + | Cell 1 | Cell 2 | Cell 3 | + | Cell 4 | Cell 5 | Cell 6 | + """.trimIndent() + + val tree = parser.buildMarkdownTreeFromString(markdown) + val tableNode = findTableNode(tree) + + assertNotNull(tableNode, "Table node should be found") + assertEquals(GFMElementTypes.TABLE, tableNode.type) + + // Find header + val headerNode = tableNode.children.find { it.type == GFMElementTypes.HEADER } + assertNotNull(headerNode, "Header node should be found") + + // Count header cells + val headerCells = headerNode.children.filter { it.type == GFMTokenTypes.CELL } + assertEquals(3, headerCells.size, "Should have 3 header cells") + + // Find body rows + val bodyRows = tableNode.children.filter { it.type == GFMElementTypes.ROW } + assertEquals(2, bodyRows.size, "Should have 2 body rows") + + // Verify first row cells + val firstRowCells = bodyRows[0].children.filter { it.type == GFMTokenTypes.CELL } + assertEquals(3, firstRowCells.size, "First row should have 3 cells") + } + + @Test + fun `should extract cell text correctly`() { + val markdown = """ + | Name | Age | City | + |------|-----|------| + | Alice | 30 | NYC | + """.trimIndent() + + val tree = parser.buildMarkdownTreeFromString(markdown) + val tableNode = findTableNode(tree) + assertNotNull(tableNode) + + val headerNode = tableNode.children.find { it.type == GFMElementTypes.HEADER } + assertNotNull(headerNode) + + val headerCells = headerNode.children.filter { it.type == GFMTokenTypes.CELL } + val headerTexts = headerCells.map { extractCellText(it, markdown) } + + assertEquals(listOf("Name", "Age", "City"), headerTexts) + + val bodyRow = tableNode.children.find { it.type == GFMElementTypes.ROW } + assertNotNull(bodyRow) + + val bodyCells = bodyRow.children.filter { it.type == GFMTokenTypes.CELL } + val bodyTexts = bodyCells.map { extractCellText(it, markdown) } + + assertEquals(listOf("Alice", "30", "NYC"), bodyTexts) + } + + @Test + fun `should handle table with inline formatting`() { + val markdown = """ + | Feature | Status | + |---------|--------| + | **Bold** | `code` | + | *Italic* | ~~strike~~ | + """.trimIndent() + + val tree = parser.buildMarkdownTreeFromString(markdown) + val tableNode = findTableNode(tree) + assertNotNull(tableNode) + + val bodyRows = tableNode.children.filter { it.type == GFMElementTypes.ROW } + assertEquals(2, bodyRows.size) + } + + @Test + fun `should calculate column count from header`() { + val markdown = """ + | A | B | C | D | E | + |---|---|---|---|---| + | 1 | 2 | 3 | 4 | 5 | + """.trimIndent() + + val tree = parser.buildMarkdownTreeFromString(markdown) + val tableNode = findTableNode(tree) + assertNotNull(tableNode) + + val headerNode = tableNode.children.find { it.type == GFMElementTypes.HEADER } + assertNotNull(headerNode) + + val columnCount = headerNode.children.count { it.type == GFMTokenTypes.CELL } + assertEquals(5, columnCount) + } + + @Test + fun `should find table separator`() { + val markdown = """ + | H1 | H2 | + |----|-----| + | C1 | C2 | + """.trimIndent() + + val tree = parser.buildMarkdownTreeFromString(markdown) + val tableNode = findTableNode(tree) + assertNotNull(tableNode) + + val hasSeparator = tableNode.children.any { it.type == GFMTokenTypes.TABLE_SEPARATOR } + assertTrue(hasSeparator, "Table should have separator") + } + + private fun findTableNode(node: ASTNode): ASTNode? { + if (node.type == GFMElementTypes.TABLE) return node + for (child in node.children) { + val found = findTableNode(child) + if (found != null) return found + } + return null + } + + private fun extractCellText(cell: ASTNode, content: String): String { + return cell.getTextInNode(content).toString() + .replace("|", "") + .replace("`", "") + .replace("**", "") + .replace("*", "") + .trim() + } +} + diff --git a/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTextParserTest.kt b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTextParserTest.kt new file mode 100644 index 0000000000..b64bc3c6c6 --- /dev/null +++ b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTextParserTest.kt @@ -0,0 +1,171 @@ +package cc.unitmesh.devins.idea.renderer.markdown + +import org.intellij.markdown.MarkdownElementTypes +import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor +import org.intellij.markdown.parser.MarkdownParser +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * Tests for MarkdownTextParser utility functions. + * These tests verify text extraction from Markdown AST nodes. + */ +class MarkdownTextParserTest { + + private val flavour = GFMFlavourDescriptor() + private val parser = MarkdownParser(flavour) + + // ============ Header Text Extraction Tests ============ + + @Test + fun `should extract ATX header text level 1`() { + val markdown = "# Hello World" + val tree = parser.buildMarkdownTreeFromString(markdown) + val headerNode = findNodeOfType(tree, MarkdownElementTypes.ATX_1) + + assertNotNull(headerNode) + val text = MarkdownTextParser.extractHeaderText(headerNode, markdown) + assertEquals("Hello World", text) + } + + @Test + fun `should extract ATX header text level 2`() { + val markdown = "## Section Title" + val tree = parser.buildMarkdownTreeFromString(markdown) + val headerNode = findNodeOfType(tree, MarkdownElementTypes.ATX_2) + + assertNotNull(headerNode) + val text = MarkdownTextParser.extractHeaderText(headerNode, markdown) + assertEquals("Section Title", text) + } + + @Test + fun `should extract ATX header text level 3`() { + val markdown = "### Subsection" + val tree = parser.buildMarkdownTreeFromString(markdown) + val headerNode = findNodeOfType(tree, MarkdownElementTypes.ATX_3) + + assertNotNull(headerNode) + val text = MarkdownTextParser.extractHeaderText(headerNode, markdown) + assertEquals("Subsection", text) + } + + @Test + fun `should extract SETEXT header text level 1`() { + val markdown = """ + Main Title + ========== + """.trimIndent() + val tree = parser.buildMarkdownTreeFromString(markdown) + val headerNode = findNodeOfType(tree, MarkdownElementTypes.SETEXT_1) + + assertNotNull(headerNode) + val text = MarkdownTextParser.extractHeaderText(headerNode, markdown) + assertEquals("Main Title", text) + } + + @Test + fun `should extract SETEXT header text level 2`() { + val markdown = """ + Sub Title + --------- + """.trimIndent() + val tree = parser.buildMarkdownTreeFromString(markdown) + val headerNode = findNodeOfType(tree, MarkdownElementTypes.SETEXT_2) + + assertNotNull(headerNode) + val text = MarkdownTextParser.extractHeaderText(headerNode, markdown) + assertEquals("Sub Title", text) + } + + // ============ Code Fence Content Extraction Tests ============ + + @Test + fun `should extract code fence content without language`() { + val markdown = """ + ``` + val x = 1 + val y = 2 + ``` + """.trimIndent() + val tree = parser.buildMarkdownTreeFromString(markdown) + val codeFenceNode = findNodeOfType(tree, MarkdownElementTypes.CODE_FENCE) + + assertNotNull(codeFenceNode) + val content = MarkdownTextParser.extractCodeFenceContent(codeFenceNode, markdown) + assertEquals("val x = 1\nval y = 2", content) + } + + @Test + fun `should extract code fence content with language`() { + val markdown = """ + ```kotlin + fun hello() = println("Hello") + ``` + """.trimIndent() + val tree = parser.buildMarkdownTreeFromString(markdown) + val codeFenceNode = findNodeOfType(tree, MarkdownElementTypes.CODE_FENCE) + + assertNotNull(codeFenceNode) + val content = MarkdownTextParser.extractCodeFenceContent(codeFenceNode, markdown) + assertEquals("fun hello() = println(\"Hello\")", content) + } + + @Test + fun `should extract code fence language`() { + val markdown = """ + ```javascript + console.log("test"); + ``` + """.trimIndent() + val tree = parser.buildMarkdownTreeFromString(markdown) + val codeFenceNode = findNodeOfType(tree, MarkdownElementTypes.CODE_FENCE) + + assertNotNull(codeFenceNode) + val language = MarkdownTextParser.extractCodeFenceLanguage(codeFenceNode, markdown) + assertEquals("javascript", language) + } + + @Test + fun `should return null for code fence without language`() { + val markdown = """ + ``` + some code + ``` + """.trimIndent() + val tree = parser.buildMarkdownTreeFromString(markdown) + val codeFenceNode = findNodeOfType(tree, MarkdownElementTypes.CODE_FENCE) + + assertNotNull(codeFenceNode) + val language = MarkdownTextParser.extractCodeFenceLanguage(codeFenceNode, markdown) + assertNull(language) + } + + // ============ Code Span Text Extraction Tests ============ + + @Test + fun `should extract inline code span text`() { + val markdown = "Use `println()` to print" + val tree = parser.buildMarkdownTreeFromString(markdown) + val codeSpanNode = findNodeOfType(tree, MarkdownElementTypes.CODE_SPAN) + + assertNotNull(codeSpanNode) + val text = MarkdownTextParser.extractCodeSpanText(codeSpanNode, markdown) + assertEquals("println()", text) + } + + // ============ Helper Function ============ + + private fun findNodeOfType(node: ASTNode, type: org.intellij.markdown.IElementType): ASTNode? { + if (node.type == type) return node + for (child in node.children) { + val found = findNodeOfType(child, type) + if (found != null) return found + } + return null + } +} + 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 new file mode 100644 index 0000000000..b26c454cf7 --- /dev/null +++ b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt @@ -0,0 +1,479 @@ +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 +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Unit tests for IdeaRemoteAgentViewModel. + * + * Tests the ViewModel's functionality including: + * - Initial state + * - Server URL management + * - Connection state handling + * - Task cancellation + * - History management + * - Event handling (via renderer) + * + * Note: These tests do not require a real server connection. + * Network-related tests are skipped as they would require mocking. + */ +class IdeaRemoteAgentViewModelTest { + + private lateinit var testScope: CoroutineScope + + @BeforeEach + fun setUp() { + testScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + } + + @AfterEach + fun tearDown() { + testScope.cancel() + } + + @Test + fun testInitialState() = runBlocking { + // Create a mock project-free test by testing the renderer and state directly + // We can't easily test the full ViewModel without IntelliJ Platform, + // but we can test the renderer and state management + val renderer = JewelRenderer() + + // Verify initial renderer state + val timeline = renderer.timeline.first() + assertTrue(timeline.isEmpty()) + + val isProcessing = renderer.isProcessing.first() + assertFalse(isProcessing) + + val errorMessage = renderer.errorMessage.first() + assertNull(errorMessage) + } + + @Test + fun testRendererHandlesIterationEvent() = runBlocking { + val renderer = JewelRenderer() + + // Simulate handling iteration event + renderer.renderIterationHeader(3, 10) + + val currentIteration = renderer.currentIteration.first() + assertEquals(3, currentIteration) + + val maxIterations = renderer.maxIterations.first() + assertEquals(10, maxIterations) + } + + @Test + fun testRendererHandlesLLMChunkEvent() = runBlocking { + val renderer = JewelRenderer() + + // Simulate LLM streaming + renderer.renderLLMResponseStart() + assertTrue(renderer.isProcessing.first()) + + renderer.renderLLMResponseChunk("Hello ") + renderer.renderLLMResponseChunk("world!") + + val streamingOutput = renderer.currentStreamingOutput.first() + assertTrue(streamingOutput.contains("Hello")) + assertTrue(streamingOutput.contains("world")) + + renderer.renderLLMResponseEnd() + assertFalse(renderer.isProcessing.first()) + } + + @Test + fun testRendererHandlesToolCallEvent() = runBlocking { + val renderer = JewelRenderer() + + // Simulate tool call + renderer.renderToolCall("read-file", "path=\"/test/file.txt\"") + + val currentToolCall = renderer.currentToolCall.first() + assertNotNull(currentToolCall) + + val timeline = renderer.timeline.first() + assertEquals(1, timeline.size) + assertTrue(timeline.first() is TimelineItem.ToolCallItem) + } + + @Test + fun testRendererHandlesToolResultEvent() = runBlocking { + val renderer = JewelRenderer() + + // Simulate tool call and result + renderer.renderToolCall("read-file", "path=\"/test/file.txt\"") + renderer.renderToolResult( + toolName = "read-file", + success = true, + output = "File content", + fullOutput = "Full file content", + metadata = emptyMap() + ) + + val currentToolCall = renderer.currentToolCall.first() + assertNull(currentToolCall) + + val timeline = renderer.timeline.first() + assertEquals(1, timeline.size) + + val toolItem = timeline.first() as TimelineItem.ToolCallItem + assertEquals(true, toolItem.success) + } + + @Test + fun testRendererHandlesErrorEvent() = runBlocking { + val renderer = JewelRenderer() + + renderer.renderError("Connection failed") + + val errorMessage = renderer.errorMessage.first() + assertEquals("Connection failed", errorMessage) + + val timeline = renderer.timeline.first() + assertEquals(1, timeline.size) + assertTrue(timeline.first() is TimelineItem.ErrorItem) + } + + @Test + fun testRendererHandlesCompleteEvent() = runBlocking { + val renderer = JewelRenderer() + + renderer.renderFinalResult(true, "Task completed", 5) + + val timeline = renderer.timeline.first() + assertEquals(1, timeline.size) + + val item = timeline.first() as TimelineItem.TaskCompleteItem + assertTrue(item.success) + assertEquals("Task completed", item.message) + assertEquals(5, item.iterations) + } + + @Test + fun testRendererClearTimeline() = runBlocking { + val renderer = JewelRenderer() + + // Add some items + renderer.addUserMessage("User message") + renderer.renderError("An error") + + var timeline = renderer.timeline.first() + assertEquals(2, timeline.size) + + // Clear timeline + renderer.clearTimeline() + + timeline = renderer.timeline.first() + assertTrue(timeline.isEmpty()) + + val errorMessage = renderer.errorMessage.first() + assertNull(errorMessage) + } + + @Test + fun testRendererForceStop() = runBlocking { + val renderer = JewelRenderer() + + // Start streaming + renderer.renderLLMResponseStart() + renderer.renderLLMResponseChunk("Partial output") + + assertTrue(renderer.isProcessing.first()) + + // Force stop + renderer.forceStop() + + assertFalse(renderer.isProcessing.first()) + + // Verify interrupted message was added + val timeline = renderer.timeline.first() + assertTrue(timeline.isNotEmpty()) + val lastItem = timeline.last() + assertTrue(lastItem is TimelineItem.MessageItem) + assertTrue((lastItem as TimelineItem.MessageItem).content.contains("[Interrupted]")) + } + + @Test + fun testRendererClearError() = runBlocking { + val renderer = JewelRenderer() + + // Set error + renderer.renderError("Test error") + assertEquals("Test error", renderer.errorMessage.first()) + + // Clear error + renderer.clearError() + assertNull(renderer.errorMessage.first()) + } + + @Test + fun testRendererAddUserMessage() = runBlocking { + val renderer = JewelRenderer() + + renderer.addUserMessage("Hello from user") + + val timeline = renderer.timeline.first() + assertEquals(1, timeline.size) + + val item = timeline.first() as TimelineItem.MessageItem + assertEquals(MessageRole.USER, item.role) + assertEquals("Hello from user", item.content) + } + + @Test + fun testRemoteAgentRequestBuilder() { + // Test the request building logic + val projectId = "test-project" + val task = "Fix the bug" + val gitUrl = "" + + // When gitUrl is empty, should use projectId + val request = RemoteAgentRequest( + projectId = projectId, + task = task, + llmConfig = null, + gitUrl = if (gitUrl.isNotBlank()) gitUrl else null + ) + + assertEquals("test-project", request.projectId) + assertEquals("Fix the bug", request.task) + assertNull(request.gitUrl) + } + + @Test + fun testRemoteAgentRequestWithGitUrl() { + // Test the request building logic with git URL + val gitUrl = "https://github.com/user/repo.git" + val task = "Fix the bug" + + val projectId = gitUrl.split('/').lastOrNull()?.removeSuffix(".git") ?: "temp-project" + + val request = RemoteAgentRequest( + projectId = projectId, + task = task, + llmConfig = null, + gitUrl = gitUrl + ) + + assertEquals("repo", request.projectId) + assertEquals("Fix the bug", request.task) + assertEquals(gitUrl, request.gitUrl) + } + + @Test + fun testLLMConfigSerialization() { + val config = LLMConfig( + provider = "OpenAI", + modelName = "gpt-4", + apiKey = "test-key", + baseUrl = "https://api.openai.com" + ) + + assertEquals("OpenAI", config.provider) + assertEquals("gpt-4", config.modelName) + assertEquals("test-key", config.apiKey) + assertEquals("https://api.openai.com", config.baseUrl) + } + + @Test + fun testHealthResponseParsing() { + val response = HealthResponse(status = "ok") + assertEquals("ok", response.status) + } + + @Test + fun testProjectInfoParsing() { + val project = ProjectInfo( + id = "proj-1", + name = "My Project", + path = "/path/to/project", + description = "A test project" + ) + + assertEquals("proj-1", project.id) + assertEquals("My Project", project.name) + assertEquals("/path/to/project", project.path) + assertEquals("A test project", project.description) + } + + // Tests for getEffectiveProjectId utility function + + @Test + fun testGetEffectiveProjectIdWithNormalGitUrl() { + val result = getEffectiveProjectId("fallback", "https://github.com/user/repo.git") + assertEquals("repo", result) + } + + @Test + fun testGetEffectiveProjectIdWithTrailingSlash() { + // Edge case: URL with trailing slash should still extract correct project ID + val result = getEffectiveProjectId("fallback", "https://github.com/user/repo/") + assertEquals("repo", result) + } + + @Test + fun testGetEffectiveProjectIdWithMultipleTrailingSlashes() { + val result = getEffectiveProjectId("fallback", "https://github.com/user/repo///") + assertEquals("repo", result) + } + + @Test + fun testGetEffectiveProjectIdWithEmptyGitUrl() { + val result = getEffectiveProjectId("my-project", "") + assertEquals("my-project", result) + } + + @Test + fun testGetEffectiveProjectIdWithBlankGitUrl() { + val result = getEffectiveProjectId("my-project", " ") + assertEquals("my-project", result) + } + + @Test + fun testGetEffectiveProjectIdWithOnlySlashes() { + // Edge case: URL that is just slashes should fallback to projectId + val result = getEffectiveProjectId("fallback", "///") + assertEquals("fallback", result) + } + + @Test + fun testGetEffectiveProjectIdWithGitSuffix() { + val result = getEffectiveProjectId("fallback", "https://github.com/user/my-repo.git") + assertEquals("my-repo", result) + } + + @Test + fun testGetEffectiveProjectIdWithoutGitSuffix() { + val result = getEffectiveProjectId("fallback", "https://github.com/user/my-repo") + assertEquals("my-repo", result) + } + + // Tests for IdeaRemoteAgentClient data classes and state management + + @Test + fun testRemoteAgentClientDataClassDefaultValues() { + // Test RemoteAgentRequest with minimal required fields + val request = RemoteAgentRequest( + projectId = "test-project", + task = "Test task" + ) + + assertEquals("test-project", request.projectId) + assertEquals("Test task", request.task) + assertNull(request.llmConfig) + assertNull(request.gitUrl) + assertNull(request.branch) + assertNull(request.username) + assertNull(request.password) + } + + @Test + fun testRemoteAgentRequestWithAllFields() { + val llmConfig = LLMConfig( + provider = "OpenAI", + modelName = "gpt-4", + apiKey = "test-key", + baseUrl = "https://api.openai.com" + ) + + val request = RemoteAgentRequest( + projectId = "test-project", + task = "Test task", + llmConfig = llmConfig, + gitUrl = "https://github.com/user/repo.git", + branch = "main", + username = "user", + password = "pass" + ) + + assertEquals("test-project", request.projectId) + assertEquals("Test task", request.task) + assertNotNull(request.llmConfig) + assertEquals("https://github.com/user/repo.git", request.gitUrl) + assertEquals("main", request.branch) + assertEquals("user", request.username) + assertEquals("pass", request.password) + } + + @Test + fun testLLMConfigWithNullBaseUrl() { + val config = LLMConfig( + provider = "Claude", + modelName = "claude-3", + apiKey = "test-key", + baseUrl = null + ) + + assertEquals("Claude", config.provider) + assertEquals("claude-3", config.modelName) + assertEquals("test-key", config.apiKey) + assertNull(config.baseUrl) + } + + @Test + fun testProjectListResponseEmpty() { + val response = ProjectListResponse(projects = emptyList()) + assertTrue(response.projects.isEmpty()) + } + + @Test + fun testProjectListResponseWithMultipleProjects() { + val projects = listOf( + ProjectInfo(id = "proj-1", name = "Project 1", path = "/path/1", description = "First"), + ProjectInfo(id = "proj-2", name = "Project 2", path = "/path/2", description = "Second") + ) + val response = ProjectListResponse(projects = projects) + + assertEquals(2, response.projects.size) + assertEquals("proj-1", response.projects[0].id) + assertEquals("proj-2", response.projects[1].id) + } + + @Test + fun testRemoteAgentExceptionWithCause() { + val cause = RuntimeException("Original error") + val exception = RemoteAgentException("Wrapper error", cause) + + assertEquals("Wrapper error", exception.message) + assertEquals(cause, exception.cause) + } + + @Test + fun testRemoteAgentExceptionWithoutCause() { + val exception = RemoteAgentException("Simple error") + + assertEquals("Simple error", exception.message) + assertNull(exception.cause) + } + + // Test utility function for extracting project ID from various URL formats + + @Test + fun testGetEffectiveProjectIdWithSshUrl() { + // SSH URLs like git@github.com:user/repo.git + val result = getEffectiveProjectId("fallback", "git@github.com:user/repo.git") + // The function splits by '/', so for SSH URLs it would get "user/repo.git" and then take last + // Actually it splits by '/' so git@github.com:user would be first, repo.git second + assertEquals("repo", result) + } + + @Test + fun testGetEffectiveProjectIdWithDeepPath() { + // URLs with deeper paths + val result = getEffectiveProjectId("fallback", "https://gitlab.com/group/subgroup/repo.git") + assertEquals("repo", result) + } +} + diff --git a/mpp-ui/build.gradle.kts b/mpp-ui/build.gradle.kts index e91b46a237..b8009b0f66 100644 --- a/mpp-ui/build.gradle.kts +++ b/mpp-ui/build.gradle.kts @@ -476,7 +476,7 @@ tasks.register("runDocumentCli") { classpath(jvmCompilation.output, configurations["jvmRuntimeClasspath"]) mainClass.set("cc.unitmesh.server.cli.DocumentCli") - // Pass properties + // Pass properties - use docProjectPath to avoid conflict with Gradle's projectPath if (project.hasProperty("docProjectPath")) { systemProperty("projectPath", project.property("docProjectPath") as String) } @@ -486,7 +486,14 @@ tasks.register("runDocumentCli") { if (project.hasProperty("docPath")) { systemProperty("documentPath", project.property("docPath") as String) } - + // New parameters for feature tree mode + if (project.hasProperty("docMode")) { + systemProperty("mode", project.property("docMode") as String) + } + if (project.hasProperty("docLanguage")) { + systemProperty("language", project.property("docLanguage") as String) + } + standardInput = System.`in` } 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..0ba49a12ab 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,12 +217,15 @@ fun RenderMessageItem( ) } - is ComposeRenderer.TimelineItem.LiveTerminalItem -> { + is TimelineItem.LiveTerminalItem -> { LiveTerminalItem( sessionId = timelineItem.sessionId, command = timelineItem.command, workingDirectory = timelineItem.workingDirectory, - ptyHandle = timelineItem.ptyHandle + ptyHandle = timelineItem.ptyHandle, + exitCode = timelineItem.exitCode, + executionTimeMs = timelineItem.executionTimeMs, + output = timelineItem.output ) } } @@ -236,13 +235,20 @@ fun RenderMessageItem( * Platform-specific live terminal display. * On JVM with PTY support: Renders an interactive terminal widget * On other platforms: Shows a message that live terminal is not available + * + * @param exitCode Exit code when completed (null if still running) + * @param executionTimeMs Execution time when completed (null if still running) + * @param output Captured output when completed (null if still running or not captured) */ @Composable expect fun LiveTerminalItem( sessionId: String, command: String, workingDirectory: String?, - ptyHandle: Any? + ptyHandle: Any?, + exitCode: Int? = null, + executionTimeMs: Long? = null, + output: String? = null ) @Composable 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/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/SessionSidebar.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/SessionSidebar.kt index 414c0440bd..a321fcda75 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/SessionSidebar.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/chat/SessionSidebar.kt @@ -20,8 +20,10 @@ import cc.unitmesh.devins.ui.compose.icons.AutoDevComposeIcons import cc.unitmesh.devins.ui.session.SessionClient import cc.unitmesh.session.Session import kotlinx.coroutines.launch +import kotlinx.datetime.DatePeriod import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone +import kotlinx.datetime.minus import kotlinx.datetime.toLocalDateTime /** @@ -679,10 +681,8 @@ fun formatTimestamp(timestamp: Long): String { val now = kotlinx.datetime.Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) - // Calculate yesterday's date - val yesterdayDate = now.date.let { - kotlinx.datetime.LocalDate(it.year, it.monthNumber, it.dayOfMonth - 1) - } + // Calculate yesterday's date properly (handles month/year boundaries) + val yesterdayDate = now.date.minus(DatePeriod(days = 1)) return when { dateTime.date == now.date -> "Today" diff --git a/mpp-ui/src/iosMain/kotlin/cc/unitmesh/devins/ui/compose/agent/LiveTerminalItem.ios.kt b/mpp-ui/src/iosMain/kotlin/cc/unitmesh/devins/ui/compose/agent/LiveTerminalItem.ios.kt index 3f687576e1..9b463132e8 100644 --- a/mpp-ui/src/iosMain/kotlin/cc/unitmesh/devins/ui/compose/agent/LiveTerminalItem.ios.kt +++ b/mpp-ui/src/iosMain/kotlin/cc/unitmesh/devins/ui/compose/agent/LiveTerminalItem.ios.kt @@ -14,7 +14,10 @@ actual fun LiveTerminalItem( sessionId: String, command: String, workingDirectory: String?, - ptyHandle: Any? + ptyHandle: Any?, + exitCode: Int?, + executionTimeMs: Long?, + output: String? ) { Column(modifier = androidx.compose.ui.Modifier.padding(16.dp)) { Text("Terminal functionality is not available on iOS") diff --git a/mpp-ui/src/jsMain/kotlin/cc/unitmesh/devins/ui/compose/agent/LiveTerminalItem.js.kt b/mpp-ui/src/jsMain/kotlin/cc/unitmesh/devins/ui/compose/agent/LiveTerminalItem.js.kt index 5d0d5d72eb..4a3a4a2050 100644 --- a/mpp-ui/src/jsMain/kotlin/cc/unitmesh/devins/ui/compose/agent/LiveTerminalItem.js.kt +++ b/mpp-ui/src/jsMain/kotlin/cc/unitmesh/devins/ui/compose/agent/LiveTerminalItem.js.kt @@ -19,7 +19,10 @@ actual fun LiveTerminalItem( sessionId: String, command: String, workingDirectory: String?, - ptyHandle: Any? + ptyHandle: Any?, + exitCode: Int?, + executionTimeMs: Long?, + output: String? ) { Card( colors = diff --git a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/db/DatabaseDriverFactory.kt b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/db/DatabaseDriverFactory.kt index 9cf96dd269..6d79135947 100644 --- a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/db/DatabaseDriverFactory.kt +++ b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/db/DatabaseDriverFactory.kt @@ -25,6 +25,15 @@ actual class DatabaseDriverFactory { } val dbFile = File(dbDir, "autodev.db") + + // Explicitly load SQLite JDBC driver for IntelliJ plugin environment + // This is needed due to classloader isolation in plugin context + try { + Class.forName("org.sqlite.JDBC") + } catch (e: ClassNotFoundException) { + println("SQLite JDBC driver not found: ${e.message}") + } + val driver = JdbcSqliteDriver( url = "jdbc:sqlite:${dbFile.absolutePath}", diff --git a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/agent/LiveTerminalItem.jvm.kt b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/agent/LiveTerminalItem.jvm.kt index 285f14e82b..9b029619be 100644 --- a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/agent/LiveTerminalItem.jvm.kt +++ b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/agent/LiveTerminalItem.jvm.kt @@ -30,13 +30,17 @@ import cc.unitmesh.devins.ui.compose.theme.AutoDevColors * - Compact header (32-36dp) to save space in timeline * - Inline status indicator * - Clean, minimal design using AutoDevColors + * - Shows completion status when exitCode is provided */ @Composable actual fun LiveTerminalItem( sessionId: String, command: String, workingDirectory: String?, - ptyHandle: Any? + ptyHandle: Any?, + exitCode: Int?, + executionTimeMs: Long?, + output: String? ) { var expanded by remember { mutableStateOf(true) } // Auto-expand live terminal val process = @@ -54,7 +58,8 @@ actual fun LiveTerminalItem( process?.let { ProcessTtyConnector(it) } } - val isRunning = process?.isAlive == true + // Determine if running: if exitCode is provided, it's completed + val isRunning = exitCode == null && (process?.isAlive == true) Card( colors = @@ -119,30 +124,42 @@ actual fun LiveTerminalItem( modifier = Modifier.weight(1f) ) -// Status badge - compact + // Status badge - compact, shows exit code when completed + val (statusText, statusColor) = when { + isRunning -> "RUNNING" to AutoDevColors.Green.c400 + exitCode == 0 -> "✓ EXIT 0" to AutoDevColors.Green.c400 + exitCode != null -> "✗ EXIT $exitCode" to AutoDevColors.Red.c400 + else -> "DONE" to MaterialTheme.colorScheme.onSurfaceVariant + } + Surface( - color = - if (isRunning) { - AutoDevColors.Green.c400.copy(alpha = 0.15f) - } else { - MaterialTheme.colorScheme.surfaceVariant - }, + color = statusColor.copy(alpha = 0.15f), shape = RoundedCornerShape(10.dp), modifier = Modifier.height(20.dp) ) { - Text( - text = if (isRunning) "RUNNING" else "DONE", + Row( modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - color = - if (isRunning) { - AutoDevColors.Green.c400 - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - style = MaterialTheme.typography.labelSmall, - fontSize = 10.sp, - fontWeight = FontWeight.Bold - ) + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = statusText, + color = statusColor, + style = MaterialTheme.typography.labelSmall, + fontSize = 10.sp, + fontWeight = FontWeight.Bold + ) + + // Show execution time when completed + if (executionTimeMs != null) { + Text( + text = "${executionTimeMs}ms", + color = statusColor.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + fontSize = 9.sp + ) + } + } } } 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" diff --git a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/DocumentCli.kt b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/DocumentCli.kt index 9ab393b45d..592a456458 100644 --- a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/DocumentCli.kt +++ b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/DocumentCli.kt @@ -3,6 +3,7 @@ package cc.unitmesh.server.cli import cc.unitmesh.agent.config.McpToolConfigService import cc.unitmesh.agent.config.ToolConfigFile import cc.unitmesh.agent.document.DocumentAgent +import cc.unitmesh.agent.document.DocumentAgentMode import cc.unitmesh.agent.document.DocumentTask import cc.unitmesh.agent.render.CodingAgentRenderer import cc.unitmesh.devins.db.DocumentIndexDatabaseRepository @@ -20,36 +21,57 @@ import java.io.File import java.security.MessageDigest /** - * JVM CLI for testing DocumentAgent with PPTX, DOCX, PDF files - * + * JVM CLI for testing DocumentAgent with PPTX, DOCX, PDF files and Product Feature Tree generation + * * Usage: * ```bash - * ./gradlew :mpp-server:runDocumentCli -PprojectPath=/path/to/docs -Pquery="What is this about?" [-PdocumentPath=specific.pptx] + * # Document query mode (default) + * ./gradlew :mpp-ui:runDocumentCli -PdocProjectPath=/path/to/docs -PdocQuery="What is this about?" [-PdocPath=specific.pptx] + * + * # Feature tree mode - generate product feature tree from source code + * ./gradlew :mpp-ui:runDocumentCli -PdocProjectPath=/path/to/project -PdocMode=feature-tree [-PdocLanguage=ZH] * ``` */ object DocumentCli { - + @JvmStatic fun main(args: Array) { println("=".repeat(80)) println("AutoDev Document CLI (JVM - Tika Support)") println("=".repeat(80)) - + // Parse arguments val projectPath = System.getProperty("projectPath") ?: args.getOrNull(0) ?: run { - System.err.println("Usage: -PprojectPath= -Pquery= [-PdocumentPath=]") + System.err.println("Usage: -PdocProjectPath= -PdocQuery= [-PdocPath=] [-PdocMode=feature-tree] [-PdocLanguage=EN|ZH]") return } - val query = System.getProperty("query") ?: args.getOrNull(1) ?: run { - System.err.println("Usage: -PprojectPath= -Pquery= [-PdocumentPath=]") - return + + // Check mode - support both positional args and system properties + val mode = System.getProperty("mode") ?: args.lastOrNull()?.takeIf { + it.equals("feature-tree", ignoreCase = true) || it.equals("featuretree", ignoreCase = true) || it == "query" + } ?: "query" + val language = System.getProperty("language") ?: "EN" + val isFeatureTreeMode = mode.equals("feature-tree", ignoreCase = true) || mode.equals("featuretree", ignoreCase = true) + + val query = if (isFeatureTreeMode) { + "Generate product feature tree for this codebase" + } else { + System.getProperty("query") ?: args.getOrNull(1) ?: run { + System.err.println("Usage: -PdocProjectPath= -PdocQuery= [-PdocPath=] [-PdocMode=feature-tree] [-PdocLanguage=EN|ZH]") + return + } } val documentPath = System.getProperty("documentPath") ?: args.getOrNull(2) - + println("📂 Project Path: $projectPath") - println("❓ Query: $query") - if (documentPath != null) { - println("📄 Document: $documentPath") + if (isFeatureTreeMode) { + println("🌳 Mode: Feature Tree Generation") + println("🌐 Language: $language") + } else { + println("❓ Query: $query") + if (documentPath != null) { + println("📄 Document: $documentPath") + } } println() @@ -240,14 +262,21 @@ object DocumentCli { println() // Execute query - println("🔍 Executing query...") + if (isFeatureTreeMode) { + println("🌳 Generating product feature tree...") + } else { + println("🔍 Executing query...") + } println() - + val queryStartTime = System.currentTimeMillis() + val agentMode = if (isFeatureTreeMode) DocumentAgentMode.FEATURE_TREE else DocumentAgentMode.DOCUMENT_QUERY val result = agent.execute( DocumentTask( query = query, - documentPath = documentPath + documentPath = documentPath, + mode = agentMode, + language = language ), onProgress = { } ) diff --git a/mpp-ui/src/wasmJsMain/kotlin/cc/unitmesh/devins/ui/compose/agent/LiveTerminalItem.wasmJs.kt b/mpp-ui/src/wasmJsMain/kotlin/cc/unitmesh/devins/ui/compose/agent/LiveTerminalItem.wasmJs.kt index 5d0d5d72eb..4a3a4a2050 100644 --- a/mpp-ui/src/wasmJsMain/kotlin/cc/unitmesh/devins/ui/compose/agent/LiveTerminalItem.wasmJs.kt +++ b/mpp-ui/src/wasmJsMain/kotlin/cc/unitmesh/devins/ui/compose/agent/LiveTerminalItem.wasmJs.kt @@ -19,7 +19,10 @@ actual fun LiveTerminalItem( sessionId: String, command: String, workingDirectory: String?, - ptyHandle: Any? + ptyHandle: Any?, + exitCode: Int?, + executionTimeMs: Long?, + output: String? ) { Card( colors =